performance improvements

This commit is contained in:
vcoppe committed 2026-10-18 10:50:34 +02:00
1 parent 9033401094
commit 48325e6016
5 files changed
+107 -120

No files matched your search

@@ -8,7 +8,7 @@
map_.queryRenderedFeatures(e.point, { map_.queryRenderedFeatures(e.point, {
layers: map_ layers: map_
.getLayersOrder() .getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')), .filter((layerId) => layerId == 'routing-controls'),
}).length }).length
) { ) {
// Clicked on routing control, ignoring // Clicked on routing control, ignoring
+10
View File
@@ -41,6 +41,16 @@ export class MapLibreGLMap {
) { ) {
this._maptilerKey = maptilerKey; this._maptilerKey = maptilerKey;
this._styleManager = new StyleManager(this._mapStore, this._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({ const map = new maplibregl.Map({
container: 'map', container: 'map',
style: { style: {
@@ -8,7 +8,6 @@ import maplibregl, {
} from 'maplibre-gl'; } from 'maplibre-gl';
import { route } from './routing'; import { route } from './routing';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { loadSVGIcon } from '$lib/utils';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor'; import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils'; 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. */ /** Whether the popup of an anchor can offer to start the loop there. */
export const canChangeStart = writable(false); export const canChangeStart = writable(false);
const MIN_ANCHOR_ZOOM = 0; /** The source and layer of the anchors. */
const MAX_ANCHOR_ZOOM = 22; const LAYER_ID = 'routing-controls';
/** An anchor of a segment of the selection, a trackpoint that can be dragged to reroute it. */ /** An anchor of a segment of the selection, a trackpoint that can be dragged to reroute it. */
type Anchor = { type Anchor = {
@@ -63,7 +62,6 @@ function isRoutable(selection: Selection) {
*/ */
export class RoutingControls { export class RoutingControls {
active = false; active = false;
layers: Map<number, { id: string; features: AnchorFeature[] }> = new Map();
anchors: Anchor[] = []; anchors: Anchor[] = [];
statistics: SelectionStatistics | undefined = undefined; statistics: SelectionStatistics | undefined = undefined;
/** What the tool needs from the engine, while it shows the anchors. */ /** 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); updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this);
constructor(popup: maplibregl.Popup, popupElement: HTMLElement) { 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.popup = popup;
this.popupElement = popupElement; this.popupElement = popupElement;
@@ -153,8 +148,6 @@ export class RoutingControls {
this.active = true; this.active = true;
this.loadIcons();
map_.on('style.load', this.updateBinded); map_.on('style.load', this.updateBinded);
map_.on('click', this.appendAnchorBinded); map_.on('click', this.appendAnchorBinded);
this.updateFileListeners(); this.updateFileListeners();
@@ -229,60 +222,54 @@ export class RoutingControls {
return; return;
} }
this.layers.forEach((layer) => (layer.features = []));
this.anchors = this.readAnchors(); this.anchors = this.readAnchors();
for (const anchor of this.anchors) { const features: AnchorFeature[] = this.anchors.map((anchor) => ({
this.layers.get(anchor.zoom)?.features.push({ type: 'Feature',
type: 'Feature', geometry: { type: 'Point', coordinates: [anchor.lng, anchor.lat] },
geometry: { type: 'Point', coordinates: [anchor.lng, anchor.lat] }, properties: { anchorIndex: anchor.id, minZoom: anchor.zoom },
properties: { anchorIndex: anchor.id, minZoom: anchor.zoom }, }));
});
}
// the anchors have changed, so has what is hovered // the anchors have changed, so has what is hovered
this.temporaryAnchor = null; this.temporaryAnchor = null;
this.layers.forEach((layer, zoom) => { // a single source for all the anchors, filtered by zoom: one update for the map to process
try { try {
const source = map_.getSource(layer.id) as maplibregl.GeoJSONSource | undefined; const source = map_.getSource(LAYER_ID) as maplibregl.GeoJSONSource | undefined;
const data: GeoJSON.FeatureCollection = { const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features };
type: 'FeatureCollection', if (source) {
features: layer.features, source.setData(data);
}; } else {
if (source) { map_.addSource(LAYER_ID, { type: 'geojson', data, promoteId: 'anchorIndex' });
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;
} }
});
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() { remove() {
@@ -298,26 +285,24 @@ export class RoutingControls {
map_?.off('mousemove', this.updateTemporaryAnchorBinded); map_?.off('mousemove', this.updateTemporaryAnchorBinded);
this.updateFileListeners(); this.updateFileListeners();
this.layers.forEach((layer) => { try {
try { layerEventManager?.off('mouseenter', LAYER_ID, this.onMouseEnterBinded);
layerEventManager?.off('mouseenter', layer.id, this.onMouseEnterBinded); layerEventManager?.off('mouseleave', LAYER_ID, this.onMouseLeaveBinded);
layerEventManager?.off('mouseleave', layer.id, this.onMouseLeaveBinded); layerEventManager?.off('click', LAYER_ID, this.onClickBinded);
layerEventManager?.off('click', layer.id, this.onClickBinded); layerEventManager?.off('contextmenu', LAYER_ID, this.onClickBinded);
layerEventManager?.off('contextmenu', layer.id, this.onClickBinded); layerEventManager?.off('mousedown', LAYER_ID, this.onMouseDownBinded);
layerEventManager?.off('mousedown', layer.id, this.onMouseDownBinded); layerEventManager?.off('touchstart', LAYER_ID, this.onTouchStartBinded);
layerEventManager?.off('touchstart', layer.id, this.onTouchStartBinded);
if (map_?.getLayer(layer.id)) { if (map_?.getLayer(LAYER_ID)) {
map_?.removeLayer(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_?.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(); this.popup.remove();
} }
@@ -463,16 +448,15 @@ export class RoutingControls {
// Exit if anchor is being dragged // Exit if anchor is being dragged
return; return;
} }
const controlLayerIds = [...this.layers.values()].map((l) => l.id);
const segmentIds = this.anchorData?.segmentIds ?? []; const segmentIds = this.anchorData?.segmentIds ?? [];
if ( if (
e.target e.target
.queryRenderedFeatures(e.point, { .queryRenderedFeatures(e.point, {
layers: [...this.listenedFileIds, ...controlLayerIds], layers: [...this.listenedFileIds, LAYER_ID],
}) })
.some( .some(
(feature) => (feature) =>
controlLayerIds.includes(feature.layer.id) || feature.layer.id === LAYER_ID ||
segmentIds.includes(feature.properties?.segmentId) segmentIds.includes(feature.properties?.segmentId)
) )
) { ) {
@@ -624,22 +608,6 @@ export class RoutingControls {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
} }
loadIcons() {
const _map = get(map);
if (!_map) {
return;
}
loadSVGIcon(
_map,
'routing-control',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8" fill="white" stroke="black" stroke-width="2" />
</svg>`,
_map.getCanvasContainer().offsetWidth > 1000 ? 56 : 80
);
}
onMouseEnter() { onMouseEnter() {
mapCursor.notify(MapCursorState.ANCHOR_HOVER, true); mapCursor.notify(MapCursorState.ANCHOR_HOVER, true);
} }
@@ -847,9 +815,7 @@ export class RoutingControls {
} }
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) { moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
const source = get(map)?.getSource( const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined;
this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? ''
) as GeoJSONSource | undefined;
if (source) { if (source) {
source.updateData({ source.updateData({
update: [ update: [
@@ -869,7 +835,7 @@ export class RoutingControls {
if (!this.temporaryAnchor) { if (!this.temporaryAnchor) {
return; 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({ source?.updateData({
add: [ add: [
{ {
@@ -890,7 +856,7 @@ export class RoutingControls {
return; return;
} }
const map_ = get(map); 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] }); source?.updateData({ remove: [this.anchors.length] });
map_?.off('mousemove', this.updateTemporaryAnchorBinded); map_?.off('mousemove', this.updateTemporaryAnchorBinded);
mapCursor.notify(MapCursorState.ANCHOR_HOVER, false); mapCursor.notify(MapCursorState.ANCHOR_HOVER, false);
+23 -16
View File
@@ -976,15 +976,30 @@ class Engine {
* rely on the order, the files and the selection being consistent when they are notified. * rely on the order, the files and the selection being consistent when they are notified.
*/ */
private async sync(backend: Backend, update: Outcome) { 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()]), Promise.all([backend.can_undo(), backend.can_redo(), backend.can_paste()]),
update.clipboardChanged ? backend.clipboard() : undefined, update.clipboardChanged ? backend.clipboard() : undefined,
update.selectionChanged ? backend.selection() : undefined, update.selectionChanged ? backend.selection() : undefined,
update.orderChanged ? backend.file_order() : undefined, update.orderChanged ? backend.file_order() : undefined,
]); filesChanged ? backend.desktop?.workspace() : undefined,
statisticsChanged ? this._statistics.refresh(backend) : undefined,
const files = get(this._files);
const [added, modified] = await Promise.all([
Promise.all( Promise.all(
update.added.map((id) => readFileState(backend, id, this._colors, this._visibility)) 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) { if (workspace) {
await this.refreshWorkspace(backend); this._workspace.set(workspace);
}
// 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);
} }
setStatistics?.();
} }
} }
+7 -3
View File
@@ -29,10 +29,14 @@ export class StatisticsReader {
/** @param enqueue Does a task after the calls to the engine that were made before it. */ /** @param enqueue Does a task after the calls to the engine that were made before it. */
constructor(private enqueue: <T>(task: () => Promise<T>) => Promise<T>) {} constructor(private enqueue: <T>(task: () => Promise<T>) => Promise<T>) {}
/** 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._backend = backend;
this._store.set(await this.read(backend)); const statistics = await this.read(backend);
return () => this._store.set(statistics);
} }
/** /**