Sync property list with map viewport

Major changes:
- Property list now updates when map pans/zooms
- Properties sorted by distance from map center (closest first)
- Shows "X properties in view" when viewport filtering active
- Min 30 properties required before grouping kicks in
- Added rule to CLAUDE.md: no commits unless asked

Backend:
- MLS_Query: Added bounds filtering and distance-based sorting
- AJAX handler: Accepts bounds/center, sorts by distance when provided

Frontend:
- Map move triggers property list refresh with same viewport
- Loop prevention flag to avoid map->filter->map recursion
- Resets to page 1 when viewport changes

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-16 01:27:44 -06:00
parent 9337a3cbc7
commit acd606bb03
6 changed files with 151 additions and 37 deletions
@@ -39,6 +39,12 @@ class MLS_Cluster {
*/
const MAX_INDIVIDUAL_MARKERS = 500;
/**
* Minimum properties before any grouping kicks in
* Below this, always show individual markers
*/
const MIN_FOR_GROUPING = 30;
/**
* Zoom thresholds for visualization modes
*/
@@ -270,6 +276,11 @@ class MLS_Cluster {
$total = (int) $wpdb->get_var($count_sql);
}
// If very few properties, always show individual markers (no grouping)
if ($total < self::MIN_FOR_GROUPING) {
return $this->get_individual_markers($where_sql, $values, $total);
}
// Calculate center latitude for Mercator adjustment
$center_lat = 45.0; // Default Minnesota
if ($args['bounds'] && count($args['bounds']) === 4) {
@@ -50,6 +50,8 @@ class MLS_Query {
'listing_key' => null,
'listing_id' => null,
'search' => null, // Search in address/remarks
'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
'center' => null, // Map center for distance sort: array(lat, lng)
'limit' => 20,
'offset' => 0,
'orderby' => 'modification_timestamp',
@@ -166,24 +168,51 @@ class MLS_Query {
$values[] = $search_term;
}
// Map bounds filtering
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$where[] = 'latitude IS NOT NULL';
$where[] = 'longitude IS NOT NULL';
$values[] = (float) $sw_lat;
$values[] = (float) $ne_lat;
$values[] = (float) $sw_lng;
$values[] = (float) $ne_lng;
}
$sql .= ' WHERE ' . implode(' AND ', $where);
// ORDER BY
$allowed_orderby = array(
'modification_timestamp',
'list_price',
'bedrooms_total',
'bathrooms_total',
'living_area',
'year_built',
'days_on_market',
'city',
'created_at',
);
// If center provided, sort by distance from center
if ($args['center'] && is_array($args['center']) && count($args['center']) === 2) {
list($center_lat, $center_lng) = $args['center'];
// Haversine formula approximation for distance (good enough for sorting)
// Using squared Euclidean distance with latitude adjustment for speed
$lat_factor = cos(deg2rad((float) $center_lat));
$sql .= $wpdb->prepare(
" ORDER BY (POW(latitude - %f, 2) + POW((longitude - %f) * %f, 2)) ASC",
(float) $center_lat,
(float) $center_lng,
$lat_factor
);
} else {
$allowed_orderby = array(
'modification_timestamp',
'list_price',
'bedrooms_total',
'bathrooms_total',
'living_area',
'year_built',
'days_on_market',
'city',
'created_at',
);
$orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
$order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
$sql .= " ORDER BY {$orderby} {$order}";
$orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
$order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
$sql .= " ORDER BY {$orderby} {$order}";
}
// LIMIT/OFFSET
$sql .= ' LIMIT %d OFFSET %d';
@@ -378,6 +407,19 @@ class MLS_Query {
$values[] = (int) $args['min_baths'];
}
// Map bounds filtering
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$where[] = 'latitude IS NOT NULL';
$where[] = 'longitude IS NOT NULL';
$values[] = (float) $sw_lat;
$values[] = (float) $ne_lat;
$values[] = (float) $sw_lng;
$values[] = (float) $ne_lng;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) {
File diff suppressed because one or more lines are too long
@@ -34,6 +34,19 @@ function homeproz_ajax_filter_properties() {
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
// Map bounds and center (for map-synced list view)
$bounds = null;
$center = null;
$has_map_bounds = false;
if (isset($_POST['bounds']) && is_array($_POST['bounds']) && count($_POST['bounds']) === 4) {
$bounds = array_map('floatval', $_POST['bounds']);
$has_map_bounds = true;
}
if (isset($_POST['center']) && is_array($_POST['center']) && count($_POST['center']) === 2) {
$center = array_map('floatval', $_POST['center']);
}
// Build filter args for count and properties
$per_page = 12;
$filter_args = array(
@@ -55,6 +68,9 @@ function homeproz_ajax_filter_properties() {
if ($beds) {
$filter_args['min_beds'] = $beds;
}
if ($bounds) {
$filter_args['bounds'] = $bounds;
}
// Get total count efficiently from database
$total = mls_get_property_count($filter_args);
@@ -64,10 +80,16 @@ function homeproz_ajax_filter_properties() {
$mls_args = array_merge($filter_args, array(
'limit' => $per_page,
'offset' => ($paged - 1) * $per_page,
'orderby' => 'modification_timestamp',
'order' => 'DESC',
));
// If we have map center, sort by distance; otherwise by date
if ($center) {
$mls_args['center'] = $center;
} else {
$mls_args['orderby'] = 'modification_timestamp';
$mls_args['order'] = 'DESC';
}
// Fetch only the properties we need for this page
$paged_properties = mls_get_properties($mls_args);
@@ -80,8 +102,11 @@ function homeproz_ajax_filter_properties() {
<?php if ($total > 0) : ?>
Showing <strong><?php echo esc_html($total); ?></strong>
<?php echo $total === 1 ? 'property' : 'properties'; ?>
<?php if ($has_map_bounds) : ?>
in view
<?php endif; ?>
<?php else : ?>
No properties found
No properties in view
<?php endif; ?>
</p>
</div>
@@ -113,19 +113,24 @@
var self = this;
var bounds = this.map.getBounds();
var center = this.map.getCenter();
var zoom = this.map.getZoom();
this.isLoading = true;
// Bounds array for both map clusters and property list
var boundsArray = [
bounds.getSouthWest().lat,
bounds.getSouthWest().lng,
bounds.getNorthEast().lat,
bounds.getNorthEast().lng
];
var centerArray = [center.lat, center.lng];
var requestData = {
action: 'mls_get_clusters',
zoom: zoom,
bounds: [
bounds.getSouthWest().lat,
bounds.getSouthWest().lng,
bounds.getNorthEast().lat,
bounds.getNorthEast().lng
],
bounds: boundsArray,
status: this.currentFilters.status || 'Active',
property_type: this.currentFilters.property_type || '',
city: this.currentFilters.city || '',
@@ -134,6 +139,9 @@
min_beds: this.currentFilters.min_beds || ''
};
// Also update the property list with the same viewport
PropertyFilters.updateFromMap(boundsArray, centerArray);
$.ajax({
url: homeprozMapData.clusterEndpoint,
type: 'GET',
@@ -518,6 +526,9 @@
// State
isFirstLoad: true,
isLoading: false,
mapBounds: null, // Current map viewport bounds
mapCenter: null, // Current map center for distance sorting
isMapUpdate: false, // Flag to prevent map->filter->map loop
/**
* Initialize
@@ -597,6 +608,18 @@
}
},
/**
* Update property list based on map viewport
* Called by PropertyMap when map moves/zooms
*/
updateFromMap: function(bounds, center) {
this.mapBounds = bounds;
this.mapCenter = center;
this.isMapUpdate = true;
// Reset to page 1 when map viewport changes
this.filterProperties(1, false);
},
/**
* Get page number from URL hash
*/
@@ -629,35 +652,47 @@
this.$results.html('<div class="property-results-loading"><div class="spinner"></div></div>');
}
// Build request data
var requestData = {
action: 'homeproz_filter_properties',
nonce: homeprozAjax.nonce,
property_type: formData.property_type,
property_location: formData.property_location,
min_price: formData.min_price,
max_price: formData.max_price,
beds: formData.beds,
paged: page
};
// Add map bounds and center if available
if (this.mapBounds) {
requestData.bounds = this.mapBounds;
}
if (this.mapCenter) {
requestData.center = this.mapCenter;
}
$.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: {
action: 'homeproz_filter_properties',
nonce: homeprozAjax.nonce,
property_type: formData.property_type,
property_location: formData.property_location,
min_price: formData.min_price,
max_price: formData.max_price,
beds: formData.beds,
paged: page
},
data: requestData,
success: function(response) {
if (response.success) {
self.$results.html(response.data.html);
self.isFirstLoad = false;
// Update map with new filter params
if (response.data.filters) {
// Update map with new filter params (but not if this was triggered by map move)
if (response.data.filters && !self.isMapUpdate) {
PropertyMap.updateFilters(response.data.filters);
}
self.isMapUpdate = false;
// Recalculate layout after content update
if (typeof LayoutCalculator !== 'undefined') {
LayoutCalculator.calculate();
}
// Update URL
// Update URL (skip when map-triggered)
if (updateHistory) {
self.updateUrl(formData, page);
}