1 Commits

Author SHA1 Message Date
vcoppe
c9472e10be New translations file.mdx (Czech) 2025-11-12 20:02:06 +01:00
149 changed files with 3956 additions and 4992 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- name: Create env file - name: Create env file
run: | run: |
touch website/.env touch website/.env
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
cat website/.env cat website/.env
- name: Build website - name: Build website

View File

@@ -1,3 +1,6 @@
website/src/lib/components/ui # Ignore files for PNPM, NPM and YARN
website/src/lib/docs/**/*.mdx pnpm-lock.yaml
**/*.webmanifest package-lock.json
yarn.lock
src/lib/components/ui
*.mdx

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 gpx.studio Copyright (c) 2024 gpx.studio
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -27,8 +27,8 @@ Any help is greatly appreciated!
The code is split into two parts: The code is split into two parts:
- `gpx`: a Typescript library for parsing and manipulating GPX files, - `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. - `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
You will need [Node.js](https://nodejs.org/) to build and run these two parts. You will need [Node.js](https://nodejs.org/) to build and run these two parts.
@@ -42,11 +42,11 @@ npm run build
### Running the website ### Running the website
To be able to load the map, you will need to create your own <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> and store it in a `.env` file in the `website` directory. To be able to load the map, you will need to create your own <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> and store it in a `.env` file in the `website` directory.
```bash ```bash
cd website cd website
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
npm install npm install
npm run dev npm run dev
``` ```
@@ -55,25 +55,25 @@ npm run dev
This project has been made possible thanks to the following open source projects: This project has been made possible thanks to the following open source projects:
- Development: - Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience - [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation - [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- Design: - Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components - [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons - [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic: - Logic:
- [immer](https://github.com/immerjs/immer) — complex state management - [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper - [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping: - Mapping:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive maps - [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
- [brouter](https://github.com/abrensch/brouter) — routing engine - [brouter](https://github.com/abrensch/brouter) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine - [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
- Search: - Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation - [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
## License ## License

View File

@@ -25,7 +25,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"postinstall": "npm run build", "postinstall": "npm run build",
"lint": "prettier --check . --config ../.prettierrc && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write . --config ../.prettierrc" "format": "prettier --write ."
} }
} }

View File

@@ -1,5 +1,4 @@
import { ramerDouglasPeucker } from './simplify'; import { ramerDouglasPeucker } from './simplify';
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
import { import {
Coordinates, Coordinates,
GPXFileAttributes, GPXFileAttributes,
@@ -18,9 +17,6 @@ import {
import { immerable, isDraft, original, freeze } from 'immer'; import { immerable, isDraft, original, freeze } from 'immer';
function cloneJSON<T>(obj: T): T { function cloneJSON<T>(obj: T): T {
if (obj === undefined) {
return undefined;
}
if (obj === null || typeof obj !== 'object') { if (obj === null || typeof obj !== 'object') {
return null; return null;
} }
@@ -37,6 +33,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
abstract getNumberOfTrackPoints(): number; abstract getNumberOfTrackPoints(): number;
abstract getStartTimestamp(): Date | undefined; abstract getStartTimestamp(): Date | undefined;
abstract getEndTimestamp(): Date | undefined; abstract getEndTimestamp(): Date | undefined;
abstract getStatistics(): GPXStatistics;
abstract getSegments(): TrackSegment[]; abstract getSegments(): TrackSegment[];
abstract getTrackPoints(): TrackPoint[]; abstract getTrackPoints(): TrackPoint[];
@@ -76,6 +73,14 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return this.children[this.children.length - 1].getEndTimestamp(); return this.children[this.children.length - 1].getEndTimestamp();
} }
getStatistics(): GPXStatistics {
let statistics = new GPXStatistics();
for (let child of this.children) {
statistics.mergeWith(child.getStatistics());
}
return statistics;
}
getSegments(): TrackSegment[] { getSegments(): TrackSegment[] {
return this.children.flatMap((child) => child.getSegments()); return this.children.flatMap((child) => child.getSegments());
} }
@@ -140,9 +145,7 @@ export class GPXFile extends GPXTreeNode<Track> {
}, },
}, },
}; };
this.wpt = gpx.wpt this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
: [];
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : []; this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
if (gpx.rte && gpx.rte.length > 0) { if (gpx.rte && gpx.rte.length > 0) {
this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route))); this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route)));
@@ -180,6 +183,9 @@ export class GPXFile extends GPXTreeNode<Track> {
segment._data['segmentIndex'] = segmentIndex; segment._data['segmentIndex'] = segmentIndex;
}); });
}); });
this.wpt.forEach((waypoint, waypointIndex) => {
waypoint._data['index'] = waypointIndex;
});
} }
get children(): Array<Track> { get children(): Array<Track> {
@@ -200,16 +206,8 @@ export class GPXFile extends GPXTreeNode<Track> {
}); });
} }
getStatistics(): GPXStatisticsGroup {
let statistics = new GPXStatisticsGroup();
this.forEachSegment((segment) => {
statistics.add(segment.getStatistics());
});
return statistics;
}
getStyle(defaultColor?: string): MergedLineStyles { getStyle(defaultColor?: string): MergedLineStyles {
const style = this.trk return this.trk
.map((track) => track.getStyle()) .map((track) => track.getStyle())
.reduce( .reduce(
(acc, style) => { (acc, style) => {
@@ -219,6 +217,8 @@ export class GPXFile extends GPXTreeNode<Track> {
!acc.color.includes(style['gpx_style:color']) !acc.color.includes(style['gpx_style:color'])
) { ) {
acc.color.push(style['gpx_style:color']); acc.color.push(style['gpx_style:color']);
} else if (defaultColor && !acc.color.includes(defaultColor)) {
acc.color.push(defaultColor);
} }
if ( if (
style && style &&
@@ -242,10 +242,6 @@ export class GPXFile extends GPXTreeNode<Track> {
width: [], width: [],
} }
); );
if (style.color.length === 0 && defaultColor) {
style.color.push(defaultColor);
}
return style;
} }
clone(): GPXFile { clone(): GPXFile {
@@ -808,7 +804,7 @@ export class TrackSegment extends GPXTreeLeaf {
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) { constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
super(); super();
if (segment) { if (segment) {
this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index)); this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
if (segment.hasOwnProperty('_data')) { if (segment.hasOwnProperty('_data')) {
this._data = segment._data; this._data = segment._data;
} }
@@ -820,12 +816,15 @@ export class TrackSegment extends GPXTreeLeaf {
_computeStatistics(): GPXStatistics { _computeStatistics(): GPXStatistics {
let statistics = new GPXStatistics(); let statistics = new GPXStatistics();
statistics.global.length = this.trkpt.length; statistics.local.points = this.trkpt.map((point) => point);
statistics.local.points = this.trkpt.slice(0);
statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics()); statistics.local.elevation.smoothed = this._computeSmoothedElevation();
statistics.local.slope.at = this._computeSlope();
const points = this.trkpt; const points = this.trkpt;
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
points[i]._data['index'] = i;
// distance // distance
let dist = 0; let dist = 0;
if (i > 0) { if (i > 0) {
@@ -834,18 +833,34 @@ export class TrackSegment extends GPXTreeLeaf {
statistics.global.distance.total += dist; statistics.global.distance.total += dist;
} }
statistics.local.data[i].distance.total = statistics.global.distance.total; statistics.local.distance.total.push(statistics.global.distance.total);
// elevation
if (i > 0) {
const ele =
statistics.local.elevation.smoothed[i] -
statistics.local.elevation.smoothed[i - 1];
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
}
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
// time // time
if (points[i].time === undefined) { if (points[i].time === undefined) {
statistics.local.data[i].time.total = 0; statistics.local.time.total.push(0);
} else { } else {
if (statistics.global.time.start === undefined) { if (statistics.global.time.start === undefined) {
statistics.global.time.start = points[i].time; statistics.global.time.start = points[i].time;
} }
statistics.global.time.end = points[i].time; statistics.global.time.end = points[i].time;
statistics.local.data[i].time.total = statistics.local.time.total.push(
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000; (points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000
);
} }
// speed // speed
@@ -860,8 +875,8 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
statistics.local.data[i].distance.moving = statistics.global.distance.moving; statistics.local.distance.moving.push(statistics.global.distance.moving);
statistics.local.data[i].time.moving = statistics.global.time.moving; statistics.local.time.moving.push(statistics.global.time.moving);
// bounds // bounds
statistics.global.bounds.southWest.lat = Math.min( statistics.global.bounds.southWest.lat = Math.min(
@@ -945,7 +960,8 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
this._elevationComputation(statistics); [statistics.local.slope.segment, statistics.local.slope.length] =
this._computeSlopeSegments(statistics);
statistics.global.time.total = statistics.global.time.total =
statistics.global.time.start && statistics.global.time.end statistics.global.time.start && statistics.global.time.end
@@ -961,115 +977,73 @@ export class TrackSegment extends GPXTreeLeaf {
? statistics.global.distance.moving / (statistics.global.time.moving / 3600) ? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0; : 0;
timeWindowSmoothing( statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(
points, points,
10000, 200,
(start, end) => (accumulated, start, end) =>
points[start].time && points[end].time points[start].time && points[end].time
? (3600 * ? (3600 * accumulated) /
(statistics.local.data[end].distance.total - (points[end].time.getTime() - points[start].time.getTime())
statistics.local.data[start].distance.total)) / : undefined
Math.max(
(points[end].time.getTime() - points[start].time.getTime()) / 1000,
1
)
: undefined,
(value, index) => {
statistics.local.data[index].speed = value;
}
); );
return statistics; return statistics;
} }
_elevationComputation(statistics: GPXStatistics) { _computeSmoothedElevation(): number[] {
const points = this.trkpt;
let smoothed = distanceWindowSmoothing(
points,
100,
(index) => points[index].ele ?? 0,
(accumulated, start, end) => accumulated / (end - start + 1)
);
if (points.length > 0) {
smoothed[0] = points[0].ele ?? 0;
smoothed[points.length - 1] = points[points.length - 1].ele ?? 0;
}
return smoothed;
}
_computeSlope(): number[] {
const points = this.trkpt;
return distanceWindowSmoothingWithDistanceAccumulator(
points,
50,
(accumulated, start, end) =>
(100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0))) /
(accumulated > 0 ? accumulated : 1)
);
}
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
let simplified = ramerDouglasPeucker( let simplified = ramerDouglasPeucker(
this.trkpt, this.trkpt,
20, 20,
getElevationDistanceFunction(statistics) getElevationDistanceFunction(statistics)
); );
for (let i = 0; i < simplified.length - 1; i++) { let slope = [];
let start = simplified[i].point._data.index; let length = [];
let end = simplified[i + 1].point._data.index;
let cumulEle = 0;
let currentStart = start;
let currentEnd = start;
let prevSmoothedEle = 0;
distanceWindowSmoothing(
start,
end + 1,
statistics,
0.1,
(s, e) => {
for (let i = currentStart; i < s; i++) {
cumulEle -= this.trkpt[i].ele ?? 0;
}
for (let i = currentEnd; i <= e; i++) {
cumulEle += this.trkpt[i].ele ?? 0;
}
currentStart = s;
currentEnd = e + 1;
return cumulEle / (e - s + 1);
},
(smoothedEle, j) => {
if (j === start) {
smoothedEle = this.trkpt[start].ele ?? 0;
prevSmoothedEle = smoothedEle;
} else if (j === end) {
smoothedEle = this.trkpt[end].ele ?? 0;
}
const ele = smoothedEle - prevSmoothedEle;
if (ele > 0) {
statistics.global.elevation.gain += ele;
} else if (ele < 0) {
statistics.global.elevation.loss -= ele;
}
prevSmoothedEle = smoothedEle;
if (j < end) {
statistics.local.data[j].elevation.gain = statistics.global.elevation.gain;
statistics.local.data[j].elevation.loss = statistics.global.elevation.loss;
}
}
);
}
if (statistics.global.length > 0) {
statistics.local.data[statistics.global.length - 1].elevation.gain =
statistics.global.elevation.gain;
statistics.local.data[statistics.global.length - 1].elevation.loss =
statistics.global.elevation.loss;
}
for (let i = 0; i < simplified.length - 1; i++) { for (let i = 0; i < simplified.length - 1; i++) {
let start = simplified[i].point._data.index; let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index; let end = simplified[i + 1].point._data.index;
let dist = let dist =
statistics.local.data[end].distance.total - statistics.local.distance.total[end] - statistics.local.distance.total[start];
statistics.local.data[start].distance.total;
let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0); let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0);
for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) { for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
statistics.local.data[j].slope.segment = (0.1 * ele) / dist; slope.push((0.1 * ele) / dist);
statistics.local.data[j].slope.length = dist; length.push(dist);
} }
} }
distanceWindowSmoothing( return [slope, length];
0,
this.trkpt.length,
statistics,
0.05,
(start, end) => {
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
const dist =
statistics.local.data[end].distance.total -
statistics.local.data[start].distance.total;
return dist > 0 ? (0.1 * ele) / dist : 0;
},
(value, index) => {
statistics.local.data[index].slope.at = value;
}
);
} }
getNumberOfTrackPoints(): number { getNumberOfTrackPoints(): number {
@@ -1316,8 +1290,8 @@ export class TrackSegment extends GPXTreeLeaf {
lastPoint: TrackPoint | undefined lastPoint: TrackPoint | undefined
) { ) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let statistics = og._computeStatistics(); let slope = og._computeSlope();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics); let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
} }
@@ -1326,7 +1300,6 @@ export class TrackSegment extends GPXTreeLeaf {
} }
} }
const emptyExtensions: Record<string, string> = {};
export class TrackPoint { export class TrackPoint {
[immerable] = true; [immerable] = true;
@@ -1337,7 +1310,7 @@ export class TrackPoint {
_data: { [key: string]: any } = {}; _data: { [key: string]: any } = {};
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) { constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) {
this.attributes = point.attributes; this.attributes = point.attributes;
this.ele = point.ele; this.ele = point.ele;
this.time = point.time; this.time = point.time;
@@ -1345,9 +1318,6 @@ export class TrackPoint {
if (point.hasOwnProperty('_data')) { if (point.hasOwnProperty('_data')) {
this._data = point._data; this._data = point._data;
} }
if (index !== undefined) {
this._data.index = index;
}
} }
getCoordinates(): Coordinates { getCoordinates(): Coordinates {
@@ -1421,7 +1391,7 @@ export class TrackPoint {
this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension'] &&
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
: emptyExtensions; : {};
} }
toTrackPointType(exclude: string[] = []): TrackPointType { toTrackPointType(exclude: string[] = []): TrackPointType {
@@ -1491,18 +1461,11 @@ export class TrackPoint {
clone(): TrackPoint { clone(): TrackPoint {
return new TrackPoint({ return new TrackPoint({
attributes: { attributes: cloneJSON(this.attributes),
lat: this.attributes.lat,
lon: this.attributes.lon,
},
ele: this.ele, ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined, time: this.time ? new Date(this.time.getTime()) : undefined,
extensions: this.extensions ? cloneJSON(this.extensions) : undefined, extensions: cloneJSON(this.extensions),
_data: { _data: cloneJSON(this._data),
index: this._data?.index,
anchor: this._data?.anchor,
zoom: this._data?.zoom,
},
}); });
} }
} }
@@ -1521,28 +1484,19 @@ export class Waypoint {
type?: string; type?: string;
_data: { [key: string]: any } = {}; _data: { [key: string]: any } = {};
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) { constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) {
this.attributes = waypoint.attributes; this.attributes = waypoint.attributes;
this.ele = waypoint.ele; this.ele = waypoint.ele;
this.time = waypoint.time; this.time = waypoint.time;
this.name = waypoint.name === '' ? undefined : waypoint.name; this.name = waypoint.name;
this.cmt = waypoint.cmt === '' ? undefined : waypoint.cmt; this.cmt = waypoint.cmt;
this.desc = waypoint.desc === '' ? undefined : waypoint.desc; this.desc = waypoint.desc;
this.link = this.link = waypoint.link;
!waypoint.link || this.sym = waypoint.sym;
!waypoint.link.attributes || this.type = waypoint.type;
!waypoint.link.attributes.href ||
waypoint.link.attributes.href === ''
? undefined
: waypoint.link;
this.sym = waypoint.sym === '' ? undefined : waypoint.sym;
this.type = waypoint.type === '' ? undefined : waypoint.type;
if (waypoint.hasOwnProperty('_data')) { if (waypoint.hasOwnProperty('_data')) {
this._data = waypoint._data; this._data = waypoint._data;
} }
if (index !== undefined) {
this._data.index = index;
}
} }
getCoordinates(): Coordinates { getCoordinates(): Coordinates {
@@ -1590,10 +1544,7 @@ export class Waypoint {
clone(): Waypoint { clone(): Waypoint {
return new Waypoint({ return new Waypoint({
attributes: { attributes: cloneJSON(this.attributes),
lat: this.attributes.lat,
lon: this.attributes.lon,
},
ele: this.ele, ele: this.ele,
time: this.time ? new Date(this.time.getTime()) : undefined, time: this.time ? new Date(this.time.getTime()) : undefined,
name: this.name, name: this.name,
@@ -1642,6 +1593,310 @@ export class Waypoint {
} }
} }
export class GPXStatistics {
global: {
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
};
local: {
points: TrackPoint[];
distance: {
moving: number[];
total: number[];
};
time: {
moving: number[];
total: number[];
};
speed: number[];
elevation: {
smoothed: number[];
gain: number[];
loss: number[];
};
slope: {
at: number[];
segment: number[];
length: number[];
};
};
constructor() {
this.global = {
distance: {
moving: 0,
total: 0,
},
time: {
start: undefined,
end: undefined,
moving: 0,
total: 0,
},
speed: {
moving: 0,
total: 0,
},
elevation: {
gain: 0,
loss: 0,
},
bounds: {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
},
atemp: {
avg: 0,
count: 0,
},
hr: {
avg: 0,
count: 0,
},
cad: {
avg: 0,
count: 0,
},
power: {
avg: 0,
count: 0,
},
extensions: {},
};
this.local = {
points: [],
distance: {
moving: [],
total: [],
},
time: {
moving: [],
total: [],
},
speed: [],
elevation: {
smoothed: [],
gain: [],
loss: [],
},
slope: {
at: [],
segment: [],
length: [],
},
};
}
mergeWith(other: GPXStatistics): void {
this.local.points = this.local.points.concat(other.local.points);
this.local.distance.total = this.local.distance.total.concat(
other.local.distance.total.map((distance) => distance + this.global.distance.total)
);
this.local.distance.moving = this.local.distance.moving.concat(
other.local.distance.moving.map((distance) => distance + this.global.distance.moving)
);
this.local.time.total = this.local.time.total.concat(
other.local.time.total.map((time) => time + this.global.time.total)
);
this.local.time.moving = this.local.time.moving.concat(
other.local.time.moving.map((time) => time + this.global.time.moving)
);
this.local.elevation.gain = this.local.elevation.gain.concat(
other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain)
);
this.local.elevation.loss = this.local.elevation.loss.concat(
other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss)
);
this.local.speed = this.local.speed.concat(other.local.speed);
this.local.elevation.smoothed = this.local.elevation.smoothed.concat(
other.local.elevation.smoothed
);
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
this.global.distance.total += other.global.distance.total;
this.global.distance.moving += other.global.distance.moving;
this.global.time.start =
this.global.time.start !== undefined && other.global.time.start !== undefined
? new Date(
Math.min(this.global.time.start.getTime(), other.global.time.start.getTime())
)
: (this.global.time.start ?? other.global.time.start);
this.global.time.end =
this.global.time.end !== undefined && other.global.time.end !== undefined
? new Date(
Math.max(this.global.time.end.getTime(), other.global.time.end.getTime())
)
: (this.global.time.end ?? other.global.time.end);
this.global.time.total += other.global.time.total;
this.global.time.moving += other.global.time.moving;
this.global.speed.moving =
this.global.time.moving > 0
? this.global.distance.moving / (this.global.time.moving / 3600)
: 0;
this.global.speed.total =
this.global.time.total > 0
? this.global.distance.total / (this.global.time.total / 3600)
: 0;
this.global.elevation.gain += other.global.elevation.gain;
this.global.elevation.loss += other.global.elevation.loss;
this.global.bounds.southWest.lat = Math.min(
this.global.bounds.southWest.lat,
other.global.bounds.southWest.lat
);
this.global.bounds.southWest.lon = Math.min(
this.global.bounds.southWest.lon,
other.global.bounds.southWest.lon
);
this.global.bounds.northEast.lat = Math.max(
this.global.bounds.northEast.lat,
other.global.bounds.northEast.lat
);
this.global.bounds.northEast.lon = Math.max(
this.global.bounds.northEast.lon,
other.global.bounds.northEast.lon
);
this.global.atemp.avg =
(this.global.atemp.count * this.global.atemp.avg +
other.global.atemp.count * other.global.atemp.avg) /
Math.max(1, this.global.atemp.count + other.global.atemp.count);
this.global.atemp.count += other.global.atemp.count;
this.global.hr.avg =
(this.global.hr.count * this.global.hr.avg +
other.global.hr.count * other.global.hr.avg) /
Math.max(1, this.global.hr.count + other.global.hr.count);
this.global.hr.count += other.global.hr.count;
this.global.cad.avg =
(this.global.cad.count * this.global.cad.avg +
other.global.cad.count * other.global.cad.avg) /
Math.max(1, this.global.cad.count + other.global.cad.count);
this.global.cad.count += other.global.cad.count;
this.global.power.avg =
(this.global.power.count * this.global.power.avg +
other.global.power.count * other.global.power.avg) /
Math.max(1, this.global.power.count + other.global.power.count);
this.global.power.count += other.global.power.count;
Object.keys(other.global.extensions).forEach((extension) => {
if (this.global.extensions[extension] === undefined) {
this.global.extensions[extension] = {};
}
Object.keys(other.global.extensions[extension]).forEach((value) => {
if (this.global.extensions[extension][value] === undefined) {
this.global.extensions[extension][value] = 0;
}
this.global.extensions[extension][value] +=
other.global.extensions[extension][value];
});
});
}
slice(start: number, end: number): GPXStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.local.points.length) {
return new GPXStatistics();
}
if (end < start) {
return new GPXStatistics();
} else if (end >= this.local.points.length) {
end = this.local.points.length - 1;
}
let statistics = new GPXStatistics();
statistics.local.points = this.local.points.slice(start, end + 1);
statistics.global.distance.total =
this.local.distance.total[end] - this.local.distance.total[start];
statistics.global.distance.moving =
this.local.distance.moving[end] - this.local.distance.moving[start];
statistics.global.time.start = this.local.points[start].time;
statistics.global.time.end = this.local.points[end].time;
statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start];
statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start];
statistics.global.speed.moving =
statistics.global.time.moving > 0
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
: 0;
statistics.global.speed.total =
statistics.global.time.total > 0
? statistics.global.distance.total / (statistics.global.time.total / 3600)
: 0;
statistics.global.elevation.gain =
this.local.elevation.gain[end] - this.local.elevation.gain[start];
statistics.global.elevation.loss =
this.local.elevation.loss[end] - this.local.elevation.loss[start];
statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.global.atemp = this.global.atemp;
statistics.global.hr = this.global.hr;
statistics.global.cad = this.global.cad;
statistics.global.power = this.global.power;
return statistics;
}
}
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export function distance( export function distance(
coord1: TrackPoint | Coordinates, coord1: TrackPoint | Coordinates,
@@ -1656,15 +1911,11 @@ export function distance(
const rad = Math.PI / 180; const rad = Math.PI / 180;
const lat1 = coord1.lat * rad; const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad; const lat2 = coord2.lat * rad;
const dLat = lat2 - lat1;
const dLon = (coord2.lon - coord1.lon) * rad;
// Haversine formula - better numerical stability for small distances
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(lat1) * Math.sin(lat2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2); Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad);
const c = 2 * Math.asin(Math.sqrt(Math.min(a, 1))); const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
return earthRadius * c; return maxMeters;
} }
export function getElevationDistanceFunction(statistics: GPXStatistics) { export function getElevationDistanceFunction(statistics: GPXStatistics) {
@@ -1675,9 +1926,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) { if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
return 0; return 0;
} }
let x1 = statistics.local.data[point1._data.index].distance.total * 1000; let x1 = statistics.local.distance.total[point1._data.index] * 1000;
let x2 = statistics.local.data[point2._data.index].distance.total * 1000; let x2 = statistics.local.distance.total[point2._data.index] * 1000;
let x3 = statistics.local.data[point3._data.index].distance.total * 1000; let x3 = statistics.local.distance.total[point3._data.index] * 1000;
let y1 = point1.ele; let y1 = point1.ele;
let y2 = point2.ele; let y2 = point2.ele;
let y3 = point3.ele; let y3 = point3.ele;
@@ -1691,61 +1942,57 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
}; };
} }
function windowSmoothing( function distanceWindowSmoothing(
left: number, points: TrackPoint[],
right: number, distanceWindow: number,
distance: (index1: number, index2: number) => number, accumulate: (index: number) => number,
window: number, compute: (accumulated: number, start: number, end: number) => number,
compute: (start: number, end: number) => number, remove?: (index: number) => number
callback: (value: number, index: number) => void ): number[] {
): void { let result = [];
let start = left;
for (var i = left; i < right; i++) { let start = 0,
while (start + 1 < i && distance(start, i) > window) { end = 0,
accumulated = 0;
for (var i = 0; i < points.length; i++) {
while (
start + 1 < i &&
distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow
) {
if (remove) {
accumulated -= remove(start);
} else {
accumulated -= accumulate(start);
}
start++; start++;
} }
let end = Math.min(i + 2, right); while (
while (end < right && distance(i, end) <= window) { end < points.length &&
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
) {
accumulated += accumulate(end);
end++; end++;
} }
callback(compute(start, end - 1), i); result[i] = compute(accumulated, start, end - 1);
} }
return result;
} }
function distanceWindowSmoothing( function distanceWindowSmoothingWithDistanceAccumulator(
left: number,
right: number,
statistics: GPXStatistics,
window: number,
compute: (start: number, end: number) => number,
callback: (value: number, index: number) => void
): void {
windowSmoothing(
left,
right,
(index1, index2) =>
statistics.local.data[index2].distance.total -
statistics.local.data[index1].distance.total,
window,
compute,
callback
);
}
function timeWindowSmoothing(
points: TrackPoint[], points: TrackPoint[],
window: number, distanceWindow: number,
compute: (start: number, end: number) => number, compute: (accumulated: number, start: number, end: number) => number
callback: (value: number, index: number) => void ): number[] {
): void { return distanceWindowSmoothing(
windowSmoothing( points,
0, distanceWindow,
points.length, (index) =>
(index1, index2) => index > 0
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window, ? distance(points[index - 1].getCoordinates(), points[index].getCoordinates())
window, : 0,
compute, compute,
callback (index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates())
); );
} }
@@ -1797,14 +2044,14 @@ function withArtificialTimestamps(
totalTime: number, totalTime: number,
lastPoint: TrackPoint | undefined, lastPoint: TrackPoint | undefined,
startTime: Date, startTime: Date,
statistics: GPXStatistics slope: number[]
): TrackPoint[] { ): TrackPoint[] {
let weight = []; let weight = [];
let totalWeight = 0; let totalWeight = 0;
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates()); let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at))); let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i])));
weight.push(w); weight.push(w);
totalWeight += w; totalWeight += w;
} }

View File

@@ -1,5 +1,4 @@
export * from './gpx'; export * from './gpx';
export * from './statistics';
export { Coordinates, LineStyleExtension, WaypointType } from './types'; export { Coordinates, LineStyleExtension, WaypointType } from './types';
export { parseGPX, buildGPX } from './io'; export { parseGPX, buildGPX } from './io';
export * from './simplify'; export * from './simplify';

View File

@@ -3,6 +3,8 @@ import { Coordinates } from './types';
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number }; export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
const earthRadius = 6371008.8;
export function ramerDouglasPeucker( export function ramerDouglasPeucker(
points: TrackPoint[], points: TrackPoint[],
epsilon: number = 50, epsilon: number = 50,
@@ -59,56 +61,76 @@ function ramerDouglasPeuckerRecursive(
} }
export function crossarcDistance( export function crossarcDistance(
point1: TrackPoint | Coordinates, point1: TrackPoint,
point2: TrackPoint | Coordinates, point2: TrackPoint,
point3: TrackPoint | Coordinates point3: TrackPoint | Coordinates
): number { ): number {
return crossarc( return crossarc(
point1 instanceof TrackPoint ? point1.getCoordinates() : point1, point1.getCoordinates(),
point2 instanceof TrackPoint ? point2.getCoordinates() : point2, point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3 point3 instanceof TrackPoint ? point3.getCoordinates() : point3
); );
} }
const metersPerLatitudeDegree = 111320;
function getMetersPerLongitudeDegree(latitude: number): number {
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number { function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
// Calculates the perpendicular distance in meters // Calculates the shortest distance in meters
// between a line segment (defined by p1 and p2) and a third point, p3. // between an arc (defined by p1 and p2) and a third point, p3.
// Uses simple planar geometry (ignores earth curvature). // Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
// Convert to meters using approximate scaling const rad = Math.PI / 180;
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat); const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const x1 = coord1.lon * metersPerLongitudeDegree; const lon1 = coord1.lon * rad;
const y1 = coord1.lat * metersPerLatitudeDegree; const lon2 = coord2.lon * rad;
const x2 = coord2.lon * metersPerLongitudeDegree; const lon3 = coord3.lon * rad;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
const dx = x2 - x1; // Prerequisites for the formulas
const dy = y2 - y1; const bear12 = bearing(lat1, lon1, lat2, lon2);
const segmentLengthSquared = dx * dx + dy * dy; const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
if (segmentLengthSquared === 0) { let diff = Math.abs(bear13 - bear12);
// p1 and p2 are the same point if (diff > Math.PI) {
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1)); diff = 2 * Math.PI - diff;
} }
// Project p3 onto the line defined by p1-p2 // Is relative bearing obtuse?
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared)); if (diff > Math.PI / 2) {
return dis13;
}
// Find the closest point on the segment // Find the cross-track distance.
const projX = x1 + t * dx; let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
const projY = y1 + t * dy;
// Return distance from p3 to the projected point // Is p4 beyond the arc?
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY)); let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3);
} else {
return Math.abs(dxt);
}
}
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points.
return (
Math.acos(
Math.sin(latA) * Math.sin(latB) +
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
) * earthRadius
);
}
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another.
return Math.atan2(
Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
);
} }
export function projectedPoint( export function projectedPoint(
@@ -124,39 +146,56 @@ export function projectedPoint(
} }
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates { function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line segment defined by p1 and p2 // Calculates the point on the line defined by p1 and p2
// that is closest to the third point, p3. // that is closest to the third point, p3.
// Uses simple planar geometry (ignores earth curvature). // Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
// Convert to meters using approximate scaling const rad = Math.PI / 180;
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat); const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const x1 = coord1.lon * metersPerLongitudeDegree; const lon1 = coord1.lon * rad;
const y1 = coord1.lat * metersPerLatitudeDegree; const lon2 = coord2.lon * rad;
const x2 = coord2.lon * metersPerLongitudeDegree; const lon3 = coord3.lon * rad;
const y2 = coord2.lat * metersPerLatitudeDegree;
const x3 = coord3.lon * metersPerLongitudeDegree;
const y3 = coord3.lat * metersPerLatitudeDegree;
const dx = x2 - x1; // Prerequisites for the formulas
const dy = y2 - y1; const bear12 = bearing(lat1, lon1, lat2, lon2);
const segmentLengthSquared = dx * dx + dy * dy; const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
if (segmentLengthSquared === 0) { let diff = Math.abs(bear13 - bear12);
// p1 and p2 are the same point if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
return coord1; return coord1;
} }
// Project p3 onto the line defined by p1-p2 // Find the cross-track distance.
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared)); let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Find the closest point on the segment // Is p4 beyond the arc?
const projX = x1 + t * dx; let dis12 = distance(lat1, lon1, lat2, lon2);
const projY = y1 + t * dy; let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return coord2;
} else {
// Determine the closest point (p4) on the great circle
const f = dis14 / earthRadius;
const lat4 = Math.asin(
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
);
const lon4 =
lon1 +
Math.atan2(
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
);
// Convert back to degrees return { lat: lat4 / rad, lon: lon4 / rad };
return { }
lat: projY / metersPerLatitudeDegree,
lon: projX / metersPerLongitudeDegree,
};
} }

View File

@@ -1,391 +0,0 @@
import { TrackPoint } from './gpx';
import { Coordinates } from './types';
export class GPXGlobalStatistics {
length: number;
distance: {
moving: number;
total: number;
};
time: {
start: Date | undefined;
end: Date | undefined;
moving: number;
total: number;
};
speed: {
moving: number;
total: number;
};
elevation: {
gain: number;
loss: number;
};
bounds: {
southWest: Coordinates;
northEast: Coordinates;
};
atemp: {
avg: number;
count: number;
};
hr: {
avg: number;
count: number;
};
cad: {
avg: number;
count: number;
};
power: {
avg: number;
count: number;
};
extensions: Record<string, Record<string, number>>;
constructor() {
this.length = 0;
this.distance = {
moving: 0,
total: 0,
};
this.time = {
start: undefined,
end: undefined,
moving: 0,
total: 0,
};
this.speed = {
moving: 0,
total: 0,
};
this.elevation = {
gain: 0,
loss: 0,
};
this.bounds = {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
};
this.atemp = {
avg: 0,
count: 0,
};
this.hr = {
avg: 0,
count: 0,
};
this.cad = {
avg: 0,
count: 0,
};
this.power = {
avg: 0,
count: 0,
};
this.extensions = {};
}
mergeWith(other: GPXGlobalStatistics): void {
this.length += other.length;
this.distance.total += other.distance.total;
this.distance.moving += other.distance.moving;
this.time.start =
this.time.start !== undefined && other.time.start !== undefined
? new Date(Math.min(this.time.start.getTime(), other.time.start.getTime()))
: (this.time.start ?? other.time.start);
this.time.end =
this.time.end !== undefined && other.time.end !== undefined
? new Date(Math.max(this.time.end.getTime(), other.time.end.getTime()))
: (this.time.end ?? other.time.end);
this.time.total += other.time.total;
this.time.moving += other.time.moving;
this.speed.moving =
this.time.moving > 0 ? this.distance.moving / (this.time.moving / 3600) : 0;
this.speed.total = this.time.total > 0 ? this.distance.total / (this.time.total / 3600) : 0;
this.elevation.gain += other.elevation.gain;
this.elevation.loss += other.elevation.loss;
this.bounds.southWest.lat = Math.min(this.bounds.southWest.lat, other.bounds.southWest.lat);
this.bounds.southWest.lon = Math.min(this.bounds.southWest.lon, other.bounds.southWest.lon);
this.bounds.northEast.lat = Math.max(this.bounds.northEast.lat, other.bounds.northEast.lat);
this.bounds.northEast.lon = Math.max(this.bounds.northEast.lon, other.bounds.northEast.lon);
this.atemp.avg =
(this.atemp.count * this.atemp.avg + other.atemp.count * other.atemp.avg) /
Math.max(1, this.atemp.count + other.atemp.count);
this.atemp.count += other.atemp.count;
this.hr.avg =
(this.hr.count * this.hr.avg + other.hr.count * other.hr.avg) /
Math.max(1, this.hr.count + other.hr.count);
this.hr.count += other.hr.count;
this.cad.avg =
(this.cad.count * this.cad.avg + other.cad.count * other.cad.avg) /
Math.max(1, this.cad.count + other.cad.count);
this.cad.count += other.cad.count;
this.power.avg =
(this.power.count * this.power.avg + other.power.count * other.power.avg) /
Math.max(1, this.power.count + other.power.count);
this.power.count += other.power.count;
Object.keys(other.extensions).forEach((extension) => {
if (this.extensions[extension] === undefined) {
this.extensions[extension] = {};
}
Object.keys(other.extensions[extension]).forEach((value) => {
if (this.extensions[extension][value] === undefined) {
this.extensions[extension][value] = 0;
}
this.extensions[extension][value] += other.extensions[extension][value];
});
});
}
}
export class TrackPointLocalStatistics {
distance: {
moving: number;
total: number;
};
time: {
moving: number;
total: number;
};
speed: number;
elevation: {
gain: number;
loss: number;
};
slope: {
at: number;
segment: number;
length: number;
};
constructor() {
this.distance = {
moving: 0,
total: 0,
};
this.time = {
moving: 0,
total: 0,
};
this.speed = 0;
this.elevation = {
gain: 0,
loss: 0,
};
this.slope = {
at: 0,
segment: 0,
length: 0,
};
}
}
export class GPXLocalStatistics {
points: TrackPoint[];
data: TrackPointLocalStatistics[];
constructor() {
this.points = [];
this.data = [];
}
}
export type TrackPointWithLocalStatistics = {
trkpt: TrackPoint;
} & TrackPointLocalStatistics;
export class GPXStatistics {
global: GPXGlobalStatistics;
local: GPXLocalStatistics;
constructor() {
this.global = new GPXGlobalStatistics();
this.local = new GPXLocalStatistics();
}
sliced(start: number, end: number): GPXGlobalStatistics {
if (start < 0) {
start = 0;
} else if (start >= this.global.length) {
return new GPXGlobalStatistics();
}
if (end < start) {
return new GPXGlobalStatistics();
} else if (end >= this.global.length) {
end = this.global.length - 1;
}
if (start === 0 && end === this.global.length - 1) {
return this.global;
}
let statistics = new GPXGlobalStatistics();
statistics.length = end - start + 1;
statistics.distance.total =
this.local.data[end].distance.total - this.local.data[start].distance.total;
statistics.distance.moving =
this.local.data[end].distance.moving - this.local.data[start].distance.moving;
statistics.time.start = this.local.points[start].time;
statistics.time.end = this.local.points[end].time;
statistics.time.total = this.local.data[end].time.total - this.local.data[start].time.total;
statistics.time.moving =
this.local.data[end].time.moving - this.local.data[start].time.moving;
statistics.speed.moving =
statistics.time.moving > 0
? statistics.distance.moving / (statistics.time.moving / 3600)
: 0;
statistics.speed.total =
statistics.time.total > 0
? statistics.distance.total / (statistics.time.total / 3600)
: 0;
statistics.elevation.gain =
this.local.data[end].elevation.gain - this.local.data[start].elevation.gain;
statistics.elevation.loss =
this.local.data[end].elevation.loss - this.local.data[start].elevation.loss;
statistics.bounds.southWest.lat = this.global.bounds.southWest.lat;
statistics.bounds.southWest.lon = this.global.bounds.southWest.lon;
statistics.bounds.northEast.lat = this.global.bounds.northEast.lat;
statistics.bounds.northEast.lon = this.global.bounds.northEast.lon;
statistics.atemp = this.global.atemp;
statistics.hr = this.global.hr;
statistics.cad = this.global.cad;
statistics.power = this.global.power;
return statistics;
}
}
export class GPXStatisticsGroup {
private _statistics: GPXStatistics[];
private _cumulative: GPXGlobalStatistics[];
private _slice: [number, number] | null = null;
global: GPXGlobalStatistics;
constructor() {
this._statistics = [];
this._cumulative = [new GPXGlobalStatistics()];
this.global = new GPXGlobalStatistics();
}
add(statistics: GPXStatistics | GPXStatisticsGroup): void {
if (statistics instanceof GPXStatisticsGroup) {
statistics._statistics.forEach((stats) => this._add(stats));
} else {
this._add(statistics);
}
}
_add(statistics: GPXStatistics): void {
this._statistics.push(statistics);
const cumulative = new GPXGlobalStatistics();
cumulative.mergeWith(this._cumulative[this._cumulative.length - 1]);
cumulative.mergeWith(statistics.global);
this._cumulative.push(cumulative);
this.global.mergeWith(statistics.global);
}
sliced(start: number, end: number): GPXGlobalStatistics {
let sliced = new GPXGlobalStatistics();
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (start < cumulative.length + statistics.global.length && end >= cumulative.length) {
const localStart = Math.max(0, start - cumulative.length);
const localEnd = Math.min(statistics.global.length - 1, end - cumulative.length);
sliced.mergeWith(statistics.sliced(localStart, localEnd));
}
}
return sliced;
}
getTrackPoint(index: number): TrackPointWithLocalStatistics | undefined {
if (this._slice !== null) {
index += this._slice[0];
}
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
if (index < cumulative.length + statistics.global.length) {
return this._getTrackPoint(cumulative, statistics, index - cumulative.length);
}
}
return undefined;
}
_getTrackPoint(
cumulative: GPXGlobalStatistics,
statistics: GPXStatistics,
index: number
): TrackPointWithLocalStatistics {
const point = statistics.local.points[index];
return {
trkpt: point,
distance: {
moving: statistics.local.data[index].distance.moving + cumulative.distance.moving,
total: statistics.local.data[index].distance.total + cumulative.distance.total,
},
time: {
moving: statistics.local.data[index].time.moving + cumulative.time.moving,
total: statistics.local.data[index].time.total + cumulative.time.total,
},
speed: statistics.local.data[index].speed,
elevation: {
gain: statistics.local.data[index].elevation.gain + cumulative.elevation.gain,
loss: statistics.local.data[index].elevation.loss + cumulative.elevation.loss,
},
slope: {
at: statistics.local.data[index].slope.at,
segment: statistics.local.data[index].slope.segment,
length: statistics.local.data[index].slope.length,
},
};
}
forEachTrackPoint(
callback: (
point: TrackPoint,
distance: number,
speed: number,
slope: { at: number; segment: number; length: number },
index: number
) => void
): void {
for (let i = 0; i < this._statistics.length; i++) {
const statistics = this._statistics[i];
const cumulative = this._cumulative[i];
statistics.local.points.forEach((point, index) =>
callback(
point,
cumulative.distance.total + statistics.local.data[index].distance.total,
statistics.local.data[index].speed,
statistics.local.data[index].slope,
cumulative.length + index
)
);
}
}
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "gpx.studio",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1 +1 @@
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN

View File

@@ -1,17 +1,17 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default", "style": "default",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks", "hooks": "$lib/hooks",
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://shadcn-svelte.com/registry" "registry": "https://shadcn-svelte.com/registry"
} }

1291
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^0.544.0",
@@ -23,14 +23,15 @@
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/mapbox__sphericalmercator": "^1.2.3",
"@types/mapbox__tilebelt": "^1.0.4", "@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.4.1",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.14.4", "bits-ui": "^2.12.0",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1", "eslint-plugin-svelte": "^3.9.1",
@@ -61,10 +62,11 @@
"dependencies": { "dependencies": {
"@docsearch/js": "^3.9.0", "@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^2.0.1", "@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2", "@mapbox/tilebelt": "^2.0.2",
"@maplibre/maplibre-gl-geocoder": "^1.9.4", "@types/mapbox__sphericalmercator": "^1.2.3",
"chart.js": "^4.5.1", "chart.js": "^4.4.9",
"chartjs-plugin-zoom": "^2.2.0", "chartjs-plugin-zoom": "^2.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.11",
@@ -72,8 +74,9 @@
"gpx": "file:../gpx", "gpx": "file:../gpx",
"immer": "^10.1.1", "immer": "^10.1.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapbox-gl": "^3.12.0",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"maplibre-gl": "^5.16.0", "png.js": "^0.2.1",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.0" "tailwind-merge": "^3.3.0"

View File

@@ -1,126 +1,124 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: hsl(0 0% 100%) /* <- Wrap in HSL */; --background: hsl(0 0% 100%) /* <- Wrap in HSL */;
--foreground: hsl(240 10% 3.9%); --foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%); --muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%); --muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%); --card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%); --card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%); --border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%); --input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%); --primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%); --primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%); --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%); --secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%); --accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%); --accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%); --destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%); --ring: hsl(240 10% 3.9%);
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%); --sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%); --sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%); --sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%); --sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%); --sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%); --sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(220 15 130); --support: rgb(220 15 130);
--link: rgb(0 110 180); --link: rgb(0 110 180);
--selection: hsl(240 4.8% 93%);
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: hsl(240 10% 3.9%); --background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%); --foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%); --muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%); --muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%); --popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%); --card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%); --card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%); --border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%); --input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%); --primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%); --primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%); --secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%); --accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%); --accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%); --destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%); --ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%); --sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%); --sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%); --sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%); --sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%); --sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: hsl(217.2 91.2% 59.8%);
--support: rgb(255 110 190); --support: rgb(255 110 190);
--link: rgb(80 190 255); --link: rgb(80 190 255);
--selection: hsl(240 3.7% 22%);
} }
@theme inline { @theme inline {
/* Radius (for rounded-*) */ /* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
/* Colors */ /* Colors */
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-radius: var(--radius); --color-radius: var(--radius);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-support: var(--support); --color-support: var(--support);
--color-link: var(--link); --color-link: var(--link);
--breakpoint-xs: 540px; --breakpoint-xs: 540px;
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@@ -24,14 +24,6 @@ export async function handle({ event, resolve }) {
let headTag = `<head> let headTag = `<head>
<title>gpx.studio — ${title}</title> <title>gpx.studio — ${title}</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "gpx.studio",
"url": "https://gpx.studio"
}
</script>
<meta name="description" content="${description}" /> <meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" /> <meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" /> <meta property="og:description" content="${description}" />

View File

@@ -17,6 +17,7 @@
} }
}, },
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite", "sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
"layers": [ "layers": [
{ {
"id": "background", "id": "background",

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@@ -22,18 +22,15 @@ import {
Binoculars, Binoculars,
Toilet, Toilet,
} from 'lucide-static'; } from 'lucide-static';
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl'; import { type StyleSpecification } from 'mapbox-gl';
import ignFrTopo from './custom/ign-fr-topo.json'; import ignFrTopo from './custom/ign-fr-topo.json';
import ignFrPlan from './custom/ign-fr-plan.json'; import ignFrPlan from './custom/ign-fr-plan.json';
import ignFrSatellite from './custom/ign-fr-satellite.json'; import ignFrSatellite from './custom/ign-fr-satellite.json';
import bikerouterGravel from './custom/bikerouter-gravel.json'; import bikerouterGravel from './custom/bikerouter-gravel.json';
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
export const basemaps: { [key: string]: string | StyleSpecification } = { export const basemaps: { [key: string]: string | StyleSpecification } = {
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`, mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`, mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
openStreetMap: { openStreetMap: {
version: 8, version: 8,
sources: { sources: {
@@ -148,7 +145,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json', swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json',
swisstopoSatellite: swisstopoSatellite:
'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json', 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json',
linz: 'https://basemaps.linz.govt.nz/v1/styles/topographic-v2.json?api=d01fbtg0ar23gctac5m0jgyy2ds', linz: 'https://basemaps.linz.govt.nz/v1/tiles/topographic/EPSG:3857/style/topographic.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
linzTopo: { linzTopo: {
version: 8, version: 8,
sources: { sources: {
@@ -371,42 +368,6 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
], ],
}, },
bikerouterGravel: bikerouterGravel as StyleSpecification, bikerouterGravel: bikerouterGravel as StyleSpecification,
openRailwayMap: {
version: 8,
sources: {
openRailwayMap: {
type: 'raster',
tiles: ['https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution:
'Data <a href="https://www.openstreetmap.org/copyright">&copy; OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a>',
},
},
layers: [
{
id: 'openRailwayMap',
type: 'raster',
source: 'openRailwayMap',
},
],
},
mapterhornHillshade: {
version: 8,
sources: {
mapterhornHillshade: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
},
},
layers: [
{
id: 'mapterhornHillshade',
type: 'hillshade',
source: 'mapterhornHillshade',
},
],
},
swisstopoSlope: { swisstopoSlope: {
version: 8, version: 8,
sources: { sources: {
@@ -776,9 +737,8 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = { export const basemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
maptilerTopo: true, mapboxOutdoors: true,
maptilerOutdoors: true, mapboxSatellite: true,
maptilerSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -839,10 +799,8 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: true, waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true, waymarkedTrailsWinter: true,
}, },
bikerouterGravel: true,
cyclOSMlite: true, cyclOSMlite: true,
mapterhornHillshade: true, bikerouterGravel: true,
openRailwayMap: true,
}, },
countries: { countries: {
france: { france: {
@@ -878,7 +836,6 @@ export const overpassTree: LayerTreeType = {
shower: true, shower: true,
shelter: true, shelter: true,
barrier: true, barrier: true,
cemetery: true,
}, },
tourism: { tourism: {
attraction: true, attraction: true,
@@ -911,7 +868,7 @@ export const overpassTree: LayerTreeType = {
}; };
// Default basemap used // Default basemap used
export const defaultBasemap = 'maptilerTopo'; export const defaultBasemap = 'mapboxOutdoors';
// Default overlays used (none) // Default overlays used (none)
export const defaultOverlays: LayerTreeType = { export const defaultOverlays: LayerTreeType = {
@@ -925,10 +882,8 @@ export const defaultOverlays: LayerTreeType = {
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
}, },
bikerouterGravel: false,
cyclOSMlite: false, cyclOSMlite: false,
mapterhornHillshade: false, bikerouterGravel: false,
openRailwayMap: false,
}, },
countries: { countries: {
france: { france: {
@@ -964,7 +919,6 @@ export const defaultOverpassQueries: LayerTreeType = {
shower: false, shower: false,
shelter: false, shelter: false,
barrier: false, barrier: false,
cemetery: false,
}, },
tourism: { tourism: {
attraction: false, attraction: false,
@@ -1000,9 +954,8 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = { export const defaultBasemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
maptilerTopo: true, mapboxOutdoors: true,
maptilerOutdoors: true, mapboxSatellite: true,
maptilerSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -1063,10 +1016,8 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsHorseRiding: false, waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false, waymarkedTrailsWinter: false,
}, },
bikerouterGravel: false,
cyclOSMlite: false, cyclOSMlite: false,
mapterhornHillshade: false, bikerouterGravel: false,
openRailwayMap: false,
}, },
countries: { countries: {
france: { france: {
@@ -1102,7 +1053,6 @@ export const defaultOverpassTree: LayerTreeType = {
shower: false, shower: false,
shelter: false, shelter: false,
barrier: false, barrier: false,
cemetery: false,
}, },
tourism: { tourism: {
attraction: false, attraction: false,
@@ -1141,7 +1091,7 @@ export type CustomLayer = {
maxZoom: number; maxZoom: number;
layerType: 'basemap' | 'overlay'; layerType: 'basemap' | 'overlay';
resourceType: 'raster' | 'vector'; resourceType: 'raster' | 'vector';
value: string | maplibregl.StyleSpecification; value: string | {};
}; };
type OverpassQueryData = { type OverpassQueryData = {
@@ -1149,7 +1099,9 @@ type OverpassQueryData = {
svg: string; svg: string;
color: string; color: string;
}; };
tags: Record<string, string | string[]> | Record<string, string | string[]>[]; tags:
| Record<string, string | boolean | string[]>
| Record<string, string | boolean | string[]>[];
symbol?: string; symbol?: string;
}; };
@@ -1230,20 +1182,6 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
}, },
symbol: 'Shelter', symbol: 'Shelter',
}, },
cemetery: {
icon: {
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 17v-10a6 5 0 1 1 12 0v10"/><path d="M 4 21 a 1 1 0 0 0 1 1 h 14 a 1 1 0 0 0 1-1 v -1 a 2 2 0 0 0-2-2 H6 a 2 2 0 0 0-2 2 z"/></svg>',
color: '#000000',
},
tags: [
{
landuse: 'cemetery',
},
{
amenity: 'grave_yard',
},
],
},
'fuel-station': { 'fuel-station': {
icon: { icon: {
svg: Fuel, svg: Fuel,
@@ -1280,25 +1218,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
color: '#000000', color: '#000000',
}, },
tags: { tags: {
barrier: [ barrier: true,
'bar',
'barrier_board',
'block',
'chain',
'cycle_barrier',
'gate',
'hampshire_gate',
'horse_stile',
'kissing_gate',
'lift_gate',
'motorcycle_barrier',
'sliding_beam',
'sliding_gate',
'stile',
'swing_gate',
'turnstile',
'wicket_gate',
],
}, },
}, },
attraction: { attraction: {
@@ -1458,16 +1378,3 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
symbol: 'Anchor', symbol: 'Anchor',
}, },
}; };
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
'maptiler-dem': {
type: 'raster-dem',
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
},
mapterhorn: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
},
};
export const defaultTerrainSource = 'maptiler-dem';

View File

@@ -1,5 +1,6 @@
import { import {
Landmark, Landmark,
Icon,
Shell, Shell,
Bike, Bike,
Building, Building,
@@ -28,7 +29,6 @@ import {
TriangleAlert, TriangleAlert,
Anchor, Anchor,
Toilet, Toilet,
X,
type IconProps, type IconProps,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import {
@@ -61,7 +61,6 @@ import {
TriangleAlert as TriangleAlertSvg, TriangleAlert as TriangleAlertSvg,
Anchor as AnchorSvg, Anchor as AnchorSvg,
Toilet as ToiletSvg, Toilet as ToiletSvg,
X as XSvg,
} from 'lucide-static'; } from 'lucide-static';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
@@ -88,11 +87,7 @@ export const symbols: { [key: string]: Symbol } = {
icon: ShoppingBasket, icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg, iconSvg: ShoppingBasketSvg,
}, },
crossing: { crossing: { value: 'Crossing' },
value: 'Crossing',
icon: X,
iconSvg: XSvg,
},
department_store: { department_store: {
value: 'Department Store', value: 'Department Store',
icon: ShoppingBasket, icon: ShoppingBasket,

View File

@@ -18,7 +18,7 @@
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE" href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank" target="_blank"
> >
MIT © 2026 gpx.studio MIT © 2025 gpx.studio
</Button> </Button>
<LanguageSelect class="w-40 mt-3" /> <LanguageSelect class="w-40 mt-3" />
</div> </div>
@@ -34,7 +34,6 @@
{i18n._('homepage.home')} {i18n._('homepage.home')}
</Button> </Button>
<Button <Button
data-sveltekit-reload
variant="link" variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')} href={getURLForLanguage(i18n.lang, '/app')}
@@ -71,6 +70,15 @@
<Logo company="facebook" class="h-4 fill-muted-foreground" /> <Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')} {i18n._('homepage.facebook')}
</Button> </Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 fill-muted-foreground" />
{i18n._('homepage.x')}
</Button>
<Button <Button
variant="link" variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"

View File

@@ -6,7 +6,7 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { GPXStatistics } from 'gpx';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
@@ -18,39 +18,39 @@
orientation, orientation,
panelSize, panelSize,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>; slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
panelSize: number; panelSize: number;
} = $props(); } = $props();
let statistics = $derived( let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global $slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
); );
</script> </script>
<Card.Root <Card.Root
class="h-full {orientation === 'vertical' class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44' ? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full h-10'} border-none shadow-none p-0 text-sm sm:text-base" : 'w-full'} border-none shadow-none p-0"
> >
<Card.Content <Card.Content
class="h-full flex {orientation === 'vertical' class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-evenly'} gap-4 p-0" : 'flex-row w-full justify-between'} gap-4 p-0"
> >
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" /> <WithUnits value={statistics.global.distance.total} type="distance" />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}> <Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" /> <WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" /> <MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" /> <WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span> </span>
</Tooltip> </Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'} {#if panelSize > 120 || orientation === 'horizontal'}
@@ -64,9 +64,13 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} /> <WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" /> <WithUnits value={statistics.global.speed.total} type="speed" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
@@ -79,9 +83,9 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Timer size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" /> <WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" /> <WithUnits value={statistics.global.time.total} type="time" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}

View File

@@ -8,7 +8,7 @@
...others ...others
}: { }: {
iconOnly?: boolean; iconOnly?: boolean;
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit'; company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'x' | 'reddit';
[key: string]: any; [key: string]: any;
} = $props(); } = $props();
</script> </script>
@@ -19,10 +19,10 @@
alt="Logo of gpx.studio." alt="Logo of gpx.studio."
{...others} {...others}
/> />
{:else if company === 'maptiler'} {:else if company === 'mapbox'}
<img <img
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg" src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Maptiler." alt="Logo of Mapbox."
{...others} {...others}
/> />
{:else if company === 'github'} {:else if company === 'github'}
@@ -55,6 +55,16 @@
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
/></svg /></svg
> >
{:else if company === 'x'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
{:else if company === 'reddit'} {:else if company === 'reddit'}
<svg <svg
role="img" role="img"

View File

@@ -538,7 +538,6 @@
let targetInput = let targetInput =
e && e &&
e.target && e.target &&
e.target instanceof HTMLElement &&
(e.target.tagName === 'INPUT' || (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' || e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' || e.target.tagName === 'SELECT' ||
@@ -645,19 +644,6 @@
} else if (e.key === 'F5') { } else if (e.key === 'F5') {
$routing = !$routing; $routing = !$routing;
e.preventDefault(); e.preventDefault();
} else if (
e.key === 'ArrowRight' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowUp'
) {
if (!targetInput) {
selection.updateFromKey(
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
e.preventDefault();
}
} }
}} }}
on:dragover={(e) => e.preventDefault()} on:dragover={(e) => e.preventDefault()}

View File

@@ -23,7 +23,6 @@
{i18n._('homepage.home')} {i18n._('homepage.home')}
</Button> </Button>
<Button <Button
data-sveltekit-reload
variant="link" variant="link"
class="text-base px-0 has-[>svg]:px-0" class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')} href={getURLForLanguage(i18n.lang, '/app')}

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced'; import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced'; import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
</script> </script>
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip"> <div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" /> <enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img <enhanced:img
src={waymarkedMap} src={waymarkedMap}
alt="Waymarked Trails map screenshot." alt="Waymarked Trails map screenshot."

View File

@@ -18,7 +18,7 @@
Construction, Construction,
} from '@lucide/svelte'; } from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -28,14 +28,12 @@
let { let {
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
showControls = true, showControls = true,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>; additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean; showControls?: boolean;
@@ -49,7 +47,6 @@
elevationProfile = new ElevationProfile( elevationProfile = new ElevationProfile(
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
canvas, canvas,
@@ -64,7 +61,7 @@
}); });
</script> </script>
<div class="h-full grow min-w-0 min-h-0 relative"> <div class="h-full grow min-w-0 relative py-2">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas> <canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas> <canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls} {#if showControls}

View File

@@ -14,14 +14,11 @@ import {
getTemperatureWithUnits, getTemperatureWithUnits,
getVelocityWithUnits, getVelocityWithUnits,
} from '$lib/units'; } from '$lib/units';
import Chart, { import Chart from 'chart.js/auto';
type ChartEvent, import mapboxgl from 'mapbox-gl';
type ChartOptions,
type ScriptableLineSegmentContext,
type TooltipItem,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store'; import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { map } from '$lib/components/map/map';
import type { GPXStatistics } from 'gpx';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
@@ -30,37 +27,22 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family = Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
interface ElevationProfilePoint {
x: number;
y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
extensions: Record<string, any>;
coordinates: Coordinates;
index: number;
}
export class ElevationProfile { export class ElevationProfile {
private _chart: Chart | null = null; private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement; private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement; private _overlay: HTMLCanvasElement;
private _marker: mapboxgl.Marker | null = null;
private _dragging = false; private _dragging = false;
private _panning = false; private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>; private _gpxStatistics: Readable<GPXStatistics>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>; private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor( constructor(
gpxStatistics: Readable<GPXStatisticsGroup>, gpxStatistics: Readable<GPXStatistics>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>, additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
@@ -68,12 +50,17 @@ export class ElevationProfile {
) { ) {
this._gpxStatistics = gpxStatistics; this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics; this._slicedGPXStatistics = slicedGPXStatistics;
this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets; this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill; this._elevationFill = elevationFill;
this._canvas = canvas; this._canvas = canvas;
this._overlay = overlay; this._overlay = overlay;
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
this._marker = new mapboxgl.Marker({
element,
});
import('chartjs-plugin-zoom').then((module) => { import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default); Chart.register(module.default);
this.initialize(); this.initialize();
@@ -103,7 +90,7 @@ export class ElevationProfile {
} }
initialize() { initialize() {
let options: ChartOptions<'line'> = { let options = {
animation: false, animation: false,
parsing: false, parsing: false,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -111,8 +98,8 @@ export class ElevationProfile {
x: { x: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number | string) { callback: function (value: number) {
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`; return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
}, },
align: 'inner', align: 'inner',
maxRotation: 0, maxRotation: 0,
@@ -121,8 +108,8 @@ export class ElevationProfile {
y: { y: {
type: 'linear', type: 'linear',
ticks: { ticks: {
callback: function (value: number | string) { callback: function (value: number) {
return getElevationWithUnits(value as number, false); return getElevationWithUnits(value, false);
}, },
}, },
}, },
@@ -153,13 +140,17 @@ export class ElevationProfile {
title: () => { title: () => {
return ''; return '';
}, },
label: (context: TooltipItem<'line'>) => { label: (context: Chart.TooltipContext) => {
let point = context.raw as ElevationProfilePoint; let point = context.raw;
if (context.datasetIndex === 0) { if (context.datasetIndex === 0) {
if (this._dragging) { const map_ = get(map);
this._hoveredPoint.set(null); if (map_ && this._marker) {
} else { if (this._dragging) {
this._hoveredPoint.set(point.coordinates); this._marker.remove();
} else {
this._marker.setLngLat(point.coordinates);
this._marker.addTo(map_);
}
} }
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) { } else if (context.datasetIndex === 1) {
@@ -174,10 +165,10 @@ export class ElevationProfile {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`; return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
} }
}, },
afterBody: (contexts: TooltipItem<'line'>[]) => { afterBody: (contexts: Chart.TooltipContext[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0); let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return; if (context.length === 0) return;
let point = context[0].raw as ElevationProfilePoint; let point = context[0].raw;
let slope = { let slope = {
at: point.slope.at.toFixed(1), at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1), segment: point.slope.segment.toFixed(1),
@@ -236,7 +227,6 @@ export class ElevationProfile {
onPanStart: () => { onPanStart: () => {
this._panning = true; this._panning = true;
this._slicedGPXStatistics.set(undefined); this._slicedGPXStatistics.set(undefined);
return true;
}, },
onPanComplete: () => { onPanComplete: () => {
this._panning = false; this._panning = false;
@@ -248,13 +238,13 @@ export class ElevationProfile {
}, },
mode: 'x', mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => { onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
if (!this._chart) {
return false;
}
const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0;
if ( if (
event.deltaY < 0 && event.deltaY < 0 &&
Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01 Math.abs(
this._chart.getInitialScaleBounds().x.max /
this._chart.options.plugins.zoom.limits.x.minRange -
this._chart.getZoomLevel()
) < 0.01
) { ) {
// Disable wheel pan if zoomed in to the max, and zooming in // Disable wheel pan if zoomed in to the max, and zooming in
return false; return false;
@@ -272,6 +262,7 @@ export class ElevationProfile {
}, },
}, },
}, },
stacked: false,
onResize: () => { onResize: () => {
this.updateOverlay(); this.updateOverlay();
}, },
@@ -279,7 +270,7 @@ export class ElevationProfile {
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power']; let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => { datasets.forEach((id) => {
options.scales![`y${id}`] = { options.scales[`y${id}`] = {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { grid: {
@@ -300,9 +291,12 @@ export class ElevationProfile {
{ {
id: 'toggleMarker', id: 'toggleMarker',
events: ['mouseout'], events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => { afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
if (args.event.type === 'mouseout') { if (args.event.type === 'mouseout') {
this._hoveredPoint.set(null); const map_ = get(map);
if (map_ && this._marker) {
this._marker.remove();
}
} }
}, },
}, },
@@ -311,7 +305,7 @@ export class ElevationProfile {
let startIndex = 0; let startIndex = 0;
let endIndex = 0; let endIndex = 0;
const getIndex = (evt: PointerEvent) => { const getIndex = (evt) => {
if (!this._chart) { if (!this._chart) {
return undefined; return undefined;
} }
@@ -329,22 +323,22 @@ export class ElevationProfile {
if (evt.x - rect.left <= this._chart.chartArea.left) { if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0; return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) { } else if (evt.x - rect.left >= this._chart.chartArea.right) {
return this._chart.data.datasets[0].data.length - 1; return get(this._gpxStatistics).local.points.length - 1;
} else { } else {
return undefined; return undefined;
} }
} }
const point = points.find((point) => (point.element as any).raw); let point = points.find((point) => point.element.raw);
if (point) { if (point) {
return (point.element as any).raw.index; return point.element.raw.index;
} else { } else {
return points[0].index; return points[0].index;
} }
}; };
let dragStarted = false; let dragStarted = false;
const onMouseDown = (evt: PointerEvent) => { const onMouseDown = (evt) => {
if (evt.shiftKey) { if (evt.shiftKey) {
// Panning interaction // Panning interaction
return; return;
@@ -353,7 +347,7 @@ export class ElevationProfile {
this._canvas.style.cursor = 'col-resize'; this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt); startIndex = getIndex(evt);
}; };
const onMouseMove = (evt: PointerEvent) => { const onMouseMove = (evt) => {
if (dragStarted) { if (dragStarted) {
this._dragging = true; this._dragging = true;
endIndex = getIndex(evt); endIndex = getIndex(evt);
@@ -362,7 +356,7 @@ export class ElevationProfile {
startIndex = endIndex; startIndex = endIndex;
} else if (startIndex !== endIndex) { } else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([ this._slicedGPXStatistics.set([
get(this._gpxStatistics).sliced( get(this._gpxStatistics).slice(
Math.min(startIndex, endIndex), Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex) Math.max(startIndex, endIndex)
), ),
@@ -373,7 +367,7 @@ export class ElevationProfile {
} }
} }
}; };
const onMouseUp = (evt: PointerEvent) => { const onMouseUp = (evt) => {
dragStarted = false; dragStarted = false;
this._dragging = false; this._dragging = false;
this._canvas.style.cursor = ''; this._canvas.style.cursor = '';
@@ -392,99 +386,85 @@ export class ElevationProfile {
return; return;
} }
const data = get(this._gpxStatistics); const data = get(this._gpxStatistics);
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
temperature: get(temperatureUnits),
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => {
datasets[0].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
time: trkpt.time,
slope: slope,
extensions: trkpt.getExtensions(),
coordinates: trkpt.getCoordinates(),
index: index,
});
if (data.global.time.total > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
}
if (data.global.atemp.count > 0) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
}
});
this._chart.data.datasets[0] = { this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'), label: i18n._('quantities.elevation'),
data: datasets[0], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
normalized: true, normalized: true,
fill: 'start', fill: 'start',
order: 1, order: 1,
segment: {}, segment: {},
}; };
this._chart.data.datasets[1] = { this._chart.data.datasets[1] = {
data: datasets[1], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yspeed', yAxisID: 'yspeed',
}; };
this._chart.data.datasets[2] = { this._chart.data.datasets[2] = {
data: datasets[2], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yhr', yAxisID: 'yhr',
}; };
this._chart.data.datasets[3] = { this._chart.data.datasets[3] = {
data: datasets[3], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'ycad', yAxisID: 'ycad',
}; };
this._chart.data.datasets[4] = { this._chart.data.datasets[4] = {
data: datasets[4], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'yatemp', yAxisID: 'yatemp',
}; };
this._chart.data.datasets[5] = { this._chart.data.datasets[5] = {
data: datasets[5], data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index,
};
}),
normalized: true, normalized: true,
yAxisID: 'ypower', yAxisID: 'ypower',
}; };
this._chart.options.scales.x['min'] = 0;
this._chart.options.scales!.x!['min'] = 0; this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total,
units.distance
);
this.setVisibility(); this.setVisibility();
this.setFill(); this.setFill();
@@ -533,24 +513,21 @@ export class ElevationProfile {
return; return;
} }
const elevationFill = get(this._elevationFill); const elevationFill = get(this._elevationFill);
const dataset = this._chart.data.datasets[0];
let segment: any = {};
if (elevationFill === 'slope') { if (elevationFill === 'slope') {
segment = { this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.slopeFillCallback, backgroundColor: this.slopeFillCallback,
}; };
} else if (elevationFill === 'surface') { } else if (elevationFill === 'surface') {
segment = { this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.surfaceFillCallback, backgroundColor: this.surfaceFillCallback,
}; };
} else if (elevationFill === 'highway') { } else if (elevationFill === 'highway') {
segment = { this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.highwayFillCallback, backgroundColor: this.highwayFillCallback,
}; };
} else { } else {
segment = {}; this._chart.data.datasets[0]['segment'] = {};
} }
Object.assign(dataset, { segment });
} }
updateOverlay() { updateOverlay() {
@@ -577,12 +554,10 @@ export class ElevationProfile {
const gpxStatistics = get(this._gpxStatistics); const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue( let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance( getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
); );
let endPixel = this._chart.scales.x.getPixelForValue( let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0) getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
); );
selectionContext.fillRect( selectionContext.fillRect(
@@ -600,22 +575,19 @@ export class ElevationProfile {
} }
} }
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { slopeFillCallback(context) {
const point = context.p0.raw as ElevationProfilePoint; return getSlopeColor(context.p0.raw.slope.segment);
return getSlopeColor(point.slope.segment);
} }
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { surfaceFillCallback(context) {
const point = context.p0.raw as ElevationProfilePoint; return getSurfaceColor(context.p0.raw.extensions.surface);
return getSurfaceColor(point.extensions.surface);
} }
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { highwayFillCallback(context) {
const point = context.p0.raw as ElevationProfilePoint;
return getHighwayColor( return getHighwayColor(
point.extensions.highway, context.p0.raw.extensions.highway,
point.extensions.sac_scale, context.p0.raw.extensions.sac_scale,
point.extensions.mtb_scale context.p0.raw.extensions.mtb_scale
); );
} }
@@ -624,5 +596,8 @@ export class ElevationProfile {
this._chart.destroy(); this._chart.destroy();
this._chart = null; this._chart = null;
} }
if (this._marker) {
this._marker.remove();
}
} }
} }

View File

@@ -16,11 +16,10 @@
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions'; import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
let { let {
useHash = true, useHash = true,
@@ -33,7 +32,6 @@
const { const {
currentBasemap, currentBasemap,
selectedBasemapTree,
distanceUnits, distanceUnits,
velocityUnits, velocityUnits,
temperatureUnits, temperatureUnits,
@@ -68,9 +66,6 @@
if (allowedEmbeddingBasemaps.includes(options.basemap)) { if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap; $currentBasemap = options.basemap;
} }
if (!isSelected($selectedBasemapTree, options.basemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
}
$distanceMarkers = options.distanceMarkers; $distanceMarkers = options.distanceMarkers;
$directionMarkers = options.directionMarkers; $directionMarkers = options.directionMarkers;
$distanceUnits = options.distanceUnits; $distanceUnits = options.distanceUnits;
@@ -102,7 +97,7 @@
<div class="grow relative"> <div class="grow relative">
<Map <Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}" class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key} accessToken={options.token}
geocoder={false} geocoder={false}
geolocate={true} geolocate={true}
hash={useHash} hash={useHash}
@@ -130,7 +125,6 @@
<ElevationProfile <ElevationProfile
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
{hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
showControls={options.elevation.controls} showControls={options.elevation.controls}

View File

@@ -22,7 +22,7 @@
getCleanedEmbeddingOptions, getCleanedEmbeddingOptions,
getMergedEmbeddingOptions, getMergedEmbeddingOptions,
} from './embedding'; } from './embedding';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public'; import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte'; import Embedding from './Embedding.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { base } from '$app/paths'; import { base } from '$app/paths';
@@ -32,7 +32,7 @@
let options = $state( let options = $state(
getMergedEmbeddingOptions( getMergedEmbeddingOptions(
{ {
key: 'YOUR_MAPTILER_KEY', token: 'YOUR_MAPBOX_TOKEN',
theme: mode.current, theme: mode.current,
}, },
defaultEmbeddingOptions defaultEmbeddingOptions
@@ -46,10 +46,10 @@
let iframeOptions = $derived( let iframeOptions = $derived(
getMergedEmbeddingOptions( getMergedEmbeddingOptions(
{ {
key: token:
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY' options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? PUBLIC_MAPTILER_KEY ? PUBLIC_MAPBOX_TOKEN
: options.key, : options.token,
files: files.split(',').filter((url) => url.length > 0), files: files.split(',').filter((url) => url.length > 0),
ids: driveIds.split(',').filter((id) => id.length > 0), ids: driveIds.split(',').filter((id) => id.length > 0),
elevation: { elevation: {
@@ -102,8 +102,8 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<fieldset class="flex flex-col gap-3"> <fieldset class="flex flex-col gap-3">
<Label for="key">{i18n._('embedding.maptiler_key')}</Label> <Label for="token">{i18n._('embedding.mapbox_token')}</Label>
<Input id="key" type="text" class="h-8" bind:value={options.key} /> <Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label> <Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} /> <Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label> <Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>

View File

@@ -1,8 +1,8 @@
import { PUBLIC_MAPTILER_KEY } from '$env/static/public'; import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { basemaps } from '$lib/assets/layers'; import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = { export type EmbeddingOptions = {
key: string; token: string;
files: string[]; files: string[];
ids: string[]; ids: string[];
basemap: string; basemap: string;
@@ -26,10 +26,10 @@ export type EmbeddingOptions = {
}; };
export const defaultEmbeddingOptions = { export const defaultEmbeddingOptions = {
key: '', token: '',
files: [], files: [],
ids: [], ids: [],
basemap: 'maptilerTopo', basemap: 'mapboxOutdoors',
elevation: { elevation: {
show: true, show: true,
height: 170, height: 170,
@@ -107,7 +107,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
export function convertOldEmbeddingOptions(options: URLSearchParams): any { export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = { let newOptions: any = {
key: PUBLIC_MAPTILER_KEY, token: PUBLIC_MAPBOX_TOKEN,
files: [], files: [],
ids: [], ids: [],
}; };
@@ -123,7 +123,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
if (options.has('source')) { if (options.has('source')) {
let basemap = options.get('source')!; let basemap = options.get('source')!;
if (basemap === 'satellite') { if (basemap === 'satellite') {
newOptions.basemap = 'maptilerSatellite'; newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') { } else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap'; newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') { } else if (basemap === 'ohm') {

View File

@@ -21,7 +21,7 @@
SquareActivity, SquareActivity,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { GPXGlobalStatistics } from 'gpx'; import { GPXStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list'; import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
@@ -48,24 +48,24 @@
extensions: false, extensions: false,
}; };
} else { } else {
let statistics = $gpxStatistics.global; let statistics = $gpxStatistics;
if (exportState.current === ExportState.ALL) { if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values()) statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics) .map((file) => file.statistics)
.reduce((acc, cur) => { .reduce((acc, cur) => {
if (cur !== undefined) { if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global); acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
} }
return acc; return acc;
}, new GPXGlobalStatistics()); }, new GPXStatistics());
} }
return { return {
time: statistics.time.total === 0, time: statistics.global.time.total === 0,
hr: statistics.hr.count === 0, hr: statistics.global.hr.count === 0,
cad: statistics.cad.count === 0, cad: statistics.global.cad.count === 0,
atemp: statistics.atemp.count === 0, atemp: statistics.global.atemp.count === 0,
power: statistics.power.count === 0, power: statistics.global.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0, extensions: Object.keys(statistics.global.extensions).length === 0,
}; };
} }
}); });

View File

@@ -121,16 +121,20 @@
} }
.vertical :global(button) { .vertical :global(button) {
@apply hover:bg-[var(--selection)]; @apply hover:bg-muted;
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
} }
.vertical :global(.sortable-selected) { .vertical :global(.sortable-selected) {
@apply bg-[var(--selection)]; @apply bg-accent;
} }
.horizontal :global(button) { .horizontal :global(button) {
@apply bg-[var(--selection)]; @apply bg-accent;
@apply hover:bg-background; @apply hover:bg-muted;
} }
.horizontal :global(.sortable-selected button) { .horizontal :global(.sortable-selected button) {

View File

@@ -34,10 +34,11 @@
import { editStyle } from '$lib/components/file-list/style/utils.svelte'; import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection'; import { selection, copied, cut } from '$lib/logic/selection';
import { map } from '$lib/components/map/map';
import { fileActions, pasteSelection } from '$lib/logic/file-actions'; import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list'; import { allowedPastes } from './sortable-file-list';
@@ -57,31 +58,41 @@
let singleSelection = $derived($selection.size === 1); let singleSelection = $derived($selection.size === 1);
let nodeColors: string[] = $derived.by(() => { let nodeColors: string[] = $state([]);
$effect.pre(() => {
let colors: string[] = []; let colors: string[] = [];
if (node) { if (node && $map) {
if (node instanceof GPXFile) { if (node instanceof GPXFile) {
let defaultColor = $gpxColors.get(item.getFileId()); let defaultColor = undefined;
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor); let style = node.getStyle(defaultColor);
colors = style.color; style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
} else if (node instanceof Track) { } else if (node instanceof Track) {
let style = node.getStyle(); let style = node.getStyle();
if ( if (style) {
style && if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
style['gpx_style:color'] && colors.push(style['gpx_style:color']);
!colors.includes(style['gpx_style:color']) }
) {
colors.push(style['gpx_style:color']);
} }
if (colors.length === 0) { if (colors.length === 0) {
let defaultColor = $gpxColors.get(item.getFileId()); let layer = gpxLayers.getLayer(item.getFileId());
if (defaultColor) { if (layer) {
colors.push(defaultColor); colors.push(layer.layerColor);
} }
} }
} }
} }
return colors; nodeColors = colors;
}); });
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
@@ -164,7 +175,7 @@
let file = fileStateCollection.getFile(item.getFileId()); let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) { if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()]; let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint && !waypoint._data.hidden) { if (waypoint) {
waypointPopup?.setItem({ waypointPopup?.setItem({
item: waypoint, item: waypoint,
fileId: item.getFileId(), fileId: item.getFileId(),

View File

@@ -5,16 +5,6 @@
map.onLoad((map_) => { map.onLoad((map_) => {
map_.on('contextmenu', (e) => { map_.on('contextmenu', (e) => {
if (
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({ trackpointPopup?.setItem({
item: new TrackPoint({ item: new TrackPoint({
attributes: { attributes: {

View File

@@ -1,25 +1,30 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/state'; import { page } from '$app/state';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
let { let {
maptilerKey = PUBLIC_MAPTILER_KEY, accessToken = PUBLIC_MAPBOX_TOKEN,
geolocate = true, geolocate = true,
geocoder = true, geocoder = true,
hash = true, hash = true,
class: className = '', class: className = '',
}: { }: {
maptilerKey?: string; accessToken?: string;
geolocate?: boolean; geolocate?: boolean;
geocoder?: boolean; geocoder?: boolean;
hash?: boolean; hash?: boolean;
class?: string; class?: string;
} = $props(); } = $props();
mapboxgl.accessToken = accessToken;
let webgl2Supported = $state(true); let webgl2Supported = $state(true);
let embeddedApp = $state(false); let embeddedApp = $state(false);
@@ -43,7 +48,7 @@
language = 'en'; language = 'en';
} }
map.init(maptilerKey, language, hash, geocoder, geolocate); map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
}); });
onDestroy(() => { onDestroy(() => {
@@ -76,21 +81,21 @@
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css"; @reference "../../../app.css";
div :global(.maplibregl-map) { div :global(.mapboxgl-map) {
@apply font-sans; @apply font-sans;
} }
div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) { div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md; @apply shadow-md;
@apply bg-background; @apply bg-background;
@apply text-foreground; @apply text-foreground;
} }
div :global(.maplibregl-ctrl-icon) { div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7]; @apply dark:brightness-[4.7];
} }
div :global(.maplibregl-ctrl-geocoder) { div :global(.mapboxgl-ctrl-geocoder) {
@apply flex; @apply flex;
@apply flex-row; @apply flex-row;
@apply w-fit; @apply w-fit;
@@ -105,45 +110,36 @@
@apply text-foreground; @apply text-foreground;
} }
div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) { div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground; @apply text-foreground;
@apply hover:text-accent-foreground; @apply hover:text-accent-foreground;
@apply hover:bg-accent; @apply hover:bg-accent;
} }
div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) { div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background; @apply bg-background;
} }
div :global(.maplibregl-ctrl-geocoder--button) { div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent; @apply bg-transparent;
@apply hover:bg-transparent; @apply hover:bg-transparent;
} }
div :global(.maplibregl-ctrl-geocoder--icon) { div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground; @apply fill-foreground;
@apply hover:fill-accent-foreground; @apply hover:fill-accent-foreground;
} }
div :global(.maplibregl-ctrl-geocoder--icon-search) { div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative; @apply relative;
@apply top-0; @apply top-0;
@apply left-0; @apply left-0;
@apply my-2;
@apply w-[29px]; @apply w-[29px];
} }
div :global(.maplibregl-ctrl-geocoder--icon-loading) { div :global(.mapboxgl-ctrl-geocoder--input) {
@apply -mt-1;
@apply mb-0;
}
div :global(.maplibregl-ctrl-geocoder--icon-close) {
@apply my-0;
}
div :global(.maplibregl-ctrl-geocoder--input) {
@apply relative; @apply relative;
@apply h-8;
@apply w-64; @apply w-64;
@apply py-0; @apply py-0;
@apply pl-2; @apply pl-2;
@@ -153,12 +149,12 @@
@apply text-foreground; @apply text-foreground;
} }
div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) { div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0; @apply w-0;
@apply p-0; @apply p-0;
} }
div :global(.maplibregl-ctrl-top-right) { div :global(.mapboxgl-ctrl-top-right) {
@apply z-40; @apply z-40;
@apply flex; @apply flex;
@apply flex-col; @apply flex-col;
@@ -167,76 +163,77 @@
@apply overflow-hidden; @apply overflow-hidden;
} }
.horizontal :global(.maplibregl-ctrl-bottom-left) { .horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px]; @apply bottom-[42px];
} }
.horizontal :global(.maplibregl-ctrl-bottom-right) { .horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px]; @apply bottom-[42px];
} }
div :global(.maplibregl-ctrl-attrib) { div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent; @apply dark:bg-transparent;
} }
div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) { div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background; @apply dark:bg-background;
} }
div :global(.maplibregl-ctrl-attrib-button) { div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground; @apply dark:bg-foreground;
} }
div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) { div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground; @apply dark:bg-foreground;
} }
div :global(.maplibregl-ctrl-attrib a) { div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground; @apply text-foreground;
} }
div :global(.maplibregl-popup) { div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50; @apply z-50;
} }
div :global(.maplibregl-popup-content) { div :global(.mapboxgl-popup-content) {
@apply p-0; @apply p-0;
@apply bg-transparent; @apply bg-transparent;
@apply shadow-none; @apply shadow-none;
} }
div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background; @apply border-b-background;
} }
div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background; @apply border-t-background;
@apply drop-shadow-md; @apply drop-shadow-md;
} }
div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background; @apply border-r-background;
} }
div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) { div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background; @apply border-l-background;
} }
</style> </style>

