diff --git a/src/interactions.ts b/src/interactions.ts index 9d70c37..180dc0e 100644 --- a/src/interactions.ts +++ b/src/interactions.ts @@ -52,7 +52,7 @@ import {VECTOR_TILES_SOURCE} from './layers/styling' import {MARKER_DEFAULT_COLOUR} from './markers' import {latex2Svg} from './mathjax' import type {NerveCentrelineDetails} from './pathways' -import {PathManager} from './pathways' +import {PathManager, PATHWAYS_LAYER} from './pathways' import {SystemsManager} from './systems' import {displayedProperties, InfoControl} from './controls/info' @@ -172,6 +172,7 @@ export class UserInteractions #currentPopup: maplibregl.Popup|null = null #featureEnabledCount: Map #featureIdToMapId: Map + #featureZoomRangesBySourceLayer: Map>> = new Map() #flatmap: FlatMap #imageLayerIds = new Map() #infoControl: InfoControl|null = null @@ -180,6 +181,7 @@ export class UserInteractions #lastFeatureModelsMouse: string|null = null #lastImageId: number = 0 #lastMarkerId: number = 900000 + #lastMousePoint: [number, number]|null = null #layerManager: LayerManager #map: maplibregl.Map #markerIdByFeatureId = new Map() @@ -189,6 +191,7 @@ export class UserInteractions #modal: boolean = false #nerveCentrelineFacet: NerveCentreFacet #pan_zoom_enabled: boolean = false + #pathLowDensityMode: boolean = false #pathManager: PathManager #pathTypeFacet: PathTypeFacet #selectedFeatureRefCount = new Map() @@ -196,6 +199,8 @@ export class UserInteractions #taxonFacet: TaxonFacet #tooltip: maplibregl.Popup|null = null #resetOnClickEnabled: boolean = true + #collectingActiveFeatures: boolean = false + #nextActiveFeatures: Map|null = null constructor(flatmap: FlatMap) { @@ -354,6 +359,11 @@ export class UserInteractions // Handle pan/zoom events this.#map.on('move', this.#panZoomEvent.bind(this, 'pan')) this.#map.on('zoom', this.#panZoomEvent.bind(this, 'zoom')) + this.#map.on('zoomend', this.#panZoomEvent.bind(this, 'zoomend')) + this.#map.on('moveend', this.#panZoomEvent.bind(this, 'moveend')) + + // Prime path density so initial rendering and hit-testing are in sync. + this.#updateAreaDensity(true) } get minimap() @@ -814,9 +824,18 @@ export class UserInteractions //========================================================== { if (feature) { - this.#setFeatureState(feature, { active: true }) - if (!this.#activeFeatures.has(+feature.id)) { - this.#activeFeatures.set(+feature.id, feature) + const activeMap = this.#collectingActiveFeatures ? this.#nextActiveFeatures + : this.#activeFeatures + if (activeMap && activeMap.has(+feature.id)) { + return + } + if (this.#collectingActiveFeatures) { + this.#nextActiveFeatures?.set(+feature.id, feature) + } else { + this.#setFeatureState(feature, { active: true }) + if (!this.#activeFeatures.has(+feature.id)) { + this.#activeFeatures.set(+feature.id, feature) + } } // If the feature is a nerve, activate its inner features too for (const innerFeatureId of this.#flatmap.featureIdsByNerveId(+feature.id)) { @@ -839,6 +858,35 @@ export class UserInteractions } } + #beginActiveFeatureUpdate() + //========================= + { + this.#collectingActiveFeatures = true + this.#nextActiveFeatures = new Map() + } + + #commitActiveFeatureUpdate() + //========================== + { + const nextActiveFeatures = this.#nextActiveFeatures || new Map() + + for (const [featureId, feature] of this.#activeFeatures.entries()) { + if (!nextActiveFeatures.has(featureId)) { + this.#removeFeatureState(feature, 'active') + } + } + + for (const [featureId, feature] of nextActiveFeatures.entries()) { + if (!this.#activeFeatures.has(featureId)) { + this.#setFeatureState(feature, { active: true }) + } + } + + this.#activeFeatures = nextActiveFeatures + this.#collectingActiveFeatures = false + this.#nextActiveFeatures = null + } + #resetActiveFeatures() //==================== { @@ -846,6 +894,8 @@ export class UserInteractions this.#removeFeatureState(feature, 'active') } this.#activeFeatures.clear() + this.#collectingActiveFeatures = false + this.#nextActiveFeatures = null } /* UNUSED @@ -1170,12 +1220,123 @@ export class UserInteractions //=========================================================== { const features = this.#layerManager.featuresAtPoint(point) - return features.filter(feature => this.#featureEnabled(feature)) + return features.filter(feature => { + const featureId = feature.properties?.featureId ?? feature.id + return this.#featureIdIsRenderable(+featureId) + }) + } + + #cacheSourceLayerFeatureZoomRanges(sourceLayer: string) + //=============================================== + { + if (this.#featureZoomRangesBySourceLayer.has(sourceLayer)) { + return + } + + const rangesByFeatureId = new Map>() + const features = this.#map.querySourceFeatures(VECTOR_TILES_SOURCE, {sourceLayer}) + + for (const feature of features) { + const featureId: number = feature.id ? +feature.id + : +feature.properties.featureId + + const minzoomRaw = Number(feature.properties?.minzoom) + const maxzoomRaw = Number(feature.properties?.maxzoom) + const minzoom = Number.isFinite(minzoomRaw) ? minzoomRaw : null + const maxzoom = Number.isFinite(maxzoomRaw) ? maxzoomRaw : null + + const ranges = rangesByFeatureId.get(featureId) || [] + ranges.push([minzoom, maxzoom]) + rangesByFeatureId.set(featureId, ranges) + } + + this.#featureZoomRangesBySourceLayer.set(sourceLayer, rangesByFeatureId) + } + + #featureIdIsRenderable(featureId: GeoJSONId): boolean + //=================================================== + { + const zoom = Math.floor(this.#map.getZoom()) + const mapFeature = this.mapFeature(+featureId) + if (!this.#featureEnabled(mapFeature)) { + return false + } + if (mapFeature === null || !mapFeature.sourceLayer) { + return false + } + + const sourceLayer = mapFeature.sourceLayer + const pathwaysSourceLayer = PATHWAYS_LAYER.replaceAll('/', '_') + const isPathFeature = sourceLayer.includes(pathwaysSourceLayer) + + if (isPathFeature && this.#pathLowDensityMode) { + return true + } + this.#cacheSourceLayerFeatureZoomRanges(sourceLayer) + const rangesByFeatureId = this.#featureZoomRangesBySourceLayer.get(sourceLayer) + const ranges = rangesByFeatureId?.get(+featureId) + if (!ranges || ranges.length === 0) { + return false + } + + return ranges.some(([minzoom, maxzoom]) => + (minzoom == null || zoom >= minzoom) + && (maxzoom == null || zoom <= maxzoom) + ) + } + + #updateAreaDensity(force=false) + //================================= + { + const previousLowDensityMode = this.#pathLowDensityMode + const renderedFeatures = this.#map.queryRenderedFeatures() + const visibleEdgeIds = new Set() + const pathwaysSourceLayer = PATHWAYS_LAYER.replaceAll('/', '_') + + for (const feature of renderedFeatures) { + const sourceLayer = feature.sourceLayer || '' + if (feature.source !== VECTOR_TILES_SOURCE || !sourceLayer.includes(pathwaysSourceLayer)) { + continue + } + const pathType = feature.properties?.type + if (!['line', 'line-dash', 'bezier'].includes(pathType)) { + continue + } + const featureId = feature.properties?.featureId ?? feature.id + visibleEdgeIds.add(+featureId) + } + + const PATH_DENSITY_MIN_EDGES = 80 + const PATH_DENSITY_MAX_EDGES = 600 + const PATH_DENSITY_LOW_THRESHOLD = 0.475 + const PATH_DENSITY_HIGH_THRESHOLD = 0.525 + const visibleEdgeCount = visibleEdgeIds.size + const density = Math.max(0, Math.min(1, (visibleEdgeCount - PATH_DENSITY_MIN_EDGES) / (PATH_DENSITY_MAX_EDGES - PATH_DENSITY_MIN_EDGES))) + let lowDensityMode = previousLowDensityMode + if (force) { + lowDensityMode = (density <= PATH_DENSITY_LOW_THRESHOLD) + } else if (density <= PATH_DENSITY_LOW_THRESHOLD) { + lowDensityMode = true + } else if (density >= PATH_DENSITY_HIGH_THRESHOLD) { + lowDensityMode = false + } + this.#pathLowDensityMode = lowDensityMode + + const lowDensityModeChanged = (this.#pathLowDensityMode !== previousLowDensityMode) + if (force || lowDensityModeChanged) { + this.#layerManager.setPaint({ + pathLowDensityMode: this.#pathLowDensityMode + }) + } } #mouseMoveEvent(event) //==================== { + this.#lastMousePoint = event.point + if (this.#map.isMoving()) { + return + } this.#updateActiveFeature(event.point, event.lngLat) } @@ -1187,8 +1348,13 @@ export class UserInteractions return } - // Remove tooltip, reset active features, etc - this.#resetFeatureDisplay() + if (this.#map.isMoving()) { + return + } + + // Remove tooltip and reset cursor; active features are updated by diff. + this.#removeTooltip() + this.#map.getCanvas().style.cursor = 'default' // Reset any info display const displayInfo = (this.#infoControl?.active) @@ -1197,12 +1363,14 @@ export class UserInteractions } const eventLngLat = this.#map.unproject(eventPoint) + this.#beginActiveFeatureUpdate() // Get all the features at the current point const features = this.#renderedFeatures(eventPoint) if (features.length === 0) { this.#lastFeatureMouseEntered = null this.#lastFeatureModelsMouse = null + this.#commitActiveFeatureUpdate() if (this.#flatmap.options.showCoords || this.#flatmap.options.showLngLat) { this.#showToolTip('', eventLngLat, null) } @@ -1319,6 +1487,8 @@ export class UserInteractions } } + this.#commitActiveFeatureUpdate() + if (info !== '') { this.#infoControl.show(info) } @@ -1498,12 +1668,16 @@ export class UserInteractions this.activateFeature(this.mapFeature(+nerveId)) } for (const featureId of this.#pathManager.nerveFeatureIds(nerveId)) { - this.activateFeature(this.mapFeature(+featureId)) + if (this.#featureIdIsRenderable(+featureId)) { + this.activateFeature(this.mapFeature(+featureId)) + } } } if ('nodeId' in feature.properties) { for (const featureId of this.#pathManager.pathFeatureIds(+feature.properties.nodeId)) { - this.activateFeature(this.mapFeature(featureId)) + if (this.#featureIdIsRenderable(+featureId)) { + this.activateFeature(this.mapFeature(+featureId)) + } } } } @@ -1913,15 +2087,25 @@ export class UserInteractions this.#flatmap.panZoomEvent(type) } if (type === 'zoom') { - if ('originalEvent' in event) { - if ('layerX' in event.originalEvent && 'layerY' in event.originalEvent) { - this.#updateActiveFeature([ - event.originalEvent.layerX, - event.originalEvent.layerY - ]) + this.#layerManager.zoomEvent() + } + + if (type === 'zoomend') { + this.#featureZoomRangesBySourceLayer.clear() + if (this.#lastMousePoint !== null) { + if ('originalEvent' in event) { + if ('layerX' in event.originalEvent && 'layerY' in event.originalEvent) { + this.#updateActiveFeature([ + event.originalEvent.layerX, + event.originalEvent.layerY + ]) + } } } - this.#layerManager.zoomEvent() + } + + if (type === 'zoomend' || type === 'moveend') { + this.#updateAreaDensity() } } diff --git a/src/layers/styling.ts b/src/layers/styling.ts index d01eb43..f8a2ea2 100644 --- a/src/layers/styling.ts +++ b/src/layers/styling.ts @@ -63,6 +63,7 @@ export interface StylingOptions extends StyleLayerOptions dimmed?: boolean hasImageLayers?: boolean opacity?: number + pathLowDensityMode?: boolean showNerveCentrelines?: boolean } @@ -594,6 +595,33 @@ function sckanFilter(options: StylingOptions={}): ExpressionFilterSpecification } } +function zoomBoundCaseExpression(zoomValue: number, inRangeOpacity: number, outOfRangeOpacity: number): any +{ + const expression: any[] = ['case'] + expression.push(['==', ['get', 'type'], 'bezier'], 1.0) + expression.push(['==', ['get', 'kind'], 'error'], 1.0) + expression.push(['boolean', ['feature-state', 'selected'], false], 0.0) + expression.push(['boolean', ['feature-state', 'active'], false], 0.0) + expression.push(['<', zoomValue, ['to-number', ['coalesce', ['get', 'minzoom'], 0], 0]], outOfRangeOpacity) + expression.push(['>', zoomValue, ['to-number', ['coalesce', ['get', 'maxzoom'], 24], 24]], outOfRangeOpacity) + expression.push(inRangeOpacity) + return expression +} + +function extentOpacityExpression(dimmed: boolean, normalOpacity: number, fadedOpacity: number, + pathLowDensityMode=false): any +{ + const inRangeOpacity = dimmed ? fadedOpacity : normalOpacity + const outOfRangeOpacity = pathLowDensityMode ? inRangeOpacity : fadedOpacity + const expression: any[] = ['step', ['zoom'], + zoomBoundCaseExpression(0, inRangeOpacity, outOfRangeOpacity)] + for (let step = 1; step <= 24; step++) { + expression.push(step) + expression.push(zoomBoundCaseExpression(step, inRangeOpacity, outOfRangeOpacity)) + } + return expression +} + //============================================================================== export class AnnotatedPathLayer extends VectorStyleLayer @@ -701,6 +729,7 @@ export class PathLineLayer extends VectorStyleLayer { const dimmed = options.dimmed || false const exclude = 'excludeAnnotated' in options && options.excludeAnnotated + const pathLowDensityMode = options.pathLowDensityMode || false const paintStyle: PaintSpecification = { 'line-color': [ 'let', 'active', ['to-number', ['feature-state', 'active'], 0], @@ -715,14 +744,7 @@ export class PathLineLayer extends VectorStyleLayer ['boolean', ['feature-state', 'selected'], false], 1.0, ['boolean', ['feature-state', 'active'], false], 1.0, 0.0 - ] : [ - 'case', - ['==', ['get', 'type'], 'bezier'], 1.0, - ['==', ['get', 'kind'], 'error'], 1.0, - ['boolean', ['feature-state', 'selected'], false], 0.0, - ['boolean', ['feature-state', 'active'], false], 0.0, - dimmed ? 0.1 : 0.8 - ], + ] : extentOpacityExpression(dimmed, 0.8, 0.1, pathLowDensityMode), 'line-width': [ 'let', 'width', [