2025-02-02 11:17:22 +01:00
|
|
|
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
|
|
|
|
|
import { get, writable, type Readable } from 'svelte/store';
|
2026-02-14 14:35:35 +01:00
|
|
|
import maplibregl, {
|
|
|
|
|
type MapMouseEvent,
|
|
|
|
|
type GeoJSONSource,
|
|
|
|
|
type MapLayerMouseEvent,
|
|
|
|
|
type MapLayerTouchEvent,
|
|
|
|
|
} 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';
|
|
|
|
|
import {
|
|
|
|
|
ListFileItem,
|
|
|
|
|
ListTrackItem,
|
|
|
|
|
ListTrackSegmentItem,
|
2025-10-05 19:34:05 +02:00
|
|
|
} from '$lib/components/file-list/file-list';
|
2026-02-14 14:35:35 +01:00
|
|
|
import { getClosestLinePoint, loadSVGIcon } from '$lib/utils';
|
2025-10-18 00:31:14 +02:00
|
|
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
2025-10-18 16:10:08 +02:00
|
|
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
|
|
|
|
import { settings } from '$lib/logic/settings';
|
|
|
|
|
import { selection } from '$lib/logic/selection';
|
|
|
|
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
|
|
|
|
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
|
|
|
|
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
|
|
|
|
import { i18n } from '$lib/i18n.svelte';
|
|
|
|
|
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-02-14 15:05:23 +01:00
|
|
|
import { MAX_ANCHOR_ZOOM, MIN_ANCHOR_ZOOM } from './simplify';
|
2025-10-18 16:10:08 +02:00
|
|
|
|
|
|
|
|
const { streetViewSource } = settings;
|
2024-06-12 14:56:29 +02:00
|
|
|
export const canChangeStart = writable(false);
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
type AnchorProperties = {
|
|
|
|
|
trackIndex: number;
|
|
|
|
|
segmentIndex: number;
|
|
|
|
|
pointIndex: number;
|
|
|
|
|
anchorIndex: number;
|
|
|
|
|
minZoom: number;
|
|
|
|
|
};
|
|
|
|
|
type Anchor = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
|
2024-07-24 15:17:41 +02:00
|
|
|
|
2024-04-25 16:41:06 +02:00
|
|
|
export class RoutingControls {
|
2024-05-24 16:37:26 +02:00
|
|
|
active: boolean = false;
|
2024-05-03 15:59:34 +02:00
|
|
|
fileId: string = '';
|
2024-05-08 21:31:54 +02:00
|
|
|
file: Readable<GPXFileWithStatistics | undefined>;
|
2026-02-14 15:05:23 +01:00
|
|
|
layers: Map<
|
|
|
|
|
number,
|
|
|
|
|
{
|
|
|
|
|
id: string;
|
|
|
|
|
anchors: GeoJSON.Feature<GeoJSON.Point, AnchorProperties>[];
|
|
|
|
|
}
|
|
|
|
|
> = new Map();
|
2026-02-14 14:35:35 +01:00
|
|
|
anchors: GeoJSON.Feature<GeoJSON.Point, AnchorProperties>[] = [];
|
2026-01-30 21:01:24 +01:00
|
|
|
popup: maplibregl.Popup;
|
2024-04-26 13:33:17 +02:00
|
|
|
popupElement: HTMLElement;
|
2025-02-02 11:17:22 +01:00
|
|
|
fileUnsubscribe: () => void = () => {};
|
2024-05-24 16:37:26 +02:00
|
|
|
unsubscribes: Function[] = [];
|
2024-04-25 16:41:06 +02:00
|
|
|
|
2026-03-27 21:23:51 +01:00
|
|
|
updateControlsBinded: () => void = this.updateControls.bind(this);
|
2026-02-14 14:35:35 +01:00
|
|
|
appendAnchorBinded: (e: MapMouseEvent) => void = this.appendAnchor.bind(this);
|
|
|
|
|
|
|
|
|
|
draggedAnchorIndex: number | null = null;
|
|
|
|
|
draggingStartingPosition: maplibregl.Point = new maplibregl.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);
|
|
|
|
|
|
|
|
|
|
temporaryAnchor: GeoJSON.Feature<GeoJSON.Point, AnchorProperties> | 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
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
constructor(
|
|
|
|
|
fileId: string,
|
|
|
|
|
file: Readable<GPXFileWithStatistics | undefined>,
|
2026-01-30 21:01:24 +01:00
|
|
|
popup: maplibregl.Popup,
|
2025-02-02 11:17:22 +01:00
|
|
|
popupElement: HTMLElement
|
|
|
|
|
) {
|
2024-05-03 15:59:34 +02:00
|
|
|
this.fileId = fileId;
|
2024-04-25 16:41:06 +02:00
|
|
|
this.file = file;
|
2026-02-14 15:05:23 +01:00
|
|
|
for (let zoom = MIN_ANCHOR_ZOOM; zoom <= MAX_ANCHOR_ZOOM; zoom++) {
|
|
|
|
|
this.layers.set(zoom, {
|
|
|
|
|
id: `routing-controls-${zoom}`,
|
|
|
|
|
anchors: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
2024-04-26 13:33:17 +02:00
|
|
|
this.popup = popup;
|
|
|
|
|
this.popupElement = popupElement;
|
|
|
|
|
|
2024-05-24 16:37:26 +02:00
|
|
|
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
|
|
|
|
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addIfNeeded() {
|
|
|
|
|
let routing = get(currentTool) === Tool.ROUTING;
|
|
|
|
|
if (!routing) {
|
|
|
|
|
if (this.active) {
|
|
|
|
|
this.remove();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, [
|
|
|
|
|
'waypoints',
|
|
|
|
|
]);
|
2024-05-24 16:37:26 +02:00
|
|
|
if (selected) {
|
2024-05-24 19:00:26 +02:00
|
|
|
if (this.active) {
|
|
|
|
|
this.updateControls();
|
|
|
|
|
} else {
|
|
|
|
|
this.add();
|
|
|
|
|
}
|
|
|
|
|
} else if (this.active) {
|
2024-05-24 16:37:26 +02:00
|
|
|
this.remove();
|
|
|
|
|
}
|
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-02-14 14:35:35 +01:00
|
|
|
this.loadIcons();
|
|
|
|
|
|
2026-03-27 21:23:51 +01:00
|
|
|
map_.on('style.load', this.updateControlsBinded);
|
2025-10-18 16:10:08 +02:00
|
|
|
map_.on('click', this.appendAnchorBinded);
|
2026-01-31 12:57:08 +01:00
|
|
|
layerEventManager.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
2024-04-25 16:41:06 +02:00
|
|
|
|
2026-03-27 21:23:51 +01:00
|
|
|
this.fileUnsubscribe = this.file.subscribe(this.updateControlsBinded);
|
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;
|
|
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!map_ || !layerEventManager || !file) {
|
2024-05-03 15:59:34 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:05:23 +01:00
|
|
|
this.layers.forEach((layer) => (layer.anchors = []));
|
2026-02-14 14:35:35 +01:00
|
|
|
this.anchors = [];
|
|
|
|
|
|
2024-05-24 16:37:26 +02:00
|
|
|
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
2025-02-02 11:17:22 +01:00
|
|
|
if (
|
|
|
|
|
get(selection).hasAnyParent(
|
|
|
|
|
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-02-14 14:35:35 +01:00
|
|
|
for (let i = 0; i < segment.trkpt.length; i++) {
|
|
|
|
|
const point = segment.trkpt[i];
|
2024-05-24 16:37:26 +02:00
|
|
|
if (point._data.anchor) {
|
2026-02-14 15:05:23 +01:00
|
|
|
const anchor: Anchor = {
|
2026-02-14 14:35:35 +01:00
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [point.getLongitude(), point.getLatitude()],
|
|
|
|
|
},
|
|
|
|
|
properties: {
|
|
|
|
|
trackIndex: trackIndex,
|
|
|
|
|
segmentIndex: segmentIndex,
|
|
|
|
|
pointIndex: i,
|
|
|
|
|
anchorIndex: this.anchors.length,
|
|
|
|
|
minZoom: point._data.zoom,
|
|
|
|
|
},
|
2026-02-14 15:05:23 +01:00
|
|
|
};
|
|
|
|
|
this.layers.get(point._data.zoom)?.anchors.push(anchor);
|
|
|
|
|
this.anchors.push(anchor);
|
2024-04-30 15:19:50 +02:00
|
|
|
}
|
2024-04-25 19:02:34 +02:00
|
|
|
}
|
2024-04-30 15:19:50 +02:00
|
|
|
}
|
2024-05-24 16:37:26 +02:00
|
|
|
});
|
2024-04-30 15:19:50 +02:00
|
|
|
|
2026-02-14 15:05:23 +01:00
|
|
|
this.layers.forEach((layer, zoom) => {
|
|
|
|
|
try {
|
|
|
|
|
let source = map_.getSource(layer.id) as maplibregl.GeoJSONSource | undefined;
|
|
|
|
|
if (source) {
|
|
|
|
|
source.setData({
|
2026-02-14 14:35:35 +01:00
|
|
|
type: 'FeatureCollection',
|
2026-02-14 15:05:23 +01:00
|
|
|
features: layer.anchors,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
map_.addSource(layer.id, {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: layer.anchors,
|
2026-02-14 14:35:35 +01:00
|
|
|
},
|
2026-02-14 15:05:23 +01:00
|
|
|
promoteId: 'anchorIndex',
|
|
|
|
|
});
|
|
|
|
|
}
|
2024-04-29 17:03:23 +02:00
|
|
|
|
2026-02-14 15:05:23 +01:00
|
|
|
if (!map_.getLayer(layer.id)) {
|
|
|
|
|
map_.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: layer.id,
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
source: layer.id,
|
|
|
|
|
layout: {
|
|
|
|
|
'icon-image': 'routing-control',
|
|
|
|
|
'icon-size': 0.25,
|
|
|
|
|
'icon-padding': 0,
|
|
|
|
|
'icon-allow-overlap': true,
|
|
|
|
|
},
|
|
|
|
|
minzoom: zoom,
|
|
|
|
|
},
|
|
|
|
|
ANCHOR_LAYER_KEY.routingControls
|
|
|
|
|
);
|
2024-04-26 14:16:59 +02:00
|
|
|
|
2026-02-14 15:05:23 +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);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// No reliable way to check if the map is ready to add sources and layers
|
2024-04-29 17:03:23 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-14 15:05:23 +01:00
|
|
|
});
|
2024-04-25 19:02:34 +02:00
|
|
|
}
|
|
|
|
|
|
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;
|
2024-04-27 09:33:49 +02:00
|
|
|
|
2026-03-27 21:23:51 +01:00
|
|
|
map_?.off('style.load', this.updateControlsBinded);
|
2026-01-31 12:57:08 +01:00
|
|
|
map_?.off('click', this.appendAnchorBinded);
|
|
|
|
|
layerEventManager?.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
|
|
|
|
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
2024-04-25 16:41:06 +02:00
|
|
|
|
2026-02-14 15:05:23 +01:00
|
|
|
this.layers.forEach((layer) => {
|
|
|
|
|
try {
|
|
|
|
|
layerEventManager?.off('mouseenter', layer.id, this.onMouseEnterBinded);
|
|
|
|
|
layerEventManager?.off('mouseleave', layer.id, this.onMouseLeaveBinded);
|
|
|
|
|
layerEventManager?.off('click', layer.id, this.onClickBinded);
|
|
|
|
|
layerEventManager?.off('contextmenu', layer.id, this.onClickBinded);
|
|
|
|
|
layerEventManager?.off('mousedown', layer.id, this.onMouseDownBinded);
|
|
|
|
|
layerEventManager?.off('touchstart', layer.id, this.onTouchStartBinded);
|
|
|
|
|
|
|
|
|
|
if (map_?.getLayer(layer.id)) {
|
|
|
|
|
map_?.removeLayer(layer.id);
|
|
|
|
|
}
|
2024-07-09 23:41:38 +02:00
|
|
|
|
2026-02-14 15:05:23 +01:00
|
|
|
if (map_?.getSource(layer.id)) {
|
|
|
|
|
map_?.removeSource(layer.id);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// No reliable way to check if the map is ready to remove sources and layers
|
2024-04-25 16:41:06 +02:00
|
|
|
}
|
|
|
|
|
});
|
2024-08-11 17:40:47 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
this.popup.remove();
|
2024-04-27 09:33:49 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
this.fileUnsubscribe();
|
2024-04-26 19:34:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
async moveAnchor(anchor: Anchor, coordinates: Coordinates) {
|
|
|
|
|
// Move the anchor and update the route from and to the neighbouring anchors
|
|
|
|
|
if (anchor === this.temporaryAnchor) {
|
|
|
|
|
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
|
|
|
|
anchor = this.getPermanentAnchor(this.temporaryAnchor);
|
|
|
|
|
this.removeTemporaryAnchor();
|
2024-04-26 19:34:46 +02:00
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!file) {
|
2024-04-26 19:34:46 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2024-04-27 09:33:49 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const segment = file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
|
|
|
|
const initialAnchorCoordinates =
|
|
|
|
|
segment.trkpt[anchor.properties.pointIndex].getCoordinates();
|
2024-04-30 15:19:50 +02:00
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
2024-04-25 19:02:34 +02:00
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
let anchors = [];
|
2026-02-14 14:35:35 +01:00
|
|
|
let targetTrackpoints = [];
|
2024-04-25 19:02:34 +02:00
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
if (previousAnchor !== null) {
|
|
|
|
|
anchors.push(previousAnchor);
|
2026-02-14 14:35:35 +01:00
|
|
|
targetTrackpoints.push(segment.trkpt[previousAnchor.properties.pointIndex]);
|
2024-04-25 19:02:34 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
anchors.push(anchor);
|
2026-02-14 14:35:35 +01:00
|
|
|
targetTrackpoints.push(
|
|
|
|
|
new TrackPoint({
|
|
|
|
|
attributes: coordinates,
|
|
|
|
|
})
|
|
|
|
|
);
|
2024-04-25 19:02:34 +02:00
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
if (nextAnchor !== null) {
|
|
|
|
|
anchors.push(nextAnchor);
|
2026-02-14 14:35:35 +01:00
|
|
|
targetTrackpoints.push(segment.trkpt[nextAnchor.properties.pointIndex]);
|
2024-04-25 19:02:34 +02:00
|
|
|
}
|
2024-04-26 10:18:08 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
let success = await this.routeBetweenAnchors(anchors, targetTrackpoints);
|
2024-04-27 09:42:55 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
if (!success && anchor.properties.anchorIndex != this.anchors.length) {
|
2025-02-02 11:17:22 +01:00
|
|
|
// Route failed, revert the anchor to the previous position
|
2026-02-14 14:35:35 +01:00
|
|
|
this.moveAnchorFeature(anchor.properties.anchorIndex, initialAnchorCoordinates);
|
2024-04-27 09:42:55 +02:00
|
|
|
}
|
2024-04-25 19:02:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
getPermanentAnchor(anchor: Anchor): Anchor {
|
|
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!file) {
|
|
|
|
|
return anchor;
|
|
|
|
|
}
|
|
|
|
|
const segment = file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
2024-06-10 20:03:57 +02:00
|
|
|
// Find the point closest to the temporary anchor
|
2026-02-14 14:35:35 +01:00
|
|
|
const anchorPoint = new TrackPoint({
|
|
|
|
|
attributes: {
|
|
|
|
|
lon: anchor.geometry.coordinates[0],
|
|
|
|
|
lat: anchor.geometry.coordinates[1],
|
|
|
|
|
},
|
2024-05-24 16:37:26 +02:00
|
|
|
});
|
2026-02-14 14:35:35 +01:00
|
|
|
let details: any = {};
|
|
|
|
|
let closest = getClosestLinePoint(segment.trkpt, anchorPoint, details);
|
|
|
|
|
|
|
|
|
|
let permanentAnchor: Anchor = {
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [closest.getLongitude(), closest.getLatitude()],
|
|
|
|
|
},
|
|
|
|
|
properties: {
|
|
|
|
|
trackIndex: anchor.properties.trackIndex,
|
|
|
|
|
segmentIndex: anchor.properties.segmentIndex,
|
|
|
|
|
pointIndex: closest._data.index,
|
|
|
|
|
anchorIndex: this.anchors.length,
|
|
|
|
|
minZoom: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
2024-04-26 19:34:46 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
return permanentAnchor;
|
2024-04-26 19:34:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-04 14:42:26 +02:00
|
|
|
turnIntoPermanentAnchor() {
|
2026-02-14 14:35:35 +01:00
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!file || !this.temporaryAnchor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const segment = file.getSegment(
|
|
|
|
|
this.temporaryAnchor.properties.trackIndex,
|
|
|
|
|
this.temporaryAnchor.properties.segmentIndex
|
|
|
|
|
);
|
2024-09-04 14:42:26 +02:00
|
|
|
// Find the point closest to the temporary anchor
|
2026-02-14 14:35:35 +01:00
|
|
|
const anchorPoint = new TrackPoint({
|
|
|
|
|
attributes: {
|
|
|
|
|
lon: this.temporaryAnchor.geometry.coordinates[0],
|
|
|
|
|
lat: this.temporaryAnchor.geometry.coordinates[1],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
let details: any = {};
|
|
|
|
|
getClosestLinePoint(segment.trkpt, anchorPoint, details);
|
|
|
|
|
|
|
|
|
|
let before = details.before ? details.index : details.index - 1;
|
|
|
|
|
|
|
|
|
|
let projectedPt = projectedPoint(
|
|
|
|
|
segment.trkpt[before],
|
|
|
|
|
segment.trkpt[before + 1],
|
|
|
|
|
anchorPoint
|
|
|
|
|
);
|
|
|
|
|
let ratio =
|
|
|
|
|
distance(segment.trkpt[before], projectedPt) /
|
|
|
|
|
distance(segment.trkpt[before], segment.trkpt[before + 1]);
|
|
|
|
|
|
|
|
|
|
let point = segment.trkpt[before].clone();
|
|
|
|
|
point.setCoordinates(projectedPt);
|
|
|
|
|
point.ele =
|
|
|
|
|
(1 - ratio) * (segment.trkpt[before].ele ?? 0) +
|
|
|
|
|
ratio * (segment.trkpt[before + 1].ele ?? 0);
|
|
|
|
|
point.time =
|
|
|
|
|
segment.trkpt[before].time && segment.trkpt[before + 1].time
|
|
|
|
|
? new Date(
|
|
|
|
|
(1 - ratio) * segment.trkpt[before].time.getTime() +
|
|
|
|
|
ratio * segment.trkpt[before + 1].time!.getTime()
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
point._data = {
|
|
|
|
|
anchor: true,
|
|
|
|
|
zoom: 0,
|
2024-09-04 14:42:26 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const trackIndex = this.temporaryAnchor!.properties.trackIndex;
|
|
|
|
|
const segmentIndex = this.temporaryAnchor!.properties.segmentIndex;
|
|
|
|
|
fileActionManager.applyToFile(this.fileId, (file) =>
|
|
|
|
|
file.replaceTrackPoints(trackIndex, segmentIndex, before + 1, before, [point])
|
|
|
|
|
);
|
2024-09-04 14:42:26 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
this.temporaryAnchor = null;
|
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();
|
|
|
|
|
|
2024-04-26 14:16:59 +02:00
|
|
|
let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
2024-04-26 13:33:17 +02:00
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
if (previousAnchor === null && nextAnchor === null) {
|
|
|
|
|
// Only one point, remove it
|
2025-10-18 16:10:08 +02:00
|
|
|
fileActionManager.applyToFile(this.fileId, (file) =>
|
2026-02-14 14:35:35 +01:00
|
|
|
file.replaceTrackPoints(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
[]
|
|
|
|
|
)
|
2025-02-02 11:17:22 +01:00
|
|
|
);
|
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
|
2025-10-18 16:10:08 +02:00
|
|
|
fileActionManager.applyToFile(this.fileId, (file) =>
|
2025-02-02 11:17:22 +01:00
|
|
|
file.replaceTrackPoints(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex,
|
2025-02-02 11:17:22 +01:00
|
|
|
0,
|
2026-02-14 14:35:35 +01:00
|
|
|
nextAnchor.properties.pointIndex - 1,
|
2025-02-02 11:17:22 +01:00
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
);
|
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
|
2025-10-18 16:10:08 +02:00
|
|
|
fileActionManager.applyToFile(this.fileId, (file) => {
|
2026-02-14 14:35:35 +01:00
|
|
|
const segment = file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
2025-02-02 11:17:22 +01:00
|
|
|
file.replaceTrackPoints(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex,
|
|
|
|
|
previousAnchor.properties.pointIndex + 1,
|
2025-02-02 11:17:22 +01:00
|
|
|
segment.trkpt.length - 1,
|
|
|
|
|
[]
|
|
|
|
|
);
|
2024-04-30 20:55:47 +02:00
|
|
|
});
|
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
|
2026-02-14 14:35:35 +01:00
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!file) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const segment = file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
2025-02-02 11:17:22 +01:00
|
|
|
this.routeBetweenAnchors(
|
|
|
|
|
[previousAnchor, nextAnchor],
|
2026-02-14 14:35:35 +01:00
|
|
|
[
|
|
|
|
|
segment.trkpt[previousAnchor.properties.pointIndex],
|
|
|
|
|
segment.trkpt[nextAnchor.properties.pointIndex],
|
|
|
|
|
]
|
2025-02-02 11:17:22 +01:00
|
|
|
);
|
2024-04-26 10:18:08 +02:00
|
|
|
}
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
2024-04-26 10:18:08 +02:00
|
|
|
|
2024-06-12 14:56:29 +02:00
|
|
|
getStartLoopAtAnchor(anchor: Anchor) {
|
|
|
|
|
return () => this.startLoopAtAnchor(anchor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startLoopAtAnchor(anchor: Anchor) {
|
|
|
|
|
this.popup.remove();
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const fileWithStats = get(this.file);
|
2024-06-18 12:35:24 +02:00
|
|
|
if (!fileWithStats) {
|
2024-06-12 14:56:29 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const speed = fileWithStats.statistics.getStatisticsFor(
|
|
|
|
|
new ListTrackSegmentItem(
|
|
|
|
|
this.fileId,
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
)
|
2025-02-02 11:17:22 +01:00
|
|
|
).global.speed.moving;
|
2024-06-18 12:35:24 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const segment = fileWithStats.file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
2025-10-18 16:10:08 +02:00
|
|
|
fileActionManager.applyToFile(this.fileId, (file) => {
|
2025-02-02 11:17:22 +01:00
|
|
|
file.replaceTrackPoints(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex,
|
2025-02-02 11:17:22 +01:00
|
|
|
segment.trkpt.length,
|
|
|
|
|
segment.trkpt.length - 1,
|
2026-02-14 14:35:35 +01:00
|
|
|
segment.trkpt.slice(0, anchor.properties.pointIndex),
|
2025-02-02 11:17:22 +01:00
|
|
|
speed > 0 ? speed : undefined
|
|
|
|
|
);
|
|
|
|
|
file.crop(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchor.properties.pointIndex,
|
|
|
|
|
anchor.properties.pointIndex + segment.trkpt.length - 1,
|
|
|
|
|
[anchor.properties.trackIndex],
|
|
|
|
|
[anchor.properties.segmentIndex]
|
2025-02-02 11:17:22 +01:00
|
|
|
);
|
2024-06-12 14:56:29 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 21:01:24 +01:00
|
|
|
async appendAnchor(e: maplibregl.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-02-14 15:05:23 +01:00
|
|
|
if (
|
|
|
|
|
e.target.queryRenderedFeatures(e.point, {
|
|
|
|
|
layers: this.layers
|
|
|
|
|
.values()
|
|
|
|
|
.map((layer) => layer.id)
|
|
|
|
|
.toArray(),
|
|
|
|
|
}).length
|
|
|
|
|
) {
|
2026-02-14 14:35:35 +01:00
|
|
|
// Clicked on routing control, ignoring
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-05-07 17:19:53 +02:00
|
|
|
this.appendAnchorWithCoordinates({
|
|
|
|
|
lat: e.lngLat.lat,
|
2025-02-02 11:17:22 +01:00
|
|
|
lon: e.lngLat.lng,
|
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-02-14 14:35:35 +01:00
|
|
|
let newAnchorPoint = new TrackPoint({
|
|
|
|
|
attributes: coordinates,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (this.anchors.length == 0) {
|
|
|
|
|
this.routeBetweenAnchors(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [
|
|
|
|
|
newAnchorPoint.getLongitude(),
|
|
|
|
|
newAnchorPoint.getLatitude(),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
properties: {
|
|
|
|
|
trackIndex: 0,
|
|
|
|
|
segmentIndex: 0,
|
|
|
|
|
pointIndex: 0,
|
|
|
|
|
anchorIndex: 0,
|
|
|
|
|
minZoom: 0,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[newAnchorPoint]
|
|
|
|
|
);
|
2024-07-16 12:17:23 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-30 15:19:50 +02:00
|
|
|
let lastAnchor = this.anchors[this.anchors.length - 1];
|
2024-04-25 16:41:06 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const file = get(this.file)?.file;
|
|
|
|
|
if (!file) {
|
2024-04-27 12:18:40 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const segment = file.getSegment(
|
|
|
|
|
lastAnchor.properties.trackIndex,
|
|
|
|
|
lastAnchor.properties.segmentIndex
|
|
|
|
|
);
|
|
|
|
|
const lastAnchorPoint = segment.trkpt[lastAnchor.properties.pointIndex];
|
|
|
|
|
|
|
|
|
|
let newAnchor: Anchor = {
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [newAnchorPoint.getLongitude(), newAnchorPoint.getLatitude()],
|
|
|
|
|
},
|
|
|
|
|
properties: {
|
|
|
|
|
trackIndex: lastAnchor.properties.trackIndex,
|
|
|
|
|
segmentIndex: lastAnchor.properties.segmentIndex,
|
|
|
|
|
pointIndex: segment.trkpt.length - 1, // Do as if the point was the last point in the segment
|
|
|
|
|
anchorIndex: 0,
|
|
|
|
|
minZoom: 0,
|
|
|
|
|
},
|
2024-04-30 15:19:50 +02:00
|
|
|
};
|
2024-04-27 09:42:55 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchorPoint, newAnchorPoint]);
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-30 15:19:50 +02:00
|
|
|
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
|
|
|
|
let previousAnchor: Anchor | null = null;
|
|
|
|
|
let nextAnchor: Anchor | null = null;
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const zoom = get(map)?.getZoom() ?? 20;
|
|
|
|
|
|
2024-04-30 15:19:50 +02:00
|
|
|
for (let i = 0; i < this.anchors.length; i++) {
|
2026-02-14 14:35:35 +01:00
|
|
|
if (
|
2026-02-14 15:05:23 +01:00
|
|
|
this.anchors[i].properties.trackIndex === anchor.properties.trackIndex &&
|
2026-02-14 14:35:35 +01:00
|
|
|
this.anchors[i].properties.segmentIndex === anchor.properties.segmentIndex &&
|
|
|
|
|
zoom >= this.anchors[i].properties.minZoom
|
|
|
|
|
) {
|
|
|
|
|
if (this.anchors[i].properties.pointIndex < anchor.properties.pointIndex) {
|
2025-02-02 11:17:22 +01:00
|
|
|
if (
|
|
|
|
|
!previousAnchor ||
|
2026-02-14 14:35:35 +01:00
|
|
|
this.anchors[i].properties.pointIndex > previousAnchor.properties.pointIndex
|
2025-02-02 11:17:22 +01:00
|
|
|
) {
|
2024-04-30 15:19:50 +02:00
|
|
|
previousAnchor = this.anchors[i];
|
|
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
} else if (this.anchors[i].properties.pointIndex > anchor.properties.pointIndex) {
|
2025-02-02 11:17:22 +01:00
|
|
|
if (
|
|
|
|
|
!nextAnchor ||
|
2026-02-14 14:35:35 +01:00
|
|
|
this.anchors[i].properties.pointIndex < nextAnchor.properties.pointIndex
|
2025-02-02 11:17:22 +01:00
|
|
|
) {
|
2024-04-30 15:19:50 +02:00
|
|
|
nextAnchor = this.anchors[i];
|
|
|
|
|
}
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [previousAnchor, nextAnchor];
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
async routeBetweenAnchors(
|
|
|
|
|
anchors: Anchor[],
|
2026-02-14 14:35:35 +01:00
|
|
|
targetTrackPoints: TrackPoint[]
|
2025-02-02 11:17:22 +01:00
|
|
|
): Promise<boolean> {
|
2026-02-14 14:35:35 +01:00
|
|
|
const fileWithStats = get(this.file);
|
2024-06-18 12:35:24 +02:00
|
|
|
if (!fileWithStats) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
if (anchors.length <= 1) {
|
2025-02-02 11:17:22 +01:00
|
|
|
// Only one anchor, update the point in the segment
|
2026-02-14 14:35:35 +01:00
|
|
|
targetTrackPoints[0]._data.anchor = true;
|
|
|
|
|
targetTrackPoints[0]._data.zoom = 0;
|
|
|
|
|
let selected = selection.getOrderedSelection();
|
|
|
|
|
if (
|
|
|
|
|
selected.length === 0 ||
|
|
|
|
|
selected[selected.length - 1].getFileId() !== this.fileId
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let item = selected[selected.length - 1];
|
|
|
|
|
fileActionManager.applyToFile(this.fileId, (file) => {
|
|
|
|
|
let trackIndex = file.trk.length > 0 ? file.trk.length - 1 : 0;
|
|
|
|
|
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
|
|
|
|
trackIndex = item.getTrackIndex();
|
|
|
|
|
}
|
|
|
|
|
let segmentIndex =
|
|
|
|
|
file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0
|
|
|
|
|
? file.trk[trackIndex].trkseg.length - 1
|
|
|
|
|
: 0;
|
|
|
|
|
if (item instanceof ListTrackSegmentItem) {
|
|
|
|
|
segmentIndex = item.getSegmentIndex();
|
|
|
|
|
}
|
|
|
|
|
if (file.trk.length === 0) {
|
|
|
|
|
let track = new Track();
|
|
|
|
|
track.replaceTrackPoints(0, 0, 0, targetTrackPoints);
|
|
|
|
|
file.replaceTracks(0, 0, [track]);
|
|
|
|
|
} else if (file.trk[trackIndex].trkseg.length === 0) {
|
|
|
|
|
let segment = new TrackSegment();
|
|
|
|
|
segment.replaceTrackPoints(0, 0, targetTrackPoints);
|
|
|
|
|
file.replaceTrackSegments(trackIndex, 0, 0, [segment]);
|
|
|
|
|
} else {
|
|
|
|
|
file.replaceTrackPoints(trackIndex, segmentIndex, 0, 0, targetTrackPoints);
|
|
|
|
|
}
|
|
|
|
|
});
|
2024-04-27 09:42:55 +02:00
|
|
|
return true;
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-27 09:42:55 +02:00
|
|
|
let response: TrackPoint[];
|
|
|
|
|
try {
|
2026-02-14 14:35:35 +01:00
|
|
|
response = await route(targetTrackPoints.map((trkpt) => trkpt.getCoordinates()));
|
2024-04-30 15:19:50 +02:00
|
|
|
} catch (e: any) {
|
2026-03-07 15:57:58 +01:00
|
|
|
toast.error(i18n._(e.message, e.message));
|
2024-04-27 09:42:55 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
2024-04-26 14:16:59 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const segment = fileWithStats.file.getSegment(
|
|
|
|
|
anchors[0].properties.trackIndex,
|
|
|
|
|
anchors[0].properties.segmentIndex
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
anchors[0].properties.pointIndex !== 0 &&
|
|
|
|
|
(anchors[0].properties.pointIndex !== segment.trkpt.length - 1 ||
|
|
|
|
|
distance(targetTrackPoints[0].getCoordinates(), response[0].getCoordinates()) > 1)
|
2025-02-02 11:17:22 +01:00
|
|
|
) {
|
2026-02-14 14:35:35 +01:00
|
|
|
response.splice(0, 0, targetTrackPoints[0].clone()); // Keep the current first anchor
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
if (anchors[anchors.length - 1].properties.pointIndex !== segment.trkpt.length - 1) {
|
|
|
|
|
response.push(targetTrackPoints[anchors.length - 1].clone()); // Keep the current last anchor
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
let anchorTrackPoints = [response[0], response[response.length - 1]];
|
2024-04-26 14:16:59 +02:00
|
|
|
for (let i = 1; i < anchors.length - 1; i++) {
|
2026-02-14 14:35:35 +01:00
|
|
|
// Find the closest point to the intermediate anchor, which will become an anchor
|
|
|
|
|
anchorTrackPoints.push(
|
|
|
|
|
getClosestLinePoint(response.slice(1, -1), targetTrackPoints[i])
|
|
|
|
|
);
|
2024-04-26 14:16:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
anchorTrackPoints.forEach((trkpt) => {
|
|
|
|
|
// Turn them into permanent anchors
|
|
|
|
|
trkpt._data.anchor = true;
|
|
|
|
|
trkpt._data.zoom = 0;
|
2024-04-26 14:16:59 +02:00
|
|
|
});
|
2024-04-26 10:18:08 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
const stats = fileWithStats.statistics.getStatisticsFor(
|
|
|
|
|
new ListTrackSegmentItem(
|
|
|
|
|
this.fileId,
|
|
|
|
|
anchors[0].properties.trackIndex,
|
|
|
|
|
anchors[0].properties.segmentIndex
|
|
|
|
|
)
|
2025-02-02 11:17:22 +01:00
|
|
|
);
|
2024-06-18 12:35:24 +02:00
|
|
|
let speed: number | undefined = undefined;
|
2026-02-14 14:35:35 +01:00
|
|
|
let startTime = segment.trkpt[anchors[0].properties.pointIndex].time;
|
2024-06-18 12:35:24 +02:00
|
|
|
|
|
|
|
|
if (stats.global.speed.moving > 0) {
|
|
|
|
|
let replacingDistance = 0;
|
|
|
|
|
for (let i = 1; i < response.length; i++) {
|
2025-02-02 11:17:22 +01:00
|
|
|
replacingDistance +=
|
|
|
|
|
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
2024-06-18 12:35:24 +02:00
|
|
|
}
|
2026-02-14 14:35:35 +01:00
|
|
|
let startAnchorStats = stats.getTrackPoint(anchors[0].properties.pointIndex)!;
|
2026-01-11 19:48:48 +01:00
|
|
|
let endAnchorStats = stats.getTrackPoint(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchors[anchors.length - 1].properties.pointIndex
|
2026-01-11 19:48:48 +01:00
|
|
|
)!;
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
let replacedDistance =
|
2026-01-11 19:48:48 +01:00
|
|
|
endAnchorStats.distance.moving - startAnchorStats.distance.moving;
|
2024-06-18 12:35:24 +02:00
|
|
|
|
|
|
|
|
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
|
2025-02-02 11:17:22 +01:00
|
|
|
let newTime = (newDistance / stats.global.speed.moving) * 3600;
|
2024-06-18 12:35:24 +02:00
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
let remainingTime =
|
|
|
|
|
stats.global.time.moving -
|
2026-01-11 19:48:48 +01:00
|
|
|
(endAnchorStats.time.moving - startAnchorStats.time.moving);
|
2024-06-18 12:35:24 +02:00
|
|
|
let replacingTime = newTime - remainingTime;
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
if (replacingTime <= 0) {
|
|
|
|
|
// Fallback to simple time difference
|
2026-01-11 19:48:48 +01:00
|
|
|
replacingTime = endAnchorStats.time.total - startAnchorStats.time.total;
|
2024-08-07 16:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
speed = (replacingDistance / replacingTime) * 3600;
|
2024-07-18 10:43:38 +02:00
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
if (startTime === undefined) {
|
|
|
|
|
// Replacing the first point
|
2026-02-14 14:35:35 +01:00
|
|
|
let endIndex = anchors[anchors.length - 1].properties.pointIndex;
|
2025-02-02 11:17:22 +01:00
|
|
|
startTime = new Date(
|
|
|
|
|
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
|
2026-01-11 19:48:48 +01:00
|
|
|
(replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) *
|
2025-02-02 11:17:22 +01:00
|
|
|
1000
|
|
|
|
|
);
|
2024-07-18 10:43:38 +02:00
|
|
|
}
|
2024-06-18 12:35:24 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-18 16:10:08 +02:00
|
|
|
fileActionManager.applyToFile(this.fileId, (file) =>
|
2025-02-02 11:17:22 +01:00
|
|
|
file.replaceTrackPoints(
|
2026-02-14 14:35:35 +01:00
|
|
|
anchors[0].properties.trackIndex,
|
|
|
|
|
anchors[0].properties.segmentIndex,
|
|
|
|
|
anchors[0].properties.pointIndex,
|
|
|
|
|
anchors[anchors.length - 1].properties.pointIndex,
|
2025-02-02 11:17:22 +01:00
|
|
|
response,
|
|
|
|
|
speed,
|
|
|
|
|
startTime
|
|
|
|
|
)
|
|
|
|
|
);
|
2024-04-27 09:42:55 +02:00
|
|
|
|
|
|
|
|
return true;
|
2024-04-25 16:41:06 +02:00
|
|
|
}
|
2024-05-24 16:37:26 +02:00
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this.remove();
|
|
|
|
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
|
|
|
}
|
2024-04-30 15:19:50 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
loadIcons() {
|
|
|
|
|
const _map = get(map);
|
|
|
|
|
if (!_map) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-10-18 16:10:08 +02:00
|
|
|
|
2026-02-14 14:35:35 +01:00
|
|
|
loadSVGIcon(
|
|
|
|
|
_map,
|
|
|
|
|
'routing-control',
|
|
|
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
|
|
|
|
<circle cx="10" cy="10" r="8" fill="white" stroke="black" stroke-width="2" />
|
|
|
|
|
</svg>`,
|
2026-02-17 21:12:04 +01:00
|
|
|
_map.getCanvasContainer().offsetWidth > 1000 ? 50 : 80
|
2026-02-14 14:35:35 +01:00
|
|
|
);
|
|
|
|
|
}
|
2024-04-30 15:19:50 +02:00
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
if (this.temporaryAnchor !== null) {
|
|
|
|
|
this.turnIntoPermanentAnchor();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
|
|
|
|
if (e.originalEvent.shiftKey) {
|
|
|
|
|
this.deleteAnchor(anchor);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canChangeStart.update(() => {
|
|
|
|
|
if (anchor.properties.pointIndex === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const segment = get(this.file)?.file.getSegment(
|
|
|
|
|
anchor.properties.trackIndex,
|
|
|
|
|
anchor.properties.segmentIndex
|
|
|
|
|
);
|
|
|
|
|
if (
|
|
|
|
|
!segment ||
|
|
|
|
|
distance(
|
|
|
|
|
segment.trkpt[0].getCoordinates(),
|
|
|
|
|
segment.trkpt[segment.trkpt.length - 1].getCoordinates()
|
|
|
|
|
) > 1000
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.popup.setLngLat(e.lngLat);
|
|
|
|
|
this.popup.addTo(e.target);
|
|
|
|
|
|
|
|
|
|
let deleteThisAnchor = this.getDeleteAnchor(anchor);
|
|
|
|
|
this.popupElement.addEventListener('delete', deleteThisAnchor); // Register the delete event for this anchor
|
|
|
|
|
let 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.draggedAnchorIndex = 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.draggedAnchorIndex = e.features![0].properties.anchorIndex;
|
|
|
|
|
this.draggingStartingPosition = e.point;
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
_map.dragPan.disable();
|
|
|
|
|
|
|
|
|
|
_map.on('touchmove', this.onMouseMoveBinded);
|
|
|
|
|
_map.once('touchend', this.onMouseUpBinded);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
|
|
|
|
if (this.draggedAnchorIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mapCursor.notify(MapCursorState.ANCHOR_DRAGGING, true);
|
|
|
|
|
|
|
|
|
|
this.moveAnchorFeature(this.draggedAnchorIndex, {
|
|
|
|
|
lat: e.lngLat.lat,
|
|
|
|
|
lon: e.lngLat.lng,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.draggedAnchorIndex === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.point.equals(this.draggingStartingPosition)) {
|
|
|
|
|
this.draggedAnchorIndex = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.draggedAnchorIndex === this.anchors.length) {
|
|
|
|
|
if (this.temporaryAnchor) {
|
|
|
|
|
this.moveAnchor(this.temporaryAnchor, {
|
|
|
|
|
lat: e.lngLat.lat,
|
|
|
|
|
lon: e.lngLat.lng,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.moveAnchor(this.anchors[this.draggedAnchorIndex], {
|
|
|
|
|
lat: e.lngLat.lat,
|
|
|
|
|
lon: e.lngLat.lng,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.draggedAnchorIndex = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showTemporaryAnchor(e: MapLayerMouseEvent) {
|
|
|
|
|
const map_ = get(map);
|
|
|
|
|
if (!map_) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.draggedAnchorIndex !== null) {
|
|
|
|
|
// Do not not change the source point if it is already being dragged
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (get(streetViewEnabled)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!get(selection).hasAnyParent(
|
|
|
|
|
new ListTrackSegmentItem(
|
|
|
|
|
this.fileId,
|
|
|
|
|
e.features![0].properties.trackIndex,
|
|
|
|
|
e.features![0].properties.segmentIndex
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.temporaryAnchorCloseToOtherAnchor(e)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.temporaryAnchor = {
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
|
|
|
|
},
|
|
|
|
|
properties: {
|
|
|
|
|
trackIndex: e.features![0].properties.trackIndex,
|
|
|
|
|
segmentIndex: e.features![0].properties.segmentIndex,
|
|
|
|
|
pointIndex: 0,
|
|
|
|
|
anchorIndex: this.anchors.length,
|
|
|
|
|
minZoom: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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.draggedAnchorIndex !== 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.geometry.coordinates as [number, number])
|
|
|
|
|
) > 20 ||
|
|
|
|
|
this.temporaryAnchorCloseToOtherAnchor(e)
|
|
|
|
|
) {
|
|
|
|
|
// Hide if too far from the layer
|
|
|
|
|
this.removeTemporaryAnchor();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the position of the temporary anchor
|
|
|
|
|
this.moveAnchorFeature(this.anchors.length, {
|
|
|
|
|
lat: e.lngLat.lat,
|
|
|
|
|
lon: e.lngLat.lng,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
temporaryAnchorCloseToOtherAnchor(e: any) {
|
|
|
|
|
const map_ = get(map);
|
|
|
|
|
if (!map_) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const zoom = map_.getZoom();
|
|
|
|
|
for (let anchor of this.anchors) {
|
|
|
|
|
if (
|
|
|
|
|
zoom >= anchor.properties.minZoom &&
|
|
|
|
|
e.point.dist(map_.project(anchor.geometry.coordinates as [number, number])) < 10
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
moveAnchorFeature(anchorIndex: number, coordinates: Coordinates) {
|
2026-02-14 15:05:23 +01:00
|
|
|
const anchor =
|
|
|
|
|
anchorIndex === this.anchors.length ? this.temporaryAnchor : this.anchors[anchorIndex];
|
|
|
|
|
let source = get(map)?.getSource(
|
|
|
|
|
this.layers.get(anchor?.properties.minZoom ?? MIN_ANCHOR_ZOOM)?.id ?? ''
|
|
|
|
|
) as GeoJSONSource | undefined;
|
2026-02-14 14:35:35 +01:00
|
|
|
if (source) {
|
|
|
|
|
source.updateData({
|
|
|
|
|
update: [
|
|
|
|
|
{
|
|
|
|
|
id: anchorIndex,
|
|
|
|
|
newGeometry: {
|
|
|
|
|
type: 'Point',
|
|
|
|
|
coordinates: [coordinates.lon, coordinates.lat],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addTemporaryAnchor() {
|
|
|
|
|
if (!this.temporaryAnchor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-14 15:05:23 +01:00
|
|
|
let source = get(map)?.getSource('routing-controls-0') as GeoJSONSource | undefined;
|
2026-02-14 14:35:35 +01:00
|
|
|
if (source) {
|
|
|
|
|
if (this.temporaryAnchor) {
|
|
|
|
|
source.updateData({
|
|
|
|
|
add: [this.temporaryAnchor],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeTemporaryAnchor() {
|
|
|
|
|
if (!this.temporaryAnchor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const map_ = get(map);
|
2026-02-14 15:05:23 +01:00
|
|
|
let source = map_?.getSource('routing-controls-0') as GeoJSONSource | undefined;
|
2026-02-14 14:35:35 +01:00
|
|
|
if (source) {
|
|
|
|
|
if (this.temporaryAnchor) {
|
|
|
|
|
source.updateData({
|
|
|
|
|
remove: [this.temporaryAnchor.properties.anchorIndex],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
|
|
|
|
mapCursor.notify(MapCursorState.ANCHOR_HOVER, false);
|
|
|
|
|
this.temporaryAnchor = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const routingControls: Map<string, RoutingControls> = new Map();
|