This commit is contained in:
vcoppe committed 2026-10-08 18:34:19 +02:00
1 parent 606a0e974a
commit 67a4c090bd
107 files changed
+3474 -3948

No files matched your search

@@ -18,7 +18,7 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { Coordinates } from 'gpx';
import type { Coordinates } from '$lib/geo';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings';
@@ -21,7 +21,7 @@ import Chart, {
type TooltipItem,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from 'gpx';
import type { Coordinates } from '$lib/geo';
import {
engine,
NO_TIME,
@@ -456,7 +456,7 @@ export class ElevationProfile {
highway: highways[index],
sacScale: sacScales[index],
mtbScale: mtbScales[index],
coordinates: { lat: data.lat[index], lon: data.lng[index] },
coordinates: { lat: data.lat[index], lng: data.lng[index] },
index,
});
if (data.speed && (global.totalTime ?? 0) > 0) {
@@ -18,8 +18,7 @@
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine';
import { hoveredPoint } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
let {
useHash = true,
@@ -39,7 +38,6 @@
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers,
} = settings;
@@ -63,10 +63,10 @@
let manualCamera = $state(false);
let zoom = $state('0');
let lat = $state('0');
let lon = $state('0');
let lng = $state('0');
let bearing = $state('0');
let pitch = $state('0');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lng}/${bearing}/${pitch}` : '');
$effect(() => {
if (options.elevation.show || options.elevation.height) {
@@ -78,7 +78,7 @@
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
lng = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
@@ -293,7 +293,7 @@
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.longitude')}</span>
<span>{lon}</span>
<span>{lng}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.zoom')}</span>
@@ -3,8 +3,7 @@
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListLevel } from './file-list';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
@@ -26,14 +25,14 @@
let container: HTMLElement;
let sortableLevel: ListLevel =
node === null
? ListLevel.FILE
? 'file'
: node.type === 'file'
? waypointRoot
? ListLevel.WAYPOINTS
: ListLevel.TRACK
? 'waypoints'
: 'track'
: node.type === 'waypoints'
? ListLevel.WAYPOINT
: ListLevel.SEGMENT;
? 'waypoint'
: 'segment';
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -18,7 +18,6 @@
Scissors,
FileStack,
} from '@lucide/svelte';
import { ListLevel } from './file-list';
import { getContext } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
@@ -56,14 +55,7 @@
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
const levels = {
file: ListLevel.FILE,
track: ListLevel.TRACK,
segment: ListLevel.SEGMENT,
waypoints: ListLevel.WAYPOINTS,
waypoint: ListLevel.WAYPOINT,
};
let level = $derived(levels[node.type]);
let level = $derived(node.type);
let singleSelection = $derived(selectionSize($selection) === 1);
@@ -128,7 +120,7 @@
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if}
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
{#if level === 'file' || level === 'track'}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
@@ -178,9 +170,9 @@
}
}}
>
{#if level === ListLevel.SEGMENT}
{#if level === 'segment'}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if level === ListLevel.WAYPOINT}
{:else if level === 'waypoint'}
{#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -250,7 +242,7 @@
<ContextMenu.Separator />
{/if}
{/if}
{#if level !== ListLevel.WAYPOINTS}
{#if level !== 'waypoints'}
<ContextMenu.Item onclick={() => engine.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
@@ -1,300 +0,0 @@
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
}
export abstract class ListItem {
[x: string]: any;
level: ListLevel;
constructor(level: ListLevel) {
this.level = level;
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
export class ListRootItem extends ListItem {
constructor() {
super(ListLevel.ROOT);
}
getId(): string {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
getFileId(): string {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
}
export class ListFileItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.FILE);
this.fileId = fileId;
}
getId(): string {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
} else {
return new ListTrackItem(this.fileId, id);
}
}
}
export class ListTrackItem extends ListItem {
fileId: string;
trackIndex: number;
constructor(fileId: string, trackIndex: number) {
super(ListLevel.TRACK);
this.fileId = fileId;
this.trackIndex = trackIndex;
}
getId(): number {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
}
export class ListTrackSegmentItem extends ListItem {
fileId: string;
trackIndex: number;
segmentIndex: number;
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
super(ListLevel.SEGMENT);
this.fileId = fileId;
this.trackIndex = trackIndex;
this.segmentIndex = segmentIndex;
}
getId(): number {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
case ListLevel.TRACK:
return this.segmentIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getSegmentIndex(): number {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
}
export class ListWaypointsItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.WAYPOINTS);
this.fileId = fileId;
}
getId(): string {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
}
export class ListWaypointItem extends ListItem {
fileId: string;
waypointIndex: number;
constructor(fileId: string, waypointIndex: number) {
super(ListLevel.WAYPOINT);
this.fileId = fileId;
this.waypointIndex = waypointIndex;
}
getId(): number {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
case ListLevel.WAYPOINTS:
return this.waypointIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getWaypointIndex(): number {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
}
export function sortItems(items: ListItem[], reverse: boolean = false) {
items.sort((a, b) => {
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
return a.getTrackIndex() - b.getTrackIndex();
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
return a.getSegmentIndex() - b.getSegmentIndex();
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
return a.getWaypointIndex() - b.getWaypointIndex();
}
return a.level - b.level;
});
if (reverse) {
items.reverse();
}
}
@@ -1,18 +1,19 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListLevel } from './file-list';
import { get, writable } from 'svelte/store';
import { tick } from 'svelte';
import { engine, type MoveTarget, type Selection } from '$lib/engine';
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
file: ['file'],
track: ['file', 'track'],
segment: ['file', 'track', 'segment'],
waypoints: ['waypoints'],
waypoint: ['waypoints', 'waypoint'],
};
export const dragging = writable<ListLevel | null>(null);
@@ -84,11 +85,11 @@ export class SortableFileList {
childNode(id: string): FileTreeNode | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'file', fileId: id };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
@@ -97,9 +98,9 @@ export class SortableFileList {
segmentId: id,
}
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
: undefined;
@@ -110,11 +111,11 @@ export class SortableFileList {
private selectionOf(ids: string[]): Selection | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'file', fileIds: ids };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
@@ -123,9 +124,9 @@ export class SortableFileList {
segmentIds: ids,
}
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids }
: undefined;
@@ -136,20 +137,20 @@ export class SortableFileList {
private targetAt(index: number): MoveTarget | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'files', index };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index }
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
// the list holds the node standing for the waypoints: they go after the others
return parent
? { type: 'waypoints', fileId: parent.fileId, index: Infinity }
: undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined;
}
}
@@ -161,7 +162,7 @@ export class SortableFileList {
return;
}
if (from === to && this._sortableLevel === ListLevel.FILE) {
if (from === to && this._sortableLevel === 'file') {
// the order of the files is the one of the list
this.updateToFileOrder();
return;
@@ -169,7 +170,7 @@ export class SortableFileList {
// The event is triggered on the source and on the destination list: handle it once.
// Nothing to move in the list of the single node of the waypoints.
if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) {
if (to !== this || (from === to && this._sortableLevel === 'waypoints')) {
return;
}
@@ -245,19 +246,19 @@ export class SortableFileList {
engine.select([]);
} else {
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
engine.select(selected);
break;
case ListLevel.TRACK:
case 'track':
engine.selectTracks(parent!.fileId, selected);
break;
case ListLevel.SEGMENT:
case 'segment':
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
break;
case ListLevel.WAYPOINTS:
case 'waypoints':
engine.selectWaypointGroup(parent!.fileId);
break;
case ListLevel.WAYPOINT:
case 'waypoint':
engine.selectWaypoints(parent!.fileId, selected);
break;
}
@@ -266,7 +267,7 @@ export class SortableFileList {
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
@@ -282,7 +283,7 @@ export class SortableFileList {
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
@@ -304,7 +305,7 @@ export class SortableFileList {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
if (this._sortableLevel === 'file' && !files.has(attr)) {
element.remove();
} else {
this._elements[attr] = element;
@@ -1,5 +1,4 @@
import { get, type Readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import maplibregl, {
type GeoJSONSource,
type FilterSpecification,
@@ -13,7 +12,6 @@ import { getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { fileStateCollection } from '$lib/logic/file-state';
import { engine, type FileState, type Selection } from '$lib/engine';
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
@@ -615,7 +613,7 @@ export class GPXLayer {
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
lng: e.lngLat.lng,
},
]).then(async (ele) => {
if (this.draggedWaypointIndex === null) {
@@ -1,12 +1,11 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { hoveredPoint } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/all-hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
@@ -93,7 +92,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
coordinates: [hovered.lng, hovered.lat],
},
properties: {
icon: 'hover-marker',
@@ -33,7 +33,7 @@
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
const [ele] = await getElevation([{ lat: poi.item.lat, lon: poi.item.lng }]);
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
await engine.newWaypoint({
lng: poi.item.lng,
lat: poi.item.lat,
@@ -1,39 +1,38 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import { cachedFetch } from '$lib/cached-fetch';
const { currentOverpassQueries } = settings;
/** The responses of Overpass are kept in the browser for a week. */
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
const mercator = new SphericalMercator({
size: 256,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
});
export class OverpassLayer {
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
popup: MapPopup;
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
// What was queried this session: the queries that were made for each tile. The responses are
// kept longer, see `cachedFetch`.
queriedTiles = new Map<string, Set<string>>();
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -54,7 +53,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -77,7 +76,7 @@ export class OverpassLayer {
update() {
this.loadIcons();
const fullData = get(data);
const fullData = get(this.data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@@ -158,7 +157,6 @@ export class OverpassLayer {
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
@@ -166,66 +164,35 @@ export class OverpassLayer {
continue;
}
db.overpasstiles
.where('[x+y]')
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
const queried = this.queriedTiles.get(`${x},${y}`);
const missingQueries = queries.filter((query) => !queried?.has(query));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
}
}
}
queryTile(x: number, y: number, queries: string[]) {
if (this.currentQueries.size > 5) {
return;
}
this.currentQueries.add(`${x},${y}`);
const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then(
(response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
},
() => this.currentQueries.delete(`${x},${y}`)
)
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
return;
}
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
pois.push({
query,
id: element.id,
poi: {
this.data.update((updatedData) => {
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
updatedData.features.push({
type: 'Feature',
geometry: {
type: 'Point',
@@ -242,17 +209,17 @@ export class OverpassLayer {
tags: element.tags,
type: element.type,
},
},
});
});
}
}
}
}
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
return updatedData;
});
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
queries.forEach((query) => queried.add(query));
this.queriedTiles.set(`${x},${y}`, queried);
this.currentQueries.delete(`${x},${y}`);
}
@@ -1,4 +1,5 @@
import { fileStateCollection } from '$lib/logic/file-state';
import { engine } from '$lib/engine';
import { get } from 'svelte/store';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
@@ -246,17 +247,23 @@ export class MapLayerEventManager {
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
const files = get(engine.files);
let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
// the tracks of a file are all within its bounds
const file = files.get(fileId);
const box = file && get(file).statistics.bounds;
return box
? bounds.getWest() <= box.east &&
bounds.getEast() >= box.west &&
bounds.getSouth() <= box.north &&
bounds.getNorth() >= box.south
: true;
}
// the waypoints have no bounds of their own
return true;
});
return result;
}
@@ -17,7 +17,7 @@
async function addElevation() {
const stats = get(statistics);
const points = Array.from(stats.lng, (lon, i) => ({ lon, lat: stats.lat[i] }));
const points = Array.from(stats.lng, (lng, i) => ({ lng, lat: stats.lat[i] }));
const ele = await getElevation(points);
// the selection or the files may have changed while the elevation was loading
@@ -1,4 +1,4 @@
import { distance, type Coordinates } from 'gpx';
import { distance, type Coordinates } from '$lib/geo';
import { get, writable } from 'svelte/store';
import maplibregl, {
type MapMouseEvent,
@@ -50,17 +50,11 @@ type Anchor = {
type AnchorProperties = { anchorIndex: number; minZoom: number };
type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
type Position = { lng: number; lat: number };
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
function isRoutable(selection: Selection) {
return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment';
}
function toCoordinates({ lng, lat }: Position): Coordinates {
return { lat, lon: lng };
}
/**
* 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
@@ -103,7 +97,7 @@ export class RoutingControls {
* Where the pointer hovers a segment of the selection: an anchor that can be dragged or
* clicked to be added.
*/
temporaryAnchor: (Position & { segment: number }) | null = null;
temporaryAnchor: (Coordinates & { segment: number }) | null = null;
showTemporaryAnchorBinded: (e: MapLayerMouseEvent) => void =
this.showTemporaryAnchor.bind(this);
updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this);
@@ -328,11 +322,11 @@ export class RoutingControls {
this.popup.remove();
}
position(index: number): Position {
position(index: number): Coordinates {
return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] };
}
async moveAnchor(anchor: Anchor, coordinates: Position) {
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 };
@@ -349,7 +343,7 @@ export class RoutingControls {
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
const chain: Anchor[] = [];
const targets: Position[] = [];
const targets: Coordinates[] = [];
if (previousAnchor) {
chain.push(previousAnchor);
targets.push(this.position(previousAnchor.index));
@@ -457,7 +451,7 @@ export class RoutingControls {
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(toCoordinates(first), toCoordinates(last)) <= 1000;
return distance(first, last) <= 1000;
}
async appendAnchor(e: maplibregl.MapMouseEvent) {
@@ -480,7 +474,7 @@ export class RoutingControls {
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
}
async appendAnchorWithCoordinates(coordinates: Position) {
async appendAnchorWithCoordinates(coordinates: Coordinates) {
// Add a new anchor to the end of the last segment
const statistics = this.statistics;
const revision = this.anchorData?.revision;
@@ -546,7 +540,7 @@ export class RoutingControls {
* 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: Position[]): Promise<boolean> {
async routeBetweenAnchors(anchors: Anchor[], targets: Coordinates[]): Promise<boolean> {
const revision = this.anchorData?.revision;
if (revision === undefined) {
return false;
@@ -565,7 +559,7 @@ export class RoutingControls {
let response;
try {
response = await route(targets.map(toCoordinates));
response = await route(targets);
} catch (e: any) {
toast.error(i18n._(e.message, e.message));
return false;
@@ -581,9 +575,9 @@ export class RoutingControls {
const keepFirst =
first.index !== segmentStart &&
(first.index !== lastIndex ||
distance(toCoordinates(targets[0]), {
distance(targets[0], {
lat: response.lat[0],
lon: response.lng[0],
lng: response.lng[0],
}) > 1);
const keepLast = last.index !== lastIndex;
@@ -843,7 +837,7 @@ export class RoutingControls {
return false;
}
moveAnchorFeature(anchor: Anchor, coordinates: Position) {
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
const source = get(map)?.getSource(
this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? ''
) as GeoJSONSource | undefined;
@@ -1,4 +1,4 @@
import { distance, type Coordinates } from 'gpx';
import { distance, type Coordinates } from '$lib/geo';
import { settings } from '$lib/logic/settings';
import { getElevation } from '$lib/utils';
import { get } from 'svelte/store';
@@ -143,7 +143,7 @@ async function getGraphHopperRoute(
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
points: points.map((point) => [point.lng, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
@@ -209,7 +209,7 @@ async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<RoutedPoints> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lng.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
@@ -283,15 +283,15 @@ function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
const dist = distance(points[i], points[i + 1]) / 1000;
for (let d = 0; d < dist; d += step) {
const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
const lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
addPoint(route, lon, lat, 0);
const lng = points[i].lng + (d / dist) * (points[i + 1].lng - points[i].lng);
addPoint(route, lng, lat, 0);
}
}
const last = points[points.length - 1];
addPoint(route, last.lon, last.lat, 0);
addPoint(route, last.lng, last.lat, 0);
return getElevation(route.lng.map((lng, i) => ({ lon: lng, lat: route.lat[i] }))).then(
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;
return route;
@@ -1,51 +0,0 @@
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
return MIN_ANCHOR_ZOOM;
}
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
}
export function updateAnchorPoints(file: GPXFile) {
let segments = file.getSegments();
for (let segment of segments) {
if (!segment._data.anchors) {
// New segment, compute anchor points for it
computeAnchorPoints(segment);
continue;
}
if (segment.trkpt.length > 0) {
// Ensure first and last points are anchors and always visible
segment.trkpt[0]._data.anchor = true;
segment.trkpt[0]._data.zoom = 0;
segment.trkpt[segment.trkpt.length - 1]._data.anchor = true;
segment.trkpt[segment.trkpt.length - 1]._data.zoom = 0;
}
}
}
function computeAnchorPoints(segment: TrackSegment) {
let points = segment.trkpt;
let anchors = ramerDouglasPeucker(points, 1);
anchors.forEach((anchor) => {
let point = anchor.point;
point._data.anchor = true;
point._data.zoom = getZoomLevelForDistance(point.getLatitude(), anchor.distance);
});
segment._data.anchors = true;
}
@@ -88,7 +88,7 @@
longitude = parseFloat(longitude.toFixed(6));
const target = $selectedWaypoint;
const [ele] = await getElevation([{ lat: latitude, lon: longitude }]);
const [ele] = await getElevation([{ lat: latitude, lng: longitude }]);
const waypoint = {
lng: longitude,
lat: latitude,