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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user