Add tiered map visualization based on zoom level
- Zoom 3-7: Heatmap overlay showing property density - Zoom 8-11: Density dots (small colored circles without numbers) - Zoom 12-15: Numbered cluster circles - Zoom 16+: Individual property markers Backend returns different data types (heatmap/density/clusters/markers) based on zoom level. Frontend uses Leaflet.heat for heatmap and custom divIcons for density dots with color gradient based on count. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,12 +23,25 @@ class MLS_Cluster {
|
||||
*/
|
||||
const CLUSTER_PIXEL_SPACING = 60;
|
||||
|
||||
/**
|
||||
* Pixel spacing for density dots (smaller, more numerous)
|
||||
*/
|
||||
const DENSITY_DOT_SPACING = 40;
|
||||
|
||||
/**
|
||||
* Maximum properties to return as individual markers
|
||||
* Above this threshold, return clusters
|
||||
*/
|
||||
const MAX_INDIVIDUAL_MARKERS = 500;
|
||||
|
||||
/**
|
||||
* Zoom thresholds for visualization modes
|
||||
*/
|
||||
const ZOOM_HEATMAP_MAX = 7; // 3-7: heatmap only
|
||||
const ZOOM_DENSITY_MAX = 11; // 8-11: density dots
|
||||
const ZOOM_CLUSTER_MAX = 15; // 12-15: numbered clusters
|
||||
// 16+: individual markers
|
||||
|
||||
/**
|
||||
* Database instance
|
||||
*/
|
||||
@@ -140,14 +153,16 @@ class MLS_Cluster {
|
||||
* 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.
|
||||
* corresponds to the target pixel spacing at the given zoom.
|
||||
*
|
||||
* @param int $zoom Map zoom level (1-20)
|
||||
* @param float $lat Center latitude (affects Mercator projection)
|
||||
* @param int $pixel_spacing Target pixel spacing (defaults to CLUSTER_PIXEL_SPACING)
|
||||
* @return array [lat_step, lng_step] in degrees
|
||||
*/
|
||||
public function get_grid_step_for_zoom($zoom, $lat = 45.0) {
|
||||
public function get_grid_step_for_zoom($zoom, $lat = 45.0, $pixel_spacing = null) {
|
||||
$zoom = max(3, min(20, (int) $zoom));
|
||||
$pixel_spacing = $pixel_spacing ?: self::CLUSTER_PIXEL_SPACING;
|
||||
|
||||
// Degrees per pixel at zoom level (longitude)
|
||||
// 360 degrees / (256 pixels * 2^zoom tiles)
|
||||
@@ -159,8 +174,8 @@ class MLS_Cluster {
|
||||
$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;
|
||||
$lng_step = $pixel_spacing * $degrees_per_pixel_lng;
|
||||
$lat_step = $pixel_spacing * $degrees_per_pixel_lat;
|
||||
|
||||
return array($lat_step, $lng_step);
|
||||
}
|
||||
@@ -250,19 +265,128 @@ class MLS_Cluster {
|
||||
$total = (int) $wpdb->get_var($count_sql);
|
||||
}
|
||||
|
||||
// If low count or high zoom, return individual markers
|
||||
if ($total <= self::MAX_INDIVIDUAL_MARKERS || $args['zoom'] >= 16) {
|
||||
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 $this->get_cluster_data($where_sql, $values, $args['zoom'], $center_lat, $total);
|
||||
$zoom = (int) $args['zoom'];
|
||||
|
||||
// Determine visualization mode based on zoom level
|
||||
// Zoom 3-7: Heatmap (just return points for client-side heatmap)
|
||||
if ($zoom <= self::ZOOM_HEATMAP_MAX) {
|
||||
return $this->get_heatmap_data($where_sql, $values, $total);
|
||||
}
|
||||
|
||||
// Zoom 8-11: Density dots (small colored circles without numbers)
|
||||
if ($zoom <= self::ZOOM_DENSITY_MAX) {
|
||||
return $this->get_density_data($where_sql, $values, $zoom, $center_lat, $total);
|
||||
}
|
||||
|
||||
// Zoom 12-15: Numbered clusters (or individual if low count)
|
||||
if ($zoom <= self::ZOOM_CLUSTER_MAX) {
|
||||
if ($total <= self::MAX_INDIVIDUAL_MARKERS) {
|
||||
return $this->get_individual_markers($where_sql, $values, $total);
|
||||
}
|
||||
return $this->get_cluster_data($where_sql, $values, $zoom, $center_lat, $total);
|
||||
}
|
||||
|
||||
// Zoom 16+: Individual markers
|
||||
return $this->get_individual_markers($where_sql, $values, $total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data (just coordinates for client-side rendering)
|
||||
*
|
||||
* @param string $where_sql WHERE clause
|
||||
* @param array $values Prepared values
|
||||
* @param int $total Total count
|
||||
* @return array
|
||||
*/
|
||||
private function get_heatmap_data($where_sql, $values, $total) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
// Get sampled points for heatmap (limit to prevent overwhelming the client)
|
||||
// Use grid-based sampling to get representative distribution
|
||||
$sql = "SELECT latitude, longitude
|
||||
FROM {$table}
|
||||
WHERE {$where_sql}
|
||||
LIMIT 10000";
|
||||
|
||||
if (!empty($values)) {
|
||||
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
||||
} else {
|
||||
$results = $wpdb->get_results($sql);
|
||||
}
|
||||
|
||||
$points = array();
|
||||
foreach ($results as $row) {
|
||||
$points[] = array(
|
||||
(float) $row->latitude,
|
||||
(float) $row->longitude,
|
||||
1.0 // intensity
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'heatmap',
|
||||
'total' => $total,
|
||||
'point_count' => count($points),
|
||||
'points' => $points,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get density dot data (clustered points with count for coloring)
|
||||
*
|
||||
* @param string $where_sql WHERE clause
|
||||
* @param array $values Prepared values
|
||||
* @param int $zoom Map zoom level
|
||||
* @param float $center_lat Center latitude for Mercator adjustment
|
||||
* @param int $total Total count
|
||||
* @return array
|
||||
*/
|
||||
private function get_density_data($where_sql, $values, $zoom, $center_lat, $total) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
// Use smaller grid cells for density dots
|
||||
list($lat_step, $lng_step) = $this->get_grid_step_for_zoom($zoom, $center_lat, self::DENSITY_DOT_SPACING);
|
||||
|
||||
$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
|
||||
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));
|
||||
|
||||
$dots = array();
|
||||
foreach ($results as $row) {
|
||||
$dots[] = array(
|
||||
'lat' => (float) $row->avg_lat,
|
||||
'lng' => (float) $row->avg_lng,
|
||||
'count' => (int) $row->count,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'density',
|
||||
'total' => $total,
|
||||
'dot_count' => count($dots),
|
||||
'zoom' => $zoom,
|
||||
'dots' => $dots,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,6 +131,8 @@ if (function_exists('mls_get_property_count')) {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<!-- Leaflet MarkerCluster JS -->
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
|
||||
<!-- Leaflet Heat JS -->
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" crossorigin=""></script>
|
||||
<script>
|
||||
var homeprozMapData = {
|
||||
isMapView: <?php echo $show_map ? 'true' : 'false'; ?>,
|
||||
|
||||
+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
@@ -16,14 +16,17 @@
|
||||
var PropertyMap = {
|
||||
map: null,
|
||||
markers: {}, // Object keyed by property ID
|
||||
clusterLayer: null, // Layer group for server clusters
|
||||
markerCluster: null, // MarkerClusterGroup for individual markers
|
||||
heatLayer: null, // Leaflet.heat layer for zoom 3-7
|
||||
densityLayer: null, // Layer group for density dots (zoom 8-11)
|
||||
clusterLayer: null, // Layer group for server clusters (zoom 12-15)
|
||||
markerCluster: null, // MarkerClusterGroup for individual markers (zoom 16+)
|
||||
selectedPropertyId: null,
|
||||
hoveredPropertyId: null,
|
||||
baseZIndex: 400,
|
||||
currentFilters: {},
|
||||
isLoading: false,
|
||||
loadTimeout: null,
|
||||
currentMode: null, // Track current visualization mode
|
||||
|
||||
/**
|
||||
* Initialize the map
|
||||
@@ -45,7 +48,25 @@
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create layer for server-side clusters
|
||||
// Create heatmap layer (zoom 3-7)
|
||||
this.heatLayer = L.heatLayer([], {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 10,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.2: '#22c55e', // green
|
||||
0.4: '#eab308', // yellow
|
||||
0.6: '#f97316', // orange
|
||||
0.8: '#ef4444', // red
|
||||
1.0: '#dc2626' // dark red
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create layer for density dots (zoom 8-11)
|
||||
this.densityLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Create layer for server-side clusters (zoom 12-15)
|
||||
this.clusterLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Create marker cluster group for individual markers (when zoomed in)
|
||||
@@ -135,10 +156,22 @@
|
||||
data: requestData,
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
if (response.data.type === 'clusters') {
|
||||
self.renderClusters(response.data.clusters);
|
||||
} else {
|
||||
self.renderMarkers(response.data.markers);
|
||||
var data = response.data;
|
||||
self.currentMode = data.type;
|
||||
|
||||
switch (data.type) {
|
||||
case 'heatmap':
|
||||
self.renderHeatmap(data.points);
|
||||
break;
|
||||
case 'density':
|
||||
self.renderDensity(data.dots);
|
||||
break;
|
||||
case 'clusters':
|
||||
self.renderClusters(data.clusters);
|
||||
break;
|
||||
case 'markers':
|
||||
self.renderMarkers(data.markers);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -149,13 +182,89 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Render server-side clusters
|
||||
* Clear all visualization layers
|
||||
*/
|
||||
renderClusters: function(clusters) {
|
||||
// Clear both layers
|
||||
clearAllLayers: function() {
|
||||
this.heatLayer.setLatLngs([]);
|
||||
this.densityLayer.clearLayers();
|
||||
this.clusterLayer.clearLayers();
|
||||
this.markerCluster.clearLayers();
|
||||
this.markers = {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Render heatmap (zoom 3-7)
|
||||
*/
|
||||
renderHeatmap: function(points) {
|
||||
this.clearAllLayers();
|
||||
this.heatLayer.setLatLngs(points);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render density dots (zoom 8-11)
|
||||
* Small colored circles based on property count
|
||||
*/
|
||||
renderDensity: function(dots) {
|
||||
this.clearAllLayers();
|
||||
|
||||
var self = this;
|
||||
|
||||
dots.forEach(function(dot) {
|
||||
// Determine color based on count
|
||||
var color = self.getDensityColor(dot.count);
|
||||
var size = self.getDensitySize(dot.count);
|
||||
|
||||
var icon = L.divIcon({
|
||||
html: '<div class="density-dot" style="background-color: ' + color + '; width: ' + size + 'px; height: ' + size + 'px;"></div>',
|
||||
className: 'density-dot-container',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
|
||||
var marker = L.marker([dot.lat, dot.lng], { icon: icon });
|
||||
|
||||
// Click to zoom in
|
||||
marker.on('click', function() {
|
||||
self.map.setView([dot.lat, dot.lng], self.map.getZoom() + 2);
|
||||
});
|
||||
|
||||
// Tooltip with count
|
||||
marker.bindTooltip(dot.count + ' properties', {
|
||||
className: 'density-tooltip'
|
||||
});
|
||||
|
||||
self.densityLayer.addLayer(marker);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get color for density dot based on count
|
||||
*/
|
||||
getDensityColor: function(count) {
|
||||
if (count >= 200) return '#dc2626'; // dark red
|
||||
if (count >= 100) return '#ef4444'; // red
|
||||
if (count >= 50) return '#f97316'; // orange
|
||||
if (count >= 20) return '#eab308'; // yellow
|
||||
if (count >= 10) return '#84cc16'; // lime
|
||||
return '#22c55e'; // green
|
||||
},
|
||||
|
||||
/**
|
||||
* Get size for density dot based on count
|
||||
*/
|
||||
getDensitySize: function(count) {
|
||||
if (count >= 200) return 12;
|
||||
if (count >= 100) return 10;
|
||||
if (count >= 50) return 8;
|
||||
if (count >= 20) return 7;
|
||||
return 6;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render server-side clusters (zoom 12-15)
|
||||
*/
|
||||
renderClusters: function(clusters) {
|
||||
this.clearAllLayers();
|
||||
|
||||
var self = this;
|
||||
|
||||
@@ -194,13 +303,10 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Render individual markers (when zoomed in or low count)
|
||||
* Render individual markers (zoom 16+ or low count)
|
||||
*/
|
||||
renderMarkers: function(properties) {
|
||||
// Clear both layers
|
||||
this.clusterLayer.clearLayers();
|
||||
this.markerCluster.clearLayers();
|
||||
this.markers = {};
|
||||
this.clearAllLayers();
|
||||
this.selectedPropertyId = null;
|
||||
this.hoveredPropertyId = null;
|
||||
|
||||
|
||||
@@ -315,6 +315,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Density dots (zoom 8-11)
|
||||
.density-dot-container {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.density-dot {
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
.density-tooltip {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
// Cluster tooltip
|
||||
.cluster-tooltip {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
// Highlighted property card (amber border)
|
||||
.property-card-highlighted {
|
||||
border-color: #F59E0B !important;
|
||||
|
||||
Reference in New Issue
Block a user