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, {
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user