Add server-side clustering for map with 30k+ properties
- Remove 1000 property limit from count display - Add MLS_Cluster class for geohash-based server-side clustering - Add AJAX endpoint for dynamic cluster loading based on viewport/zoom - Update property-results.php and ajax-handlers.php to use efficient counting - Update map JavaScript to fetch clusters dynamically as user pans/zooms - Server returns clusters at low zoom, individual markers at high zoom - Fixes property count showing 1000 instead of actual ~30k properties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -106,52 +106,20 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
|
||||
</main>
|
||||
|
||||
<?php
|
||||
// Load MLS properties for map markers
|
||||
$markers = array();
|
||||
// Get initial filter values from URL for map clustering
|
||||
$initial_filters = array(
|
||||
'status' => isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : 'Active',
|
||||
'property_type' => isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '',
|
||||
'city' => isset($_GET['property_location']) ? sanitize_text_field($_GET['property_location']) : '',
|
||||
'min_price' => isset($_GET['min_price']) ? intval($_GET['min_price']) : '',
|
||||
'max_price' => isset($_GET['max_price']) ? intval($_GET['max_price']) : '',
|
||||
'min_beds' => isset($_GET['beds']) ? intval($_GET['beds']) : '',
|
||||
);
|
||||
|
||||
if (function_exists('mls_get_properties')) {
|
||||
$mls_properties = mls_get_properties(array(
|
||||
'status' => 'Active',
|
||||
'limit' => 1000, // Reasonable limit for map performance
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
));
|
||||
|
||||
foreach ($mls_properties as $property) {
|
||||
// Skip properties without coordinates
|
||||
if (empty($property->latitude) || empty($property->longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format address
|
||||
$address_parts = array();
|
||||
if ($property->street_number) {
|
||||
$address_parts[] = $property->street_number;
|
||||
}
|
||||
if ($property->street_name) {
|
||||
$address_parts[] = $property->street_name;
|
||||
}
|
||||
if ($property->street_suffix) {
|
||||
$address_parts[] = $property->street_suffix;
|
||||
}
|
||||
$street = implode(' ', $address_parts);
|
||||
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
||||
|
||||
$markers[] = array(
|
||||
'id' => $property->listing_key,
|
||||
'lat' => (float) $property->latitude,
|
||||
'lng' => (float) $property->longitude,
|
||||
'title' => $full_address,
|
||||
'price' => '$' . number_format($property->list_price),
|
||||
'address' => $full_address,
|
||||
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
|
||||
'beds' => $property->bedrooms_total,
|
||||
'baths' => $property->bathrooms_total,
|
||||
'sqft' => $property->living_area,
|
||||
'status' => $property->standard_status,
|
||||
'photo' => null, // Placeholder - photos will be added later
|
||||
);
|
||||
}
|
||||
// Get total property count with coordinates for display
|
||||
$total_with_coords = 0;
|
||||
if (function_exists('mls_get_property_count')) {
|
||||
$total_with_coords = mls_get_property_count(array('status' => $initial_filters['status'] ?: 'Active'));
|
||||
}
|
||||
?>
|
||||
<!-- Leaflet CSS -->
|
||||
@@ -165,8 +133,10 @@ if (function_exists('mls_get_properties')) {
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
|
||||
<script>
|
||||
var homeprozMapData = {
|
||||
properties: <?php echo json_encode($markers); ?>,
|
||||
isMapView: <?php echo $show_map ? 'true' : 'false'; ?>
|
||||
isMapView: <?php echo $show_map ? 'true' : 'false'; ?>,
|
||||
clusterEndpoint: '<?php echo admin_url('admin-ajax.php'); ?>',
|
||||
initialFilters: <?php echo json_encode($initial_filters); ?>,
|
||||
totalProperties: <?php echo (int) $total_with_coords; ?>
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -34,40 +34,42 @@ function homeproz_ajax_filter_properties() {
|
||||
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
||||
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
||||
|
||||
// Build MLS query args
|
||||
// Build filter args for count and properties
|
||||
$per_page = 12;
|
||||
$mls_args = array(
|
||||
$filter_args = array(
|
||||
'status' => $property_status ?: 'Active',
|
||||
'limit' => 1000, // Get all for counting, then paginate
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
// Map filter values to MLS query args
|
||||
if ($property_type) {
|
||||
$mls_args['property_type'] = $property_type;
|
||||
$filter_args['property_type'] = $property_type;
|
||||
}
|
||||
if ($property_location) {
|
||||
$mls_args['city'] = $property_location;
|
||||
$filter_args['city'] = $property_location;
|
||||
}
|
||||
if ($min_price) {
|
||||
$mls_args['min_price'] = $min_price;
|
||||
$filter_args['min_price'] = $min_price;
|
||||
}
|
||||
if ($max_price) {
|
||||
$mls_args['max_price'] = $max_price;
|
||||
$filter_args['max_price'] = $max_price;
|
||||
}
|
||||
if ($beds) {
|
||||
$mls_args['min_beds'] = $beds;
|
||||
$filter_args['min_beds'] = $beds;
|
||||
}
|
||||
|
||||
// Fetch all matching properties
|
||||
$all_properties = mls_get_properties($mls_args);
|
||||
|
||||
// Handle pagination manually
|
||||
$total = count($all_properties);
|
||||
// Get total count efficiently from database
|
||||
$total = mls_get_property_count($filter_args);
|
||||
$max_pages = ceil($total / $per_page);
|
||||
$offset = ($paged - 1) * $per_page;
|
||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
||||
|
||||
// Build query args for paginated results
|
||||
$mls_args = array_merge($filter_args, array(
|
||||
'limit' => $per_page,
|
||||
'offset' => ($paged - 1) * $per_page,
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
));
|
||||
|
||||
// Fetch only the properties we need for this page
|
||||
$paged_properties = mls_get_properties($mls_args);
|
||||
|
||||
ob_start();
|
||||
|
||||
@@ -140,50 +142,13 @@ function homeproz_ajax_filter_properties() {
|
||||
<?php
|
||||
$html = ob_get_clean();
|
||||
|
||||
// Build markers data for map view from MLS properties
|
||||
$markers = array();
|
||||
|
||||
foreach ($all_properties as $property) {
|
||||
// Skip properties without coordinates
|
||||
if (empty($property->latitude) || empty($property->longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format address
|
||||
$address_parts = array();
|
||||
if ($property->street_number) {
|
||||
$address_parts[] = $property->street_number;
|
||||
}
|
||||
if ($property->street_name) {
|
||||
$address_parts[] = $property->street_name;
|
||||
}
|
||||
if ($property->street_suffix) {
|
||||
$address_parts[] = $property->street_suffix;
|
||||
}
|
||||
$street = implode(' ', $address_parts);
|
||||
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
||||
|
||||
$markers[] = array(
|
||||
'id' => $property->listing_key,
|
||||
'lat' => (float) $property->latitude,
|
||||
'lng' => (float) $property->longitude,
|
||||
'title' => $full_address,
|
||||
'price' => '$' . number_format($property->list_price),
|
||||
'address' => $full_address,
|
||||
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
|
||||
'beds' => $property->bedrooms_total,
|
||||
'baths' => $property->bathrooms_total,
|
||||
'sqft' => $property->living_area,
|
||||
'status' => $property->standard_status,
|
||||
'photo' => null, // Placeholder - photos will be added later
|
||||
);
|
||||
}
|
||||
|
||||
// Return filter params for map clustering endpoint
|
||||
// The frontend will call the clustering endpoint separately with these
|
||||
wp_send_json_success(array(
|
||||
'html' => $html,
|
||||
'found_posts' => $total,
|
||||
'max_pages' => $max_pages,
|
||||
'markers' => $markers,
|
||||
'filters' => $filter_args,
|
||||
));
|
||||
}
|
||||
add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
|
||||
|
||||
@@ -11,24 +11,32 @@
|
||||
|
||||
/**
|
||||
* Property Map Manager
|
||||
* Uses server-side clustering for efficient rendering of 30k+ properties
|
||||
*/
|
||||
var PropertyMap = {
|
||||
map: null,
|
||||
markers: {}, // Object keyed by property ID
|
||||
markerCluster: null, // MarkerClusterGroup
|
||||
clusterLayer: null, // Layer group for server clusters
|
||||
markerCluster: null, // MarkerClusterGroup for individual markers
|
||||
selectedPropertyId: null,
|
||||
hoveredPropertyId: null,
|
||||
baseZIndex: 400,
|
||||
currentFilters: {},
|
||||
isLoading: false,
|
||||
loadTimeout: null,
|
||||
|
||||
/**
|
||||
* Initialize the map
|
||||
*/
|
||||
init: function(initialProperties) {
|
||||
init: function(filters) {
|
||||
var $mapContainer = $('#property-map');
|
||||
if (!$mapContainer.length || typeof L === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store initial filters
|
||||
this.currentFilters = filters || {};
|
||||
|
||||
// Initialize map centered on Minnesota
|
||||
this.map = L.map('property-map').setView([45.0, -93.5], 7);
|
||||
|
||||
@@ -37,13 +45,16 @@
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create marker cluster group
|
||||
// Create layer for server-side clusters
|
||||
this.clusterLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Create marker cluster group for individual markers (when zoomed in)
|
||||
this.markerCluster = L.markerClusterGroup({
|
||||
maxClusterRadius: 50,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: true,
|
||||
disableClusteringAtZoom: 15,
|
||||
disableClusteringAtZoom: 18,
|
||||
chunkedLoading: true,
|
||||
chunkInterval: 200,
|
||||
chunkDelay: 50,
|
||||
@@ -64,39 +75,130 @@
|
||||
});
|
||||
this.map.addLayer(this.markerCluster);
|
||||
|
||||
// Add initial markers
|
||||
if (initialProperties && initialProperties.length > 0) {
|
||||
this.updateMarkers(initialProperties);
|
||||
}
|
||||
// Bind map events for dynamic loading
|
||||
var self = this;
|
||||
this.map.on('moveend zoomend', function() {
|
||||
self.loadClusters();
|
||||
});
|
||||
|
||||
// Bind card hover events
|
||||
this.bindCardHoverEvents();
|
||||
|
||||
// Load initial clusters
|
||||
this.loadClusters();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create marker icon with specified color
|
||||
* Pin size: 16.5x22 (aspect ratio 0.75)
|
||||
* Load clusters/markers from server based on viewport
|
||||
*/
|
||||
createIcon: function(color) {
|
||||
color = color || 'red';
|
||||
return L.divIcon({
|
||||
className: 'property-marker property-marker-' + color,
|
||||
html: '<div class="marker-pin"></div>',
|
||||
iconSize: [17, 22],
|
||||
iconAnchor: [8, 22],
|
||||
popupAnchor: [0, -22]
|
||||
loadClusters: function() {
|
||||
if (!this.map) return;
|
||||
|
||||
var self = this;
|
||||
|
||||
// Debounce rapid requests
|
||||
clearTimeout(this.loadTimeout);
|
||||
this.loadTimeout = setTimeout(function() {
|
||||
self._doLoadClusters();
|
||||
}, 150);
|
||||
},
|
||||
|
||||
_doLoadClusters: function() {
|
||||
if (this.isLoading) return;
|
||||
|
||||
var self = this;
|
||||
var bounds = this.map.getBounds();
|
||||
var zoom = this.map.getZoom();
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
var requestData = {
|
||||
action: 'mls_get_clusters',
|
||||
zoom: zoom,
|
||||
bounds: [
|
||||
bounds.getSouthWest().lat,
|
||||
bounds.getSouthWest().lng,
|
||||
bounds.getNorthEast().lat,
|
||||
bounds.getNorthEast().lng
|
||||
],
|
||||
status: this.currentFilters.status || 'Active',
|
||||
property_type: this.currentFilters.property_type || '',
|
||||
city: this.currentFilters.city || '',
|
||||
min_price: this.currentFilters.min_price || '',
|
||||
max_price: this.currentFilters.max_price || '',
|
||||
min_beds: this.currentFilters.min_beds || ''
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: homeprozMapData.clusterEndpoint,
|
||||
type: 'GET',
|
||||
data: requestData,
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
if (response.data.type === 'clusters') {
|
||||
self.renderClusters(response.data.clusters);
|
||||
} else {
|
||||
self.renderMarkers(response.data.markers);
|
||||
}
|
||||
}
|
||||
},
|
||||
complete: function() {
|
||||
self.isLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update map markers with new property data
|
||||
* Render server-side clusters
|
||||
*/
|
||||
updateMarkers: function(properties) {
|
||||
if (!this.map || !this.markerCluster) {
|
||||
return;
|
||||
}
|
||||
renderClusters: function(clusters) {
|
||||
// Clear both layers
|
||||
this.clusterLayer.clearLayers();
|
||||
this.markerCluster.clearLayers();
|
||||
this.markers = {};
|
||||
|
||||
// Clear existing markers and reset state
|
||||
var self = this;
|
||||
|
||||
clusters.forEach(function(cluster) {
|
||||
var size = 'small';
|
||||
if (cluster.count >= 100) {
|
||||
size = 'large';
|
||||
} else if (cluster.count >= 10) {
|
||||
size = 'medium';
|
||||
}
|
||||
|
||||
var icon = L.divIcon({
|
||||
html: '<div><span>' + cluster.count + '</span></div>',
|
||||
className: 'marker-cluster marker-cluster-' + size + ' server-cluster',
|
||||
iconSize: L.point(40, 40)
|
||||
});
|
||||
|
||||
var marker = L.marker([cluster.lat, cluster.lng], { icon: icon });
|
||||
|
||||
// Click to zoom in
|
||||
marker.on('click', function() {
|
||||
self.map.setView([cluster.lat, cluster.lng], self.map.getZoom() + 2);
|
||||
});
|
||||
|
||||
// Tooltip with price range
|
||||
var priceRange = '$' + self.formatNumber(cluster.min_price);
|
||||
if (cluster.max_price !== cluster.min_price) {
|
||||
priceRange += ' - $' + self.formatNumber(cluster.max_price);
|
||||
}
|
||||
marker.bindTooltip(cluster.count + ' properties<br>' + priceRange, {
|
||||
className: 'cluster-tooltip'
|
||||
});
|
||||
|
||||
self.clusterLayer.addLayer(marker);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render individual markers (when zoomed in or low count)
|
||||
*/
|
||||
renderMarkers: function(properties) {
|
||||
// Clear both layers
|
||||
this.clusterLayer.clearLayers();
|
||||
this.markerCluster.clearLayers();
|
||||
this.markers = {};
|
||||
this.selectedPropertyId = null;
|
||||
@@ -140,9 +242,36 @@
|
||||
|
||||
// Bulk add markers for performance
|
||||
this.markerCluster.addLayers(markersToAdd);
|
||||
},
|
||||
|
||||
// Fit bounds to show all markers
|
||||
this.fitBounds(properties);
|
||||
/**
|
||||
* Update filters and reload
|
||||
*/
|
||||
updateFilters: function(filters) {
|
||||
this.currentFilters = filters || {};
|
||||
this.loadClusters();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
formatNumber: function(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create marker icon with specified color
|
||||
* Pin size: 16.5x22 (aspect ratio 0.75)
|
||||
*/
|
||||
createIcon: function(color) {
|
||||
color = color || 'red';
|
||||
return L.divIcon({
|
||||
className: 'property-marker property-marker-' + color,
|
||||
html: '<div class="marker-pin"></div>',
|
||||
iconSize: [17, 22],
|
||||
iconAnchor: [8, 22],
|
||||
popupAnchor: [0, -22]
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -280,23 +409,6 @@
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit map bounds to show all properties
|
||||
*/
|
||||
fitBounds: function(properties) {
|
||||
if (!this.map || !properties || properties.length === 0) {
|
||||
// Reset to default view if no properties
|
||||
if (this.map) {
|
||||
this.map.setView([43.6480, -93.3685], 10);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = L.latLngBounds(properties.map(function(p) {
|
||||
return [p.lat, p.lng];
|
||||
}));
|
||||
this.map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
};
|
||||
|
||||
var PropertyFilters = {
|
||||
@@ -430,9 +542,9 @@
|
||||
self.$results.html(response.data.html);
|
||||
self.isFirstLoad = false;
|
||||
|
||||
// Update map markers if map is active
|
||||
if (response.data.markers) {
|
||||
PropertyMap.updateMarkers(response.data.markers);
|
||||
// Update map with new filter params
|
||||
if (response.data.filters) {
|
||||
PropertyMap.updateFilters(response.data.filters);
|
||||
}
|
||||
|
||||
// Recalculate layout after content update
|
||||
@@ -553,8 +665,8 @@
|
||||
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
|
||||
|
||||
// Initialize map if above breakpoint, map view selected, and we have data
|
||||
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined' && homeprozMapData.properties) {
|
||||
PropertyMap.init(homeprozMapData.properties);
|
||||
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
|
||||
PropertyMap.init(homeprozMapData.initialFilters || {});
|
||||
this.mapInitialized = true;
|
||||
}
|
||||
|
||||
@@ -581,7 +693,7 @@
|
||||
|
||||
// Initialize map if not already done
|
||||
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
|
||||
PropertyMap.init(homeprozMapData.properties);
|
||||
PropertyMap.init(homeprozMapData.initialFilters || {});
|
||||
this.mapInitialized = true;
|
||||
} else if (PropertyMap.map) {
|
||||
// Invalidate size to fix map rendering after show
|
||||
|
||||
@@ -474,3 +474,30 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side cluster marker styling
|
||||
.server-cluster {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover div {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster tooltip styling
|
||||
.cluster-tooltip {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&::before {
|
||||
border-top-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,39 +35,41 @@ $current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
|
||||
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
||||
$per_page = 12;
|
||||
|
||||
// Build MLS query args
|
||||
$mls_args = array(
|
||||
// Build filter args for count and properties
|
||||
$filter_args = array(
|
||||
'status' => $current_status ?: 'Active',
|
||||
'limit' => 1000, // Get all for counting, then paginate
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
// Map filter values to MLS query args
|
||||
if ($current_type) {
|
||||
$mls_args['property_type'] = $current_type;
|
||||
$filter_args['property_type'] = $current_type;
|
||||
}
|
||||
if ($current_location) {
|
||||
$mls_args['city'] = $current_location;
|
||||
$filter_args['city'] = $current_location;
|
||||
}
|
||||
if ($current_min_price) {
|
||||
$mls_args['min_price'] = $current_min_price;
|
||||
$filter_args['min_price'] = $current_min_price;
|
||||
}
|
||||
if ($current_max_price) {
|
||||
$mls_args['max_price'] = $current_max_price;
|
||||
$filter_args['max_price'] = $current_max_price;
|
||||
}
|
||||
if ($current_beds) {
|
||||
$mls_args['min_beds'] = $current_beds;
|
||||
$filter_args['min_beds'] = $current_beds;
|
||||
}
|
||||
|
||||
// Fetch all matching properties
|
||||
$all_properties = mls_get_properties($mls_args);
|
||||
|
||||
// Handle pagination manually
|
||||
$total = count($all_properties);
|
||||
// Get total count efficiently from database
|
||||
$total = mls_get_property_count($filter_args);
|
||||
$max_pages = ceil($total / $per_page);
|
||||
$offset = ($paged - 1) * $per_page;
|
||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
||||
|
||||
// Build query args for paginated results
|
||||
$mls_args = array_merge($filter_args, array(
|
||||
'limit' => $per_page,
|
||||
'offset' => ($paged - 1) * $per_page,
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
));
|
||||
|
||||
// Fetch only the properties we need for this page
|
||||
$paged_properties = mls_get_properties($mls_args);
|
||||
?>
|
||||
|
||||
<!-- Results Meta -->
|
||||
|
||||
Reference in New Issue
Block a user