Fix clustering density with pixel-based grid calculation

Replace geohash precision mapping with dynamic grid sizing based on
target pixel spacing (60px between cluster centers). Uses Leaflet/OSM
tile math to calculate degrees-per-pixel at each zoom level, adjusted
for Mercator projection at the viewport's center latitude.

At zoom 7, this gives ~52km cells and ~150 clusters statewide,
properly separating Minneapolis from St. Cloud.

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:31:11 -06:00
parent ae0fc65e4e
commit 1c728ec60e
@@ -18,29 +18,10 @@ class MLS_Cluster {
const GEOHASH_CHARS = '0123456789bcdefghjkmnpqrstuvwxyz'; const GEOHASH_CHARS = '0123456789bcdefghjkmnpqrstuvwxyz';
/** /**
* Zoom level to geohash precision mapping * Target pixel spacing between cluster centers
* Lower precision = larger geographic area = more clustering * With 30px cluster icons, 45px gap gives 75px total spacing
*/ */
const ZOOM_PRECISION = array( const CLUSTER_PIXEL_SPACING = 60;
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 * Maximum properties to return as individual markers
@@ -156,20 +137,32 @@ class MLS_Cluster {
} }
/** /**
* Get precision for zoom level * Calculate grid cell size in degrees for a given zoom level
*
* Uses Leaflet/OSM tile math to determine what geographic distance
* corresponds to CLUSTER_PIXEL_SPACING pixels at the given zoom.
* *
* @param int $zoom Map zoom level (1-20) * @param int $zoom Map zoom level (1-20)
* @return int Geohash precision * @param float $lat Center latitude (affects Mercator projection)
* @return array [lat_step, lng_step] in degrees
*/ */
public function get_precision_for_zoom($zoom) { public function get_grid_step_for_zoom($zoom, $lat = 45.0) {
$zoom = max(3, min(20, (int) $zoom)); $zoom = max(3, min(20, (int) $zoom));
if (isset(self::ZOOM_PRECISION[$zoom])) { // Degrees per pixel at zoom level (longitude)
return self::ZOOM_PRECISION[$zoom]; // 360 degrees / (256 pixels * 2^zoom tiles)
} $degrees_per_pixel_lng = 360.0 / (256 * pow(2, $zoom));
// Default to precision 5 for unknown zoom levels // Latitude degrees per pixel (adjusted for Mercator at given latitude)
return 5; // At the equator it's the same, at poles it's compressed
$lat_rad = deg2rad(abs($lat));
$degrees_per_pixel_lat = $degrees_per_pixel_lng * cos($lat_rad);
// Calculate step size to achieve target pixel spacing
$lng_step = self::CLUSTER_PIXEL_SPACING * $degrees_per_pixel_lng;
$lat_step = self::CLUSTER_PIXEL_SPACING * $degrees_per_pixel_lat;
return array($lat_step, $lng_step);
} }
/** /**
@@ -258,14 +251,18 @@ class MLS_Cluster {
} }
// If low count or high zoom, return individual markers // 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'] >= 16) {
if ($total <= self::MAX_INDIVIDUAL_MARKERS || $args['zoom'] >= 18) {
return $this->get_individual_markers($where_sql, $values, $total); 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) {
$center_lat = ($args['bounds'][0] + $args['bounds'][2]) / 2;
}
// Return clusters // Return clusters
return $this->get_cluster_data($where_sql, $values, $precision, $total); return $this->get_cluster_data($where_sql, $values, $args['zoom'], $center_lat, $total);
} }
/** /**
@@ -334,27 +331,22 @@ class MLS_Cluster {
} }
/** /**
* Get clustered data using geohash grouping * Get clustered data using grid-based grouping
* *
* @param string $where_sql WHERE clause * @param string $where_sql WHERE clause
* @param array $values Prepared values * @param array $values Prepared values
* @param int $precision Geohash precision * @param int $zoom Map zoom level
* @param float $center_lat Center latitude for Mercator adjustment
* @param int $total Total count * @param int $total Total count
* @return array * @return array
*/ */
private function get_cluster_data($where_sql, $values, $precision, $total) { private function get_cluster_data($where_sql, $values, $zoom, $center_lat, $total) {
global $wpdb; global $wpdb;
$table = $this->db->properties_table(); $table = $this->db->properties_table();
// Use MySQL SUBSTR to extract geohash prefix for grouping // Calculate grid cell size based on zoom level and target pixel spacing
// We compute geohash on-the-fly using latitude/longitude list($lat_step, $lng_step) = $this->get_grid_step_for_zoom($zoom, $center_lat);
// 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 // Use FLOOR to group coordinates into cells
$sql = "SELECT $sql = "SELECT
@@ -388,7 +380,8 @@ class MLS_Cluster {
'type' => 'clusters', 'type' => 'clusters',
'total' => $total, 'total' => $total,
'cluster_count' => count($clusters), 'cluster_count' => count($clusters),
'precision' => $precision, 'zoom' => $zoom,
'grid_size_deg' => array('lat' => $lat_step, 'lng' => $lng_step),
'clusters' => $clusters, 'clusters' => $clusters,
); );
} }