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:
Hanson.xyz Dev
2025-12-16 00:53:21 -06:00
parent 1c728ec60e
commit 93d5b01111
6 changed files with 299 additions and 28 deletions
@@ -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'; ?>,
File diff suppressed because one or more lines are too long
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: '&copy; <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;