From 48325e60164eb9ea34d78e9630050e52cca6a818 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 18 Oct 2026 10:50:34 +0200 Subject: [PATCH] performance improvements --- .../components/map/CoordinatesPopup.svelte | 2 +- website/src/lib/components/map/map.ts | 10 ++ .../toolbar/tools/routing/routing-controls.ts | 166 +++++++----------- website/src/lib/engine/engine.ts | 39 ++-- website/src/lib/engine/statistics.ts | 10 +- 5 files changed, 107 insertions(+), 120 deletions(-) diff --git a/website/src/lib/components/map/CoordinatesPopup.svelte b/website/src/lib/components/map/CoordinatesPopup.svelte index fa6f113e2..cf36fc599 100644 --- a/website/src/lib/components/map/CoordinatesPopup.svelte +++ b/website/src/lib/components/map/CoordinatesPopup.svelte @@ -8,7 +8,7 @@ map_.queryRenderedFeatures(e.point, { layers: map_ .getLayersOrder() - .filter((layerId) => layerId.startsWith('routing-controls')), + .filter((layerId) => layerId == 'routing-controls'), }).length ) { // Clicked on routing control, ignoring diff --git a/website/src/lib/components/map/map.ts b/website/src/lib/components/map/map.ts index 3c3423e8e..cf2e7028e 100644 --- a/website/src/lib/components/map/map.ts +++ b/website/src/lib/components/map/map.ts @@ -41,6 +41,16 @@ export class MapLibreGLMap { ) { this._maptilerKey = maptilerKey; this._styleManager = new StyleManager(this._mapStore, this._maptilerKey); + // MapLibre gives Safari more workers, but only recognizes it by its user agent: not + // WebKit in the desktop app, where the data of the sources was then processed one source + // after the other, and the map drew the files a frame before the anchors of the routing + // tool. Before the first map, which starts the workers. + const userAgent = navigator.userAgent; + if (userAgent.includes('AppleWebKit') && !userAgent.includes('Chrome')) { + maplibregl.setWorkerCount( + Math.max(Math.min(Math.floor(navigator.hardwareConcurrency / 2), 3), 1) + ); + } const map = new maplibregl.Map({ container: 'map', style: { diff --git a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts index 6b3433b86..36f4cac9d 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts @@ -8,7 +8,6 @@ import maplibregl, { } from 'maplibre-gl'; import { route } from './routing'; import { toast } from 'svelte-sonner'; -import { loadSVGIcon } from '$lib/utils'; import { mapCursor, MapCursorState } from '$lib/state/map-cursor'; import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { streetViewEnabled } from '$lib/components/map/street-view-control/utils'; @@ -30,8 +29,8 @@ const { streetViewSource } = settings; /** Whether the popup of an anchor can offer to start the loop there. */ export const canChangeStart = writable(false); -const MIN_ANCHOR_ZOOM = 0; -const MAX_ANCHOR_ZOOM = 22; +/** The source and layer of the anchors. */ +const LAYER_ID = 'routing-controls'; /** An anchor of a segment of the selection, a trackpoint that can be dragged to reroute it. */ type Anchor = { @@ -63,7 +62,6 @@ function isRoutable(selection: Selection) { */ export class RoutingControls { active = false; - layers: Map = new Map(); anchors: Anchor[] = []; statistics: SelectionStatistics | undefined = undefined; /** What the tool needs from the engine, while it shows the anchors. */ @@ -103,9 +101,6 @@ export class RoutingControls { updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this); constructor(popup: maplibregl.Popup, popupElement: HTMLElement) { - for (let zoom = MIN_ANCHOR_ZOOM; zoom <= MAX_ANCHOR_ZOOM; zoom++) { - this.layers.set(zoom, { id: `routing-controls-${zoom}`, features: [] }); - } this.popup = popup; this.popupElement = popupElement; @@ -153,8 +148,6 @@ export class RoutingControls { this.active = true; - this.loadIcons(); - map_.on('style.load', this.updateBinded); map_.on('click', this.appendAnchorBinded); this.updateFileListeners(); @@ -229,60 +222,54 @@ export class RoutingControls { return; } - this.layers.forEach((layer) => (layer.features = [])); this.anchors = this.readAnchors(); - for (const anchor of this.anchors) { - this.layers.get(anchor.zoom)?.features.push({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [anchor.lng, anchor.lat] }, - properties: { anchorIndex: anchor.id, minZoom: anchor.zoom }, - }); - } + const features: AnchorFeature[] = this.anchors.map((anchor) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [anchor.lng, anchor.lat] }, + properties: { anchorIndex: anchor.id, minZoom: anchor.zoom }, + })); // the anchors have changed, so has what is hovered this.temporaryAnchor = null; - this.layers.forEach((layer, zoom) => { - try { - const source = map_.getSource(layer.id) as maplibregl.GeoJSONSource | undefined; - const data: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: layer.features, - }; - if (source) { - source.setData(data); - } else { - map_.addSource(layer.id, { type: 'geojson', data, promoteId: 'anchorIndex' }); - } - - if (!map_.getLayer(layer.id)) { - map_.addLayer( - { - id: layer.id, - type: 'symbol', - source: layer.id, - layout: { - 'icon-image': 'routing-control', - 'icon-size': 0.25, - 'icon-padding': 0, - 'icon-allow-overlap': true, - }, - minzoom: zoom, - }, - ANCHOR_LAYER_KEY.routingControls - ); - - layerEventManager.on('mouseenter', layer.id, this.onMouseEnterBinded); - layerEventManager.on('mouseleave', layer.id, this.onMouseLeaveBinded); - layerEventManager.on('click', layer.id, this.onClickBinded); - layerEventManager.on('contextmenu', layer.id, this.onClickBinded); - layerEventManager.on('mousedown', layer.id, this.onMouseDownBinded); - layerEventManager.on('touchstart', layer.id, this.onTouchStartBinded); - } - } catch (e) { - // No reliable way to check if the map is ready to add sources and layers - return; + // a single source for all the anchors, filtered by zoom: one update for the map to process + try { + const source = map_.getSource(LAYER_ID) as maplibregl.GeoJSONSource | undefined; + const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; + if (source) { + source.setData(data); + } else { + map_.addSource(LAYER_ID, { type: 'geojson', data, promoteId: 'anchorIndex' }); } - }); + + if (!map_.getLayer(LAYER_ID)) { + // bigger on small screens, to be touched + const scale = map_.getCanvasContainer().offsetWidth > 1000 ? 0.7 : 1; + map_.addLayer( + { + id: LAYER_ID, + type: 'circle', + source: LAYER_ID, + paint: { + 'circle-radius': 7 * scale, + 'circle-color': 'white', + 'circle-stroke-width': 2 * scale, + 'circle-stroke-color': 'black', + }, + filter: ['>=', ['zoom'], ['get', 'minZoom']], + }, + ANCHOR_LAYER_KEY.routingControls + ); + + layerEventManager.on('mouseenter', LAYER_ID, this.onMouseEnterBinded); + layerEventManager.on('mouseleave', LAYER_ID, this.onMouseLeaveBinded); + layerEventManager.on('click', LAYER_ID, this.onClickBinded); + layerEventManager.on('contextmenu', LAYER_ID, this.onClickBinded); + layerEventManager.on('mousedown', LAYER_ID, this.onMouseDownBinded); + layerEventManager.on('touchstart', LAYER_ID, this.onTouchStartBinded); + } + } catch (e) { + // No reliable way to check if the map is ready to add sources and layers + } } remove() { @@ -298,26 +285,24 @@ export class RoutingControls { map_?.off('mousemove', this.updateTemporaryAnchorBinded); this.updateFileListeners(); - this.layers.forEach((layer) => { - try { - layerEventManager?.off('mouseenter', layer.id, this.onMouseEnterBinded); - layerEventManager?.off('mouseleave', layer.id, this.onMouseLeaveBinded); - layerEventManager?.off('click', layer.id, this.onClickBinded); - layerEventManager?.off('contextmenu', layer.id, this.onClickBinded); - layerEventManager?.off('mousedown', layer.id, this.onMouseDownBinded); - layerEventManager?.off('touchstart', layer.id, this.onTouchStartBinded); + try { + layerEventManager?.off('mouseenter', LAYER_ID, this.onMouseEnterBinded); + layerEventManager?.off('mouseleave', LAYER_ID, this.onMouseLeaveBinded); + layerEventManager?.off('click', LAYER_ID, this.onClickBinded); + layerEventManager?.off('contextmenu', LAYER_ID, this.onClickBinded); + layerEventManager?.off('mousedown', LAYER_ID, this.onMouseDownBinded); + layerEventManager?.off('touchstart', LAYER_ID, this.onTouchStartBinded); - if (map_?.getLayer(layer.id)) { - map_?.removeLayer(layer.id); - } - - if (map_?.getSource(layer.id)) { - map_?.removeSource(layer.id); - } - } catch (e) { - // No reliable way to check if the map is ready to remove sources and layers + if (map_?.getLayer(LAYER_ID)) { + map_?.removeLayer(LAYER_ID); } - }); + + if (map_?.getSource(LAYER_ID)) { + map_?.removeSource(LAYER_ID); + } + } catch (e) { + // No reliable way to check if the map is ready to remove sources and layers + } this.popup.remove(); } @@ -463,16 +448,15 @@ export class RoutingControls { // Exit if anchor is being dragged return; } - const controlLayerIds = [...this.layers.values()].map((l) => l.id); const segmentIds = this.anchorData?.segmentIds ?? []; if ( e.target .queryRenderedFeatures(e.point, { - layers: [...this.listenedFileIds, ...controlLayerIds], + layers: [...this.listenedFileIds, LAYER_ID], }) .some( (feature) => - controlLayerIds.includes(feature.layer.id) || + feature.layer.id === LAYER_ID || segmentIds.includes(feature.properties?.segmentId) ) ) { @@ -624,22 +608,6 @@ export class RoutingControls { this.unsubscribes.forEach((unsubscribe) => unsubscribe()); } - loadIcons() { - const _map = get(map); - if (!_map) { - return; - } - - loadSVGIcon( - _map, - 'routing-control', - ` - - `, - _map.getCanvasContainer().offsetWidth > 1000 ? 56 : 80 - ); - } - onMouseEnter() { mapCursor.notify(MapCursorState.ANCHOR_HOVER, true); } @@ -847,9 +815,7 @@ export class RoutingControls { } moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) { - const source = get(map)?.getSource( - this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? '' - ) as GeoJSONSource | undefined; + const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined; if (source) { source.updateData({ update: [ @@ -869,7 +835,7 @@ export class RoutingControls { if (!this.temporaryAnchor) { return; } - const source = get(map)?.getSource(this.layers.get(0)!.id) as GeoJSONSource | undefined; + const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined; source?.updateData({ add: [ { @@ -890,7 +856,7 @@ export class RoutingControls { return; } const map_ = get(map); - const source = map_?.getSource(this.layers.get(0)!.id) as GeoJSONSource | undefined; + const source = map_?.getSource(LAYER_ID) as GeoJSONSource | undefined; source?.updateData({ remove: [this.anchors.length] }); map_?.off('mousemove', this.updateTemporaryAnchorBinded); mapCursor.notify(MapCursorState.ANCHOR_HOVER, false); diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index 9a33940de..19508e8e9 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -976,15 +976,30 @@ class Engine { * rely on the order, the files and the selection being consistent when they are notified. */ private async sync(backend: Backend, update: Outcome) { - const [[canUndo, canRedo, canPaste], clipboard, selection, order] = await Promise.all([ + const filesChanged = + update.added.length + update.modified.length + update.removed.length > 0; + // the statistics depend on the selection, on its order, and on the files + const statisticsChanged = update.selectionChanged || update.orderChanged || filesChanged; + + // read in parallel: with the engine in another process (desktop app), each read is a + // round trip + const files = get(this._files); + const [ + [canUndo, canRedo, canPaste], + clipboard, + selection, + order, + workspace, + setStatistics, + added, + modified, + ] = await Promise.all([ Promise.all([backend.can_undo(), backend.can_redo(), backend.can_paste()]), update.clipboardChanged ? backend.clipboard() : undefined, update.selectionChanged ? backend.selection() : undefined, update.orderChanged ? backend.file_order() : undefined, - ]); - - const files = get(this._files); - const [added, modified] = await Promise.all([ + filesChanged ? backend.desktop?.workspace() : undefined, + statisticsChanged ? this._statistics.refresh(backend) : undefined, Promise.all( update.added.map((id) => readFileState(backend, id, this._colors, this._visibility)) ), @@ -1036,18 +1051,10 @@ class Engine { } }); - if (update.added.length + update.modified.length + update.removed.length > 0) { - await this.refreshWorkspace(backend); - } - - // the statistics depend on the selection, on its order, and on the files - if ( - update.selectionChanged || - update.orderChanged || - update.added.length + update.modified.length + update.removed.length > 0 - ) { - await this._statistics.refresh(backend); + if (workspace) { + this._workspace.set(workspace); } + setStatistics?.(); } } diff --git a/website/src/lib/engine/statistics.ts b/website/src/lib/engine/statistics.ts index d5a3fec4e..222936afb 100644 --- a/website/src/lib/engine/statistics.ts +++ b/website/src/lib/engine/statistics.ts @@ -29,10 +29,14 @@ export class StatisticsReader { /** @param enqueue Does a task after the calls to the engine that were made before it. */ constructor(private enqueue: (task: () => Promise) => Promise) {} - /** Reads the statistics of the selection again, after it or the files changed. */ - async refresh(backend: Backend) { + /** + * Reads the statistics of the selection again, after it or the files changed. Resolves to what + * puts them in the store, for the caller to do it together with its other updates. + */ + async refresh(backend: Backend): Promise<() => void> { this._backend = backend; - this._store.set(await this.read(backend)); + const statistics = await this.read(backend); + return () => this._store.set(statistics); } /**