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:
parent
150342bb9e
commit
42d34fd21a
3 changed files with 93 additions and 45 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue