From bda4a5c69b2928769631da345f8ddba1d2b9386b Mon Sep 17 00:00:00 2001 From: Timo Giese Date: Thu, 30 Apr 2026 23:34:34 +0200 Subject: [PATCH] feat(map): zoom-driven TRACK marker LOD via screen-space simplification Introduces LodManager, which hides/shows TRACK circle-dot markers in lockstep with Leaflet's own polyline smoothFactor rendering. On each zoomend, each feed's TRACK runs are projected to screen coordinates and passed through L.LineUtil.simplify (the same algorithm and tolerance as the polyline), so a marker is visible iff the polyline would show a bend at that point. - LOD activates only when total TRACK points across all feeds >= 10 000 - The last TRACK point and all TRACK points within 24 h of it are always visible regardless of zoom level - start() is triggered by map.once('moveend') registered before fitBounds so the initial simplification fires at the correct zoom after the animation settles - refresh() short-circuits as soon as the 10 k threshold is confirmed, avoiding a full scan on every zoom event - Removes dead rebuildLinesForFeed() from LineManager (leftover from an earlier geographic-RDP approach that was superseded) --- src/map-engine/LineManager.ts | 23 +++- src/map-engine/LodManager.ts | 219 ++++++++++++++++++++++++++++++++ src/map-engine/MarkerManager.ts | 4 + src/map-engine/Spotmap.ts | 26 +++- src/map-engine/constants.ts | 9 ++ src/spotmap/edit.jsx | 8 +- 6 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 src/map-engine/LodManager.ts diff --git a/src/map-engine/LineManager.ts b/src/map-engine/LineManager.ts index 2d84d2d..5aa3b76 100644 --- a/src/map-engine/LineManager.ts +++ b/src/map-engine/LineManager.ts @@ -4,6 +4,7 @@ import { LINE_ARROW_CHAR, LINE_ARROW_FONT_SIZE, LINE_ARROW_OFFSET, + LINE_SMOOTH_FACTOR, } from './constants'; import type { LayerManager } from './LayerManager'; @@ -47,13 +48,15 @@ export class LineManager { clearArrows(): void { for ( const feed of Object.values( this.layers.feeds ) ) { for ( const line of feed.lines ) { - ( line as unknown as { setText: ( t: null ) => void } ).setText( - null - ); + this.clearLineTextPath( line ); } } } + private clearLineTextPath( line: L.Polyline ): void { + ( line as unknown as { setText: ( t: null ) => void } ).setText( null ); + } + private applyTextPath( line: L.Polyline ): void { ( line as unknown as { @@ -128,14 +131,20 @@ export class LineManager { } /** - * Create an empty polyline styled for the given feed. - * Includes directional arrow text along the path. + * Create a polyline styled for the given feed. + * Pass coords to pre-populate to avoid per-point redraws during initial load. + * Omit coords for an empty line (used by addPointToLine for splits). */ - createLine( feedName: string ): L.Polyline { + createLine( feedName: string, coords: L.LatLngTuple[] = [] ): L.Polyline { const color = this.layerManager.getFeedColor( feedName ); const weight = this.layerManager.getFeedLineWidth( feedName ); const opacity = this.layerManager.getFeedLineOpacity( feedName ); - const line = L.polyline( [], { color, weight, opacity } ); + const line = L.polyline( coords, { + color, + weight, + opacity, + smoothFactor: LINE_SMOOTH_FACTOR, + } ); // During initial load, arrows are deferred until applyArrows() is called // after fitBounds(). Lines created after that (e.g. auto-reload splits) diff --git a/src/map-engine/LodManager.ts b/src/map-engine/LodManager.ts new file mode 100644 index 0000000..cb44d84 --- /dev/null +++ b/src/map-engine/LodManager.ts @@ -0,0 +1,219 @@ +import type { SpotmapLayers, SpotPoint } from './types'; +import type { MarkerManager } from './MarkerManager'; +import { LOD_DEBOUNCE_MS, LINE_SMOOTH_FACTOR, LOD_MIN_TRACK_POINTS } from './constants'; +import { debug as debugLog } from './utils'; + +interface FeedLodState { + visibleTrackIds: Set< number >; +} + +/** + * Zoom-driven TRACK marker visibility using the same screen-space simplification + * as the polyline's smoothFactor (L.LineUtil.simplify). + * + * A marker is visible iff its point would be kept by Leaflet when rendering the + * polyline at the current zoom. Markers stay in the featureGroup at all times — + * visibility is toggled via opacity (CircleMarker) or display style (DOM Marker). + */ +export class LodManager { + private readonly map: L.Map; + private readonly layers: SpotmapLayers; + private readonly markerManager: MarkerManager; + private readonly dbg: ( ...args: unknown[] ) => void; + private readonly feedState = new Map< string, FeedLodState >(); + private debounceTimer: ReturnType< typeof setTimeout > | null = null; + private started = false; + private readonly onZoomEnd: () => void; + + constructor( + map: L.Map, + layers: SpotmapLayers, + markerManager: MarkerManager, + debugEnabled = false + ) { + this.map = map; + this.layers = layers; + this.markerManager = markerManager; + this.dbg = ( ...args ) => debugLog( debugEnabled, ...args ); + + this.onZoomEnd = () => { + if ( this.debounceTimer !== null ) { + clearTimeout( this.debounceTimer ); + } + this.debounceTimer = setTimeout( () => { + this.debounceTimer = null; + this.refresh(); + }, LOD_DEBOUNCE_MS ); + }; + } + + start(): void { + if ( this.started ) { + return; + } + this.started = true; + this.map.on( 'zoomend', this.onZoomEnd ); + this.refresh(); + } + + refresh(): void { + let totalTrack = 0; + outer: for ( const feed of Object.values( this.layers.feeds ) ) { + for ( const p of feed.points ) { + if ( p.type === 'TRACK' && ++totalTrack >= LOD_MIN_TRACK_POINTS ) { + break outer; + } + } + } + if ( totalTrack < LOD_MIN_TRACK_POINTS ) { + return; + } + for ( const feedName of Object.keys( this.layers.feeds ) ) { + this.applyLodForFeed( feedName ); + } + } + + onPointAppended( point: SpotPoint ): void { + if ( point.type === 'TRACK' && this.feedState.size > 0 ) { + this.applyLodForFeed( point.feed_name ); + } + } + + destroy(): void { + if ( this.debounceTimer !== null ) { + clearTimeout( this.debounceTimer ); + this.debounceTimer = null; + } + if ( this.started ) { + this.map.off( 'zoomend', this.onZoomEnd ); + } + } + + private setMarkerVisible( + marker: L.Marker | L.CircleMarker, + visible: boolean + ): void { + if ( marker instanceof L.CircleMarker ) { + marker.setStyle( + visible + ? { opacity: 1, fillOpacity: 1 } + : { opacity: 0, fillOpacity: 0 } + ); + } else { + const el = ( marker as L.Marker ).getElement(); + if ( el ) { + ( el as HTMLElement ).style.display = visible ? '' : 'none'; + } + } + } + + private applyLodForFeed( feedName: string ): void { + const feed = this.layers.feeds[ feedName ]; + if ( ! feed || feed.points.length === 0 ) { + return; + } + + // Compute the set of TRACK point IDs that are geometrically significant + // at the current zoom, using the same screen-space RDP that Leaflet uses + // to render the polyline. Process each consecutive TRACK run independently + // so the simplification matches the polyline's per-segment rendering. + const newVisibleIds = new Set< number >(); + let i = 0; + + while ( i < feed.points.length ) { + if ( feed.points[ i ].type !== 'TRACK' ) { + i++; + continue; + } + + const runStart = i; + while ( i < feed.points.length && feed.points[ i ].type === 'TRACK' ) { + i++; + } + const run = feed.points.slice( runStart, i ); + + if ( run.length <= 2 ) { + for ( const p of run ) { + newVisibleIds.add( p.id ); + } + continue; + } + + // Project to screen-pixel coordinates + const px: L.Point[] = run.map( ( p ) => + this.map.latLngToLayerPoint( [ p.latitude, p.longitude ] ) + ); + + // Simplify with the same tolerance as the polyline's smoothFactor + const kept = new Set( + L.LineUtil.simplify( px, LINE_SMOOTH_FACTOR ) + ); + + for ( let j = 0; j < px.length; j++ ) { + if ( kept.has( px[ j ] ) ) { + newVisibleIds.add( run[ j ].id ); + } + } + } + + // Always keep the last TRACK point and everything within 24 h of it visible, + // regardless of zoom level. + let lastTrackPoint: SpotPoint | undefined; + for ( let k = feed.points.length - 1; k >= 0; k-- ) { + if ( feed.points[ k ].type === 'TRACK' ) { + lastTrackPoint = feed.points[ k ]; + break; + } + } + if ( lastTrackPoint ) { + const cutoff = lastTrackPoint.unixtime - 24 * 3600; + for ( const p of feed.points ) { + if ( p.type === 'TRACK' && p.unixtime >= cutoff ) { + newVisibleIds.add( p.id ); + } + } + } + + const state = this.feedState.get( feedName ); + const prevVisibleIds = + state?.visibleTrackIds ?? + new Set< number >( + feed.points + .filter( ( p ) => p.type === 'TRACK' ) + .map( ( p ) => p.id ) + ); + + const pinnedId = + feed.lastPointMarker !== undefined + ? feed.points.at( -1 )?.id + : undefined; + + for ( const id of prevVisibleIds ) { + if ( ! newVisibleIds.has( id ) ) { + const marker = this.markerManager.getMarkerForPoint( id ); + if ( marker ) { + this.setMarkerVisible( marker, false ); + } + } + } + + for ( const id of newVisibleIds ) { + if ( id === pinnedId ) { + continue; + } + if ( ! prevVisibleIds.has( id ) ) { + const marker = this.markerManager.getMarkerForPoint( id ); + if ( marker ) { + this.setMarkerVisible( marker, true ); + } + } + } + + this.feedState.set( feedName, { visibleTrackIds: newVisibleIds } ); + + this.dbg( + `LodManager: "${ feedName }" zoom=${ this.map.getZoom() } ` + + `${ prevVisibleIds.size }→${ newVisibleIds.size } track markers` + ); + } +} diff --git a/src/map-engine/MarkerManager.ts b/src/map-engine/MarkerManager.ts index b930137..0906663 100644 --- a/src/map-engine/MarkerManager.ts +++ b/src/map-engine/MarkerManager.ts @@ -356,6 +356,10 @@ export class MarkerManager { return icon; } + getMarkerForPoint( id: number ): L.Marker | L.CircleMarker | undefined { + return this.markerById.get( id ); + } + /** * Remove all document event listeners registered by this instance. */ diff --git a/src/map-engine/Spotmap.ts b/src/map-engine/Spotmap.ts index 75acd39..cf9e4c8 100644 --- a/src/map-engine/Spotmap.ts +++ b/src/map-engine/Spotmap.ts @@ -24,6 +24,7 @@ import { LineManager } from './LineManager'; import { BoundsManager } from './BoundsManager'; import { ButtonManager } from './ButtonManager'; import { TableRenderer } from './TableRenderer'; +import { LodManager } from './LodManager'; /** * Main Spotmap orchestrator. @@ -49,6 +50,7 @@ export class Spotmap { private latestUnixtimeByFeed: Map< string, number > = new Map(); private onVisibilityChange: ( () => void ) | null = null; private reloadBody: AjaxRequestBody | null = null; + private lodManager: LodManager | null = null; constructor( options: SpotmapOptions ) { this.options = options; @@ -293,6 +295,24 @@ export class Spotmap { this.layerManager.addFeedsToMap(); this.addLastPointMarkers(); + this.lodManager = new LodManager( + this.map, + this.layers, + this.markerManager, + dbg + ); + this.lineManager.applyArrows(); + this.layerManager.addOverlays(); + // Register before fitBounds so moveend is caught whether the + // animation is synchronous or asynchronous. By the time the + // handler fires, fitBounds's own zoomend has already passed, so + // the registered zoomend listener only catches user-initiated zooms. + this.map.once( 'moveend', () => { + if ( ! this._destroyed ) { + this.lodManager?.start(); + } + } ); + if ( ( response.empty || response.error ) && ( ! this.options.gpx || this.options.gpx.length === 0 ) @@ -302,9 +322,6 @@ export class Spotmap { this.boundsManager.fitBounds( this.options.mapcenter ); } - this.lineManager.applyArrows(); - this.layerManager.addOverlays(); - if ( this.options.autoReload && ! response.empty ) { for ( const [ feedName, feed ] of Object.entries( this.layers.feeds @@ -383,6 +400,8 @@ export class Spotmap { this.tableRenderer?.destroy(); this.markerManager?.destroy(); this.dataFetcher?.abort(); + this.lodManager?.destroy(); + this.lodManager = null; if ( this.map ) { // Clear textpath text before removal: map.remove() internally calls @@ -624,6 +643,7 @@ export class Spotmap { ); this.markerManager.addPoint( entry ); this.lineManager.addPointToLine( entry ); + this.lodManager?.onPointAppended( entry ); if ( this.options.styles?.[ feedName ]?.lastPoint ) { diff --git a/src/map-engine/constants.ts b/src/map-engine/constants.ts index 48b7bcb..c5be9cd 100644 --- a/src/map-engine/constants.ts +++ b/src/map-engine/constants.ts @@ -34,3 +34,12 @@ export const Z_INDEX_LAST_POINT = 1900; export const LINE_ARROW_CHAR = ' \u25BA '; export const LINE_ARROW_FONT_SIZE = 7; export const LINE_ARROW_OFFSET = 2; + +/** Polyline smooth factor — higher = fewer SVG vertices at low zoom, faster textpath */ +export const LINE_SMOOTH_FACTOR = 5; + +/** Debounce for zoom-driven updates */ +export const LOD_DEBOUNCE_MS = 150; + +/** Minimum TRACK point count before LOD simplification is applied */ +export const LOD_MIN_TRACK_POINTS = 10_000; diff --git a/src/spotmap/edit.jsx b/src/spotmap/edit.jsx index 7442f3e..fcdcb61 100644 --- a/src/spotmap/edit.jsx +++ b/src/spotmap/edit.jsx @@ -264,7 +264,7 @@ export default function Edit( { attributes, setAttributes } ) { // If the DB already holds more than 10 000 points, default to no feeds // so the editor doesn't immediately try to render a huge dataset. - const enabledNames = totalPoints > 10000 ? [] : feedNames; + const enabledNames = totalPoints > 50000 ? [] : feedNames; const defaultFeedObjects = enabledNames.map( ( name, i ) => ( { name, ...DEFAULT_FEED_STYLE, @@ -312,7 +312,7 @@ export default function Edit( { attributes, setAttributes } ) { } // Skip map init while point count is still loading, or if selected feeds exceed the editor threshold. - if ( selectedPoints === null || selectedPoints > 10000 ) { + if ( selectedPoints === null || selectedPoints > 50000 ) { return; } @@ -852,7 +852,7 @@ export default function Edit( { attributes, setAttributes } ) { }, } ) } > - { selectedPoints !== null && selectedPoints > 10000 ? ( + { selectedPoints !== null && selectedPoints > 50000 ? (
{ __(