From 86550a7a4bc1e4ca6afda431da9458345e0a929f Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 16 Apr 2026 13:23:42 +1200 Subject: [PATCH 1/6] Update active feature on pan/zoom with zoomend control (#74). --- src/interactions.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/interactions.ts b/src/interactions.ts index 9d70c37..90a1b87 100644 --- a/src/interactions.ts +++ b/src/interactions.ts @@ -180,6 +180,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() @@ -354,6 +355,7 @@ 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')) } get minimap() @@ -1176,6 +1178,10 @@ export class UserInteractions #mouseMoveEvent(event) //==================== { + this.#lastMousePoint = event.point + if (this.#map.isMoving()) { + return + } this.#updateActiveFeature(event.point, event.lngLat) } @@ -1913,15 +1919,21 @@ 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') { + 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() } - this.#layerManager.zoomEvent() } } From a0c9272b84d7fa133ee4e6d624b7417074bc4550 Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 16 Apr 2026 14:03:50 +1200 Subject: [PATCH 2/6] Use minzoom and maxzoom properties to manage path opacity (#74). --- src/layers/styling.ts | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/layers/styling.ts b/src/layers/styling.ts index d01eb43..6194c6f 100644 --- a/src/layers/styling.ts +++ b/src/layers/styling.ts @@ -64,6 +64,7 @@ export interface StylingOptions extends StyleLayerOptions hasImageLayers?: boolean opacity?: number showNerveCentrelines?: boolean + hidePaths?: boolean } //============================================================================== @@ -197,7 +198,7 @@ export class VectorStyleLayer extends StyleLayer this.#lastPaintStyle = {} } - defaultFilter(): ExpressionFilterSpecification + defaultFilter(_options: StylingOptions={}): ExpressionFilterSpecification { return null } @@ -594,6 +595,31 @@ 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): any +{ + const inRangeOpacity = dimmed ? fadedOpacity : normalOpacity + const expression: any[] = ['step', ['zoom'], + zoomBoundCaseExpression(0, inRangeOpacity, fadedOpacity)] + for (let step = 1; step <= 24; step++) { + expression.push(step) + expression.push(zoomBoundCaseExpression(step, inRangeOpacity, fadedOpacity)) + } + return expression +} + //============================================================================== export class AnnotatedPathLayer extends VectorStyleLayer @@ -715,14 +741,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), 'line-width': [ 'let', 'width', [ From 3fb1d179cd57e7fa3f09756977a26d8efcb6b99d Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 16 Apr 2026 16:32:19 +1200 Subject: [PATCH 3/6] Enable path interaction based on minzoom and maxzoom thresholds (#74). --- src/interactions.ts | 65 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/interactions.ts b/src/interactions.ts index 90a1b87..8c3aa75 100644 --- a/src/interactions.ts +++ b/src/interactions.ts @@ -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 @@ -1172,7 +1173,59 @@ 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 + 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 minzoom = feature.properties?.minzoom + const maxzoom = feature.properties?.maxzoom + + 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 + } + + this.#cacheSourceLayerFeatureZoomRanges(mapFeature.sourceLayer) + const rangesByFeatureId = this.#featureZoomRangesBySourceLayer.get(mapFeature.sourceLayer) + const ranges = rangesByFeatureId?.get(+featureId) + if (!ranges || ranges.length === 0) { + return false + } + + return ranges.some(([minzoom, maxzoom]) => + zoom >= minzoom && zoom <= maxzoom + ) } #mouseMoveEvent(event) @@ -1504,12 +1557,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)) + } } } } @@ -1923,6 +1980,7 @@ export class UserInteractions } if (type === 'zoomend') { + this.#featureZoomRangesBySourceLayer.clear() if (this.#lastMousePoint !== null) { if ('originalEvent' in event) { if ('layerX' in event.originalEvent && 'layerY' in event.originalEvent) { @@ -1932,7 +1990,6 @@ export class UserInteractions ]) } } - this.#layerManager.zoomEvent() } } } From 218702f9062a2dab10cbd3ce45e3978536ade193 Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 16 Apr 2026 17:17:20 +1200 Subject: [PATCH 4/6] Buffer active-feature updates during hover to reduce per-event state churn (#74). --- src/interactions.ts | 74 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/src/interactions.ts b/src/interactions.ts index 8c3aa75..ad0d5f4 100644 --- a/src/interactions.ts +++ b/src/interactions.ts @@ -198,6 +198,8 @@ export class UserInteractions #taxonFacet: TaxonFacet #tooltip: maplibregl.Popup|null = null #resetOnClickEnabled: boolean = true + #collectingActiveFeatures: boolean = false + #nextActiveFeatures: Map|null = null constructor(flatmap: FlatMap) { @@ -817,9 +819,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)) { @@ -842,6 +853,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() //==================== { @@ -849,6 +889,8 @@ export class UserInteractions this.#removeFeatureState(feature, 'active') } this.#activeFeatures.clear() + this.#collectingActiveFeatures = false + this.#nextActiveFeatures = null } /* UNUSED @@ -1174,8 +1216,8 @@ export class UserInteractions { const features = this.#layerManager.featuresAtPoint(point) return features.filter(feature => { - const featureId = feature.properties?.featureId - return this.#featureIdIsRenderable(featureId) + const featureId = feature.properties?.featureId ?? feature.id + return this.#featureIdIsRenderable(+featureId) }) } @@ -1193,8 +1235,10 @@ export class UserInteractions const featureId: number = feature.id ? +feature.id : +feature.properties.featureId - const minzoom = feature.properties?.minzoom - const maxzoom = feature.properties?.maxzoom + 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]) @@ -1224,7 +1268,8 @@ export class UserInteractions } return ranges.some(([minzoom, maxzoom]) => - zoom >= minzoom && zoom <= maxzoom + (minzoom == null || zoom >= minzoom) + && (maxzoom == null || zoom <= maxzoom) ) } @@ -1246,8 +1291,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) @@ -1256,12 +1306,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) } @@ -1378,6 +1430,8 @@ export class UserInteractions } } + this.#commitActiveFeatureUpdate() + if (info !== '') { this.#infoControl.show(info) } From 802276133fbb34687a72f9b48bbd8f2388e7cb9d Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 16 Apr 2026 17:27:11 +1200 Subject: [PATCH 5/6] Tidy up styling.ts (#74). --- src/layers/styling.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/layers/styling.ts b/src/layers/styling.ts index 6194c6f..523e574 100644 --- a/src/layers/styling.ts +++ b/src/layers/styling.ts @@ -64,7 +64,6 @@ export interface StylingOptions extends StyleLayerOptions hasImageLayers?: boolean opacity?: number showNerveCentrelines?: boolean - hidePaths?: boolean } //============================================================================== @@ -198,7 +197,7 @@ export class VectorStyleLayer extends StyleLayer this.#lastPaintStyle = {} } - defaultFilter(_options: StylingOptions={}): ExpressionFilterSpecification + defaultFilter(): ExpressionFilterSpecification { return null } From 1f46effe51a342b9e03660dd412c9eb906e4fce5 Mon Sep 17 00:00:00 2001 From: napakalas Date: Thu, 23 Apr 2026 11:25:11 +1200 Subject: [PATCH 6/6] Use density to control rendering activation based on minzoom and maxzoom (#74). --- src/interactions.ts | 67 +++++++++++++++++++++++++++++++++++++++++-- src/layers/styling.ts | 12 +++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/interactions.ts b/src/interactions.ts index ad0d5f4..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' @@ -191,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() @@ -359,6 +360,10 @@ export class UserInteractions 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() @@ -1260,8 +1265,15 @@ export class UserInteractions return false } - this.#cacheSourceLayerFeatureZoomRanges(mapFeature.sourceLayer) - const rangesByFeatureId = this.#featureZoomRangesBySourceLayer.get(mapFeature.sourceLayer) + 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 @@ -1273,6 +1285,51 @@ export class UserInteractions ) } + #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) //==================== { @@ -2046,6 +2103,10 @@ export class UserInteractions } } } + + if (type === 'zoomend' || type === 'moveend') { + this.#updateAreaDensity() + } } //========================================================================== diff --git a/src/layers/styling.ts b/src/layers/styling.ts index 523e574..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 } @@ -607,14 +608,16 @@ function zoomBoundCaseExpression(zoomValue: number, inRangeOpacity: number, outO return expression } -function extentOpacityExpression(dimmed: boolean, normalOpacity: number, fadedOpacity: number): any +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, fadedOpacity)] + zoomBoundCaseExpression(0, inRangeOpacity, outOfRangeOpacity)] for (let step = 1; step <= 24; step++) { expression.push(step) - expression.push(zoomBoundCaseExpression(step, inRangeOpacity, fadedOpacity)) + expression.push(zoomBoundCaseExpression(step, inRangeOpacity, outOfRangeOpacity)) } return expression } @@ -726,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], @@ -740,7 +744,7 @@ export class PathLineLayer extends VectorStyleLayer ['boolean', ['feature-state', 'selected'], false], 1.0, ['boolean', ['feature-state', 'active'], false], 1.0, 0.0 - ] :extentOpacityExpression(dimmed, 0.8, 0.1), + ] : extentOpacityExpression(dimmed, 0.8, 0.1, pathLowDensityMode), 'line-width': [ 'let', 'width', [