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:
Hanson.xyz Dev
2025-12-16 00:04:22 -06:00
parent 30eb593020
commit 1862cef42a
10 changed files with 741 additions and 174 deletions
@@ -0,0 +1,422 @@
<?php
/**
* MLS Map Clustering
*
* Server-side geohash-based clustering for efficient map rendering.
* Handles ~30k properties by returning clustered markers based on zoom level.
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Cluster {
/**
* Geohash characters
*/
const GEOHASH_CHARS = '0123456789bcdefghjkmnpqrstuvwxyz';
/**
* Zoom level to geohash precision mapping
* Lower precision = larger geographic area = more clustering
*/
const ZOOM_PRECISION = array(
3 => 2, // ~630km cells - continent level
4 => 2,
5 => 2,
6 => 3, // ~78km cells - state level
7 => 3,
8 => 3,
9 => 4, // ~20km cells - county level
10 => 4,
11 => 4,
12 => 5, // ~2.4km cells - city level
13 => 5,
14 => 5,
15 => 6, // ~610m cells - neighborhood level
16 => 6,
17 => 6,
18 => 7, // ~76m cells - street level
19 => 7,
20 => 8, // Individual points
);
/**
* Maximum properties to return as individual markers
* Above this threshold, return clusters
*/
const MAX_INDIVIDUAL_MARKERS = 500;
/**
* Database instance
*/
private $db;
/**
* Constructor
*/
public function __construct(MLS_DB $db) {
$this->db = $db;
}
/**
* Encode latitude/longitude to geohash
*
* @param float $lat Latitude
* @param float $lng Longitude
* @param int $precision Geohash precision (1-12)
* @return string Geohash string
*/
public function encode($lat, $lng, $precision = 6) {
$lat_range = array(-90.0, 90.0);
$lng_range = array(-180.0, 180.0);
$hash = '';
$bits = array(16, 8, 4, 2, 1);
$bit = 0;
$ch = 0;
$is_lng = true;
while (strlen($hash) < $precision) {
if ($is_lng) {
$mid = ($lng_range[0] + $lng_range[1]) / 2;
if ($lng >= $mid) {
$ch |= $bits[$bit];
$lng_range[0] = $mid;
} else {
$lng_range[1] = $mid;
}
} else {
$mid = ($lat_range[0] + $lat_range[1]) / 2;
if ($lat >= $mid) {
$ch |= $bits[$bit];
$lat_range[0] = $mid;
} else {
$lat_range[1] = $mid;
}
}
$is_lng = !$is_lng;
$bit++;
if ($bit === 5) {
$hash .= self::GEOHASH_CHARS[$ch];
$bit = 0;
$ch = 0;
}
}
return $hash;
}
/**
* Decode geohash to latitude/longitude bounds
*
* @param string $hash Geohash string
* @return array Array with lat_min, lat_max, lng_min, lng_max, lat_center, lng_center
*/
public function decode($hash) {
$lat_range = array(-90.0, 90.0);
$lng_range = array(-180.0, 180.0);
$is_lng = true;
for ($i = 0; $i < strlen($hash); $i++) {
$c = $hash[$i];
$cd = strpos(self::GEOHASH_CHARS, $c);
for ($j = 0; $j < 5; $j++) {
$mask = 1 << (4 - $j);
if ($is_lng) {
$mid = ($lng_range[0] + $lng_range[1]) / 2;
if ($cd & $mask) {
$lng_range[0] = $mid;
} else {
$lng_range[1] = $mid;
}
} else {
$mid = ($lat_range[0] + $lat_range[1]) / 2;
if ($cd & $mask) {
$lat_range[0] = $mid;
} else {
$lat_range[1] = $mid;
}
}
$is_lng = !$is_lng;
}
}
return array(
'lat_min' => $lat_range[0],
'lat_max' => $lat_range[1],
'lng_min' => $lng_range[0],
'lng_max' => $lng_range[1],
'lat_center' => ($lat_range[0] + $lat_range[1]) / 2,
'lng_center' => ($lng_range[0] + $lng_range[1]) / 2,
);
}
/**
* Get precision for zoom level
*
* @param int $zoom Map zoom level (1-20)
* @return int Geohash precision
*/
public function get_precision_for_zoom($zoom) {
$zoom = max(3, min(20, (int) $zoom));
if (isset(self::ZOOM_PRECISION[$zoom])) {
return self::ZOOM_PRECISION[$zoom];
}
// Default to precision 5 for unknown zoom levels
return 5;
}
/**
* Get clustered markers for map viewport
*
* @param array $args Query arguments
* - bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
* - zoom: int Map zoom level
* - status: string Property status filter
* - property_type: string Property type filter
* - city: string City filter
* - min_price, max_price: int Price range
* - min_beds: int Minimum bedrooms
* @return array Array with 'clusters' or 'markers' depending on density
*/
public function get_clusters($args = array()) {
global $wpdb;
$defaults = array(
'bounds' => null,
'zoom' => 10,
'status' => 'Active',
'property_type' => null,
'city' => null,
'min_price' => null,
'max_price' => null,
'min_beds' => null,
);
$args = wp_parse_args($args, $defaults);
$table = $this->db->properties_table();
// Build WHERE clause
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
$values = array();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if ($args['city']) {
$where[] = 'city = %s';
$values[] = $args['city'];
}
if ($args['min_price']) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if ($args['max_price']) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if ($args['min_beds']) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
// Add bounds filter if provided
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';
$values[] = (float) $sw_lat;
$values[] = (float) $ne_lat;
$values[] = (float) $sw_lng;
$values[] = (float) $ne_lng;
}
$where_sql = implode(' AND ', $where);
// First, get total count to decide clustering strategy
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
if (!empty($values)) {
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $values));
} else {
$total = (int) $wpdb->get_var($count_sql);
}
// If low count or high zoom, return individual markers
$precision = $this->get_precision_for_zoom($args['zoom']);
if ($total <= self::MAX_INDIVIDUAL_MARKERS || $args['zoom'] >= 18) {
return $this->get_individual_markers($where_sql, $values, $total);
}
// Return clusters
return $this->get_cluster_data($where_sql, $values, $precision, $total);
}
/**
* Get individual property markers
*
* @param string $where_sql WHERE clause
* @param array $values Prepared values
* @param int $total Total count
* @return array
*/
private function get_individual_markers($where_sql, $values, $total) {
global $wpdb;
$table = $this->db->properties_table();
$sql = "SELECT listing_key, latitude, longitude, list_price, street_number,
street_name, street_suffix, city, bedrooms_total,
bathrooms_total, living_area, standard_status
FROM {$table}
WHERE {$where_sql}
ORDER BY modification_timestamp DESC
LIMIT 5000";
if (!empty($values)) {
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
} else {
$results = $wpdb->get_results($sql);
}
$markers = array();
foreach ($results as $property) {
// 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,
'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,
);
}
return array(
'type' => 'markers',
'total' => $total,
'count' => count($markers),
'markers' => $markers,
);
}
/**
* Get clustered data using geohash grouping
*
* @param string $where_sql WHERE clause
* @param array $values Prepared values
* @param int $precision Geohash precision
* @param int $total Total count
* @return array
*/
private function get_cluster_data($where_sql, $values, $precision, $total) {
global $wpdb;
$table = $this->db->properties_table();
// Use MySQL SUBSTR to extract geohash prefix for grouping
// We compute geohash on-the-fly using latitude/longitude
// For better performance with 30k+ records, we use grid-based clustering
// Calculate grid cell size based on precision
// Approximate degrees per geohash precision level
$lat_step = 180 / pow(2, (int)($precision * 2.5));
$lng_step = 360 / pow(2, (int)($precision * 2.5));
// Use FLOOR to group coordinates into cells
$sql = "SELECT
FLOOR(latitude / %f) as lat_cell,
FLOOR(longitude / %f) as lng_cell,
COUNT(*) as count,
AVG(latitude) as avg_lat,
AVG(longitude) as avg_lng,
MIN(list_price) as min_price,
MAX(list_price) as max_price
FROM {$table}
WHERE {$where_sql}
GROUP BY lat_cell, lng_cell
HAVING count >= 1";
$grid_values = array_merge(array($lat_step, $lng_step), $values);
$results = $wpdb->get_results($wpdb->prepare($sql, $grid_values));
$clusters = array();
foreach ($results as $row) {
$clusters[] = array(
'lat' => (float) $row->avg_lat,
'lng' => (float) $row->avg_lng,
'count' => (int) $row->count,
'min_price' => (int) $row->min_price,
'max_price' => (int) $row->max_price,
);
}
return array(
'type' => 'clusters',
'total' => $total,
'cluster_count' => count($clusters),
'precision' => $precision,
'clusters' => $clusters,
);
}
/**
* Get total count with coordinates
*
* @param array $args Filter arguments
* @return int
*/
public function get_total_with_coords($args = array()) {
global $wpdb;
$table = $this->db->properties_table();
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
$values = array();
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
}
return (int) $wpdb->get_var($sql);
}
}
@@ -353,6 +353,31 @@ class MLS_Query {
$values[] = $args['city']; $values[] = $args['city'];
} }
if (!empty($args['county'])) {
$where[] = 'county = %s';
$values[] = $args['county'];
}
if (!empty($args['min_price'])) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if (!empty($args['max_price'])) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if (!empty($args['min_beds'])) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
if (!empty($args['min_baths'])) {
$where[] = 'bathrooms_total >= %d';
$values[] = (int) $args['min_baths'];
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where); $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) { if (!empty($values)) {
@@ -54,6 +54,7 @@ final class MLS_Plugin {
private $media_handler; private $media_handler;
private $image_endpoint; private $image_endpoint;
private $query; private $query;
private $cluster;
/** /**
* Get single instance * Get single instance
@@ -87,6 +88,7 @@ final class MLS_Plugin {
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php'; require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php'; require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php'; require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-cluster.php';
// Activation/Deactivation // Activation/Deactivation
require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php'; require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php';
@@ -137,6 +139,11 @@ final class MLS_Plugin {
$this->logger $this->logger
); );
$this->query = new MLS_Query($this->db); $this->query = new MLS_Query($this->db);
$this->cluster = new MLS_Cluster($this->db);
// Register AJAX handlers
add_action('wp_ajax_mls_get_clusters', array($this, 'ajax_get_clusters'));
add_action('wp_ajax_nopriv_mls_get_clusters', array($this, 'ajax_get_clusters'));
// Initialize admin // Initialize admin
if (is_admin()) { if (is_admin()) {
@@ -223,6 +230,43 @@ final class MLS_Plugin {
public function get_query() { public function get_query() {
return $this->query; return $this->query;
} }
/**
* Get Cluster instance
*/
public function get_cluster() {
return $this->cluster;
}
/**
* AJAX handler for getting map clusters
*/
public function ajax_get_clusters() {
// Parse input
$zoom = isset($_REQUEST['zoom']) ? (int) $_REQUEST['zoom'] : 10;
$bounds = isset($_REQUEST['bounds']) ? array_map('floatval', (array) $_REQUEST['bounds']) : null;
$status = isset($_REQUEST['status']) ? sanitize_text_field($_REQUEST['status']) : 'Active';
$property_type = isset($_REQUEST['property_type']) ? sanitize_text_field($_REQUEST['property_type']) : null;
$city = isset($_REQUEST['city']) ? sanitize_text_field($_REQUEST['city']) : null;
$min_price = isset($_REQUEST['min_price']) ? (int) $_REQUEST['min_price'] : null;
$max_price = isset($_REQUEST['max_price']) ? (int) $_REQUEST['max_price'] : null;
$min_beds = isset($_REQUEST['min_beds']) ? (int) $_REQUEST['min_beds'] : null;
$args = array(
'zoom' => $zoom,
'bounds' => $bounds,
'status' => $status,
'property_type' => $property_type ?: null,
'city' => $city ?: null,
'min_price' => $min_price ?: null,
'max_price' => $max_price ?: null,
'min_beds' => $min_beds ?: null,
);
$result = $this->cluster->get_clusters($args);
wp_send_json_success($result);
}
} }
/** /**
+17 -47
View File
@@ -106,52 +106,20 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
</main> </main>
<?php <?php
// Load MLS properties for map markers // Get initial filter values from URL for map clustering
$markers = array(); $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')) { // Get total property count with coordinates for display
$mls_properties = mls_get_properties(array( $total_with_coords = 0;
'status' => 'Active', if (function_exists('mls_get_property_count')) {
'limit' => 1000, // Reasonable limit for map performance $total_with_coords = mls_get_property_count(array('status' => $initial_filters['status'] ?: 'Active'));
'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
);
}
} }
?> ?>
<!-- Leaflet CSS --> <!-- 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 src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
<script> <script>
var homeprozMapData = { 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> </script>
File diff suppressed because one or more lines are too long
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'; $sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1; $paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
// Build MLS query args // Build filter args for count and properties
$per_page = 12; $per_page = 12;
$mls_args = array( $filter_args = array(
'status' => $property_status ?: 'Active', '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) { if ($property_type) {
$mls_args['property_type'] = $property_type; $filter_args['property_type'] = $property_type;
} }
if ($property_location) { if ($property_location) {
$mls_args['city'] = $property_location; $filter_args['city'] = $property_location;
} }
if ($min_price) { if ($min_price) {
$mls_args['min_price'] = $min_price; $filter_args['min_price'] = $min_price;
} }
if ($max_price) { if ($max_price) {
$mls_args['max_price'] = $max_price; $filter_args['max_price'] = $max_price;
} }
if ($beds) { if ($beds) {
$mls_args['min_beds'] = $beds; $filter_args['min_beds'] = $beds;
} }
// Fetch all matching properties // Get total count efficiently from database
$all_properties = mls_get_properties($mls_args); $total = mls_get_property_count($filter_args);
// Handle pagination manually
$total = count($all_properties);
$max_pages = ceil($total / $per_page); $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(); ob_start();
@@ -140,50 +142,13 @@ function homeproz_ajax_filter_properties() {
<?php <?php
$html = ob_get_clean(); $html = ob_get_clean();
// Build markers data for map view from MLS properties // Return filter params for map clustering endpoint
$markers = array(); // The frontend will call the clustering endpoint separately with these
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
);
}
wp_send_json_success(array( wp_send_json_success(array(
'html' => $html, 'html' => $html,
'found_posts' => $total, 'found_posts' => $total,
'max_pages' => $max_pages, 'max_pages' => $max_pages,
'markers' => $markers, 'filters' => $filter_args,
)); ));
} }
add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties'); add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
@@ -11,24 +11,32 @@
/** /**
* Property Map Manager * Property Map Manager
* Uses server-side clustering for efficient rendering of 30k+ properties
*/ */
var PropertyMap = { var PropertyMap = {
map: null, map: null,
markers: {}, // Object keyed by property ID markers: {}, // Object keyed by property ID
markerCluster: null, // MarkerClusterGroup clusterLayer: null, // Layer group for server clusters
markerCluster: null, // MarkerClusterGroup for individual markers
selectedPropertyId: null, selectedPropertyId: null,
hoveredPropertyId: null, hoveredPropertyId: null,
baseZIndex: 400, baseZIndex: 400,
currentFilters: {},
isLoading: false,
loadTimeout: null,
/** /**
* Initialize the map * Initialize the map
*/ */
init: function(initialProperties) { init: function(filters) {
var $mapContainer = $('#property-map'); var $mapContainer = $('#property-map');
if (!$mapContainer.length || typeof L === 'undefined') { if (!$mapContainer.length || typeof L === 'undefined') {
return; return;
} }
// Store initial filters
this.currentFilters = filters || {};
// Initialize map centered on Minnesota // Initialize map centered on Minnesota
this.map = L.map('property-map').setView([45.0, -93.5], 7); this.map = L.map('property-map').setView([45.0, -93.5], 7);
@@ -37,13 +45,16 @@
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(this.map); }).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({ this.markerCluster = L.markerClusterGroup({
maxClusterRadius: 50, maxClusterRadius: 50,
spiderfyOnMaxZoom: true, spiderfyOnMaxZoom: true,
showCoverageOnHover: false, showCoverageOnHover: false,
zoomToBoundsOnClick: true, zoomToBoundsOnClick: true,
disableClusteringAtZoom: 15, disableClusteringAtZoom: 18,
chunkedLoading: true, chunkedLoading: true,
chunkInterval: 200, chunkInterval: 200,
chunkDelay: 50, chunkDelay: 50,
@@ -64,39 +75,130 @@
}); });
this.map.addLayer(this.markerCluster); this.map.addLayer(this.markerCluster);
// Add initial markers // Bind map events for dynamic loading
if (initialProperties && initialProperties.length > 0) { var self = this;
this.updateMarkers(initialProperties); this.map.on('moveend zoomend', function() {
} self.loadClusters();
});
// Bind card hover events // Bind card hover events
this.bindCardHoverEvents(); this.bindCardHoverEvents();
// Load initial clusters
this.loadClusters();
}, },
/** /**
* Create marker icon with specified color * Load clusters/markers from server based on viewport
* Pin size: 16.5x22 (aspect ratio 0.75)
*/ */
createIcon: function(color) { loadClusters: function() {
color = color || 'red'; if (!this.map) return;
return L.divIcon({
className: 'property-marker property-marker-' + color, var self = this;
html: '<div class="marker-pin"></div>',
iconSize: [17, 22], // Debounce rapid requests
iconAnchor: [8, 22], clearTimeout(this.loadTimeout);
popupAnchor: [0, -22] 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) { renderClusters: function(clusters) {
if (!this.map || !this.markerCluster) { // Clear both layers
return; this.clusterLayer.clearLayers();
this.markerCluster.clearLayers();
this.markers = {};
var self = this;
clusters.forEach(function(cluster) {
var size = 'small';
if (cluster.count >= 100) {
size = 'large';
} else if (cluster.count >= 10) {
size = 'medium';
} }
// Clear existing markers and reset state 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.markerCluster.clearLayers();
this.markers = {}; this.markers = {};
this.selectedPropertyId = null; this.selectedPropertyId = null;
@@ -140,9 +242,36 @@
// Bulk add markers for performance // Bulk add markers for performance
this.markerCluster.addLayers(markersToAdd); 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 = { var PropertyFilters = {
@@ -430,9 +542,9 @@
self.$results.html(response.data.html); self.$results.html(response.data.html);
self.isFirstLoad = false; self.isFirstLoad = false;
// Update map markers if map is active // Update map with new filter params
if (response.data.markers) { if (response.data.filters) {
PropertyMap.updateMarkers(response.data.markers); PropertyMap.updateFilters(response.data.filters);
} }
// Recalculate layout after content update // Recalculate layout after content update
@@ -553,8 +665,8 @@
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint; this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
// Initialize map if above breakpoint, map view selected, and we have data // Initialize map if above breakpoint, map view selected, and we have data
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined' && homeprozMapData.properties) { if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.properties); PropertyMap.init(homeprozMapData.initialFilters || {});
this.mapInitialized = true; this.mapInitialized = true;
} }
@@ -581,7 +693,7 @@
// Initialize map if not already done // Initialize map if not already done
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') { if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.properties); PropertyMap.init(homeprozMapData.initialFilters || {});
this.mapInitialized = true; this.mapInitialized = true;
} else if (PropertyMap.map) { } else if (PropertyMap.map) {
// Invalidate size to fix map rendering after show // Invalidate size to fix map rendering after show
@@ -474,3 +474,30 @@
transform: rotate(360deg); 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; $paged = get_query_var('paged') ? get_query_var('paged') : 1;
$per_page = 12; $per_page = 12;
// Build MLS query args // Build filter args for count and properties
$mls_args = array( $filter_args = array(
'status' => $current_status ?: 'Active', '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) { if ($current_type) {
$mls_args['property_type'] = $current_type; $filter_args['property_type'] = $current_type;
} }
if ($current_location) { if ($current_location) {
$mls_args['city'] = $current_location; $filter_args['city'] = $current_location;
} }
if ($current_min_price) { if ($current_min_price) {
$mls_args['min_price'] = $current_min_price; $filter_args['min_price'] = $current_min_price;
} }
if ($current_max_price) { if ($current_max_price) {
$mls_args['max_price'] = $current_max_price; $filter_args['max_price'] = $current_max_price;
} }
if ($current_beds) { if ($current_beds) {
$mls_args['min_beds'] = $current_beds; $filter_args['min_beds'] = $current_beds;
} }
// Fetch all matching properties // Get total count efficiently from database
$all_properties = mls_get_properties($mls_args); $total = mls_get_property_count($filter_args);
// Handle pagination manually
$total = count($all_properties);
$max_pages = ceil($total / $per_page); $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 --> <!-- Results Meta -->