View File

@@ -17,7 +17,7 @@
let control: CustomControl | null = null; let control: CustomControl | null = null;
onMount(() => { onMount(() => {
map.onLoad((map: maplibregl.Map) => { map.onLoad((map: mapboxgl.Map) => {
if (position.includes('right')) container.classList.add('float-right'); if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left'); else container.classList.add('float-left');
container.classList.remove('hidden'); container.classList.remove('hidden');

View File

@@ -1,4 +1,4 @@
import { type Map, type IControl } from 'maplibre-gl'; import { type Map, type IControl } from 'mapbox-gl';
export default class CustomControl implements IControl { export default class CustomControl implements IControl {
_map: Map | undefined; _map: Map | undefined;

View File

@@ -16,8 +16,7 @@
</script> </script>
<Button <Button
size="sm" class="p-1 has-[>svg]:px-2 h-8 justify-start {className}"
class="justify-start {className}"
variant="outline" variant="outline"
onclick={() => { onclick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers'; import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers'; import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
@@ -9,10 +9,13 @@
let distanceMarkers: DistanceMarkers; let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers; let startEndMarkers: StartEndMarkers;
map.onLoad((map_) => { onMount(() => {
gpxLayers.init(); gpxLayers.init();
startEndMarkers = new StartEndMarkers(); startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers(); distanceMarkers = new DistanceMarkers();
});
map.onLoad((map_) => {
createPopups(map_); createPopups(map_);
}); });

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { TrackPoint } from 'gpx'; import type { TrackPoint } from 'gpx';
import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte'; import { Compass, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { map } from '$lib/components/map/map';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props(); let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
</script> </script>
@@ -37,17 +35,5 @@
onCopy={() => trackpoint.hide?.()} onCopy={() => trackpoint.hide?.()}
class="mt-0.5" class="mt-0.5"
/> />
{#if trackpoint.fileId === undefined}
<Button
size="sm"
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
{i18n._('menu.edit_osm')}
</Button>
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -13,8 +13,6 @@
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { selection } from '$lib/logic/selection';
import { ListFileItem } from '$lib/components/file-list/file-list';
let { let {
waypoint, waypoint,
@@ -22,9 +20,6 @@
waypoint: PopupItem<Waypoint>; waypoint: PopupItem<Waypoint>;
} = $props(); } = $props();
let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
);
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined); let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
function sanitize(text: string | undefined): string { function sanitize(text: string | undefined): string {
@@ -86,7 +81,7 @@
</ScrollArea> </ScrollArea>
<div class="mt-2 flex flex-col gap-1"> <div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} /> <CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT && selected} {#if $currentTool === Tool.WAYPOINT}
<Button <Button
class="p-1 has-[>svg]:px-2 h-8" class="p-1 has-[>svg]:px-2 h-8"
variant="outline" variant="outline"

View File

@@ -1,15 +1,21 @@
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units'; import { getConvertedDistanceToKilometers } from '$lib/units';
import type { GeoJSONSource } from 'mapbox-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '../style';
const { distanceMarkers, distanceUnits } = settings; const { distanceMarkers, distanceUnits } = settings;
const levels = [100, 50, 25, 10, 5, 1]; const stops = [
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
export class DistanceMarkers { export class DistanceMarkers {
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
@@ -23,7 +29,7 @@ export class DistanceMarkers {
this.unsubscribes.push( this.unsubscribes.push(
map.subscribe((map_) => { map.subscribe((map_) => {
if (map_) { if (map_) {
map_.on('style.load', this.updateBinded); map_.on('style.import.load', this.updateBinded);
} }
}) })
); );
@@ -44,33 +50,22 @@ export class DistanceMarkers {
data: this.getDistanceMarkersGeoJSON(), data: this.getDistanceMarkersGeoJSON(),
}); });
} }
if (!map_.getLayer('distance-markers')) { stops.forEach(([d, minzoom, maxzoom]) => {
map_.addLayer( if (!map_.getLayer(`distance-markers-${d}`)) {
{ map_.addLayer({
id: 'distance-markers', id: `distance-markers-${d}`,
type: 'symbol', type: 'symbol',
source: 'distance-markers', source: 'distance-markers',
filter: [ filter:
'match', d === 5
['get', 'level'], ? [
100, 'any',
['>=', ['zoom'], 0], ['==', ['get', 'level'], 5],
50, ['==', ['get', 'level'], 25],
['>=', ['zoom'], 7], ]
25, : ['==', ['get', 'level'], d],
[ minzoom: minzoom,
'any', maxzoom: maxzoom ?? 24,
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
['>=', ['zoom'], 11],
],
10,
['>=', ['zoom'], 10],
5,
['>=', ['zoom'], 11],
1,
['>=', ['zoom'], 13],
false,
],
layout: { layout: {
'text-field': ['get', 'distance'], 'text-field': ['get', 'distance'],
'text-size': 14, 'text-size': 14,
@@ -81,14 +76,17 @@ export class DistanceMarkers {
'text-halo-width': 2, 'text-halo-width': 2,
'text-halo-color': 'white', 'text-halo-color': 'white',
}, },
}, });
ANCHOR_LAYER_KEY.distanceMarkers } else {
); map_.moveLayer(`distance-markers-${d}`);
} }
});
} else { } else {
if (map_.getLayer('distance-markers')) { stops.forEach(([d]) => {
map_.removeLayer('distance-markers'); if (map_.getLayer(`distance-markers-${d}`)) {
} map_.removeLayer(`distance-markers-${d}`);
}
});
} }
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to add sources and layers // No reliable way to check if the map is ready to add sources and layers
@@ -103,26 +101,35 @@ export class DistanceMarkers {
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics); let statistics = get(gpxStatistics);
let features: GeoJSON.Feature[] = []; let features = [];
let currentTargetDistance = 1; let currentTargetDistance = 1;
statistics.forEachTrackPoint((trkpt, dist) => { for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) { if (
statistics.local.distance.total[i] >=
getConvertedDistanceToKilometers(currentTargetDistance)
) {
let distance = currentTargetDistance.toFixed(0); let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1; let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
0, 0,
];
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()], coordinates: [
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
}, },
properties: { properties: {
distance, distance,
level, level,
minzoom,
}, },
} as GeoJSON.Feature); } as GeoJSON.Feature);
currentTargetDistance += 1; currentTargetDistance += 1;
} }
}); }
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',

View File

@@ -3,14 +3,13 @@ import { MapPopup } from '$lib/components/map/map-popup';
export let waypointPopup: MapPopup | null = null; export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null; export let trackpointPopup: MapPopup | null = null;
export function createPopups(map: maplibregl.Map) { export function createPopups(map: mapboxgl.Map) {
removePopups(); removePopups();
waypointPopup = new MapPopup(map, { waypointPopup = new MapPopup(map, {
closeButton: false, closeButton: false,
focusAfterOpen: false, focusAfterOpen: false,
maxWidth: undefined, maxWidth: undefined,
offset: { offset: {
center: [0, 0],
top: [0, 0], top: [0, 0],
'top-left': [0, 0], 'top-left': [0, 0],
'top-right': [0, 0], 'top-right': [0, 0],

View File

@@ -1,10 +1,5 @@
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import maplibregl, { import mapboxgl from 'mapbox-gl';
type GeoJSONSource,
type FilterSpecification,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
} from 'maplibre-gl';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import { import {
@@ -15,7 +10,7 @@ import {
ListFileItem, ListFileItem,
ListRootItem, ListRootItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils'; import { getClosestLinePoint, getElevation } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint'; import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static'; import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
@@ -27,8 +22,6 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from './gpx-layers';
const colors = [ const colors = [
'#ff0000', '#ff0000',
@@ -50,49 +43,26 @@ for (let color of colors) {
} }
// Get the color with the least amount of uses // Get the color with the least amount of uses
function getColor(fileId: string) { function getColor() {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b)); let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++; colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color; return color;
} }
function replaceColor(fileId: string, oldColor: string, newColor: string) { function decrementColor(color: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
if (colorCount.hasOwnProperty(color)) { if (colorCount.hasOwnProperty(color)) {
colorCount[color]--; colorCount[color]--;
} }
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
} }
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) { function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined; let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${ ${Square.replace('width="24"', 'width="12"')
layerColor .replace('height="24"', 'height="12"')
? Square.replace('width="24"', 'width="12"') .replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('height="24"', 'height="12"') .replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"') .replace('fill="none"', `fill="${layerColor}"`)}
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)
: ''
}
${MapPin.replace('width="24"', '') ${MapPin.replace('width="24"', '')
.replace('height="24"', '') .replace('height="24"', '')
.replace('stroke="currentColor"', '') .replace('stroke="currentColor"', '')
@@ -117,41 +87,26 @@ export class GPXLayer {
fileId: string; fileId: string;
file: Readable<GPXFileWithStatistics | undefined>; file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string; layerColor: string;
markers: mapboxgl.Marker[] = [];
selected: boolean = false; selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null; draggable: boolean;
draggedWaypointIndex: number | null = null;
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
unsubscribe: Function[] = []; unsubscribe: Function[] = [];
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this); layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this); layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this); layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseEnter.bind(this);
waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseLeave.bind(this);
waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnClick.bind(this);
waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
this.waypointLayerOnMouseDown.bind(this);
waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
this.waypointLayerOnTouchStart.bind(this);
waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseMove.bind(this);
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) { constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
this.fileId = fileId; this.fileId = fileId;
this.file = file; this.file = file;
this.layerColor = getColor(fileId); this.layerColor = getColor();
this.unsubscribe.push( this.unsubscribe.push(
map.subscribe(($map) => { map.subscribe(($map) => {
if ($map) { if ($map) {
$map.on('style.load', this.updateBinded); $map.on('style.import.load', this.updateBinded);
this.update(); this.update();
} }
}) })
@@ -170,13 +125,24 @@ export class GPXLayer {
}) })
); );
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded)); this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
this.unsubscribe.push(
currentTool.subscribe((tool) => {
if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true;
this.markers.forEach((marker) => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false;
this.markers.forEach((marker) => marker.setDraggable(false));
}
})
);
this.draggable = get(currentTool) === Tool.WAYPOINT;
} }
update() { update() {
const _map = get(map); const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file; let file = get(this.file)?.file;
if (!_map || !layerEventManager || !file) { if (!_map || !file) {
return; return;
} }
@@ -185,14 +151,12 @@ export class GPXLayer {
file._data.style.color && file._data.style.color &&
this.layerColor !== `#${file._data.style.color}` this.layerColor !== `#${file._data.style.color}`
) { ) {
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`); decrementColor(this.layerColor);
this.layerColor = `#${file._data.style.color}`; this.layerColor = `#${file._data.style.color}`;
} }
this.loadIcons();
try { try {
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined; let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
if (source) { if (source) {
source.setData(this.getGeoJSON()); source.setData(this.getGeoJSON());
} else { } else {
@@ -203,45 +167,28 @@ export class GPXLayer {
} }
if (!_map.getLayer(this.fileId)) { if (!_map.getLayer(this.fileId)) {
_map.addLayer( _map.addLayer({
{ id: this.fileId,
id: this.fileId, type: 'line',
type: 'line', source: this.fileId,
source: this.fileId, layout: {
layout: { 'line-join': 'round',
'line-join': 'round', 'line-cap': 'round',
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
}, },
ANCHOR_LAYER_KEY.tracks paint: {
); 'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity'],
},
});
layerEventManager.on('click', this.fileId, this.layerOnClickBinded); _map.on('click', this.fileId, this.layerOnClickBinded);
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded); _map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded); _map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); _map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded); _map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
} }
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (get(directionMarkers)) { if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) { if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer( _map.addLayer(
@@ -266,136 +213,172 @@ export class GPXLayer {
'text-halo-color': 'white', 'text-halo-color': 'white',
}, },
}, },
ANCHOR_LAYER_KEY.directionMarkers _map.getLayer('distance-markers-100') ? 'distance-markers-100' : undefined
); );
} }
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
} else { } else {
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction'); _map.removeLayer(this.fileId + '-direction');
} }
} }
let waypointSource = _map.getSource(this.fileId + '-waypoints') as let visibleItems: [number, number][] = [];
| GeoJSONSource file.forEachSegment((segment, trackIndex, segmentIndex) => {
| undefined; if (!segment._data.hidden) {
this.currentWaypointData = this.getWaypointsGeoJSON(); visibleItems.push([trackIndex, segmentIndex]);
if (waypointSource) {
waypointSource.setData(this.currentWaypointData);
} else {
_map.addSource(this.fileId + '-waypoints', {
type: 'geojson',
data: this.currentWaypointData,
promoteId: 'waypointIndex',
});
}
if (!_map.getLayer(this.fileId + '-waypoints')) {
_map.addLayer(
{
id: this.fileId + '-waypoints',
type: 'symbol',
source: this.fileId + '-waypoints',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.3,
'icon-anchor': 'bottom',
'icon-padding': 0,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.waypoints
);
layerEventManager.on(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.on(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.on(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.on(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.on(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
visibleWaypoints.push(waypointIndex);
} }
}); });
_map.setFilter( _map.setFilter(
this.fileId + '-waypoints', this.fileId,
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]], [
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false } { validate: false }
); );
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(
this.fileId + '-direction',
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
}
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to add sources and layers // No reliable way to check if the map is ready to add sources and layers
return; return;
} }
let markerIndex = 0;
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
file.wpt.forEach((waypoint) => {
// Update markers
let symbolKey = getSymbolKey(waypoint.sym);
if (markerIndex < this.markers.length) {
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
symbolKey,
this.layerColor
);
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
value: waypoint,
writable: true,
});
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
let marker = new mapboxgl.Marker({
draggable: this.draggable,
element,
anchor: 'bottom',
}).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0;
marker.getElement().addEventListener('mousemove', (e) => {
if (marker._isDragging) {
return;
}
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
e.stopPropagation();
});
marker.getElement().addEventListener('click', (e) => {
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
return;
}
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
fileActions.deleteWaypoint(this.fileId, marker._waypoint._data.index);
e.stopPropagation();
return;
}
if (get(treeFileView)) {
if (
(e.ctrlKey || e.metaKey) &&
get(selection).hasAnyChildren(
new ListWaypointsItem(this.fileId),
false
)
) {
selection.addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else {
selection.selectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
}
} else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]);
} else {
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
}
e.stopPropagation();
});
marker.on('dragstart', () => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
fileActionManager.applyToFile(this.fileId, (file) => {
let latLng = marker.getLngLat();
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng,
});
wpt.ele = ele[0];
});
});
dragEndTimestamp = Date.now();
});
this.markers.push(marker);
}
markerIndex++;
});
}
while (markerIndex < this.markers.length) {
// Remove extra markers
this.markers.pop()?.remove();
}
this.markers.forEach((marker) => {
if (!marker._waypoint._data.hidden) {
marker.addTo(_map);
} else {
marker.remove();
}
});
} }
remove() { remove() {
const _map = get(map); const _map = get(map);
if (_map) { if (_map) {
_map.off('style.load', this.updateBinded); _map.off('click', this.fileId, this.layerOnClickBinded);
} _map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
_map.off('style.import.load', this.updateBinded);
const layerEventManager = map.layerEventManager;
if (layerEventManager) {
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
layerEventManager.off(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
layerEventManager.off(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
layerEventManager.off(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.off(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.off(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
if (_map) {
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction'); _map.removeLayer(this.fileId + '-direction');
} }
@@ -405,17 +388,15 @@ export class GPXLayer {
if (_map.getSource(this.fileId)) { if (_map.getSource(this.fileId)) {
_map.removeSource(this.fileId); _map.removeSource(this.fileId);
} }
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.removeLayer(this.fileId + '-waypoints');
}
if (_map.getSource(this.fileId + '-waypoints')) {
_map.removeSource(this.fileId + '-waypoints');
}
} }
this.markers.forEach((marker) => {
marker.remove();
});
this.unsubscribe.forEach((unsubscribe) => unsubscribe()); this.unsubscribe.forEach((unsubscribe) => unsubscribe());
removeColor(this.fileId, this.layerColor); decrementColor(this.layerColor);
} }
moveToFront() { moveToFront() {
@@ -424,13 +405,10 @@ export class GPXLayer {
return; return;
} }
if (_map.getLayer(this.fileId)) { if (_map.getLayer(this.fileId)) {
_map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks); _map.moveLayer(this.fileId);
}
if (_map.getLayer(this.fileId + '-waypoints')) {
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
} }
if (_map.getLayer(this.fileId + '-direction')) { if (_map.getLayer(this.fileId + '-direction')) {
_map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers); _map.moveLayer(this.fileId + '-direction');
} }
} }
@@ -471,7 +449,7 @@ export class GPXLayer {
} }
} }
layerOnClick(e: MapLayerMouseEvent) { layerOnClick(e: any) {
if ( if (
get(currentTool) === Tool.ROUTING && get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
@@ -479,8 +457,8 @@ export class GPXLayer {
return; return;
} }
let trackIndex = e.features![0].properties!.trackIndex; let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features![0].properties!.segmentIndex; let segmentIndex = e.features[0].properties.segmentIndex;
if ( if (
get(currentTool) === Tool.SCISSORS && get(currentTool) === Tool.SCISSORS &&
@@ -488,11 +466,6 @@ export class GPXLayer {
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) )
) { ) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring
return;
}
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, { fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat, lat: e.lngLat.lat,
lon: e.lngLat.lng, lon: e.lngLat.lng,
@@ -529,179 +502,6 @@ export class GPXLayer {
} }
} }
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
if (this.draggedWaypointIndex !== null) {
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex;
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
}
waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) {
if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex);
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListFileItem(this.fileId));
}
selectedWaypoint.set([waypoint, this.fileId]);
}
} else {
if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
}
} else {
if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId));
}
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
}
}
}
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
e.preventDefault();
_map.dragPan.disable();
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
return;
}
const _map = get(map);
if (!_map) {
return;
}
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
waypointPopup?.hide();
e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
}
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
return;
}
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
(
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
if (waypointSource) {
waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
}
}
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
const _map = get(map);
if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
if (this.draggedWaypointIndex === null) {
return;
}
if (e.point.equals(this.draggingStartingPosition)) {
this.draggedWaypointIndex = null;
return;
}
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
]).then((ele) => {
if (this.draggedWaypointIndex === null) {
return;
}
fileActionManager.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[this.draggedWaypointIndex!];
wpt.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
wpt.ele = ele[0];
});
this.draggedWaypointIndex = null;
});
}
getGeoJSON(): GeoJSON.FeatureCollection { getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file; let file = get(this.file)?.file;
if (!file) { if (!file) {
@@ -739,7 +539,6 @@ export class GPXLayer {
} }
feature.properties.trackIndex = trackIndex; feature.properties.trackIndex = trackIndex;
feature.properties.segmentIndex = segmentIndex; feature.properties.segmentIndex = segmentIndex;
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
segmentIndex++; segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) { if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
@@ -749,52 +548,4 @@ export class GPXLayer {
} }
return data; return data;
} }
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
properties: {
fileId: this.fileId,
waypointIndex: index,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
},
});
});
return data;
}
loadIcons() {
const _map = get(map);
let file = get(this.file)?.file;
if (!_map || !file) {
return;
}
let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => {
symbols.add(getSymbolKey(waypoint.sym));
});
symbols.forEach((symbol) => {
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
});
}
} }

