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:
parent
42d34fd21a
commit
5e2c5923f6
2 changed files with 103 additions and 36 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue