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 ? (
{ __(