Compare commits
1 Commits
dev
...
c9472e10be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9472e10be |
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
|||||||
open_collective: gpxstudio
|
ko_fi: gpxstudio
|
||||||
2
.github/workflows/deploy.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
2
LICENSE
@@ -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
|
||||||
|
|||||||
48
README.md
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 ."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
686
gpx/src/gpx.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "gpx.studio",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
|
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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}" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
website/src/lib/assets/img/docs/getting-started/interface.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 596 KiB |
BIN
website/src/lib/assets/img/home/cyclosm.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
website/src/lib/assets/img/home/ign.png
Normal file
|
After Width: | Height: | Size: 5.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
BIN
website/src/lib/assets/img/home/map.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
website/src/lib/assets/img/home/mapbox-outdoors.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
website/src/lib/assets/img/home/mapbox-satellite.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
BIN
website/src/lib/assets/img/home/routing.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 448 KiB |
@@ -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">© 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';
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={className}
|
class={className}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : ''}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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_);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>());
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}° {poi.item.lon.toFixed(6)}°
|
<div class="text-muted-foreground text-xs font-normal">
|
||||||
|
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||