mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-02-06 08:23:09 +00:00
Compare commits
40 Commits
7e60a8fc33
...
maplibre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c1500aad | ||
|
|
bfd0d90abc | ||
|
|
dba01e1826 | ||
|
|
2189c76edd | ||
|
|
6f8c9d66db | ||
|
|
9408ce10c7 | ||
|
|
9895c3c304 | ||
|
|
0ab3b77db8 | ||
|
|
d13e7e7a0a | ||
|
|
e96b544a75 | ||
|
|
375204c379 | ||
|
|
d76c03af4f | ||
|
|
200a6586ba | ||
|
|
f0f1ecb2df | ||
|
|
2eb6ef6f03 | ||
|
|
f7c0805161 | ||
|
|
4e18e3c8a0 | ||
|
|
59f31caf26 | ||
|
|
f24956c58d | ||
|
|
9019317e5c | ||
|
|
2a0227c1de | ||
|
|
f70db42b91 | ||
|
|
9cd87742f0 | ||
|
|
5dcb93ca5d | ||
|
|
256d62b29b | ||
|
|
595ea8e2d3 | ||
|
|
d3e733aa3e | ||
|
|
a011768d2d | ||
|
|
4b45b5d716 | ||
|
|
ebe9681c12 | ||
|
|
51c85e4cd5 | ||
|
|
2e171dfbee | ||
|
|
a6a3917986 | ||
|
|
21f2448213 | ||
|
|
e7a1d0488b | ||
|
|
22b8e0edb4 | ||
|
|
d062a38e8f | ||
|
|
affa59130f | ||
|
|
3c816567bc | ||
|
|
e92e48ffde |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Create env file
|
||||
run: |
|
||||
touch website/.env
|
||||
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
||||
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||
cat website/.env
|
||||
|
||||
- name: Build website
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
||||
website/src/lib/components/ui
|
||||
website/src/lib/docs/**/*.mdx
|
||||
**/*.webmanifest
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 gpx.studio
|
||||
Copyright (c) 2026 gpx.studio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
46
README.md
46
README.md
@@ -27,8 +27,8 @@ Any help is greatly appreciated!
|
||||
|
||||
The code is split into two parts:
|
||||
|
||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||
- `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.
|
||||
|
||||
@@ -42,11 +42,11 @@ npm run build
|
||||
|
||||
### Running the website
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
cd website
|
||||
echo PUBLIC_MAPBOX_TOKEN={YOUR_MAPBOX_TOKEN} >> .env
|
||||
echo PUBLIC_MAPTILER_KEY={YOUR_MAPTILER_KEY} >> .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -55,25 +55,25 @@ npm run dev
|
||||
|
||||
This project has been made possible thanks to the following open source projects:
|
||||
|
||||
- Development:
|
||||
- [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
|
||||
- Design:
|
||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [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
|
||||
- Mapping:
|
||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
- Development:
|
||||
- [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
|
||||
- Design:
|
||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||
- Logic:
|
||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||
- [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
|
||||
- Mapping:
|
||||
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive maps
|
||||
- [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
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"lint": "prettier --check . --config ../.prettierrc && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc"
|
||||
}
|
||||
}
|
||||
|
||||
547
gpx/src/gpx.ts
547
gpx/src/gpx.ts
@@ -1,4 +1,5 @@
|
||||
import { ramerDouglasPeucker } from './simplify';
|
||||
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
|
||||
import {
|
||||
Coordinates,
|
||||
GPXFileAttributes,
|
||||
@@ -36,7 +37,6 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
|
||||
abstract getNumberOfTrackPoints(): number;
|
||||
abstract getStartTimestamp(): Date | undefined;
|
||||
abstract getEndTimestamp(): Date | undefined;
|
||||
abstract getStatistics(): GPXStatistics;
|
||||
abstract getSegments(): TrackSegment[];
|
||||
abstract getTrackPoints(): TrackPoint[];
|
||||
|
||||
@@ -76,14 +76,6 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
|
||||
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[] {
|
||||
return this.children.flatMap((child) => child.getSegments());
|
||||
}
|
||||
@@ -148,7 +140,9 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
},
|
||||
},
|
||||
};
|
||||
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
|
||||
this.wpt = gpx.wpt
|
||||
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
|
||||
: [];
|
||||
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
|
||||
if (gpx.rte && gpx.rte.length > 0) {
|
||||
this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route)));
|
||||
@@ -186,9 +180,6 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
segment._data['segmentIndex'] = segmentIndex;
|
||||
});
|
||||
});
|
||||
this.wpt.forEach((waypoint, waypointIndex) => {
|
||||
waypoint._data['index'] = waypointIndex;
|
||||
});
|
||||
}
|
||||
|
||||
get children(): Array<Track> {
|
||||
@@ -209,8 +200,16 @@ 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 {
|
||||
return this.trk
|
||||
const style = this.trk
|
||||
.map((track) => track.getStyle())
|
||||
.reduce(
|
||||
(acc, style) => {
|
||||
@@ -220,8 +219,6 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
!acc.color.includes(style['gpx_style:color'])
|
||||
) {
|
||||
acc.color.push(style['gpx_style:color']);
|
||||
} else if (defaultColor && !acc.color.includes(defaultColor)) {
|
||||
acc.color.push(defaultColor);
|
||||
}
|
||||
if (
|
||||
style &&
|
||||
@@ -245,6 +242,10 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
width: [],
|
||||
}
|
||||
);
|
||||
if (style.color.length === 0 && defaultColor) {
|
||||
style.color.push(defaultColor);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
clone(): GPXFile {
|
||||
@@ -807,7 +808,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
|
||||
super();
|
||||
if (segment) {
|
||||
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
|
||||
this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index));
|
||||
if (segment.hasOwnProperty('_data')) {
|
||||
this._data = segment._data;
|
||||
}
|
||||
@@ -819,12 +820,12 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
_computeStatistics(): GPXStatistics {
|
||||
let statistics = new GPXStatistics();
|
||||
|
||||
statistics.local.points = this.trkpt.map((point) => point);
|
||||
statistics.global.length = this.trkpt.length;
|
||||
statistics.local.points = this.trkpt.slice(0);
|
||||
statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics());
|
||||
|
||||
const points = this.trkpt;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
points[i]._data['index'] = i;
|
||||
|
||||
// distance
|
||||
let dist = 0;
|
||||
if (i > 0) {
|
||||
@@ -833,19 +834,18 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
statistics.global.distance.total += dist;
|
||||
}
|
||||
|
||||
statistics.local.distance.total.push(statistics.global.distance.total);
|
||||
statistics.local.data[i].distance.total = statistics.global.distance.total;
|
||||
|
||||
// time
|
||||
if (points[i].time === undefined) {
|
||||
statistics.local.time.total.push(0);
|
||||
statistics.local.data[i].time.total = 0;
|
||||
} else {
|
||||
if (statistics.global.time.start === undefined) {
|
||||
statistics.global.time.start = points[i].time;
|
||||
}
|
||||
statistics.global.time.end = points[i].time;
|
||||
statistics.local.time.total.push(
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000
|
||||
);
|
||||
statistics.local.data[i].time.total =
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000;
|
||||
}
|
||||
|
||||
// speed
|
||||
@@ -860,8 +860,8 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
statistics.local.distance.moving.push(statistics.global.distance.moving);
|
||||
statistics.local.time.moving.push(statistics.global.time.moving);
|
||||
statistics.local.data[i].distance.moving = statistics.global.distance.moving;
|
||||
statistics.local.data[i].time.moving = statistics.global.time.moving;
|
||||
|
||||
// bounds
|
||||
statistics.global.bounds.southWest.lat = Math.min(
|
||||
@@ -961,13 +961,22 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
||||
: 0;
|
||||
|
||||
statistics.local.speed = timeWindowSmoothing(points, 10000, (start, end) =>
|
||||
points[start].time && points[end].time
|
||||
? (3600 *
|
||||
(statistics.local.distance.total[end] -
|
||||
statistics.local.distance.total[start])) /
|
||||
Math.max((points[end].time.getTime() - points[start].time.getTime()) / 1000, 1)
|
||||
: undefined
|
||||
timeWindowSmoothing(
|
||||
points,
|
||||
10000,
|
||||
(start, end) =>
|
||||
points[start].time && points[end].time
|
||||
? (3600 *
|
||||
(statistics.local.data[end].distance.total -
|
||||
statistics.local.data[start].distance.total)) /
|
||||
Math.max(
|
||||
(points[end].time.getTime() - points[start].time.getTime()) / 1000,
|
||||
1
|
||||
)
|
||||
: undefined,
|
||||
(value, index) => {
|
||||
statistics.local.data[index].speed = value;
|
||||
}
|
||||
);
|
||||
|
||||
return statistics;
|
||||
@@ -987,53 +996,65 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
let cumulEle = 0;
|
||||
let currentStart = start;
|
||||
let currentEnd = start;
|
||||
let smoothedEle = distanceWindowSmoothing(start, end + 1, statistics, 0.1, (s, e) => {
|
||||
for (let i = currentStart; i < s; i++) {
|
||||
cumulEle -= this.trkpt[i].ele ?? 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (let i = currentEnd; i <= e; i++) {
|
||||
cumulEle += this.trkpt[i].ele ?? 0;
|
||||
}
|
||||
currentStart = s;
|
||||
currentEnd = e + 1;
|
||||
return cumulEle / (e - s + 1);
|
||||
});
|
||||
smoothedEle[0] = this.trkpt[start].ele ?? 0;
|
||||
smoothedEle[smoothedEle.length - 1] = this.trkpt[end].ele ?? 0;
|
||||
|
||||
for (let j = start; j < end; j++) {
|
||||
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
||||
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
||||
|
||||
const ele = smoothedEle[j - start + 1] - smoothedEle[j - start];
|
||||
if (ele > 0) {
|
||||
statistics.global.elevation.gain += ele;
|
||||
} else if (ele < 0) {
|
||||
statistics.global.elevation.loss -= ele;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
||||
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
||||
|
||||
let slope = [];
|
||||
let length = [];
|
||||
for (let i = 0; i < simplified.length - 1; i++) {
|
||||
let start = simplified[i].point._data.index;
|
||||
let end = simplified[i + 1].point._data.index;
|
||||
let dist =
|
||||
statistics.local.distance.total[end] - statistics.local.distance.total[start];
|
||||
statistics.local.data[end].distance.total -
|
||||
statistics.local.data[start].distance.total;
|
||||
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++) {
|
||||
slope.push((0.1 * ele) / dist);
|
||||
length.push(dist);
|
||||
statistics.local.data[j].slope.segment = (0.1 * ele) / dist;
|
||||
statistics.local.data[j].slope.length = dist;
|
||||
}
|
||||
}
|
||||
|
||||
statistics.local.slope.segment = slope;
|
||||
statistics.local.slope.length = length;
|
||||
statistics.local.slope.at = distanceWindowSmoothing(
|
||||
distanceWindowSmoothing(
|
||||
0,
|
||||
this.trkpt.length,
|
||||
statistics,
|
||||
@@ -1041,8 +1062,12 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
(start, end) => {
|
||||
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
|
||||
const dist =
|
||||
statistics.local.distance.total[end] - statistics.local.distance.total[start];
|
||||
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;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1292,13 +1317,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
) {
|
||||
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
|
||||
let statistics = og._computeStatistics();
|
||||
let trkpt = withArtificialTimestamps(
|
||||
og.trkpt,
|
||||
totalTime,
|
||||
lastPoint,
|
||||
startTime,
|
||||
statistics.local.slope.at
|
||||
);
|
||||
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics);
|
||||
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
|
||||
}
|
||||
|
||||
@@ -1307,6 +1326,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyExtensions: Record<string, string> = {};
|
||||
export class TrackPoint {
|
||||
[immerable] = true;
|
||||
|
||||
@@ -1317,7 +1337,7 @@ export class TrackPoint {
|
||||
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) {
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) {
|
||||
this.attributes = point.attributes;
|
||||
this.ele = point.ele;
|
||||
this.time = point.time;
|
||||
@@ -1325,6 +1345,9 @@ export class TrackPoint {
|
||||
if (point.hasOwnProperty('_data')) {
|
||||
this._data = point._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1398,7 +1421,7 @@ export class TrackPoint {
|
||||
this.extensions['gpxtpx:TrackPointExtension'] &&
|
||||
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
|
||||
? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']
|
||||
: {};
|
||||
: emptyExtensions;
|
||||
}
|
||||
|
||||
toTrackPointType(exclude: string[] = []): TrackPointType {
|
||||
@@ -1468,11 +1491,18 @@ export class TrackPoint {
|
||||
|
||||
clone(): TrackPoint {
|
||||
return new TrackPoint({
|
||||
attributes: cloneJSON(this.attributes),
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
extensions: cloneJSON(this.extensions),
|
||||
_data: cloneJSON(this._data),
|
||||
extensions: this.extensions ? cloneJSON(this.extensions) : undefined,
|
||||
_data: {
|
||||
index: this._data?.index,
|
||||
anchor: this._data?.anchor,
|
||||
zoom: this._data?.zoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1491,7 +1521,7 @@ export class Waypoint {
|
||||
type?: string;
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) {
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) {
|
||||
this.attributes = waypoint.attributes;
|
||||
this.ele = waypoint.ele;
|
||||
this.time = waypoint.time;
|
||||
@@ -1510,6 +1540,9 @@ export class Waypoint {
|
||||
if (waypoint.hasOwnProperty('_data')) {
|
||||
this._data = waypoint._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1557,7 +1590,10 @@ export class Waypoint {
|
||||
|
||||
clone(): Waypoint {
|
||||
return new Waypoint({
|
||||
attributes: cloneJSON(this.attributes),
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
name: this.name,
|
||||
@@ -1606,305 +1642,6 @@ 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: {
|
||||
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: {
|
||||
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.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;
|
||||
export function distance(
|
||||
coord1: TrackPoint | Coordinates,
|
||||
@@ -1938,9 +1675,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
|
||||
return 0;
|
||||
}
|
||||
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
|
||||
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
|
||||
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
|
||||
let x1 = statistics.local.data[point1._data.index].distance.total * 1000;
|
||||
let x2 = statistics.local.data[point2._data.index].distance.total * 1000;
|
||||
let x3 = statistics.local.data[point3._data.index].distance.total * 1000;
|
||||
let y1 = point1.ele;
|
||||
let y2 = point2.ele;
|
||||
let y3 = point3.ele;
|
||||
@@ -1959,10 +1696,9 @@ function windowSmoothing(
|
||||
right: number,
|
||||
distance: (index1: number, index2: number) => number,
|
||||
window: number,
|
||||
compute: (start: number, end: number) => number
|
||||
): number[] {
|
||||
let result = [];
|
||||
|
||||
compute: (start: number, end: number) => number,
|
||||
callback: (value: number, index: number) => void
|
||||
): void {
|
||||
let start = left;
|
||||
for (var i = left; i < right; i++) {
|
||||
while (start + 1 < i && distance(start, i) > window) {
|
||||
@@ -1972,10 +1708,8 @@ function windowSmoothing(
|
||||
while (end < right && distance(i, end) <= window) {
|
||||
end++;
|
||||
}
|
||||
result.push(compute(start, end - 1));
|
||||
callback(compute(start, end - 1), i);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function distanceWindowSmoothing(
|
||||
@@ -1983,30 +1717,35 @@ function distanceWindowSmoothing(
|
||||
right: number,
|
||||
statistics: GPXStatistics,
|
||||
window: number,
|
||||
compute: (start: number, end: number) => number
|
||||
): number[] {
|
||||
return windowSmoothing(
|
||||
compute: (start: number, end: number) => number,
|
||||
callback: (value: number, index: number) => void
|
||||
): void {
|
||||
windowSmoothing(
|
||||
left,
|
||||
right,
|
||||
(index1, index2) =>
|
||||
statistics.local.distance.total[index2] - statistics.local.distance.total[index1],
|
||||
statistics.local.data[index2].distance.total -
|
||||
statistics.local.data[index1].distance.total,
|
||||
window,
|
||||
compute
|
||||
compute,
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
function timeWindowSmoothing(
|
||||
points: TrackPoint[],
|
||||
window: number,
|
||||
compute: (start: number, end: number) => number
|
||||
): number[] {
|
||||
return windowSmoothing(
|
||||
compute: (start: number, end: number) => number,
|
||||
callback: (value: number, index: number) => void
|
||||
): void {
|
||||
windowSmoothing(
|
||||
0,
|
||||
points.length,
|
||||
(index1, index2) =>
|
||||
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window,
|
||||
window,
|
||||
compute
|
||||
compute,
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2058,14 +1797,14 @@ function withArtificialTimestamps(
|
||||
totalTime: number,
|
||||
lastPoint: TrackPoint | undefined,
|
||||
startTime: Date,
|
||||
slope: number[]
|
||||
statistics: GPXStatistics
|
||||
): TrackPoint[] {
|
||||
let weight = [];
|
||||
let totalWeight = 0;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
|
||||
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i])));
|
||||
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at)));
|
||||
weight.push(w);
|
||||
totalWeight += w;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './gpx';
|
||||
export * from './statistics';
|
||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
@@ -59,13 +59,13 @@ function ramerDouglasPeuckerRecursive(
|
||||
}
|
||||
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point1: TrackPoint | Coordinates,
|
||||
point2: TrackPoint | Coordinates,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point1 instanceof TrackPoint ? point1.getCoordinates() : point1,
|
||||
point2 instanceof TrackPoint ? point2.getCoordinates() : point2,
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
391
gpx/src/statistics.ts
Normal file
391
gpx/src/statistics.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN
|
||||
PUBLIC_MAPTILER_KEY=YOUR_MAPTILER_KEY
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
1245
website/package-lock.json
generated
1245
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
@@ -23,15 +23,14 @@
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"@types/mapbox__tilebelt": "^1.0.4",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/png.js": "^0.2.3",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"bits-ui": "^2.12.0",
|
||||
"bits-ui": "^2.14.4",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-svelte": "^3.9.1",
|
||||
@@ -62,11 +61,10 @@
|
||||
"dependencies": {
|
||||
"@docsearch/js": "^3.9.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||
"@mapbox/sphericalmercator": "^2.0.1",
|
||||
"@mapbox/tilebelt": "^2.0.2",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"chart.js": "^4.4.9",
|
||||
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
@@ -74,9 +72,8 @@
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"mapbox-gl": "^3.16.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"png.js": "^0.2.1",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
|
||||
@@ -1,126 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
--support: rgb(220 15 130);
|
||||
--link: rgb(0 110 180);
|
||||
--selection: hsl(240 4.8% 93%);
|
||||
--support: rgb(220 15 130);
|
||||
--link: rgb(0 110 180);
|
||||
--selection: hsl(240 4.8% 93%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
--support: rgb(255 110 190);
|
||||
--link: rgb(80 190 255);
|
||||
--selection: hsl(240 3.7% 22%);
|
||||
--support: rgb(255 110 190);
|
||||
--link: rgb(80 190 255);
|
||||
--selection: hsl(240 3.7% 22%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Radius (for rounded-*) */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
/* Radius (for rounded-*) */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-support: var(--support);
|
||||
--color-link: var(--link);
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-support: var(--support);
|
||||
--color-link: var(--link);
|
||||
|
||||
--breakpoint-xs: 540px;
|
||||
--breakpoint-xs: 540px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
}
|
||||
},
|
||||
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
|
||||
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
@@ -22,15 +22,18 @@ import {
|
||||
Binoculars,
|
||||
Toilet,
|
||||
} from 'lucide-static';
|
||||
import { type StyleSpecification } from 'mapbox-gl';
|
||||
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'maplibre-gl';
|
||||
import ignFrTopo from './custom/ign-fr-topo.json';
|
||||
import ignFrPlan from './custom/ign-fr-plan.json';
|
||||
import ignFrSatellite from './custom/ign-fr-satellite.json';
|
||||
import bikerouterGravel from './custom/bikerouter-gravel.json';
|
||||
|
||||
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
|
||||
|
||||
export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
|
||||
mapboxSatellite: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
|
||||
openStreetMap: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -368,6 +371,42 @@ export const overlays: { [key: string]: string | 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: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -737,8 +776,9 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
|
||||
export const basemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -799,8 +839,10 @@ export const overlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: true,
|
||||
waymarkedTrailsWinter: true,
|
||||
},
|
||||
cyclOSMlite: true,
|
||||
bikerouterGravel: true,
|
||||
cyclOSMlite: true,
|
||||
mapterhornHillshade: true,
|
||||
openRailwayMap: true,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -869,7 +911,7 @@ export const overpassTree: LayerTreeType = {
|
||||
};
|
||||
|
||||
// Default basemap used
|
||||
export const defaultBasemap = 'mapboxOutdoors';
|
||||
export const defaultBasemap = 'maptilerTopo';
|
||||
|
||||
// Default overlays used (none)
|
||||
export const defaultOverlays: LayerTreeType = {
|
||||
@@ -883,8 +925,10 @@ export const defaultOverlays: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -956,8 +1000,9 @@ export const defaultOverpassQueries: LayerTreeType = {
|
||||
export const defaultBasemapTree: LayerTreeType = {
|
||||
basemaps: {
|
||||
world: {
|
||||
mapboxOutdoors: true,
|
||||
mapboxSatellite: true,
|
||||
maptilerTopo: true,
|
||||
maptilerOutdoors: true,
|
||||
maptilerSatellite: true,
|
||||
openStreetMap: true,
|
||||
openTopoMap: true,
|
||||
openHikingMap: true,
|
||||
@@ -1018,8 +1063,10 @@ export const defaultOverlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -1094,7 +1141,7 @@ export type CustomLayer = {
|
||||
maxZoom: number;
|
||||
layerType: 'basemap' | 'overlay';
|
||||
resourceType: 'raster' | 'vector';
|
||||
value: string | {};
|
||||
value: string | maplibregl.StyleSpecification;
|
||||
};
|
||||
|
||||
type OverpassQueryData = {
|
||||
@@ -1411,3 +1458,16 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
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';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2025 gpx.studio
|
||||
MIT © 2026 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
orientation,
|
||||
panelSize,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
panelSize: number;
|
||||
} = $props();
|
||||
|
||||
let statistics = $derived(
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -42,15 +42,15 @@
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
<WithUnits value={statistics.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" />
|
||||
<WithUnits value={statistics.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
@@ -64,13 +64,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits
|
||||
value={statistics.global.speed.moving}
|
||||
type="speed"
|
||||
showUnits={false}
|
||||
/>
|
||||
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
@@ -83,9 +79,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<WithUnits value={statistics.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
...others
|
||||
}: {
|
||||
iconOnly?: boolean;
|
||||
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -19,10 +19,10 @@
|
||||
alt="Logo of gpx.studio."
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'mapbox'}
|
||||
{:else if company === 'maptiler'}
|
||||
<img
|
||||
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of Maptiler."
|
||||
{...others}
|
||||
/>
|
||||
{:else if company === 'github'}
|
||||
|
||||
@@ -538,6 +538,7 @@
|
||||
let targetInput =
|
||||
e &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
(e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' ||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced';
|
||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||
</script>
|
||||
|
||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" />
|
||||
<enhanced:img
|
||||
src={waymarkedMap}
|
||||
alt="Waymarked Trails map screenshot."
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||
@@ -28,12 +28,14 @@
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
showControls = true,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
hoveredPoint: Writable<Coordinates | null>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
showControls?: boolean;
|
||||
@@ -47,6 +49,7 @@
|
||||
elevationProfile = new ElevationProfile(
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
canvas,
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
getTemperatureWithUnits,
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units';
|
||||
import Chart from 'chart.js/auto';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import Chart, {
|
||||
type ChartEvent,
|
||||
type ChartOptions,
|
||||
type ScriptableLineSegmentContext,
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
import { get, type Readable, type Writable } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
|
||||
@@ -27,22 +30,37 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
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
|
||||
|
||||
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 {
|
||||
private _chart: Chart | null = null;
|
||||
private _canvas: HTMLCanvasElement;
|
||||
private _overlay: HTMLCanvasElement;
|
||||
private _marker: mapboxgl.Marker | null = null;
|
||||
private _dragging = false;
|
||||
private _panning = false;
|
||||
|
||||
private _gpxStatistics: Readable<GPXStatistics>;
|
||||
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
private _hoveredPoint: Writable<Coordinates | null>;
|
||||
private _additionalDatasets: Readable<string[]>;
|
||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
|
||||
constructor(
|
||||
gpxStatistics: Readable<GPXStatistics>,
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||
hoveredPoint: Writable<Coordinates | null>,
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -50,17 +68,12 @@ export class ElevationProfile {
|
||||
) {
|
||||
this._gpxStatistics = gpxStatistics;
|
||||
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||
this._hoveredPoint = hoveredPoint;
|
||||
this._additionalDatasets = additionalDatasets;
|
||||
this._elevationFill = elevationFill;
|
||||
this._canvas = canvas;
|
||||
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) => {
|
||||
Chart.register(module.default);
|
||||
this.initialize();
|
||||
@@ -90,7 +103,7 @@ export class ElevationProfile {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
let options = {
|
||||
let options: ChartOptions<'line'> = {
|
||||
animation: false,
|
||||
parsing: false,
|
||||
maintainAspectRatio: false,
|
||||
@@ -98,8 +111,8 @@ export class ElevationProfile {
|
||||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
callback: function (value: number | string) {
|
||||
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
},
|
||||
align: 'inner',
|
||||
maxRotation: 0,
|
||||
@@ -108,8 +121,8 @@ export class ElevationProfile {
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return getElevationWithUnits(value, false);
|
||||
callback: function (value: number | string) {
|
||||
return getElevationWithUnits(value as number, false);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -140,17 +153,13 @@ export class ElevationProfile {
|
||||
title: () => {
|
||||
return '';
|
||||
},
|
||||
label: (context: Chart.TooltipContext) => {
|
||||
let point = context.raw;
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
let point = context.raw as ElevationProfilePoint;
|
||||
if (context.datasetIndex === 0) {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
if (this._dragging) {
|
||||
this._marker.remove();
|
||||
} else {
|
||||
this._marker.setLngLat(point.coordinates);
|
||||
this._marker.addTo(map_);
|
||||
}
|
||||
if (this._dragging) {
|
||||
this._hoveredPoint.set(null);
|
||||
} else {
|
||||
this._hoveredPoint.set(point.coordinates);
|
||||
}
|
||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||
} else if (context.datasetIndex === 1) {
|
||||
@@ -165,10 +174,10 @@ export class ElevationProfile {
|
||||
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||
}
|
||||
},
|
||||
afterBody: (contexts: Chart.TooltipContext[]) => {
|
||||
afterBody: (contexts: TooltipItem<'line'>[]) => {
|
||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||
if (context.length === 0) return;
|
||||
let point = context[0].raw;
|
||||
let point = context[0].raw as ElevationProfilePoint;
|
||||
let slope = {
|
||||
at: point.slope.at.toFixed(1),
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
@@ -227,6 +236,7 @@ export class ElevationProfile {
|
||||
onPanStart: () => {
|
||||
this._panning = true;
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
return true;
|
||||
},
|
||||
onPanComplete: () => {
|
||||
this._panning = false;
|
||||
@@ -238,13 +248,13 @@ export class ElevationProfile {
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
|
||||
if (!this._chart) {
|
||||
return false;
|
||||
}
|
||||
const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0;
|
||||
if (
|
||||
event.deltaY < 0 &&
|
||||
Math.abs(
|
||||
this._chart.getInitialScaleBounds().x.max /
|
||||
this._chart.options.plugins.zoom.limits.x.minRange -
|
||||
this._chart.getZoomLevel()
|
||||
) < 0.01
|
||||
Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01
|
||||
) {
|
||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||
return false;
|
||||
@@ -262,7 +272,6 @@ export class ElevationProfile {
|
||||
},
|
||||
},
|
||||
},
|
||||
stacked: false,
|
||||
onResize: () => {
|
||||
this.updateOverlay();
|
||||
},
|
||||
@@ -270,7 +279,7 @@ export class ElevationProfile {
|
||||
|
||||
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
||||
datasets.forEach((id) => {
|
||||
options.scales[`y${id}`] = {
|
||||
options.scales![`y${id}`] = {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {
|
||||
@@ -291,12 +300,9 @@ export class ElevationProfile {
|
||||
{
|
||||
id: 'toggleMarker',
|
||||
events: ['mouseout'],
|
||||
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
|
||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
if (args.event.type === 'mouseout') {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
this._marker.remove();
|
||||
}
|
||||
this._hoveredPoint.set(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -305,7 +311,7 @@ export class ElevationProfile {
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex = 0;
|
||||
const getIndex = (evt) => {
|
||||
const getIndex = (evt: PointerEvent) => {
|
||||
if (!this._chart) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -323,22 +329,22 @@ export class ElevationProfile {
|
||||
if (evt.x - rect.left <= this._chart.chartArea.left) {
|
||||
return 0;
|
||||
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
|
||||
return get(this._gpxStatistics).local.points.length - 1;
|
||||
return this._chart.data.datasets[0].data.length - 1;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let point = points.find((point) => point.element.raw);
|
||||
const point = points.find((point) => (point.element as any).raw);
|
||||
if (point) {
|
||||
return point.element.raw.index;
|
||||
return (point.element as any).raw.index;
|
||||
} else {
|
||||
return points[0].index;
|
||||
}
|
||||
};
|
||||
|
||||
let dragStarted = false;
|
||||
const onMouseDown = (evt) => {
|
||||
const onMouseDown = (evt: PointerEvent) => {
|
||||
if (evt.shiftKey) {
|
||||
// Panning interaction
|
||||
return;
|
||||
@@ -347,7 +353,7 @@ export class ElevationProfile {
|
||||
this._canvas.style.cursor = 'col-resize';
|
||||
startIndex = getIndex(evt);
|
||||
};
|
||||
const onMouseMove = (evt) => {
|
||||
const onMouseMove = (evt: PointerEvent) => {
|
||||
if (dragStarted) {
|
||||
this._dragging = true;
|
||||
endIndex = getIndex(evt);
|
||||
@@ -356,7 +362,7 @@ export class ElevationProfile {
|
||||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
this._slicedGPXStatistics.set([
|
||||
get(this._gpxStatistics).slice(
|
||||
get(this._gpxStatistics).sliced(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
@@ -367,7 +373,7 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseUp = (evt) => {
|
||||
const onMouseUp = (evt: PointerEvent) => {
|
||||
dragStarted = false;
|
||||
this._dragging = false;
|
||||
this._canvas.style.cursor = '';
|
||||
@@ -386,85 +392,99 @@ export class ElevationProfile {
|
||||
return;
|
||||
}
|
||||
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] = {
|
||||
label: i18n._('quantities.elevation'),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
data: datasets[0],
|
||||
normalized: true,
|
||||
fill: 'start',
|
||||
order: 1,
|
||||
segment: {},
|
||||
};
|
||||
this._chart.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,
|
||||
};
|
||||
}),
|
||||
data: datasets[1],
|
||||
normalized: true,
|
||||
yAxisID: 'yspeed',
|
||||
};
|
||||
this._chart.data.datasets[2] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getHeartRate(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[2],
|
||||
normalized: true,
|
||||
yAxisID: 'yhr',
|
||||
};
|
||||
this._chart.data.datasets[3] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getCadence(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[3],
|
||||
normalized: true,
|
||||
yAxisID: 'ycad',
|
||||
};
|
||||
this._chart.data.datasets[4] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedTemperature(point.getTemperature()),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[4],
|
||||
normalized: true,
|
||||
yAxisID: 'yatemp',
|
||||
};
|
||||
this._chart.data.datasets[5] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getPower(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
data: datasets[5],
|
||||
normalized: true,
|
||||
yAxisID: 'ypower',
|
||||
};
|
||||
this._chart.options.scales.x['min'] = 0;
|
||||
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||
|
||||
this._chart.options.scales!.x!['min'] = 0;
|
||||
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
||||
data.global.distance.total,
|
||||
units.distance
|
||||
);
|
||||
|
||||
this.setVisibility();
|
||||
this.setFill();
|
||||
@@ -513,21 +533,24 @@ export class ElevationProfile {
|
||||
return;
|
||||
}
|
||||
const elevationFill = get(this._elevationFill);
|
||||
const dataset = this._chart.data.datasets[0];
|
||||
let segment: any = {};
|
||||
if (elevationFill === 'slope') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.slopeFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'surface') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.surfaceFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'highway') {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
segment = {
|
||||
backgroundColor: this.highwayFillCallback,
|
||||
};
|
||||
} else {
|
||||
this._chart.data.datasets[0]['segment'] = {};
|
||||
segment = {};
|
||||
}
|
||||
Object.assign(dataset, { segment });
|
||||
}
|
||||
|
||||
updateOverlay() {
|
||||
@@ -554,10 +577,12 @@ export class ElevationProfile {
|
||||
|
||||
const gpxStatistics = get(this._gpxStatistics);
|
||||
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
|
||||
getConvertedDistance(
|
||||
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
|
||||
)
|
||||
);
|
||||
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
|
||||
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
|
||||
);
|
||||
|
||||
selectionContext.fillRect(
|
||||
@@ -575,19 +600,22 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
|
||||
slopeFillCallback(context) {
|
||||
return getSlopeColor(context.p0.raw.slope.segment);
|
||||
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSlopeColor(point.slope.segment);
|
||||
}
|
||||
|
||||
surfaceFillCallback(context) {
|
||||
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSurfaceColor(point.extensions.surface);
|
||||
}
|
||||
|
||||
highwayFillCallback(context) {
|
||||
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getHighwayColor(
|
||||
context.p0.raw.extensions.highway,
|
||||
context.p0.raw.extensions.sac_scale,
|
||||
context.p0.raw.extensions.mtb_scale
|
||||
point.extensions.highway,
|
||||
point.extensions.sac_scale,
|
||||
point.extensions.mtb_scale
|
||||
);
|
||||
}
|
||||
|
||||
@@ -596,8 +624,5 @@ export class ElevationProfile {
|
||||
this._chart.destroy();
|
||||
this._chart = null;
|
||||
}
|
||||
if (this._marker) {
|
||||
this._marker.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { loadFile } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -102,7 +102,7 @@
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||
accessToken={options.token}
|
||||
maptilerKey={options.key}
|
||||
geocoder={false}
|
||||
geolocate={true}
|
||||
hash={useHash}
|
||||
@@ -130,6 +130,7 @@
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
showControls={options.elevation.controls}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
getCleanedEmbeddingOptions,
|
||||
getMergedEmbeddingOptions,
|
||||
} from './embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
@@ -32,7 +32,7 @@
|
||||
let options = $state(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
token: 'YOUR_MAPBOX_TOKEN',
|
||||
key: 'YOUR_MAPTILER_KEY',
|
||||
theme: mode.current,
|
||||
},
|
||||
defaultEmbeddingOptions
|
||||
@@ -46,10 +46,10 @@
|
||||
let iframeOptions = $derived(
|
||||
getMergedEmbeddingOptions(
|
||||
{
|
||||
token:
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? PUBLIC_MAPBOX_TOKEN
|
||||
: options.token,
|
||||
key:
|
||||
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
|
||||
? PUBLIC_MAPTILER_KEY
|
||||
: options.key,
|
||||
files: files.split(',').filter((url) => url.length > 0),
|
||||
ids: driveIds.split(',').filter((id) => id.length > 0),
|
||||
elevation: {
|
||||
@@ -102,8 +102,8 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="key">{i18n._('embedding.maptiler_key')}</Label>
|
||||
<Input id="key" type="text" class="h-8" bind:value={options.key} />
|
||||
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
import { basemaps } from '$lib/assets/layers';
|
||||
|
||||
export type EmbeddingOptions = {
|
||||
token: string;
|
||||
key: string;
|
||||
files: string[];
|
||||
ids: string[];
|
||||
basemap: string;
|
||||
@@ -26,10 +26,10 @@ export type EmbeddingOptions = {
|
||||
};
|
||||
|
||||
export const defaultEmbeddingOptions = {
|
||||
token: '',
|
||||
key: '',
|
||||
files: [],
|
||||
ids: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
basemap: 'maptilerTopo',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
@@ -107,7 +107,7 @@ export function getURLForGoogleDriveFile(fileId: string): string {
|
||||
|
||||
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
let newOptions: any = {
|
||||
token: PUBLIC_MAPBOX_TOKEN,
|
||||
key: PUBLIC_MAPTILER_KEY,
|
||||
files: [],
|
||||
ids: [],
|
||||
};
|
||||
@@ -123,7 +123,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
if (options.has('source')) {
|
||||
let basemap = options.get('source')!;
|
||||
if (basemap === 'satellite') {
|
||||
newOptions.basemap = 'mapboxSatellite';
|
||||
newOptions.basemap = 'maptilerSatellite';
|
||||
} else if (basemap === 'otm') {
|
||||
newOptions.basemap = 'openTopoMap';
|
||||
} else if (basemap === 'ohm') {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
SquareActivity,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { GPXStatistics } from 'gpx';
|
||||
import { GPXGlobalStatistics } from 'gpx';
|
||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
@@ -48,24 +48,24 @@
|
||||
extensions: false,
|
||||
};
|
||||
} else {
|
||||
let statistics = $gpxStatistics;
|
||||
let statistics = $gpxStatistics.global;
|
||||
if (exportState.current === ExportState.ALL) {
|
||||
statistics = Array.from(get(fileStateCollection).values())
|
||||
.map((file) => file.statistics)
|
||||
.reduce((acc, cur) => {
|
||||
if (cur !== undefined) {
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
|
||||
}
|
||||
return acc;
|
||||
}, new GPXStatistics());
|
||||
}, new GPXGlobalStatistics());
|
||||
}
|
||||
return {
|
||||
time: statistics.global.time.total === 0,
|
||||
hr: statistics.global.hr.count === 0,
|
||||
cad: statistics.global.cad.count === 0,
|
||||
atemp: statistics.global.atemp.count === 0,
|
||||
power: statistics.global.power.count === 0,
|
||||
extensions: Object.keys(statistics.global.extensions).length === 0,
|
||||
time: statistics.time.total === 0,
|
||||
hr: statistics.hr.count === 0,
|
||||
cad: statistics.cad.count === 0,
|
||||
atemp: statistics.atemp.count === 0,
|
||||
power: statistics.power.count === 0,
|
||||
extensions: Object.keys(statistics.extensions).length === 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,11 +34,10 @@
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { selection, copied, cut } from '$lib/logic/selection';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
import { allowedPastes } from './sortable-file-list';
|
||||
@@ -58,41 +57,31 @@
|
||||
|
||||
let singleSelection = $derived($selection.size === 1);
|
||||
|
||||
let nodeColors: string[] = $state([]);
|
||||
|
||||
$effect.pre(() => {
|
||||
let nodeColors: string[] = $derived.by(() => {
|
||||
let colors: string[] = [];
|
||||
if (node && $map) {
|
||||
if (node) {
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
colors = style.color;
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (
|
||||
style &&
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (colors.length === 0) {
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
colors.push(layer.layerColor);
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
if (defaultColor) {
|
||||
colors.push(defaultColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nodeColors = colors;
|
||||
return colors;
|
||||
});
|
||||
|
||||
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
<script lang="ts">
|
||||
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 { i18n } from '$lib/i18n.svelte';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { page } from '$app/state';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
|
||||
let {
|
||||
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||
maptilerKey = PUBLIC_MAPTILER_KEY,
|
||||
geolocate = true,
|
||||
geocoder = true,
|
||||
hash = true,
|
||||
class: className = '',
|
||||
}: {
|
||||
accessToken?: string;
|
||||
maptilerKey?: string;
|
||||
geolocate?: boolean;
|
||||
geocoder?: boolean;
|
||||
hash?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = $state(true);
|
||||
let embeddedApp = $state(false);
|
||||
|
||||
@@ -48,7 +43,7 @@
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
|
||||
map.init(maptilerKey, language, hash, geocoder, geolocate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -81,21 +76,21 @@
|
||||
<style lang="postcss">
|
||||
@reference "../../../app.css";
|
||||
|
||||
div :global(.mapboxgl-map) {
|
||||
div :global(.maplibregl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
div :global(.maplibregl-ctrl-top-right > .maplibregl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
div :global(.maplibregl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
div :global(.maplibregl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@@ -110,27 +105,27 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
div :global(.maplibregl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
div :global(.maplibregl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
div :global(.maplibregl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
div :global(.maplibregl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@@ -138,7 +133,7 @@
|
||||
@apply w-[29px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
div :global(.maplibregl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@@ -149,12 +144,12 @@
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
div :global(.maplibregl-ctrl-geocoder--collapsed .maplibregl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
div :global(.maplibregl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@@ -163,77 +158,76 @@
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
.horizontal :global(.maplibregl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
div :global(.maplibregl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
div :global(.maplibregl-compact-show.maplibregl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
div :global(.maplibregl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
div :global(.maplibregl-compact-show .maplibregl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
div :global(.maplibregl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
div :global(.maplibregl-popup) {
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
div :global(.maplibregl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top-left .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-top-right .maplibregl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-left .maplibregl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
div :global(.maplibregl-popup-anchor-right .maplibregl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
let control: CustomControl | null = null;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
map.onLoad((map: maplibregl.Map) => {
|
||||
if (position.includes('right')) container.classList.add('float-right');
|
||||
else container.classList.add('float-left');
|
||||
container.classList.remove('hidden');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Map, type IControl } from 'mapbox-gl';
|
||||
import { type Map, type IControl } from 'maplibre-gl';
|
||||
|
||||
export default class CustomControl implements IControl {
|
||||
_map: Map | undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
|
||||
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||
@@ -9,13 +9,10 @@
|
||||
let distanceMarkers: DistanceMarkers;
|
||||
let startEndMarkers: StartEndMarkers;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map_) => {
|
||||
gpxLayers.init();
|
||||
startEndMarkers = new StartEndMarkers();
|
||||
distanceMarkers = new DistanceMarkers();
|
||||
});
|
||||
|
||||
map.onLoad((map_) => {
|
||||
createPopups(map_);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '../style';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
@@ -22,7 +23,7 @@ export class DistanceMarkers {
|
||||
this.unsubscribes.push(
|
||||
map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
map_.on('style.import.load', this.updateBinded);
|
||||
map_.on('style.load', this.updateBinded);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -44,44 +45,45 @@ export class DistanceMarkers {
|
||||
});
|
||||
}
|
||||
if (!map_.getLayer('distance-markers')) {
|
||||
map_.addLayer({
|
||||
id: 'distance-markers',
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: [
|
||||
'match',
|
||||
['get', 'level'],
|
||||
100,
|
||||
['>=', ['zoom'], 0],
|
||||
50,
|
||||
['>=', ['zoom'], 7],
|
||||
25,
|
||||
[
|
||||
'any',
|
||||
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'distance-markers',
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: [
|
||||
'match',
|
||||
['get', 'level'],
|
||||
100,
|
||||
['>=', ['zoom'], 0],
|
||||
50,
|
||||
['>=', ['zoom'], 7],
|
||||
25,
|
||||
[
|
||||
'any',
|
||||
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
|
||||
['>=', ['zoom'], 11],
|
||||
],
|
||||
10,
|
||||
['>=', ['zoom'], 10],
|
||||
5,
|
||||
['>=', ['zoom'], 11],
|
||||
1,
|
||||
['>=', ['zoom'], 13],
|
||||
false,
|
||||
],
|
||||
10,
|
||||
['>=', ['zoom'], 10],
|
||||
5,
|
||||
['>=', ['zoom'], 11],
|
||||
1,
|
||||
['>=', ['zoom'], 13],
|
||||
false,
|
||||
],
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer('distance-markers');
|
||||
ANCHOR_LAYER_KEY.distanceMarkers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (map_.getLayer('distance-markers')) {
|
||||
@@ -101,23 +103,17 @@ export class DistanceMarkers {
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let statistics = get(gpxStatistics);
|
||||
|
||||
let features = [];
|
||||
let features: GeoJSON.Feature[] = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
getConvertedDistanceToKilometers(currentTargetDistance)
|
||||
) {
|
||||
statistics.forEachTrackPoint((trkpt, dist) => {
|
||||
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
@@ -126,7 +122,7 @@ export class DistanceMarkers {
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
|
||||
@@ -3,13 +3,14 @@ import { MapPopup } from '$lib/components/map/map-popup';
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
|
||||
export function createPopups(map: mapboxgl.Map) {
|
||||
export function createPopups(map: maplibregl.Map) {
|
||||
removePopups();
|
||||
waypointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
center: [0, 0],
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type FilterSpecification,
|
||||
type MapLayerMouseEvent,
|
||||
type MapLayerTouchEvent,
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import {
|
||||
@@ -10,7 +15,7 @@ import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
@@ -22,6 +27,8 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { gpxColors } from './gpx-layers';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -43,16 +50,35 @@ for (let color of colors) {
|
||||
}
|
||||
|
||||
// Get the color with the least amount of uses
|
||||
function getColor() {
|
||||
function getColor(fileId: string) {
|
||||
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
||||
colorCount[color]++;
|
||||
gpxColors.update((colors) => {
|
||||
colors.set(fileId, color);
|
||||
return colors;
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
function decrementColor(color: string) {
|
||||
function replaceColor(fileId: string, oldColor: string, newColor: 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)) {
|
||||
colorCount[color]--;
|
||||
}
|
||||
gpxColors.update((colors) => {
|
||||
colors.delete(fileId);
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||
@@ -94,38 +120,38 @@ export class GPXLayer {
|
||||
selected: boolean = false;
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
draggingStartingPosition: mapboxgl.Point = new mapboxgl.Point(0, 0);
|
||||
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
|
||||
unsubscribe: Function[] = [];
|
||||
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
waypointLayerOnMouseEnterBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
|
||||
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
|
||||
this.waypointLayerOnMouseEnter.bind(this);
|
||||
waypointLayerOnMouseLeaveBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
|
||||
this.waypointLayerOnMouseLeave.bind(this);
|
||||
waypointLayerOnClickBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
|
||||
this.waypointLayerOnClick.bind(this);
|
||||
waypointLayerOnMouseDownBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
|
||||
this.waypointLayerOnMouseDown.bind(this);
|
||||
waypointLayerOnTouchStartBinded: (e: mapboxgl.MapTouchEvent) => void =
|
||||
waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
|
||||
this.waypointLayerOnTouchStart.bind(this);
|
||||
waypointLayerOnMouseMoveBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
|
||||
this.waypointLayerOnMouseMove.bind(this);
|
||||
waypointLayerOnMouseUpBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
|
||||
this.waypointLayerOnMouseUp.bind(this);
|
||||
|
||||
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.layerColor = getColor(fileId);
|
||||
this.unsubscribe.push(
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
$map.on('style.import.load', this.updateBinded);
|
||||
$map.on('style.load', this.updateBinded);
|
||||
this.update();
|
||||
}
|
||||
})
|
||||
@@ -148,24 +174,25 @@ export class GPXLayer {
|
||||
|
||||
update() {
|
||||
const _map = get(map);
|
||||
const layerEventManager = map.layerEventManager;
|
||||
let file = get(this.file)?.file;
|
||||
if (!_map || !file) {
|
||||
if (!_map || !layerEventManager || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
if (
|
||||
file._data.style &&
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
try {
|
||||
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
|
||||
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(this.getGeoJSON());
|
||||
} else {
|
||||
@@ -176,77 +203,44 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
if (!_map.getLayer(this.fileId)) {
|
||||
_map.addLayer({
|
||||
id: this.fileId,
|
||||
type: 'line',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
_map.addLayer(
|
||||
{
|
||||
id: this.fileId,
|
||||
type: 'line',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
ANCHOR_LAYER_KEY.tracks
|
||||
);
|
||||
|
||||
_map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
|
||||
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||
| mapboxgl.GeoJSONSource
|
||||
| undefined;
|
||||
this.currentWaypointData = this.getWaypointsGeoJSON();
|
||||
if (waypointSource) {
|
||||
waypointSource.setData(this.currentWaypointData);
|
||||
} else {
|
||||
_map.addSource(this.fileId + '-waypoints', {
|
||||
type: 'geojson',
|
||||
data: this.currentWaypointData,
|
||||
});
|
||||
}
|
||||
let visibleTrackSegmentIds: string[] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
const segmentFilter: FilterSpecification = [
|
||||
'in',
|
||||
['get', 'trackSegmentId'],
|
||||
['literal', visibleTrackSegmentIds],
|
||||
];
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
_map.on(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
_map.on(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.on(
|
||||
'mousedown',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseDownBinded
|
||||
);
|
||||
_map.on(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
}
|
||||
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||
@@ -272,34 +266,73 @@ export class GPXLayer {
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
_map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
ANCHOR_LAYER_KEY.directionMarkers
|
||||
);
|
||||
}
|
||||
|
||||
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
|
||||
} else {
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
}
|
||||
|
||||
let visibleSegments: [number, number][] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleSegments.push([trackIndex, segmentIndex]);
|
||||
}
|
||||
});
|
||||
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||
| GeoJSONSource
|
||||
| undefined;
|
||||
this.currentWaypointData = this.getWaypointsGeoJSON();
|
||||
if (waypointSource) {
|
||||
waypointSource.setData(this.currentWaypointData);
|
||||
} else {
|
||||
_map.addSource(this.fileId + '-waypoints', {
|
||||
type: 'geojson',
|
||||
data: this.currentWaypointData,
|
||||
});
|
||||
}
|
||||
|
||||
_map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleSegments.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
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) => {
|
||||
@@ -313,21 +346,6 @@ export class GPXLayer {
|
||||
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleSegments.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
@@ -336,32 +354,47 @@ export class GPXLayer {
|
||||
|
||||
remove() {
|
||||
const _map = get(map);
|
||||
if (_map) {
|
||||
_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);
|
||||
|
||||
_map.off(
|
||||
if (_map) {
|
||||
_map.off('style.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
|
||||
);
|
||||
_map.off(
|
||||
layerEventManager.off(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
|
||||
_map.off(
|
||||
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')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
@@ -381,7 +414,7 @@ export class GPXLayer {
|
||||
|
||||
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
decrementColor(this.layerColor);
|
||||
removeColor(this.fileId, this.layerColor);
|
||||
}
|
||||
|
||||
moveToFront() {
|
||||
@@ -390,13 +423,13 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
if (_map.getLayer(this.fileId)) {
|
||||
_map.moveLayer(this.fileId);
|
||||
_map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||
_map.moveLayer(this.fileId + '-waypoints');
|
||||
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.moveLayer(this.fileId + '-direction');
|
||||
_map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +470,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
@@ -495,7 +528,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
|
||||
if (this.draggedWaypointIndex !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -515,7 +548,7 @@ export class GPXLayer {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||
}
|
||||
|
||||
waypointLayerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
waypointLayerOnClick(e: MapLayerMouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
@@ -557,7 +590,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseDown(e: mapboxgl.MapMouseEvent) {
|
||||
waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
|
||||
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
@@ -567,6 +600,7 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
@@ -576,7 +610,7 @@ export class GPXLayer {
|
||||
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnTouchStart(e: mapboxgl.MapTouchEvent) {
|
||||
waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
|
||||
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
@@ -590,12 +624,13 @@ export class GPXLayer {
|
||||
waypointPopup?.hide();
|
||||
|
||||
e.preventDefault();
|
||||
_map.dragPan.disable();
|
||||
|
||||
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseMove(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
||||
return;
|
||||
}
|
||||
@@ -607,18 +642,25 @@ export class GPXLayer {
|
||||
).coordinates = [e.lngLat.lng, e.lngLat.lat];
|
||||
|
||||
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
|
||||
| mapboxgl.GeoJSONSource
|
||||
| GeoJSONSource
|
||||
| undefined;
|
||||
if (waypointSource) {
|
||||
waypointSource.setData(this.currentWaypointData!);
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseUp(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||
|
||||
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
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;
|
||||
@@ -686,6 +728,7 @@ export class GPXLayer {
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
feature.properties.segmentIndex = segmentIndex;
|
||||
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
|
||||
|
||||
segmentIndex++;
|
||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||
@@ -718,7 +761,7 @@ export class GPXLayer {
|
||||
properties: {
|
||||
fileId: this.fileId,
|
||||
waypointIndex: index,
|
||||
icon: `${this.fileId}-waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}`,
|
||||
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -739,21 +782,8 @@ export class GPXLayer {
|
||||
});
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const iconId = `${this.fileId}-waypoint-${symbol ?? 'default'}`;
|
||||
if (!_map.hasImage(iconId)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!_map.hasImage(iconId)) {
|
||||
_map.addImage(iconId, 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(getSvgForSymbol(symbol, this.layerColor));
|
||||
}
|
||||
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
|
||||
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { writable } from 'svelte/store';
|
||||
import { GPXLayer } from './gpx-layer';
|
||||
|
||||
export class GPXLayerCollection {
|
||||
@@ -42,3 +43,4 @@ export class GPXLayerCollection {
|
||||
}
|
||||
|
||||
export const gpxLayers = new GPXLayerCollection();
|
||||
export const gpxColors = writable(new Map<string, string>());
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/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 {
|
||||
start: mapboxgl.Marker;
|
||||
end: mapboxgl.Marker;
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
let startElement = document.createElement('div');
|
||||
let endElement = document.createElement('div');
|
||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||
endElement.style.background =
|
||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
|
||||
this.start = new mapboxgl.Marker({ element: startElement });
|
||||
this.end = new mapboxgl.Marker({ element: endElement });
|
||||
|
||||
map.onLoad(() => this.update());
|
||||
this.unsubscribes.push(gpxStatistics.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(allHidden.subscribe(this.updateBinded));
|
||||
}
|
||||
@@ -33,26 +43,113 @@ export class StartEndMarkers {
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
const tool = get(currentTool);
|
||||
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
const statistics = get(gpxStatistics);
|
||||
const slicedStatistics = get(slicedGPXStatistics);
|
||||
const hovered = get(hoveredPoint);
|
||||
const hidden = get(allHidden);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||
const start = statistics
|
||||
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
|
||||
.trkpt.getCoordinates();
|
||||
const end = statistics
|
||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||
.trkpt.getCoordinates();
|
||||
const data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [start.lon, start.lat],
|
||||
},
|
||||
properties: {
|
||||
icon: 'start-marker',
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
this.start.remove();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
const map_ = get(map);
|
||||
if (!map_) return;
|
||||
|
||||
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,9 +20,8 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { onMount } from 'svelte';
|
||||
import { customBasemapUpdate, isSelected, remove } from './utils';
|
||||
import { remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
const {
|
||||
@@ -42,13 +41,8 @@
|
||||
let maxZoom: number = $state(20);
|
||||
let layerType: 'basemap' | 'overlay' = $state('basemap');
|
||||
let resourceType: 'raster' | 'vector' = $derived.by(() => {
|
||||
if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
return 'vector';
|
||||
}
|
||||
if (tileUrls[0].length > 0 && tileUrls[0].includes('.json')) {
|
||||
return 'vector';
|
||||
}
|
||||
return 'raster';
|
||||
});
|
||||
@@ -134,8 +128,8 @@
|
||||
],
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
$customLayers[layerId] = layer;
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
@@ -158,9 +152,7 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
if ($currentBasemap !== layerId) {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
@@ -176,14 +168,6 @@
|
||||
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) => {
|
||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||
$overlays.overlays['custom'] = {};
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Layers } from '@lucide/svelte';
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
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 overpassLayer: OverpassLayer;
|
||||
@@ -23,127 +19,14 @@
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
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) => {
|
||||
map.onLoad((_map: maplibregl.Map) => {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer(_map);
|
||||
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
|
||||
overpassLayer.add();
|
||||
let first = true;
|
||||
_map.on('style.import.load', () => {
|
||||
if (!first) return;
|
||||
first = false;
|
||||
updateOverlays();
|
||||
});
|
||||
});
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
overlays,
|
||||
overlayTree,
|
||||
overpassTree,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
@@ -31,6 +32,7 @@
|
||||
currentOverpassQueries,
|
||||
customLayers,
|
||||
opacities,
|
||||
terrainSource,
|
||||
} = settings;
|
||||
|
||||
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||
@@ -54,7 +56,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (open && $selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
@@ -65,7 +67,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedOverlayTree) {
|
||||
if (open && $selectedOverlayTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
@@ -86,7 +88,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedOverpassTree) {
|
||||
if (open && $selectedOverpassTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverpassQueries) {
|
||||
let overlayLayers = getLayers($currentOverpassQueries);
|
||||
@@ -165,11 +167,11 @@
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{#if $isLayerFromExtension(selectedOverlay)}
|
||||
{$getLayerName(selectedOverlay)}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
{/if}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{/if}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
@@ -211,7 +213,9 @@
|
||||
isSelected($currentOverlays, selectedOverlay)
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(selectedOverlay);
|
||||
if ($map.getLayer(selectedOverlay)) {
|
||||
$map.removeLayer(selectedOverlay);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
@@ -233,6 +237,23 @@
|
||||
</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.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
|
||||
@@ -54,28 +54,27 @@
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
|
||||
<Card.Header class="p-0 gap-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
<Card.Title class="text-md flex flex-row">
|
||||
<div class="flex flex-col">
|
||||
<p>{name}</p>
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</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>
|
||||
|
||||
<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.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<Card.Content class="flex flex-col gap-1 p-0 text-sm whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
@@ -100,8 +99,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
|
||||
<MapPin size="16" />
|
||||
<Button
|
||||
size="sm"
|
||||
class="mt-1 justify-start"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
onclick={addToFile}
|
||||
>
|
||||
<MapPin size="14" />
|
||||
{i18n._('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ExtensionAPI {
|
||||
if (current && isSelected(current, overlay.id)) {
|
||||
show = true;
|
||||
try {
|
||||
get(map)?.removeImport(overlay.id);
|
||||
get(map)?.removeLayer(overlay.id);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '../style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
@@ -20,11 +24,12 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||
});
|
||||
|
||||
export class OverpassLayer {
|
||||
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
|
||||
overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter';
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: mapboxgl.Map;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
@@ -35,8 +40,9 @@ export class OverpassLayer {
|
||||
updateBinded = this.update.bind(this);
|
||||
onHoverBinded = this.onHover.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
this.popup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
@@ -47,7 +53,7 @@ export class OverpassLayer {
|
||||
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
@@ -71,10 +77,17 @@ export class OverpassLayer {
|
||||
update() {
|
||||
this.loadIcons();
|
||||
|
||||
let d = get(data);
|
||||
const fullData = get(data);
|
||||
const queries = getCurrentQueries();
|
||||
const d: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: fullData.features.filter((feature) =>
|
||||
queries.includes(feature.properties!.query)
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
|
||||
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(d);
|
||||
} else {
|
||||
@@ -85,25 +98,24 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
if (!this.map.getLayer('overpass')) {
|
||||
this.map.addLayer({
|
||||
id: 'overpass',
|
||||
type: 'symbol',
|
||||
source: 'overpass',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: 'overpass',
|
||||
type: 'symbol',
|
||||
source: 'overpass',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
|
||||
},
|
||||
},
|
||||
});
|
||||
ANCHOR_LAYER_KEY.overpass
|
||||
);
|
||||
|
||||
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
|
||||
}
|
||||
|
||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
|
||||
validate: false,
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
@@ -111,7 +123,9 @@ export class OverpassLayer {
|
||||
|
||||
remove() {
|
||||
this.map.off('moveend', this.queryIfNeededBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
try {
|
||||
@@ -244,27 +258,16 @@ export class OverpassLayer {
|
||||
loadIcons() {
|
||||
let currentQueries = getCurrentQueries();
|
||||
currentQueries.forEach((query) => {
|
||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
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">
|
||||
loadSVGIcon(
|
||||
this.map,
|
||||
`overpass-${query}`,
|
||||
`<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}" />
|
||||
<g transform="translate(8 8)">
|
||||
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
</svg>`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,3 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
||||
|
||||
287
website/src/lib/components/map/map-layer-event-manager.ts
Normal file
287
website/src/lib/components/map/map-layer-event-manager.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
|
||||
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
|
||||
type MapLayerListener = {
|
||||
features: maplibregl.MapGeoJSONFeature[];
|
||||
mousemoves: MapLayerMouseEventListener[];
|
||||
mouseenters: MapLayerMouseEventListener[];
|
||||
mouseleaves: MapLayerMouseEventListener[];
|
||||
mousedowns: MapLayerMouseEventListener[];
|
||||
clicks: MapLayerMouseEventListener[];
|
||||
contextmenus: MapLayerMouseEventListener[];
|
||||
touchstarts: MapLayerTouchEventListener[];
|
||||
};
|
||||
|
||||
export class MapLayerEventManager {
|
||||
private _map: maplibregl.Map;
|
||||
private _listeners: Record<string, MapLayerListener> = {};
|
||||
|
||||
constructor(map: maplibregl.Map) {
|
||||
this._map = map;
|
||||
this._map.on('mousemove', this._handleMouseMove.bind(this));
|
||||
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
|
||||
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
|
||||
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
|
||||
this._map.on('touchstart', this._handleTouchStart.bind(this));
|
||||
}
|
||||
|
||||
on(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (!this._listeners[layerId]) {
|
||||
this._listeners[layerId] = {
|
||||
features: [],
|
||||
mousemoves: [],
|
||||
mouseenters: [],
|
||||
mouseleaves: [],
|
||||
mousedowns: [],
|
||||
clicks: [],
|
||||
contextmenus: [],
|
||||
touchstarts: [],
|
||||
};
|
||||
}
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
off(
|
||||
eventType:
|
||||
| 'mousemove'
|
||||
| 'mouseenter'
|
||||
| 'mouseleave'
|
||||
| 'mousedown'
|
||||
| 'click'
|
||||
| 'contextmenu'
|
||||
| 'touchstart',
|
||||
layerId: string,
|
||||
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
|
||||
) {
|
||||
if (this._listeners[layerId]) {
|
||||
switch (eventType) {
|
||||
case 'mousemove':
|
||||
this._listeners[layerId].mousemoves = this._listeners[
|
||||
layerId
|
||||
].mousemoves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseenter':
|
||||
this._listeners[layerId].mouseenters = this._listeners[
|
||||
layerId
|
||||
].mouseenters.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this._listeners[layerId].mouseleaves = this._listeners[
|
||||
layerId
|
||||
].mouseleaves.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this._listeners[layerId].mousedowns = this._listeners[
|
||||
layerId
|
||||
].mousedowns.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'click':
|
||||
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
|
||||
(l) => l !== listener
|
||||
);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this._listeners[layerId].contextmenus = this._listeners[
|
||||
layerId
|
||||
].contextmenus.filter((l) => l !== listener);
|
||||
break;
|
||||
case 'touchstart':
|
||||
this._listeners[layerId].touchstarts = this._listeners[
|
||||
layerId
|
||||
].touchstarts.filter((l) => l !== listener);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
this._listeners[layerId].mousemoves.length === 0 &&
|
||||
this._listeners[layerId].mouseenters.length === 0 &&
|
||||
this._listeners[layerId].mouseleaves.length === 0 &&
|
||||
this._listeners[layerId].mousedowns.length === 0 &&
|
||||
this._listeners[layerId].clicks.length === 0 &&
|
||||
this._listeners[layerId].contextmenus.length === 0 &&
|
||||
this._listeners[layerId].touchstarts.length === 0
|
||||
) {
|
||||
delete this._listeners[layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
|
||||
const 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);
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
listener.features = features;
|
||||
}
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
|
||||
Object.values(this._listeners).forEach((listener) => {
|
||||
if (listener.features.length > 0) {
|
||||
if (type === 'click' && listener.clicks.length > 0) {
|
||||
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
|
||||
features: listener.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: listener.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: listener.features,
|
||||
}
|
||||
);
|
||||
listener.mousedowns.forEach((l) => l(event));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
|
||||
const layerIds = this._filterLayersIntersectingBounds(
|
||||
Object.keys(this._listeners).filter(
|
||||
(layerId) => this._listeners[layerId].touchstarts.length > 0
|
||||
),
|
||||
this._getBounds(e.point)
|
||||
);
|
||||
if (layerIds.length === 0) return;
|
||||
const features = this._map.queryRenderedFeatures(e.points[0], { 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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { mount, tick, unmount } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
||||
@@ -11,15 +11,15 @@ export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
};
|
||||
|
||||
export class MapPopup {
|
||||
map: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
map: maplibregl.Map;
|
||||
popup: maplibregl.Popup;
|
||||
item: Writable<PopupItem | null> = writable(null);
|
||||
component: ReturnType<typeof mount>;
|
||||
maybeHideBinded = this.maybeHide.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||
constructor(map: maplibregl.Map, options?: maplibregl.PopupOptions) {
|
||||
this.map = map;
|
||||
this.popup = new mapboxgl.Popup(options);
|
||||
this.popup = new maplibregl.Popup(options);
|
||||
this.component = mount(MapPopupComponent, {
|
||||
target: document.body,
|
||||
props: {
|
||||
@@ -51,7 +51,7 @@ export class MapPopup {
|
||||
this.map.on('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||
maybeHide(e: maplibregl.MapMouseEvent) {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@@ -75,10 +75,10 @@ export class MapPopup {
|
||||
getCoordinates() {
|
||||
const item = get(this.item);
|
||||
if (item === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
return new maplibregl.LngLat(0, 0);
|
||||
}
|
||||
return item.item instanceof Waypoint || item.item instanceof TrackPoint
|
||||
? item.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
|
||||
: new maplibregl.LngLat(item.item.lon, item.item.lat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,80 @@
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
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 { settings } from '$lib/logic/settings';
|
||||
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;
|
||||
|
||||
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
export class MapboxGLMap {
|
||||
private _map: Writable<mapboxgl.Map | null> = writable(null);
|
||||
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||
export class MapLibreGLMap {
|
||||
private _maptilerKey: string = '';
|
||||
private _map: maplibregl.Map | null = null;
|
||||
private _mapStore: Writable<maplibregl.Map | null> = writable(null);
|
||||
private _styleManager: StyleManager | null = null;
|
||||
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
|
||||
public layerEventManager: MapLayerEventManager | null = null;
|
||||
|
||||
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._map.subscribe(run, invalidate);
|
||||
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
|
||||
return this._mapStore.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
init(
|
||||
accessToken: string,
|
||||
maptilerKey: string,
|
||||
language: string,
|
||||
hash: boolean,
|
||||
geocoder: boolean,
|
||||
geolocate: boolean
|
||||
) {
|
||||
const map = new mapboxgl.Map({
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
projection: {
|
||||
type: 'globe',
|
||||
},
|
||||
sources: {},
|
||||
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: 'mapbox://sprites/mapbox/outdoors-v12',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false,
|
||||
maxPitch: 85,
|
||||
});
|
||||
this.layerEventManager = new MapLayerEventManager(map);
|
||||
map.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
map.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
new maplibregl.NavigationControl({
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
if (geocoder) {
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
localGeocoder: () => [],
|
||||
localGeocoderOnly: true,
|
||||
externalGeocoder: (query: string) =>
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.map((result: any) => {
|
||||
let geocoder = new MaplibreGeocoder(
|
||||
{
|
||||
forwardGeocode: async (config) => {
|
||||
const results: MaplibreGeocoderFeatureResults = {
|
||||
features: [],
|
||||
type: 'FeatureCollection',
|
||||
};
|
||||
try {
|
||||
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
|
||||
const response = await fetch(request);
|
||||
const geojson = await response.json();
|
||||
results.features = geojson.map((result: any) => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
@@ -104,74 +84,43 @@ export class MapboxGLMap {
|
||||
place_name: result.display_name,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
// Trigger search on Enter key only
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||
} else if (geocoder._typeahead.data.length > 0) {
|
||||
geocoder._typeahead.clear();
|
||||
} catch (e) {}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
{
|
||||
maplibregl: maplibregl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
}
|
||||
};
|
||||
);
|
||||
map.addControl(geocoder);
|
||||
}
|
||||
if (geolocate) {
|
||||
map.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
const scaleControl = new mapboxgl.ScaleControl({
|
||||
const scaleControl = new maplibregl.ScaleControl({
|
||||
unit: get(distanceUnits),
|
||||
});
|
||||
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', () => {
|
||||
this._map.set(map); // only set the store after the map has loaded
|
||||
this._map = map;
|
||||
this._mapStore.set(map); // only set the store after the map has loaded
|
||||
window._map = map; // entry point for extensions
|
||||
this.resize();
|
||||
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(elevationProfile.subscribe(() => this.resize()));
|
||||
@@ -184,44 +133,48 @@ export class MapboxGLMap {
|
||||
);
|
||||
}
|
||||
|
||||
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
callback(map);
|
||||
} else {
|
||||
this._onLoadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
map.remove();
|
||||
this._map.set(null);
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
this._mapStore.set(null);
|
||||
}
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes = [];
|
||||
}
|
||||
|
||||
resize() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
if (this._map) {
|
||||
tick().then(() => {
|
||||
map.resize();
|
||||
this._map?.resize();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggle3D() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
if (map.getPitch() === 0) {
|
||||
map.easeTo({ pitch: 70 });
|
||||
if (this._map) {
|
||||
if (this._map.getPitch() === 0) {
|
||||
this._map.easeTo({ pitch: 70 });
|
||||
} else {
|
||||
map.easeTo({ pitch: 0 });
|
||||
this._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 MapboxGLMap();
|
||||
export const map = new MapLibreGLMap();
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
let container: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
map.onLoad((map: mapboxgl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map);
|
||||
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
||||
map.onLoad((map_: maplibregl.Map) => {
|
||||
googleRedirect = new GoogleRedirect(map_);
|
||||
mapillaryLayer = new MapillaryLayer(
|
||||
map_,
|
||||
map.layerEventManager!,
|
||||
container,
|
||||
mapillaryOpen
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
map: maplibregl.Map;
|
||||
enabled = false;
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ export class GoogleRedirect {
|
||||
this.map.off('click', this.openStreetView);
|
||||
}
|
||||
|
||||
openStreetView(e: mapboxgl.MapMouseEvent) {
|
||||
openStreetView(e: maplibregl.MapMouseEvent) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '../style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
@@ -41,8 +43,9 @@ const mapillaryImageLayer: LayerSpecification = {
|
||||
};
|
||||
|
||||
export class MapillaryLayer {
|
||||
map: mapboxgl.Map;
|
||||
marker: mapboxgl.Marker;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
marker: maplibregl.Marker;
|
||||
viewer: Viewer;
|
||||
|
||||
active = false;
|
||||
@@ -52,8 +55,14 @@ export class MapillaryLayer {
|
||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
||||
constructor(
|
||||
map: maplibregl.Map,
|
||||
layerEventManager: MapLayerEventManager,
|
||||
container: HTMLElement,
|
||||
popupOpen: { value: boolean }
|
||||
) {
|
||||
this.map = map;
|
||||
this.layerEventManager = layerEventManager;
|
||||
|
||||
this.viewer = new Viewer({
|
||||
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
@@ -61,15 +70,12 @@ export class MapillaryLayer {
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
|
||||
element.className = 'maplibregl-user-location maplibregl-user-location-show-heading';
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mapboxgl-user-location-dot';
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'mapboxgl-user-location-heading';
|
||||
dot.className = 'maplibregl-user-location-dot';
|
||||
element.appendChild(dot);
|
||||
element.appendChild(heading);
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
this.marker = new maplibregl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element,
|
||||
});
|
||||
@@ -99,20 +105,20 @@ export class MapillaryLayer {
|
||||
this.map.addSource('mapillary', mapillarySource);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-sequence')) {
|
||||
this.map.addLayer(mapillarySequenceLayer);
|
||||
this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-image')) {
|
||||
this.map.addLayer(mapillaryImageLayer);
|
||||
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
}
|
||||
this.map.on('style.load', this.addBinded);
|
||||
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.map.off('style.load', this.addBinded);
|
||||
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
|
||||
|
||||
if (this.map.getLayer('mapillary-image')) {
|
||||
this.map.removeLayer('mapillary-image');
|
||||
@@ -134,7 +140,7 @@ export class MapillaryLayer {
|
||||
this.popupOpen.value = false;
|
||||
}
|
||||
|
||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
onMouseEnter(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (
|
||||
e.features &&
|
||||
e.features.length > 0 &&
|
||||
|
||||
230
website/src/lib/components/map/style.ts
Normal file
230
website/src/lib/components/map/style.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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',
|
||||
};
|
||||
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||
id: id,
|
||||
type: 'symbol',
|
||||
source: 'empty-source',
|
||||
}));
|
||||
|
||||
export class StyleManager {
|
||||
private _map: Writable<maplibregl.Map | null>;
|
||||
private _maptilerKey: string;
|
||||
private _pastOverlays: Set<string> = new Set();
|
||||
|
||||
constructor(map: Writable<maplibregl.Map | null>, maptilerKey: string) {
|
||||
this._map = map;
|
||||
this._maptilerKey = maptilerKey;
|
||||
this._map.subscribe((map_) => {
|
||||
if (map_) {
|
||||
this.updateBasemap();
|
||||
map_.on('style.load', () => this.updateOverlays());
|
||||
map_.on('pitch', () => this.updateTerrain());
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe(() => this.updateBasemap());
|
||||
currentOverlays.subscribe(() => this.updateOverlays());
|
||||
opacities.subscribe(() => this.updateOverlays());
|
||||
terrainSource.subscribe(() => this.updateTerrain());
|
||||
customLayers.subscribe(() => this.updateBasemap());
|
||||
}
|
||||
|
||||
updateBasemap() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
this.buildStyle().then((style) => map_.setStyle(style));
|
||||
}
|
||||
|
||||
async buildStyle(): Promise<maplibregl.StyleSpecification> {
|
||||
const custom = get(customLayers);
|
||||
|
||||
const style: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
projection: {
|
||||
type: 'globe',
|
||||
},
|
||||
sources: {
|
||||
'empty-source': emptySource,
|
||||
},
|
||||
layers: [],
|
||||
};
|
||||
|
||||
let basemap = get(currentBasemap);
|
||||
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
|
||||
const basemapStyle = await this.get(basemapInfo);
|
||||
|
||||
this.merge(style, basemapStyle);
|
||||
|
||||
const terrain = this.getCurrentTerrain();
|
||||
style.sources[terrain.source] = terrainSources[terrain.source];
|
||||
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
|
||||
|
||||
style.layers.push(...anchorLayers);
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async updateOverlays() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
if (!map_.getSource('empty-source')) return;
|
||||
|
||||
const custom = get(customLayers);
|
||||
const overlayOpacities = get(opacities);
|
||||
try {
|
||||
const layers = getLayers(get(currentOverlays) ?? {});
|
||||
for (let overlay in layers) {
|
||||
if (!layers[overlay]) {
|
||||
if (this._pastOverlays.has(overlay)) {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (map_.getLayer(layer.id)) {
|
||||
map_.removeLayer(layer.id);
|
||||
}
|
||||
}
|
||||
this._pastOverlays.delete(overlay);
|
||||
}
|
||||
} else {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
const opacity = overlayOpacities[overlay];
|
||||
|
||||
for (let sourceId in overlayStyle.sources) {
|
||||
if (!map_.getSource(sourceId)) {
|
||||
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let layer of overlayStyle.layers ?? []) {
|
||||
if (!map_.getLayer(layer.id)) {
|
||||
if (opacity !== undefined) {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = opacity;
|
||||
} else if (layer.type === 'hillshade') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['hillshade-exaggeration'] = opacity / 2;
|
||||
}
|
||||
}
|
||||
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
||||
}
|
||||
}
|
||||
|
||||
this._pastOverlays.add(overlay);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateTerrain() {
|
||||
const map_ = get(this._map);
|
||||
if (!map_) return;
|
||||
|
||||
const mapTerrain = map_.getTerrain();
|
||||
const terrain = this.getCurrentTerrain();
|
||||
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
|
||||
if (terrain.exaggeration > 0) {
|
||||
if (!map_.getSource(terrain.source)) {
|
||||
map_.addSource(terrain.source, terrainSources[terrain.source]);
|
||||
}
|
||||
map_.setTerrain(terrain);
|
||||
} else {
|
||||
map_.setTerrain(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(
|
||||
styleInfo: maplibregl.StyleSpecification | string
|
||||
): Promise<maplibregl.StyleSpecification> {
|
||||
if (typeof styleInfo === 'string') {
|
||||
let styleUrl = styleInfo as string;
|
||||
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
|
||||
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const response = await fetch(styleUrl, { cache: 'force-cache' });
|
||||
const style = await response.json();
|
||||
return style;
|
||||
} else {
|
||||
return styleInfo;
|
||||
}
|
||||
}
|
||||
|
||||
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
|
||||
style.sources = { ...style.sources, ...other.sources };
|
||||
for (let layer of other.layers ?? []) {
|
||||
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
|
||||
const textField = layer.layout['text-field'];
|
||||
if (
|
||||
Array.isArray(textField) &&
|
||||
textField.length >= 2 &&
|
||||
textField[0] === 'coalesce' &&
|
||||
Array.isArray(textField[1]) &&
|
||||
textField[1][0] === 'get' &&
|
||||
typeof textField[1][1] === 'string' &&
|
||||
textField[1][1].startsWith('name')
|
||||
) {
|
||||
layer.layout['text-field'] = [
|
||||
'coalesce',
|
||||
['get', `name:${i18n.lang}`],
|
||||
['get', 'name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
style.layers.push(layer);
|
||||
}
|
||||
if (other.sprite && !style.sprite) {
|
||||
style.sprite = other.sprite;
|
||||
}
|
||||
if (other.glyphs && !style.glyphs) {
|
||||
style.glyphs = other.glyphs;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTerrain() {
|
||||
const terrain = get(terrainSource);
|
||||
const source = terrainSources[terrain];
|
||||
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
|
||||
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
|
||||
}
|
||||
const map_ = get(this._map);
|
||||
return {
|
||||
source: terrain,
|
||||
exaggeration: !map_ || map_.getPitch() === 0 ? 0 : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
let {
|
||||
@@ -23,11 +23,11 @@
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
|
||||
let popupElement: HTMLDivElement | undefined = $state(undefined);
|
||||
let popup: mapboxgl.Popup | undefined = $derived.by(() => {
|
||||
let popup: maplibregl.Popup | undefined = $derived.by(() => {
|
||||
if (!popupElement) {
|
||||
return undefined;
|
||||
}
|
||||
let popup = new mapboxgl.Popup({
|
||||
let popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
@@ -28,7 +29,7 @@
|
||||
let cleanType = $state(CleanType.INSIDE);
|
||||
let deleteTrackpoints = $state(true);
|
||||
let deleteWaypoints = $state(true);
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = $state([]);
|
||||
let rectangleCoordinates: maplibregl.LngLat[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if ($map) {
|
||||
@@ -63,15 +64,18 @@
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
$map.addLayer(
|
||||
{
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
@@ -20,11 +19,7 @@
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={() => {
|
||||
if ($map) {
|
||||
fileActions.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
onclick={() => fileActions.addElevationToSelection()}
|
||||
>
|
||||
<MountainSnow size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.elevation.button')}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export const minTolerance = 0.1;
|
||||
@@ -28,17 +29,15 @@ export class ReducedGPXLayer {
|
||||
|
||||
update() {
|
||||
const file = this._fileState.file;
|
||||
const stats = this._fileState.statistics;
|
||||
if (!file || !stats) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
|
||||
let statistics = stats.getStatisticsFor(segmentItem);
|
||||
this._updateSimplified(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
segment.trkpt.length,
|
||||
ramerDouglasPeucker(segment.trkpt, minTolerance),
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -146,17 +145,18 @@ export class ReducedGPXLayerCollection {
|
||||
});
|
||||
}
|
||||
if (!map_.getLayer('simplified')) {
|
||||
map_.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer('simplified');
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight,
|
||||
} from '@lucide/svelte';
|
||||
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
@@ -51,7 +51,7 @@
|
||||
}: {
|
||||
minimized?: boolean;
|
||||
minimizable?: boolean;
|
||||
popup?: mapboxgl.Popup;
|
||||
popup?: maplibregl.Popup;
|
||||
popupElement?: HTMLDivElement;
|
||||
class?: string;
|
||||
} = $props();
|
||||
@@ -167,7 +167,7 @@
|
||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
{#each Object.keys(routingProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{i18n._(
|
||||
`toolbar.routing.activities.${profile}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
|
||||
import { get, writable, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { route } from './routing';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ export class RoutingControls {
|
||||
file: Readable<GPXFileWithStatistics | undefined>;
|
||||
anchors: AnchorWithMarker[] = [];
|
||||
shownAnchors: AnchorWithMarker[] = [];
|
||||
popup: mapboxgl.Popup;
|
||||
popup: maplibregl.Popup;
|
||||
popupElement: HTMLElement;
|
||||
temporaryAnchor: AnchorWithMarker;
|
||||
lastDragEvent = 0;
|
||||
@@ -43,12 +43,12 @@ export class RoutingControls {
|
||||
this.toggleAnchorsForZoomLevelAndBounds.bind(this);
|
||||
showTemporaryAnchorBinded: (e: any) => void = this.showTemporaryAnchor.bind(this);
|
||||
updateTemporaryAnchorBinded: (e: any) => void = this.updateTemporaryAnchor.bind(this);
|
||||
appendAnchorBinded: (e: mapboxgl.MapMouseEvent) => void = this.appendAnchor.bind(this);
|
||||
appendAnchorBinded: (e: maplibregl.MapMouseEvent) => void = this.appendAnchor.bind(this);
|
||||
|
||||
constructor(
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>,
|
||||
popup: mapboxgl.Popup,
|
||||
popup: maplibregl.Popup,
|
||||
popupElement: HTMLElement
|
||||
) {
|
||||
this.fileId = fileId;
|
||||
@@ -94,7 +94,8 @@ export class RoutingControls {
|
||||
|
||||
add() {
|
||||
const map_ = get(map);
|
||||
if (!map_) {
|
||||
const layerEventManager = map.layerEventManager;
|
||||
if (!map_ || !layerEventManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,8 +103,8 @@ export class RoutingControls {
|
||||
|
||||
map_.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
map_.on('click', this.appendAnchorBinded);
|
||||
map_.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
map_.on('click', this.fileId, stopPropagation);
|
||||
layerEventManager.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
layerEventManager.on('click', this.fileId, stopPropagation);
|
||||
|
||||
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
||||
}
|
||||
@@ -152,20 +153,18 @@ export class RoutingControls {
|
||||
|
||||
remove() {
|
||||
const map_ = get(map);
|
||||
if (!map_) {
|
||||
return;
|
||||
}
|
||||
const layerEventManager = map.layerEventManager;
|
||||
|
||||
this.active = false;
|
||||
|
||||
for (let anchor of this.anchors) {
|
||||
anchor.marker.remove();
|
||||
}
|
||||
map_.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
map_.off('click', this.appendAnchorBinded);
|
||||
map_.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
map_.off('click', this.fileId, stopPropagation);
|
||||
map_.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
map_?.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
map_?.off('click', this.appendAnchorBinded);
|
||||
layerEventManager?.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
layerEventManager?.off('click', this.fileId, stopPropagation);
|
||||
map_?.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
this.temporaryAnchor.marker.remove();
|
||||
|
||||
this.fileUnsubscribe();
|
||||
@@ -180,7 +179,7 @@ export class RoutingControls {
|
||||
let element = document.createElement('div');
|
||||
element.className = `h-5 w-5 xs:h-4 xs:w-4 md:h-3 md:w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
let marker = new maplibregl.Marker({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element,
|
||||
@@ -215,7 +214,7 @@ export class RoutingControls {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
handleClickForAnchor(anchor: Anchor, marker: mapboxgl.Marker) {
|
||||
handleClickForAnchor(anchor: Anchor, marker: maplibregl.Marker) {
|
||||
return (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -607,7 +606,7 @@ export class RoutingControls {
|
||||
});
|
||||
}
|
||||
|
||||
async appendAnchor(e: mapboxgl.MapMouseEvent) {
|
||||
async appendAnchor(e: maplibregl.MapMouseEvent) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
|
||||
return;
|
||||
@@ -793,24 +792,25 @@ export class RoutingControls {
|
||||
replacingDistance +=
|
||||
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
||||
}
|
||||
let startAnchorStats = stats.getTrackPoint(anchors[0].point._data.index)!;
|
||||
let endAnchorStats = stats.getTrackPoint(
|
||||
anchors[anchors.length - 1].point._data.index
|
||||
)!;
|
||||
|
||||
let replacedDistance =
|
||||
stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.distance.moving[anchors[0].point._data.index];
|
||||
endAnchorStats.distance.moving - startAnchorStats.distance.moving;
|
||||
|
||||
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
|
||||
let newTime = (newDistance / stats.global.speed.moving) * 3600;
|
||||
|
||||
let remainingTime =
|
||||
stats.global.time.moving -
|
||||
(stats.local.time.moving[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.time.moving[anchors[0].point._data.index]);
|
||||
(endAnchorStats.time.moving - startAnchorStats.time.moving);
|
||||
let replacingTime = newTime - remainingTime;
|
||||
|
||||
if (replacingTime <= 0) {
|
||||
// Fallback to simple time difference
|
||||
replacingTime =
|
||||
stats.local.time.total[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.time.total[anchors[0].point._data.index];
|
||||
replacingTime = endAnchorStats.time.total - startAnchorStats.time.total;
|
||||
}
|
||||
|
||||
speed = (replacingDistance / replacingTime) * 3600;
|
||||
@@ -820,9 +820,7 @@ export class RoutingControls {
|
||||
let endIndex = anchors[anchors.length - 1].point._data.index;
|
||||
startTime = new Date(
|
||||
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
|
||||
(replacingTime +
|
||||
stats.local.time.total[endIndex] -
|
||||
stats.local.time.moving[endIndex]) *
|
||||
(replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) *
|
||||
1000
|
||||
);
|
||||
}
|
||||
@@ -859,6 +857,6 @@ type Anchor = {
|
||||
};
|
||||
|
||||
type AnchorWithMarker = Anchor & {
|
||||
marker: mapboxgl.Marker;
|
||||
marker: maplibregl.Marker;
|
||||
inZoom: boolean;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
export const brouterProfiles: { [key: string]: string } = {
|
||||
export const routingProfiles: { [key: string]: string } = {
|
||||
bike: 'Trekking-dry',
|
||||
racing_bike: 'fastbike',
|
||||
gravel_bike: 'gravel',
|
||||
@@ -19,7 +19,7 @@ export const brouterProfiles: { [key: string]: string } = {
|
||||
|
||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
if (get(routing)) {
|
||||
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
|
||||
return getRoute(points, routingProfiles[get(routingProfile)], get(privateRoads));
|
||||
} else {
|
||||
return getIntermediatePoints(points);
|
||||
}
|
||||
|
||||
@@ -26,26 +26,24 @@
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.local.points.length > 0
|
||||
$gpxStatistics.global.length > 0
|
||||
);
|
||||
let maxSliderValue = $derived(
|
||||
validSelection && $gpxStatistics.local.points.length > 0
|
||||
? $gpxStatistics.local.points.length - 1
|
||||
: 1
|
||||
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
|
||||
);
|
||||
let sliderValues = $derived([0, maxSliderValue]);
|
||||
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
|
||||
|
||||
onMount(() => {
|
||||
if ($map) {
|
||||
splitControls = new SplitControls($map);
|
||||
splitControls = new SplitControls($map, map.layerEventManager!);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1],
|
||||
];
|
||||
|
||||
@@ -8,39 +8,33 @@ import { get } from 'svelte/store';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
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 {
|
||||
map: mapboxgl.Map;
|
||||
map: maplibregl.Map;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
||||
this.map = map;
|
||||
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
this.map.addImage('split-control', 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="white" />
|
||||
<g transform="translate(8 8)">
|
||||
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
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(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
@@ -97,7 +91,7 @@ export class SplitControls {
|
||||
}, false);
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('split-controls') as mapboxgl.GeoJSONSource | undefined;
|
||||
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
@@ -108,33 +102,42 @@ export class SplitControls {
|
||||
}
|
||||
|
||||
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,
|
||||
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']],
|
||||
},
|
||||
filter: ['<=', ['get', 'minZoom'], ['zoom']],
|
||||
});
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
|
||||
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
this.layerEventManager.on(
|
||||
'mouseenter',
|
||||
'split-controls',
|
||||
this.layerOnMouseEnterBinded
|
||||
);
|
||||
this.layerEventManager.on(
|
||||
'mouseleave',
|
||||
'split-controls',
|
||||
this.layerOnMouseLeaveBinded
|
||||
);
|
||||
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
}
|
||||
|
||||
this.map.moveLayer('split-controls');
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
|
||||
try {
|
||||
if (this.map.getLayer('split-controls')) {
|
||||
@@ -157,7 +160,7 @@ export class SplitControls {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
|
||||
}
|
||||
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
layerOnClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
|
||||
fileActions.split(
|
||||
get(splitAs),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
||||
|
||||
let props: {
|
||||
@@ -41,7 +41,7 @@
|
||||
})
|
||||
);
|
||||
|
||||
let marker: mapboxgl.Marker | null = null;
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
|
||||
function reset() {
|
||||
if ($selectedWaypoint) {
|
||||
@@ -125,7 +125,7 @@
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8');
|
||||
element.innerHTML = getSvgForSymbol(symbolKey);
|
||||
marker = new mapboxgl.Marker({
|
||||
marker = new maplibregl.Marker({
|
||||
element,
|
||||
anchor: 'bottom',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt.
|
||||
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt.
|
||||
|
||||
Wir sind äusserst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt.
|
||||
Wir sind äußerst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt.
|
||||
Diese Partnerschaft ermöglicht es **gpx.studio**, von den Mapbox-Tools zu ermäßigten Preisen zu profitieren, was erheblich zur finanziellen Tragfähigkeit des Projekts beiträgt und es uns ermöglicht, die bestmögliche Benutzererfahrung zu bieten.
|
||||
|
||||
@@ -12,6 +12,7 @@ title: Files and statistics
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let hoveredPoint = writable(null);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
</script>
|
||||
@@ -84,6 +85,7 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
Each time you add or move GPS points, our servers calculate the best route on the road network.
|
||||
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
|
||||
We also use APIs from <a href="https://maptiler.com" target="_blank">MapTiler</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
|
||||
|
||||
Unfortunately, this is expensive.
|
||||
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
Mapbox is the company that provides some of the beautiful maps on this website.
|
||||
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
|
||||
|
||||
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
|
||||
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
|
||||
2
website/src/lib/docs/en/home/maptiler.mdx
Normal file
2
website/src/lib/docs/en/home/maptiler.mdx
Normal file
@@ -0,0 +1,2 @@
|
||||
MapTiler is the company that provides some of the beautiful maps on this website.
|
||||
This partnership allows **gpx.studio** to benefit from MapTiler tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
|
||||
@@ -12,7 +12,7 @@ title: Integration
|
||||
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
|
||||
|
||||
All you need is:
|
||||
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
|
||||
1. A <a href="https://cloud.maptiler.com/auth/widget?next=https://cloud.maptiler.com/maps/" target="_blank">MapTiler key</a> to load the map, and
|
||||
1. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
|
||||
|
||||
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
|
||||
|
||||
@@ -58,7 +58,7 @@ Only one basemap can be displayed at a time.
|
||||
<div class="flex flex-col items-center">
|
||||
<DocsLayers />
|
||||
<span class="text-sm text-center mt-2">
|
||||
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
|
||||
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.maptiler.com/maps/outdoor-topo/" target="_blank">MapTiler Topo</a> basemap.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -67,4 +67,4 @@ They can be enabled in the [map layer settings dialog](./menu/settings).
|
||||
|
||||
In these settings, you can also manage the opacity of the overlays.
|
||||
|
||||
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs.
|
||||
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://maplibre.org/maplibre-style-spec/" target="_blank">MapLibre style JSON</a> URLs.
|
||||
|
||||
@@ -18,7 +18,7 @@ This tool allows you to add elevation data to traces and [points of interest](..
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.
|
||||
Elevation data is provided by <a href="https://maptiler.com" target="_blank">MapTiler</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.maptiler.com/guides/map-tiling-hosting/data-hosting/rgb-terrain-by-maptiler/" target="_blank">documentation</a>.
|
||||
|
||||
</DocsNote>
|
||||
@@ -29,13 +29,13 @@ Beste era batez, fitxategiak zuzenean arrastatu eta jaregin ditzakezu zure fitxa
|
||||
|
||||
Sortu hautatutako fitxategien kopia bat.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu
|
||||
|
||||
Delete the currently selected files.
|
||||
Ezabatu hautatutako fitxategiak.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu guztiak
|
||||
|
||||
Delete all files.
|
||||
Ezabatu fitxategi guztiak.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esportatu...
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Facendo clic destro su una scheda file, è possibile accedere alle stesse azioni
|
||||
|
||||
Come accennato nella [sezione opzioni di visualizzazione](./menu/view), è possibile passare a un layout ad albero per l'elenco dei file.
|
||||
Questo layout è ideale per gestire un gran numero di file aperti, organizzandoli in una lista verticale sul lato destro della mappa.
|
||||
Inoltre, la vista ad albero dei file consente di ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili.
|
||||
Inoltre, la vista ad albero dei file consente d'ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili.
|
||||
|
||||
Puoi anche applicare [modifiche](./menu/edit) e [strumenti](./toolbar) agli elementi interni del file.
|
||||
Inoltre, è possibile trascinare e rilasciare gli elementi per riordinarli, o spostarli nella gerarchia o anche in un altro file.
|
||||
@@ -78,7 +78,7 @@ Quando si passa sopra il profilo di elevazione, un suggerimento mostrerà le sta
|
||||
Per ottenere le statistiche per una sezione specifica del profilo di elevazione, è possibile trascinare un rettangolo di selezione sul profilo.
|
||||
Fare clic sul profilo per resettare la selezione.
|
||||
|
||||
È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto <kbd>Maiusc</kbd>.
|
||||
È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto <kbd>Maiuscolo</kbd>.
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
|
||||
@@ -21,7 +21,7 @@ Queste sono organizzate in una struttura gerarchica, con le tracce stesse al liv
|
||||
- Una **traccia** è composta da una sequenza di segmenti scollegati.
|
||||
Inoltre, può contenere metadati come un **nome**, una **descrizione**, e **proprietà di visualizzazione**.
|
||||
- Un **segmento** è una sequenza di punti GPS che formano un percorso continuo.
|
||||
- Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente un timestamp e un'altitudine.
|
||||
- Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente una marcatura temporale e un'altitudine.
|
||||
Alcuni dispositivi memorizzano anche informazioni aggiuntive come frequenza cardiaca, cadenza, temperatura e potenza.
|
||||
|
||||
Nella maggior parte dei casi, i file GPX contengono una singola traccia con un singolo segmento.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Aiuta a mantenere il sito gratuito (e senza pubblicità)
|
||||
|
||||
Ogni volta che aggiungi o sposti i punti GPS, i nostri server calcolano il percorso migliore sulla rete stradale.
|
||||
Utilizziamo anche le API di <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe gradevoli, recuperare i dati altimetrici e consentire la ricerca di luoghi.
|
||||
Utilizziamo anche le API di <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe stupende, recuperare i dati altimetrici e consentire la ricerca di luoghi.
|
||||
|
||||
Sfortunatamente, questo è costoso.
|
||||
Sfortunatamente, fare tutto ciò è costoso.
|
||||
Se ti piace utilizzare questo strumento e lo trovi utile, per favore considera di fare una piccola donazione per aiutare a mantenere il sito web gratuito e senza pubblicità.
|
||||
|
||||
Grazie mille per il vostro supporto! ❤️
|
||||
|
||||
@@ -14,7 +14,7 @@ Deze handleiding zal je door alle componenten en gereedschappen van de interface
|
||||
<DocsImage src="getting-started/interface" alt="De gpx.studio interface." />
|
||||
|
||||
Zoals weergegeven in bovenstaande scherm, is de interface verdeeld in vier hoofddelen rond de kaart.
|
||||
Voordat we in de details van elke sectie duiken, hebben we een snel overzicht van de interface.
|
||||
Voordat we in de details van elke sectie duiken, eerst een snel overzicht van de interface.
|
||||
|
||||
## Menu
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Deze actie is alleen beschikbaar wanneer de verticale indeling van de bestandsli
|
||||
|
||||
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Plakken
|
||||
|
||||
Plak de bestandsitems van het klembord naar het huidige hiërarchie niveau indien compatibel.
|
||||
Plak de bestandsitems van het klembord naar het huidige hiërarchieniveau indien compatibel.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Hãy giúp duy trì trang web miễn phí (và không có quảng cáo)
|
||||
|
||||
Khi bạn thêm hoặc di chuyển các điểm định vị, máy chủ của chúng tôi sẽ tính toán đoạn đường tốt nhất trên mạng lưới giao thông.
|
||||
Chúng tôi cũng sử dụng các API từ <a href="https://mapbox.com" target="_blank">Mapbox</a> để hiển thị đa dạng các bản đồ, lưu trữ các dữ liệu độ cao cũng như giúp bạn có thể tìm kiếm các địa điểm khác nhau.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Mapbox is the company that provides some of the beautiful maps on this website.
|
||||
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
|
||||
Mapbox là công ty cung cấp một số bản đồ đẹp trên trang web này.
|
||||
Họ cũng phát triển <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">công cụ bản đồ</a> cung cấp sức mạnh cho **gpx.studio**.
|
||||
|
||||
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> program, which supports nonprofits, educational institutions, and positive impact organizations.
|
||||
This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.
|
||||
Chúng tôi vô cùng may mắn và biết ơn khi được tham gia chương trình <a href="https://mapbox.com/community" target="_blank">Cộng đồng</a> của họ, chương trình hỗ trợ các tổ chức phi lợi nhuận, các tổ chức giáo dục và các tổ chức tạo ra tác động tích cực.
|
||||
Sự hợp tác này cho phép **gpx.studio** được hưởng lợi từ các công cụ của Mapbox với giá ưu đãi, góp phần đáng kể vào tính khả thi về tài chính của dự án và giúp chúng tôi mang đến trải nghiệm người dùng tốt nhất có thể.
|
||||
|
||||
@@ -9,8 +9,8 @@ title: Edit actions
|
||||
|
||||
# { title }
|
||||
|
||||
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
|
||||
Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
|
||||
Không giống như các thao tác trên tệp, các thao tác chỉnh sửa có thể thay đổi nội dung của các tệp hiện đang được chọn.
|
||||
Hơn nữa, khi bố cục dạng cây của danh sách tệp được bật (xem [Tệp và thống kê](../files-and-stats)), chúng cũng có thể được áp dụng cho [đường đi, đoạn đường và điểm quan tâm](../gpx).
|
||||
Therefore, we will refer to the elements that can be modified by these actions as _file items_.
|
||||
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Create a copy of the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Delete the currently selected files.
|
||||
.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ title: 文件
|
||||
|
||||
创建当前选中文件的副本。
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除
|
||||
|
||||
Delete the currently selected files.
|
||||
删除当前选中的文件。
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除全部
|
||||
|
||||
Delete all files.
|
||||
删除全部文件。
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 导出...
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { ListFileItem, ListWaypointItem } from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
@@ -10,7 +10,7 @@ import type { Coordinates } from 'gpx';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export class BoundsManager {
|
||||
private _bounds: mapboxgl.LngLatBounds = new mapboxgl.LngLatBounds();
|
||||
private _bounds: maplibregl.LngLatBounds = new maplibregl.LngLatBounds();
|
||||
private _files: Set<string> = new Set();
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
@@ -87,12 +87,12 @@ export class BoundsManager {
|
||||
}
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribes = [];
|
||||
this._bounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]);
|
||||
this._bounds = new maplibregl.LngLatBounds([180, 90, -180, -90]);
|
||||
}
|
||||
|
||||
centerMapOnSelection() {
|
||||
let selected = get(selection).getSelected();
|
||||
let bounds = new mapboxgl.LngLatBounds();
|
||||
let bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
if (selected.find((item) => item instanceof ListWaypointItem)) {
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { freeze, type WritableDraft } from 'immer';
|
||||
import {
|
||||
distance,
|
||||
GPXFile,
|
||||
parseGPX,
|
||||
Track,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
} from 'gpx';
|
||||
import { get } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { boundsManager } from './bounds';
|
||||
|
||||
@@ -216,7 +215,7 @@ export const fileActions = {
|
||||
reverseSelection: () => {
|
||||
if (
|
||||
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
|
||||
get(gpxStatistics).local.points?.length <= 1
|
||||
get(gpxStatistics).global.length <= 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -346,19 +345,20 @@ export const fileActions = {
|
||||
let startTime: Date | undefined = undefined;
|
||||
if (speed !== undefined) {
|
||||
if (
|
||||
statistics.local.points.length > 0 &&
|
||||
statistics.local.points[0].time !== undefined
|
||||
statistics.global.length > 0 &&
|
||||
statistics.getTrackPoint(0)!.trkpt.time !== undefined
|
||||
) {
|
||||
startTime = statistics.local.points[0].time;
|
||||
startTime = statistics.getTrackPoint(0)!.trkpt.time;
|
||||
} else {
|
||||
let index = statistics.local.points.findIndex(
|
||||
(point) => point.time !== undefined
|
||||
);
|
||||
if (index !== -1 && statistics.local.points[index].time) {
|
||||
startTime = new Date(
|
||||
statistics.local.points[index].time.getTime() -
|
||||
(1000 * 3600 * statistics.local.distance.total[index]) / speed
|
||||
);
|
||||
for (let i = 0; i < statistics.global.length; i++) {
|
||||
const point = statistics.getTrackPoint(i)!;
|
||||
if (point.trkpt.time !== undefined) {
|
||||
startTime = new Date(
|
||||
point.trkpt.time.getTime() -
|
||||
(1000 * 3600 * point.distance.total) / speed
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,34 +453,13 @@ export const fileActions = {
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
if (level === ListLevel.FILE) {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let statistics = fileStateCollection.getStatistics(fileId);
|
||||
if (file && statistics) {
|
||||
if (file.trk.length > 1) {
|
||||
let fileIds = getFileIds(file.trk.length);
|
||||
let closest = file.wpt.map((wpt, wptIndex) => {
|
||||
return {
|
||||
wptIndex: wptIndex,
|
||||
index: [0],
|
||||
distance: Number.MAX_VALUE,
|
||||
};
|
||||
});
|
||||
file.trk.forEach((track, index) => {
|
||||
track.getSegments().forEach((segment) => {
|
||||
segment.trkpt.forEach((point) => {
|
||||
file.wpt.forEach((wpt, wptIndex) => {
|
||||
let dist = distance(
|
||||
point.getCoordinates(),
|
||||
wpt.getCoordinates()
|
||||
);
|
||||
if (dist < closest[wptIndex].distance) {
|
||||
closest[wptIndex].distance = dist;
|
||||
closest[wptIndex].index = [index];
|
||||
} else if (dist === closest[wptIndex].distance) {
|
||||
closest[wptIndex].index.push(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let closest = file.wpt.map((wpt) =>
|
||||
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
|
||||
);
|
||||
file.trk.forEach((track, index) => {
|
||||
let newFile = file.clone();
|
||||
let tracks = track.trkseg.map((segment, segmentIndex) => {
|
||||
@@ -495,9 +474,11 @@ export const fileActions = {
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
file.wpt.filter((wpt, wptIndex) =>
|
||||
closest[wptIndex].some(
|
||||
([trackIndex, segmentIndex]) => trackIndex === index
|
||||
)
|
||||
)
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name =
|
||||
@@ -506,29 +487,9 @@ export const fileActions = {
|
||||
});
|
||||
} else if (file.trk.length === 1) {
|
||||
let fileIds = getFileIds(file.trk[0].trkseg.length);
|
||||
let closest = file.wpt.map((wpt, wptIndex) => {
|
||||
return {
|
||||
wptIndex: wptIndex,
|
||||
index: [0],
|
||||
distance: Number.MAX_VALUE,
|
||||
};
|
||||
});
|
||||
file.trk[0].trkseg.forEach((segment, index) => {
|
||||
segment.trkpt.forEach((point) => {
|
||||
file.wpt.forEach((wpt, wptIndex) => {
|
||||
let dist = distance(
|
||||
point.getCoordinates(),
|
||||
wpt.getCoordinates()
|
||||
);
|
||||
if (dist < closest[wptIndex].distance) {
|
||||
closest[wptIndex].distance = dist;
|
||||
closest[wptIndex].index = [index];
|
||||
} else if (dist === closest[wptIndex].distance) {
|
||||
closest[wptIndex].index.push(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let closest = file.wpt.map((wpt) =>
|
||||
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
|
||||
);
|
||||
file.trk[0].trkseg.forEach((segment, index) => {
|
||||
let newFile = file.clone();
|
||||
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
|
||||
@@ -537,9 +498,11 @@ export const fileActions = {
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
file.wpt.filter((wpt, wptIndex) =>
|
||||
closest[wptIndex].some(
|
||||
([trackIndex, segmentIndex]) => segmentIndex === index
|
||||
)
|
||||
)
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
|
||||
@@ -844,7 +807,7 @@ export const fileActions = {
|
||||
});
|
||||
});
|
||||
},
|
||||
addElevationToSelection: async (map: mapboxgl.Map) => {
|
||||
addElevationToSelection: async () => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import {
|
||||
basemaps,
|
||||
defaultBasemap,
|
||||
defaultBasemapTree,
|
||||
defaultOpacities,
|
||||
@@ -8,7 +9,11 @@ import {
|
||||
defaultOverlayTree,
|
||||
defaultOverpassQueries,
|
||||
defaultOverpassTree,
|
||||
defaultTerrainSource,
|
||||
overlays,
|
||||
overpassQueryData,
|
||||
type CustomLayer,
|
||||
type LayerTreeType,
|
||||
} from '$lib/assets/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
@@ -18,10 +23,12 @@ export class Setting<V> {
|
||||
private _subscription: { unsubscribe: () => void } | null = null;
|
||||
private _key: string;
|
||||
private _value: Writable<V>;
|
||||
private _validator?: (value: V) => V;
|
||||
|
||||
constructor(key: string, initial: V) {
|
||||
constructor(key: string, initial: V, validator?: (value: V) => V) {
|
||||
this._key = key;
|
||||
this._value = writable(initial);
|
||||
this._validator = validator;
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database) {
|
||||
@@ -35,6 +42,9 @@ export class Setting<V> {
|
||||
this._value.set(value);
|
||||
}
|
||||
} else {
|
||||
if (this._validator) {
|
||||
value = this._validator(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
}
|
||||
first = false;
|
||||
@@ -72,11 +82,13 @@ export class SettingInitOnFirstRead<V> {
|
||||
private _key: string;
|
||||
private _value: Writable<V | undefined>;
|
||||
private _initial: V;
|
||||
private _validator?: (value: V) => V;
|
||||
|
||||
constructor(key: string, initial: V) {
|
||||
constructor(key: string, initial: V, validator?: (value: V) => V) {
|
||||
this._key = key;
|
||||
this._value = writable(undefined);
|
||||
this._initial = initial;
|
||||
this._validator = validator;
|
||||
}
|
||||
|
||||
connectToDatabase(db: Database) {
|
||||
@@ -92,6 +104,9 @@ export class SettingInitOnFirstRead<V> {
|
||||
this._value.set(value);
|
||||
}
|
||||
} else {
|
||||
if (this._validator) {
|
||||
value = this._validator(value);
|
||||
}
|
||||
this._value.set(value);
|
||||
}
|
||||
first = false;
|
||||
@@ -127,36 +142,166 @@ export class SettingInitOnFirstRead<V> {
|
||||
}
|
||||
}
|
||||
|
||||
function getValueValidator<V>(allowed: V[], fallback: V) {
|
||||
const dict = new Set<V>(allowed);
|
||||
return (value: V) => (dict.has(value) ? value : fallback);
|
||||
}
|
||||
|
||||
function getArrayValidator<V>(allowed: V[]) {
|
||||
const dict = new Set<V>(allowed);
|
||||
return (value: V[]) => value.filter((v) => dict.has(v));
|
||||
}
|
||||
|
||||
function getLayerValidator(allowed: Record<string, any>, fallback: string) {
|
||||
return (layer: string) =>
|
||||
allowed.hasOwnProperty(layer) ||
|
||||
layer.startsWith('custom-') ||
|
||||
layer.startsWith('extension-')
|
||||
? layer
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function filterLayerTree(t: LayerTreeType, allowed: Record<string, any>): LayerTreeType {
|
||||
const filtered: LayerTreeType = {};
|
||||
Object.entries(t).forEach(([key, value]) => {
|
||||
if (typeof value === 'object') {
|
||||
filtered[key] = filterLayerTree(value, allowed);
|
||||
} else if (
|
||||
allowed.hasOwnProperty(key) ||
|
||||
key.startsWith('custom-') ||
|
||||
key.startsWith('extension-')
|
||||
) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getLayerTreeValidator(allowed: Record<string, any>) {
|
||||
return (value: LayerTreeType) => filterLayerTree(value, allowed);
|
||||
}
|
||||
|
||||
type DistanceUnits = 'metric' | 'imperial' | 'nautical';
|
||||
type VelocityUnits = 'speed' | 'pace';
|
||||
type TemperatureUnits = 'celsius' | 'fahrenheit';
|
||||
type AdditionalDataset = 'speed' | 'hr' | 'cad' | 'atemp' | 'power';
|
||||
type ElevationFill = 'slope' | 'surface' | undefined;
|
||||
type RoutingProfile =
|
||||
| 'bike'
|
||||
| 'racing_bike'
|
||||
| 'gravel_bike'
|
||||
| 'mountain_bike'
|
||||
| 'foot'
|
||||
| 'motorcycle'
|
||||
| 'water'
|
||||
| 'railway';
|
||||
type TerrainSource = 'maptiler-dem' | 'mapterhorn';
|
||||
type StreetViewSource = 'mapillary' | 'google';
|
||||
|
||||
export const settings = {
|
||||
distanceUnits: new Setting<'metric' | 'imperial' | 'nautical'>('distanceUnits', 'metric'),
|
||||
velocityUnits: new Setting<'speed' | 'pace'>('velocityUnits', 'speed'),
|
||||
temperatureUnits: new Setting<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'),
|
||||
distanceUnits: new Setting<DistanceUnits>(
|
||||
'distanceUnits',
|
||||
'metric',
|
||||
getValueValidator<DistanceUnits>(['metric', 'imperial', 'nautical'], 'metric')
|
||||
),
|
||||
velocityUnits: new Setting<VelocityUnits>(
|
||||
'velocityUnits',
|
||||
'speed',
|
||||
getValueValidator<VelocityUnits>(['speed', 'pace'], 'speed')
|
||||
),
|
||||
temperatureUnits: new Setting<TemperatureUnits>(
|
||||
'temperatureUnits',
|
||||
'celsius',
|
||||
getValueValidator<TemperatureUnits>(['celsius', 'fahrenheit'], 'celsius')
|
||||
),
|
||||
elevationProfile: new Setting<boolean>('elevationProfile', true),
|
||||
additionalDatasets: new Setting<string[]>('additionalDatasets', []),
|
||||
elevationFill: new Setting<'slope' | 'surface' | undefined>('elevationFill', undefined),
|
||||
additionalDatasets: new Setting<AdditionalDataset[]>(
|
||||
'additionalDatasets',
|
||||
[],
|
||||
getArrayValidator<AdditionalDataset>(['speed', 'hr', 'cad', 'atemp', 'power'])
|
||||
),
|
||||
elevationFill: new Setting<ElevationFill>(
|
||||
'elevationFill',
|
||||
undefined,
|
||||
getValueValidator(['slope', 'surface', undefined], undefined)
|
||||
),
|
||||
treeFileView: new Setting<boolean>('fileView', false),
|
||||
minimizeRoutingMenu: new Setting('minimizeRoutingMenu', false),
|
||||
routing: new Setting('routing', true),
|
||||
routingProfile: new Setting('routingProfile', 'bike'),
|
||||
routingProfile: new Setting<RoutingProfile>(
|
||||
'routingProfile',
|
||||
'bike',
|
||||
getValueValidator<RoutingProfile>(
|
||||
[
|
||||
'bike',
|
||||
'racing_bike',
|
||||
'gravel_bike',
|
||||
'mountain_bike',
|
||||
'foot',
|
||||
'motorcycle',
|
||||
'water',
|
||||
'railway',
|
||||
],
|
||||
'bike'
|
||||
)
|
||||
),
|
||||
privateRoads: new Setting('privateRoads', false),
|
||||
currentBasemap: new Setting('currentBasemap', defaultBasemap),
|
||||
previousBasemap: new Setting('previousBasemap', defaultBasemap),
|
||||
selectedBasemapTree: new Setting('selectedBasemapTree', defaultBasemapTree),
|
||||
currentOverlays: new SettingInitOnFirstRead('currentOverlays', defaultOverlays),
|
||||
previousOverlays: new Setting('previousOverlays', defaultOverlays),
|
||||
selectedOverlayTree: new Setting('selectedOverlayTree', defaultOverlayTree),
|
||||
currentBasemap: new Setting(
|
||||
'currentBasemap',
|
||||
defaultBasemap,
|
||||
getLayerValidator(basemaps, defaultBasemap)
|
||||
),
|
||||
previousBasemap: new Setting(
|
||||
'previousBasemap',
|
||||
defaultBasemap,
|
||||
getLayerValidator(Object.keys(basemaps), defaultBasemap)
|
||||
),
|
||||
selectedBasemapTree: new Setting(
|
||||
'selectedBasemapTree',
|
||||
defaultBasemapTree,
|
||||
getLayerTreeValidator(basemaps)
|
||||
),
|
||||
currentOverlays: new SettingInitOnFirstRead(
|
||||
'currentOverlays',
|
||||
defaultOverlays,
|
||||
getLayerTreeValidator(overlays)
|
||||
),
|
||||
previousOverlays: new Setting(
|
||||
'previousOverlays',
|
||||
defaultOverlays,
|
||||
getLayerTreeValidator(overlays)
|
||||
),
|
||||
selectedOverlayTree: new Setting(
|
||||
'selectedOverlayTree',
|
||||
defaultOverlayTree,
|
||||
getLayerTreeValidator(overlays)
|
||||
),
|
||||
currentOverpassQueries: new SettingInitOnFirstRead(
|
||||
'currentOverpassQueries',
|
||||
defaultOverpassQueries
|
||||
defaultOverpassQueries,
|
||||
getLayerTreeValidator(overpassQueryData)
|
||||
),
|
||||
selectedOverpassTree: new Setting(
|
||||
'selectedOverpassTree',
|
||||
defaultOverpassTree,
|
||||
getLayerTreeValidator(overpassQueryData)
|
||||
),
|
||||
selectedOverpassTree: new Setting('selectedOverpassTree', defaultOverpassTree),
|
||||
opacities: new Setting('opacities', defaultOpacities),
|
||||
customLayers: new Setting<Record<string, CustomLayer>>('customLayers', {}),
|
||||
customBasemapOrder: new Setting<string[]>('customBasemapOrder', []),
|
||||
customOverlayOrder: new Setting<string[]>('customOverlayOrder', []),
|
||||
terrainSource: new Setting<TerrainSource>(
|
||||
'terrainSource',
|
||||
defaultTerrainSource,
|
||||
getValueValidator(['maptiler-dem', 'mapterhorn'], defaultTerrainSource)
|
||||
),
|
||||
directionMarkers: new Setting('directionMarkers', false),
|
||||
distanceMarkers: new Setting('distanceMarkers', false),
|
||||
streetViewSource: new Setting('streetViewSource', 'mapillary'),
|
||||
streetViewSource: new Setting<StreetViewSource>(
|
||||
'streetViewSource',
|
||||
'mapillary',
|
||||
getValueValidator<StreetViewSource>(['mapillary', 'google'], 'mapillary')
|
||||
),
|
||||
fileOrder: new Setting<string[]>('fileOrder', []),
|
||||
defaultOpacity: new Setting('defaultOpacity', 0.7),
|
||||
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { ListItem, ListLevel } from '$lib/components/file-list/file-list';
|
||||
import { GPXFile, GPXStatistics, type Track } from 'gpx';
|
||||
import { GPXFile, GPXStatistics, GPXStatisticsGroup, type Track } from 'gpx';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
export class GPXStatisticsTree {
|
||||
level: ListLevel;
|
||||
statistics: {
|
||||
[key: string]: GPXStatisticsTree | GPXStatistics;
|
||||
} = {};
|
||||
wptBounds: maplibregl.LngLatBounds;
|
||||
|
||||
constructor(element: GPXFile | Track) {
|
||||
this.wptBounds = new maplibregl.LngLatBounds();
|
||||
if (element instanceof GPXFile) {
|
||||
this.level = ListLevel.FILE;
|
||||
element.children.forEach((child, index) => {
|
||||
this.statistics[index] = new GPXStatisticsTree(child);
|
||||
});
|
||||
element.wpt.forEach((wpt) => {
|
||||
this.wptBounds.extend(wpt.getCoordinates());
|
||||
});
|
||||
} else {
|
||||
this.level = ListLevel.TRACK;
|
||||
element.children.forEach((child, index) => {
|
||||
@@ -21,26 +27,48 @@ export class GPXStatisticsTree {
|
||||
}
|
||||
}
|
||||
|
||||
getStatisticsFor(item: ListItem): GPXStatistics {
|
||||
let statistics = new GPXStatistics();
|
||||
getStatisticsFor(item: ListItem): GPXStatisticsGroup {
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
let id = item.getIdAtLevel(this.level);
|
||||
if (id === undefined || id === 'waypoints') {
|
||||
Object.keys(this.statistics).forEach((key) => {
|
||||
if (this.statistics[key] instanceof GPXStatistics) {
|
||||
statistics.mergeWith(this.statistics[key]);
|
||||
statistics.add(this.statistics[key]);
|
||||
} else {
|
||||
statistics.mergeWith(this.statistics[key].getStatisticsFor(item));
|
||||
statistics.add(this.statistics[key].getStatisticsFor(item));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let child = this.statistics[id];
|
||||
if (child instanceof GPXStatistics) {
|
||||
statistics.mergeWith(child);
|
||||
statistics.add(child);
|
||||
} else if (child !== undefined) {
|
||||
statistics.mergeWith(child.getStatisticsFor(item));
|
||||
statistics.add(child.getStatisticsFor(item));
|
||||
}
|
||||
}
|
||||
return statistics;
|
||||
}
|
||||
|
||||
intersectsBBox(bounds: maplibregl.LngLatBounds): boolean {
|
||||
for (let key in this.statistics) {
|
||||
const stats = this.statistics[key];
|
||||
if (stats instanceof GPXStatistics) {
|
||||
const bbox = new maplibregl.LngLatBounds(
|
||||
stats.global.bounds.southWest,
|
||||
stats.global.bounds.northEast
|
||||
);
|
||||
if (!bbox.isEmpty() && bbox.intersects(bounds)) {
|
||||
return true;
|
||||
}
|
||||
} else if (stats.intersectsBBox(bounds)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
intersectsWaypointBBox(bounds: maplibregl.LngLatBounds): boolean {
|
||||
return !this.wptBounds.isEmpty() && this.wptBounds.intersects(bounds);
|
||||
}
|
||||
}
|
||||
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { GPXStatistics } from 'gpx';
|
||||
import { GPXGlobalStatistics, GPXStatisticsGroup, type Coordinates } from 'gpx';
|
||||
import { fileStateCollection, GPXFileState } from '$lib/logic/file-state';
|
||||
import {
|
||||
ListFileItem,
|
||||
@@ -12,7 +12,7 @@ import { settings } from '$lib/logic/settings';
|
||||
const { fileOrder } = settings;
|
||||
|
||||
export class SelectedGPXStatistics {
|
||||
private _statistics: Writable<GPXStatistics>;
|
||||
private _statistics: Writable<GPXStatisticsGroup>;
|
||||
private _files: Map<
|
||||
string,
|
||||
{
|
||||
@@ -22,18 +22,21 @@ export class SelectedGPXStatistics {
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this._statistics = writable(new GPXStatistics());
|
||||
this._statistics = writable(new GPXStatisticsGroup());
|
||||
this._files = new Map();
|
||||
selection.subscribe(() => this.update());
|
||||
fileOrder.subscribe(() => this.update());
|
||||
}
|
||||
|
||||
subscribe(run: (value: GPXStatistics) => void, invalidate?: (value?: GPXStatistics) => void) {
|
||||
subscribe(
|
||||
run: (value: GPXStatisticsGroup) => void,
|
||||
invalidate?: (value?: GPXStatisticsGroup) => void
|
||||
) {
|
||||
return this._statistics.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
update() {
|
||||
let statistics = new GPXStatistics();
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let stats = fileStateCollection.getStatistics(fileId);
|
||||
if (stats) {
|
||||
@@ -43,7 +46,7 @@ export class SelectedGPXStatistics {
|
||||
!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) ||
|
||||
first
|
||||
) {
|
||||
statistics.mergeWith(stats.getStatisticsFor(item));
|
||||
statistics.add(stats.getStatisticsFor(item));
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
@@ -76,9 +79,11 @@ export class SelectedGPXStatistics {
|
||||
|
||||
export const gpxStatistics = new SelectedGPXStatistics();
|
||||
|
||||
export const slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined> =
|
||||
export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> =
|
||||
writable(undefined);
|
||||
|
||||
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
||||
|
||||
gpxStatistics.subscribe(() => {
|
||||
slicedGPXStatistics.set(undefined);
|
||||
});
|
||||
|
||||
@@ -229,6 +229,9 @@ export function getConvertedVelocity(
|
||||
}
|
||||
}
|
||||
|
||||
export function getConvertedTemperature(value: number) {
|
||||
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
|
||||
export function getConvertedTemperature(
|
||||
value: number,
|
||||
targetTemperatureUnits = get(temperatureUnits)
|
||||
) {
|
||||
return targetTemperatureUnits === 'celsius' ? value : celsiusToFahrenheit(value);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { base } from '$app/paths';
|
||||
import { languages } from '$lib/languages';
|
||||
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance, GPXFile } from 'gpx';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { pointToTile, pointToTileFraction } from '@mapbox/tilebelt';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import PNGReader from 'png.js';
|
||||
import type { GPXStatisticsTree } from '$lib/logic/statistics-tree';
|
||||
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -47,6 +48,59 @@ export function getClosestLinePoint(
|
||||
return closest;
|
||||
}
|
||||
|
||||
export function getClosestTrackSegments(
|
||||
file: GPXFile,
|
||||
statistics: GPXStatisticsTree,
|
||||
point: Coordinates
|
||||
): [number, number][] {
|
||||
let segmentBoundsDistances: [number, number, number][] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentStatistics = statistics.getStatisticsFor(
|
||||
new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex)
|
||||
);
|
||||
let segmentBounds = segmentStatistics.global.bounds;
|
||||
let northEast = segmentBounds.northEast;
|
||||
let southWest = segmentBounds.southWest;
|
||||
let bounds = new maplibregl.LngLatBounds(southWest, northEast);
|
||||
if (bounds.contains(point)) {
|
||||
segmentBoundsDistances.push([0, trackIndex, segmentIndex]);
|
||||
} else {
|
||||
let northWest: Coordinates = { lat: northEast.lat, lon: southWest.lon };
|
||||
let southEast: Coordinates = { lat: southWest.lat, lon: northEast.lon };
|
||||
let distanceToBounds = Math.min(
|
||||
crossarcDistance(northWest, northEast, point),
|
||||
crossarcDistance(northEast, southEast, point),
|
||||
crossarcDistance(southEast, southWest, point),
|
||||
crossarcDistance(southWest, northWest, point)
|
||||
);
|
||||
segmentBoundsDistances.push([distanceToBounds, trackIndex, segmentIndex]);
|
||||
}
|
||||
});
|
||||
segmentBoundsDistances.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let closest: { distance: number; indices: [number, number][] } = {
|
||||
distance: Number.MAX_VALUE,
|
||||
indices: [],
|
||||
};
|
||||
for (let s = 0; s < segmentBoundsDistances.length; s++) {
|
||||
if (segmentBoundsDistances[s][0] > closest.distance) {
|
||||
break;
|
||||
}
|
||||
const segment = file.getSegment(segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]);
|
||||
segment.trkpt.forEach((pt) => {
|
||||
let dist = distance(pt.getCoordinates(), point);
|
||||
if (dist < closest.distance) {
|
||||
closest.distance = dist;
|
||||
closest.indices = [[segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]];
|
||||
} else if (dist === closest.distance) {
|
||||
closest.indices.push([segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return closest.indices;
|
||||
}
|
||||
|
||||
export function getElevation(
|
||||
points: (TrackPoint | Waypoint | Coordinates)[],
|
||||
ELEVATION_ZOOM: number = 13,
|
||||
@@ -55,33 +109,49 @@ export function getElevation(
|
||||
let coordinates = points.map((point) =>
|
||||
point instanceof TrackPoint || point instanceof Waypoint ? point.getCoordinates() : point
|
||||
);
|
||||
let bbox = new mapboxgl.LngLatBounds();
|
||||
let bbox = new maplibregl.LngLatBounds();
|
||||
coordinates.forEach((coord) => bbox.extend(coord));
|
||||
|
||||
let tiles = coordinates.map((coord) => pointToTile(coord.lon, coord.lat, ELEVATION_ZOOM));
|
||||
let uniqueTiles = Array.from(new Set(tiles.map((tile) => tile.join(',')))).map((tile) =>
|
||||
tile.split(',').map((x) => parseInt(x))
|
||||
);
|
||||
let pngs = new Map<string, any>();
|
||||
let images = new Map<string, ImageData>();
|
||||
|
||||
const getPixelFromImageData = (imageData: ImageData, x: number, y: number): number[] => {
|
||||
const index = (y * imageData.width + x) * 4;
|
||||
return [imageData.data[index], imageData.data[index + 1], imageData.data[index + 2]];
|
||||
};
|
||||
|
||||
let promises = uniqueTiles.map((tile) =>
|
||||
fetch(
|
||||
`https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/${ELEVATION_ZOOM}/${tile[0]}/${tile[1]}@2x.pngraw?access_token=${PUBLIC_MAPBOX_TOKEN}`,
|
||||
`https://api.maptiler.com/tiles/terrain-rgb-v2/${ELEVATION_ZOOM}/${tile[0]}/${tile[1]}.webp?key=${PUBLIC_MAPTILER_KEY}`,
|
||||
{ cache: 'force-cache' }
|
||||
)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((response) => response.blob())
|
||||
.then(
|
||||
(buffer) =>
|
||||
new Promise((resolve) => {
|
||||
let png = new PNGReader(new Uint8Array(buffer));
|
||||
png.parse((err, png) => {
|
||||
if (err) {
|
||||
resolve(false); // Also resolve so that Promise.all doesn't fail
|
||||
} else {
|
||||
pngs.set(tile.join(','), png);
|
||||
resolve(true);
|
||||
(blob) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
images.set(tile.join(','), imageData);
|
||||
}
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
img.src = url;
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -89,9 +159,9 @@ export function getElevation(
|
||||
return Promise.all(promises).then(() =>
|
||||
coordinates.map((coord, index) => {
|
||||
let tile = tiles[index];
|
||||
let png = pngs.get(tile.join(','));
|
||||
let imageData = images.get(tile.join(','));
|
||||
|
||||
if (!png) {
|
||||
if (!imageData) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -103,10 +173,11 @@ export function getElevation(
|
||||
let dx = x - _x;
|
||||
let dy = y - _y;
|
||||
|
||||
const p00 = png.getPixel(_x, _y);
|
||||
const p01 = png.getPixel(_x, _y + (_y + 1 == tileSize ? 0 : 1));
|
||||
const p10 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y);
|
||||
const p11 = png.getPixel(
|
||||
const p00 = getPixelFromImageData(imageData, _x, _y);
|
||||
const p01 = getPixelFromImageData(imageData, _x, _y + (_y + 1 == tileSize ? 0 : 1));
|
||||
const p10 = getPixelFromImageData(imageData, _x + (_x + 1 == tileSize ? 0 : 1), _y);
|
||||
const p11 = getPixelFromImageData(
|
||||
imageData,
|
||||
_x + (_x + 1 == tileSize ? 0 : 1),
|
||||
_y + (_y + 1 == tileSize ? 0 : 1)
|
||||
);
|
||||
@@ -126,6 +197,18 @@ export function getElevation(
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSVGIcon(map: maplibregl.Map, id: string, svg: string) {
|
||||
if (!map.hasImage(id)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!map.hasImage(id)) {
|
||||
map.addImage(id, icon);
|
||||
}
|
||||
};
|
||||
icon.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
|
||||
}
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
return navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry"
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Actualitza la capa"
|
||||
},
|
||||
"opacity": "Opacitat de la superposició",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Mapes base",
|
||||
"overlays": "Capes",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Estació de tren",
|
||||
"tram-stop": "Parada de tramvia",
|
||||
"bus-stop": "Parada d'autobús",
|
||||
"ferry": "Ferri"
|
||||
"ferry": "Ferri",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Aktualizovat vrstvu"
|
||||
},
|
||||
"opacity": "Průhlednost překryvu",
|
||||
"terrain": "Zdroj terénu",
|
||||
"label": {
|
||||
"basemaps": "Základní mapy",
|
||||
"overlays": "Překrytí",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Vrstevnice",
|
||||
"swisstopoHiking": "swisstopo Turistická",
|
||||
"swisstopoHikingClosures": "swisstopo Turistické uzávěry",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Železniční stanice",
|
||||
"tram-stop": "Zastávka tramvaje",
|
||||
"bus-stop": "Autobusová zastávka",
|
||||
"ferry": "Trajekt"
|
||||
"ferry": "Trajekt",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry"
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Layer aktualisieren"
|
||||
},
|
||||
"opacity": "Deckkraft der Überlagerung",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basiskarte",
|
||||
"overlays": "Ebenen",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Neigung",
|
||||
"swisstopoHiking": "swisstopo Wandern",
|
||||
"swisstopoHikingClosures": "swisstopo Wanderungen Schließungen",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Bahnhof",
|
||||
"tram-stop": "Straßenbahnhaltestelle",
|
||||
"bus-stop": "Bushaltestelle",
|
||||
"ferry": "Fähre"
|
||||
"ferry": "Fähre",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -473,7 +478,7 @@
|
||||
},
|
||||
"homepage": {
|
||||
"website": "Webseite",
|
||||
"home": "Zuhause",
|
||||
"home": "Startseite",
|
||||
"app": "App",
|
||||
"contact": "Kontakt",
|
||||
"reddit": "Reddit",
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry"
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
},
|
||||
"elevation": {
|
||||
"button": "Request elevation data",
|
||||
"help": "Requesting elevation data will erase the existing elevation data, if any, and replace it with data from Mapbox.",
|
||||
"help": "Requesting elevation data will erase the existing elevation data, if any, and replace it with data from MapTiler.",
|
||||
"help_no_selection": "Select a file item to request elevation data."
|
||||
},
|
||||
"waypoint": {
|
||||
@@ -273,7 +273,7 @@
|
||||
"new": "New custom layer",
|
||||
"edit": "Edit custom layer",
|
||||
"urls": "URL(s)",
|
||||
"url_placeholder": "WMTS, WMS or Mapbox style JSON",
|
||||
"url_placeholder": "WMTS, WMS or MapLibre style JSON",
|
||||
"max_zoom": "Max zoom",
|
||||
"layer_type": "Layer type",
|
||||
"basemap": "Basemap",
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -299,8 +300,9 @@
|
||||
"switzerland": "Switzerland",
|
||||
"united_kingdom": "United Kingdom",
|
||||
"united_states": "United States",
|
||||
"mapboxOutdoors": "Mapbox Outdoors",
|
||||
"mapboxSatellite": "Mapbox Satellite",
|
||||
"maptilerTopo": "MapTiler Topo",
|
||||
"maptilerOutdoors": "MapTiler Outdoors",
|
||||
"maptilerSatellite": "MapTiler Satellite",
|
||||
"openStreetMap": "OpenStreetMap",
|
||||
"openTopoMap": "OpenTopoMap",
|
||||
"openHikingMap": "OpenHikingMap",
|
||||
@@ -325,6 +327,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +381,9 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry"
|
||||
"ferry": "Ferry",
|
||||
"maptiler-dem": "MapTiler DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -518,7 +524,7 @@
|
||||
},
|
||||
"embedding": {
|
||||
"title": "Create your own map",
|
||||
"mapbox_token": "Mapbox access token",
|
||||
"maptiler_key": "MapTiler key",
|
||||
"file_urls": "File URLs (separated by commas)",
|
||||
"drive_ids": "Google Drive file IDs (separated by commas)",
|
||||
"basemap": "Basemap",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"metadata": {
|
||||
"home_title": "el editor online de archivos GPX",
|
||||
"app_title": "app",
|
||||
"embed_title": "El editor online de archivos GPX",
|
||||
"embed_title": " editor online de archivos GPX",
|
||||
"help_title": "ayuda",
|
||||
"404_title": "página no encontrada",
|
||||
"description": "Mira, edita y crea archivos GPX online con planificación avanzada de rutas y herramientas de procesamiento de archivos, bonitos mapas y visualizaciones detalladas de datos."
|
||||
@@ -80,7 +80,7 @@
|
||||
"center": "Centrar",
|
||||
"open_in": "Abrir en",
|
||||
"copy_coordinates": "Copiar coordenadas",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"edit_osm": "Editar en OpenStreetMap"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Actualizar capa"
|
||||
},
|
||||
"opacity": "Opacidad de la capa superpuesta",
|
||||
"terrain": "Origen del terreno",
|
||||
"label": {
|
||||
"basemaps": "Mapas base",
|
||||
"overlays": "Capas",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "Gravel bikerouter.de",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Senderismo",
|
||||
"swisstopoHikingClosures": "swisstopo Rutas Senderismo",
|
||||
@@ -353,7 +356,7 @@
|
||||
"water": "Agua",
|
||||
"shower": "Ducha",
|
||||
"shelter": "Refugio",
|
||||
"cemetery": "Cemetery",
|
||||
"cemetery": "Cementerio",
|
||||
"motorized": "Coches y motos",
|
||||
"fuel-station": "Gasolinera",
|
||||
"parking": "Aparcamiento",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Estación de tren",
|
||||
"tram-stop": "Parada de tranvía",
|
||||
"bus-stop": "Parada de autobús",
|
||||
"ferry": "Ferri"
|
||||
"ferry": "Ferri",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"undo": "Desegin",
|
||||
"redo": "Berregin",
|
||||
"delete": "Ezabatu",
|
||||
"delete_all": "Delete all",
|
||||
"delete_all": "Ezabatu guztiak",
|
||||
"select_all": "Hautatu dena",
|
||||
"view": "Ikusi",
|
||||
"elevation_profile": "Altuera profila",
|
||||
@@ -80,7 +80,7 @@
|
||||
"center": "Erdiratu",
|
||||
"open_in": "Ireki hemen",
|
||||
"copy_coordinates": "Kopiatu koordenatuak",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"edit_osm": "Editatu OpenStreeMapen"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Eguneratu geruza"
|
||||
},
|
||||
"opacity": "Geruzaren opakutasuna",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Oinarrizko mapak",
|
||||
"overlays": "Geruzak",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Malda",
|
||||
"swisstopoHiking": "swisstopo Mendi ibilaldiak",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -353,7 +356,7 @@
|
||||
"water": "Ura",
|
||||
"shower": "Dutxa",
|
||||
"shelter": "Babeslekua",
|
||||
"cemetery": "Cemetery",
|
||||
"cemetery": "Hilerria",
|
||||
"motorized": "Kotxeak eta motorrak",
|
||||
"fuel-station": "Gasolindegia",
|
||||
"parking": "Aparkalekua",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Tren geltokia",
|
||||
"tram-stop": "Tranbia geltokia",
|
||||
"bus-stop": "Autobus geltokia",
|
||||
"ferry": "Ferria"
|
||||
"ferry": "Ferria",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"update": "Päivitä karttataso"
|
||||
},
|
||||
"opacity": "Peitetason läpinäkyvyys",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Taustakartat",
|
||||
"overlays": "Peitetasot",
|
||||
@@ -325,6 +326,8 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Rinnekaltevuus",
|
||||
"swisstopoHiking": "swisstopo Retkeilyreitit",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -377,7 +380,9 @@
|
||||
"railway-station": "Rautatieasemat",
|
||||
"tram-stop": "Raitiovaunupysäkit",
|
||||
"bus-stop": "Linja-autopysäkit",
|
||||
"ferry": "Lautat"
|
||||
"ferry": "Lautat",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user