From 42d34fd21a73a03a5983ce91a1a46fb8a9409ead Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 7 Feb 2026 21:59:02 +0000 Subject: [PATCH] Fix map UX: instant load, stable hex grid, and clickable hexagons - Skip fly-to animation on initial data load (fitBounds duration: 0) - Replace turf.hexGrid with origin-anchored manual hex generation so hexagons stay stable across pan/zoom instead of flickering - Quantize zoom to integers so hex pattern only changes at discrete levels - Scale search radius with cell size so low-zoom hexagons find their data - Make hexagons clickable with pointer cursor; each hex shows the exact listings it aggregated via stored search bounds - Remove unused SEARCH_BUFFER constant --- crawler/frontend/public/HexgridHeatmap.js | 105 +++++++++++++++------- crawler/frontend/src/components/Map.tsx | 32 ++++--- crawler/frontend/src/constants/index.ts | 1 - 3 files changed, 93 insertions(+), 45 deletions(-) diff --git a/crawler/frontend/public/HexgridHeatmap.js b/crawler/frontend/public/HexgridHeatmap.js index e0fb627..5b5e15e 100644 --- a/crawler/frontend/public/HexgridHeatmap.js +++ b/crawler/frontend/public/HexgridHeatmap.js @@ -155,49 +155,88 @@ _generateGrid: function () { - // Rebuild grid - //var cellSize = Math.min(Math.max(1000/Math.pow(2,this.map.transform.zoom), 0.01), 0.1); // Constant screen size + // Fix: quantize zoom to integers so hex pattern only changes at discrete levels + var zoom = Math.floor(this.map.transform.zoom); + var cellSize = Math.max(500 / Math.pow(2, zoom) / this._cellDensity, 0.01); - var cellSize = Math.max(500 / Math.pow(2, this.map.transform.zoom) / this._cellDensity, 0.01); // Constant screen size - - // TODO: These extents don't work when the map is rotated - var extents = this.map.getBounds().toArray() + var extents = this.map.getBounds().toArray(); extents = [extents[0][0], extents[0][1], extents[1][0], extents[1][1]]; - var hexgrid = turf.hexGrid(extents, cellSize, 'kilometers'); + // Convert cell size from km to degrees + var centerLat = (extents[1] + extents[3]) / 2; + var kmPerDegLon = 111.32 * Math.cos(centerLat * Math.PI / 180); + var kmPerDegLat = 110.574; + var rx = cellSize / kmPerDegLon; // hex x-radius in degrees + var ry = cellSize / kmPerDegLat * Math.sqrt(3) / 2; // hex y-radius in degrees - var sigma = this._spread; - var a = 1 / (sigma * Math.sqrt(2 * Math.PI)); - var amplitude = this._intensity; + // Flat-top hex grid spacing + var xStep = rx * 1.5; // horizontal center-to-center + var yStep = ry * 2; // vertical center-to-center + + // Fix: compute grid indices from fixed origin (0,0) — hex positions + // are deterministic regardless of viewport position + var xStart = Math.floor(extents[0] / xStep) - 1; + var xEnd = Math.ceil(extents[2] / xStep) + 1; + var yStart = Math.floor(extents[1] / yStep) - 1; + var yEnd = Math.ceil(extents[3] / yStep) + 1; + + // Fix: scale search radius with cell size so all points in a cell are found + var searchDegLon = Math.max(cellSize * 0.75, this._spread * 4) / kmPerDegLon; + var searchDegLat = Math.max(cellSize * 0.75, this._spread * 4) / kmPerDegLat; var cellsToSave = []; - var thisthis = this; - hexgrid.features.forEach(function (cell) { - var center = turf.center(cell); - var strength = 0; - var SW = turf.destination(center, sigma * 4, -135); - var NE = turf.destination(center, sigma * 4, 45); - var pois = thisthis._tree.search({ - minX: SW.geometry.coordinates[0], - minY: SW.geometry.coordinates[1], - maxX: NE.geometry.coordinates[0], - maxY: NE.geometry.coordinates[1] - }); - if (pois.length > 0) { - var values = pois.map(function (d) { return d['properties'][thisthis._propertyName] }); - var strength = thisthis._reduceFunction(values); - if (!isNaN(strength)) { - cell.properties.count = strength; - cellsToSave.push(cell); + + for (var xi = xStart; xi <= xEnd; xi++) { + for (var yi = yStart; yi <= yEnd; yi++) { + var cx = xi * xStep; + var cy = yi * yStep; + // Odd columns offset by half a row + if (xi % 2 !== 0) { + cy -= yStep / 2; + } + + // Search for data points near this cell center + var pois = thisthis._tree.search({ + minX: cx - searchDegLon, + minY: cy - searchDegLat, + maxX: cx + searchDegLon, + maxY: cy + searchDegLat + }); + + if (pois.length > 0) { + var values = pois.map(function (d) { + return d['properties'][thisthis._propertyName]; + }); + var strength = thisthis._reduceFunction(values); + if (!isNaN(strength)) { + // Build hexagon polygon + var coords = [ + [cx + rx, cy], + [cx + rx / 2, cy + ry], + [cx - rx / 2, cy + ry], + [cx - rx, cy], + [cx - rx / 2, cy - ry], + [cx + rx / 2, cy - ry], + [cx + rx, cy] + ]; + cellsToSave.push({ + type: "Feature", + geometry: { type: "Polygon", coordinates: [coords] }, + properties: { + count: strength, + searchMinX: cx - searchDegLon, + searchMinY: cy - searchDegLat, + searchMaxX: cx + searchDegLon, + searchMaxY: cy + searchDegLat + } + }); + } } } + } - }); - - hexgrid.features = cellsToSave; - return hexgrid; - + return { type: "FeatureCollection", features: cellsToSave }; }, _updateGrid: function () { if (!this._calculatingGrid) { diff --git a/crawler/frontend/src/components/Map.tsx b/crawler/frontend/src/components/Map.tsx index 0bd0669..e44a178 100644 --- a/crawler/frontend/src/components/Map.tsx +++ b/crawler/frontend/src/components/Map.tsx @@ -157,7 +157,7 @@ export function Map(props: MapProps) { mapRef.current?.fitBounds([ [minlng, minlat], [maxlng, maxlat] - ]); + ], { duration: 0 }); } lastDataLengthRef.current = subset.features.length; @@ -179,8 +179,24 @@ export function Map(props: MapProps) { lastDataLengthRef.current = 0; updateHeatmap(); }); - mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) { - openListingsDialog(e.lngLat.lng, e.lngLat.lat); + mapRef.current.on('click', 'hexgrid-heatmap', function (e: mapboxgl.MapLayerMouseEvent) { + if (e.features && e.features.length > 0) { + const props = e.features[0].properties; + if (props) { + openListingsDialog(e.lngLat.lng, e.lngLat.lat, { + minX: props.searchMinX, + minY: props.searchMinY, + maxX: props.searchMaxX, + maxY: props.searchMaxY + }); + } + } + }); + mapRef.current.on('mouseenter', 'hexgrid-heatmap', function () { + if (mapRef.current) mapRef.current.getCanvas().style.cursor = 'pointer'; + }); + mapRef.current.on('mouseleave', 'hexgrid-heatmap', function () { + if (mapRef.current) mapRef.current.getCanvas().style.cursor = ''; }); return () => { if (updateTimeoutRef.current) { @@ -293,16 +309,10 @@ export function Map(props: MapProps) { .text(metricInfo.high); } - function openListingsDialog(longitude: number, latitude: number) { + function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) { if (!heatmapRef.current || !mapRef.current) return; - const searchBuffer = HEATMAP_CONFIG.SEARCH_BUFFER; - const properties = heatmapRef.current._tree.search({ - minX: longitude - searchBuffer, - maxX: longitude + searchBuffer, - minY: latitude - searchBuffer, - maxY: latitude + searchBuffer - }); + const properties = heatmapRef.current._tree.search(searchBounds); if (properties.length > 0) { const listingDialogPopup = getListingDialog(properties); new mapboxgl.Popup() diff --git a/crawler/frontend/src/constants/index.ts b/crawler/frontend/src/constants/index.ts index 4efda18..82eeee2 100644 --- a/crawler/frontend/src/constants/index.ts +++ b/crawler/frontend/src/constants/index.ts @@ -28,7 +28,6 @@ export const HEATMAP_CONFIG = { INTENSITY: 9, SPREAD: 0.05, CELL_DENSITY: 0.5, // Smaller value = bigger hexagons - SEARCH_BUFFER: 0.001, // ~100m for click detection } as const; // Percentile configuration for data visualization