Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions src/map-engine/LineManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
219 changes: 219 additions & 0 deletions src/map-engine/LodManager.ts
Original file line number Diff line number Diff line change
@@ -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`
);
}
}
4 changes: 4 additions & 0 deletions src/map-engine/MarkerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
26 changes: 23 additions & 3 deletions src/map-engine/Spotmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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 )
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
) {
Expand Down
9 changes: 9 additions & 0 deletions src/map-engine/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 4 additions & 4 deletions src/spotmap/edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -852,7 +852,7 @@ export default function Edit( { attributes, setAttributes } ) {
},
} ) }
>
{ selectedPoints !== null && selectedPoints > 10000 ? (
{ selectedPoints !== null && selectedPoints > 50000 ? (
<div
style={ {
height: '100%',
Expand All @@ -873,7 +873,7 @@ export default function Edit( { attributes, setAttributes } ) {
'Map preview disabled — selected feeds contain'
) }{ ' ' }
{ selectedPoints.toLocaleString() }{ ' ' }
{ __( 'points (limit: 10,000).' ) }
{ __( 'points (limit: 50,000).' ) }
</strong>
<span>
{ __(
Expand Down
Loading