View File

@@ -1,5 +1,4 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state'; import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer'; import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection { export class GPXLayerCollection {
@@ -43,4 +42,3 @@ export class GPXLayerCollection {
} }
export const gpxLayers = new GPXLayerCollection(); export const gpxLayers = new GPXLayerCollection();
export const gpxColors = writable(new Map<string, string>());

View File

@@ -1,40 +1,30 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl'; import mapboxgl from 'mapbox-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
</svg>`;
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
</pattern>
</defs>
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
</svg>`;
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
export class StartEndMarkers { export class StartEndMarkers {
start: mapboxgl.Marker;
end: mapboxgl.Marker;
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = []; unsubscribes: (() => void)[] = [];
constructor() { constructor() {
let startElement = document.createElement('div');
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
map.onLoad(() => this.update()); map.onLoad(() => this.update());
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded)); this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded)); this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
} }
@@ -43,115 +33,26 @@ export class StartEndMarkers {
const map_ = get(map); const map_ = get(map);
if (!map_) return; if (!map_) return;
this.loadIcons();
const tool = get(currentTool); const tool = get(currentTool);
const statistics = get(gpxStatistics); const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden); const hidden = get(allHidden);
if (!hidden) { if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
const data: GeoJSON.FeatureCollection = { this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
type: 'FeatureCollection', this.end
features: [], .setLngLat(
}; statistics.local.points[statistics.local.points.length - 1].getCoordinates()
)
if (statistics.global.length > 0 && tool !== Tool.ROUTING) { .addTo(map_);
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
},
properties: {
icon: 'start-marker',
},
});
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
},
properties: {
icon: 'end-marker',
},
});
}
if (hovered) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
},
properties: {
icon: 'hover-marker',
},
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
map_.addSource('start-end-markers', {
type: 'geojson',
data: data,
});
}
if (!map_.getLayer('start-end-markers')) {
map_.addLayer(
{
id: 'start-end-markers',
type: 'symbol',
source: 'start-end-markers',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.2,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.startEndMarkers
);
}
} else { } else {
if (map_.getLayer('start-end-markers')) { this.start.remove();
map_.removeLayer('start-end-markers'); this.end.remove();
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
} }
} }
remove() { remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
const map_ = get(map); this.start.remove();
if (!map_) return; this.end.remove();
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
}
loadIcons() {
const map_ = get(map);
if (!map_) return;
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
} }
} }

