1 Commits

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

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
open_collective: gpxstudio ko_fi: gpxstudio

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

@@ -5,7 +5,7 @@
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files. [**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.webp) ![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
This repository contains the source code of the website. This repository contains the source code of the website.
@@ -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 map rendering - [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
- [GraphHopper](https://github.com/graphhopper/graphhopper) — 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 {
@@ -1398,7 +1368,10 @@ export class TrackPoint {
: undefined; : undefined;
} }
setExtension(key: string, value: string) { setExtensions(extensions: Record<string, string>) {
if (Object.keys(extensions).length === 0) {
return;
}
if (!this.extensions) { if (!this.extensions) {
this.extensions = {}; this.extensions = {};
} }
@@ -1408,12 +1381,8 @@ export class TrackPoint {
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) { if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {}; this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
} }
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}
setExtensions(extensions: Record<string, string>) {
Object.entries(extensions).forEach(([key, value]) => { Object.entries(extensions).forEach(([key, value]) => {
this.setExtension(key, value); this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}); });
} }
@@ -1422,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 {
@@ -1492,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,
},
}); });
} }
} }
@@ -1522,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 {
@@ -1591,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,
@@ -1643,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,
@@ -1657,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) {
@@ -1676,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;
@@ -1692,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())
); );
} }
@@ -1798,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,8 +1,9 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "neutral" "baseColor": "slate"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
@@ -12,9 +13,5 @@
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://shadcn-svelte.com/registry", "registry": "https://shadcn-svelte.com/registry"
"style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
} }

8648
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,11 @@
"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": {
"@fontsource-variable/inter": "^5.2.8", "@lucide/svelte": "^0.544.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.7.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0", "@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2", "@sveltejs/kit": "^2.21.2",
@@ -25,15 +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.17.2", "bits-ui": "^2.12.0",
"clsx": "^2.1.1",
"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",
@@ -46,37 +44,41 @@
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"shadcn-svelte": "^1.2.7",
"svelte": "^5.33.18", "svelte": "^5.33.18",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65", "svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.1.0", "svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.5.0", "tailwind-variants": "^3.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7", "vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5" "vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@docsearch/js": "^3.9.0", "@docsearch/js": "^3.9.0",
"@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",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"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.21.1", "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"
} }
} }

View File

@@ -1,164 +1,124 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(1 0 0); --background: hsl(0 0% 100%) /* <- Wrap in HSL */;
--foreground: oklch(0.145 0 0); --foreground: hsl(240 10% 3.9%);
--muted: oklch(0.97 0 0); --muted: hsl(240 4.8% 95.9%);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: hsl(240 3.8% 46.1%);
--popover: oklch(1 0 0); --popover: hsl(0 0% 100%);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: hsl(240 10% 3.9%);
--card: oklch(1 0 0); --card: hsl(0 0% 100%);
--card-foreground: oklch(0.145 0 0); --card-foreground: hsl(240 10% 3.9%);
--border: oklch(0.922 0 0); --border: hsl(240 5.9% 90%);
--input: oklch(0.922 0 0); --input: hsl(240 5.9% 90%);
--primary: oklch(0.205 0 0); --primary: hsl(240 5.9% 10%);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: hsl(0 0% 98%);
--secondary: oklch(0.97 0 0); --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: hsl(240 5.9% 10%);
--accent: oklch(0.97 0 0); --accent: hsl(240 4.8% 95.9%);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: hsl(240 5.9% 10%);
--destructive: oklch(0.577 0.245 27.325); --destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: oklch(0.708 0 0); --ring: hsl(240 10% 3.9%);
--sidebar: oklch(0.985 0 0); --sidebar: hsl(0 0% 98%);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: hsl(220 13% 91%);
--sidebar-ring: oklch(0.708 0 0); --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;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: hsl(240 10% 3.9%);
--foreground: oklch(0.985 0 0); --foreground: hsl(0 0% 98%);
--muted: oklch(0.269 0 0); --muted: hsl(240 3.7% 15.9%);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: hsl(240 5% 64.9%);
--popover: oklch(0.205 0 0); --popover: hsl(240 10% 3.9%);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: hsl(0 0% 98%);
--card: oklch(0.205 0 0); --card: hsl(240 10% 3.9%);
--card-foreground: oklch(0.985 0 0); --card-foreground: hsl(0 0% 98%);
--border: oklch(1 0 0 / 10%); --border: hsl(240 3.7% 15.9%);
--input: oklch(1 0 0 / 15%); --input: hsl(240 3.7% 15.9%);
--primary: oklch(0.922 0 0); --primary: hsl(0 0% 98%);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: hsl(240 5.9% 10%);
--secondary: oklch(0.269 0 0); --secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: hsl(0 0% 98%);
--accent: oklch(0.269 0 0); --accent: hsl(240 3.7% 15.9%);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: hsl(0 0% 98%);
--destructive: oklch(0.704 0.191 22.216); --destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--ring: oklch(0.556 0 0); --ring: hsl(240 4.9% 83.9%);
--sidebar: oklch(0.205 0 0); --sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: oklch(0.556 0 0); --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%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
} }
@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;
--font-sans: 'Inter Variable', sans-serif;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html {
@apply font-sans;
}
} }

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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 448 KiB

View File

