diff --git a/jest.stub.js b/jest.stub.js index 6957129f..df699aab 100644 --- a/jest.stub.js +++ b/jest.stub.js @@ -2,12 +2,21 @@ globalThis.mockMapGL = { on: jest.fn(), + once: jest.fn(), + off: jest.fn(), addLayer: jest.fn(), addSource: jest.fn(), addControl: jest.fn(), + removeLayer: jest.fn(), + removeSource: jest.fn(), getLayer: jest.fn(), getSource: jest.fn(), + getStyle: jest.fn(() => ({ layers: [] })), setFeatureState: jest.fn(), + setFilter: jest.fn(), + setPaintProperty: jest.fn(), + setLayoutProperty: jest.fn(), + moveLayer: jest.fn(), getCanvas: jest.fn(() => ({ style: {} })), _getUIString: jest.fn(), } @@ -15,7 +24,9 @@ globalThis.mockMapGL = { globalThis.mockMap = { getMapGL: () => globalThis.mockMapGL, setHoverState: jest.fn(), + setSelectedState: jest.fn(), getBeforeLayerId: jest.fn(), + invalidateInteractiveLayerIds: jest.fn(), styleIsLoaded: () => true, } diff --git a/src/Map.js b/src/Map.js index 53165b14..1b911a65 100644 --- a/src/Map.js +++ b/src/Map.js @@ -52,6 +52,11 @@ export class MapGL extends Evented { this._glyphs = glyphs this._renderTimeout = null this._mouseMoveEnabled = true + this._hoveredLayer = null + this._hoveredFeatureId = null + this._selectedFeatures = null + this._hoverStateKey = null + this._selectedStateKey = null // Translate strings if (locale) { @@ -68,7 +73,7 @@ export class MapGL extends Evented { mapgl.on('load', this.onLoad) mapgl.on('click', this.onClick) mapgl.on('contextmenu', this.onContextMenu) - mapgl.on('mousemove', this.onMouseMove.bind(this)) + mapgl.on('mousemove', this.onMouseMove) mapgl.on('mouseout', this.onMouseOut) mapgl.on('error', this.onError) /* Data and dataloading events are an indication that @@ -266,32 +271,65 @@ export class MapGL extends Evented { } const feature = this.getEventFeature(evt) - let layer + const hoverTarget = feature + ? this._resolveHoverTarget(evt, feature) + : null - if (feature) { - layer = this.getLayerFromId(feature.layer.id) + if (!feature) { + this._hideLabelUnlessOverOverlay(evt) + } - if (layer) { - layer.onMouseMove(evt, feature) - } - } else { - const target = evt && evt.originalEvent && evt.originalEvent.target - if ( - !target || - !target.closest || - !OVERLAY_SELECTORS.some(sel => target.closest(sel)) - ) { - this.hideLabel() - } + this._updateHoveredLayer(hoverTarget, feature) + + this.getMapGL().getCanvas().style.cursor = feature ? 'pointer' : '' + } + + _resolveHoverTarget(evt, feature) { + const layer = this.getLayerFromId(feature.layer.id) + + if (!layer) { + return null } - this.setHoverState( - layer && feature?.properties?.id - ? layer.getFeaturesById(feature.properties.id) - : null + layer.onMouseMove(evt, feature) + + return ( + (typeof layer.getSubLayerFromId === 'function' && + layer.getSubLayerFromId(feature.layer.id)) || + layer ) + } - this.getMapGL().getCanvas().style.cursor = feature ? 'pointer' : '' + _hideLabelUnlessOverOverlay(evt) { + const target = evt?.originalEvent?.target + const isOverOverlay = + target?.closest && + OVERLAY_SELECTORS.some(sel => target.closest(sel)) + + if (!isOverOverlay) { + this.hideLabel() + } + } + + _updateHoveredLayer(hoverTarget, feature) { + const featureId = feature?.properties?.id ?? null + const nextHover = hoverTarget && featureId !== null ? hoverTarget : null + + if ( + nextHover === this._hoveredLayer && + featureId === this._hoveredFeatureId + ) { + return + } + + if (this._hoveredLayer) { + this._hoveredLayer.fire('mouseleave') + } + if (nextHover) { + nextHover.fire('mouseenter', { feature }) + } + this._hoveredLayer = nextHover + this._hoveredFeatureId = nextHover ? featureId : null } // Remove rendered class if rendering is happening @@ -310,12 +348,12 @@ export class MapGL extends Evented { } // Set hover state for features - setHoverState(features) { - // Only set hover state when features are changed - if ( - getFeaturesString(features) !== - getFeaturesString(this._hoverFeatures) - ) { + setHoverState(features, color) { + const key = `${getFeaturesString(features)}|${color ?? ''}` + + if (key !== this._hoverStateKey) { + this._hoverStateKey = key + if (this._hoverFeatures) { // Clear state for existing hover features this._hoverFeatures.forEach(feature => @@ -327,7 +365,40 @@ export class MapGL extends Evented { if (Array.isArray(features)) { this._hoverFeatures = features features.forEach(feature => - this.setFeatureState(feature, { hover: true }) + this.setFeatureState( + feature, + color !== undefined + ? { hover: true, highlightColor: color } + : { hover: true } + ) + ) + } + } + } + + // Set selected state for features + setSelectedState(features, color) { + const key = `${getFeaturesString(features)}|${color ?? ''}` + + if (key !== this._selectedStateKey) { + this._selectedStateKey = key + + if (this._selectedFeatures) { + this._selectedFeatures.forEach(feature => + this.setFeatureState(feature, { selected: false }) + ) + this._selectedFeatures = null + } + + if (Array.isArray(features)) { + this._selectedFeatures = features + features.forEach(feature => + this.setFeatureState( + feature, + color !== undefined + ? { selected: true, highlightColor: color } + : { selected: true } + ) ) } } @@ -354,6 +425,12 @@ export class MapGL extends Evented { return } this.hideLabel() + + if (this._hoveredLayer) { + this._hoveredLayer.fire('mouseleave') + this._hoveredLayer = null + this._hoveredFeatureId = null + } } onError = evt => { @@ -373,6 +450,11 @@ export class MapGL extends Evented { return this.getMapGL().getZoom() } + // Clears the cached interactive layer ids so they're recomputed on next use + invalidateInteractiveLayerIds() { + this._interactiveLayerIds = null + } + getEventFeature(evt) { if (!this._interactiveLayerIds) { this._interactiveLayerIds = this.getLayers() @@ -513,8 +595,10 @@ export class MapGL extends Evented { const { x, y } = this.getMapGL().project(lngLat) const position = [x, y] const feature = this.getEventFeature(evt) + // Lets consumers distinguish a plain click from a ctrl/cmd-click. + const { ctrlKey = false, metaKey = false } = evt.originalEvent || {} - return { type, coordinates, position, feature } + return { type, coordinates, position, feature, ctrlKey, metaKey } } _setRenderTimeout() { diff --git a/src/__tests__/Map.spec.js b/src/__tests__/Map.spec.js index 422d27bd..a7e88c9a 100644 --- a/src/__tests__/Map.spec.js +++ b/src/__tests__/Map.spec.js @@ -28,9 +28,8 @@ describe('DHIS2 Maps-gl Map', () => { expect(mapgl.on).toHaveBeenCalledTimes(10) }) - it('should call setHoverState on mousemove when mousemove enabled', () => { + it('should forward mousemove to the hovered layer and set the cursor when mousemove enabled', () => { const map = new Map('el') - const setHoverStateSpy = jest.spyOn(map, 'setHoverState') const mockLayer = { isInteractive: () => true, @@ -39,6 +38,7 @@ describe('DHIS2 Maps-gl Map', () => { hasLayerId: id => id === 'layer-1', getIndex: () => 0, onMouseMove: jest.fn(), + fire: jest.fn(), } map._layers = [mockLayer] jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) @@ -51,6 +51,8 @@ describe('DHIS2 Maps-gl Map', () => { properties: { id: 1 }, }, ]) + const canvas = { style: {} } + map.getMapGL().getCanvas = jest.fn(() => canvas) map.setMouseMoveEnabled(true) @@ -66,18 +68,273 @@ describe('DHIS2 Maps-gl Map', () => { ], }) - expect(setHoverStateSpy).toHaveBeenCalledWith([ - { id: 1, source: 'abc' }, - ]) + expect(mockLayer.onMouseMove).toHaveBeenCalled() + expect(canvas.style.cursor).toBe('pointer') }) - it('should not call setHoverState on mousemove when mousemove disabled', () => { + it('should not resolve a hovered feature on mousemove when mousemove disabled', () => { const map = new Map('el') - const setHoverStateSpy = jest.spyOn(map, 'setHoverState') + const mockLayer = { + isInteractive: () => true, + getInteractiveIds: () => ['layer-1'], + hasLayerId: id => id === 'layer-1', + getIndex: () => 0, + onMouseMove: jest.fn(), + fire: jest.fn(), + } + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + map.getMapGL().queryRenderedFeatures = jest.fn(() => [ + { + id: 1, + source: 'abc', + layer: { id: 'layer-1' }, + properties: { id: 1 }, + }, + ]) map.setMouseMoveEnabled(false) - map.onMouseMove({ features: [{ id: 1, source: 'abc' }] }) + map.onMouseMove({ point: {}, features: [{ id: 1, source: 'abc' }] }) + + expect(mockLayer.onMouseMove).not.toHaveBeenCalled() + }) + + describe('setSelectedState', () => { + it('sets and clears selected feature-state independently of setHoverState', () => { + const map = new Map('el') + const feature = { id: 1, source: 'abc' } + map.getMapGL().getSource = jest.fn(() => true) + + map.setSelectedState([feature]) + expect(map.getMapGL().setFeatureState).toHaveBeenCalledWith( + feature, + { selected: true } + ) + + map.setSelectedState(null) + expect(map.getMapGL().setFeatureState).toHaveBeenCalledWith( + feature, + { selected: false } + ) + }) + + it('is never touched by the mousemove-driven hover-outline effect', () => { + const map = new Map('el') + const setSelectedStateSpy = jest.spyOn(map, 'setSelectedState') + const mockLayer = { + isInteractive: () => true, + getInteractiveIds: () => ['layer-1'], + getFeaturesById: () => [{ id: 1, source: 'abc' }], + hasLayerId: id => id === 'layer-1', + getIndex: () => 0, + onMouseMove: jest.fn(), + fire: jest.fn(), + } + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + map.getMapGL().queryRenderedFeatures = jest.fn(() => [ + { + id: 1, + source: 'abc', + layer: { id: 'layer-1' }, + properties: { id: 1 }, + }, + ]) + map.setMouseMoveEnabled(true) + + map.onMouseMove({ point: {} }) + + expect(setSelectedStateSpy).not.toHaveBeenCalled() + }) + + it('writes the given color into feature-state alongside selected/hover', () => { + const map = new Map('el') + const feature = { id: 1, source: 'abc' } + map.getMapGL().getSource = jest.fn(() => true) + + map.setSelectedState([feature], '#FFC800') + expect(map.getMapGL().setFeatureState).toHaveBeenLastCalledWith( + feature, + { selected: true, highlightColor: '#FFC800' } + ) + + map.setHoverState([feature], '#FF0000') + expect(map.getMapGL().setFeatureState).toHaveBeenLastCalledWith( + feature, + { hover: true, highlightColor: '#FF0000' } + ) + }) + + it('re-applies the same features with a different color (color is part of the dedup key)', () => { + const map = new Map('el') + const feature = { id: 1, source: 'abc' } + map.getMapGL().getSource = jest.fn(() => true) + + map.setSelectedState([feature], '#FFC800') + map.getMapGL().setFeatureState.mockClear() + + map.setSelectedState([feature], '#FF0000') + expect(map.getMapGL().setFeatureState).toHaveBeenCalledWith( + feature, + { selected: true, highlightColor: '#FF0000' } + ) + }) + + it("a color-less call (native cursor tracking) doesn't erase a previously-set color, since setFeatureState merges", () => { + const map = new Map('el') + const feature = { id: 1, source: 'abc' } + map.getMapGL().getSource = jest.fn(() => true) + + map.setSelectedState([feature], '#FFC800') + map.getMapGL().setFeatureState.mockClear() - expect(setHoverStateSpy).not.toHaveBeenCalled() + // Same features, no color — e.g. the built-in per-pixel hover path + map.setHoverState([feature]) + expect(map.getMapGL().setFeatureState).toHaveBeenCalledWith( + feature, + { hover: true } + ) + }) + }) + + describe('click events', () => { + const setUpClickableLayer = (map, layerId, featureId) => { + const mockLayer = { + onClick: jest.fn(), + hasLayerId: id => id === layerId, + isInteractive: () => true, + getInteractiveIds: () => [layerId], + } + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + jest.spyOn(map, 'getLayerFromId').mockReturnValue(mockLayer) + map.getMapGL().project = jest.fn(() => ({ x: 1, y: 2 })) + map.getMapGL().queryRenderedFeatures = jest.fn(() => [ + { + id: featureId, + layer: { id: layerId }, + properties: { id: featureId }, + }, + ]) + return mockLayer + } + + it('passes ctrlKey/metaKey through to the click event', () => { + const map = new Map('el') + const mockLayer = setUpClickableLayer(map, 'layer-1', 1) + + map.onClick({ + lngLat: { lng: 1, lat: 2 }, + originalEvent: { ctrlKey: true, metaKey: false }, + }) + + expect(mockLayer.onClick).toHaveBeenCalledWith( + expect.objectContaining({ ctrlKey: true, metaKey: false }) + ) + }) + + it('defaults ctrlKey/metaKey to false when there is no original DOM event', () => { + const map = new Map('el') + const mockLayer = setUpClickableLayer(map, 'layer-1', 1) + + map.onClick({ lngLat: { lng: 1, lat: 2 } }) + + expect(mockLayer.onClick).toHaveBeenCalledWith( + expect.objectContaining({ ctrlKey: false, metaKey: false }) + ) + }) + }) + + describe('mouseenter/mouseleave', () => { + const createMockLayer = (layerId, featureId) => ({ + isInteractive: () => true, + getInteractiveIds: () => [layerId], + getFeaturesById: () => [{ id: featureId, source: 'abc' }], + hasLayerId: id => id === layerId, + getIndex: () => 0, + onMouseMove: jest.fn(), + fire: jest.fn(), + }) + + const moveTo = (map, layerId, featureId) => { + map.getMapGL().queryRenderedFeatures = jest.fn(() => [ + { + id: featureId, + source: 'abc', + layer: { id: layerId }, + properties: { id: featureId }, + }, + ]) + map.onMouseMove({ point: {} }) + } + + it('fires mouseenter once when hovering onto a feature, not on every tick', () => { + const map = new Map('el') + const mockLayer = createMockLayer('layer-1', 1) + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + map.setMouseMoveEnabled(true) + + moveTo(map, 'layer-1', 1) + moveTo(map, 'layer-1', 1) + + expect(mockLayer.fire).toHaveBeenCalledTimes(1) + expect(mockLayer.fire).toHaveBeenCalledWith( + 'mouseenter', + expect.objectContaining({ feature: expect.anything() }) + ) + }) + + it('fires mouseenter for a feature whose id is 0, not treating it as "no feature"', () => { + const map = new Map('el') + const mockLayer = createMockLayer('layer-1', 0) + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + map.setMouseMoveEnabled(true) + + moveTo(map, 'layer-1', 0) + + expect(mockLayer.fire).toHaveBeenCalledWith( + 'mouseenter', + expect.objectContaining({ feature: expect.anything() }) + ) + }) + + it('fires mouseleave on the old target and mouseenter on the new one when the hovered feature changes', () => { + const map = new Map('el') + const layerA = createMockLayer('layer-a', 1) + const layerB = createMockLayer('layer-b', 2) + map._layers = [layerA, layerB] + jest.spyOn(map, 'getLayers').mockReturnValue([layerA, layerB]) + map.setMouseMoveEnabled(true) + + moveTo(map, 'layer-a', 1) + layerA.fire.mockClear() + + moveTo(map, 'layer-b', 2) + + expect(layerA.fire).toHaveBeenCalledWith('mouseleave') + expect(layerB.fire).toHaveBeenCalledWith( + 'mouseenter', + expect.objectContaining({ feature: expect.anything() }) + ) + }) + + it('fires mouseleave on mouseout and resets tracked hover state', () => { + const map = new Map('el') + const mockLayer = createMockLayer('layer-1', 1) + map._layers = [mockLayer] + jest.spyOn(map, 'getLayers').mockReturnValue([mockLayer]) + map.setMouseMoveEnabled(true) + + moveTo(map, 'layer-1', 1) + mockLayer.fire.mockClear() + + map.onMouseOut({}) + + expect(mockLayer.fire).toHaveBeenCalledWith('mouseleave') + expect(map._hoveredLayer).toBeNull() + expect(map._hoveredFeatureId).toBeNull() + }) }) }) diff --git a/src/layers/Boundary.js b/src/layers/Boundary.js index f99661f7..7d54f837 100644 --- a/src/layers/Boundary.js +++ b/src/layers/Boundary.js @@ -1,3 +1,5 @@ +import { highlightColorExpr } from '../utils/expressions.js' +import { isHover, isSelected } from '../utils/filters.js' import { labelLayer } from '../utils/labels.js' import Layer from './Layer.js' @@ -37,10 +39,10 @@ class Boundary extends Layer { type: 'line', source: id, paint: { - 'line-color': ['get', 'color'], + 'line-color': highlightColorExpr(['get', 'color']), 'line-width': [ 'case', - ['boolean', ['feature-state', 'hover'], false], + ['any', isHover, isSelected], ['+', ['get', 'width'], 2], ['get', 'width'], ], @@ -59,10 +61,10 @@ class Boundary extends Layer { paint: { 'circle-color': 'transparent', 'circle-radius': ['get', 'radius'], - 'circle-stroke-color': ['get', 'color'], + 'circle-stroke-color': highlightColorExpr(['get', 'color']), 'circle-stroke-width': [ 'case', - ['boolean', ['feature-state', 'hover'], false], + ['any', isHover, isSelected], ['+', ['get', 'width'], 2], ['get', 'width'], ], diff --git a/src/layers/Cluster.js b/src/layers/Cluster.js index 6aef952f..24a64f4a 100644 --- a/src/layers/Cluster.js +++ b/src/layers/Cluster.js @@ -1,4 +1,5 @@ import centerOfMass from '@turf/center-of-mass' +import { dropHiddenIds } from '../utils/core.js' import { isClusterPoint } from '../utils/filters.js' import { featureCollection } from '../utils/geometry.js' import { labelClusterLayer } from '../utils/labels.js' @@ -30,6 +31,7 @@ class Cluster extends Layer { this._polygonsOnMap = [] // Translate from polygon to point before clustering + // This means highlight()/select() on a polygon feature here has no visible effect this._features = this._features.map(f => { if (f.geometry.type === 'Polygon') { this._polygons[f.id] = f @@ -245,6 +247,36 @@ class Cluster extends Layer { return this.getMapGL().querySourceFeatures(this.getId()) } + // Cluster circles have no per-feature id to filter on, so we re-send the + // filtered features as source data instead, letting clustering recompute + setVisibleIds(ids) { + const mapgl = this.getMapGL() + const source = mapgl?.getSource(this.getId()) + + if (!source) { + return + } + + const features = ids + ? this.getFeatures().filter(f => ids.includes(f.properties.id)) + : this.getFeatures() + + source.setData(featureCollection(features)) + + if (this._hasPolygons) { + mapgl.off('idle', this.updatePolygons) + mapgl.once('idle', this.updatePolygons) + } + + const dropped = dropHiddenIds(this._hoverIds, this._selectedIds, ids) + + if (dropped) { + this._hoverIds = dropped.hoverIds + this._selectedIds = dropped.selectedIds + this._syncOverlay() + } + } + onSpiderClose = clusterId => { this.setClusterOpacity(clusterId) } @@ -275,6 +307,7 @@ class Cluster extends Layer { onRemove() { this.unspiderfy() this.spider = null + this.getMapGL()?.off('idle', this.updatePolygons) } } diff --git a/src/layers/Layer.js b/src/layers/Layer.js index 51e40a81..5008d02d 100644 --- a/src/layers/Layer.js +++ b/src/layers/Layer.js @@ -2,11 +2,27 @@ import bbox from '@turf/bbox' import { Evented } from 'maplibre-gl' import { v4 as uuidv4 } from 'uuid' import { bufferSource } from '../utils/buffers.js' +import { dropHiddenIds, normalizeIds } from '../utils/core.js' import { featureCollection } from '../utils/geometry.js' +import { + createHighlightOverlay, + updateHighlightOverlay, + removeHighlightOverlay, +} from '../utils/highlightOverlay.js' import { addImages } from '../utils/images.js' import { labelSource } from '../utils/labels.js' import { setLayersOpacity, clearLayerOpacityCache } from '../utils/opacity.js' +const buildVisibleIdsFilter = (ids, baseFilter) => { + if (!ids) { + return baseFilter ?? null + } + + const idsFilter = ['in', ['get', 'id'], ['literal', ids]] + + return baseFilter ? ['all', baseFilter, idsFilter] : idsFilter +} + class Layer extends Evented { constructor(options = {}) { super() @@ -17,6 +33,10 @@ class Layer extends Evented { this._features = [] this._isVisible = true this._interactiveIds = [] + this._overlayLayerIds = [] + this._hoverIds = [] + this._selectedIds = [] + this._highlightColor = undefined this.options = options @@ -26,7 +46,8 @@ class Layer extends Evented { } async addTo(map) { - const { opacity, onClick, onRightClick } = this.options + const { opacity, onClick, onRightClick, onMouseEnter, onMouseLeave } = + this.options this._map = map @@ -58,6 +79,19 @@ class Layer extends Evented { } }) + if (map.styleIsLoaded()) { + this._overlayLayerIds = createHighlightOverlay(map, { + id: this.getId(), + glLayers: layers, + beforeId, + }) + + // Replays a highlight/selection recorded before the overlay existed + if (this._hoverIds.length || this._selectedIds.length) { + this._syncOverlay() + } + } + if (!this.isVisible()) { this.setVisibility(false) } @@ -74,6 +108,14 @@ class Layer extends Evented { this.on('contextmenu', onRightClick) } + if (onMouseEnter) { + this.on('mouseenter', onMouseEnter) + } + + if (onMouseLeave) { + this.on('mouseleave', onMouseLeave) + } + this.onAdd() } @@ -81,7 +123,8 @@ class Layer extends Evented { const mapgl = map.getMapGL() const source = this.getSource() const layers = this.getLayers() - const { onClick, onRightClick } = this.options + const { onClick, onRightClick, onMouseEnter, onMouseLeave } = + this.options this.onRemove() @@ -99,6 +142,9 @@ class Layer extends Evented { mapgl.removeSource(id) } }) + + removeHighlightOverlay(map, this.getId(), this._overlayLayerIds) + this._overlayLayerIds = [] } if (onClick) { @@ -109,6 +155,14 @@ class Layer extends Evented { this.off('contextmenu', onRightClick) } + if (onMouseEnter) { + this.off('mouseenter', onMouseEnter) + } + + if (onMouseLeave) { + this.off('mouseleave', onMouseLeave) + } + this._map = null } @@ -144,7 +198,16 @@ class Layer extends Evented { layers.forEach(layer => mapgl.setLayoutProperty(layer.id, 'visibility', value) ) + + // Hide the overlay's cloned layers too + this._overlayLayerIds.forEach(layerId => + mapgl.setLayoutProperty(layerId, 'visibility', value) + ) } + + // isInteractive() depends on isVisible(), so a visibility change + // outside addLayer/removeLayer must invalidate the cache too + this.getMap()?.invalidateInteractiveLayerIds?.() } this._isVisible = isVisible @@ -224,6 +287,12 @@ class Layer extends Evented { this.getLayers().forEach(layer => { mapgl.moveLayer(layer.id, beforeId) }) + + // The highlight overlay must stay drawn above these base layers + // Having just moved the base layers, move the overlay too + this._overlayLayerIds.forEach(layerId => { + mapgl.moveLayer(layerId, beforeId) + }) } getFeatures() { @@ -335,13 +404,69 @@ class Layer extends Evented { return mapgl.getZoom() >= mapgl.getMaxZoom() } - // Highlight a layer feature - highlight(id) { + // Hover and selection share one highlight color (last caller wins) + + highlight(ids, color) { const map = this.getMap() - if (map) { - map.setHoverState(id ? this.getFeaturesById(id) : null) + if (!map) { + return } + + this._hoverIds = normalizeIds(ids) + this._highlightColor = color + this._syncOverlay() + } + + select(ids, color) { + const map = this.getMap() + + if (!map) { + return + } + + this._selectedIds = normalizeIds(ids) + this._highlightColor = color + this._syncOverlay() + } + + // `ids` of null/undefined restores each layer's own filter unchanged + setVisibleIds(ids) { + const mapgl = this.getMapGL() + + if (!mapgl) { + return + } + + this.getLayers().forEach(({ id, filter: baseFilter }) => { + mapgl.setFilter(id, buildVisibleIdsFilter(ids, baseFilter)) + }) + + const dropped = dropHiddenIds(this._hoverIds, this._selectedIds, ids) + + if (dropped) { + this._hoverIds = dropped.hoverIds + this._selectedIds = dropped.selectedIds + this._syncOverlay() + } + } + + // Syncs the highlight overlay with the union of hovered/selected ids + _syncOverlay() { + const map = this.getMap() + + if (!map) { + return + } + + const ids = [...new Set([...this._hoverIds, ...this._selectedIds])] + const features = ids.flatMap(id => this.getFeaturesById(id)) + + updateHighlightOverlay(map, { + id: this.getId(), + features, + color: this._highlightColor, + }) } // Override if needed in subclass diff --git a/src/layers/LayerGroup.js b/src/layers/LayerGroup.js index 90b39d90..1221a486 100644 --- a/src/layers/LayerGroup.js +++ b/src/layers/LayerGroup.js @@ -1,14 +1,21 @@ import { Evented } from 'maplibre-gl' +import { v4 as uuidv4 } from 'uuid' +import { dropHiddenIds, normalizeIds } from '../utils/core.js' import { getBoundsFromLayers } from '../utils/geometry.js' +import { updateHighlightOverlay } from '../utils/highlightOverlay.js' class LayerGroup extends Evented { constructor(options) { super() + this._id = uuidv4() this.options = options || {} this._layers = [] this._layerConfigs = [] this._isVisible = true + this._hoverIds = [] + this._selectedIds = [] + this._highlightColor = undefined } createLayer() { @@ -21,16 +28,21 @@ class LayerGroup extends Evented { } } - addTo(map) { + async addTo(map) { this._map = map if (!this._layers.length) { this.createLayer() } - this._layers.forEach(layer => layer.addTo(map)) - this.on('contextmenu', this.onContextMenu) + + // Each sub-layer's overlay only exists once its own addTo() resolves + await Promise.all(this._layers.map(layer => layer.addTo(map))) + + if (this._hoverIds.length || this._selectedIds.length) { + this._syncOverlay() + } } removeFrom(map) { @@ -38,6 +50,10 @@ class LayerGroup extends Evented { this.off('contextmenu', this.onContextMenu) } + getId() { + return this._id + } + addLayer(config) { this._layerConfigs.push(config) } @@ -73,6 +89,70 @@ class LayerGroup extends Evented { return this._layers.map(layer => layer.getFeaturesById(id)).flat() } + getMap() { + return this._map + } + + getSubLayerFromId(id) { + return this._layers.find(layer => layer.hasLayerId(id)) + } + + highlight(ids, color) { + const map = this.getMap() + + if (!map) { + return + } + + this._hoverIds = normalizeIds(ids) + this._highlightColor = color + this._syncOverlay() + } + + select(ids, color) { + const map = this.getMap() + + if (!map) { + return + } + + this._selectedIds = normalizeIds(ids) + this._highlightColor = color + this._syncOverlay() + } + + setVisibleIds(ids) { + this._layers.forEach(layer => layer.setVisibleIds(ids)) + + const dropped = dropHiddenIds(this._hoverIds, this._selectedIds, ids) + + if (dropped) { + this._hoverIds = dropped.hoverIds + this._selectedIds = dropped.selectedIds + this._syncOverlay() + } + } + + // Drives each sub-layer's own overlay directly + _syncOverlay() { + const map = this.getMap() + + if (!map) { + return + } + + const ids = [...new Set([...this._hoverIds, ...this._selectedIds])] + + this._layers.forEach(layer => { + const features = ids.flatMap(id => layer.getFeaturesById(id)) + updateHighlightOverlay(map, { + id: layer.getId(), + features, + color: this._highlightColor, + }) + }) + } + setOpacity(opacity) { this._layers.forEach(layer => layer.setOpacity(opacity)) } @@ -100,8 +180,7 @@ class LayerGroup extends Evented { const { feature } = evt if (feature) { - const { id } = feature.layer - const layer = this._layers.find(l => l.hasLayerId(id)) + const layer = this.getSubLayerFromId(feature.layer.id) if (layer) { layer.fire('click', evt) @@ -113,8 +192,7 @@ class LayerGroup extends Evented { const { feature } = evt if (feature) { - const { id } = feature.layer - const layer = this._layers.find(l => l.hasLayerId(id)) + const layer = this.getSubLayerFromId(feature.layer.id) if (layer) { layer.fire('contextmenu', evt) @@ -124,8 +202,7 @@ class LayerGroup extends Evented { onMouseMove = (evt, feature) => { if (feature) { - const { id } = feature.layer - const layer = this._layers.find(l => l.hasLayerId(id)) + const layer = this.getSubLayerFromId(feature.layer.id) if (layer) { layer.onMouseMove(evt, feature) diff --git a/src/layers/__tests__/Cluster.spec.js b/src/layers/__tests__/Cluster.spec.js index d1ea7442..a05019ee 100644 --- a/src/layers/__tests__/Cluster.spec.js +++ b/src/layers/__tests__/Cluster.spec.js @@ -1,9 +1,26 @@ +/* global mockMap, mockMapGL */ import { isClusterPoint } from '../../utils/filters.js' +import { updateHighlightOverlay } from '../../utils/highlightOverlay.js' import Cluster from '../Cluster.js' +jest.mock('../../utils/highlightOverlay.js') + const findLabelLayer = cluster => cluster.getLayers().find(layer => layer.id === `${cluster.getId()}-label`) +const data = [ + { + type: 'Feature', + properties: { id: 'a' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + type: 'Feature', + properties: { id: 'b' }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, +] + describe('Cluster', () => { it('Should not add a label layer when label is not set', () => { const cluster = new Cluster({}) @@ -49,4 +66,180 @@ describe('Cluster', () => { // not the one smuggled in via labelStyle (999) expect(layer.layout['text-offset'][1]).toBeCloseTo(5 / 12 + 0.4) }) + + describe('setVisibleIds', () => { + beforeEach(() => { + jest.resetAllMocks() + // addTo() below triggers onAdd(), which always calls setOpacity(); + // that reads the map style, so it must be stubbed even though this + // test isn't about opacity. + mockMapGL.getStyle.mockReturnValue({ layers: [] }) + }) + + it('Should re-send only the matching features as the source data, so clustering recomputes from just the visible subset', () => { + const cluster = new Cluster({ data }) + const source = { setData: jest.fn() } + mockMapGL.getSource.mockReturnValue(source) + + cluster.addTo(mockMap) + cluster.setVisibleIds(['b']) + + expect(source.setData).toHaveBeenCalledWith({ + type: 'FeatureCollection', + features: [ + expect.objectContaining({ properties: { id: 'b' } }), + ], + }) + }) + + it('Should restore the full feature set when ids is cleared', () => { + const cluster = new Cluster({ data }) + const source = { setData: jest.fn() } + mockMapGL.getSource.mockReturnValue(source) + + cluster.addTo(mockMap) + cluster.setVisibleIds(null) + + expect(source.setData).toHaveBeenCalledWith({ + type: 'FeatureCollection', + features: cluster.getFeatures(), + }) + }) + + it('Should no-op when the source is not available', () => { + const cluster = new Cluster({ data }) + mockMapGL.getSource.mockReturnValue(undefined) + + cluster.addTo(mockMap) + expect(() => cluster.setVisibleIds(['b'])).not.toThrow() + }) + + it('Should refresh the separate polygon source once the map settles, when the cluster has polygon features', () => { + const polygonData = [ + { + type: 'Feature', + properties: { id: 'p' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [0, 0], + ], + ], + }, + }, + ] + const cluster = new Cluster({ data: polygonData }) + const source = { setData: jest.fn() } + mockMapGL.getSource.mockReturnValue(source) + cluster.updatePolygons = jest.fn() + + cluster.addTo(mockMap) + cluster.setVisibleIds(['p']) + + expect(mockMapGL.once).toHaveBeenCalledWith( + 'idle', + cluster.updatePolygons + ) + }) + + it('Should not register an idle listener when the cluster has no polygon features', () => { + const cluster = new Cluster({ data }) + const source = { setData: jest.fn() } + mockMapGL.getSource.mockReturnValue(source) + + cluster.addTo(mockMap) + cluster.setVisibleIds(['b']) + + expect(mockMapGL.once).not.toHaveBeenCalled() + }) + + it('Should replace, not stack, the pending idle listener on repeated calls', () => { + const polygonData = [ + { + type: 'Feature', + properties: { id: 'p' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [0, 0], + ], + ], + }, + }, + ] + const cluster = new Cluster({ data: polygonData }) + mockMapGL.getSource.mockReturnValue({ setData: jest.fn() }) + + cluster.addTo(mockMap) + cluster.setVisibleIds(['p']) + cluster.setVisibleIds(['p']) + + expect(mockMapGL.off).toHaveBeenCalledWith( + 'idle', + cluster.updatePolygons + ) + expect(mockMapGL.once).toHaveBeenCalledTimes(2) + }) + + it('Should drop a hovered/selected id from the overlay once setVisibleIds() filters it out', () => { + const cluster = new Cluster({ data }) + mockMapGL.getSource.mockReturnValue({ setData: jest.fn() }) + + cluster.addTo(mockMap) + cluster.highlight(['a', 'b']) + updateHighlightOverlay.mockClear() + + cluster.setVisibleIds(['b']) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect( + updateHighlightOverlay.mock.lastCall[1].features + ).toMatchObject([{ properties: { id: 'b' } }]) + }) + }) + + describe('onRemove', () => { + it('Should drop the pending idle listener, so it cannot fire after removal', () => { + const cluster = new Cluster({ + data: [ + { + type: 'Feature', + properties: { id: 'p' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [0, 0], + ], + ], + }, + }, + ], + }) + mockMapGL.getStyle.mockReturnValue({ layers: [] }) + mockMapGL.getSource.mockReturnValue({ setData: jest.fn() }) + + cluster.addTo(mockMap) + cluster.setVisibleIds(['p']) + mockMapGL.off.mockClear() + + cluster.removeFrom(mockMap) + + expect(mockMapGL.off).toHaveBeenCalledWith( + 'idle', + cluster.updatePolygons + ) + }) + }) }) diff --git a/src/layers/__tests__/Layer.spec.js b/src/layers/__tests__/Layer.spec.js index 52e86e2e..7ee53ba2 100644 --- a/src/layers/__tests__/Layer.spec.js +++ b/src/layers/__tests__/Layer.spec.js @@ -1,6 +1,9 @@ /* global mockMap, mockMapGL */ +import { updateHighlightOverlay } from '../../utils/highlightOverlay.js' import Layer from '../Layer.js' +jest.mock('../../utils/highlightOverlay.js') + const data = [ { type: 'Feature', @@ -45,6 +48,27 @@ describe('Layer', () => { expect(layer.getMapGL()).toBe(mockMapGL) mockMapGL.getLayer.mockReturnValueOnce(true) }) + it('Should re-move the highlight overlay after its base layers when moved, so it stays on top', () => { + const layer = new Layer() + layer.addLayer({ id: 'layer-1' }) + layer.addTo(mockMap) + layer._overlayLayerIds = ['layer-1-highlight'] + mockMap.getBeforeLayerId.mockReturnValue('before-id') + mockMapGL.moveLayer.mockClear() + + layer.move() + + expect(mockMapGL.moveLayer).toHaveBeenCalledWith('layer-1', 'before-id') + expect(mockMapGL.moveLayer).toHaveBeenCalledWith( + 'layer-1-highlight', + 'before-id' + ) + // Overlay must be moved after the base layer + const movedIds = mockMapGL.moveLayer.mock.calls.map(call => call[0]) + expect(movedIds.indexOf('layer-1-highlight')).toBeGreaterThan( + movedIds.indexOf('layer-1') + ) + }) it('Should add a non-interactive layer', () => { const layer = new Layer() const mockMapLayer = { id: 42 } @@ -82,30 +106,256 @@ describe('Layer', () => { ) expect(layer.getFeaturesById(3)).toStrictEqual([]) }) - it('Should set feature hover state', () => { + it('Should update the highlight overlay on highlight(), not the base source', () => { const layer = new Layer({ data }) - const mockFn = mockMap.setHoverState const source = layer.getId() layer.addTo(mockMap) + updateHighlightOverlay.mockClear() // addTo() flushes the (empty) overlay layer.highlight('fdc6uOvgoji') - expect(mockFn).toHaveBeenCalled() - expect(mockFn.mock.calls[0][0]).toMatchObject([ - { - id: 2, - source, - }, + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect(updateHighlightOverlay.mock.calls[0][1].features).toMatchObject([ + { id: 2, source }, ]) + layer.highlight(1) - expect(mockFn).toHaveBeenCalledTimes(2) - expect(mockFn.mock.calls[1][0]).toMatchObject([ - { - id: 1, - source, - }, + expect(updateHighlightOverlay).toHaveBeenCalledTimes(2) + expect(updateHighlightOverlay.mock.calls[1][1].features).toMatchObject([ + { id: 1, source }, ]) + layer.highlight('abc') - expect(mockFn).toHaveBeenCalledTimes(3) - expect(mockFn).toHaveBeenLastCalledWith([]) + expect(updateHighlightOverlay).toHaveBeenCalledTimes(3) + expect(updateHighlightOverlay.mock.lastCall[1].features).toEqual([]) + + // The overlay is the sole renderer of the highlighted look + expect(mockMap.setHoverState).not.toHaveBeenCalled() + expect(mockMap.setSelectedState).not.toHaveBeenCalled() + }) + it('Should update the highlight overlay for multiple ids at once', () => { + const layer = new Layer({ data }) + const source = layer.getId() + + layer.addTo(mockMap) + updateHighlightOverlay.mockClear() // addTo() flushes the (empty) overlay + layer.highlight(['O6uvpzGd5pu', 'fdc6uOvgoji']) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect(updateHighlightOverlay.mock.calls[0][1].features).toMatchObject([ + { id: 1, source }, + { id: 2, source }, + ]) + }) + it('Should clear the highlight overlay for an empty array or null', () => { + const layer = new Layer({ data }) + + layer.addTo(mockMap) + layer.highlight([]) + expect(updateHighlightOverlay.mock.lastCall[1].features).toEqual([]) + + layer.highlight(null) + expect(updateHighlightOverlay.mock.lastCall[1].features).toEqual([]) + }) + it('Should update the highlight overlay with selected ids, keyed independently from hover', () => { + const layer = new Layer({ data }) + const source = layer.getId() + + layer.addTo(mockMap) + updateHighlightOverlay.mockClear() // addTo() flushes the (empty) overlay + layer.select(['O6uvpzGd5pu', 'fdc6uOvgoji']) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect(updateHighlightOverlay.mock.calls[0][1].features).toMatchObject([ + { id: 1, source }, + { id: 2, source }, + ]) + expect(mockMap.setHoverState).not.toHaveBeenCalled() + expect(mockMap.setSelectedState).not.toHaveBeenCalled() + }) + it('Should clear selected ids from the overlay for an empty array or null', () => { + const layer = new Layer({ data }) + + layer.addTo(mockMap) + layer.select([]) + expect(updateHighlightOverlay.mock.lastCall[1].features).toEqual([]) + + layer.select(null) + expect(updateHighlightOverlay.mock.lastCall[1].features).toEqual([]) + }) + it('Should forward a caller-chosen color to highlight()/select()', () => { + const layer = new Layer({ data }) + + layer.addTo(mockMap) + layer.highlight('fdc6uOvgoji', '#FFC800') + expect(updateHighlightOverlay.mock.lastCall[1].color).toBe('#FFC800') + + layer.select(['O6uvpzGd5pu'], '#FF0000') + expect(updateHighlightOverlay.mock.lastCall[1].color).toBe('#FF0000') + }) + it('Should combine hover and a persistent selection into one overlay update instead of one clobbering the other', () => { + const layer = new Layer({ data }) + const source = layer.getId() + + layer.addTo(mockMap) + layer.select(['O6uvpzGd5pu']) + layer.highlight('fdc6uOvgoji') + + // The later highlight() call's overlay update includes both the + // new hover id and the earlier selection, not just the hover id + expect(updateHighlightOverlay.mock.lastCall[1].features).toMatchObject([ + { id: 2, source }, + { id: 1, source }, + ]) + }) + it('Should let the last highlight()/select() color win for the whole overlay, by design', () => { + const layer = new Layer({ data }) + + layer.addTo(mockMap) + layer.select(['O6uvpzGd5pu'], 'red') + layer.highlight('fdc6uOvgoji', 'blue') + + // Hover and selection share one highlight color by design + // The later call's color applies to the whole overlay, including the earlier selection + expect(updateHighlightOverlay.mock.lastCall[1].color).toBe('blue') + }) + it('Should replay a pending highlight into the overlay once it is (re-)created, e.g. after a style reload', () => { + const layer = new Layer({ data }) + const source = layer.getId() + + layer.addTo(mockMap) + layer.highlight('fdc6uOvgoji', '#FFC800') + updateHighlightOverlay.mockClear() + + // Simulate the overlay being torn down and recreated + // The previously recorded hover id/color must be flushed into the freshly created overlay + layer.addTo(mockMap) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect(updateHighlightOverlay.mock.calls[0][1]).toMatchObject({ + features: [{ id: 2, source }], + color: '#FFC800', + }) + }) + it('Should drop a hovered/selected id from the overlay once setVisibleIds() filters it out', () => { + const layer = new Layer({ data }) + const source = layer.getId() + + layer.addTo(mockMap) + layer.highlight(['O6uvpzGd5pu', 'fdc6uOvgoji']) + updateHighlightOverlay.mockClear() + + layer.setVisibleIds(['fdc6uOvgoji']) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(1) + expect(updateHighlightOverlay.mock.lastCall[1].features).toMatchObject([ + { id: 2, source }, + ]) + }) + it('Should leave the overlay untouched when setVisibleIds() clears the filter (nothing to drop)', () => { + const layer = new Layer({ data }) + + layer.addTo(mockMap) + layer.highlight(['O6uvpzGd5pu']) + updateHighlightOverlay.mockClear() + + layer.setVisibleIds(null) + + expect(updateHighlightOverlay).not.toHaveBeenCalled() + }) + it('Should restrict rendered features to the given ids, ANDed with any existing static filter', () => { + const layer = new Layer({ data }) + layer.addLayer({ id: 'layer-1', filter: ['==', '$type', 'Polygon'] }) + layer.addLayer({ id: 'layer-1-points' }) + + layer.addTo(mockMap) + layer.setVisibleIds(['O6uvpzGd5pu', 'fdc6uOvgoji']) + + expect(mockMapGL.setFilter).toHaveBeenCalledWith('layer-1', [ + 'all', + ['==', '$type', 'Polygon'], + ['in', ['get', 'id'], ['literal', ['O6uvpzGd5pu', 'fdc6uOvgoji']]], + ]) + expect(mockMapGL.setFilter).toHaveBeenCalledWith('layer-1-points', [ + 'in', + ['get', 'id'], + ['literal', ['O6uvpzGd5pu', 'fdc6uOvgoji']], + ]) + }) + it('Should restore the original static filter when shown ids is cleared', () => { + const layer = new Layer({ data }) + layer.addLayer({ id: 'layer-1', filter: ['==', '$type', 'Polygon'] }) + layer.addLayer({ id: 'layer-1-points' }) + + layer.addTo(mockMap) + layer.setVisibleIds(null) + + expect(mockMapGL.setFilter).toHaveBeenCalledWith('layer-1', [ + '==', + '$type', + 'Polygon', + ]) + expect(mockMapGL.setFilter).toHaveBeenCalledWith('layer-1-points', null) + }) + it('Should invalidate the map interactive-layer cache when visibility changes, so a re-shown layer stays clickable/hoverable', () => { + const layer = new Layer() + layer.addLayer({ id: 'layer-1' }) + layer.addTo(mockMap) + // createHighlightOverlay is mocked here and returns undefined by default + layer._overlayLayerIds = [] + mockMapGL.getLayer.mockReturnValue(true) + + layer.setVisibility(false) + expect(mockMap.invalidateInteractiveLayerIds).toHaveBeenCalledTimes(1) + + layer.setVisibility(true) + expect(mockMap.invalidateInteractiveLayerIds).toHaveBeenCalledTimes(2) + }) + it('Should hide/show the highlight overlay along with its base layer, so a hidden layer has no leftover glow', () => { + const layer = new Layer() + layer.addLayer({ id: 'layer-1' }) + layer.addTo(mockMap) + layer._overlayLayerIds = ['layer-1-highlight'] + mockMapGL.getLayer.mockReturnValue(true) + mockMapGL.setLayoutProperty.mockClear() + + layer.setVisibility(false) + + expect(mockMapGL.setLayoutProperty).toHaveBeenCalledWith( + 'layer-1', + 'visibility', + 'none' + ) + expect(mockMapGL.setLayoutProperty).toHaveBeenCalledWith( + 'layer-1-highlight', + 'visibility', + 'none' + ) + }) + it('Should wire and unwire onMouseEnter/onMouseLeave callbacks', () => { + const onMouseEnter = jest.fn() + const onMouseLeave = jest.fn() + const layer = new Layer({ onMouseEnter, onMouseLeave }) + + layer.addTo(mockMap) + layer.fire('mouseenter') + layer.fire('mouseleave') + expect(onMouseEnter).toHaveBeenCalledTimes(1) + expect(onMouseLeave).toHaveBeenCalledTimes(1) + + layer.removeFrom(mockMap) + layer.fire('mouseenter') + layer.fire('mouseleave') + expect(onMouseEnter).toHaveBeenCalledTimes(1) + expect(onMouseLeave).toHaveBeenCalledTimes(1) + }) + it('Should reset _overlayLayerIds on removal, so a stale id is never moved/toggled after re-adding', () => { + const layer = new Layer() + layer.addLayer({ id: 'layer-1' }) + layer.addTo(mockMap) + layer._overlayLayerIds = ['layer-1-highlight'] + + layer.removeFrom(mockMap) + + expect(layer._overlayLayerIds).toEqual([]) }) }) diff --git a/src/layers/__tests__/LayerGroup.spec.js b/src/layers/__tests__/LayerGroup.spec.js new file mode 100644 index 00000000..7af9a893 --- /dev/null +++ b/src/layers/__tests__/LayerGroup.spec.js @@ -0,0 +1,227 @@ +/* global mockMap, mockMapGL */ +import { updateHighlightOverlay } from '../../utils/highlightOverlay.js' +import LayerGroup from '../LayerGroup.js' + +jest.mock('../../utils/highlightOverlay.js') + +const createMockSubLayer = (id, featureId) => ({ + id, + getId: () => id, + hasLayerId: layerId => layerId === id, + getLayers: () => [{ id }], + getFeaturesById: fid => + fid === featureId ? [{ id: featureId, source: id }] : [], + addTo: jest.fn(), + removeFrom: jest.fn(), + setIndex: jest.fn(), + setOpacity: jest.fn(), + setVisibility: jest.fn(), + isOnMap: () => true, + isInteractive: () => true, + getInteractiveIds: () => [id], + move: jest.fn(), + onMouseMove: jest.fn(), + fire: jest.fn(), + setVisibleIds: jest.fn(), +}) + +describe('LayerGroup', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('should resolve a sub-layer by its GL layer id', () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 2) + group._layers = [layerA, layerB] + + expect(group.getSubLayerFromId('layer-b')).toBe(layerB) + expect(group.getSubLayerFromId('missing')).toBeUndefined() + }) + + it("should update each sub-layer's own highlight overlay directly, not a second group-level one", () => { + // A group-level overlay would derive the same layer ids as each + // sub-layer's own overlay (added in their addTo()), colliding. + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 2) + group._layers = [layerA, layerB] + group._map = mockMap + + group.highlight([1, 2]) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(2) + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-a', + features: [{ id: 1, source: 'layer-a' }], + color: undefined, + }) + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-b', + features: [{ id: 2, source: 'layer-b' }], + color: undefined, + }) + expect(mockMap.setHoverState).not.toHaveBeenCalled() + expect(mockMap.setSelectedState).not.toHaveBeenCalled() + }) + + it("should clear each sub-layer's overlay for an empty array or null", () => { + const group = new LayerGroup() + group._layers = [createMockSubLayer('layer-a', 1)] + group._map = mockMap + + group.highlight([]) + expect(updateHighlightOverlay).toHaveBeenLastCalledWith(mockMap, { + id: 'layer-a', + features: [], + color: undefined, + }) + + group.highlight(null) + expect(updateHighlightOverlay).toHaveBeenLastCalledWith(mockMap, { + id: 'layer-a', + features: [], + color: undefined, + }) + }) + + it('should no-op when not yet added to a map', () => { + const group = new LayerGroup() + group._layers = [createMockSubLayer('layer-a', 1)] + + expect(() => group.highlight([1])).not.toThrow() + expect(mockMapGL.setFeatureState).not.toHaveBeenCalled() + expect(updateHighlightOverlay).not.toHaveBeenCalled() + }) + + it("should update each sub-layer's own overlay with selected ids", () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 2) + group._layers = [layerA, layerB] + group._map = mockMap + + group.select([1, 2]) + + expect(updateHighlightOverlay).toHaveBeenCalledTimes(2) + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-a', + features: [{ id: 1, source: 'layer-a' }], + color: undefined, + }) + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-b', + features: [{ id: 2, source: 'layer-b' }], + color: undefined, + }) + expect(mockMap.setHoverState).not.toHaveBeenCalled() + expect(mockMap.setSelectedState).not.toHaveBeenCalled() + }) + + it('should not conflate two sub-layers whose features happen to share the same numeric id', () => { + // Each sub-layer independently auto-assigns ids starting at 1, so + // id collisions across sub-layers are expected — scoping the lookup + // to each sub-layer (rather than the group's aggregated + // getFeaturesById) keeps them from bleeding into each other. + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 1) + group._layers = [layerA, layerB] + group._map = mockMap + + group.highlight([1]) + + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-a', + features: [{ id: 1, source: 'layer-a' }], + color: undefined, + }) + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-b', + features: [{ id: 1, source: 'layer-b' }], + color: undefined, + }) + }) + + it("should replay a pending highlight into each sub-layer's overlay once addTo() resolves, e.g. after a style reload", async () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + group._layers = [layerA] + group._map = mockMap + + group.highlight([1], '#FFC800') + updateHighlightOverlay.mockClear() + + // Simulate the group being re-added + // The previously recorded hover id/color must be flushed into each sub-layer's freshly (re)created overlay + await group.addTo(mockMap) + + expect(updateHighlightOverlay).toHaveBeenCalledWith(mockMap, { + id: 'layer-a', + features: [{ id: 1, source: 'layer-a' }], + color: '#FFC800', + }) + }) + + it("should drop a hovered/selected id from each sub-layer's overlay once setVisibleIds() filters it out", () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + group._layers = [layerA] + group._map = mockMap + + group.highlight([1]) + updateHighlightOverlay.mockClear() + + group.setVisibleIds(['some-other-id']) + + expect(updateHighlightOverlay).toHaveBeenLastCalledWith(mockMap, { + id: 'layer-a', + features: [], + color: undefined, + }) + }) + + it('should restrict rendered features to the given ids across all sub-layers', () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 2) + group._layers = [layerA, layerB] + + group.setVisibleIds(['O6uvpzGd5pu']) + + expect(layerA.setVisibleIds).toHaveBeenCalledWith(['O6uvpzGd5pu']) + expect(layerB.setVisibleIds).toHaveBeenCalledWith(['O6uvpzGd5pu']) + }) + + it('should wire its contextmenu listener synchronously, before sub-layers finish loading, so an event during that window is not dropped', () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + // A sub-layer whose own addTo() is still pending (e.g. loading images) + layerA.addTo = jest.fn(() => new Promise(() => {})) + group._layers = [layerA] + + group.addTo(mockMap) // not awaited, mirroring Map#addLayer's timing + + group.fire('contextmenu', { feature: { layer: { id: 'layer-a' } } }) + + expect(layerA.fire).toHaveBeenCalledWith( + 'contextmenu', + expect.objectContaining({ feature: expect.anything() }) + ) + }) + + it('should move each sub-layer (each sub-layer repositions its own overlay itself; the group has none of its own)', () => { + const group = new LayerGroup() + const layerA = createMockSubLayer('layer-a', 1) + const layerB = createMockSubLayer('layer-b', 2) + group._layers = [layerA, layerB] + group._map = mockMap + + group.move() + + expect(layerA.move).toHaveBeenCalled() + expect(layerB.move).toHaveBeenCalled() + expect(mockMapGL.moveLayer).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/__tests__/core.spec.js b/src/utils/__tests__/core.spec.js index 3db583dc..bbfd21a5 100644 --- a/src/utils/__tests__/core.spec.js +++ b/src/utils/__tests__/core.spec.js @@ -1,4 +1,4 @@ -import { setTemplate } from '../core.js' +import { dropHiddenIds, setTemplate } from '../core.js' describe('core utils', () => { it('Should add values to template string', () => { @@ -17,4 +17,18 @@ describe('core utils', () => { }) ).toBe('Population: no value') }) + + describe('dropHiddenIds', () => { + it('Should drop hover/selected ids that are no longer visible', () => { + expect(dropHiddenIds(['a', 'b'], ['b', 'c'], ['b'])).toEqual({ + hoverIds: ['b'], + selectedIds: ['b'], + }) + }) + + it('Should return null when visibleIds is null/undefined, so nothing is dropped', () => { + expect(dropHiddenIds(['a'], ['b'], null)).toBeNull() + expect(dropHiddenIds(['a'], ['b'], undefined)).toBeNull() + }) + }) }) diff --git a/src/utils/__tests__/highlightOverlay.spec.js b/src/utils/__tests__/highlightOverlay.spec.js new file mode 100644 index 00000000..57b7dd11 --- /dev/null +++ b/src/utils/__tests__/highlightOverlay.spec.js @@ -0,0 +1,274 @@ +import { + getOverlaySourceId, + createHighlightOverlay, + updateHighlightOverlay, + removeHighlightOverlay, +} from '../highlightOverlay.js' + +const createMockMapgl = ({ existingSourceId, existingLayerId } = {}) => { + // getSource(id) must return the same instance every call, like real maplibre-gl. + const source = existingSourceId ? { setData: jest.fn() } : null + + return { + addSource: jest.fn(), + addLayer: jest.fn(), + removeLayer: jest.fn(), + removeSource: jest.fn(), + setFeatureState: jest.fn(), + getSource: jest.fn(id => + id === existingSourceId ? source : undefined + ), + getLayer: jest.fn(id => id === existingLayerId), + } +} + +const createMockMap = mapgl => ({ + getMapGL: () => mapgl, +}) + +describe('highlightOverlay', () => { + it('clones eligible layers, halos icon layers, and skips fill/label layers', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + + const overlayIds = createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [ + { id: 'layer-1-point', type: 'circle', paint: {} }, + { id: 'layer-1-outline', type: 'line', paint: {} }, + { + id: 'layer-1-symbol', + type: 'symbol', + layout: { 'icon-image': ['get', 'iconUrl'] }, + }, + { + id: 'layer-1-label', + type: 'symbol', + layout: { 'text-field': '{name}' }, + }, + { id: 'layer-1-polygon', type: 'fill', paint: {} }, + ], + beforeId: 'before-id', + }) + + expect(mapgl.addSource).toHaveBeenCalledWith( + getOverlaySourceId('layer-1'), + expect.objectContaining({ type: 'geojson' }) + ) + expect(overlayIds).toEqual([ + 'layer-1-point-highlight', + 'layer-1-outline-highlight', + 'layer-1-symbol-highlight-halo', + 'layer-1-symbol-highlight-icon', + ]) + expect(mapgl.addLayer).toHaveBeenCalledTimes(4) + expect(mapgl.addLayer).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'layer-1-point-highlight', + type: 'circle', + source: getOverlaySourceId('layer-1'), + }), + 'before-id' + ) + }) + + it('adds a colored halo behind the icon clone for icon layers', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + const isSymbolFilter = [ + 'all', + ['==', '$type', 'Point'], + ['has', 'iconUrl'], + ] + + const overlayIds = createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [ + { + id: 'layer-1-symbol', + type: 'symbol', + layout: { 'icon-image': ['get', 'iconUrl'] }, + filter: isSymbolFilter, + }, + ], + beforeId: undefined, + }) + + expect(overlayIds).toEqual([ + 'layer-1-symbol-highlight-halo', + 'layer-1-symbol-highlight-icon', + ]) + // Halo added first, so the icon clone renders on top of it. + expect(mapgl.addLayer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: 'layer-1-symbol-highlight-halo', + type: 'circle', + source: getOverlaySourceId('layer-1'), + filter: isSymbolFilter, + paint: expect.objectContaining({ 'circle-blur': 0.6 }), + }), + undefined + ) + expect(mapgl.addLayer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: 'layer-1-symbol-highlight-icon', + type: 'symbol', + source: getOverlaySourceId('layer-1'), + layout: { 'icon-image': ['get', 'iconUrl'] }, + filter: isSymbolFilter, + }), + undefined + ) + }) + + it('omits the filter when the base layer has none, rather than matching everything', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + + createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [ + { + id: 'layer-1-symbol', + type: 'symbol', + layout: { 'icon-image': ['get', 'iconUrl'] }, + }, + ], + beforeId: undefined, + }) + + expect(mapgl.addLayer.mock.calls[0][0].filter).toBeUndefined() + expect(mapgl.addLayer.mock.calls[1][0].filter).toBeUndefined() + }) + + it('skips text-label symbol layers (text-field, not icon-image)', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + + const overlayIds = createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [ + { + id: 'layer-1-label', + type: 'symbol', + layout: { 'text-field': '{name}' }, + }, + ], + beforeId: undefined, + }) + + expect(overlayIds).toEqual([]) + expect(mapgl.addLayer).not.toHaveBeenCalled() + }) + + it('never clones a fill layer', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + + const overlayIds = createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [{ id: 'layer-1-polygon', type: 'fill', paint: {} }], + beforeId: undefined, + }) + + expect(overlayIds).toEqual([]) + expect(mapgl.addLayer).not.toHaveBeenCalled() + }) + + it('does not re-add a source/layer that already exists', () => { + const mapgl = createMockMapgl({ + existingSourceId: getOverlaySourceId('layer-1'), + existingLayerId: 'layer-1-point-highlight', + }) + const map = createMockMap(mapgl) + + createHighlightOverlay(map, { + id: 'layer-1', + glLayers: [{ id: 'layer-1-point', type: 'circle', paint: {} }], + beforeId: undefined, + }) + + expect(mapgl.addSource).not.toHaveBeenCalled() + expect(mapgl.addLayer).not.toHaveBeenCalled() + }) + + it('updates the overlay source data and marks features hover+selected with the given color', () => { + const mapgl = createMockMapgl({ + existingSourceId: getOverlaySourceId('layer-1'), + }) + const map = createMockMap(mapgl) + const source = mapgl.getSource(getOverlaySourceId('layer-1')) + const feature = { id: 7, type: 'Feature', properties: {} } + + updateHighlightOverlay(map, { + id: 'layer-1', + features: [feature], + color: '#FFC800', + }) + + expect(source.setData).toHaveBeenCalledWith({ + type: 'FeatureCollection', + features: [{ ...feature, id: 1 }], + }) + expect(mapgl.setFeatureState).toHaveBeenCalledWith( + { source: getOverlaySourceId('layer-1'), id: 1 }, + { hover: true, selected: true, highlightColor: '#FFC800' } + ) + }) + + it('re-keys features with source-local ids to avoid cross-sub-layer id collisions', () => { + const mapgl = createMockMapgl({ + existingSourceId: getOverlaySourceId('group-1'), + }) + const map = createMockMap(mapgl) + + // Two features from different sub-layers happen to share id=1 + const boundaryFeature = { id: 1, type: 'Feature', properties: {} } + const bubbleFeature = { id: 1, type: 'Feature', properties: {} } + + updateHighlightOverlay(map, { + id: 'group-1', + features: [boundaryFeature, bubbleFeature], + color: '#FFC800', + }) + + const source = mapgl.getSource(getOverlaySourceId('group-1')) + const written = source.setData.mock.calls[0][0].features + + expect(written.map(f => f.id)).toEqual([1, 2]) + expect(mapgl.setFeatureState).toHaveBeenCalledTimes(2) + }) + + it('no-ops when the overlay source does not exist yet', () => { + const mapgl = createMockMapgl() + const map = createMockMap(mapgl) + + expect(() => + updateHighlightOverlay(map, { + id: 'missing', + features: [], + color: '#FFC800', + }) + ).not.toThrow() + expect(mapgl.setFeatureState).not.toHaveBeenCalled() + }) + + it('removes overlay layers and source', () => { + const mapgl = createMockMapgl({ + existingSourceId: getOverlaySourceId('layer-1'), + existingLayerId: 'layer-1-point-highlight', + }) + const map = createMockMap(mapgl) + + removeHighlightOverlay(map, 'layer-1', ['layer-1-point-highlight']) + + expect(mapgl.removeLayer).toHaveBeenCalledWith( + 'layer-1-point-highlight' + ) + expect(mapgl.removeSource).toHaveBeenCalledWith( + getOverlaySourceId('layer-1') + ) + }) +}) diff --git a/src/utils/__tests__/layers.spec.js b/src/utils/__tests__/layers.spec.js index 9a304a23..f0f6b83c 100644 --- a/src/utils/__tests__/layers.spec.js +++ b/src/utils/__tests__/layers.spec.js @@ -1,3 +1,4 @@ +import { highlightColorExpr } from '../expressions.js' import { pointLayer, lineLayer, @@ -55,4 +56,12 @@ describe('layers', () => { textOpacity ) }) + + it('Should recolor the cluster bubble stroke on hover/selection, like the other layer builders', () => { + const strokeColor = '#333333' + + expect( + clusterLayer({ id, strokeColor }).paint['circle-stroke-color'] + ).toEqual(highlightColorExpr(strokeColor)) + }) }) diff --git a/src/utils/core.js b/src/utils/core.js index 0712aa62..c7323295 100644 --- a/src/utils/core.js +++ b/src/utils/core.js @@ -10,3 +10,25 @@ export const getFeaturesString = features => .map(({ id, source }) => `${id}-${source}`) .join('-') : '' + +// Normalizes a scalar id, array of ids, or null/undefined into an array +export const normalizeIds = ids => { + if (Array.isArray(ids)) { + return ids + } + + return ids ? [ids] : [] +} + +export const dropHiddenIds = (hoverIds, selectedIds, visibleIds) => { + if (!visibleIds) { + return null + } + + const visible = new Set(visibleIds) + + return { + hoverIds: hoverIds.filter(id => visible.has(id)), + selectedIds: selectedIds.filter(id => visible.has(id)), + } +} diff --git a/src/utils/expressions.js b/src/utils/expressions.js index 6e755bbd..60f434f3 100644 --- a/src/utils/expressions.js +++ b/src/utils/expressions.js @@ -1,4 +1,4 @@ -import { isHover } from './filters.js' +import { isHover, isSelected } from './filters.js' import { strokeWidth, hoverStrokeMultiplier } from './style.js' // Returns color from feature with fallback @@ -9,11 +9,20 @@ export const colorExpr = color => [ color, ] -// Returns width (weight) from feature with fallback and hover support +// Returns width (weight) from feature with fallback; boosted on hover/selection export const widthExpr = (width = strokeWidth) => [ '*', ['case', ['has', 'weight'], ['get', 'weight'], width], - ['case', isHover, hoverStrokeMultiplier, 1], + ['case', ['any', isHover, isSelected], hoverStrokeMultiplier, 1], +] + +// On hover/selection, swaps in the color from feature-state (set by +// Layer#highlight/#select), else returns `fallback`. +export const highlightColorExpr = fallback => [ + 'case', + ['any', isHover, isSelected], + ['coalesce', ['feature-state', 'highlightColor'], fallback], + fallback, ] // Returns radius from feature with fallback diff --git a/src/utils/filters.js b/src/utils/filters.js index 70075b3d..6a2fe48f 100644 --- a/src/utils/filters.js +++ b/src/utils/filters.js @@ -19,3 +19,4 @@ export const isClusterPoint = [ export const isClusterPolygon = ['all', noCluster, ['==', getIsPolygon, true]] export const isHover = ['boolean', ['feature-state', 'hover'], false] +export const isSelected = ['boolean', ['feature-state', 'selected'], false] diff --git a/src/utils/highlightOverlay.js b/src/utils/highlightOverlay.js new file mode 100644 index 00000000..b46735e4 --- /dev/null +++ b/src/utils/highlightOverlay.js @@ -0,0 +1,114 @@ +import { highlightColorExpr } from './expressions.js' +import { featureCollection } from './geometry.js' + +// Only circle/line paint reacts to hover/selected feature-state +// Cloning a fill layer would double-render the same fill +const CLONEABLE_TYPES = new Set(['circle', 'line']) + +// Icon layers have no paint property that reacts to feature-state +// Instead we add a colored halo behind a plain clone of the icon +const ICON_HALO_RADIUS = 14 +const ICON_HALO_BLUR = 0.6 +const ICON_HALO_OPACITY = 0.6 + +const iconHighlightHalo = layer => ({ + id: `${layer.id}-highlight-halo`, + type: 'circle', + // Reuse the base layer's filter + filter: layer.filter, + paint: { + 'circle-color': highlightColorExpr('#333333'), + 'circle-radius': ICON_HALO_RADIUS, + 'circle-blur': ICON_HALO_BLUR, + 'circle-opacity': ICON_HALO_OPACITY, + }, +}) + +// Text labels are also `type: 'symbol'` in maplibre-gl but have no icon-image +const isIconLayer = layer => + layer.type === 'symbol' && layer.layout?.['icon-image'] !== undefined + +export const getOverlaySourceId = id => `${id}-highlight` + +// Creates a small overlay source + cloned layers, drawn above the base +// layers they came from, only ever holds the currently hover/selected features +export const createHighlightOverlay = (map, { id, glLayers, beforeId }) => { + const mapgl = map.getMapGL() + const sourceId = getOverlaySourceId(id) + + if (!mapgl.getSource(sourceId)) { + mapgl.addSource(sourceId, { + type: 'geojson', + data: featureCollection(), + }) + } + + const addOverlayLayer = overlayLayer => { + if (!mapgl.getLayer(overlayLayer.id)) { + mapgl.addLayer({ ...overlayLayer, source: sourceId }, beforeId) + } + return overlayLayer.id + } + + return glLayers.flatMap(layer => { + if (isIconLayer(layer)) { + // Halo first, so the icon clone renders on top of it + return [ + addOverlayLayer(iconHighlightHalo(layer)), + addOverlayLayer({ ...layer, id: `${layer.id}-highlight-icon` }), + ] + } + + if (CLONEABLE_TYPES.has(layer.type)) { + return [addOverlayLayer({ ...layer, id: `${layer.id}-highlight` })] + } + + return [] + }) +} + +// Replaces the overlay's contents with `features` and marks them +// hover+selected so the cloned paint renders the highlighted look +export const updateHighlightOverlay = (map, { id, features, color }) => { + const mapgl = map.getMapGL() + const sourceId = getOverlaySourceId(id) + const source = mapgl.getSource(sourceId) + + if (!source) { + return + } + + // Re-key to ids local to this source + const overlayFeatures = features.map((feature, index) => ({ + ...feature, + id: index + 1, + })) + + source.setData(featureCollection(overlayFeatures)) + + overlayFeatures.forEach(feature => + mapgl.setFeatureState( + { source: sourceId, id: feature.id }, + { hover: true, selected: true, highlightColor: color } + ) + ) +} + +export const removeHighlightOverlay = (map, id, overlayLayerIds = []) => { + const mapgl = map.getMapGL() + const sourceId = getOverlaySourceId(id) + + if (!mapgl) { + return + } + + overlayLayerIds.forEach(layerId => { + if (mapgl.getLayer(layerId)) { + mapgl.removeLayer(layerId) + } + }) + + if (mapgl.getSource(sourceId)) { + mapgl.removeSource(sourceId) + } +} diff --git a/src/utils/layers.js b/src/utils/layers.js index 0635371a..a4c58824 100644 --- a/src/utils/layers.js +++ b/src/utils/layers.js @@ -3,6 +3,7 @@ import { widthExpr, radiusExpr, clusterRadiusExpr, + highlightColorExpr, } from './expressions.js' import { isPointNoSymbol, @@ -50,7 +51,9 @@ export const pointLayer = ({ 'circle-radius': radiusExpr(radius || circleRadius), 'circle-opacity': opacity ?? circleOpacity, 'circle-stroke-width': widthExpr(width), - 'circle-stroke-color': strokeColor || circleStrokeColor, + 'circle-stroke-color': highlightColorExpr( + strokeColor || circleStrokeColor + ), 'circle-stroke-opacity': opacity ?? circleOpacity, }, filter: filter || isPointNoSymbol, @@ -62,7 +65,7 @@ export const lineLayer = ({ id, color, width, opacity, source, filter }) => ({ type: 'line', source: source || id, paint: { - 'line-color': color || lineStrokeColor, + 'line-color': highlightColorExpr(color || lineStrokeColor), 'line-width': widthExpr(width), 'line-opacity': opacity ?? lineOpacity, }, @@ -99,7 +102,7 @@ export const outlineLayer = ({ type: 'line', source: source || id, paint: { - 'line-color': color || lineStrokeColor, + 'line-color': highlightColorExpr(color || lineStrokeColor), 'line-width': widthExpr(width), 'line-opacity': opacity ?? lineOpacity, }, @@ -135,7 +138,7 @@ export const clusterLayer = ({ id, color, strokeColor, opacity }) => ({ 'circle-color': color, 'circle-radius': clusterRadiusExpr, 'circle-opacity': opacity ?? circleOpacity, - 'circle-stroke-color': strokeColor, + 'circle-stroke-color': highlightColorExpr(strokeColor), 'circle-stroke-width': strokeWidth, 'circle-stroke-opacity': opacity ?? circleOpacity, },