mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
performance improvements
This commit is contained in:
1 parent
9033401094
commit
48325e6016
5 files changed
+107
-120
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<number, { id: string; features: AnchorFeature[] }> = 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',
|
||||
`<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() {
|
||||
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);
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <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._store.set(await this.read(backend));
|
||||
const statistics = await this.read(backend);
|
||||
return () => this._store.set(statistics);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user