@@ -22,41 +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 } = {
maptilerStreets: `https://api.maptiler.com/maps/streets-v4/style.json?key=${maptilerKeyPlaceHolder}`, mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`, mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
esriSatellite: {
version: 8,
sources: {
esriSatellite: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/tile/1.0.0/World_Imagery/default/default028mm/{z}/{y}/{x}.jpg',
],
tileSize: 256,
maxzoom: 19,
attribution:
'© <a href="https://www.esri.com/" target="_blank">Esri</a>, Vantor, Earthstar Geographics, and the GIS User Community',
},
},
layers: [
{
id: 'esriSatellite',
type: 'raster',
source: 'esriSatellite',
},
],
},
openStreetMap: { openStreetMap: {
version: 8, version: 8,
sources: { sources: {
@@ -171,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: {
@@ -394,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: {
@@ -799,11 +737,8 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = { export const basemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
maptilerStreets: true, mapboxOutdoors: true,
maptilerTopo: true, mapboxSatellite: true,
maptilerOutdoors: true,
maptilerSatellite: true,
esriSatellite: true,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -864,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: {
@@ -903,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,
@@ -936,7 +868,7 @@ export const overpassTree: LayerTreeType = {
}; };
// Default basemap used // Default basemap used
export const defaultBasemap = 'maptilerStreets'; export const defaultBasemap = 'mapboxOutdoors';
// Default overlays used (none) // Default overlays used (none)
export const defaultOverlays: LayerTreeType = { export const defaultOverlays: LayerTreeType = {
@@ -950,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: {
@@ -989,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,
@@ -1025,11 +954,8 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = { export const defaultBasemapTree: LayerTreeType = {
basemaps: { basemaps: {
world: { world: {
maptilerStreets: true, mapboxOutdoors: true,
maptilerTopo: true, mapboxSatellite: true,
maptilerOutdoors: true,
maptilerSatellite: true,
esriSatellite: false,
openStreetMap: true, openStreetMap: true,
openTopoMap: true, openTopoMap: true,
openHikingMap: true, openHikingMap: true,
@@ -1090,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: {
@@ -1129,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,
@@ -1168,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 = {
@@ -1176,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;
}; };
@@ -1257,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,
@@ -1307,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: {
@@ -1485,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

@@ -64,9 +64,3 @@
</svelte:head> </svelte:head>
<div id="docsearch" class={props.class ?? ''}></div> <div id="docsearch" class={props.class ?? ''}></div>
<style>
#docsearch :global(button) {
margin-left: 0px;
}
</style>

View File

@@ -26,7 +26,7 @@
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button {...props} {variant} class="bg-inherit {className}" {onclick}> <Button {...props} {variant} class={className} {onclick}>
{@render children()} {@render children()}
</Button> </Button>
{/snippet} {/snippet}

View File

@@ -1,118 +1,124 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte'; import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte'; import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
</script> </script>
<footer class="w-full px-12 py-10 border-t flex flex-col items-center"> <footer class="w-full">
<div class="w-full max-w-5xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="mx-6 border-t">
<div class="grow flex flex-col items-start"> <div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<Logo class="h-8" width="153" /> <div class="grow flex flex-col items-start">
<Button <Logo class="h-8" width="153" />
variant="link" <Button
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" variant="link"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE" class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
target="_blank" href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
> target="_blank"
MIT © 2026 gpx.studio >
</Button> MIT © 2025 gpx.studio
<div class="mt-3 flex flex-row gap-1.5"> </Button>
<LanguageSelect /> <LanguageSelect class="w-40 mt-3" />
<ModeSwitch />
</div> </div>
</div> <div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6"> <div class="flex flex-col items-start gap-1">
<div class="flex flex-col items-start gap-1"> <span class="font-semibold">{i18n._('homepage.website')}</span>
<span class="font-semibold">{i18n._('homepage.website')}</span> <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" href={getURLForLanguage(i18n.lang, '/')}
href={getURLForLanguage(i18n.lang, '/')} >
> <House size="16" />
<House size="16" /> {i18n._('homepage.home')}
{i18n._('homepage.home')} </Button>
</Button> <Button
<Button variant="link"
data-sveltekit-reload class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href={getURLForLanguage(i18n.lang, '/app')}
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" >
href={getURLForLanguage(i18n.lang, '/app')} <Map size="16" />
> {i18n._('homepage.app')}
<Map size="16" /> </Button>
{i18n._('homepage.app')} <Button
</Button> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href={getURLForLanguage(i18n.lang, '/help')}
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" >
href={getURLForLanguage(i18n.lang, '/help')} <BookOpenText size="16" />
> {i18n._('menu.help')}
<BookOpenText size="16" /> </Button>
{i18n._('menu.help')} </div>
</Button> <div class="flex flex-col items-start gap-1" id="contact">
</div> <span class="font-semibold">{i18n._('homepage.contact')}</span>
<div class="flex flex-col items-start gap-1" id="contact"> <Button
<span class="font-semibold">{i18n._('homepage.contact')}</span> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href="https://www.reddit.com/r/gpxstudio/"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" target="_blank"
href="https://www.reddit.com/r/gpxstudio/" >
target="_blank" <Logo company="reddit" class="h-4 fill-muted-foreground" />
> {i18n._('homepage.reddit')}
<Logo company="reddit" class="h-4 fill-muted-foreground" /> </Button>
{i18n._('homepage.reddit')} <Button
</Button> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href="https://facebook.com/gpx.studio"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" target="_blank"
href="https://facebook.com/gpx.studio" >
target="_blank" <Logo company="facebook" class="h-4 fill-muted-foreground" />
> {i18n._('homepage.facebook')}
<Logo company="facebook" class="h-4 fill-muted-foreground" /> </Button>
{i18n._('homepage.facebook')} <Button
</Button> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href="https://x.com/gpxstudio"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" target="_blank"
href="mailto:hello@gpx.studio" >
target="_blank" <Logo company="x" class="h-4 fill-muted-foreground" />
> {i18n._('homepage.x')}
<AtSign size="16" /> </Button>
{i18n._('homepage.email')} <Button
</Button> variant="link"
</div> class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
<div class="flex flex-col items-start gap-1"> href="mailto:hello@gpx.studio"
<span class="font-semibold">{i18n._('homepage.contribute')}</span> target="_blank"
<Button >
variant="link" <AtSign size="16" />
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" {i18n._('homepage.email')}
href="https://opencollective.com/gpxstudio" </Button>
target="_blank" </div>
> <div class="flex flex-col items-start gap-1">
<Heart size="16" /> <span class="font-semibold">{i18n._('homepage.contribute')}</span>
{i18n._('menu.donate')} <Button
</Button> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href="https://ko-fi.com/gpxstudio"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" target="_blank"
href="https://crowdin.com/project/gpxstudio" >
target="_blank" <Heart size="16" />
> {i18n._('menu.donate')}
<Logo company="crowdin" class="h-4 fill-muted-foreground" /> </Button>
{i18n._('homepage.crowdin')} <Button
</Button> variant="link"
<Button class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
variant="link" href="https://crowdin.com/project/gpxstudio"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground" target="_blank"
href="https://github.com/gpxstudio/gpx.studio" >
target="_blank" <Logo company="crowdin" class="h-4 fill-muted-foreground" />
> {i18n._('homepage.crowdin')}
<Logo company="github" class="h-4 fill-muted-foreground" /> </Button>
{i18n._('homepage.github')} <Button
</Button> variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,87 +6,88 @@
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';
const { velocityUnits } = settings; const { velocityUnits } = settings;
let panelHeight: number = $state(0);
let panelWidth: number = $state(0);
let { let {
gpxStatistics, gpxStatistics,
slicedGPXStatistics, slicedGPXStatistics,
orientation, orientation,
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;
} = $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-fit my-1'} ring-0 p-0 text-sm sm:text-base bg-transparent" : 'w-full'} border-none shadow-none p-0"
> >
<Card.Content class="h-full p-0"> <Card.Content
<div class="h-full flex {orientation === 'vertical'
bind:clientHeight={panelHeight} ? 'flex-col justify-center'
bind:clientWidth={panelWidth} : 'flex-row w-full justify-between'} gap-4 p-0"
class="flex {orientation === 'vertical' >
? 'flex-col h-full justify-center' <Tooltip label={i18n._('quantities.distance')}>
: 'flex-row w-full justify-evenly'} gap-4" <span class="flex flex-row items-center">
> <Ruler size="16" class="mr-1" />
<Tooltip label={i18n._('quantities.distance')}> <WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" /> <WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}> {/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" /> <WithUnits value={statistics.global.time.moving} type="time" />
<MoveDownRight size="16" class="mx-1" /> <span class="mx-1">/</span>
<WithUnits value={statistics.elevation.loss} type="elevation" /> <WithUnits value={statistics.global.time.total} type="time" />
</span> </span>
</Tooltip> </Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)} {/if}
<Tooltip
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
<Tooltip
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
</span>
</Tooltip>
{/if}
</div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -14,12 +14,12 @@
} = $props(); } = $props();
</script> </script>
<div class="text-[13px] bg-secondary rounded border flex flex-row items-center p-2 {className}"> <div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" /> <CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div> <div>
{@render children()} {@render children()}
{#if link} {#if link}
<a href={link} target="_blank" class="text-[13px] text-link hover:underline"> <a href={link} target="_blank" class="text-sm text-link hover:underline">
{i18n._('menu.more')} {i18n._('menu.more')}
</a> </a>
{/if} {/if}

View File

@@ -5,10 +5,16 @@
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { Languages } from '@lucide/svelte'; import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
let {
class: className = '',
}: {
class?: string;
} = $props();
</script> </script>
<Select.Root type="single" value={i18n.lang}> <Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}> <Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
<Languages size="16" /> <Languages size="16" />
<span class="mr-auto"> <span class="mr-auto">
{languages[i18n.lang]} {languages[i18n.lang]}

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

@@ -43,8 +43,6 @@
BookOpenText, BookOpenText,
ChartArea, ChartArea,
Maximize, Maximize,
Maximize2,
Minimize2,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -72,7 +70,7 @@
import { copied, selection } from '$lib/logic/selection'; import { copied, selection } from '$lib/logic/selection';
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 { tick, onMount } from 'svelte'; import { tick } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list'; import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const { const {
@@ -107,23 +105,6 @@
} }
let layerSettingsOpen = $state(false); let layerSettingsOpen = $state(false);
let fullscreen = $state(false);
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}
onMount(() => {
const handler = () => {
fullscreen = document.fullscreenElement !== null;
};
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
});
</script> </script>
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none"> <div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
@@ -394,18 +375,8 @@
<Menubar.Item inset onclick={() => map.toggle3D()}> <Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" /> <Box size="16" />
{i18n._('menu.toggle_3d')} {i18n._('menu.toggle_3d')}
<Shortcut key={i18n._('menu.right_click_drag')} /> <Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator />
<Menubar.CheckboxItem checked={fullscreen} onCheckedChange={toggleFullscreen}>
{#if fullscreen}
<Minimize2 size="16" />
{:else}
<Maximize2 size="16" />
{/if}
{i18n._('menu.fullscreen')}
<Shortcut key="F11" />
</Menubar.CheckboxItem>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
@@ -418,7 +389,7 @@
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Ruler size="16" />{i18n._('menu.distance_units')} <Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}> <Menubar.RadioGroup bind:value={$distanceUnits}>
@@ -436,7 +407,7 @@
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Zap size="16" />{i18n._('menu.velocity_units')} <Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}> <Menubar.RadioGroup bind:value={$velocityUnits}>
@@ -451,7 +422,7 @@
</Menubar.Sub> </Menubar.Sub>
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Thermometer size="16" />{i18n._('menu.temperature_units')} <Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}> <Menubar.RadioGroup bind:value={$temperatureUnits}>
@@ -467,7 +438,7 @@
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<Languages size="16" /> <Languages size="16" class="mr-2" />
{i18n._('menu.language')} {i18n._('menu.language')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
@@ -483,9 +454,9 @@
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
{#if mode.current === 'light' || !mode.current} {#if mode.current === 'light' || !mode.current}
<Sun size="16" /> <Sun size="16" class="mr-2" />
{:else} {:else}
<Moon size="16" /> <Moon size="16" class="mr-2" />
{/if} {/if}
{i18n._('menu.mode')} {i18n._('menu.mode')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
@@ -508,7 +479,7 @@
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Sub> <Menubar.Sub>
<Menubar.SubTrigger> <Menubar.SubTrigger>
<PersonStanding size="16" /> <PersonStanding size="16" class="mr-2" />
{i18n._('menu.street_view_source')} {i18n._('menu.street_view_source')}
</Menubar.SubTrigger> </Menubar.SubTrigger>
<Menubar.SubContent> <Menubar.SubContent>
@@ -529,12 +500,12 @@
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
</Menubar.Root> </Menubar.Root>
<div class="h-fit flex flex-row items-center"> <div class="h-fit flex flex-row items-center ml-1 gap-1">
<Button <Button
variant="ghost" variant="ghost"
href="./help" href="./help"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-md px-3 py-0.5" class="cursor-default h-fit rounded-sm px-3 py-0.5"
aria-label={i18n._('menu.help')} aria-label={i18n._('menu.help')}
> >
<BookOpenText size="18" class="md:hidden" /> <BookOpenText size="18" class="md:hidden" />
@@ -544,9 +515,9 @@
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
href="https://opencollective.com/gpxstudio" href="https://ko-fi.com/gpxstudio"
target="_blank" target="_blank"
class="cursor-default h-fit rounded-md font-bold text-support hover:text-support px-3 py-0.5" class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')} aria-label={i18n._('menu.donate')}
> >
<HeartHandshake size="18" class="md:hidden" /> <HeartHandshake size="18" class="md:hidden" />
@@ -567,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' ||
@@ -674,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

@@ -12,7 +12,7 @@
</script> </script>
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
class={className} class={className}
onclick={() => { onclick={() => {

View File

@@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, House, Map } from '@lucide/svelte'; import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
</script> </script>
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b"> <nav class="w-full sticky top-0 bg-background z-50">
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8"> <div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<a <a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
href={getURLForLanguage(i18n.lang, '/')} <Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
class="shrink-0 translate-y-0.25 justify-self-start" <Logo class="h-8 hidden sm:block" width="153" />
>
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden xs:block" width="153" />
</a> </a>
<Button <Button
variant="link" variant="link"
class="text-base px-0 has-[>svg]:px-0 ml-auto" class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/')} href={getURLForLanguage(i18n.lang, '/')}
> >
<House size="18" /> <House size="18" />
{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')}
@@ -40,5 +38,7 @@
<BookOpenText size="18" /> <BookOpenText size="18" />
{i18n._('menu.help')} {i18n._('menu.help')}
</Button> </Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:inline-flex" />
</div> </div>
</nav> </nav>

View File

@@ -35,7 +35,7 @@
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="w-full flex flex-row gap-1 border-none {side === 'right' class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover : 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background' ? 'hover:bg-background'
@@ -62,7 +62,7 @@
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="w-full flex flex-row gap-1 border-none {side === 'right' class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}" : 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
> >

View File

@@ -19,7 +19,7 @@
@apply text-foreground; @apply text-foreground;
@apply text-3xl; @apply text-3xl;
@apply font-semibold; @apply font-semibold;
@apply mb-3; @apply mb-3 pt-6;
} }
:global(.markdown h2) { :global(.markdown h2) {

View File

@@ -12,7 +12,7 @@
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto"> <div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'} {#if src === 'getting-started/interface'}
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.webp" src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt} {alt}
class="w-full max-w-3xl" class="w-full max-w-3xl"
/> />
@@ -20,13 +20,13 @@
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png" src="/src/lib/assets/img/docs/tools/routing.png"
{alt} {alt}
class="w-full max-w-lg" class="w-full max-w-3xl"
/> />
{:else if src === 'tools/split'} {:else if src === 'tools/split'}
<enhanced:img <enhanced:img
src="/src/lib/assets/img/docs/tools/split.png" src="/src/lib/assets/img/docs/tools/split.png"
{alt} {alt}
class="w-full max-w-lg" class="w-full max-w-3xl"
/> />
{/if} {/if}
</div> </div>

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,35 +61,37 @@
}); });
</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}
<div class="absolute bottom-9 right-2.5"> <div class="absolute bottom-10 right-1.5">
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('chart.settings')} label={i18n._('chart.settings')}
variant="outline" variant="outline"
side="left" side="left"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 bg-background" class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
> >
<ChartNoAxesColumn size="18" /> <ChartNoAxesColumn size="18" />
</ButtonWithTooltip> </ButtonWithTooltip>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content <Popover.Content
class="w-fit p-0 flex flex-col gap-0 overflow-hidden" class="w-fit p-0 flex flex-col"
side="top" side="top"
align="end" align="end"
sideOffset={-32} sideOffset={-32}
> >
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col w-full border-none" class="flex flex-col items-start gap-0 p-1 w-full border-none"
type="single" type="single"
size="sm"
bind:value={$elevationFill} bind:value={$elevationFill}
> >
<ToggleGroup.Item value="slope" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'slope'} {#if $elevationFill === 'slope'}
<Circle class="size-1.5 fill-current text-current" /> <Circle class="size-1.5 fill-current text-current" />
@@ -102,8 +101,9 @@
{i18n._('quantities.slope')} {i18n._('quantities.slope')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface" value="surface"
class="w-full flex flex-row justify-start" variant="outline"
> >
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'} {#if $elevationFill === 'surface'}
@@ -114,8 +114,9 @@
{i18n._('quantities.surface')} {i18n._('quantities.surface')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway" value="highway"
class="w-full flex flex-row justify-start" variant="outline"
> >
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'} {#if $elevationFill === 'highway'}
@@ -128,12 +129,14 @@
</ToggleGroup.Root> </ToggleGroup.Root>
<Separator /> <Separator />
<ToggleGroup.Root <ToggleGroup.Root
class="flex flex-col gap-0" class="flex flex-col items-start gap-0 p-1"
type="multiple" type="multiple"
size="sm"
bind:value={$additionalDatasets} bind:value={$additionalDatasets}
> >
<ToggleGroup.Item value="speed" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('speed')} {#if $additionalDatasets.includes('speed')}
<Check size="14" /> <Check size="14" />
@@ -144,7 +147,10 @@
? i18n._('quantities.speed') ? i18n._('quantities.speed')
: i18n._('quantities.pace')} : i18n._('quantities.pace')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item value="hr" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('hr')} {#if $additionalDatasets.includes('hr')}
<Check size="14" /> <Check size="14" />
@@ -153,7 +159,10 @@
<HeartPulse size="15" /> <HeartPulse size="15" />
{i18n._('quantities.heartrate')} {i18n._('quantities.heartrate')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item value="cad" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('cad')} {#if $additionalDatasets.includes('cad')}
<Check size="14" /> <Check size="14" />
@@ -162,7 +171,10 @@
<Orbit size="15" /> <Orbit size="15" />
{i18n._('quantities.cadence')} {i18n._('quantities.cadence')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item value="atemp" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('atemp')} {#if $additionalDatasets.includes('atemp')}
<Check size="14" /> <Check size="14" />
@@ -171,7 +183,10 @@
<Thermometer size="15" /> <Thermometer size="15" />
{i18n._('quantities.temperature')} {i18n._('quantities.temperature')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item value="power" class="w-full flex flex-row justify-start"> <ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center"> <div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('power')} {#if $additionalDatasets.includes('power')}
<Check size="14" /> <Check size="14" />

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}
@@ -117,19 +112,19 @@
{/if} {/if}
</div> </div>
<div <div
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 p-2 sm:px-4" class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''} style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
> >
<GPXStatistics <GPXStatistics
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'} orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/> />
{#if options.elevation.show} {#if options.elevation.show}
<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: 'maptilerStreets', basemap: 'mapboxOutdoors',
elevation: { elevation: {
show: true, show: true,
height: 170, height: 170,
@@ -90,9 +90,6 @@ export function getCleanedEmbeddingOptions(
delete cleanedOptions[key]; delete cleanedOptions[key];
} }
} }
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
delete cleanedOptions['key'];
}
return cleanedOptions; return cleanedOptions;
} }
@@ -110,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: [],
}; };
@@ -126,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,
}; };
} }
}); });
@@ -100,11 +100,7 @@
</span> </span>
</div> </div>
<div class="w-full flex flex-row flex-wrap gap-2"> <div class="w-full flex flex-row flex-wrap gap-2">
<Button <Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
class="bg-support grow"
href="https://opencollective.com/gpxstudio"
target="_blank"
>
{i18n._('menu.support_button')} {i18n._('menu.support_button')}
<span>🙏</span> <span>🙏</span>
</Button> </Button>

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);
@@ -114,10 +125,10 @@
<ContextMenu.Trigger class="grow truncate"> <ContextMenu.Trigger class="grow truncate">
<Button <Button
variant="ghost" variant="ghost"
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation === class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical' 'vertical'
? 'h-7' ? 'h-fit'
: 'h-9 px-1.5'} pointer-events-auto" : 'h-9 px-1.5 shadow-md'} pointer-events-auto"
> >
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} /> <MetadataDialog bind:open={openEditMetadata} {node} {item} />
@@ -126,7 +137,7 @@
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK} {#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div <div
class="absolute {orientation === 'vertical' class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1' ? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}" : 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical' style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom' ? 'bottom'
@@ -139,7 +150,7 @@
></div> ></div>
{/if} {/if}
<span <span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId()) : ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground' ? 'text-muted-foreground'
@@ -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,7 +16,7 @@
</script> </script>
<Button <Button
class="justify-start {className}" class="p-1 has-[>svg]:px-2 h-8 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,16 +35,5 @@
onCopy={() => trackpoint.hide?.()} onCopy={() => trackpoint.hide?.()}
class="mt-0.5" class="mt-0.5"
/> />
{#if trackpoint.fileId === undefined}
<Button
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,8 +81,9 @@
</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"
variant="outline" variant="outline"
onclick={() => { onclick={() => {
if (waypoint.fileId) { if (waypoint.fileId) {

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 '$lib/components/map/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 '$lib/components/map/gpx-layer/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(
@@ -251,7 +198,7 @@ export class GPXLayer {
source: this.fileId, source: this.fileId,
layout: { layout: {
'text-field': '»', 'text-field': '»',
'text-offset': [0, -0.06], 'text-offset': [0, -0.1],
'text-keep-upright': false, 'text-keep-upright': false,
'text-max-angle': 361, 'text-max-angle': 361,
'text-allow-overlap': true, 'text-allow-overlap': true,
@@ -261,140 +208,177 @@ export class GPXLayer {
}, },
paint: { paint: {
'text-color': 'white', 'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2, 'text-halo-width': 0.2,
'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');
} }
@@ -404,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() {
@@ -423,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');
} }
} }
@@ -470,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'])
@@ -478,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 &&
@@ -487,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,
@@ -528,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) {
@@ -738,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) {
@@ -748,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() {
map.onLoad((map_) => map_.on('style.load', this.updateBinded)); 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());
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'] = {};
@@ -232,127 +248,120 @@
<div class="flex flex-col"> <div class="flex flex-col">
{#if $customBasemapOrder.length > 0} {#if $customBasemapOrder.length > 0}
<div class="px-3 py-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <Map size="16" />
<Map size="16" /> {i18n._('layers.label.basemaps')}
{i18n._('layers.label.basemaps')} <div class="grow">
</div> <Separator />
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div> </div>
</div> </div>
<Separator />
{/if} {/if}
<div
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0} {#if $customOverlayOrder.length > 0}
<div class="px-3 py-2"> <div class="flex flex-row items-center gap-1 font-semibold mb-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2"> <Layers2 size="16" />
<Layers2 size="16" /> {i18n._('layers.label.overlays')}
{i18n._('layers.label.overlays')} <div class="grow">
<div class="grow"></div> <Separator />
</div>
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div> </div>
</div> </div>
<Separator />
{/if} {/if}
<Card.Root class="py-0 gap-0 shadow-none ring-0"> <div
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root class="py-0 gap-0 shadow-none">
<Card.Header class="p-3"> <Card.Header class="p-3">
<Card.Title class="text-sm font-semibold"> <Card.Title class="text-base">
{#if selectedLayerId} {#if selectedLayerId}
{i18n._('layers.custom_layers.edit')} {i18n._('layers.custom_layers.edit')}
{:else} {:else}
@@ -360,7 +369,7 @@
{/if} {/if}
</Card.Title> </Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="px-3 py-2"> <Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label> <Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" /> <Input bind:value={name} id="name" class="h-8" />
@@ -417,7 +426,7 @@
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
{#if selectedLayerId} {#if selectedLayerId}
<div class="mt-2 flex flex-row gap-1"> <div class="mt-2 flex flex-row gap-2">
<Button variant="outline" onclick={createLayer} class="grow"> <Button variant="outline" onclick={createLayer} class="grow">
<Save size="16" /> <Save size="16" />
{i18n._('layers.custom_layers.update')} {i18n._('layers.custom_layers.update')}

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);
@@ -121,7 +119,7 @@
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single"> <Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<Accordion.Item value="layer-selection" class="flex flex-col"> <Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger> <Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded-md mb-1.5"> <Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-3 pr-2"> <div class="py-2 pl-3 pr-2">
<LayerTree <LayerTree
layerTree={basemapTree} layerTree={basemapTree}
@@ -152,9 +150,7 @@
</Accordion.Item> </Accordion.Item>
<Accordion.Item value="overlay-opacity"> <Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger> <Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content <Accordion.Content class="flex flex-col gap-3 overflow-visible">
class="flex flex-col gap-3 overflow-visible border rounded-md px-3 py-2 mb-1.5"
>
<div class="flex flex-row gap-6 items-center"> <div class="flex flex-row gap-6 items-center">
<Label> <Label>
{i18n._('layers.custom_layers.overlay')} {i18n._('layers.custom_layers.overlay')}
@@ -164,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>
@@ -215,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
} }
@@ -233,27 +227,10 @@
<Accordion.Item value="custom-layers"> <Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger <Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
> >
<Accordion.Content <Accordion.Content>
class="flex flex-col overflow-visible border rounded-md p-0 mb-1.5" <ScrollArea>
> <CustomLayers />
<CustomLayers /> </ScrollArea>
</Accordion.Content>
</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.Content>
</Accordion.Item> </Accordion.Item>
</Accordion.Root> </Accordion.Root>

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 '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings; const { currentOverpassQueries } = settings;
@@ -28,8 +24,7 @@ export class OverpassLayer {
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

@@ -1,4 +1,5 @@
import type { LayerTreeType } from '$lib/assets/layers'; import type { LayerTreeType } from '$lib/assets/layers';
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) { export function anySelectedLayer(node: LayerTreeType) {
return ( return (
@@ -75,3 +76,5 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
}); });
return node; return node;
} }
export const customBasemapUpdate = writable(0);

View File

@@ -1,282 +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) {
if (e.originalEvent.buttons > 0) return;
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: 90,
}); });
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
);
}); });
}); });
@@ -53,7 +48,7 @@
<CustomControl class="w-[29px] h-[29px] shrink-0"> <CustomControl class="w-[29px] h-[29px] shrink-0">
<ButtonWithTooltip <ButtonWithTooltip
variant="ghost" variant="ghost"
class="w-full h-full border-none rounded-sm" class="w-full h-full"
side="left" side="left"
label={i18n._('menu.toggle_street_view')} label={i18n._('menu.toggle_street_view')}
onclick={() => { onclick={() => {

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 '$lib/components/map/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,249 +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];
let basemapStyle = basemaps.openStreetMap as maplibregl.StyleSpecification;
try {
basemapStyle = await this.get(basemapInfo);
} catch (e) {
console.error(e.message);
}
this.merge(style, basemapStyle);
if (this._maptilerKey !== '') {
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];
try {
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
} catch (e) {
// Should not happen
}
this._pastOverlays.delete(overlay);
}
} else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try {
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) {
console.error(e.message);
}
}
}
} catch (e) {}
}
updateTerrain() {
if (this._maptilerKey === '') return;
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' });
if (!response.ok) {
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
}
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';
@@ -17,9 +18,13 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<Button <Button
variant="outline" variant="outline"
class="whitespace-normal h-fit min-h-8 py-1" 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

@@ -76,7 +76,7 @@
{/if} {/if}
<Button <Button
variant="outline" variant="outline"
class="whitespace-normal h-fit min-h-8 py-1" class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) || disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)} (mergeType === MergeType.CONTENTS && !canMergeContents)}
onclick={() => { onclick={() => {

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());
@@ -185,8 +185,8 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-1.5 justify-center"> <div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-1 grow"> <div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row"> <Label for="speed" class="flex flex-row">
<Zap size="16" /> <Zap size="16" />
{#if $velocityUnits === 'speed'} {#if $velocityUnits === 'speed'}
@@ -239,7 +239,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex flex-col gap-1 grow"> <div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row"> <Label for="duration" class="flex flex-row">
<Timer size="16" /> <Timer size="16" />
{i18n._('toolbar.time.total_time')} {i18n._('toolbar.time.total_time')}
@@ -253,61 +253,57 @@
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <Label class="flex flex-row">
<Label class="flex flex-row"> <CirclePlay size="16" />
<CirclePlay size="16" /> {i18n._('toolbar.time.start')}
{i18n._('toolbar.time.start')} </Label>
</Label> <div class="flex flex-row gap-2">
<div class="flex flex-row gap-1.5"> <DatePicker
<DatePicker bind:value={startDate}
bind:value={startDate} disabled={!canUpdate}
disabled={!canUpdate} locale={i18n.lang}
locale={i18n.lang} placeholder={i18n._('toolbar.time.pick_date')}
placeholder={i18n._('toolbar.time.pick_date')} class="w-fit grow"
class="w-fit grow" onchange={() => {
onchange={() => { untrack(() => updateEnd());
untrack(() => updateEnd()); }}
}} />
/> <Input
<Input type="time"
type="time" step={1}
step={1} disabled={!canUpdate}
disabled={!canUpdate} bind:value={startTime}
bind:value={startTime} class="w-fit"
class="w-fit" onchange={() => {
onchange={() => { untrack(() => updateEnd());
untrack(() => updateEnd()); }}
}} />
/>
</div>
</div> </div>
<div class="flex flex-col gap-1"> <Label class="flex flex-row">
<Label class="flex flex-row"> <CircleStop size="16" />
<CircleStop size="16" /> {i18n._('toolbar.time.end')}
{i18n._('toolbar.time.end')} </Label>
</Label> <div class="flex flex-row gap-2">
<div class="flex flex-row gap-1.5"> <DatePicker
<DatePicker bind:value={endDate}
bind:value={endDate} disabled={!canUpdate}
disabled={!canUpdate} locale={i18n.lang}
locale={i18n.lang} placeholder={i18n._('toolbar.time.pick_date')}
placeholder={i18n._('toolbar.time.pick_date')} class="w-fit grow"
class="w-fit grow" onchange={() => {
onchange={() => { untrack(() => updateStart());
untrack(() => updateStart()); }}
}} />
/> <Input
<Input type="time"
type="time" step={1}
step={1} disabled={!canUpdate}
disabled={!canUpdate} bind:value={endTime}
bind:value={endTime} class="w-fit"
class="w-fit" onchange={() => {
onchange={() => { untrack(() => updateStart());
untrack(() => updateStart()); }}
}} />
/>
</div>
</div> </div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} {#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center"> <div class="mt-0.5 flex flex-row gap-1 items-center">
@@ -318,11 +314,11 @@
</div> </div>
{/if} {/if}
</fieldset> </fieldset>
<div class="flex flex-row gap-1.5 items-center"> <div class="flex flex-row gap-2 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canUpdate} disabled={!canUpdate}
class="grow shrink whitespace-normal h-fit min-h-8 py-1" class="grow whitespace-normal h-fit"
onclick={() => { onclick={() => {
let effectiveSpeed = getSpeed(); let effectiveSpeed = getSpeed();
if ( if (
@@ -350,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!
@@ -363,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!,
@@ -378,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,11 +10,11 @@
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();
let sliderValue = $state(50); let sliderValue = $state([50]);
const maxTolerance = 10000; const maxTolerance = 10000;
let validSelection = $derived( let validSelection = $derived(
@@ -25,7 +25,7 @@
$effect(() => { $effect(() => {
tolerance.set( tolerance.set(
minTolerance * 2 ** (sliderValue / (100 / Math.log2(maxTolerance / minTolerance))) minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
); );
}); });
@@ -36,7 +36,7 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<div class="p-2"> <div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="single" /> <Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
</div> </div>
<Label class="flex flex-row justify-between"> <Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span> <span>{i18n._('toolbar.reduce.tolerance')}</span>

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}`
@@ -191,16 +191,16 @@
<ButtonWithTooltip <ButtonWithTooltip
label={i18n._('toolbar.routing.reverse.tooltip')} label={i18n._('toolbar.routing.reverse.tooltip')}
variant="outline" variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit" class="gap-1 text-xs"
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')}
variant="outline" variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit" class="gap-1 text-xs"
disabled={!validSelection} disabled={!validSelection}
onclick={() => { onclick={() => {
const selected = selection.getOrderedSelection(); const selected = selection.getOrderedSelection();
@@ -231,19 +231,19 @@
} }
}} }}
> >
<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')}
variant="outline" variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit" class="gap-1 text-xs"
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-1 items-end justify-between"> <div class="w-full flex flex-row gap-2 items-end justify-between">
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}> <Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
{#if !validSelection} {#if !validSelection}
{i18n._('toolbar.routing.help_no_file')} {i18n._('toolbar.routing.help_no_file')}

View File

@@ -6,213 +6,37 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings; const { routing, routingProfile, privateRoads } = settings;
export type RoutingProfile = { export const brouterProfiles: { [key: string]: string } = {
engine: 'graphhopper' | 'brouter'; bike: 'Trekking-dry',
profile: string; racing_bike: 'fastbike',
}; gravel_bike: 'gravel',
mountain_bike: 'MTB',
export const routingProfiles: { [key: string]: RoutingProfile } = { foot: 'Hiking-Alpine-SAC6',
bike: { engine: 'graphhopper', profile: 'bike' }, motorcycle: 'Car-FastEco',
racing_bike: { engine: 'graphhopper', profile: 'racingbike' }, water: 'river',
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' }, railway: 'rail',
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
foot: { engine: 'graphhopper', profile: 'foot' },
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
water: { engine: 'brouter', profile: 'river' },
railway: { engine: 'brouter', profile: 'rail' },
}; };
export function route(points: Coordinates[]): Promise<TrackPoint[]> { export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (get(routing)) { if (get(routing)) {
const profile = routingProfiles[get(routingProfile)]; return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
if (profile.engine === 'graphhopper') {
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
} else {
return getBRouterRoute(points, profile.profile);
}
} else { } else {
return getIntermediatePoints(points); return getIntermediatePoints(points);
} }
} }
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating']; async function getRoute(
const hikeRatingToSACScale: { [key: string]: string } = {
'1': 'hiking',
'2': 'mountain_hiking',
'3': 'demanding_mountain_hiking',
'4': 'alpine_hiking',
'5': 'demanding_alpine_hiking',
'6': 'difficult_alpine_hiking',
};
const mtbRatingToScale: { [key: string]: string } = {
'1': '0',
'2': '1',
'3': '2',
'4': '3',
'5': '4',
'6': '5',
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
racingbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
gravelbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
mtb: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
foot: {
priority: [
{
if: 'foot_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
motorcycle: {
priority: [
{
if: 'road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
};
async function getGraphHopperRoute(
points: Coordinates[], points: Coordinates[],
graphHopperProfile: string, brouterProfile: string,
privateRoads: boolean privateRoads: boolean
): Promise<TrackPoint[]> { ): Promise<TrackPoint[]> {
let response = await fetch('https://graphhopper.gpx.studio/route', { let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
details: graphhopperDetails,
custom_model: privateRoads
? {}
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
}),
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
}
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
extensions: {},
})
);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
}
}
}
}
}
return route;
}
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url); let response = await fetch(url);
// Check if the response is ok
if (!response.ok) { if (!response.ok) {
const error = await response.text(); throw new Error(`${await response.text()}`);
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
} }
let geojson = await response.json(); let geojson = await response.json();
@@ -228,13 +52,14 @@ async function getBRouterRoute(
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {}; let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) { for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
route.push( route.push(
new TrackPoint({ new TrackPoint({
attributes: { attributes: {
lat: coordinates[i][1], lat: coord[1],
lon: coordinates[i][0], lon: coord[0],
}, },
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0), ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
}) })
); );

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,81 +112,62 @@
$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>
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
<fieldset class="flex flex-col gap-1.5"> <fieldset class="flex flex-col gap-2">
<div class="flex flex-col gap-1"> <Label for="name">{i18n._('menu.metadata.name')}</Label>
<Label for="name">{i18n._('menu.metadata.name')}</Label> <Input
<Input bind:value={name}
bind:value={name} id="name"
id="name" class="font-semibold h-8"
class="font-semibold" disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
class="w-full h-8"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
/> >
</div> {#if symbolKey}
<div class="flex flex-col gap-1"> {i18n._(`gpx.symbol.${symbolKey}`)}
<Label for="description">{i18n._('menu.metadata.description')}</Label> {:else}
<Textarea {sym}
bind:value={description} {/if}
id="description" </Select.Trigger>
disabled={!canCreate && !$selectedWaypoint} <Select.Content class="max-h-60 overflow-y-scroll">
class="min-h-8 h-8 py-1 px-3 text-sm" {#each sortedSymbols as [key, symbol]}
/> <Select.Item value={symbol.value}>
</div> <span>
<div class="flex flex-col gap-1"> {#if symbol.icon}
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label> {@const Component = symbol.icon}
<Select.Root bind:value={sym} type="single"> <Component size="14" class="inline-block align-sub mr-0.5" />
<Select.Trigger {:else}
id="symbol" <span class="w-4 inline-block"></span>
class="w-full"
disabled={!canCreate && !$selectedWaypoint}
>
<span class="flex flex-row gap-1.5 items-center">
{#if symbolKey}
{#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
{/if} {/if}
{i18n._(`gpx.symbol.${symbolKey}`)} {i18n._(`gpx.symbol.${key}`)}
{:else} </span>
{sym} </Select.Item>
{/if} {/each}
</span> </Select.Content>
</Select.Trigger> </Select.Root>
<Select.Content class="max-h-60"> <Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
{#each sortedSymbols as [key, symbol]} <Input
<Select.Item value={symbol.value}> bind:value={link}
<span> id="link"
{#if symbol.icon} class="h-8"
{@const Component = symbol.icon} disabled={!canCreate && !$selectedWaypoint}
<Component size="14" class="inline-block align-sub" /> />
{:else} <div class="flex flex-row gap-2">
<span class="w-4 inline-block"></span> <div class="grow flex flex-col gap-2">
{/if}
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1">
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="flex flex-row gap-1.5">
<div class="grow flex flex-col gap-1">
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label> <Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
<Input <Input
bind:value={latitude} bind:value={latitude}
@@ -240,7 +180,7 @@
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
<div class="grow flex flex-col gap-1"> <div class="grow flex flex-col gap-2">
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label> <Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
<Input <Input
bind:value={longitude} bind:value={longitude}
@@ -255,11 +195,11 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<div class="flex flex-row gap-1.5 items-center"> <div class="flex flex-row gap-2 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
class="grow shrink h-fit min-h-8 whitespace-normal py-1" class="grow whitespace-normal h-fit"
onclick={createOrUpdateWaypoint} onclick={createOrUpdateWaypoint}
> >
{#if $selectedWaypoint} {#if $selectedWaypoint}
@@ -270,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

@@ -13,15 +13,10 @@
<AccordionPrimitive.Content <AccordionPrimitive.Content
bind:ref bind:ref
data-slot="accordion-content" data-slot="accordion-content"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden" class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps} {...restProps}
> >
<div <div class={cn("pb-4 pt-0", className)}>
class={cn(
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className
)}
>
{@render children?.()} {@render children?.()}
</div> </div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>

View File

@@ -12,6 +12,6 @@
<AccordionPrimitive.Item <AccordionPrimitive.Item
bind:ref bind:ref
data-slot="accordion-item" data-slot="accordion-item"
class={cn("not-last:border-b", className)} class={cn("border-b last:border-b-0", className)}
{...restProps} {...restProps}
/> />

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui"; import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -20,13 +19,14 @@
data-slot="accordion-trigger" data-slot="accordion-trigger"
bind:ref bind:ref
class={cn( class={cn(
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" /> <ChevronDownIcon
<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" /> class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>

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