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 () {
// 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) {

View file

@ -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()

View file

@ -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