Files
gpx.studio/website/src/lib/components/toolbar/tools/routing/routing-controls.ts
T

869 lines
30 KiB
TypeScript
Raw Normal View History

2026-10-31 18:08:22 +01:00
import { distance, type Coordinates } from '$lib/geo/coordinates';
import { get, writable } from 'svelte/store';
2026-07-24 19:06:39 +02:00
import {
type GeoJSONSource,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
2026-10-31 18:08:22 +01:00
type MapMouseEvent,
2026-07-24 19:06:39 +02:00
Point,
2026-10-31 18:08:22 +01:00
type Popup,
} from 'maplibre-gl';
2025-10-18 16:10:08 +02:00
import { route } from './routing';
2025-02-02 11:17:22 +01:00
import { toast } from 'svelte-sonner';
2026-10-31 18:08:22 +01:00
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
2025-10-18 16:10:08 +02:00
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
2026-10-31 18:08:22 +01:00
import { i18n } from '$lib/i18n/i18n.svelte';
2025-10-18 16:10:08 +02:00
import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
2026-10-31 18:08:22 +01:00
import { settings } from '$lib/state/settings';
import { closestPointIndexIn } from '$lib/geo/closest-point';
import { coversSegments } from '$lib/engine/selection-helpers';
import {
engine,
type Selection,
type SelectionAnchors,
type SelectionStatistics,
type StatisticsRequest,
} from '$lib/engine';
2024-04-25 16:41:06 +02:00
2025-10-18 16:10:08 +02:00
const { streetViewSource } = settings;
2026-10-31 18:08:22 +01:00
/** Whether the popup of an anchor can offer to start the loop there. */
2024-06-12 14:56:29 +02:00
export const canChangeStart = writable(false);
2026-10-31 18:08:22 +01:00
/** The source and layer of the anchors. */
const LAYER_ID = 'routing-controls';
2024-07-24 15:17:41 +02:00
2026-10-31 18:08:22 +01:00
/** An anchor of a segment of the selection, a trackpoint that can be dragged to reroute it. */
type Anchor = {
/** Position among the anchors of the selection, which identifies its feature on the map. */
id: number;
/** Index of the trackpoint in the selection. */
index: number;
/** Position among the segments of the selection. */
segment: number;
/** Lowest map zoom level at which the anchor is shown. */
zoom: number;
lng: number;
lat: number;
};
type AnchorProperties = { anchorIndex: number; minZoom: number };
type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
function isRoutable(selection: Selection) {
return coversSegments(selection);
}
/**
* The anchors of the selection of the engine, shown on the map where the user can drag them to
* reroute the segments, or click on the segments to add some. Every interaction is turned into a
* command of the engine, which finds the files and segments by the indices of the trackpoints in
* the selection.
*/
2024-04-25 16:41:06 +02:00
export class RoutingControls {
2026-10-31 18:08:22 +01:00
active = false;
anchors: Anchor[] = [];
statistics: SelectionStatistics | undefined = undefined;
/** What the tool needs from the engine, while it shows the anchors. */
request: StatisticsRequest = engine.requestStatistics();
selection: Selection = { type: 'empty' };
/** Ids of the files whose line layer is listened to. */
listenedFileIds: string[] = [];
2026-07-24 19:06:39 +02:00
popup: Popup;
2024-04-26 13:33:17 +02:00
popupElement: HTMLElement;
2026-10-31 18:08:22 +01:00
unsubscribes: (() => void)[] = [];
2024-04-25 16:41:06 +02:00
2026-10-31 18:08:22 +01:00
updateBinded: () => void = this.update.bind(this);
appendAnchorBinded: (e: MapMouseEvent) => void = this.appendAnchor.bind(this);
2026-04-19 16:34:06 +02:00
addIntermediateAnchorBinded: (e: MapMouseEvent) => void = this.addIntermediateAnchor.bind(this);
2026-10-31 18:08:22 +01:00
/** Id of the anchor that is dragged: `anchors.length` for the temporary anchor. */
draggedAnchorId: number | null = null;
lastDraggedAnchorEventTime = 0;
2026-07-24 19:06:39 +02:00
draggingStartingPosition: Point = new Point(0, 0);
onMouseEnterBinded: () => void = this.onMouseEnter.bind(this);
onMouseLeaveBinded: () => void = this.onMouseLeave.bind(this);
onClickBinded: (e: MapLayerMouseEvent) => void = this.onClick.bind(this);
onMouseDownBinded: (e: MapLayerMouseEvent) => void = this.onMouseDown.bind(this);
onTouchStartBinded: (e: MapLayerTouchEvent) => void = this.onTouchStart.bind(this);
onMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.onMouseMove.bind(this);
onMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.onMouseUp.bind(this);
2026-10-31 18:08:22 +01:00
/**
* Where the pointer hovers a segment of the selection: an anchor that can be dragged or
* clicked to be added.
*/
temporaryAnchor: (Coordinates & { segment: number }) | null = null;
showTemporaryAnchorBinded: (e: MapLayerMouseEvent) => void =
this.showTemporaryAnchor.bind(this);
updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this);
2024-04-25 16:41:06 +02:00
2026-10-31 18:08:22 +01:00
constructor(popup: Popup, popupElement: HTMLElement) {
2024-04-26 13:33:17 +02:00
this.popup = popup;
this.popupElement = popupElement;
2026-10-31 18:08:22 +01:00
this.unsubscribes.push(engine.selection.subscribe(this.onSelection.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(engine.statistics.subscribe(this.onStatistics.bind(this)));
this.unsubscribes.push(engine.files.subscribe(this.updateFileListeners.bind(this)));
2024-05-24 16:37:26 +02:00
}
2026-10-31 18:08:22 +01:00
onSelection(selection: Selection) {
this.selection = selection;
this.update();
}
onStatistics(statistics: SelectionStatistics) {
this.statistics = statistics;
this.update();
}
get anchorData(): SelectionAnchors | undefined {
return this.statistics?.anchors;
}
update() {
const routing = get(currentTool) === Tool.ROUTING && isRoutable(this.selection);
this.request.set(routing ? ['anchors'] : []);
2024-05-24 16:37:26 +02:00
if (!routing) {
if (this.active) {
this.remove();
}
return;
}
2026-10-31 18:08:22 +01:00
if (!this.active) {
this.add();
2024-05-24 16:37:26 +02:00
}
2026-10-31 18:08:22 +01:00
this.updateControls();
2024-04-25 16:41:06 +02:00
}
add() {
2025-10-18 16:10:08 +02:00
const map_ = get(map);
const layerEventManager = map.layerEventManager;
if (!map_ || !layerEventManager) {
2025-10-18 16:10:08 +02:00
return;
}
2024-05-24 16:37:26 +02:00
this.active = true;
2026-10-31 18:08:22 +01:00
map_.on('style.load', this.updateBinded);
2025-10-18 16:10:08 +02:00
map_.on('click', this.appendAnchorBinded);
2026-10-31 18:08:22 +01:00
this.updateFileListeners();
}
2024-04-25 16:41:06 +02:00
2026-10-31 18:08:22 +01:00
/** Listens to the lines of the files, to add anchors where the pointer hovers a segment. */
updateFileListeners() {
const layerEventManager = map.layerEventManager;
if (!layerEventManager) {
return;
}
const fileIds = this.active ? [...get(engine.files).keys()] : [];
for (const fileId of this.listenedFileIds) {
if (!fileIds.includes(fileId)) {
layerEventManager.off('mousemove', fileId, this.showTemporaryAnchorBinded);
layerEventManager.off('click', fileId, this.addIntermediateAnchorBinded);
}
}
for (const fileId of fileIds) {
if (!this.listenedFileIds.includes(fileId)) {
layerEventManager.on('mousemove', fileId, this.showTemporaryAnchorBinded);
layerEventManager.on('click', fileId, this.addIntermediateAnchorBinded);
}
}
this.listenedFileIds = fileIds;
}
/** The anchors of the selection, from the statistics of the engine. */
readAnchors(): Anchor[] {
const statistics = this.statistics;
const data = this.anchorData;
if (!statistics || !data) {
return [];
}
const anchors: Anchor[] = [];
let segment = 0;
for (let id = 0; id < data.indices.length; id++) {
const index = data.indices[id];
// the anchors are sorted: the segment can only be the same or a following one
while (
segment + 1 < data.segmentStarts.length &&
data.segmentStarts[segment + 1] <= index
) {
segment++;
}
anchors.push({
id,
index,
segment,
zoom: data.zooms[id],
lng: statistics.lng[index],
lat: statistics.lat[index],
});
}
return anchors;
}
/** First trackpoint of a segment of the selection, and the one after its last one. */
segmentRange(segment: number): [number, number] {
const starts = this.anchorData?.segmentStarts;
const length = this.statistics?.length ?? 0;
if (!starts) {
return [0, 0];
}
return [starts[segment], segment + 1 < starts.length ? starts[segment + 1] : length];
2024-04-25 16:41:06 +02:00
}
2025-02-02 11:17:22 +01:00
updateControls() {
const map_ = get(map);
const layerEventManager = map.layerEventManager;
2026-10-31 18:08:22 +01:00
if (!map_ || !layerEventManager || !this.active) {
2024-05-03 15:59:34 +02:00
return;
}
2026-10-31 18:08:22 +01:00
this.anchors = this.readAnchors();
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;
2026-10-31 18:08:22 +01:00
// 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 GeoJSONSource | undefined;
const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features };
if (source) {
source.setData(data);
} else {
map_.addSource(LAYER_ID, { type: 'geojson', data, promoteId: 'anchorIndex' });
2024-04-30 15:19:50 +02:00
}
2026-10-31 18:08:22 +01:00
if (!map_.getLayer(LAYER_ID)) {
// bigger on small screens, to be touched
const scale = window.innerWidth > 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',
},
2026-10-31 18:08:22 +01:00
filter: ['>=', ['zoom'], ['get', 'minZoom']],
},
ANCHOR_LAYER_KEY.routingControls
);
2024-04-25 19:02:34 +02:00
2026-10-31 18:08:22 +01:00
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);
}
2026-10-31 18:08:22 +01:00
} catch {
// No reliable way to check if the map is ready to add sources and layers
}
2024-04-25 16:41:06 +02:00
}
remove() {
2025-10-18 16:10:08 +02:00
const map_ = get(map);
const layerEventManager = map.layerEventManager;
2025-10-18 16:10:08 +02:00
2024-05-24 16:37:26 +02:00
this.active = false;
2026-10-31 18:08:22 +01:00
this.anchors = [];
this.temporaryAnchor = null;
2024-05-24 16:37:26 +02:00
2026-10-31 18:08:22 +01:00
map_?.off('style.load', this.updateBinded);
map_?.off('click', this.appendAnchorBinded);
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
2026-10-31 18:08:22 +01:00
this.updateFileListeners();
2026-10-31 18:08:22 +01:00
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);
2026-10-31 18:08:22 +01:00
if (map_?.getLayer(LAYER_ID)) {
map_?.removeLayer(LAYER_ID);
}
2026-10-31 18:08:22 +01:00
if (map_?.getSource(LAYER_ID)) {
map_?.removeSource(LAYER_ID);
}
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
this.popup.remove();
2026-10-31 18:08:22 +01:00
}
2024-04-25 16:41:06 +02:00
2026-10-31 18:08:22 +01:00
position(index: number): Coordinates {
return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] };
2024-04-25 16:41:06 +02:00
}
async moveAnchor(anchor: Anchor, coordinates: Coordinates) {
2025-02-02 11:17:22 +01:00
// Move the anchor and update the route from and to the neighbouring anchors
2026-10-31 18:08:22 +01:00
const initialAnchor = anchor;
const initialCoordinates = { lng: anchor.lng, lat: anchor.lat };
if (anchor.id === this.anchors.length) {
2025-02-02 11:17:22 +01:00
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
2026-10-31 18:08:22 +01:00
const permanent = this.getPermanentAnchor();
this.removeTemporaryAnchor();
2026-10-31 18:08:22 +01:00
if (!permanent) {
return;
}
anchor = permanent;
}
2026-10-31 18:08:22 +01:00
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
2024-04-30 15:19:50 +02:00
2026-10-31 18:08:22 +01:00
const chain: Anchor[] = [];
const targets: Coordinates[] = [];
if (previousAnchor) {
chain.push(previousAnchor);
targets.push(this.position(previousAnchor.index));
}
chain.push(anchor);
targets.push(coordinates);
if (nextAnchor) {
chain.push(nextAnchor);
targets.push(this.position(nextAnchor.index));
2024-04-25 19:02:34 +02:00
}
2026-10-31 18:08:22 +01:00
const success = await this.routeBetweenAnchors(chain, targets);
2024-04-26 14:16:59 +02:00
2026-10-31 18:08:22 +01:00
if (!success && initialAnchor.id !== this.anchors.length) {
2025-02-02 11:17:22 +01:00
// Route failed, revert the anchor to the previous position
2026-10-31 18:08:22 +01:00
this.moveAnchorFeature(initialAnchor, initialCoordinates);
2024-04-27 09:42:55 +02:00
}
2024-04-25 19:02:34 +02:00
}
2026-10-31 18:08:22 +01:00
/** The trackpoint of the hovered segment that is the closest to the temporary anchor. */
getPermanentAnchor(): Anchor | undefined {
const temporary = this.temporaryAnchor;
const statistics = this.statistics;
if (!temporary || !statistics) {
return undefined;
2024-08-22 10:41:04 +02:00
}
2026-10-31 18:08:22 +01:00
const [start, end] = this.segmentRange(temporary.segment);
const index = closestPointIndexIn(statistics.lng, statistics.lat, start, end, temporary);
if (index === undefined) {
return undefined;
}
return {
id: this.anchors.length,
index,
segment: temporary.segment,
zoom: 0,
...this.position(index),
};
2024-04-26 19:34:46 +02:00
}
2026-10-31 18:08:22 +01:00
/** Makes an anchor of the hovered point of a segment. */
turnIntoPermanentAnchor() {
2026-10-31 18:08:22 +01:00
const temporary = this.temporaryAnchor;
const revision = this.anchorData?.revision;
if (!temporary || revision === undefined) {
return;
}
2026-10-31 18:08:22 +01:00
engine.insertAnchor(revision, temporary.lng, temporary.lat);
this.removeTemporaryAnchor();
}
2024-04-30 15:19:50 +02:00
getDeleteAnchor(anchor: Anchor) {
2024-04-26 13:33:17 +02:00
return () => this.deleteAnchor(anchor);
}
2025-02-02 11:17:22 +01:00
async deleteAnchor(anchor: Anchor) {
// Remove the anchor and route between the neighbouring anchors if they exist
2024-04-30 15:19:50 +02:00
this.popup.remove();
2026-10-31 18:08:22 +01:00
const revision = this.anchorData?.revision;
if (revision === undefined) {
return;
}
const [segmentStart, segmentEnd] = this.segmentRange(anchor.segment);
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
const noPoints = { lng: [], lat: [], ele: [] };
2024-04-26 14:16:59 +02:00
2025-02-02 11:17:22 +01:00
if (previousAnchor === null && nextAnchor === null) {
// Only one point, remove it
2026-10-31 18:08:22 +01:00
engine.route(revision, anchor.index, anchor.index + 1, noPoints);
2025-11-10 13:11:44 +01:00
} else if (previousAnchor === null && nextAnchor !== null) {
2025-02-02 11:17:22 +01:00
// First point, remove trackpoints until nextAnchor
2026-10-31 18:08:22 +01:00
engine.route(revision, segmentStart, nextAnchor.index, noPoints);
2025-11-10 13:11:44 +01:00
} else if (nextAnchor === null && previousAnchor !== null) {
2025-02-02 11:17:22 +01:00
// Last point, remove trackpoints from previousAnchor
2026-10-31 18:08:22 +01:00
engine.route(revision, previousAnchor.index + 1, segmentEnd, noPoints);
2025-11-10 13:11:44 +01:00
} else if (previousAnchor !== null && nextAnchor !== null) {
2025-02-02 11:17:22 +01:00
// Route between previousAnchor and nextAnchor
this.routeBetweenAnchors(
[previousAnchor, nextAnchor],
2026-10-31 18:08:22 +01:00
[this.position(previousAnchor.index), this.position(nextAnchor.index)]
2025-02-02 11:17:22 +01:00
);
2024-04-26 14:16:59 +02:00
}
2024-04-26 13:33:17 +02:00
}
2024-06-12 14:56:29 +02:00
getStartLoopAtAnchor(anchor: Anchor) {
return () => this.startLoopAtAnchor(anchor);
}
startLoopAtAnchor(anchor: Anchor) {
this.popup.remove();
2026-10-31 18:08:22 +01:00
const revision = this.anchorData?.revision;
if (revision !== undefined) {
engine.changeLoopStart(revision, anchor.index);
2024-06-12 14:56:29 +02:00
}
2026-10-31 18:08:22 +01:00
}
2024-06-12 14:56:29 +02:00
2026-10-31 18:08:22 +01:00
/** Whether the segment of the anchor is a loop that can start at the anchor. */
canStartLoopAt(anchor: Anchor): boolean {
const [start, end] = this.segmentRange(anchor.segment);
if (!this.statistics || anchor.index === start || end <= start) {
return false;
}
const first = this.position(start);
const last = this.position(end - 1);
// the end of the segment has to be close to its start, up to a kilometer
return distance(first, last) <= 1000;
2024-06-12 14:56:29 +02:00
}
2026-07-24 19:06:39 +02:00
async appendAnchor(e: MapMouseEvent) {
2025-02-02 11:17:22 +01:00
// Add a new anchor to the end of the last segment
2024-10-02 18:52:02 +02:00
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
2024-06-24 19:41:44 +02:00
return;
}
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) {
2026-04-19 16:34:06 +02:00
// Exit if anchor is being dragged
return;
}
2026-10-31 18:08:22 +01:00
const segmentIds = this.anchorData?.segmentIds ?? [];
if (
2026-10-31 18:08:22 +01:00
e.target
.queryRenderedFeatures(e.point, {
layers: [...this.listenedFileIds, LAYER_ID],
})
.some(
(feature) =>
feature.layer.id === LAYER_ID ||
segmentIds.includes(feature.properties?.segmentId)
)
) {
2026-10-31 18:08:22 +01:00
// Clicked on a routing control, or on a segment being routed (an anchor is inserted
// there instead): the other files do not stop the route
return;
}
2026-10-31 18:08:22 +01:00
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
2024-05-07 17:19:53 +02:00
}
2025-02-02 11:17:22 +01:00
async appendAnchorWithCoordinates(coordinates: Coordinates) {
// Add a new anchor to the end of the last segment
2026-10-31 18:08:22 +01:00
const statistics = this.statistics;
const revision = this.anchorData?.revision;
if (!statistics || revision === undefined) {
return;
}
2026-10-31 18:08:22 +01:00
if (this.anchors.length === 0) {
// Nothing in the selection yet: the first trackpoint, in a new segment if needed
engine.route(
revision,
0,
0,
{ lng: [coordinates.lng], lat: [coordinates.lat], ele: [0] },
[0]
);
return;
}
2026-10-31 18:08:22 +01:00
// Route from the last trackpoint (an anchor), as if the new anchor was also the last one
const last = this.anchors[this.anchors.length - 1];
const lastIndex = statistics.length - 1;
await this.routeBetweenAnchors(
[last, { ...last, id: 0, index: lastIndex }],
[this.position(last.index), coordinates]
);
}
2026-07-24 19:06:39 +02:00
addIntermediateAnchor(e: MapMouseEvent) {
2026-04-19 16:34:06 +02:00
e.preventDefault();
if (this.temporaryAnchor !== null) {
2026-04-19 16:49:32 +02:00
this.turnIntoPermanentAnchor();
2026-04-19 16:34:06 +02:00
return;
}
}
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
let previousAnchor: Anchor | null = null;
let nextAnchor: Anchor | null = null;
const zoom = get(map)?.getZoom() ?? 20;
2026-10-31 18:08:22 +01:00
for (const other of this.anchors) {
if (other.segment === anchor.segment && zoom >= other.zoom) {
if (other.index < anchor.index) {
if (!previousAnchor || other.index > previousAnchor.index) {
previousAnchor = other;
}
2026-10-31 18:08:22 +01:00
} else if (other.index > anchor.index) {
if (!nextAnchor || other.index < nextAnchor.index) {
nextAnchor = other;
}
}
}
}
return [previousAnchor, nextAnchor];
}
2026-10-31 18:08:22 +01:00
/**
* Routes through the positions `targets`, which are the ones of the anchors `anchors` (in a
* single segment) or where they were moved to, and replaces what is between the first and
* the last anchor by the route.
*/
async routeBetweenAnchors(anchors: Anchor[], targets: Coordinates[]): Promise<boolean> {
const revision = this.anchorData?.revision;
if (revision === undefined) {
return false;
}
if (anchors.length <= 1) {
2026-10-31 18:08:22 +01:00
// Only one anchor: it moves
return engine.route(
revision,
anchors[0].index,
anchors[0].index + 1,
{ lng: [targets[0].lng], lat: [targets[0].lat], ele: [0] },
[0]
);
2024-04-26 14:16:59 +02:00
}
2026-10-31 18:08:22 +01:00
let response;
try {
response = await route(targets);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(i18n._(message, message));
return false;
}
2024-04-26 14:16:59 +02:00
2026-10-31 18:08:22 +01:00
const first = anchors[0];
const last = anchors[anchors.length - 1];
const [segmentStart, segmentEnd] = this.segmentRange(first.segment);
const lastIndex = segmentEnd - 1;
2024-06-18 12:35:24 +02:00
2026-10-31 18:08:22 +01:00
// The anchors at the ends are kept as they are, except if they are the ends of the
// segment: then the route goes in their place.
const keepFirst =
first.index !== segmentStart &&
(first.index !== lastIndex ||
distance(targets[0], {
lat: response.lat[0],
lng: response.lng[0],
}) > 1);
const keepLast = last.index !== lastIndex;
2026-01-11 19:48:48 +01:00
2026-10-31 18:08:22 +01:00
const start = keepFirst ? first.index + 1 : first.index;
const end = keepLast ? last.index : last.index + 1;
2024-06-18 12:35:24 +02:00
2026-10-31 18:08:22 +01:00
// Anchors among the new points: the ends that are not kept, and the point of the route
// that is the closest to each intermediate anchor.
const newAnchors: number[] = [];
if (!keepFirst) {
newAnchors.push(0);
}
if (!keepLast) {
newAnchors.push(response.lng.length - 1);
}
for (let i = 1; i < anchors.length - 1; i++) {
const closest = closestPointIndexIn(
response.lng,
response.lat,
1,
response.lng.length - 1,
targets[i]
);
if (closest !== undefined) {
newAnchors.push(closest);
2024-07-18 10:43:38 +02:00
}
2024-06-18 12:35:24 +02:00
}
2026-10-31 18:08:22 +01:00
return engine.route(revision, start, end, response, newAnchors);
2024-04-25 16:41:06 +02:00
}
2024-05-24 16:37:26 +02:00
destroy() {
this.remove();
2026-10-31 18:08:22 +01:00
this.request.release();
2024-05-24 16:37:26 +02:00
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
}
onMouseEnter() {
mapCursor.notify(MapCursorState.ANCHOR_HOVER, true);
}
onMouseLeave() {
if (this.temporaryAnchor !== null) {
return;
}
mapCursor.notify(MapCursorState.ANCHOR_HOVER, false);
}
onClick(e: MapLayerMouseEvent) {
e.preventDefault();
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) {
2026-04-19 16:34:06 +02:00
// Exit if anchor is being dragged
return;
}
const anchor = this.anchors[e.features![0].properties.anchorIndex];
2026-10-31 18:08:22 +01:00
if (!anchor) {
return;
}
if (e.originalEvent.shiftKey) {
this.deleteAnchor(anchor);
return;
}
2026-10-31 18:08:22 +01:00
canChangeStart.set(this.canStartLoopAt(anchor));
this.popup.setLngLat(e.lngLat);
this.popup.addTo(e.target);
2026-10-31 18:08:22 +01:00
const deleteThisAnchor = this.getDeleteAnchor(anchor);
this.popupElement.addEventListener('delete', deleteThisAnchor); // Register the delete event for this anchor
2026-10-31 18:08:22 +01:00
const startLoopAtThisAnchor = this.getStartLoopAtAnchor(anchor);
this.popupElement.addEventListener('change-start', startLoopAtThisAnchor); // Register the start loop event for this anchor
this.popup.once('close', () => {
this.popupElement.removeEventListener('delete', deleteThisAnchor);
this.popupElement.removeEventListener('change-start', startLoopAtThisAnchor);
});
}
onMouseDown(e: MapLayerMouseEvent) {
const _map = get(map);
if (!_map) {
return;
}
e.preventDefault();
_map.dragPan.disable();
2026-10-31 18:08:22 +01:00
this.draggedAnchorId = e.features![0].properties.anchorIndex;
this.draggingStartingPosition = e.point;
_map.on('mousemove', this.onMouseMoveBinded);
_map.once('mouseup', this.onMouseUpBinded);
}
onTouchStart(e: MapLayerTouchEvent) {
if (e.points.length !== 1) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
2026-10-31 18:08:22 +01:00
this.draggedAnchorId = e.features![0].properties.anchorIndex;
this.draggingStartingPosition = e.point;
e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.onMouseMoveBinded);
_map.once('touchend', this.onMouseUpBinded);
}
2026-10-31 18:08:22 +01:00
/** The anchor with this id, which is the temporary one if it is `anchors.length`. */
anchorWithId(id: number): Anchor | undefined {
if (id === this.anchors.length && this.temporaryAnchor) {
return { id, index: 0, zoom: 0, ...this.temporaryAnchor };
}
return this.anchors[id];
}
onMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId === null || e.point.equals(this.draggingStartingPosition)) {
return;
}
mapCursor.notify(MapCursorState.ANCHOR_DRAGGING, true);
2026-10-31 18:08:22 +01:00
const anchor = this.anchorWithId(this.draggedAnchorId);
if (anchor) {
this.moveAnchorFeature(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat });
}
2026-04-19 16:34:06 +02:00
this.lastDraggedAnchorEventTime = Date.now();
}
onMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.ANCHOR_DRAGGING, false);
const _map = get(map);
if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.onMouseMoveBinded);
_map.off('touchmove', this.onMouseMoveBinded);
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId === null) {
return;
}
if (e.point.equals(this.draggingStartingPosition)) {
2026-10-31 18:08:22 +01:00
this.draggedAnchorId = null;
return;
}
2026-10-31 18:08:22 +01:00
const anchor = this.anchorWithId(this.draggedAnchorId);
if (anchor) {
this.moveAnchor(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat });
}
2026-10-31 18:08:22 +01:00
this.draggedAnchorId = null;
2026-04-19 16:34:06 +02:00
this.lastDraggedAnchorEventTime = Date.now();
}
showTemporaryAnchor(e: MapLayerMouseEvent) {
const map_ = get(map);
2026-10-31 18:08:22 +01:00
const segmentIds = this.anchorData?.segmentIds;
if (!map_ || !segmentIds) {
return;
}
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId !== null) {
// Do not not change the source point if it is already being dragged
return;
}
if (get(streetViewEnabled)) {
return;
}
2026-10-31 18:08:22 +01:00
// only the segments of the selection
const segment = segmentIds.indexOf(e.features![0].properties.segmentId);
if (segment < 0) {
return;
}
if (this.temporaryAnchorCloseToOtherAnchor(e)) {
return;
}
2026-10-31 18:08:22 +01:00
this.temporaryAnchor = { lng: e.lngLat.lng, lat: e.lngLat.lat, segment };
this.addTemporaryAnchor();
mapCursor.notify(MapCursorState.ANCHOR_HOVER, true);
map_.on('mousemove', this.updateTemporaryAnchorBinded);
}
updateTemporaryAnchor(e: MapMouseEvent) {
const map_ = get(map);
if (!map_ || !this.temporaryAnchor) {
return;
}
2026-10-31 18:08:22 +01:00
if (this.draggedAnchorId !== null) {
// Do not hide if it is being dragged, and stop listening for mousemove
map_.off('mousemove', this.updateTemporaryAnchorBinded);
return;
}
if (
2026-10-31 18:08:22 +01:00
e.point.dist(map_.project([this.temporaryAnchor.lng, this.temporaryAnchor.lat])) > 20 ||
this.temporaryAnchorCloseToOtherAnchor(e)
) {
// Hide if too far from the layer
this.removeTemporaryAnchor();
return;
}
// Update the position of the temporary anchor
2026-10-31 18:08:22 +01:00
const position = { lng: e.lngLat.lng, lat: e.lngLat.lat };
this.moveAnchorFeature(this.anchorWithId(this.anchors.length)!, position);
}
2026-10-31 18:08:22 +01:00
temporaryAnchorCloseToOtherAnchor(e: MapMouseEvent) {
const map_ = get(map);
if (!map_) {
return false;
}
const zoom = map_.getZoom();
2026-10-31 18:08:22 +01:00
for (const anchor of this.anchors) {
if (zoom >= anchor.zoom && e.point.dist(map_.project([anchor.lng, anchor.lat])) < 10) {
return true;
}
}
return false;
}
2026-10-31 18:08:22 +01:00
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined;
if (source) {
source.updateData({
update: [
{
2026-10-31 18:08:22 +01:00
id: anchor.id,
newGeometry: {
type: 'Point',
2026-10-31 18:08:22 +01:00
coordinates: [coordinates.lng, coordinates.lat],
},
},
],
});
}
}
addTemporaryAnchor() {
if (!this.temporaryAnchor) {
return;
}
2026-10-31 18:08:22 +01:00
const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined;
source?.updateData({
add: [
{
type: 'Feature',
id: this.anchors.length,
geometry: {
type: 'Point',
coordinates: [this.temporaryAnchor.lng, this.temporaryAnchor.lat],
},
properties: { anchorIndex: this.anchors.length, minZoom: 0 },
},
],
});
}
removeTemporaryAnchor() {
if (!this.temporaryAnchor) {
return;
}
const map_ = get(map);
2026-10-31 18:08:22 +01:00
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);
this.temporaryAnchor = null;
}
2024-04-30 15:19:50 +02:00
}