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
This commit is contained in:
Viktor Barzin 2026-02-07 21:59:02 +00:00
parent 150342bb9e
commit 42d34fd21a
No known key found for this signature in database
GPG key ID: 0EB088298288D958
3 changed files with 93 additions and 45 deletions

View file

@ -155,49 +155,88 @@
_generateGrid: function () { _generateGrid: function () {
// Rebuild grid // Fix: quantize zoom to integers so hex pattern only changes at discrete levels
//var cellSize = Math.min(Math.max(1000/Math.pow(2,this.map.transform.zoom), 0.01), 0.1); // Constant screen size 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 var extents = this.map.getBounds().toArray();
// TODO: These extents don't work when the map is rotated
var extents = this.map.getBounds().toArray()
extents = [extents[0][0], extents[0][1], extents[1][0], extents[1][1]]; 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; // Flat-top hex grid spacing
var a = 1 / (sigma * Math.sqrt(2 * Math.PI)); var xStep = rx * 1.5; // horizontal center-to-center
var amplitude = this._intensity; 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 cellsToSave = [];
var thisthis = this; var thisthis = this;
hexgrid.features.forEach(function (cell) {
var center = turf.center(cell); for (var xi = xStart; xi <= xEnd; xi++) {
var strength = 0; for (var yi = yStart; yi <= yEnd; yi++) {
var SW = turf.destination(center, sigma * 4, -135); var cx = xi * xStep;
var NE = turf.destination(center, sigma * 4, 45); var cy = yi * yStep;
var pois = thisthis._tree.search({ // Odd columns offset by half a row
minX: SW.geometry.coordinates[0], if (xi % 2 !== 0) {
minY: SW.geometry.coordinates[1], cy -= yStep / 2;
maxX: NE.geometry.coordinates[0], }
maxY: NE.geometry.coordinates[1]
}); // Search for data points near this cell center
if (pois.length > 0) { var pois = thisthis._tree.search({
var values = pois.map(function (d) { return d['properties'][thisthis._propertyName] }); minX: cx - searchDegLon,
var strength = thisthis._reduceFunction(values); minY: cy - searchDegLat,
if (!isNaN(strength)) { maxX: cx + searchDegLon,
cell.properties.count = strength; maxY: cy + searchDegLat
cellsToSave.push(cell); });
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
}
});
}
} }
} }
}
}); return { type: "FeatureCollection", features: cellsToSave };
hexgrid.features = cellsToSave;
return hexgrid;
}, },
_updateGrid: function () { _updateGrid: function () {
if (!this._calculatingGrid) { if (!this._calculatingGrid) {

View file

@ -157,7 +157,7 @@ export function Map(props: MapProps) {
mapRef.current?.fitBounds([ mapRef.current?.fitBounds([
[minlng, minlat], [minlng, minlat],
[maxlng, maxlat] [maxlng, maxlat]
]); ], { duration: 0 });
} }
lastDataLengthRef.current = subset.features.length; lastDataLengthRef.current = subset.features.length;
@ -179,8 +179,24 @@ export function Map(props: MapProps) {
lastDataLengthRef.current = 0; lastDataLengthRef.current = 0;
updateHeatmap(); updateHeatmap();
}); });
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) { mapRef.current.on('click', 'hexgrid-heatmap', function (e: mapboxgl.MapLayerMouseEvent) {
openListingsDialog(e.lngLat.lng, e.lngLat.lat); 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 () => { return () => {
if (updateTimeoutRef.current) { if (updateTimeoutRef.current) {
@ -293,16 +309,10 @@ export function Map(props: MapProps) {
.text(metricInfo.high); .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; if (!heatmapRef.current || !mapRef.current) return;
const searchBuffer = HEATMAP_CONFIG.SEARCH_BUFFER; const properties = heatmapRef.current._tree.search(searchBounds);
const properties = heatmapRef.current._tree.search({
minX: longitude - searchBuffer,
maxX: longitude + searchBuffer,
minY: latitude - searchBuffer,
maxY: latitude + searchBuffer
});
if (properties.length > 0) { if (properties.length > 0) {
const listingDialogPopup = getListingDialog(properties); const listingDialogPopup = getListingDialog(properties);
new mapboxgl.Popup() new mapboxgl.Popup()

View file

@ -28,7 +28,6 @@ export const HEATMAP_CONFIG = {
INTENSITY: 9, INTENSITY: 9,
SPREAD: 0.05, SPREAD: 0.05,
CELL_DENSITY: 0.5, // Smaller value = bigger hexagons CELL_DENSITY: 0.5, // Smaller value = bigger hexagons
SEARCH_BUFFER: 0.001, // ~100m for click detection
} as const; } as const;
// Percentile configuration for data visualization // Percentile configuration for data visualization