Cross-fade hex grid on zoom with dual-layer swap

Replace single-layer fade-out/swap/fade-in with a dual-layer (A/B)
cross-fade so old hexagons dissolve while new ones appear simultaneously.
Pans still do a direct data swap on the active layer since hex positions
are origin-anchored and stable.

Fix click/hover handlers to query both layers via queryRenderedFeatures,
and use moveLayer to keep the active layer on top so stale features from
the faded-out layer don't intercept clicks.
This commit is contained in:
Viktor Barzin 2026-02-07 22:25:50 +00:00
parent 42d34fd21a
commit 5e2c5923f6
No known key found for this signature in database
GPG key ID: 0EB088298288D958
2 changed files with 103 additions and 36 deletions

View file

@ -33,6 +33,9 @@
this._checkUpdateCompleteClosure = function (e) { thisthis._checkUpdateComplete(e); }
this._calculatingGrid = false;
this._recalcWhenReady = false;
this._pendingSource = null; // tracks which source has a pending 'data' event
this._clearTimeout = null; // deferred cleanup of old layer data
this._lastZoom = null;
this._reduceFunction = function (data) {
var sum = 0;
@ -50,30 +53,42 @@
HexgridHeatmap.prototype = {
_setupLayers: function (layername, addBefore) {
var defaultPaint = {
'fill-opacity': 0,
'fill-opacity-transition': { duration: 200, delay: 0 },
'fill-color': {
property: 'count',
stops: [
[0, "rgba(0,185,243,0)"],
[50, "rgba(0,185,243,0.24)"],
[130, "rgba(255,223,0,0.3)"],
[200, "rgba(255,105,0,0.3)"],
]
}
};
var emptyData = { type: "FeatureCollection", features: [] };
// Layer A: primary layer, starts active at opacity 1
this.map.addLayer({
'id': layername,
'type': 'fill',
'source': {
type: 'geojson',
data: { type: "FeatureCollection", features: [] }
},
'paint': {
'fill-opacity': 1.0,
'fill-color': {
property: 'count',
stops: [
// Short rainbow blue
[0, "rgba(0,185,243,0)"],
[50, "rgba(0,185,243,0.24)"],
[130, "rgba(255,223,0,0.3)"],
[200, "rgba(255,105,0,0.3)"],
]
}
}
'source': { type: 'geojson', data: emptyData },
'paint': Object.assign({}, defaultPaint, { 'fill-opacity': 1.0 })
}, addBefore);
this.layer = this.map.getLayer(layername);
this.source = this.map.getSource(layername);
// Layer B: back layer, starts inactive at opacity 0, renders below A
this.map.addLayer({
'id': layername + '-back',
'type': 'fill',
'source': { type: 'geojson', data: emptyData },
'paint': Object.assign({}, defaultPaint)
}, layername);
this._layerA = layername;
this._layerB = layername + '-back';
this._sourceA = this.map.getSource(layername);
this._sourceB = this.map.getSource(layername + '-back');
this._activeIsA = true;
},
_setupEvents: function () {
var thisthis = this;
@ -128,10 +143,9 @@
* @param {array} stops - An array of `stops` in the format of the Mapbox GL Style Spec. Values should range from 0 to about 200, though you can control saturation by setting different values here.
*/
setColorStops: function (stops) {
this.layer.paint["fill-color"] = { property: "count", stops: stops };
// not sure how to update the layer so forcing reload
this.map.removeLayer(this.layer.id)
this.map.addLayer(this.layer)
var colorProp = { property: "count", stops: stops };
this.map.setPaintProperty(this._layerA, 'fill-color', colorProp);
this.map.setPaintProperty(this._layerB, 'fill-color', colorProp);
},
@ -241,11 +255,48 @@
_updateGrid: function () {
if (!this._calculatingGrid) {
this._calculatingGrid = true;
var currentZoom = Math.floor(this.map.transform.zoom);
var zoomChanged = this._lastZoom !== null && this._lastZoom !== currentZoom;
this._lastZoom = currentZoom;
var hexgrid = this._generateGrid();
if (hexgrid != null) {
var thisthis = this;
this.source.on("data", this._checkUpdateCompleteClosure);
this.source.setData(hexgrid);
var activeSource = this._activeIsA ? this._sourceA : this._sourceB;
var activeLayer = this._activeIsA ? this._layerA : this._layerB;
var inactiveSource = this._activeIsA ? this._sourceB : this._sourceA;
var inactiveLayer = this._activeIsA ? this._layerB : this._layerA;
// Cancel any pending cleanup timeout
if (this._clearTimeout) {
clearTimeout(this._clearTimeout);
this._clearTimeout = null;
}
if (zoomChanged) {
// Zoom changed: cross-fade between layers
// Move outgoing layer below incoming so queryRenderedFeatures
// returns the new (active) layer's features first
this.map.moveLayer(activeLayer, inactiveLayer);
// Set new data on the inactive layer
this._pendingSource = inactiveSource;
inactiveSource.on("data", this._checkUpdateCompleteClosure);
inactiveSource.setData(hexgrid);
// Fade inactive layer in, active layer out
this.map.setPaintProperty(inactiveLayer, 'fill-opacity', 1.0);
this.map.setPaintProperty(activeLayer, 'fill-opacity', 0);
// Flip active flag
this._activeIsA = !this._activeIsA;
// Clear old layer data after transition completes
this._clearTimeout = setTimeout(function () {
thisthis._clearTimeout = null;
activeSource.setData({ type: "FeatureCollection", features: [] });
}, 250);
} else {
// Pan only: swap data directly on the active source
this._pendingSource = activeSource;
activeSource.on("data", this._checkUpdateCompleteClosure);
activeSource.setData(hexgrid);
}
}
else {
this._calculatingGrid = false;
@ -257,9 +308,18 @@
},
_checkUpdateComplete: function (e) {
if (e.dataType == "source") {
this.source.off("data", this._checkUpdateCompleteClosure);
if (this._pendingSource) {
this._pendingSource.off("data", this._checkUpdateCompleteClosure);
this._pendingSource = null;
}
// Ensure the now-active layer is at full opacity
var activeLayer = this._activeIsA ? this._layerA : this._layerB;
this.map.setPaintProperty(activeLayer, 'fill-opacity', 1.0);
this._calculatingGrid = false;
if (this._recalcWhenReady) this._updateGrid();
if (this._recalcWhenReady) {
this._recalcWhenReady = false;
this._updateGrid();
}
}
}
};

View file

@ -179,10 +179,15 @@ export function Map(props: MapProps) {
lastDataLengthRef.current = 0;
updateHeatmap();
});
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) {
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
if (!mapRef.current) return;
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
.filter(l => mapRef.current!.getLayer(l));
if (layers.length === 0) return;
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
if (features.length > 0) {
const props = features[0].properties;
if (props && props.searchMinX !== undefined) {
openListingsDialog(e.lngLat.lng, e.lngLat.lat, {
minX: props.searchMinX,
minY: props.searchMinY,
@ -192,11 +197,13 @@ export function Map(props: MapProps) {
}
}
});
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 = '';
mapRef.current.on('mousemove', function (e: mapboxgl.MapMouseEvent) {
if (!mapRef.current) return;
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
.filter(l => mapRef.current!.getLayer(l));
if (layers.length === 0) return;
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
mapRef.current.getCanvas().style.cursor = features.length > 0 ? 'pointer' : '';
});
return () => {
if (updateTimeoutRef.current) {