View File

@@ -20,8 +20,9 @@
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers'; import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { remove } from './utils'; import { customBasemapUpdate, isSelected, remove } from './utils';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { dndzone } from 'svelte-dnd-action'; import { dndzone } from 'svelte-dnd-action';
const { const {
@@ -41,8 +42,13 @@
let maxZoom: number = $state(20); let maxZoom: number = $state(20);
let layerType: 'basemap' | 'overlay' = $state('basemap'); let layerType: 'basemap' | 'overlay' = $state('basemap');
let resourceType: 'raster' | 'vector' = $derived.by(() => { let resourceType: 'raster' | 'vector' = $derived.by(() => {
if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) { if (tileUrls[0].length > 0) {
return 'vector'; if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
return 'vector';
}
} }
return 'raster'; return 'raster';
}); });
@@ -128,8 +134,8 @@
], ],
}; };
} }
addLayer(layerId);
$customLayers[layerId] = layer; $customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined; selectedLayerId = undefined;
setDataFromSelectedLayer(); setDataFromSelectedLayer();
} }
@@ -152,7 +158,9 @@
return $tree; return $tree;
}); });
if ($currentBasemap !== layerId) { if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId; $currentBasemap = layerId;
} }
@@ -168,6 +176,14 @@
return $tree; return $tree;
}); });
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update(($overlays) => { currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) { if (!$overlays.overlays.hasOwnProperty('custom')) {
$overlays.overlays['custom'] = {}; $overlays.overlays['custom'] = {};

View File

@@ -5,8 +5,12 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from '@lucide/svelte'; import { Layers } from '@lucide/svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { customBasemapUpdate, getLayers } from './utils';
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
import { untrack } from 'svelte';
let container: HTMLDivElement; let container: HTMLDivElement;
let overpassLayer: OverpassLayer; let overpassLayer: OverpassLayer;
@@ -19,14 +23,127 @@
selectedBasemapTree, selectedBasemapTree,
selectedOverlayTree, selectedOverlayTree,
selectedOverpassTree, selectedOverpassTree,
customLayers,
opacities,
} = settings; } = settings;
map.onLoad((_map: maplibregl.Map) => { function setStyle() {
if (!$map) {
return;
}
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
url: '',
data: basemap as StyleSpecification,
},
'overlays'
);
}
}
$effect(() => {
if ($map && ($currentBasemap || $customBasemapUpdate)) {
untrack(() => setStyle());
}
});
function addOverlay(id: string) {
if (!$map) {
return;
}
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: (overlay as StyleSpecification).layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
}),
};
}
$map.addImport({
id,
url: '',
data: overlay as StyleSpecification,
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays =
$map
.getStyle()
.imports?.reduce(
(
acc: Record<string, ImportSpecification>,
imprt: ImportSpecification
) => {
if (
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
) {
acc[imprt.id] = imprt;
}
return acc;
},
{}
) || {};
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map?.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$effect(() => {
if ($map && $currentOverlays && $opacities) {
untrack(() => updateOverlays());
}
});
map.onLoad((_map: mapboxgl.Map) => {
if (overpassLayer) { if (overpassLayer) {
overpassLayer.remove(); overpassLayer.remove();
} }
overpassLayer = new OverpassLayer(_map, map.layerEventManager!); overpassLayer = new OverpassLayer(_map);
overpassLayer.add(); overpassLayer.add();
let first = true;
_map.on('style.import.load', () => {
if (!first) return;
first = false;
updateOverlays();
});
}); });
let open = $state(false); let open = $state(false);

