mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
Hovering a line in the routing tool shows a temporary anchor, which is drawn in the anchors' layer with the index one past the last anchor. Clicking it reached onClick, which looked that index up in the anchors, got undefined and threw reading its properties. The click now adds the anchor, as a click on the line does. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: vcoppe <vianney.coppe@gmail.com>
875 lines
31 KiB
TypeScript
875 lines
31 KiB
TypeScript
import { distance, type Coordinates } from '$lib/geo/coordinates';
|
|
import { get, writable } from 'svelte/store';
|
|
import {
|
|
type GeoJSONSource,
|
|
type MapLayerMouseEvent,
|
|
type MapLayerTouchEvent,
|
|
type MapMouseEvent,
|
|
Point,
|
|
type Popup,
|
|
} from 'maplibre-gl';
|
|
import { route } from './routing';
|
|
import { toast } from 'svelte-sonner';
|
|
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';
|
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
|
import { map } from '$lib/components/map/map';
|
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
|
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';
|
|
|
|
const { streetViewSource } = settings;
|
|
|
|
/** Whether the popup of an anchor can offer to start the loop there. */
|
|
export const canChangeStart = writable(false);
|
|
|
|
/** 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 = {
|
|
/** 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.
|
|
*/
|
|
export class RoutingControls {
|
|
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[] = [];
|
|
popup: Popup;
|
|
popupElement: HTMLElement;
|
|
unsubscribes: (() => void)[] = [];
|
|
|
|
updateBinded: () => void = this.update.bind(this);
|
|
appendAnchorBinded: (e: MapMouseEvent) => void = this.appendAnchor.bind(this);
|
|
addIntermediateAnchorBinded: (e: MapMouseEvent) => void = this.addIntermediateAnchor.bind(this);
|
|
|
|
/** Id of the anchor that is dragged: `anchors.length` for the temporary anchor. */
|
|
draggedAnchorId: number | null = null;
|
|
lastDraggedAnchorEventTime = 0;
|
|
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);
|
|
|
|
/**
|
|
* 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);
|
|
|
|
constructor(popup: Popup, popupElement: HTMLElement) {
|
|
this.popup = popup;
|
|
this.popupElement = popupElement;
|
|
|
|
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)));
|
|
}
|
|
|
|
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'] : []);
|
|
if (!routing) {
|
|
if (this.active) {
|
|
this.remove();
|
|
}
|
|
return;
|
|
}
|
|
if (!this.active) {
|
|
this.add();
|
|
}
|
|
this.updateControls();
|
|
}
|
|
|
|
add() {
|
|
const map_ = get(map);
|
|
const layerEventManager = map.layerEventManager;
|
|
if (!map_ || !layerEventManager) {
|
|
return;
|
|
}
|
|
|
|
this.active = true;
|
|
|
|
map_.on('style.load', this.updateBinded);
|
|
map_.on('click', this.appendAnchorBinded);
|
|
this.updateFileListeners();
|
|
}
|
|
|
|
/** 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];
|
|
}
|
|
|
|
updateControls() {
|
|
const map_ = get(map);
|
|
const layerEventManager = map.layerEventManager;
|
|
if (!map_ || !layerEventManager || !this.active) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
// 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' });
|
|
}
|
|
|
|
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',
|
|
},
|
|
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 {
|
|
// No reliable way to check if the map is ready to add sources and layers
|
|
}
|
|
}
|
|
|
|
remove() {
|
|
const map_ = get(map);
|
|
const layerEventManager = map.layerEventManager;
|
|
|
|
this.active = false;
|
|
this.anchors = [];
|
|
this.temporaryAnchor = null;
|
|
|
|
map_?.off('style.load', this.updateBinded);
|
|
map_?.off('click', this.appendAnchorBinded);
|
|
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
|
this.updateFileListeners();
|
|
|
|
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 {
|
|
// No reliable way to check if the map is ready to remove sources and layers
|
|
}
|
|
|
|
this.popup.remove();
|
|
}
|
|
|
|
position(index: number): Coordinates {
|
|
return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] };
|
|
}
|
|
|
|
async moveAnchor(anchor: Anchor, coordinates: Coordinates) {
|
|
// Move the anchor and update the route from and to the neighbouring anchors
|
|
const initialAnchor = anchor;
|
|
const initialCoordinates = { lng: anchor.lng, lat: anchor.lat };
|
|
if (anchor.id === this.anchors.length) {
|
|
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
|
const permanent = this.getPermanentAnchor();
|
|
this.removeTemporaryAnchor();
|
|
if (!permanent) {
|
|
return;
|
|
}
|
|
anchor = permanent;
|
|
}
|
|
|
|
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
|
|
|
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));
|
|
}
|
|
|
|
const success = await this.routeBetweenAnchors(chain, targets);
|
|
|
|
if (!success && initialAnchor.id !== this.anchors.length) {
|
|
// Route failed, revert the anchor to the previous position
|
|
this.moveAnchorFeature(initialAnchor, initialCoordinates);
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
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),
|
|
};
|
|
}
|
|
|
|
/** Makes an anchor of the hovered point of a segment. */
|
|
turnIntoPermanentAnchor() {
|
|
const temporary = this.temporaryAnchor;
|
|
const revision = this.anchorData?.revision;
|
|
if (!temporary || revision === undefined) {
|
|
return;
|
|
}
|
|
engine.insertAnchor(revision, temporary.lng, temporary.lat);
|
|
this.removeTemporaryAnchor();
|
|
}
|
|
|
|
getDeleteAnchor(anchor: Anchor) {
|
|
return () => this.deleteAnchor(anchor);
|
|
}
|
|
|
|
async deleteAnchor(anchor: Anchor) {
|
|
// Remove the anchor and route between the neighbouring anchors if they exist
|
|
this.popup.remove();
|
|
|
|
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: [] };
|
|
|
|
if (previousAnchor === null && nextAnchor === null) {
|
|
// Only one point, remove it
|
|
engine.route(revision, anchor.index, anchor.index + 1, noPoints);
|
|
} else if (previousAnchor === null && nextAnchor !== null) {
|
|
// First point, remove trackpoints until nextAnchor
|
|
engine.route(revision, segmentStart, nextAnchor.index, noPoints);
|
|
} else if (nextAnchor === null && previousAnchor !== null) {
|
|
// Last point, remove trackpoints from previousAnchor
|
|
engine.route(revision, previousAnchor.index + 1, segmentEnd, noPoints);
|
|
} else if (previousAnchor !== null && nextAnchor !== null) {
|
|
// Route between previousAnchor and nextAnchor
|
|
this.routeBetweenAnchors(
|
|
[previousAnchor, nextAnchor],
|
|
[this.position(previousAnchor.index), this.position(nextAnchor.index)]
|
|
);
|
|
}
|
|
}
|
|
|
|
getStartLoopAtAnchor(anchor: Anchor) {
|
|
return () => this.startLoopAtAnchor(anchor);
|
|
}
|
|
|
|
startLoopAtAnchor(anchor: Anchor) {
|
|
this.popup.remove();
|
|
|
|
const revision = this.anchorData?.revision;
|
|
if (revision !== undefined) {
|
|
engine.changeLoopStart(revision, anchor.index);
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
async appendAnchor(e: MapMouseEvent) {
|
|
// Add a new anchor to the end of the last segment
|
|
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
|
|
return;
|
|
}
|
|
if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) {
|
|
// Exit if anchor is being dragged
|
|
return;
|
|
}
|
|
const segmentIds = this.anchorData?.segmentIds ?? [];
|
|
if (
|
|
e.target
|
|
.queryRenderedFeatures(e.point, {
|
|
layers: [...this.listenedFileIds, LAYER_ID],
|
|
})
|
|
.some(
|
|
(feature) =>
|
|
feature.layer.id === LAYER_ID ||
|
|
segmentIds.includes(feature.properties?.segmentId)
|
|
)
|
|
) {
|
|
// 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;
|
|
}
|
|
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
|
|
}
|
|
|
|
async appendAnchorWithCoordinates(coordinates: Coordinates) {
|
|
// Add a new anchor to the end of the last segment
|
|
const statistics = this.statistics;
|
|
const revision = this.anchorData?.revision;
|
|
if (!statistics || revision === undefined) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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]
|
|
);
|
|
}
|
|
|
|
addIntermediateAnchor(e: MapMouseEvent) {
|
|
e.preventDefault();
|
|
|
|
if (this.temporaryAnchor !== null) {
|
|
this.turnIntoPermanentAnchor();
|
|
return;
|
|
}
|
|
}
|
|
|
|
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
|
let previousAnchor: Anchor | null = null;
|
|
let nextAnchor: Anchor | null = null;
|
|
|
|
const zoom = get(map)?.getZoom() ?? 20;
|
|
|
|
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;
|
|
}
|
|
} else if (other.index > anchor.index) {
|
|
if (!nextAnchor || other.index < nextAnchor.index) {
|
|
nextAnchor = other;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [previousAnchor, nextAnchor];
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// 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]
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const first = anchors[0];
|
|
const last = anchors[anchors.length - 1];
|
|
const [segmentStart, segmentEnd] = this.segmentRange(first.segment);
|
|
const lastIndex = segmentEnd - 1;
|
|
|
|
// 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;
|
|
|
|
const start = keepFirst ? first.index + 1 : first.index;
|
|
const end = keepLast ? last.index : last.index + 1;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
return engine.route(revision, start, end, response, newAnchors);
|
|
}
|
|
|
|
destroy() {
|
|
this.remove();
|
|
this.request.release();
|
|
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();
|
|
|
|
if (this.draggedAnchorId !== null || Date.now() - this.lastDraggedAnchorEventTime < 100) {
|
|
// Exit if anchor is being dragged
|
|
return;
|
|
}
|
|
|
|
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
|
if (!anchor) {
|
|
// The temporary anchor on the line is not one of the anchors (nor is a feature
|
|
// still rendered from before they were rebuilt), a click on it adds it
|
|
if (e.type === 'click') {
|
|
this.addIntermediateAnchor(e);
|
|
}
|
|
return;
|
|
}
|
|
if (e.originalEvent.shiftKey) {
|
|
this.deleteAnchor(anchor);
|
|
return;
|
|
}
|
|
|
|
canChangeStart.set(this.canStartLoopAt(anchor));
|
|
|
|
this.popup.setLngLat(e.lngLat);
|
|
this.popup.addTo(e.target);
|
|
|
|
const deleteThisAnchor = this.getDeleteAnchor(anchor);
|
|
this.popupElement.addEventListener('delete', deleteThisAnchor); // Register the delete event for this anchor
|
|
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();
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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) {
|
|
if (this.draggedAnchorId === null || e.point.equals(this.draggingStartingPosition)) {
|
|
return;
|
|
}
|
|
|
|
mapCursor.notify(MapCursorState.ANCHOR_DRAGGING, true);
|
|
|
|
const anchor = this.anchorWithId(this.draggedAnchorId);
|
|
if (anchor) {
|
|
this.moveAnchorFeature(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat });
|
|
}
|
|
|
|
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);
|
|
|
|
if (this.draggedAnchorId === null) {
|
|
return;
|
|
}
|
|
if (e.point.equals(this.draggingStartingPosition)) {
|
|
this.draggedAnchorId = null;
|
|
return;
|
|
}
|
|
|
|
const anchor = this.anchorWithId(this.draggedAnchorId);
|
|
if (anchor) {
|
|
this.moveAnchor(anchor, { lng: e.lngLat.lng, lat: e.lngLat.lat });
|
|
}
|
|
|
|
this.draggedAnchorId = null;
|
|
this.lastDraggedAnchorEventTime = Date.now();
|
|
}
|
|
|
|
showTemporaryAnchor(e: MapLayerMouseEvent) {
|
|
const map_ = get(map);
|
|
const segmentIds = this.anchorData?.segmentIds;
|
|
if (!map_ || !segmentIds) {
|
|
return;
|
|
}
|
|
|
|
if (this.draggedAnchorId !== null) {
|
|
// Do not not change the source point if it is already being dragged
|
|
return;
|
|
}
|
|
|
|
if (get(streetViewEnabled)) {
|
|
return;
|
|
}
|
|
|
|
// only the segments of the selection
|
|
const segment = segmentIds.indexOf(e.features![0].properties.segmentId);
|
|
if (segment < 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.temporaryAnchorCloseToOtherAnchor(e)) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (this.draggedAnchorId !== null) {
|
|
// Do not hide if it is being dragged, and stop listening for mousemove
|
|
map_.off('mousemove', this.updateTemporaryAnchorBinded);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
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
|
|
const position = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
|
this.moveAnchorFeature(this.anchorWithId(this.anchors.length)!, position);
|
|
}
|
|
|
|
temporaryAnchorCloseToOtherAnchor(e: MapMouseEvent) {
|
|
const map_ = get(map);
|
|
if (!map_) {
|
|
return false;
|
|
}
|
|
|
|
const zoom = map_.getZoom();
|
|
for (const anchor of this.anchors) {
|
|
if (zoom >= anchor.zoom && e.point.dist(map_.project([anchor.lng, anchor.lat])) < 10) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
|
|
const source = get(map)?.getSource(LAYER_ID) as GeoJSONSource | undefined;
|
|
if (source) {
|
|
source.updateData({
|
|
update: [
|
|
{
|
|
id: anchor.id,
|
|
newGeometry: {
|
|
type: 'Point',
|
|
coordinates: [coordinates.lng, coordinates.lat],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
addTemporaryAnchor() {
|
|
if (!this.temporaryAnchor) {
|
|
return;
|
|
}
|
|
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);
|
|
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;
|
|
}
|
|
}
|