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 {
|
2026-02-14 14:35:35 +01:00
|
|
|
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,
|
2026-02-14 14:35:35 +01:00
|
|
|
} 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';
|
2026-02-14 14:35:35 +01:00
|
|
|
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);
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
|
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);
|
2026-02-14 14:35:35 +01:00
|
|
|
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;
|
2026-02-14 14:35:35 +01:00
|
|
|
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);
|
2026-01-31 12:57:08 +01:00
|
|
|
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() {
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
},
|
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-02-14 14:35:35 +01:00
|
|
|
}
|
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);
|
2026-01-31 12:57:08 +01:00
|
|
|
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);
|
2026-01-31 12:57:08 +01:00
|
|
|
map_?.off('click', this.appendAnchorBinded);
|
|
|
|
|
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
2026-10-31 18:08:22 +01:00
|
|
|
this.updateFileListeners();
|
2026-02-14 14:35:35 +01:00
|
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
if (map_?.getLayer(LAYER_ID)) {
|
|
|
|
|
map_?.removeLayer(LAYER_ID);
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
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
|
|
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01: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();
|
2026-02-14 14:35:35 +01:00
|
|
|
this.removeTemporaryAnchor();
|
2026-10-31 18:08:22 +01:00
|
|
|
if (!permanent) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
anchor = permanent;
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
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),
|
2026-02-14 14:35:35 +01:00
|
|
|
};
|
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. */
|
2024-09-04 14:42:26 +02:00
|
|
|
turnIntoPermanentAnchor() {
|
2026-10-31 18:08:22 +01:00
|
|
|
const temporary = this.temporaryAnchor;
|
|
|
|
|
const revision = this.anchorData?.revision;
|
|
|
|
|
if (!temporary || revision === undefined) {
|
2026-02-14 14:35:35 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-10-31 18:08:22 +01:00
|
|
|
engine.insertAnchor(revision, temporary.lng, temporary.lat);
|
|
|
|
|
this.removeTemporaryAnchor();
|
2024-09-04 14:42:26 +02:00
|
|
|
}
|
|
|
|
|
|
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 ?? [];
|
2026-02-14 15:05:23 +01:00
|
|
|
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-02-14 15:05:23 +01:00
|
|
|
) {
|
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
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
|
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]
|
2026-02-14 14:35:35 +01:00
|
|
|
);
|
2024-07-16 12:17:23 +02:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
}
|
2026-10-31 18:08:22 +01:00
|
|
|
} else if (other.index > anchor.index) {
|
|
|
|
|
if (!nextAnchor || other.index < nextAnchor.index) {
|
|
|
|
|
nextAnchor = other;
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-14 14:35:35 +01:00
|
|
|
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]
|
2026-02-14 14:35:35 +01:00
|
|
|
);
|
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());
|
|
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
|
|
|
|
|
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
|
2026-02-14 14:35:35 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
2026-10-31 18:08:22 +01:00
|
|
|
if (!anchor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
if (e.originalEvent.shiftKey) {
|
|
|
|
|
this.deleteAnchor(anchor);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
canChangeStart.set(this.canStartLoopAt(anchor));
|
2026-02-14 14:35:35 +01:00
|
|
|
|
|
|
|
|
this.popup.setLngLat(e.lngLat);
|
|
|
|
|
this.popup.addTo(e.target);
|
|
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
const deleteThisAnchor = this.getDeleteAnchor(anchor);
|
2026-02-14 14:35:35 +01:00
|
|
|
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);
|
2026-02-14 14:35:35 +01:00
|
|
|
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;
|
2026-02-14 14:35:35 +01:00
|
|
|
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;
|
2026-02-14 14:35:35 +01:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
onMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
2026-10-31 18:08:22 +01:00
|
|
|
if (this.draggedAnchorId === null || e.point.equals(this.draggingStartingPosition)) {
|
2026-02-14 14:35:35 +01:00
|
|
|
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();
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-14 14:35:35 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.point.equals(this.draggingStartingPosition)) {
|
2026-10-31 18:08:22 +01:00
|
|
|
this.draggedAnchorId = null;
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
this.draggedAnchorId = null;
|
2026-04-19 16:34:06 +02:00
|
|
|
this.lastDraggedAnchorEventTime = Date.now();
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showTemporaryAnchor(e: MapLayerMouseEvent) {
|
|
|
|
|
const map_ = get(map);
|
2026-10-31 18:08:22 +01:00
|
|
|
const segmentIds = this.anchorData?.segmentIds;
|
|
|
|
|
if (!map_ || !segmentIds) {
|
2026-02-14 14:35:35 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
if (this.draggedAnchorId !== null) {
|
2026-02-14 14:35:35 +01:00
|
|
|
// 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) {
|
2026-02-14 14:35:35 +01:00
|
|
|
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 };
|
2026-02-14 14:35:35 +01:00
|
|
|
|
|
|
|
|
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) {
|
2026-02-14 14:35:35 +01:00
|
|
|
// 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 ||
|
2026-02-14 14:35:35 +01:00
|
|
|
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-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-31 18:08:22 +01:00
|
|
|
temporaryAnchorCloseToOtherAnchor(e: MapMouseEvent) {
|
2026-02-14 14:35:35 +01:00
|
|
|
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) {
|
2026-02-14 14:35:35 +01:00
|
|
|
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;
|
2026-02-14 14:35:35 +01:00
|
|
|
if (source) {
|
|
|
|
|
source.updateData({
|
|
|
|
|
update: [
|
|
|
|
|
{
|
2026-10-31 18:08:22 +01:00
|
|
|
id: anchor.id,
|
2026-02-14 14:35:35 +01:00
|
|
|
newGeometry: {
|
|
|
|
|
type: 'Point',
|
2026-10-31 18:08:22 +01:00
|
|
|
coordinates: [coordinates.lng, coordinates.lat],
|
2026-02-14 14:35:35 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
2026-02-14 14:35:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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] });
|
2026-02-14 14:35:35 +01:00
|
|
|
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
|
|
|
|
mapCursor.notify(MapCursorState.ANCHOR_HOVER, false);
|
|
|
|
|
this.temporaryAnchor = null;
|
|
|
|
|
}
|
2024-04-30 15:19:50 +02:00
|
|
|
}
|