View File

@@ -13,7 +13,6 @@
overlays, overlays,
overlayTree, overlayTree,
overpassTree, overpassTree,
terrainSources,
} from '$lib/assets/layers'; } from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
@@ -32,7 +31,6 @@
currentOverpassQueries, currentOverpassQueries,
customLayers, customLayers,
opacities, opacities,
terrainSource,
} = settings; } = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI; const { isLayerFromExtension, getLayerName } = extensionAPI;
@@ -56,7 +54,7 @@
} }
$effect(() => { $effect(() => {
if (open && $selectedBasemapTree && $currentBasemap) { if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) { if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) { if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap); $selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
@@ -67,7 +65,7 @@
}); });
$effect(() => { $effect(() => {
if (open && $selectedOverlayTree) { if ($selectedOverlayTree) {
untrack(() => { untrack(() => {
if ($currentOverlays) { if ($currentOverlays) {
let overlayLayers = getLayers($currentOverlays); let overlayLayers = getLayers($currentOverlays);
@@ -88,7 +86,7 @@
}); });
$effect(() => { $effect(() => {
if (open && $selectedOverpassTree) { if ($selectedOverpassTree) {
untrack(() => { untrack(() => {
if ($currentOverpassQueries) { if ($currentOverpassQueries) {
let overlayLayers = getLayers($currentOverpassQueries); let overlayLayers = getLayers($currentOverpassQueries);
@@ -162,16 +160,16 @@
type="single" type="single"
onValueChange={setOpacityFromSelection} onValueChange={setOpacityFromSelection}
> >
<Select.Trigger class="mr-1 w-full" size="sm"> <Select.Trigger class="h-8 mr-1 w-full">
{#if selectedOverlay} {#if selectedOverlay}
{#if isSelected($selectedOverlayTree, selectedOverlay)} {#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)} {#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)} {$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else} {:else}
{i18n._(`layers.label.${selectedOverlay}`)} {i18n._(`layers.label.${selectedOverlay}`)}
{/if} {/if}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if} {/if}
{/if} {/if}
</Select.Trigger> </Select.Trigger>
@@ -213,9 +211,7 @@
isSelected($currentOverlays, selectedOverlay) isSelected($currentOverlays, selectedOverlay)
) { ) {
try { try {
if ($map.getLayer(selectedOverlay)) { $map.removeImport(selectedOverlay);
$map.removeLayer(selectedOverlay);
}
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to remove sources and layers // No reliable way to check if the map is ready to remove sources and layers
} }
@@ -237,23 +233,6 @@
</ScrollArea> </ScrollArea>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="terrain-source">
<Accordion.Trigger>{i18n._('layers.terrain')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<Select.Root bind:value={$terrainSource} type="single">
<Select.Trigger class="mr-1 w-full" size="sm">
{i18n._(`layers.label.${$terrainSource}`)}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(terrainSources) as id}
<Select.Item value={id}>
{i18n._(`layers.label.${id}`)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root> </Accordion.Root>
</ScrollArea> </ScrollArea>
</Sheet.Header> </Sheet.Header>

View File

@@ -85,7 +85,7 @@
{:else if anySelectedLayer(node[id])} {:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
{#snippet trigger()} {#snippet trigger()}
<span>{i18n._(`layers.label.${id}`, id)}</span> <span>{i18n._(`layers.label.${id}`)}</span>
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-2"> <div class="ml-2">

View File

@@ -54,27 +54,28 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0"> <Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0"> <Card.Header class="p-0 gap-0">
<Card.Title class="text-md flex flex-row"> <Card.Title class="text-md">
<div class="flex flex-col"> <div class="flex flex-row gap-3">
<p>{name}</p> <div class="flex flex-col">
<div class="text-muted-foreground text-xs font-normal"> {name}
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg; <div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</div> </div>
<Button
class="ml-auto"
variant="outline"
size="icon"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
'node'}={poi.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</div> </div>
<Button
class="ml-auto"
variant="outline"
size="icon-sm"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? 'node'}={poi
.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="flex flex-col gap-1 p-0 text-sm whitespace-normal break-all"> <Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<ScrollArea class="flex flex-col max-h-[30dvh]"> <ScrollArea class="flex flex-col max-h-[30dvh]">
{#if tags.image || tags['image:0']} {#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto"> <div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
@@ -99,14 +100,8 @@
{/each} {/each}
</div> </div>
</ScrollArea> </ScrollArea>
<Button <Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
size="sm" <MapPin size="16" />
class="mt-1 justify-start"
variant="outline"
disabled={$selection.size === 0}
onclick={addToFile}
>
<MapPin size="14" />
{i18n._('toolbar.waypoint.add')} {i18n._('toolbar.waypoint.add')}
</Button> </Button>
</Card.Content> </Card.Content>

View File

@@ -8,7 +8,6 @@ import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings; const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
export type CustomOverlay = { export type CustomOverlay = {
extensionName: string;
id: string; id: string;
name: string; name: string;
tileUrls: string[]; tileUrls: string[];
@@ -47,16 +46,8 @@ export class ExtensionAPI {
} }
addOrUpdateOverlay(overlay: CustomOverlay) { addOrUpdateOverlay(overlay: CustomOverlay) {
if ( if (!overlay.id || !overlay.name || !overlay.tileUrls || overlay.tileUrls.length === 0) {
!overlay.extensionName || throw new Error('Overlay must have an id, name, and at least one tile URL.');
!overlay.id ||
!overlay.name ||
!overlay.tileUrls ||
overlay.tileUrls.length === 0
) {
throw new Error(
'Overlay must have an extensionName, id, name, and at least one tile URL.'
);
} }
overlay.id = this.getOverlayId(overlay.id); overlay.id = this.getOverlayId(overlay.id);
@@ -84,17 +75,10 @@ export class ExtensionAPI {
], ],
}; };
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) { overlayTree.overlays.world[overlay.id] = true;
overlayTree.overlays[overlay.extensionName] = {};
}
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
selectedOverlayTree.update((selected) => { selectedOverlayTree.update((selected) => {
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) { selected.overlays.world[overlay.id] = true;
selected.overlays[overlay.extensionName] = {};
}
selected.overlays[overlay.extensionName][overlay.id] = true;
return selected; return selected;
}); });
@@ -103,17 +87,14 @@ export class ExtensionAPI {
if (current && isSelected(current, overlay.id)) { if (current && isSelected(current, overlay.id)) {
show = true; show = true;
try { try {
get(map)?.removeLayer(overlay.id); get(map)?.removeImport(overlay.id);
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to remove sources and layers // No reliable way to check if the map is ready to remove sources and layers
} }
} }
currentOverlays.update((current) => { currentOverlays.update((current) => {
if (!current.overlays.hasOwnProperty(overlay.extensionName)) { current.overlays.world[overlay.id] = show;
current.overlays[overlay.extensionName] = {};
}
current.overlays[overlay.extensionName][overlay.id] = show;
return current; return current;
}); });
} }
@@ -152,29 +133,6 @@ export class ExtensionAPI {
}); });
} }
updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
selectedOverlayTree.update((selected) => {
let isSelected: Record<string, boolean> = {};
ids.forEach((id) => {
const overlay = get(this._overlays).get(id);
if (
overlay &&
selected.overlays.hasOwnProperty(overlay.extensionName) &&
selected.overlays[overlay.extensionName].hasOwnProperty(id)
) {
isSelected[id] = selected.overlays[overlay.extensionName][id];
delete selected.overlays[overlay.extensionName][id];
}
});
Object.entries(isSelected).forEach(([id, value]) => {
const overlay = get(this._overlays).get(id)!;
selected.overlays[overlay.extensionName][id] = value;
});
return selected;
});
}
isLayerFromExtension = derived(this._overlays, ($overlays) => { isLayerFromExtension = derived(this._overlays, ($overlays) => {
return (id: string) => $overlays.has(id); return (id: string) => $overlays.has(id);
}); });

View File

@@ -6,10 +6,6 @@ import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup'; import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { db } from '$lib/db'; import { db } from '$lib/db';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '../style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings; const { currentOverpassQueries } = settings;
@@ -24,12 +20,11 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
}); });
export class OverpassLayer { export class OverpassLayer {
overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter'; overpassUrl = 'https://overpass.private.coffee/api/interpreter';
minZoom = 12; minZoom = 12;
queryZoom = 12; queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000; expirationTime = 7 * 24 * 3600 * 1000;
map: maplibregl.Map; map: mapboxgl.Map;
layerEventManager: MapLayerEventManager;
popup: MapPopup; popup: MapPopup;
currentQueries: Set<string> = new Set(); currentQueries: Set<string> = new Set();
@@ -40,9 +35,8 @@ export class OverpassLayer {
updateBinded = this.update.bind(this); updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this); onHoverBinded = this.onHover.bind(this);
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) { constructor(map: mapboxgl.Map) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
this.popup = new MapPopup(map, { this.popup = new MapPopup(map, {
closeButton: false, closeButton: false,
focusAfterOpen: false, focusAfterOpen: false,
@@ -53,7 +47,7 @@ export class OverpassLayer {
add() { add() {
this.map.on('moveend', this.queryIfNeededBinded); this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded); this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded)); this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push( this.unsubscribes.push(
currentOverpassQueries.subscribe(() => { currentOverpassQueries.subscribe(() => {
@@ -77,17 +71,10 @@ export class OverpassLayer {
update() { update() {
this.loadIcons(); this.loadIcons();
const fullData = get(data); let d = get(data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: fullData.features.filter((feature) =>
queries.includes(feature.properties!.query)
),
};
try { try {
let source = this.map.getSource('overpass') as GeoJSONSource | undefined; let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
if (source) { if (source) {
source.setData(d); source.setData(d);
} else { } else {
@@ -98,24 +85,23 @@ export class OverpassLayer {
} }
if (!this.map.getLayer('overpass')) { if (!this.map.getLayer('overpass')) {
this.map.addLayer( this.map.addLayer({
{ id: 'overpass',
id: 'overpass', type: 'symbol',
type: 'symbol', source: 'overpass',
source: 'overpass', layout: {
layout: { 'icon-image': ['get', 'icon'],
'icon-image': ['get', 'icon'], 'icon-size': 0.25,
'icon-size': 0.25, 'icon-padding': 0,
'icon-padding': 0, 'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
},
}, },
ANCHOR_LAYER_KEY.overpass });
);
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded); this.map.on('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.on('click', 'overpass', this.onHoverBinded); this.map.on('click', 'overpass', this.onHoverBinded);
} }
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
} catch (e) { } catch (e) {
// No reliable way to check if the map is ready to add sources and layers // No reliable way to check if the map is ready to add sources and layers
} }
@@ -123,9 +109,7 @@ export class OverpassLayer {
remove() { remove() {
this.map.off('moveend', this.queryIfNeededBinded); this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.load', this.updateBinded); this.map.off('style.import.load', this.updateBinded);
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try { try {
@@ -258,16 +242,27 @@ export class OverpassLayer {
loadIcons() { loadIcons() {
let currentQueries = getCurrentQueries(); let currentQueries = getCurrentQueries();
currentQueries.forEach((query) => { currentQueries.forEach((query) => {
loadSVGIcon( if (!this.map.hasImage(`overpass-${query}`)) {
this.map, let icon = new Image(100, 100);
`overpass-${query}`, icon.onload = () => {
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"> if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" /> <circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)"> <g transform="translate(8 8)">
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')} ${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
</g> </g>
</svg>` </svg>
); `);
}
}); });
} }
} }
@@ -288,12 +283,10 @@ function getQuery(query: string) {
} }
} }
function getQueryItem(tags: Record<string, string | string[]>) { function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] => let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
Array.isArray(entry[1])
);
if (arrayEntry !== undefined) { if (arrayEntry !== undefined) {
return arrayEntry[1] return arrayEntry
.map( .map(
(val) => (val) =>
`nwr${Object.entries(tags) `nwr${Object.entries(tags)
@@ -316,7 +309,7 @@ function belongsToQuery(element: any, query: string) {
} }
} }
function belongsToQueryItem(element: any, tags: Record<string, string | string[]>) { function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
return Object.entries(tags).every(([tag, value]) => return Object.entries(tags).every(([tag, value]) =>
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
); );

View File

@@ -76,3 +76,5 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
}); });
return node; return node;
} }
export const customBasemapUpdate = writable(0);

View File

@@ -1,281 +0,0 @@
import { fileStateCollection } from '$lib/logic/file-state';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
type MapLayerListener = {
features: maplibregl.MapGeoJSONFeature[];
mousemoves: MapLayerMouseEventListener[];
mouseenters: MapLayerMouseEventListener[];
mouseleaves: MapLayerMouseEventListener[];
mousedowns: MapLayerMouseEventListener[];
clicks: MapLayerMouseEventListener[];
contextmenus: MapLayerMouseEventListener[];
touchstarts: MapLayerTouchEventListener[];
};
export class MapLayerEventManager {
private _map: maplibregl.Map;
private _listeners: Record<string, MapLayerListener> = {};
constructor(map: maplibregl.Map) {
this._map = map;
this._map.on('mousemove', this._handleMouseMove.bind(this));
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
this._map.on('touchstart', this._handleTouchStart.bind(this));
}
on(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (!this._listeners[layerId]) {
this._listeners[layerId] = {
features: [],
mousemoves: [],
mouseenters: [],
mouseleaves: [],
mousedowns: [],
clicks: [],
contextmenus: [],
touchstarts: [],
};
}
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
break;
case 'click':
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
break;
}
}
off(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (this._listeners[layerId]) {
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves = this._listeners[
layerId
].mousemoves.filter((l) => l !== listener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters = this._listeners[
layerId
].mouseenters.filter((l) => l !== listener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves = this._listeners[
layerId
].mouseleaves.filter((l) => l !== listener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns = this._listeners[
layerId
].mousedowns.filter((l) => l !== listener);
break;
case 'click':
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
(l) => l !== listener
);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus = this._listeners[
layerId
].contextmenus.filter((l) => l !== listener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts = this._listeners[
layerId
].touchstarts.filter((l) => l !== listener);
break;
}
if (
this._listeners[layerId].mousemoves.length === 0 &&
this._listeners[layerId].mouseenters.length === 0 &&
this._listeners[layerId].mouseleaves.length === 0 &&
this._listeners[layerId].mousedowns.length === 0 &&
this._listeners[layerId].clicks.length === 0 &&
this._listeners[layerId].contextmenus.length === 0 &&
this._listeners[layerId].touchstarts.length === 0
) {
delete this._listeners[layerId];
}
}
}
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if ((features.length == 0) != (listener.features.length == 0)) {
if (features.length > 0) {
if (listener.mouseenters.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseenter',
e.target,
e.originalEvent,
{
features: featuresByLayer[layerId]!,
}
);
listener.mouseenters.forEach((l) => l(event));
}
} else {
if (listener.mouseleaves.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseleave',
e.target,
e.originalEvent
);
listener.mouseleaves.forEach((l) => l(event));
}
}
}
if (features.length > 0 && listener.mousemoves.length > 0) {
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
features: featuresByLayer[layerId]!,
});
listener.mousemoves.forEach((l) => l(event));
}
listener.features = features;
});
}
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
if (type === 'click' && listener.clicks.length > 0) {
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
features: features,
});
listener.clicks.forEach((l) => l(event));
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
const event = new maplibregl.MapMouseEvent(
'contextmenu',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.contextmenus.forEach((l) => l(event));
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mousedown',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.mousedowns.forEach((l) => l(event));
}
}
});
}
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
'touchstart',
e.target,
e.originalEvent
);
event.features = featuresByLayer[layerId]!;
listener.touchstarts.forEach((l) => l(event));
}
});
}
private _getBounds(point: maplibregl.Point) {
const delta = 30;
return new maplibregl.LngLatBounds(
this._map.unproject([point.x - delta, point.y + delta]),
this._map.unproject([point.x + delta, point.y - delta])
);
}
private _filterLayersIntersectingBounds(
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
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
);
}
});
return result;
}
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersIntersectingBounds(
Object.keys(this._listeners),
this._getBounds(e.point)
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
return featuresByLayer;
}
}

View File

@@ -1,5 +1,5 @@
import { TrackPoint, Waypoint } from 'gpx'; import { TrackPoint, Waypoint } from 'gpx';
import maplibregl from 'maplibre-gl'; import mapboxgl from 'mapbox-gl';
import { mount, tick, unmount } from 'svelte'; import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte'; import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
@@ -11,15 +11,15 @@ export type PopupItem<T = Waypoint | TrackPoint | any> = {
}; };
export class MapPopup { export class MapPopup {
map: maplibregl.Map; map: mapboxgl.Map;
popup: maplibregl.Popup; popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null); item: Writable<PopupItem | null> = writable(null);
component: ReturnType<typeof mount>; component: ReturnType<typeof mount>;
maybeHideBinded = this.maybeHide.bind(this); maybeHideBinded = this.maybeHide.bind(this);
constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) { constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
this.map = map; this.map = map;
this.popup = new maplibregl.Popup(options); this.popup = new mapboxgl.Popup(options);
this.component = mount(MapPopupComponent, { this.component = mount(MapPopupComponent, {
target: document.body, target: document.body,
props: { props: {
@@ -51,7 +51,7 @@ export class MapPopup {
this.map.on('mousemove', this.maybeHideBinded); this.map.on('mousemove', this.maybeHideBinded);
} }
maybeHide(e: maplibregl.MapMouseEvent) { maybeHide(e: mapboxgl.MapMouseEvent) {
const item = get(this.item); const item = get(this.item);
if (item === null) { if (item === null) {
this.hide(); this.hide();
@@ -75,10 +75,10 @@ export class MapPopup {
getCoordinates() { getCoordinates() {
const item = get(this.item); const item = get(this.item);
if (item === null) { if (item === null) {
return new maplibregl.LngLat(0, 0); return new mapboxgl.LngLat(0, 0);
} }
return item.item instanceof Waypoint || item.item instanceof TrackPoint return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates() ? item.item.getCoordinates()
: new maplibregl.LngLat(item.item.lon, item.item.lat); : new mapboxgl.LngLat(item.item.lon, item.item.lat);
} }
} }

View File

@@ -1,80 +1,100 @@
import maplibregl from 'maplibre-gl'; import mapboxgl from 'mapbox-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import MaplibreGeocoder, {
type MaplibreGeocoderFeatureResults,
} from '@maplibre/maplibre-gl-geocoder';
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings; const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = { let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15, maxZoom: 15,
linear: true, linear: true,
easing: () => 1, easing: () => 1,
}; };
export class MapLibreGLMap { export class MapboxGLMap {
private _maptilerKey: string = ''; private _map: Writable<mapboxgl.Map | null> = writable(null);
private _map: maplibregl.Map | null = null; private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
private _mapStore: Writable<maplibregl.Map | null> = writable(null);
private _styleManager: StyleManager | null = null;
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
public layerEventManager: MapLayerEventManager | null = null;
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) { subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
return this._mapStore.subscribe(run, invalidate); return this._map.subscribe(run, invalidate);
} }
init( init(
maptilerKey: string, accessToken: string,
language: string, language: string,
hash: boolean, hash: boolean,
geocoder: boolean, geocoder: boolean,
geolocate: boolean geolocate: boolean
) { ) {
this._maptilerKey = maptilerKey; const map = new mapboxgl.Map({
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
const map = new maplibregl.Map({
container: 'map', container: 'map',
style: { style: {
version: 8, version: 8,
projection: {
type: 'globe',
},
sources: {}, sources: {},
layers: [], layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${accessToken}`,
},
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: [],
},
},
],
}, },
projection: 'globe',
zoom: 0, zoom: 0,
hash: hash, hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false, boxZoom: false,
maxPitch: 85,
}); });
this.layerEventManager = new MapLayerEventManager(map);
map.addControl( map.addControl(
new maplibregl.NavigationControl({ new mapboxgl.AttributionControl({
compact: true,
})
);
map.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true, visualizePitch: true,
}) })
); );
if (geocoder) { if (geocoder) {
let geocoder = new MaplibreGeocoder( let geocoder = new MapboxGeocoder({
{ mapboxgl: mapboxgl,
forwardGeocode: async (config) => { enableEventLogging: false,
const results: MaplibreGeocoderFeatureResults = { collapsed: true,
features: [], flyTo: fitBoundsOptions,
type: 'FeatureCollection', language,
}; localGeocoder: () => [],
try { localGeocoderOnly: true,
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`; externalGeocoder: (query: string) =>
const response = await fetch(request); fetch(
const geojson = await response.json(); `https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
results.features = geojson.map((result: any) => { )
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return { return {
type: 'Feature', type: 'Feature',
geometry: { geometry: {
@@ -84,43 +104,74 @@ export class MapLibreGLMap {
place_name: result.display_name, place_name: result.display_name,
}; };
}); });
} catch (e) {} }),
return results; });
}, let onKeyDown = geocoder._onKeyDown;
}, geocoder._onKeyDown = (e: KeyboardEvent) => {
{ // Trigger search on Enter key only
maplibregl: maplibregl, if (e.key === 'Enter') {
enableEventLogging: false, onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
collapsed: true, } else if (geocoder._typeahead.data.length > 0) {
flyTo: fitBoundsOptions, geocoder._typeahead.clear();
language,
} }
); };
map.addControl(geocoder); map.addControl(geocoder);
} }
if (geolocate) { if (geolocate) {
map.addControl( map.addControl(
new maplibregl.GeolocateControl({ new mapboxgl.GeolocateControl({
positionOptions: { positionOptions: {
enableHighAccuracy: true, enableHighAccuracy: true,
}, },
fitBoundsOptions, fitBoundsOptions,
trackUserLocation: true, trackUserLocation: true,
showUserHeading: true,
}) })
); );
} }
const scaleControl = new maplibregl.ScaleControl({ const scaleControl = new mapboxgl.ScaleControl({
unit: get(distanceUnits), unit: get(distanceUnits),
}); });
map.addControl(scaleControl); map.addControl(scaleControl);
map.on('style.load', () => {
map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
map.on('pitch', () => {
if (map.getPitch() > 0) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
map.setTerrain(null);
}
});
});
map.on('load', () => { map.on('load', () => {
this._map = map; this._map.set(map); // only set the store after the map has loaded
this._mapStore.set(map); // only set the store after the map has loaded
window._map = map; // entry point for extensions window._map = map; // entry point for extensions
this.resize(); this.resize();
scaleControl.setUnit(get(distanceUnits)); scaleControl.setUnit(get(distanceUnits));
this._onLoadCallbacks.forEach((callback) => callback(map));
this._onLoadCallbacks = [];
}); });
map.on('style.load', this.callOnLoadBinded);
this._unsubscribes.push(treeFileView.subscribe(() => this.resize())); this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize())); this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
@@ -133,48 +184,44 @@ export class MapLibreGLMap {
); );
} }
onLoad(callback: (map: mapboxgl.Map) => void) {
const map = get(this._map);
if (map) {
callback(map);
} else {
this._onLoadCallbacks.push(callback);
}
}
destroy() { destroy() {
if (this._map) { const map = get(this._map);
this._map.remove(); if (map) {
this._mapStore.set(null); map.remove();
this._map.set(null);
} }
this._unsubscribes.forEach((unsubscribe) => unsubscribe()); this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = []; this._unsubscribes = [];
} }
resize() { resize() {
if (this._map) { const map = get(this._map);
if (map) {
tick().then(() => { tick().then(() => {
this._map?.resize(); map.resize();
}); });
} }
} }
toggle3D() { toggle3D() {
if (this._map) { const map = get(this._map);
if (this._map.getPitch() === 0) { if (map) {
this._map.easeTo({ pitch: 70 }); if (map.getPitch() === 0) {
map.easeTo({ pitch: 70 });
} else { } else {
this._map.easeTo({ pitch: 0 }); map.easeTo({ pitch: 0 });
} }
} }
} }
onLoad(callback: (map: maplibregl.Map) => void) {
if (this._map) {
callback(this._map);
} else {
this._onLoadCallbacks.push(callback);
}
}
callOnLoad() {
if (this._map && this._map.getLayer(ANCHOR_LAYER_KEY.overlays)) {
this._onLoadCallbacks.forEach((callback) => callback(this._map!));
this._onLoadCallbacks = [];
this._map.off('style.load', this.callOnLoadBinded);
}
}
} }
export const map = new MapLibreGLMap(); export const map = new MapboxGLMap();

View File

@@ -20,14 +20,9 @@
let container: HTMLElement; let container: HTMLElement;
onMount(() => { onMount(() => {
map.onLoad((map_: maplibregl.Map) => { map.onLoad((map: mapboxgl.Map) => {
googleRedirect = new GoogleRedirect(map_); googleRedirect = new GoogleRedirect(map);
mapillaryLayer = new MapillaryLayer( mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
map_,
map.layerEventManager!,
container,
mapillaryOpen
);
}); });
}); });

View File

@@ -1,10 +1,11 @@
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type mapboxgl from 'mapbox-gl';
export class GoogleRedirect { export class GoogleRedirect {
map: maplibregl.Map; map: mapboxgl.Map;
enabled = false; enabled = false;
constructor(map: maplibregl.Map) { constructor(map: mapboxgl.Map) {
this.map = map; this.map = map;
} }
@@ -24,7 +25,7 @@ export class GoogleRedirect {
this.map.off('click', this.openStreetView); this.map.off('click', this.openStreetView);
} }
openStreetView(e: maplibregl.MapMouseEvent) { openStreetView(e: mapboxgl.MapMouseEvent) {
window.open( window.open(
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}` `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
); );

View File

@@ -1,9 +1,7 @@
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl'; import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module'; import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css'; import 'mapillary-js/dist/mapillary.css';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '../style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const mapillarySource: VectorSourceSpecification = { const mapillarySource: VectorSourceSpecification = {
type: 'vector', type: 'vector',
@@ -43,9 +41,8 @@ const mapillaryImageLayer: LayerSpecification = {
}; };
export class MapillaryLayer { export class MapillaryLayer {
map: maplibregl.Map; map: mapboxgl.Map;
layerEventManager: MapLayerEventManager; marker: mapboxgl.Marker;
marker: maplibregl.Marker;
viewer: Viewer; viewer: Viewer;
active = false; active = false;
@@ -55,14 +52,8 @@ export class MapillaryLayer {
onMouseEnterBinded = this.onMouseEnter.bind(this); onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this); onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor( constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
map: maplibregl.Map,
layerEventManager: MapLayerEventManager,
container: HTMLElement,
popupOpen: { value: boolean }
) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
this.viewer = new Viewer({ this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011', accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
@@ -70,12 +61,15 @@ export class MapillaryLayer {
}); });
const element = document.createElement('div'); const element = document.createElement('div');
element.className = 'maplibregl-user-location maplibregl-user-location-show-heading'; element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
const dot = document.createElement('div'); const dot = document.createElement('div');
dot.className = 'maplibregl-user-location-dot'; dot.className = 'mapboxgl-user-location-dot';
const heading = document.createElement('div');
heading.className = 'mapboxgl-user-location-heading';
element.appendChild(dot); element.appendChild(dot);
element.appendChild(heading);
this.marker = new maplibregl.Marker({ this.marker = new mapboxgl.Marker({
rotationAlignment: 'map', rotationAlignment: 'map',
element, element,
}); });
@@ -105,20 +99,20 @@ export class MapillaryLayer {
this.map.addSource('mapillary', mapillarySource); this.map.addSource('mapillary', mapillarySource);
} }
if (!this.map.getLayer('mapillary-sequence')) { if (!this.map.getLayer('mapillary-sequence')) {
this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary); this.map.addLayer(mapillarySequenceLayer);
} }
if (!this.map.getLayer('mapillary-image')) { if (!this.map.getLayer('mapillary-image')) {
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary); this.map.addLayer(mapillaryImageLayer);
} }
this.map.on('style.load', this.addBinded); this.map.on('style.load', this.addBinded);
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded); this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded); this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
} }
remove() { remove() {
this.map.off('style.load', this.addBinded); this.map.off('style.load', this.addBinded);
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded); this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded); this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
if (this.map.getLayer('mapillary-image')) { if (this.map.getLayer('mapillary-image')) {
this.map.removeLayer('mapillary-image'); this.map.removeLayer('mapillary-image');
@@ -140,7 +134,7 @@ export class MapillaryLayer {
this.popupOpen.value = false; this.popupOpen.value = false;
} }
onMouseEnter(e: maplibregl.MapLayerMouseEvent) { onMouseEnter(e: mapboxgl.MapMouseEvent) {
if ( if (
e.features && e.features &&
e.features.length > 0 && e.features.length > 0 &&

View File

@@ -1,231 +0,0 @@
import { settings } from '$lib/logic/settings';
import { get, type Writable } from 'svelte/store';
import {
basemaps,
defaultBasemap,
maptilerKeyPlaceHolder,
overlays,
terrainSources,
} from '$lib/assets/layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
const emptySource: maplibregl.GeoJSONSourceSpecification = {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
};
export const ANCHOR_LAYER_KEY = {
overlays: 'overlays-end',
mapillary: 'mapillary-end',
tracks: 'tracks-end',
directionMarkers: 'direction-markers-end',
distanceMarkers: 'distance-markers-end',
startEndMarkers: 'start-end-markers-end',
interactions: 'interactions-end',
overpass: 'overpass-end',
waypoints: 'waypoints-end',
routingControls: 'routing-controls-end',
};
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id,
type: 'symbol',
source: 'empty-source',
}));
export class StyleManager {
private _map: Writable<maplibregl.Map | null>;
private _maptilerKey: string;
private _pastOverlays: Set<string> = new Set();
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
this._map = map;
this._maptilerKey = maptilerKey;
this._map.subscribe((map_) => {
if (map_) {
this.updateBasemap();
map_.on('style.load', () => this.updateOverlays());
map_.on('pitch', () => this.updateTerrain());
}
});
currentBasemap.subscribe(() => this.updateBasemap());
currentOverlays.subscribe(() => this.updateOverlays());
opacities.subscribe(() => this.updateOverlays());
terrainSource.subscribe(() => this.updateTerrain());
customLayers.subscribe(() => this.updateBasemap());
}
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
this.buildStyle().then((style) => map_.setStyle(style));
}
async buildStyle(): Promise<maplibregl.StyleSpecification> {
const custom = get(customLayers);
const style: maplibregl.StyleSpecification = {
version: 8,
projection: {
type: 'globe',
},
sources: {
'empty-source': emptySource,
},
layers: [],
};
let basemap = get(currentBasemap);
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
const basemapStyle = await this.get(basemapInfo);
this.merge(style, basemapStyle);
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
style.layers.push(...anchorLayers);
return style;
}
async updateOverlays() {
const map_ = get(this._map);
if (!map_) return;
if (!map_.getSource('empty-source')) return;
const custom = get(customLayers);
const overlayOpacities = get(opacities);
try {
const layers = getLayers(get(currentOverlays) ?? {});
for (let overlay in layers) {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
this._pastOverlays.delete(overlay);
}
} else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacity;
} else if (layer.type === 'hillshade') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
}
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
}
}
this._pastOverlays.add(overlay);
}
}
} catch (e) {}
}
updateTerrain() {
const map_ = get(this._map);
if (!map_) return;
const mapTerrain = map_.getTerrain();
const terrain = this.getCurrentTerrain();
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
if (terrain.exaggeration > 0) {
if (!map_.getSource(terrain.source)) {
map_.addSource(terrain.source, terrainSources[terrain.source]);
}
map_.setTerrain(terrain);
} else {
map_.setTerrain(null);
}
}
}
async get(
styleInfo: maplibregl.StyleSpecification | string
): Promise<maplibregl.StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const response = await fetch(styleUrl, { cache: 'force-cache' });
const style = await response.json();
return style;
} else {
return styleInfo;
}
}
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
for (let layer of other.layers ?? []) {
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
const textField = layer.layout['text-field'];
if (
Array.isArray(textField) &&
textField.length >= 2 &&
textField[0] === 'coalesce' &&
Array.isArray(textField[1]) &&
textField[1][0] === 'get' &&
typeof textField[1][1] === 'string' &&
textField[1][1].startsWith('name')
) {
layer.layout['text-field'] = [
'coalesce',
['get', `name:${i18n.lang}`],
['get', 'name'],
];
}
}
style.layers.push(layer);
}
if (other.sprite && !style.sprite) {
style.sprite = other.sprite;
}
if (other.glyphs && !style.glyphs) {
style.glyphs = other.glyphs;
}
}
getCurrentTerrain() {
const terrain = get(terrainSource);
const source = terrainSources[terrain];
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const map_ = get(this._map);
return {
source: terrain,
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
};
}
}

View File

@@ -11,7 +11,7 @@
import Clean from '$lib/components/toolbar/tools/Clean.svelte'; import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte'; import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte'; import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import maplibregl from 'maplibre-gl'; import mapboxgl from 'mapbox-gl';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
let { let {
@@ -23,11 +23,11 @@
const { minimizeRoutingMenu } = settings; const { minimizeRoutingMenu } = settings;
let popupElement: HTMLDivElement | undefined = $state(undefined); let popupElement: HTMLDivElement | undefined = $state(undefined);
let popup: maplibregl.Popup | undefined = $derived.by(() => { let popup: mapboxgl.Popup | undefined = $derived.by(() => {
if (!popupElement) { if (!popupElement) {
return undefined; return undefined;
} }
let popup = new maplibregl.Popup({ let popup = new mapboxgl.Popup({
closeButton: false, closeButton: false,
maxWidth: undefined, maxWidth: undefined,
}); });

View File

@@ -16,11 +16,10 @@
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from '@lucide/svelte';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import type { GeoJSONSource } from 'maplibre-gl'; import type { GeoJSONSource } from 'mapbox-gl';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
let props: { let props: {
class?: string; class?: string;
@@ -29,7 +28,7 @@
let cleanType = $state(CleanType.INSIDE); let cleanType = $state(CleanType.INSIDE);
let deleteTrackpoints = $state(true); let deleteTrackpoints = $state(true);
let deleteWaypoints = $state(true); let deleteWaypoints = $state(true);
let rectangleCoordinates: maplibregl.LngLat[] = $state([]); let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
$effect(() => { $effect(() => {
if ($map) { if ($map) {
@@ -64,18 +63,15 @@
}); });
} }
if (!$map.getLayer('rectangle')) { if (!$map.getLayer('rectangle')) {
$map.addLayer( $map.addLayer({
{ id: 'rectangle',
id: 'rectangle', type: 'fill',
type: 'fill', source: 'rectangle',
source: 'rectangle', paint: {
paint: { 'fill-color': 'SteelBlue',
'fill-color': 'SteelBlue', 'fill-opacity': 0.5,
'fill-opacity': 0.5,
},
}, },
ANCHOR_LAYER_KEY.interactions });
);
} }
} }
} }

View File

@@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { MountainSnow } from '@lucide/svelte'; import { MountainSnow } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
@@ -19,7 +20,11 @@
variant="outline" variant="outline"
class="whitespace-normal h-fit" class="whitespace-normal h-fit"
disabled={!validSelection} disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()} onclick={() => {
if ($map) {
fileActions.addElevationToSelection($map);
}
}}
> >
<MountainSnow size="16" class="shrink-0" /> <MountainSnow size="16" class="shrink-0" />
{i18n._('toolbar.elevation.button')} {i18n._('toolbar.elevation.button')}

View File

@@ -38,7 +38,7 @@
let endTime: string | undefined = $state(undefined); let endTime: string | undefined = $state(undefined);
let movingTime: number | undefined = $state(undefined); let movingTime: number | undefined = $state(undefined);
let speed: number | undefined = $state(undefined); let speed: number | undefined = $state(undefined);
let artificial = $state(true); let artificial = $state(false);
function toCalendarDate(date: Date): CalendarDate { function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
@@ -346,7 +346,7 @@
let fileId = item.getFileId(); let fileId = item.getFileId();
fileActionManager.applyToFile(fileId, (file) => { fileActionManager.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
if (artificial && !$gpxStatistics.global.time.moving) { if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime! movingTime!
@@ -359,7 +359,7 @@
); );
} }
} else if (item instanceof ListTrackItem) { } else if (item instanceof ListTrackItem) {
if (artificial && !$gpxStatistics.global.time.moving) { if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime!, movingTime!,
@@ -374,7 +374,7 @@
); );
} }
} else if (item instanceof ListTrackSegmentItem) { } else if (item instanceof ListTrackSegmentItem) {
if (artificial && !$gpxStatistics.global.time.moving) { if (artificial || !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps( file.createArtificialTimestamps(
getDate(startDate!, startTime!), getDate(startDate!, startTime!),
movingTime!, movingTime!,

View File

@@ -10,7 +10,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte'; import { minTolerance, ReducedGPXLayerCollection, tolerance } from './reduce.svelte';
let props: { class?: string } = $props(); let props: { class?: string } = $props();

View File

@@ -1,12 +1,11 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state'; import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx'; import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import type { GeoJSONSource } from 'maplibre-gl'; import type { GeoJSONSource } from 'mapbox-gl';
import { get, writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
export const minTolerance = 0.1; export const minTolerance = 0.1;
@@ -29,15 +28,17 @@ export class ReducedGPXLayer {
update() { update() {
const file = this._fileState.file; const file = this._fileState.file;
if (!file) { const stats = this._fileState.statistics;
if (!file || !stats) {
return; return;
} }
file.forEachSegment((segment, trackIndex, segmentIndex) => { file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex); let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
let statistics = stats.getStatisticsFor(segmentItem);
this._updateSimplified(segmentItem.getFullId(), [ this._updateSimplified(segmentItem.getFullId(), [
segmentItem, segmentItem,
segment.trkpt.length, statistics.local.points.length,
ramerDouglasPeucker(segment.trkpt, minTolerance), ramerDouglasPeucker(statistics.local.points, minTolerance),
]); ]);
}); });
} }
@@ -145,18 +146,17 @@ export class ReducedGPXLayerCollection {
}); });
} }
if (!map_.getLayer('simplified')) { if (!map_.getLayer('simplified')) {
map_.addLayer( map_.addLayer({
{ id: 'simplified',
id: 'simplified', type: 'line',
type: 'line', source: 'simplified',
source: 'simplified', paint: {
paint: { 'line-color': 'white',
'line-color': 'white', 'line-width': 3,
'line-width': 3,
},
}, },
ANCHOR_LAYER_KEY.interactions });
); } else {
map_.moveLayer('simplified');
} }
} }

View File

@@ -21,7 +21,7 @@
SquareArrowUpLeft, SquareArrowUpLeft,
SquareArrowOutDownRight, SquareArrowOutDownRight,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing'; import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { import {
@@ -51,7 +51,7 @@
}: { }: {
minimized?: boolean; minimized?: boolean;
minimizable?: boolean; minimizable?: boolean;
popup?: maplibregl.Popup; popup?: mapboxgl.Popup;
popupElement?: HTMLDivElement; popupElement?: HTMLDivElement;
class?: string; class?: string;
} = $props(); } = $props();
@@ -163,11 +163,11 @@
{i18n._('toolbar.routing.activity')} {i18n._('toolbar.routing.activity')}
</span> </span>
<Select.Root type="single" bind:value={$routingProfile}> <Select.Root type="single" bind:value={$routingProfile}>
<Select.Trigger class="grow" size="sm"> <Select.Trigger class="h-8 grow">
{i18n._(`toolbar.routing.activities.${$routingProfile}`)} {i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each Object.keys(routingProfiles) as profile} {#each Object.keys(brouterProfiles) as profile}
<Select.Item value={profile} <Select.Item value={profile}
>{i18n._( >{i18n._(
`toolbar.routing.activities.${profile}` `toolbar.routing.activities.${profile}`
@@ -195,7 +195,7 @@
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.reverseSelection} onclick={fileActions.reverseSelection}
> >
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')} <ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.route_back_to_start.tooltip')} label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
@@ -231,7 +231,7 @@
} }
}} }}
> >
<House class="size-3" />{i18n._('toolbar.routing.route_back_to_start.button')} <House size="12" />{i18n._('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.round_trip.tooltip')} label={i18n._('toolbar.routing.round_trip.tooltip')}
@@ -240,7 +240,7 @@
disabled={!validSelection} disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection} onclick={fileActions.createRoundTripForSelection}
> >
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')} <Repeat size="12" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip> </ButtonWithTooltip>
</div> </div>
<div class="w-full flex flex-row gap-2 items-end justify-between"> <div class="w-full flex flex-row gap-2 items-end justify-between">

View File

@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings; const { routing, routingProfile, privateRoads } = settings;
export const routingProfiles: { [key: string]: string } = { export const brouterProfiles: { [key: string]: string } = {
bike: 'Trekking-dry', bike: 'Trekking-dry',
racing_bike: 'fastbike', racing_bike: 'fastbike',
gravel_bike: 'gravel', gravel_bike: 'gravel',
@@ -19,7 +19,7 @@ export const routingProfiles: { [key: string]: string } = {
export function route(points: Coordinates[]): Promise<TrackPoint[]> { export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (get(routing)) { if (get(routing)) {
return getRoute(points, routingProfiles[get(routingProfile)], get(privateRoads)); return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
} else { } else {
return getIntermediatePoints(points); return getIntermediatePoints(points);
} }

View File

@@ -2,21 +2,15 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number { export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) { if (distance === undefined) {
return MIN_ANCHOR_ZOOM; return 0;
} }
const rad = Math.PI / 180; const rad = Math.PI / 180;
const lat = latitude * rad; const lat = latitude * rad;
return Math.min( return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
} }
export function updateAnchorPoints(file: GPXFile) { export function updateAnchorPoints(file: GPXFile) {

View File

@@ -26,24 +26,26 @@
let validSelection = $derived( let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.global.length > 0 $gpxStatistics.local.points.length > 0
); );
let maxSliderValue = $derived( let maxSliderValue = $derived(
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1 validSelection && $gpxStatistics.local.points.length > 0
? $gpxStatistics.local.points.length - 1
: 1
); );
let sliderValues = $derived([0, maxSliderValue]); let sliderValues = $derived([0, maxSliderValue]);
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue); let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
onMount(() => { onMount(() => {
if ($map) { if ($map) {
splitControls = new SplitControls($map, map.layerEventManager!); splitControls = new SplitControls($map);
} }
}); });
function updateSlicedGPXStatistics() { function updateSlicedGPXStatistics() {
if (validSelection && canCrop) { if (validSelection && canCrop) {
$slicedGPXStatistics = [ $slicedGPXStatistics = [
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]), get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
sliderValues[0], sliderValues[0],
sliderValues[1], sliderValues[1],
]; ];
@@ -105,7 +107,7 @@
{i18n._('toolbar.scissors.split_as')} {i18n._('toolbar.scissors.split_as')}
</span> </span>
<Select.Root bind:value={$splitAs} type="single"> <Select.Root bind:value={$splitAs} type="single">
<Select.Trigger class="w-fit grow" size="sm"> <Select.Trigger class="h-8 w-fit grow">
{i18n._('gpx.' + $splitAs)} {i18n._('gpx.' + $splitAs)}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>

View File

@@ -1,3 +1,5 @@
import { TrackPoint, TrackSegment } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
@@ -7,34 +9,19 @@ import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
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';
export class SplitControls { export class SplitControls {
map: maplibregl.Map; active: boolean = false;
layerEventManager: MapLayerEventManager; map: mapboxgl.Map;
controls: ControlWithMarker[] = [];
shownControls: ControlWithMarker[] = [];
unsubscribes: Function[] = []; unsubscribes: Function[] = [];
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this); toggleControlsForZoomLevelAndBoundsBinded: () => void =
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); this.toggleControlsForZoomLevelAndBounds.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) { constructor(map: mapboxgl.Map) {
this.map = map; this.map = map;
this.layerEventManager = layerEventManager;
loadSVGIcon(
this.map,
'split-control',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>`
);
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this))); this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
@@ -44,18 +31,29 @@ export class SplitControls {
addIfNeeded() { addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS; let scissors = get(currentTool) === Tool.SCISSORS;
if (!scissors) { if (!scissors) {
this.remove(); if (this.active) {
this.remove();
}
return; return;
} }
this.updateControls(); if (this.active) {
this.updateControls();
} else {
this.add();
}
}
add() {
this.active = true;
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
} }
updateControls() { updateControls() {
let data: GeoJSON.FeatureCollection = { // Update the markers when the files change
type: 'FeatureCollection', let controlIndex = 0;
features: [],
};
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId); let file = fileStateCollection.getFile(fileId);
@@ -66,23 +64,30 @@ export class SplitControls {
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex) new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
) )
) { ) {
for (let i = 1; i < segment.trkpt.length - 1; i++) { for (let point of segment.trkpt.slice(1, -1)) {
let point = segment.trkpt[i]; // Update the existing controls (could be improved by matching the existing controls with the new ones?)
if (point._data.anchor) { if (point._data.anchor) {
data.features.push({ if (controlIndex < this.controls.length) {
type: 'Feature', this.controls[controlIndex].fileId = fileId;
geometry: { this.controls[controlIndex].point = point;
type: 'Point', this.controls[controlIndex].segment = segment;
coordinates: [point.getLongitude(), point.getLatitude()], this.controls[controlIndex].trackIndex = trackIndex;
}, this.controls[controlIndex].segmentIndex = segmentIndex;
properties: { this.controls[controlIndex].marker.setLngLat(
fileId: fileId, point.getCoordinates()
trackIndex: trackIndex, );
segmentIndex: segmentIndex, } else {
pointIndex: i, this.controls.push(
minZoom: point._data.zoom, this.createControl(
}, point,
}); segment,
fileId,
trackIndex,
segmentIndex
)
);
}
controlIndex++;
} }
} }
} }
@@ -90,86 +95,86 @@ export class SplitControls {
} }
}, false); }, false);
try { while (controlIndex < this.controls.length) {
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined; // Remove the extra controls
if (source) { this.controls.pop()?.marker.remove();
source.setData(data);
} else {
this.map.addSource('split-controls', {
type: 'geojson',
data: data,
});
}
if (!this.map.getLayer('split-controls')) {
this.map.addLayer(
{
id: 'split-controls',
type: 'symbol',
source: 'split-controls',
layout: {
'icon-image': 'split-control',
'icon-size': 0.25,
'icon-padding': 0,
},
filter: ['<=', ['get', 'minZoom'], ['zoom']],
},
ANCHOR_LAYER_KEY.interactions
);
this.layerEventManager.on(
'mouseenter',
'split-controls',
this.layerOnMouseEnterBinded
);
this.layerEventManager.on(
'mouseleave',
'split-controls',
this.layerOnMouseLeaveBinded
);
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
} }
this.toggleControlsForZoomLevelAndBounds();
} }
remove() { remove() {
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded); this.active = false;
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
try { for (let control of this.controls) {
if (this.map.getLayer('split-controls')) { control.marker.remove();
this.map.removeLayer('split-controls');
}
if (this.map.getSource('split-controls')) {
this.map.removeSource('split-controls');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
} }
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
} }
layerOnMouseEnter(e: any) { toggleControlsForZoomLevelAndBounds() {
mapCursor.notify(MapCursorState.SPLIT_CONTROL, true); // Show markers only if they are in the current zoom level and bounds
this.shownControls.splice(0, this.shownControls.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let zoom = this.map.getZoom();
this.controls.forEach((control) => {
control.inZoom = control.point._data.zoom <= zoom;
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
control.marker.addTo(this.map);
this.shownControls.push(control);
} else {
control.marker.remove();
}
});
} }
layerOnMouseLeave() { createControl(
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false); point: TrackPoint,
} segment: TrackSegment,
fileId: string,
trackIndex: number,
segmentIndex: number
): ControlWithMarker {
let element = document.createElement('div');
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
element.innerHTML = Scissors.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', 'stroke="black"');
layerOnClick(e: maplibregl.MapLayerMouseEvent) { let marker = new mapboxgl.Marker({
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates; draggable: true,
fileActions.split( className: 'z-10',
get(splitAs), element,
e.features![0].properties!.fileId, }).setLngLat(point.getCoordinates());
e.features![0].properties!.trackIndex,
e.features![0].properties!.segmentIndex, let control = {
{ lon: coordinates[0], lat: coordinates[1] }, point,
e.features![0].properties!.pointIndex segment,
); fileId,
trackIndex,
segmentIndex,
marker,
inZoom: false,
};
marker.getElement().addEventListener('click', (e) => {
e.stopPropagation();
fileActions.split(
get(splitAs),
control.fileId,
control.trackIndex,
control.segmentIndex,
control.point.getCoordinates(),
control.point._data.index
);
});
return control;
} }
destroy() { destroy() {
@@ -177,3 +182,16 @@ export class SplitControls {
this.unsubscribes.forEach((unsubscribe) => unsubscribe()); this.unsubscribes.forEach((unsubscribe) => unsubscribe());
} }
} }
type Control = {
segment: TrackSegment;
fileId: string;
trackIndex: number;
segmentIndex: number;
point: TrackPoint;
};
type ControlWithMarker = Control & {
marker: mapboxgl.Marker;
inZoom: boolean;
};

View File

@@ -16,8 +16,6 @@
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import maplibregl from 'maplibre-gl';
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
let props: { let props: {
class?: string; class?: string;
@@ -41,21 +39,6 @@
}) })
); );
let marker: maplibregl.Marker | null = null;
function reset() {
if ($selectedWaypoint) {
selectedWaypoint.reset();
} else {
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
}
}
$effect(() => { $effect(() => {
if ($selectedWaypoint) { if ($selectedWaypoint) {
const wpt = $selectedWaypoint[0]; const wpt = $selectedWaypoint[0];
@@ -71,7 +54,14 @@
latitude = parseFloat(wpt.getLatitude().toFixed(6)); latitude = parseFloat(wpt.getLatitude().toFixed(6));
}); });
} else { } else {
untrack(reset); untrack(() => {
name = '';
description = '';
link = '';
sym = '';
longitude = 0;
latitude = 0;
});
} }
}); });
@@ -95,14 +85,14 @@
desc: description.length > 0 ? description : undefined, desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined, cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined, link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: sym.length > 0 ? sym : undefined, sym: sym,
}, },
selectedWaypoint.wpt && selectedWaypoint.fileId selectedWaypoint.wpt && selectedWaypoint.fileId
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index) ? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
: undefined : undefined
); );
reset(); selectedWaypoint.reset();
} }
function setCoordinates(e: any) { function setCoordinates(e: any) {
@@ -110,37 +100,6 @@
longitude = e.lngLat.lng.toFixed(6); longitude = e.lngLat.lng.toFixed(6);
} }
$effect(() => {
if ($selectedWaypoint) {
if (marker) {
marker.remove();
marker = null;
}
} else if (latitude != 0 || longitude != 0) {
if ($map) {
if (marker) {
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
getSvgForSymbol(symbolKey);
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8');
element.innerHTML = getSvgForSymbol(symbolKey);
marker = new maplibregl.Marker({
element,
anchor: 'bottom',
})
.setLngLat([longitude, latitude])
.addTo($map);
}
}
} else {
if (marker) {
marker.remove();
marker = null;
}
}
});
onMount(() => { onMount(() => {
if ($map) { if ($map) {
$map.on('click', setCoordinates); $map.on('click', setCoordinates);
@@ -153,10 +112,6 @@
$map.off('click', setCoordinates); $map.off('click', setCoordinates);
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false); mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
} }
if (marker) {
marker.remove();
marker = null;
}
}); });
</script> </script>
@@ -174,27 +129,19 @@
bind:value={description} bind:value={description}
id="description" id="description"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/> />
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label> <Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single"> <Select.Root bind:value={sym} type="single">
<Select.Trigger <Select.Trigger
id="symbol" id="symbol"
size="sm" class="w-full h-8"
class="w-full"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
> >
<span class="flex flex-row gap-1.5 items-center"> {#if symbolKey}
{#if symbolKey} {i18n._(`gpx.symbol.${symbolKey}`)}
{#if symbols[symbolKey].icon} {:else}
{@const Component = symbols[symbolKey].icon} {sym}
<Component size="14" /> {/if}
{/if}
{i18n._(`gpx.symbol.${symbolKey}`)}
{:else}
{sym}
{/if}
</span>
</Select.Trigger> </Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll"> <Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]} {#each sortedSymbols as [key, symbol]}
@@ -202,7 +149,7 @@
<span> <span>
{#if symbol.icon} {#if symbol.icon}
{@const Component = symbol.icon} {@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub" /> <Component size="14" class="inline-block align-sub mr-0.5" />
{:else} {:else}
<span class="w-4 inline-block"></span> <span class="w-4 inline-block"></span>
{/if} {/if}
@@ -263,7 +210,7 @@
{i18n._('toolbar.waypoint.create')} {i18n._('toolbar.waypoint.create')}
{/if} {/if}
</Button> </Button>
<Button variant="outline" size="icon" onclick={reset}> <Button variant="outline" size="icon" onclick={() => selectedWaypoint.reset()}>
<CircleX size="16" /> <CircleX size="16" />
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt. Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt.
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt. Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt.
Wir sind äußerst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt. Wir sind äusserst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt.
Diese Partnerschaft ermöglicht es **gpx.studio**, von den Mapbox-Tools zu ermäßigten Preisen zu profitieren, was erheblich zur finanziellen Tragfähigkeit des Projekts beiträgt und es uns ermöglicht, die bestmögliche Benutzererfahrung zu bieten. Diese Partnerschaft ermöglicht es **gpx.studio**, von den Mapbox-Tools zu ermäßigten Preisen zu profitieren, was erheblich zur finanziellen Tragfähigkeit des Projekts beiträgt und es uns ermöglicht, die bestmögliche Benutzererfahrung zu bieten.

View File

@@ -12,7 +12,6 @@ title: Files and statistics
let gpxStatistics = writable(exampleGPXFile.getStatistics()); let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined); let slicedGPXStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']); let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined); let elevationFill = writable(undefined);
</script> </script>
@@ -85,7 +84,6 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<ElevationProfile <ElevationProfile
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
{hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
/> />

View File

@@ -5,7 +5,7 @@
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free) ## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network. Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places. We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive. Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free. If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.

View File

@@ -0,0 +1,5 @@
Mapbox is the company that provides some of the beautiful maps on this website.
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.

View File

@@ -12,7 +12,7 @@ title: Integration
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website. You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
All you need is: All you need is:
1. A <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load the map, and 1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL. 1. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
You can then play with the configurator below to customize your map and generate the corresponding HTML code. You can then play with the configurator below to customize your map and generate the corresponding HTML code.

View File

@@ -58,7 +58,7 @@ Only one basemap can be displayed at a time.
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<DocsLayers /> <DocsLayers />
<span class="text-sm text-center mt-2"> <span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap. Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
</span> </span>
</div> </div>
@@ -67,4 +67,4 @@ They can be enabled in the [map layer settings dialog](./menu/settings).
In these settings, you can also manage the opacity of the overlays. In these settings, you can also manage the opacity of the overlays.
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs. For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs.

View File

@@ -18,7 +18,7 @@ This tool allows you to add elevation data to traces and [points of interest](..
<DocsNote> <DocsNote>
Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>. Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>. You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.
</DocsNote> </DocsNote>

View File

@@ -1,5 +1,5 @@
--- ---
title: Opciones de vista title: View options
--- ---
<script lang="ts"> <script lang="ts">

View File

@@ -29,13 +29,13 @@ Beste era batez, fitxategiak zuzenean arrastatu eta jaregin ditzakezu zure fitxa
Sortu hautatutako fitxategien kopia bat. Sortu hautatutako fitxategien kopia bat.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Ezabatu hautatutako fitxategiak. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu guztiak ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Ezabatu fitxategi guztiak. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esportatu... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esportatu...

View File

@@ -50,7 +50,7 @@ Facendo clic destro su una scheda file, è possibile accedere alle stesse azioni
Come accennato nella [sezione opzioni di visualizzazione](./menu/view), è possibile passare a un layout ad albero per l'elenco dei file. Come accennato nella [sezione opzioni di visualizzazione](./menu/view), è possibile passare a un layout ad albero per l'elenco dei file.
Questo layout è ideale per gestire un gran numero di file aperti, organizzandoli in una lista verticale sul lato destro della mappa. Questo layout è ideale per gestire un gran numero di file aperti, organizzandoli in una lista verticale sul lato destro della mappa.
Inoltre, la vista ad albero dei file consente d'ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili. Inoltre, la vista ad albero dei file consente di ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili.
Puoi anche applicare [modifiche](./menu/edit) e [strumenti](./toolbar) agli elementi interni del file. Puoi anche applicare [modifiche](./menu/edit) e [strumenti](./toolbar) agli elementi interni del file.
Inoltre, è possibile trascinare e rilasciare gli elementi per riordinarli, o spostarli nella gerarchia o anche in un altro file. Inoltre, è possibile trascinare e rilasciare gli elementi per riordinarli, o spostarli nella gerarchia o anche in un altro file.
@@ -78,7 +78,7 @@ Quando si passa sopra il profilo di elevazione, un suggerimento mostrerà le sta
Per ottenere le statistiche per una sezione specifica del profilo di elevazione, è possibile trascinare un rettangolo di selezione sul profilo. Per ottenere le statistiche per una sezione specifica del profilo di elevazione, è possibile trascinare un rettangolo di selezione sul profilo.
Fare clic sul profilo per resettare la selezione. Fare clic sul profilo per resettare la selezione.
È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto <kbd>Maiuscolo</kbd>. È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto <kbd>Maiusc</kbd>.
<div class="h-48 w-full"> <div class="h-48 w-full">
<ElevationProfile <ElevationProfile

View File

@@ -21,7 +21,7 @@ Queste sono organizzate in una struttura gerarchica, con le tracce stesse al liv
- Una **traccia** è composta da una sequenza di segmenti scollegati. - Una **traccia** è composta da una sequenza di segmenti scollegati.
Inoltre, può contenere metadati come un **nome**, una **descrizione**, e **proprietà di visualizzazione**. Inoltre, può contenere metadati come un **nome**, una **descrizione**, e **proprietà di visualizzazione**.
- Un **segmento** è una sequenza di punti GPS che formano un percorso continuo. - Un **segmento** è una sequenza di punti GPS che formano un percorso continuo.
- Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente una marcatura temporale e un'altitudine. - Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente un timestamp e un'altitudine.
Alcuni dispositivi memorizzano anche informazioni aggiuntive come frequenza cardiaca, cadenza, temperatura e potenza. Alcuni dispositivi memorizzano anche informazioni aggiuntive come frequenza cardiaca, cadenza, temperatura e potenza.
Nella maggior parte dei casi, i file GPX contengono una singola traccia con un singolo segmento. Nella maggior parte dei casi, i file GPX contengono una singola traccia con un singolo segmento.

View File

@@ -5,9 +5,9 @@
## <HeartHandshake size="18" class="inline-block align-baseline" /> Aiuta a mantenere il sito gratuito (e senza pubblicità) ## <HeartHandshake size="18" class="inline-block align-baseline" /> Aiuta a mantenere il sito gratuito (e senza pubblicità)
Ogni volta che aggiungi o sposti i punti GPS, i nostri server calcolano il percorso migliore sulla rete stradale. Ogni volta che aggiungi o sposti i punti GPS, i nostri server calcolano il percorso migliore sulla rete stradale.
Utilizziamo anche le API di <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe stupende, recuperare i dati altimetrici e consentire la ricerca di luoghi. Utilizziamo anche le API di <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe gradevoli, recuperare i dati altimetrici e consentire la ricerca di luoghi.
Sfortunatamente, fare tutto ciò è costoso. Sfortunatamente, questo è costoso.
Se ti piace utilizzare questo strumento e lo trovi utile, per favore considera di fare una piccola donazione per aiutare a mantenere il sito web gratuito e senza pubblicità. Se ti piace utilizzare questo strumento e lo trovi utile, per favore considera di fare una piccola donazione per aiutare a mantenere il sito web gratuito e senza pubblicità.
Grazie mille per il vostro supporto! ❤️ Grazie mille per il vostro supporto! ❤️

View File

@@ -29,13 +29,13 @@ cÈ inoltre possibile trascinare i file direttamente dal file system del tuo Pc
Crea una copia dei file attualmente selezionati. Crea una copia dei file attualmente selezionati.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" />Elimina ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Elimina i file attualmente selezionati. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" />Cancella tutto ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
Elimina tutti i file. Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esporta... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esporta...

View File

@@ -14,7 +14,7 @@ Deze handleiding zal je door alle componenten en gereedschappen van de interface
<DocsImage src="getting-started/interface" alt="De gpx.studio interface." /> <DocsImage src="getting-started/interface" alt="De gpx.studio interface." />
Zoals weergegeven in bovenstaande scherm, is de interface verdeeld in vier hoofddelen rond de kaart. Zoals weergegeven in bovenstaande scherm, is de interface verdeeld in vier hoofddelen rond de kaart.
Voordat we in de details van elke sectie duiken, eerst een snel overzicht van de interface. Voordat we in de details van elke sectie duiken, hebben we een snel overzicht van de interface.
## Menu ## Menu

View File

@@ -83,7 +83,7 @@ Deze actie is alleen beschikbaar wanneer de verticale indeling van de bestandsli
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Plakken ### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Plakken
Plak de bestandsitems van het klembord naar het huidige hiërarchieniveau indien compatibel. Plak de bestandsitems van het klembord naar het huidige hiërarchie niveau indien compatibel.
<DocsNote> <DocsNote>

View File

@@ -2,7 +2,7 @@
import { HeartHandshake } from '@lucide/svelte'; import { HeartHandshake } from '@lucide/svelte';
</script> </script>
## <HeartHandshake size="18" class="inline-block align-baseline" /> Hãy giúp duy trì trang web miễn phí (và không có quảng cáo) ## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
Khi bạn thêm hoặc di chuyển các điểm định vị, máy chủ của chúng tôi sẽ tính toán đoạn đường tốt nhất trên mạng lưới giao thông. Khi bạn thêm hoặc di chuyển các điểm định vị, máy chủ của chúng tôi sẽ tính toán đoạn đường tốt nhất trên mạng lưới giao thông.
Chúng tôi cũng sử dụng các API từ <a href="https://mapbox.com" target="_blank">Mapbox</a> để hiển thị đa dạng các bản đồ, lưu trữ các dữ liệu độ cao cũng như giúp bạn có thể tìm kiếm các địa điểm khác nhau. Chúng tôi cũng sử dụng các API từ <a href="https://mapbox.com" target="_blank">Mapbox</a> để hiển thị đa dạng các bản đồ, lưu trữ các dữ liệu độ cao cũng như giúp bạn có thể tìm kiếm các địa điểm khác nhau.

View File

@@ -1,5 +1,5 @@
Mapbox là công ty cung cấp một số bản đồ đẹp trên trang web này. Mapbox is the company that provides some of the beautiful maps on this website.
Họ cũng phát triển <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">công cụ bản đồ</a> cung cấp sức mạnh cho **gpx.studio**. They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
Chúng tôi vô cùng may mắn và biết ơn khi được tham gia chương trình <a href="https://mapbox.com/community" target="_blank">Cộng đồng</a> của họ, chương trình hỗ trợ các tổ chức phi lợi nhuận, các tổ chức giáo dục và các tổ chức tạo ra tác động tích cực. We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
Sự hợp tác này cho phép **gpx.studio** được hưởng lợi từ các công cụ của Mapbox với giá ưu đãi, góp phần đáng kể vào tính khả thi về tài chính của dự án và giúp chúng tôi mang đến trải nghiệm người dùng tốt nhất có thể. This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.

View File

@@ -9,8 +9,8 @@ title: Edit actions
# { title } # { title }
Không giống như các thao tác trên tệp, các thao tác chỉnh sửa có thể thay đổi nội dung của các tệp hiện đang được chọn. Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
Hơn nữa, khi bố cục dạng cây của danh sách tệp được bật (xem [Tệp và thống kê](../files-and-stats)), chúng cũng có thể được áp dụng cho [đường đi, đoạn đường và điểm quan tâm](../gpx). Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
Therefore, we will refer to the elements that can be modified by these actions as _file items_. Therefore, we will refer to the elements that can be modified by these actions as _file items_.
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items. Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.

View File

@@ -31,7 +31,7 @@ Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
. Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all

View File

@@ -29,13 +29,13 @@ title: 文件
创建当前选中文件的副本。 创建当前选中文件的副本。
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除 ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
删除当前选中的文件。 Delete the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除全部 ### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
删除全部文件。 Delete all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 导出... ### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 导出...

View File

@@ -14,7 +14,7 @@ class Locale {
private _isLoadingInitial = $state(true); private _isLoadingInitial = $state(true);
private _isLoading = $state(true); private _isLoading = $state(true);
private dictionary: Dictionary = $state({}); private dictionary: Dictionary = $state({});
private _t = $derived((key: string, fallback?: string) => { private _t = $derived((key: string) => {
const keys = key.split('.'); const keys = key.split('.');
let value: string | Dictionary = this.dictionary; let value: string | Dictionary = this.dictionary;
@@ -22,7 +22,7 @@ class Locale {
if (value && typeof value === 'object' && k in value) { if (value && typeof value === 'object' && k in value) {
value = value[k]; value = value[k];
} else { } else {
return fallback || key; return key;
} }
} }

View File

@@ -1,6 +1,6 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { selection } from '$lib/logic/selection'; import { selection } from '$lib/logic/selection';
import maplibregl from 'maplibre-gl'; import mapboxgl from 'mapbox-gl';
import { ListFileItem, ListWaypointItem } from '$lib/components/file-list/file-list'; import { ListFileItem, ListWaypointItem } from '$lib/components/file-list/file-list';
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state'; import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
@@ -10,7 +10,7 @@ import type { Coordinates } from 'gpx';
import { page } from '$app/state'; import { page } from '$app/state';
export class BoundsManager { export class BoundsManager {
private _bounds: maplibregl.LngLatBounds = new maplibregl.LngLatBounds(); private _bounds: mapboxgl.LngLatBounds = new mapboxgl.LngLatBounds();
private _files: Set<string> = new Set(); private _files: Set<string> = new Set();
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null; private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
@@ -66,8 +66,10 @@ export class BoundsManager {
finalizeFitBounds() { finalizeFitBounds() {
if ( if (
this._bounds.getSouth() >= this._bounds.getNorth() && this._bounds.getSouth() === 90 &&
this._bounds.getWest() >= this._bounds.getEast() this._bounds.getWest() === 180 &&
this._bounds.getNorth() === -90 &&
this._bounds.getEast() === -180
) { ) {
return; return;
} }
@@ -87,12 +89,12 @@ export class BoundsManager {
} }
this._unsubscribes.forEach((unsubscribe) => unsubscribe()); this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = []; this._unsubscribes = [];
this._bounds = new maplibregl.LngLatBounds([180, 90, -180, -90]); this._bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]);
} }
centerMapOnSelection() { centerMapOnSelection() {
let selected = get(selection).getSelected(); let selected = get(selection).getSelected();
let bounds = new maplibregl.LngLatBounds(); let bounds = new mapboxgl.LngLatBounds();
if (selected.find((item) => item instanceof ListWaypointItem)) { if (selected.find((item) => item instanceof ListWaypointItem)) {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {

View File

@@ -17,6 +17,7 @@ import {
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { freeze, type WritableDraft } from 'immer'; import { freeze, type WritableDraft } from 'immer';
import { import {
distance,
GPXFile, GPXFile,
parseGPX, parseGPX,
Track, Track,
@@ -29,7 +30,7 @@ import {
} from 'gpx'; } from 'gpx';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils'; import { getClosestLinePoint, getElevation } from '$lib/utils';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
import { boundsManager } from './bounds'; import { boundsManager } from './bounds';
@@ -215,7 +216,7 @@ export const fileActions = {
reverseSelection: () => { reverseSelection: () => {
if ( if (
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || !get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
get(gpxStatistics).global.length <= 1 get(gpxStatistics).local.points?.length <= 1
) { ) {
return; return;
} }
@@ -345,20 +346,19 @@ export const fileActions = {
let startTime: Date | undefined = undefined; let startTime: Date | undefined = undefined;
if (speed !== undefined) { if (speed !== undefined) {
if ( if (
statistics.global.length > 0 && statistics.local.points.length > 0 &&
statistics.getTrackPoint(0)!.trkpt.time !== undefined statistics.local.points[0].time !== undefined
) { ) {
startTime = statistics.getTrackPoint(0)!.trkpt.time; startTime = statistics.local.points[0].time;
} else { } else {
for (let i = 0; i < statistics.global.length; i++) { let index = statistics.local.points.findIndex(
const point = statistics.getTrackPoint(i)!; (point) => point.time !== undefined
if (point.trkpt.time !== undefined) { );
startTime = new Date( if (index !== -1 && statistics.local.points[index].time) {
point.trkpt.time.getTime() - startTime = new Date(
(1000 * 3600 * point.distance.total) / speed statistics.local.points[index].time.getTime() -
); (1000 * 3600 * statistics.local.distance.total[index]) / speed
break; );
}
} }
} }
} }
@@ -453,13 +453,34 @@ export const fileActions = {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
if (level === ListLevel.FILE) { if (level === ListLevel.FILE) {
let file = fileStateCollection.getFile(fileId); let file = fileStateCollection.getFile(fileId);
let statistics = fileStateCollection.getStatistics(fileId); if (file) {
if (file && statistics) {
if (file.trk.length > 1) { if (file.trk.length > 1) {
let fileIds = getFileIds(file.trk.length); let fileIds = getFileIds(file.trk.length);
let closest = file.wpt.map((wpt) => let closest = file.wpt.map((wpt, wptIndex) => {
getClosestTrackSegments(file, statistics, wpt.getCoordinates()) return {
); wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE,
};
});
file.trk.forEach((track, index) => {
track.getSegments().forEach((segment) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(
point.getCoordinates(),
wpt.getCoordinates()
);
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
} else if (dist === closest[wptIndex].distance) {
closest[wptIndex].index.push(index);
}
});
});
});
});
file.trk.forEach((track, index) => { file.trk.forEach((track, index) => {
let newFile = file.clone(); let newFile = file.clone();
let tracks = track.trkseg.map((segment, segmentIndex) => { let tracks = track.trkseg.map((segment, segmentIndex) => {
@@ -474,11 +495,9 @@ export const fileActions = {
newFile.replaceWaypoints( newFile.replaceWaypoints(
0, 0,
file.wpt.length - 1, file.wpt.length - 1,
file.wpt.filter((wpt, wptIndex) => closest
closest[wptIndex].some( .filter((c) => c.index.includes(index))
([trackIndex, segmentIndex]) => trackIndex === index .map((c) => file.wpt[c.wptIndex])
)
)
); );
newFile._data.id = fileIds[index]; newFile._data.id = fileIds[index];
newFile.metadata.name = newFile.metadata.name =
@@ -487,9 +506,29 @@ export const fileActions = {
}); });
} else if (file.trk.length === 1) { } else if (file.trk.length === 1) {
let fileIds = getFileIds(file.trk[0].trkseg.length); let fileIds = getFileIds(file.trk[0].trkseg.length);
let closest = file.wpt.map((wpt) => let closest = file.wpt.map((wpt, wptIndex) => {
getClosestTrackSegments(file, statistics, wpt.getCoordinates()) return {
); wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE,
};
});
file.trk[0].trkseg.forEach((segment, index) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(
point.getCoordinates(),
wpt.getCoordinates()
);
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
} else if (dist === closest[wptIndex].distance) {
closest[wptIndex].index.push(index);
}
});
});
});
file.trk[0].trkseg.forEach((segment, index) => { file.trk[0].trkseg.forEach((segment, index) => {
let newFile = file.clone(); let newFile = file.clone();
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [ newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
@@ -498,11 +537,9 @@ export const fileActions = {
newFile.replaceWaypoints( newFile.replaceWaypoints(
0, 0,
file.wpt.length - 1, file.wpt.length - 1,
file.wpt.filter((wpt, wptIndex) => closest
closest[wptIndex].some( .filter((c) => c.index.includes(index))
([trackIndex, segmentIndex]) => segmentIndex === index .map((c) => file.wpt[c.wptIndex])
)
)
); );
newFile._data.id = fileIds[index]; newFile._data.id = fileIds[index];
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`; newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
@@ -807,7 +844,7 @@ export const fileActions = {
}); });
}); });
}, },
addElevationToSelection: async () => { addElevationToSelection: async (map: mapboxgl.Map) => {
if (get(selection).size === 0) { if (get(selection).size === 0) {
return; return;
} }

View File

@@ -4,13 +4,10 @@ import { get, writable, type Writable } from 'svelte/store';
export enum MapCursorState { export enum MapCursorState {
DEFAULT, DEFAULT,
LAYER_HOVER, LAYER_HOVER,
TOOL_WITH_CROSSHAIR,
WAYPOINT_HOVER,
WAYPOINT_DRAGGING, WAYPOINT_DRAGGING,
ANCHOR_HOVER, TRACKPOINT_DRAGGING,
ANCHOR_DRAGGING, TOOL_WITH_CROSSHAIR,
SCISSORS, SCISSORS,
SPLIT_CONTROL,
MAPILLARY_HOVER, MAPILLARY_HOVER,
STREET_VIEW_CROSSHAIR, STREET_VIEW_CROSSHAIR,
} }
@@ -19,13 +16,10 @@ const scissorsCursor = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/20
const cursorStyles = { const cursorStyles = {
[MapCursorState.DEFAULT]: 'default', [MapCursorState.DEFAULT]: 'default',
[MapCursorState.LAYER_HOVER]: 'pointer', [MapCursorState.LAYER_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_DRAGGING]: 'grabbing', [MapCursorState.WAYPOINT_DRAGGING]: 'grabbing',
[MapCursorState.ANCHOR_HOVER]: 'pointer', [MapCursorState.TRACKPOINT_DRAGGING]: 'grabbing',
[MapCursorState.ANCHOR_DRAGGING]: 'grabbing',
[MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair', [MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair',
[MapCursorState.SCISSORS]: scissorsCursor, [MapCursorState.SCISSORS]: scissorsCursor,
[MapCursorState.SPLIT_CONTROL]: 'pointer',
[MapCursorState.MAPILLARY_HOVER]: 'pointer', [MapCursorState.MAPILLARY_HOVER]: 'pointer',
[MapCursorState.STREET_VIEW_CROSSHAIR]: 'crosshair', [MapCursorState.STREET_VIEW_CROSSHAIR]: 'crosshair',
}; };
@@ -36,8 +30,8 @@ export class MapCursor {
constructor() { constructor() {
this._states = writable(new Set()); this._states = writable(new Set());
this._states.subscribe((states) => { this._states.subscribe((states) => {
let state = Array.from(states.values()).reduce((max, value) => { let state = states.entries().reduce((max, entry) => {
return value > max ? value : max; return entry[0] > max ? entry[0] : max;
}, MapCursorState.DEFAULT); }, MapCursorState.DEFAULT);
let canvas = get(map)?.getCanvas(); let canvas = get(map)?.getCanvas();
if (canvas) { if (canvas) {

Some files were not shown because too many files have changed in this diff Show More