mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-10-15 20:08:19 +00:00
Compare commits
217 Commits
preserve-t
...
f3aae26996
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f3aae26996 | ||
![]() |
f3c17a8e0f | ||
![]() |
d6b24f8753 | ||
![]() |
253db0a303 | ||
![]() |
8499e52461 | ||
![]() |
d0153179a9 | ||
![]() |
264d03727e | ||
![]() |
544405d9b9 | ||
![]() |
24488a3b67 | ||
![]() |
ae78185b29 | ||
![]() |
7f682b24ef | ||
![]() |
d42a52d8cf | ||
![]() |
b85df15890 | ||
![]() |
393499f34f | ||
![]() |
c656d0f9b5 | ||
![]() |
32017a8859 | ||
![]() |
d87c5b1140 | ||
![]() |
f59f783d3f | ||
![]() |
ec298eac61 | ||
![]() |
81a25bb4ee | ||
![]() |
e99f044e45 | ||
![]() |
5ae25a5fd9 | ||
![]() |
e9d1cb4907 | ||
![]() |
99f8ca2dca | ||
![]() |
ddea5d38b5 | ||
![]() |
31d2b83550 | ||
![]() |
5535e56ed2 | ||
![]() |
d740b95dbc | ||
![]() |
ae92e9a945 | ||
![]() |
29730c3896 | ||
![]() |
a5ae8270f0 | ||
![]() |
54f5fa6432 | ||
![]() |
0260644063 | ||
![]() |
267fc03a82 | ||
![]() |
bf1537584c | ||
![]() |
9ee7825022 | ||
![]() |
2be0c42dd1 | ||
![]() |
3423c053a2 | ||
![]() |
26923cca00 | ||
![]() |
36e027659c | ||
![]() |
f447dccdb4 | ||
![]() |
69eae32851 | ||
![]() |
aa2fcfb8cb | ||
![]() |
fae5ef2a41 | ||
![]() |
7251ca7d2d | ||
![]() |
7cdbd919bf | ||
![]() |
d450f95602 | ||
![]() |
5a65201971 | ||
![]() |
d303b8db3e | ||
![]() |
06baa33827 | ||
![]() |
42743e637e | ||
![]() |
9969fd7dec | ||
![]() |
fc6d5c2a1d | ||
![]() |
f8abb1ca24 | ||
![]() |
a5af38ae3d | ||
![]() |
aab70951dc | ||
![]() |
334cacf93c | ||
![]() |
53024012fc | ||
![]() |
86a72f77c1 | ||
![]() |
bc11a5ad0a | ||
![]() |
8f2d217fd4 | ||
![]() |
183727cd50 | ||
![]() |
676e87591a | ||
![]() |
8c05fc4da0 | ||
![]() |
2bab06561e | ||
![]() |
dfa7e2f5bb | ||
![]() |
78bece5616 | ||
![]() |
eeea15e373 | ||
![]() |
80cd513ab7 | ||
![]() |
942ef1615e | ||
![]() |
a354698022 | ||
![]() |
0cdea488c9 | ||
![]() |
4f4291ac47 | ||
![]() |
bf0cf03091 | ||
![]() |
f7da09f20f | ||
![]() |
be1529331c | ||
![]() |
301d658a29 | ||
![]() |
1cc54e5b2c | ||
![]() |
65a7fd21e7 | ||
![]() |
856537c0cd | ||
![]() |
b2a88e0063 | ||
![]() |
85a7068785 | ||
![]() |
cbb733d99a | ||
![]() |
ce88c94a19 | ||
![]() |
16516915d8 | ||
![]() |
6addb8da23 | ||
![]() |
bc7f664fd8 | ||
![]() |
aac17aa33c | ||
![]() |
825500e207 | ||
![]() |
4d42016c72 | ||
![]() |
9d665df602 | ||
![]() |
9087f69fb0 | ||
![]() |
2a06f6a214 | ||
![]() |
78a8428bd0 | ||
![]() |
0d235768fa | ||
![]() |
af092bbdec | ||
![]() |
4961630d62 | ||
![]() |
81920b9ab9 | ||
![]() |
9e031d3b5b | ||
![]() |
7ae3ed6d2a | ||
![]() |
05d79f2b51 | ||
![]() |
274e591354 | ||
![]() |
95fd152b3d | ||
![]() |
ffc91ed6d8 | ||
![]() |
de0b759875 | ||
![]() |
f041dcf944 | ||
![]() |
946b9bd9d1 | ||
![]() |
db77a69838 | ||
![]() |
d10f4d26e2 | ||
![]() |
6b62d686ba | ||
![]() |
065826e64d | ||
![]() |
a3b096343f | ||
![]() |
b33be91b06 | ||
![]() |
a94a1816c5 | ||
![]() |
9a9e7fea07 | ||
![]() |
9a03042077 | ||
![]() |
704d3b2d6b | ||
![]() |
e5c2be238d | ||
![]() |
9feea07527 | ||
![]() |
b0967d03b8 | ||
![]() |
d33fd71f93 | ||
![]() |
226b5b2682 | ||
![]() |
f8879b0223 | ||
![]() |
ada09d96c4 | ||
![]() |
a9ea0e223d | ||
![]() |
37e8237f78 | ||
![]() |
40deb19837 | ||
![]() |
a9deb681e0 | ||
![]() |
018e638ae3 | ||
![]() |
967d271667 | ||
![]() |
4e92a16d8d | ||
![]() |
b4bcda12c2 | ||
![]() |
e5bb902cd4 | ||
![]() |
a3817fd5cd | ||
![]() |
3a6338e9fb | ||
![]() |
34d2342bdb | ||
![]() |
5623a6b662 | ||
![]() |
de0cc63d53 | ||
![]() |
cb4892ace3 | ||
![]() |
3cd17d7409 | ||
![]() |
2848402e97 | ||
![]() |
7e8f1acf67 | ||
![]() |
8aa8a77260 | ||
![]() |
ea4e078c92 | ||
![]() |
21261f732f | ||
![]() |
4e9d65089c | ||
![]() |
e4f6dfbf78 | ||
![]() |
9083784ffd | ||
![]() |
6c4c4dbac9 | ||
![]() |
ea0abf15ce | ||
![]() |
71cccc5bdc | ||
![]() |
4964928ac0 | ||
![]() |
7fde60a267 | ||
![]() |
306ed2ae0e | ||
![]() |
a7cfe36b2e | ||
![]() |
f4879a9e8a | ||
![]() |
c8e09fcd90 | ||
![]() |
c5f20d323c | ||
![]() |
e3dcdf2f41 | ||
![]() |
82d8b5d61e | ||
![]() |
47692656e4 | ||
![]() |
b5bf06b37a | ||
![]() |
bc3b1e5f7c | ||
![]() |
63eae15191 | ||
![]() |
848b6dcef3 | ||
![]() |
dfcdd71057 | ||
![]() |
7368945bf3 | ||
![]() |
de52203e89 | ||
![]() |
62d1a3e01f | ||
![]() |
68b4ecadf5 | ||
![]() |
7831774703 | ||
![]() |
52984d4b70 | ||
![]() |
0b457f9a1e | ||
![]() |
01cfd448f0 | ||
![]() |
c189ebd8ca | ||
![]() |
b35d11c9ed | ||
![]() |
5fa5908072 | ||
![]() |
a89f2754d3 | ||
![]() |
453ae55db0 | ||
![]() |
c1a5bdd7ae | ||
![]() |
d19e702084 | ||
![]() |
e02a22eaea | ||
![]() |
63f3d63518 | ||
![]() |
0d03ebfe96 | ||
![]() |
074da855c1 | ||
![]() |
9a028b9d5d | ||
![]() |
a502980a39 | ||
![]() |
1a1f6e5131 | ||
![]() |
a67501e6de | ||
![]() |
1f4164aca3 | ||
![]() |
25e05f9855 | ||
![]() |
1555799533 | ||
![]() |
93d5211d27 | ||
![]() |
de38fea917 | ||
![]() |
3a3bc1c0db | ||
![]() |
b5871d974e | ||
![]() |
2985c3201b | ||
![]() |
cf2fcd8221 | ||
![]() |
2cb21a43c5 | ||
![]() |
bae0a3f93b | ||
![]() |
a853c45ec7 | ||
![]() |
6cb6c88cd1 | ||
![]() |
077f2b4435 | ||
![]() |
8c3c4860f8 | ||
![]() |
143592f724 | ||
![]() |
dc404706c5 | ||
![]() |
7a80e9e104 | ||
![]() |
d7aae81c41 | ||
![]() |
745c7e8470 | ||
![]() |
52623350bd | ||
![]() |
7d2c030ebd | ||
![]() |
b841326e19 | ||
![]() |
23c41f18de | ||
![]() |
44e11e1a51 | ||
![]() |
798d8e7a14 | ||
![]() |
29748cf114 | ||
![]() |
f5794b1355 |
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.svelte",
|
||||
"options": {
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"svelte.svelte-vscode"
|
||||
]
|
||||
}
|
||||
|
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
}
|
||||
}
|
||||
|
1
gpx/.prettierignore
Normal file
1
gpx/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
package-lock.json
|
1617
gpx/package-lock.json
generated
1617
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,20 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"immer": "^10.1.1",
|
||||
"ts-node": "^10.9.2"
|
||||
"immer": "^10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/node": "^20.16.10",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"prettier": "^3.4.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build"
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
}
|
||||
}
|
||||
|
992
gpx/src/gpx.ts
992
gpx/src/gpx.ts
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,3 @@ export * from './gpx';
|
||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
|
110
gpx/src/io.ts
110
gpx/src/io.ts
@@ -1,25 +1,68 @@
|
||||
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
||||
import { GPXFileType } from "./types";
|
||||
import { GPXFile } from "./gpx";
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { GPXFileType } from './types';
|
||||
import { GPXFile } from './gpx';
|
||||
|
||||
const attributesWithNamespace = {
|
||||
RoutePointExtension: 'gpxx:RoutePointExtension',
|
||||
rpt: 'gpxx:rpt',
|
||||
TrackPointExtension: 'gpxtpx:TrackPointExtension',
|
||||
PowerExtension: 'gpxpx:PowerExtension',
|
||||
atemp: 'gpxtpx:atemp',
|
||||
hr: 'gpxtpx:hr',
|
||||
cad: 'gpxtpx:cad',
|
||||
Extensions: 'gpxtpx:Extensions',
|
||||
PowerInWatts: 'gpxpx:PowerInWatts',
|
||||
power: 'gpxpx:PowerExtension',
|
||||
line: 'gpx_style:line',
|
||||
color: 'gpx_style:color',
|
||||
opacity: 'gpx_style:opacity',
|
||||
width: 'gpx_style:width',
|
||||
};
|
||||
|
||||
const floatPatterns = [
|
||||
/[-+]?\d*\.\d+$/, // decimal
|
||||
/[-+]?\d+$/, // integer
|
||||
];
|
||||
function safeParseFloat(value: string): number {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
for (const pattern of floatPatterns) {
|
||||
const match = value.match(pattern);
|
||||
if (match) {
|
||||
return parseFloat(match[0]);
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
export function parseGPX(gpxData: string): GPXFile {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
attributeNamePrefix: '',
|
||||
attributesGroupName: 'attributes',
|
||||
removeNSPrefix: true,
|
||||
isArray(name: string) {
|
||||
return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
|
||||
return (
|
||||
name === 'trk' ||
|
||||
name === 'trkseg' ||
|
||||
name === 'trkpt' ||
|
||||
name === 'wpt' ||
|
||||
name === 'rte' ||
|
||||
name === 'rtept' ||
|
||||
name === 'gpxx:rpt'
|
||||
);
|
||||
},
|
||||
attributeValueProcessor(attrName, attrValue, jPath) {
|
||||
if (attrName === 'lat' || attrName === 'lon') {
|
||||
return parseFloat(attrValue);
|
||||
return safeParseFloat(attrValue);
|
||||
}
|
||||
return attrValue;
|
||||
},
|
||||
transformTagName(tagName: string) {
|
||||
if (tagName === 'power') {
|
||||
// Transform the simple <power> tag to the more complex <gpxpx:PowerExtension> tag, the nested <gpxpx:PowerInWatts> tag is then handled by the tagValueProcessor
|
||||
return 'gpxpx:PowerExtension';
|
||||
if (attributesWithNamespace[tagName]) {
|
||||
return attributesWithNamespace[tagName];
|
||||
}
|
||||
return tagName;
|
||||
},
|
||||
@@ -27,22 +70,29 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
|
||||
if (isLeafNode) {
|
||||
if (tagName === 'ele') {
|
||||
return parseFloat(tagValue);
|
||||
return safeParseFloat(tagValue);
|
||||
}
|
||||
|
||||
if (tagName === 'time') {
|
||||
return new Date(tagValue);
|
||||
}
|
||||
|
||||
if (tagName === 'gpxtpx:atemp' || tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
|
||||
return parseFloat(tagValue);
|
||||
if (
|
||||
tagName === 'gpxtpx:atemp' ||
|
||||
tagName === 'gpxtpx:hr' ||
|
||||
tagName === 'gpxtpx:cad' ||
|
||||
tagName === 'gpxpx:PowerInWatts' ||
|
||||
tagName === 'gpx_style:opacity' ||
|
||||
tagName === 'gpx_style:width'
|
||||
) {
|
||||
return safeParseFloat(tagValue);
|
||||
}
|
||||
|
||||
if (tagName === 'gpxpx:PowerExtension') {
|
||||
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
||||
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
||||
return {
|
||||
'gpxpx:PowerInWatts': parseFloat(tagValue)
|
||||
'gpxpx:PowerInWatts': safeParseFloat(tagValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -54,7 +104,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
||||
|
||||
// @ts-ignore
|
||||
if (parsed.metadata === "") {
|
||||
if (parsed.metadata === '') {
|
||||
parsed.metadata = {};
|
||||
}
|
||||
|
||||
@@ -64,25 +114,32 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
const gpx = file.toGPXFileType(exclude);
|
||||
|
||||
let lastDate = undefined;
|
||||
const builder = new XMLBuilder({
|
||||
format: true,
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
attributeNamePrefix: '',
|
||||
attributesGroupName: 'attributes',
|
||||
suppressEmptyNode: true,
|
||||
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
|
||||
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => {
|
||||
if (tagValue instanceof Date) {
|
||||
if (isNaN(tagValue.getTime())) {
|
||||
return lastDate?.toISOString();
|
||||
}
|
||||
lastDate = tagValue;
|
||||
return tagValue.toISOString();
|
||||
}
|
||||
return tagValue.toString();
|
||||
},
|
||||
});
|
||||
|
||||
gpx.attributes.creator = gpx.attributes.creator ?? 'https://gpx.studio';
|
||||
if (!gpx.attributes) gpx.attributes = {};
|
||||
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
|
||||
gpx.attributes['version'] = '1.1';
|
||||
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
|
||||
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
gpx.attributes['xsi:schemaLocation'] = 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['xsi:schemaLocation'] =
|
||||
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
||||
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
||||
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
||||
@@ -93,19 +150,24 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
}
|
||||
|
||||
return builder.build({
|
||||
"?xml": {
|
||||
'?xml': {
|
||||
attributes: {
|
||||
version: "1.0",
|
||||
encoding: "UTF-8",
|
||||
}
|
||||
version: '1.0',
|
||||
encoding: 'UTF-8',
|
||||
},
|
||||
gpx: removeEmptyElements(gpx)
|
||||
},
|
||||
gpx: removeEmptyElements(gpx),
|
||||
});
|
||||
}
|
||||
|
||||
function removeEmptyElements(obj: GPXFileType): GPXFileType {
|
||||
for (const key in obj) {
|
||||
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
|
||||
if (
|
||||
obj[key] === null ||
|
||||
obj[key] === undefined ||
|
||||
obj[key] === '' ||
|
||||
(Array.isArray(obj[key]) && obj[key].length === 0)
|
||||
) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
||||
removeEmptyElements(obj[key]);
|
||||
|
@@ -1,33 +1,48 @@
|
||||
import { TrackPoint } from "./gpx";
|
||||
import { Coordinates } from "./types";
|
||||
import { TrackPoint } from './gpx';
|
||||
import { Coordinates } from './types';
|
||||
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
|
||||
export function ramerDouglasPeucker(
|
||||
points: TrackPoint[],
|
||||
epsilon: number = 50,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
|
||||
): SimplifiedTrackPoint[] {
|
||||
if (points.length == 0) {
|
||||
return [];
|
||||
} else if (points.length == 1) {
|
||||
return [{
|
||||
point: points[0]
|
||||
}];
|
||||
return [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let simplified = [{
|
||||
point: points[0]
|
||||
}];
|
||||
let simplified = [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
||||
simplified.push({
|
||||
point: points[points.length - 1]
|
||||
point: points[points.length - 1],
|
||||
});
|
||||
return simplified;
|
||||
}
|
||||
|
||||
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
|
||||
function ramerDouglasPeuckerRecursive(
|
||||
points: TrackPoint[],
|
||||
epsilon: number,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
|
||||
start: number,
|
||||
end: number,
|
||||
simplified: SimplifiedTrackPoint[]
|
||||
) {
|
||||
let largest = {
|
||||
index: 0,
|
||||
distance: 0
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
@@ -45,8 +60,16 @@ function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, mea
|
||||
}
|
||||
}
|
||||
|
||||
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
|
||||
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||
@@ -74,7 +97,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > (Math.PI / 2)) {
|
||||
if (diff > Math.PI / 2) {
|
||||
return dis13;
|
||||
}
|
||||
|
||||
@@ -83,7 +106,8 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return distance(lat2, lon2, lat3, lon3);
|
||||
} else {
|
||||
@@ -93,18 +117,32 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
|
||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the distance between two lat / lon points.
|
||||
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
|
||||
return (
|
||||
Math.acos(
|
||||
Math.sin(latA) * Math.sin(latB) +
|
||||
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
) * earthRadius
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the bearing from one lat / lon point to another.
|
||||
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
|
||||
return Math.atan2(
|
||||
Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
);
|
||||
}
|
||||
|
||||
export function projectedPoint(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
|
||||
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||
export function projectedPoint(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): Coordinates {
|
||||
return projected(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
||||
@@ -132,7 +170,7 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > (Math.PI / 2)) {
|
||||
if (diff > Math.PI / 2) {
|
||||
return coord1;
|
||||
}
|
||||
|
||||
@@ -141,14 +179,22 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
|
||||
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return coord2;
|
||||
} else {
|
||||
// Determine the closest point (p4) on the great circle
|
||||
const f = dis14 / earthRadius;
|
||||
const lat4 = Math.asin(Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12));
|
||||
const lon4 = lon1 + Math.atan2(Math.sin(bear12) * Math.sin(f) * Math.cos(lat1), Math.cos(f) - Math.sin(lat1) * Math.sin(lat4));
|
||||
const lat4 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
|
||||
);
|
||||
const lon4 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
|
||||
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
|
||||
);
|
||||
|
||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
||||
}
|
||||
|
@@ -67,9 +67,9 @@ export type TrackExtensions = {
|
||||
};
|
||||
|
||||
export type LineStyleExtension = {
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
weight?: number;
|
||||
'gpx_style:color'?: string;
|
||||
'gpx_style:opacity'?: number;
|
||||
'gpx_style:width'?: number;
|
||||
};
|
||||
|
||||
export type TrackSegmentType = {
|
||||
@@ -93,11 +93,11 @@ export type TrackPointExtension = {
|
||||
'gpxtpx:hr'?: number;
|
||||
'gpxtpx:cad'?: number;
|
||||
'gpxtpx:Extensions'?: Record<string, string>;
|
||||
}
|
||||
};
|
||||
|
||||
export type PowerExtension = {
|
||||
'gpxpx:PowerInWatts'?: number;
|
||||
}
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
name?: string;
|
||||
@@ -114,12 +114,12 @@ export type RouteType = {
|
||||
type?: string;
|
||||
extensions?: TrackExtensions;
|
||||
rtept: WaypointType[];
|
||||
}
|
||||
};
|
||||
|
||||
export type RoutePointExtension = {
|
||||
'gpxx:rpt'?: GPXXRoutePoint[];
|
||||
}
|
||||
};
|
||||
|
||||
export type GPXXRoutePoint = {
|
||||
attributes: Coordinates;
|
||||
}
|
||||
};
|
||||
|
@@ -16,9 +16,9 @@
|
||||
<type>Cycling</type>
|
||||
<extensions>
|
||||
<gpx_style:line>
|
||||
<color>#2d3ee9</color>
|
||||
<opacity>0.5</opacity>
|
||||
<weight>6</weight>
|
||||
<gpx_style:color>2d3ee9</gpx_style:color>
|
||||
<gpx_style:opacity>0.5</gpx_style:opacity>
|
||||
<gpx_style:width>6</gpx_style:width>
|
||||
</gpx_style:line>
|
||||
</extensions>
|
||||
<trkseg>
|
||||
|
@@ -4,9 +4,7 @@
|
||||
"target": "ES2015",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
@@ -5,27 +5,27 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'prettier'
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
extraFileExtensions: ['.svelte'],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
]
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@@ -2,3 +2,5 @@
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
1337
website/package-lock.json
generated
1337
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"prebuild": "npx tsx src/lib/pwa-manifest.ts",
|
||||
"postbuild": "npx tsx src/lib/sitemap.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -20,7 +21,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@types/eslint": "^8.56.12",
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mapbox__tilebelt": "^1.0.4",
|
||||
"@types/mapbox-gl": "^3.4.0",
|
||||
"@types/node": "^20.16.10",
|
||||
@@ -35,7 +36,7 @@
|
||||
"eslint-plugin-svelte": "^2.44.1",
|
||||
"events": "^3.3.0",
|
||||
"glob": "^10.4.5",
|
||||
"mdsvex": "^0.11.2",
|
||||
"mdsvex": "^0.12.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
@@ -61,11 +62,13 @@
|
||||
"chartjs-plugin-zoom": "^2.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-static": "^0.427.0",
|
||||
"lucide-svelte": "^0.427.0",
|
||||
"mapbox-gl": "^3.7.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-static": "^0.460.0",
|
||||
"lucide-svelte": "^0.460.1",
|
||||
"mapbox-gl": "^3.11.1",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mode-watcher": "^0.3.1",
|
||||
"png.js": "^0.2.1",
|
||||
|
@@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
@@ -1,15 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
@@ -72,7 +72,7 @@
|
||||
|
||||
--link: 80 190 255;
|
||||
|
||||
--ring: hsl(212.7,26.8%,83.9);
|
||||
--ring: hsl(212.7, 26.8%, 83.9);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -38,7 +38,8 @@ export async function handle({ event, resolve }) {
|
||||
<meta name="twitter:url" content="https://gpx.studio/" />
|
||||
<meta name="twitter:site" content="@gpxstudio" />
|
||||
<meta name="twitter:creator" content="@gpxstudio" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
|
||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
||||
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
||||
|
||||
for (let lang of Object.keys(languages)) {
|
||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
||||
@@ -46,7 +47,8 @@ export async function handle({ event, resolve }) {
|
||||
}
|
||||
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||
});
|
||||
|
||||
return response;
|
||||
|
@@ -1,28 +1,28 @@
|
||||
export const surfaceColors: { [key: string]: string } = {
|
||||
"missing": "#d1d1d1",
|
||||
"paved": "#8c8c8c",
|
||||
"unpaved": "#6b443a",
|
||||
"asphalt": "#8c8c8c",
|
||||
"concrete": "#8c8c8c",
|
||||
"cobblestone": "#ffd991",
|
||||
"paving_stones": "#8c8c8c",
|
||||
"sett": "#ffd991",
|
||||
"metal": "#8c8c8c",
|
||||
"wood": "#6b443a",
|
||||
"compacted": "#ffffa8",
|
||||
"fine_gravel": "#ffffa8",
|
||||
"gravel": "#ffffa8",
|
||||
"pebblestone": "#ffffa8",
|
||||
"rock": "#ffd991",
|
||||
"dirt": "#ffffa8",
|
||||
"ground": "#6b443a",
|
||||
"earth": "#6b443a",
|
||||
"mud": "#6b443a",
|
||||
"sand": "#ffffc4",
|
||||
"grass": "#61b55c",
|
||||
"grass_paver": "#61b55c",
|
||||
"clay": "#6b443a",
|
||||
"stone": "#ffd991",
|
||||
missing: '#d1d1d1',
|
||||
paved: '#8c8c8c',
|
||||
unpaved: '#6b443a',
|
||||
asphalt: '#8c8c8c',
|
||||
concrete: '#8c8c8c',
|
||||
cobblestone: '#ffd991',
|
||||
paving_stones: '#8c8c8c',
|
||||
sett: '#ffd991',
|
||||
metal: '#8c8c8c',
|
||||
wood: '#6b443a',
|
||||
compacted: '#ffffa8',
|
||||
fine_gravel: '#ffffa8',
|
||||
gravel: '#ffffa8',
|
||||
pebblestone: '#ffffa8',
|
||||
rock: '#ffd991',
|
||||
dirt: '#ffffa8',
|
||||
ground: '#6b443a',
|
||||
earth: '#6b443a',
|
||||
mud: '#6b443a',
|
||||
sand: '#ffffc4',
|
||||
grass: '#61b55c',
|
||||
grass_paver: '#61b55c',
|
||||
clay: '#6b443a',
|
||||
stone: '#ffd991',
|
||||
};
|
||||
|
||||
export function getSurfaceColor(surface: string): string {
|
||||
@@ -30,66 +30,72 @@ export function getSurfaceColor(surface: string): string {
|
||||
}
|
||||
|
||||
export const highwayColors: { [key: string]: string } = {
|
||||
"missing": "#d1d1d1",
|
||||
"motorway": "#ff4d33",
|
||||
"motorway_link": "#ff4d33",
|
||||
"trunk": "#ff5e4d",
|
||||
"trunk_link": "#ff947f",
|
||||
"primary": "#ff6e5c",
|
||||
"primary_link": "#ff6e5c",
|
||||
"secondary": "#ff8d7b",
|
||||
"secondary_link": "#ff8d7b",
|
||||
"tertiary": "#ffd75f",
|
||||
"tertiary_link": "#ffd75f",
|
||||
"unclassified": "#f1f2a5",
|
||||
"road": "#f1f2a5",
|
||||
"residential": "#73b2ff",
|
||||
"living_street": "#73b2ff",
|
||||
"service": "#9c9cd9",
|
||||
"track": "#a8e381",
|
||||
"footway": "#a8e381",
|
||||
"path": "#a8e381",
|
||||
"pedestrian": "#a8e381",
|
||||
"cycleway": "#9de2ff",
|
||||
"construction": "#e09a4a",
|
||||
"bridleway": "#946f43",
|
||||
"raceway": "#ff0000",
|
||||
"rest_area": "#9c9cd9",
|
||||
"services": "#9c9cd9",
|
||||
"corridor": "#474747",
|
||||
"elevator": "#474747",
|
||||
"steps": "#474747",
|
||||
"bus_stop": "#8545a3",
|
||||
"busway": "#8545a3",
|
||||
"via_ferrata": "#474747"
|
||||
missing: '#d1d1d1',
|
||||
motorway: '#ff4d33',
|
||||
motorway_link: '#ff4d33',
|
||||
trunk: '#ff5e4d',
|
||||
trunk_link: '#ff947f',
|
||||
primary: '#ff6e5c',
|
||||
primary_link: '#ff6e5c',
|
||||
secondary: '#ff8d7b',
|
||||
secondary_link: '#ff8d7b',
|
||||
tertiary: '#ffd75f',
|
||||
tertiary_link: '#ffd75f',
|
||||
unclassified: '#f1f2a5',
|
||||
road: '#f1f2a5',
|
||||
residential: '#73b2ff',
|
||||
living_street: '#73b2ff',
|
||||
service: '#9c9cd9',
|
||||
track: '#a8e381',
|
||||
footway: '#a8e381',
|
||||
path: '#a8e381',
|
||||
pedestrian: '#a8e381',
|
||||
cycleway: '#9de2ff',
|
||||
construction: '#e09a4a',
|
||||
bridleway: '#946f43',
|
||||
raceway: '#ff0000',
|
||||
rest_area: '#9c9cd9',
|
||||
services: '#9c9cd9',
|
||||
corridor: '#474747',
|
||||
elevator: '#474747',
|
||||
steps: '#474747',
|
||||
bus_stop: '#8545a3',
|
||||
busway: '#8545a3',
|
||||
via_ferrata: '#474747',
|
||||
};
|
||||
|
||||
export const sacScaleColors: { [key: string]: string } = {
|
||||
"hiking": "#007700",
|
||||
"mountain_hiking": "#1843ad",
|
||||
"demanding_mountain_hiking": "#ffff00",
|
||||
"alpine_hiking": "#ff9233",
|
||||
"demanding_alpine_hiking": "#ff0000",
|
||||
"difficult_alpine_hiking": "#000000",
|
||||
hiking: '#007700',
|
||||
mountain_hiking: '#1843ad',
|
||||
demanding_mountain_hiking: '#ffff00',
|
||||
alpine_hiking: '#ff9233',
|
||||
demanding_alpine_hiking: '#ff0000',
|
||||
difficult_alpine_hiking: '#000000',
|
||||
};
|
||||
|
||||
export const mtbScaleColors: { [key: string]: string } = {
|
||||
"0-": "#007700",
|
||||
"0": "#007700",
|
||||
"0+": "#007700",
|
||||
"1-": "#1843ad",
|
||||
"1": "#1843ad",
|
||||
"1+": "#1843ad",
|
||||
"2-": "#ffff00",
|
||||
"2": "#ffff00",
|
||||
"2+": "#ffff00",
|
||||
"3": "#ff0000",
|
||||
"4": "#00ff00",
|
||||
"5": "#000000",
|
||||
"6": "#b105eb",
|
||||
'0-': '#007700',
|
||||
'0': '#007700',
|
||||
'0+': '#007700',
|
||||
'1-': '#1843ad',
|
||||
'1': '#1843ad',
|
||||
'1+': '#1843ad',
|
||||
'2-': '#ffff00',
|
||||
'2': '#ffff00',
|
||||
'2+': '#ffff00',
|
||||
'3': '#ff0000',
|
||||
'4': '#00ff00',
|
||||
'5': '#000000',
|
||||
'6': '#b105eb',
|
||||
};
|
||||
|
||||
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
|
||||
function createPattern(
|
||||
backgroundColor: string,
|
||||
sacScaleColor: string | undefined,
|
||||
mtbScaleColor: string | undefined,
|
||||
size: number = 16,
|
||||
lineWidth: number = 4
|
||||
) {
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
@@ -104,11 +110,11 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
if (sacScaleColor) {
|
||||
ctx.strokeStyle = sacScaleColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
|
||||
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
|
||||
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -119,8 +125,8 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
|
||||
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
@@ -128,12 +134,16 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
}
|
||||
|
||||
const patterns: Record<string, string | CanvasPattern> = {};
|
||||
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
|
||||
export function getHighwayColor(
|
||||
highway: string,
|
||||
sacScale: string | undefined,
|
||||
mtbScale: string | undefined
|
||||
) {
|
||||
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
|
||||
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
|
||||
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
|
||||
if (sacScale || mtbScale) {
|
||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
|
||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
|
||||
if (!patterns[patternId]) {
|
||||
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,67 @@
|
||||
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
|
||||
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
Landmark,
|
||||
Icon,
|
||||
Shell,
|
||||
Bike,
|
||||
Building,
|
||||
Tent,
|
||||
Car,
|
||||
Wrench,
|
||||
ShoppingBasket,
|
||||
Droplet,
|
||||
DoorOpen,
|
||||
Trees,
|
||||
Fuel,
|
||||
Home,
|
||||
Info,
|
||||
TreeDeciduous,
|
||||
CircleParking,
|
||||
Cross,
|
||||
Utensils,
|
||||
Construction,
|
||||
BrickWall,
|
||||
ShowerHead,
|
||||
Mountain,
|
||||
Phone,
|
||||
TrainFront,
|
||||
Bed,
|
||||
Binoculars,
|
||||
TriangleAlert,
|
||||
Anchor,
|
||||
Toilet,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
Landmark as LandmarkSvg,
|
||||
Shell as ShellSvg,
|
||||
Bike as BikeSvg,
|
||||
Building as BuildingSvg,
|
||||
Tent as TentSvg,
|
||||
Car as CarSvg,
|
||||
Wrench as WrenchSvg,
|
||||
ShoppingBasket as ShoppingBasketSvg,
|
||||
Droplet as DropletSvg,
|
||||
DoorOpen as DoorOpenSvg,
|
||||
Trees as TreesSvg,
|
||||
Fuel as FuelSvg,
|
||||
Home as HomeSvg,
|
||||
Info as InfoSvg,
|
||||
TreeDeciduous as TreeDeciduousSvg,
|
||||
CircleParking as CircleParkingSvg,
|
||||
Cross as CrossSvg,
|
||||
Utensils as UtensilsSvg,
|
||||
Construction as ConstructionSvg,
|
||||
BrickWall as BrickWallSvg,
|
||||
ShowerHead as ShowerHeadSvg,
|
||||
Mountain as MountainSvg,
|
||||
Phone as PhoneSvg,
|
||||
TrainFront as TrainFrontSvg,
|
||||
Bed as BedSvg,
|
||||
Binoculars as BinocularsSvg,
|
||||
TriangleAlert as TriangleAlertSvg,
|
||||
Anchor as AnchorSvg,
|
||||
Toilet as ToiletSvg,
|
||||
} from 'lucide-static';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export type Symbol = {
|
||||
value: string;
|
||||
@@ -20,16 +81,28 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
||||
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
||||
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
||||
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
convenience_store: {
|
||||
value: 'Convenience Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
crossing: { value: 'Crossing' },
|
||||
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
department_store: {
|
||||
value: 'Department Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
|
||||
ground_transportation: {
|
||||
value: 'Ground Transportation',
|
||||
icon: TrainFront,
|
||||
iconSvg: TrainFrontSvg,
|
||||
},
|
||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||
@@ -39,7 +112,7 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
||||
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
||||
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
||||
restroom: { value: 'Restroom' },
|
||||
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg },
|
||||
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
||||
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
||||
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
||||
@@ -55,6 +128,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return Object.keys(symbols).find(key => symbols[key].value === value);
|
||||
return Object.keys(symbols).find((key) => symbols[key].value === value);
|
||||
}
|
||||
}
|
@@ -13,14 +13,14 @@
|
||||
indexName: 'gpx',
|
||||
container: '#docsearch',
|
||||
searchParameters: {
|
||||
facetFilters: ['lang:' + ($locale ?? 'en')]
|
||||
facetFilters: ['lang:' + ($locale ?? 'en')],
|
||||
},
|
||||
placeholder: $_('docs.search.search'),
|
||||
disableUserPersonalization: true,
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: $_('docs.search.search'),
|
||||
buttonAriaLabel: $_('docs.search.search')
|
||||
buttonAriaLabel: $_('docs.search.search'),
|
||||
},
|
||||
modal: {
|
||||
searchBox: {
|
||||
@@ -28,19 +28,19 @@
|
||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
||||
cancelButtonText: $_('docs.search.cancel'),
|
||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
||||
searchInputLabel: $_('docs.search.search')
|
||||
searchInputLabel: $_('docs.search.search'),
|
||||
},
|
||||
footer: {
|
||||
selectText: $_('docs.search.to_select'),
|
||||
navigateText: $_('docs.search.to_navigate'),
|
||||
closeText: $_('docs.search.to_close')
|
||||
closeText: $_('docs.search.to_close'),
|
||||
},
|
||||
noResultsScreen: {
|
||||
noResultsText: $_('docs.search.no_results'),
|
||||
suggestedQueryText: $_('docs.search.no_results_suggestion')
|
||||
}
|
||||
}
|
||||
}
|
||||
suggestedQueryText: $_('docs.search.no_results_suggestion'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
18
website/src/lib/components/CoordinatesPopup.svelte
Normal file
18
website/src/lib/components/CoordinatesPopup.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
@@ -17,7 +17,7 @@
|
||||
Circle,
|
||||
Check,
|
||||
ChartNoAxesColumn,
|
||||
Construction
|
||||
Construction,
|
||||
} from 'lucide-svelte';
|
||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
||||
import { _ } from 'svelte-i18n';
|
||||
@@ -33,7 +33,7 @@
|
||||
getHeartRateWithUnits,
|
||||
getPowerWithUnits,
|
||||
getTemperatureWithUnits,
|
||||
getVelocityWithUnits
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
@@ -72,37 +72,37 @@
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
},
|
||||
align: 'inner',
|
||||
maxRotation: 0
|
||||
}
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return getElevationWithUnits(value, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
line: {
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
cubicInterpolationMode: 'monotone'
|
||||
}
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
decimation: {
|
||||
enabled: true
|
||||
enabled: true,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: () => !dragging && !panning,
|
||||
@@ -141,16 +141,20 @@
|
||||
let slope = {
|
||||
at: point.slope.at.toFixed(1),
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
length: getDistanceWithUnits(point.slope.length)
|
||||
length: getDistanceWithUnits(point.slope.length),
|
||||
};
|
||||
let surface = point.extensions.surface ? point.extensions.surface : 'unknown';
|
||||
let highway = point.extensions.highway ? point.extensions.highway : 'unknown';
|
||||
let surface = point.extensions.surface
|
||||
? point.extensions.surface
|
||||
: 'unknown';
|
||||
let highway = point.extensions.highway
|
||||
? point.extensions.highway
|
||||
: 'unknown';
|
||||
let sacScale = point.extensions.sac_scale;
|
||||
let mtbScale = point.extensions.mtb_scale;
|
||||
|
||||
let labels = [
|
||||
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`
|
||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
||||
];
|
||||
|
||||
if (elevationFill === 'surface') {
|
||||
@@ -162,7 +166,9 @@
|
||||
if (elevationFill === 'highway') {
|
||||
labels.push(
|
||||
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
|
||||
sacScale ? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})` : ''
|
||||
sacScale
|
||||
? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
if (mtbScale) {
|
||||
@@ -175,8 +181,8 @@
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
@@ -190,18 +196,19 @@
|
||||
},
|
||||
onPanComplete: function () {
|
||||
panning = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true
|
||||
enabled: true,
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
|
||||
if (
|
||||
event.deltaY < 0 &&
|
||||
Math.abs(
|
||||
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
|
||||
chart.getInitialScaleBounds().x.max /
|
||||
chart.options.plugins.zoom.limits.x.minRange -
|
||||
chart.getZoomLevel()
|
||||
) < 0.01
|
||||
) {
|
||||
@@ -210,21 +217,21 @@
|
||||
}
|
||||
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
minRange: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
minRange: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stacked: false,
|
||||
onResize: function () {
|
||||
updateOverlay();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
||||
@@ -233,10 +240,10 @@
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
reverse: () => id === 'speed' && $velocityUnits === 'pace',
|
||||
display: false
|
||||
display: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -246,7 +253,7 @@
|
||||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: []
|
||||
datasets: [],
|
||||
},
|
||||
options,
|
||||
plugins: [
|
||||
@@ -259,16 +266,16 @@
|
||||
marker.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Map marker to show on hover
|
||||
let element = document.createElement('div');
|
||||
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
||||
marker = new mapboxgl.Marker({
|
||||
element
|
||||
element,
|
||||
});
|
||||
|
||||
let startIndex = 0;
|
||||
@@ -278,7 +285,7 @@
|
||||
evt,
|
||||
'x',
|
||||
{
|
||||
intersect: false
|
||||
intersect: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
@@ -321,9 +328,12 @@
|
||||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
$slicedGPXStatistics = [
|
||||
$gpxStatistics.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)),
|
||||
$gpxStatistics.slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -357,76 +367,76 @@
|
||||
slope: {
|
||||
at: data.local.slope.at[index],
|
||||
segment: data.local.slope.segment[index],
|
||||
length: data.local.slope.length[index]
|
||||
length: data.local.slope.length[index],
|
||||
},
|
||||
extensions: point.getExtensions(),
|
||||
coordinates: point.getCoordinates(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
fill: 'start',
|
||||
order: 1
|
||||
order: 1,
|
||||
};
|
||||
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
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yspeed',
|
||||
hidden: true
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[2] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getHeartRate(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yhr',
|
||||
hidden: true
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[3] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getCadence(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'ycad',
|
||||
hidden: true
|
||||
hidden: true,
|
||||
};
|
||||
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
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yatemp',
|
||||
hidden: true
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[5] = {
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getPower(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'ypower',
|
||||
hidden: true
|
||||
hidden: true,
|
||||
};
|
||||
chart.options.scales.x['min'] = 0;
|
||||
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||
@@ -453,15 +463,15 @@
|
||||
$: if (chart) {
|
||||
if (elevationFill === 'slope') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: slopeFillCallback
|
||||
backgroundColor: slopeFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'surface') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: surfaceFillCallback
|
||||
backgroundColor: surfaceFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'highway') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: highwayFillCallback
|
||||
backgroundColor: highwayFillCallback,
|
||||
};
|
||||
} else {
|
||||
chart.data.datasets[0]['segment'] = {};
|
||||
@@ -553,7 +563,11 @@
|
||||
<ChartNoAxesColumn size="18" />
|
||||
</ButtonWithTooltip>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-fit p-0 flex flex-col divide-y" side="top" sideOffset={-32}>
|
||||
<Popover.Content
|
||||
class="w-fit p-0 flex flex-col divide-y"
|
||||
side="top"
|
||||
sideOffset={-32}
|
||||
>
|
||||
<ToggleGroup.Root
|
||||
class="flex flex-col items-start gap-0 p-1"
|
||||
type="single"
|
||||
@@ -613,7 +627,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<Zap size="15" class="mr-1" />
|
||||
{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}
|
||||
{$velocityUnits === 'speed'
|
||||
? $_('quantities.speed')
|
||||
: $_('quantities.pace')}
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
|
@@ -10,7 +10,7 @@
|
||||
exportSelectedFiles,
|
||||
ExportState,
|
||||
exportState,
|
||||
gpxStatistics
|
||||
gpxStatistics,
|
||||
} from '$lib/stores';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import {
|
||||
@@ -20,7 +20,7 @@
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity
|
||||
SquareActivity,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './file-list/Selection';
|
||||
@@ -35,7 +35,7 @@
|
||||
cad: true,
|
||||
atemp: true,
|
||||
power: true,
|
||||
extensions: true
|
||||
extensions: true,
|
||||
};
|
||||
let hide: Record<string, boolean> = {
|
||||
time: false,
|
||||
@@ -43,7 +43,7 @@
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false,
|
||||
extensions: false
|
||||
extensions: false,
|
||||
};
|
||||
|
||||
$: if ($exportState !== ExportState.NONE) {
|
||||
@@ -121,7 +121,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
|
||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
|
||||
(v) => !v
|
||||
)
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
@@ -144,7 +146,9 @@
|
||||
{$_('quantities.time')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}">
|
||||
<div
|
||||
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
|
||||
>
|
||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||
<Earth size="16" />
|
||||
|
@@ -53,13 +53,17 @@
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
||||
'quantities.moving'
|
||||
)} / {$_('quantities.total')})"
|
||||
label="{$velocityUnits === 'speed'
|
||||
? $_('quantities.speed')
|
||||
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
||||
>
|
||||
<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.global.speed.moving}
|
||||
type="speed"
|
||||
showUnits={false}
|
||||
/>
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
</span>
|
||||
@@ -68,7 +72,9 @@
|
||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
|
@@ -8,13 +8,13 @@
|
||||
|
||||
let selected = {
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
};
|
||||
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale]
|
||||
label: languages[$locale],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
@@ -22,16 +22,17 @@
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
unit: $distanceUnits,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
@@ -40,6 +41,10 @@
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
@@ -50,20 +55,6 @@
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
const loadJson = mapboxgl.Style.prototype._load;
|
||||
mapboxgl.Style.prototype._load = function (json, validate) {
|
||||
if (
|
||||
json['sources'] &&
|
||||
json['sources']['mapbox-satellite'] &&
|
||||
json['sources']['mapbox-satellite']['data'] &&
|
||||
json['sources']['mapbox-satellite']['data']['data']
|
||||
) {
|
||||
// Temporary fix for https://github.com/gpxstudio/gpx.studio/issues/129
|
||||
delete json['sources']['mapbox-satellite']['data']['data'];
|
||||
}
|
||||
loadJson.call(this, json, validate);
|
||||
};
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
@@ -79,12 +70,12 @@
|
||||
sources: {},
|
||||
layers: [],
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
|
||||
}
|
||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: ''
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
@@ -92,17 +83,18 @@
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: []
|
||||
}
|
||||
}
|
||||
]
|
||||
layers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
boxZoom: false,
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
@@ -112,13 +104,13 @@
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -142,12 +134,12 @@
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [result.lon, result.lat]
|
||||
coordinates: [result.lon, result.lat],
|
||||
},
|
||||
place_name: result.display_name
|
||||
place_name: result.display_name,
|
||||
};
|
||||
});
|
||||
})
|
||||
}),
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -165,11 +157,11 @@
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -181,25 +173,25 @@
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
maxzoom: 14,
|
||||
});
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
exaggeration: 1,
|
||||
});
|
||||
}
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)'
|
||||
'space-color': 'rgb(156, 240, 255)',
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
newMap.setTerrain(null);
|
||||
@@ -215,23 +207,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...$$restProps}>
|
||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||
!embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { TrackPoint, Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { tick } from "svelte";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import MapPopupComponent from "./MapPopup.svelte";
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { tick } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from './MapPopup.svelte';
|
||||
|
||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
item: T;
|
||||
fileId?: string;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
export class MapPopup {
|
||||
@@ -22,14 +23,15 @@ export class MapPopup {
|
||||
let component = new MapPopupComponent({
|
||||
target: document.body,
|
||||
props: {
|
||||
item: this.item
|
||||
}
|
||||
item: this.item,
|
||||
},
|
||||
});
|
||||
|
||||
tick().then(() => this.popup.setDOMContent(component.container));
|
||||
}
|
||||
|
||||
setItem(item: PopupItem | null) {
|
||||
if (item) item.hide = () => this.hide();
|
||||
this.item.set(item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@@ -73,6 +75,8 @@ export class MapPopup {
|
||||
if (i === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
||||
? i.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
}
|
||||
}
|
@@ -22,7 +22,7 @@
|
||||
Sun,
|
||||
Moon,
|
||||
Layers,
|
||||
GalleryVertical,
|
||||
ListTree,
|
||||
Languages,
|
||||
Settings,
|
||||
Info,
|
||||
@@ -42,7 +42,7 @@
|
||||
FileX,
|
||||
BookOpenText,
|
||||
ChartArea,
|
||||
Maximize
|
||||
Maximize,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import {
|
||||
@@ -56,7 +56,7 @@
|
||||
editStyle,
|
||||
exportState,
|
||||
ExportState,
|
||||
centerMapOnSelection
|
||||
centerMapOnSelection,
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
copied,
|
||||
@@ -64,7 +64,7 @@
|
||||
cutSelection,
|
||||
pasteSelection,
|
||||
selectAll,
|
||||
selection
|
||||
selection,
|
||||
} from '$lib/components/file-list/Selection';
|
||||
import { derived } from 'svelte/store';
|
||||
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
||||
@@ -83,7 +83,7 @@
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
elevationProfile,
|
||||
verticalFileView,
|
||||
treeFileView,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
@@ -91,7 +91,7 @@
|
||||
distanceMarkers,
|
||||
directionMarkers,
|
||||
streetViewSource,
|
||||
routing
|
||||
routing,
|
||||
} = settings;
|
||||
|
||||
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
||||
@@ -128,7 +128,7 @@
|
||||
<div
|
||||
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
||||
>
|
||||
<a href="./" target="_blank" class="shrink-0">
|
||||
<a href={getURLForLanguage($locale, '/')} target="_blank" class="shrink-0">
|
||||
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
||||
</a>
|
||||
@@ -151,18 +151,27 @@
|
||||
<Shortcut key="O" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.duplicateSelection} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.duplicateSelection}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.deleteSelectedFiles} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteSelectedFiles}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item on:click={dbUtils.deleteAllFiles} disabled={$fileObservers.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteAllFiles}
|
||||
disabled={$fileObservers.size == 0}
|
||||
>
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close_all')}
|
||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||
@@ -207,7 +216,11 @@
|
||||
disabled={$selection.size !== 1 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
on:click={() => ($editMetadata = true)}
|
||||
>
|
||||
<Info size="16" class="mr-1" />
|
||||
@@ -218,7 +231,11 @@
|
||||
disabled={$selection.size === 0 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
on:click={() => ($editStyle = true)}
|
||||
>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
@@ -243,17 +260,20 @@
|
||||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $verticalFileView}
|
||||
{#if $treeFileView}
|
||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
on:click={() => dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
||||
on:click={() =>
|
||||
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
||||
disabled={$selection.size !== 1}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
</Menubar.Item>
|
||||
{:else if $selection.getSelected().some((item) => item instanceof ListTrackItem)}
|
||||
{:else if $selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListTrackItem)}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
on:click={() => {
|
||||
@@ -284,7 +304,7 @@
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $verticalFileView}
|
||||
{#if $treeFileView}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
@@ -300,7 +320,9 @@
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
($selection.size > 0 &&
|
||||
!allowedPastes[$copied[0].level].includes($selection.getSelected().pop()?.level))}
|
||||
!allowedPastes[$copied[0].level].includes(
|
||||
$selection.getSelected().pop()?.level
|
||||
))}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
@@ -309,7 +331,10 @@
|
||||
</Menubar.Item>
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.deleteSelection} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteSelection}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
@@ -327,24 +352,32 @@
|
||||
{$_('menu.elevation_profile')}
|
||||
<Shortcut key="P" ctrl={true} />
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.CheckboxItem bind:checked={$verticalFileView}>
|
||||
<GalleryVertical size="16" class="mr-1" />
|
||||
{$_('menu.vertical_file_view')}
|
||||
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
||||
<ListTree size="16" class="mr-1" />
|
||||
{$_('menu.tree_file_view')}
|
||||
<Shortcut key="L" ctrl={true} />
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item inset on:click={switchBasemaps}>
|
||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut key="F1" />
|
||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
|
||||
key="F1"
|
||||
/>
|
||||
</Menubar.Item>
|
||||
<Menubar.Item inset on:click={toggleOverlays}>
|
||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut key="F2" />
|
||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
|
||||
key="F2"
|
||||
/>
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut key="F3" />
|
||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
|
||||
key="F3"
|
||||
/>
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut key="F4" />
|
||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
|
||||
key="F4"
|
||||
/>
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item inset on:click={toggle3D}>
|
||||
@@ -368,9 +401,15 @@
|
||||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="metric"
|
||||
>{$_('menu.metric')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="imperial"
|
||||
>{$_('menu.imperial')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="nautical"
|
||||
>{$_('menu.nautical')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -380,8 +419,12 @@
|
||||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
||||
<Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="pace">{$_('quantities.pace')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="speed"
|
||||
>{$_('quantities.speed')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="pace"
|
||||
>{$_('quantities.pace')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -391,8 +434,12 @@
|
||||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
||||
<Menubar.RadioItem value="celsius">{$_('menu.celsius')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="fahrenheit">{$_('menu.fahrenheit')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="celsius"
|
||||
>{$_('menu.celsius')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="fahrenheit"
|
||||
>{$_('menu.fahrenheit')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -428,8 +475,11 @@
|
||||
setMode(value);
|
||||
}}
|
||||
>
|
||||
<Menubar.RadioItem value="light">{$_('menu.light')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="light"
|
||||
>{$_('menu.light')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -441,8 +491,12 @@
|
||||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
||||
<Menubar.RadioItem value="mapillary">{$_('menu.mapillary')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="google">{$_('menu.google')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="mapillary"
|
||||
>{$_('menu.mapillary')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="google"
|
||||
>{$_('menu.google')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -567,7 +621,7 @@
|
||||
$elevationProfile = !$elevationProfile;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
|
||||
$verticalFileView = !$verticalFileView;
|
||||
$treeFileView = !$treeFileView;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
||||
if ($allHidden) {
|
||||
|
@@ -12,7 +12,8 @@
|
||||
|
||||
const handleMouseMove = (event: PointerEvent) => {
|
||||
const newAfter =
|
||||
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
startAfter +
|
||||
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||
after = newAfter;
|
||||
} else if (newAfter < minAfter && after !== minAfter) {
|
||||
|
@@ -8,7 +8,7 @@
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS
|
||||
secondsToHHMMSS,
|
||||
} from '$lib/units';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
@@ -43,6 +43,7 @@
|
||||
:global(.markdown > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
@apply contents;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
|
@@ -18,7 +18,11 @@
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/split'}
|
||||
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/split.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||
|
@@ -1,39 +1,64 @@
|
||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
File,
|
||||
FilePen,
|
||||
View,
|
||||
type Icon,
|
||||
Settings,
|
||||
Pencil,
|
||||
MapPin,
|
||||
Scissors,
|
||||
CalendarClock,
|
||||
Group,
|
||||
Ungroup,
|
||||
Filter,
|
||||
SquareDashedMousePointer,
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
|
||||
toolbar: [
|
||||
'routing',
|
||||
'poi',
|
||||
'scissors',
|
||||
'time',
|
||||
'merge',
|
||||
'extract',
|
||||
'elevation',
|
||||
'minify',
|
||||
'clean',
|
||||
],
|
||||
'map-controls': [],
|
||||
'gpx': [],
|
||||
'integration': [],
|
||||
'faq': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"getting-started": "🚀",
|
||||
"menu": "📂 ⚙️",
|
||||
"file": File,
|
||||
"edit": FilePen,
|
||||
"view": View,
|
||||
"settings": Settings,
|
||||
"files-and-stats": "🗂 📈",
|
||||
"toolbar": "🧰",
|
||||
"routing": Pencil,
|
||||
"poi": MapPin,
|
||||
"scissors": Scissors,
|
||||
"time": CalendarClock,
|
||||
"merge": Group,
|
||||
"extract": Ungroup,
|
||||
"elevation": MountainSnow,
|
||||
"minify": Filter,
|
||||
"clean": SquareDashedMousePointer,
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
"faq": "🔮",
|
||||
'getting-started': '🚀',
|
||||
menu: '📂 ⚙️',
|
||||
file: File,
|
||||
edit: FilePen,
|
||||
view: View,
|
||||
settings: Settings,
|
||||
'files-and-stats': '🗂 📈',
|
||||
toolbar: '🧰',
|
||||
routing: Pencil,
|
||||
poi: MapPin,
|
||||
scissors: Scissors,
|
||||
time: CalendarClock,
|
||||
merge: Group,
|
||||
extract: Ungroup,
|
||||
elevation: MountainSnow,
|
||||
minify: Filter,
|
||||
clean: SquareDashedMousePointer,
|
||||
'map-controls': '🗺',
|
||||
gpx: '💾',
|
||||
integration: '{ 👩💻 }',
|
||||
faq: '🔮',
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
|
@@ -12,7 +12,7 @@
|
||||
embedding,
|
||||
loadFile,
|
||||
map,
|
||||
updateGPXData
|
||||
updateGPXData,
|
||||
} from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||
@@ -23,7 +23,7 @@
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
type EmbeddingOptions
|
||||
type EmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { browser } from '$app/environment';
|
||||
@@ -37,7 +37,7 @@
|
||||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers
|
||||
directionMarkers,
|
||||
} = settings;
|
||||
|
||||
export let useHash = true;
|
||||
@@ -50,7 +50,7 @@
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function applyOptions() {
|
||||
@@ -74,12 +74,12 @@
|
||||
let bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180
|
||||
lon: 180,
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180
|
||||
}
|
||||
lon: -180,
|
||||
},
|
||||
};
|
||||
|
||||
fileObservers.update(($fileObservers) => {
|
||||
@@ -96,12 +96,13 @@
|
||||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics
|
||||
statistics,
|
||||
})
|
||||
);
|
||||
|
||||
ids.push(id);
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
|
||||
.bounds;
|
||||
|
||||
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||
@@ -130,12 +131,12 @@
|
||||
bounds.southWest.lon,
|
||||
bounds.southWest.lat,
|
||||
bounds.northEast.lon,
|
||||
bounds.northEast.lat
|
||||
bounds.northEast.lat,
|
||||
],
|
||||
{
|
||||
padding: 80,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
easing: () => 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -143,7 +144,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||
if (
|
||||
options.basemap !== $currentBasemap &&
|
||||
allowedEmbeddingBasemaps.includes(options.basemap)
|
||||
) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
|
||||
@@ -257,7 +261,7 @@
|
||||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null
|
||||
options.elevation.power ? 'power' : null,
|
||||
].filter((dataset) => dataset !== null)}
|
||||
elevationFill={options.elevation.fill}
|
||||
showControls={options.elevation.controls}
|
||||
|
@@ -39,14 +39,14 @@ export const defaultEmbeddingOptions = {
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false
|
||||
power: false,
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
@@ -59,7 +59,11 @@ export function getMergedEmbeddingOptions(
|
||||
): EmbeddingOptions {
|
||||
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
||||
for (const key in options) {
|
||||
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
|
||||
if (
|
||||
typeof options[key] === 'object' &&
|
||||
options[key] !== null &&
|
||||
!Array.isArray(options[key])
|
||||
) {
|
||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||
} else {
|
||||
mergedOptions[key] = options[key];
|
||||
@@ -79,7 +83,10 @@ export function getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key] !== null &&
|
||||
!Array.isArray(cleanedOptions[key])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key],
|
||||
defaultOptions[key]
|
||||
);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
@@ -141,7 +148,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
}
|
||||
if (options.has('slope')) {
|
||||
newOptions.elevation = {
|
||||
fill: 'slope'
|
||||
fill: 'slope',
|
||||
};
|
||||
}
|
||||
return newOptions;
|
||||
|
@@ -13,13 +13,13 @@
|
||||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video
|
||||
Video,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions
|
||||
getDefaultEmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
@@ -30,7 +30,7 @@
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
||||
];
|
||||
|
||||
let files = options.files[0];
|
||||
@@ -130,7 +130,11 @@
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
{$_('embedding.height')}
|
||||
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={options.elevation.height}
|
||||
class="h-8 w-20"
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
@@ -142,7 +146,11 @@
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
|
||||
} else if (
|
||||
value === 'slope' ||
|
||||
value === 'surface' ||
|
||||
value === 'highway'
|
||||
) {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
@@ -152,8 +160,10 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
||||
>
|
||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
|
||||
>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -318,7 +328,8 @@
|
||||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<pre
|
||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<code class="language-html">
|
||||
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
</code>
|
||||
|
@@ -17,9 +17,9 @@
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
verticalFileView.subscribe(($vertical) => {
|
||||
treeFileView.subscribe(($vertical) => {
|
||||
if ($vertical) {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { freeze } from "immer";
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
|
||||
import { selection } from "./Selection";
|
||||
import { newGPXFile } from "$lib/stores";
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { freeze } from 'immer';
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
|
||||
import { selection } from './Selection';
|
||||
import { newGPXFile } from '$lib/stores';
|
||||
|
||||
export enum ListLevel {
|
||||
ROOT,
|
||||
@@ -10,7 +10,7 @@ export enum ListLevel {
|
||||
TRACK,
|
||||
SEGMENT,
|
||||
WAYPOINTS,
|
||||
WAYPOINT
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
@@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
@@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export abstract class ListItem {
|
||||
@@ -322,7 +322,13 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
||||
export function moveItems(
|
||||
fromParent: ListItem,
|
||||
toParent: ListItem,
|
||||
fromItems: ListItem[],
|
||||
toItems: ListItem[],
|
||||
remove: boolean = true
|
||||
) {
|
||||
if (fromItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -338,11 +344,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
context.push(file.clone());
|
||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
||||
context.push(file.trk[item.getTrackIndex()].clone());
|
||||
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
item.getTrackIndex() < file.trk.length &&
|
||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
||||
) {
|
||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
||||
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
|
||||
} else if (
|
||||
item instanceof ListWaypointItem &&
|
||||
item.getWaypointIndex() < file.wpt.length
|
||||
) {
|
||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
||||
}
|
||||
}
|
||||
@@ -359,7 +372,12 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
if (item instanceof ListTrackItem) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex(),
|
||||
[]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@@ -371,25 +389,43 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
if (context[i] instanceof Track) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
context[i] instanceof TrackSegment
|
||||
) {
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex() - 1,
|
||||
[context[i]]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
|
||||
if (
|
||||
Array.isArray(context[i]) &&
|
||||
context[i].length > 0 &&
|
||||
context[i][0] instanceof Waypoint
|
||||
) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
||||
} else if (context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (fromParent instanceof ListRootItem) {
|
||||
@@ -400,7 +436,10 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
callbacks.splice(0, 1);
|
||||
}
|
||||
|
||||
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
dbUtils.applyEachToFilesAndGlobal(
|
||||
files,
|
||||
callbacks,
|
||||
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (context[i] instanceof GPXFile) {
|
||||
@@ -421,14 +460,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
newFile.replaceTracks(0, 0, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
newFile.replaceTracks(0, 0, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, context);
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
|
@@ -5,7 +5,7 @@
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement
|
||||
type GPXTreeElement,
|
||||
} from 'gpx';
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
@@ -19,7 +19,7 @@
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
type ListItem,
|
||||
type ListTrackItem
|
||||
type ListTrackItem,
|
||||
} from './FileList';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './Selection';
|
||||
@@ -39,19 +39,20 @@
|
||||
node instanceof GPXFile && item instanceof ListFileItem
|
||||
? node.metadata.name
|
||||
: node instanceof Track
|
||||
? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
|
||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||
: node instanceof TrackSegment
|
||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||
: node instanceof Waypoint
|
||||
? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
|
||||
? (node.name ??
|
||||
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||
? $_('gpx.waypoints')
|
||||
: '';
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
ListWaypointsItem,
|
||||
allowedMoves,
|
||||
moveItems,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { isMac } from '$lib/utils';
|
||||
@@ -113,7 +113,7 @@
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
@@ -155,7 +155,7 @@
|
||||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
@@ -233,16 +233,16 @@
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
value: item,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX
|
||||
FileX,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
@@ -26,7 +26,7 @@
|
||||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
allowedPastes,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import {
|
||||
copied,
|
||||
@@ -36,7 +36,7 @@
|
||||
pasteSelection,
|
||||
selectAll,
|
||||
selectItem,
|
||||
selection
|
||||
selection,
|
||||
} from './Selection';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -47,13 +47,14 @@
|
||||
embedding,
|
||||
centerMapOnSelection,
|
||||
gpxLayers,
|
||||
map
|
||||
map,
|
||||
} from '$lib/stores';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import MetadataDialog from './MetadataDialog.svelte';
|
||||
import StyleDialog from './StyleDialog.svelte';
|
||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
@@ -69,13 +70,14 @@
|
||||
nodeColors = [];
|
||||
|
||||
if (node instanceof GPXFile) {
|
||||
let style = node.getStyle();
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
style.color.push(layer.layerColor);
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
@@ -84,8 +86,8 @@
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !nodeColors.includes(style.color)) {
|
||||
nodeColors.push(style.color);
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
@@ -97,6 +99,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
|
||||
@@ -173,7 +177,10 @@
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,16 +197,30 @@
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="16"
|
||||
class="mr-1 shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
|
||||
{/if}
|
||||
<span
|
||||
class="grow select-none truncate {orientation === 'vertical'
|
||||
? 'last:mr-2'
|
||||
: ''}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{#if hidden}
|
||||
<EyeOff
|
||||
size="12"
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
|
||||
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
||||
? 'mr-2'
|
||||
: ''} {item.level === ListLevel.SEGMENT ||
|
||||
item.level === ListLevel.WAYPOINT
|
||||
? 'mr-3'
|
||||
: ''}"
|
||||
/>
|
||||
|
@@ -17,15 +17,15 @@
|
||||
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
? (node.metadata.name ?? '')
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
? (node.name ?? '')
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
? (node.metadata.desc ?? '')
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
? (node.desc ?? '')
|
||||
: '';
|
||||
|
||||
$: if (!open) {
|
||||
|
@@ -1,12 +1,23 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
|
||||
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
|
||||
import { get, writable } from 'svelte/store';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
moveItems,
|
||||
} from './FileList';
|
||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
@@ -67,7 +78,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@@ -80,7 +95,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@@ -131,7 +150,7 @@ export class SelectionTreeType {
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||
|
||||
@@ -181,7 +200,10 @@ export function selectAll() {
|
||||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
|
||||
$selection.set(
|
||||
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@@ -205,14 +227,24 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
get(settings.fileOrder).forEach((fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +257,10 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
|
||||
});
|
||||
}
|
||||
|
||||
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
@@ -270,7 +305,11 @@ export function pasteSelection() {
|
||||
let startIndex: number | undefined = undefined;
|
||||
|
||||
if (fromItems[0].level === toParent.level) {
|
||||
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
||||
if (
|
||||
toParent instanceof ListTrackItem ||
|
||||
toParent instanceof ListTrackSegmentItem ||
|
||||
toParent instanceof ListWaypointItem
|
||||
) {
|
||||
startIndex = toParent.getId() + 1;
|
||||
}
|
||||
toParent = toParent.getParent();
|
||||
@@ -288,20 +327,41 @@ export function pasteSelection() {
|
||||
fromItems.forEach((item, index) => {
|
||||
if (toParent instanceof ListFileItem) {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.trk.length) + index
|
||||
)
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListTrackItem) {
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
let toTrackIndex = toParent.getTrackIndex();
|
||||
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackSegmentItem(
|
||||
toParent.getFileId(),
|
||||
toTrackIndex,
|
||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListWaypointsItem) {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -14,20 +14,20 @@
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
|
||||
const { defaultOpacity, defaultWeight } = settings;
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let weight: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let weightChanged = false;
|
||||
let widthChanged = false;
|
||||
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
weight = [];
|
||||
width = [];
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
@@ -47,9 +47,9 @@
|
||||
opacity.push(o);
|
||||
}
|
||||
});
|
||||
style.weight.forEach((w) => {
|
||||
if (!weight.includes(w)) {
|
||||
weight.push(w);
|
||||
style.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.push(w);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -60,14 +60,20 @@
|
||||
let track = file.trk[item.getTrackIndex()];
|
||||
let style = track.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !colors.includes(style.color)) {
|
||||
colors.push(style.color);
|
||||
if (
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (style.opacity && !opacity.includes(style.opacity)) {
|
||||
opacity.push(style.opacity);
|
||||
if (
|
||||
style['gpx_style:opacity'] &&
|
||||
!opacity.includes(style['gpx_style:opacity'])
|
||||
) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style.weight && !weight.includes(style.weight)) {
|
||||
weight.push(style.weight);
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
@@ -79,11 +85,11 @@
|
||||
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
weight = [weight[0] ?? $defaultWeight];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
weightChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
|
||||
$: if ($selection && open) {
|
||||
@@ -123,37 +129,37 @@
|
||||
{$_('menu.style.width')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={weight}
|
||||
id="weight"
|
||||
bind:value={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (weightChanged = true)}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !weightChanged}
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style.color = color;
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style.opacity = opacity[0];
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (weightChanged) {
|
||||
style.weight = weight[0];
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style.opacity) {
|
||||
$defaultOpacity = style.opacity;
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style.weight) {
|
||||
$defaultWeight = style.weight;
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
|
||||
|
23
website/src/lib/components/gpx-layer/CopyCoordinates.svelte
Normal file
23
website/src/lib/components/gpx-layer/CopyCoordinates.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ClipboardCopy } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Coordinates } from 'gpx';
|
||||
|
||||
export let coordinates: Coordinates;
|
||||
export let onCopy: () => void = () => {};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
@@ -1,11 +1,17 @@
|
||||
|
||||
import { settings } from "$lib/db";
|
||||
import { gpxStatistics } from "$lib/stores";
|
||||
import { get } from "svelte/store";
|
||||
import { settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
|
||||
export class DistanceMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@@ -30,7 +36,7 @@ export class DistanceMarkers {
|
||||
} else {
|
||||
this.map.addSource('distance-markers', {
|
||||
type: 'geojson',
|
||||
data: this.getDistanceMarkersGeoJSON()
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
@@ -39,7 +45,14 @@ export class DistanceMarkers {
|
||||
id: `distance-markers-${d}`,
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
layout: {
|
||||
@@ -51,7 +64,7 @@ export class DistanceMarkers {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.map.moveLayer(`distance-markers-${d}`);
|
||||
@@ -64,13 +77,14 @@ export class DistanceMarkers {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
@@ -79,20 +93,28 @@ export class DistanceMarkers {
|
||||
let features = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
|
||||
) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
level,
|
||||
minzoom,
|
||||
}
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
@@ -100,7 +122,7 @@ export class DistanceMarkers {
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
features,
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,14 +1,28 @@
|
||||
import { currentTool, map, Tool } from "$lib/stores";
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
import { currentTool, map, Tool } from '$lib/stores';
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
|
||||
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
ListTrackItem,
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
getClosestLinePoint,
|
||||
getElevation,
|
||||
resetCursor,
|
||||
setGrabbingCursor,
|
||||
setPointerCursor,
|
||||
setScissorsCursor,
|
||||
} from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -21,7 +35,7 @@ const colors = [
|
||||
'#288228',
|
||||
'#9933ff',
|
||||
'#50f0be',
|
||||
'#8c645a'
|
||||
'#8c645a',
|
||||
];
|
||||
|
||||
const colorCount: { [key: string]: number } = {};
|
||||
@@ -42,54 +56,33 @@ function decrementColor(color: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const inspectKey = 'Shift';
|
||||
let inspectKeyDown: KeyDown | null = null;
|
||||
class KeyDown {
|
||||
key: string;
|
||||
down: boolean = false;
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
document.addEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = true;
|
||||
}
|
||||
}
|
||||
onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = false;
|
||||
}
|
||||
}
|
||||
isDown() {
|
||||
return this.down;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
${Square
|
||||
.replace('width="24"', 'width="12"')
|
||||
${Square.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
||||
${MapPin
|
||||
.replace('width="24"', '')
|
||||
${MapPin.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
|
||||
${symbolSvg?.replace('width="24"', 'width="10"')
|
||||
.replace(
|
||||
'circle',
|
||||
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
|
||||
)}
|
||||
${
|
||||
symbolSvg
|
||||
?.replace('width="24"', 'width="10"')
|
||||
.replace('height="24"', 'height="10"')
|
||||
.replace('stroke="currentColor"', 'stroke="white"')
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
||||
}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
|
||||
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
export class GPXLayer {
|
||||
map: mapboxgl.Map;
|
||||
@@ -108,13 +101,18 @@ export class GPXLayer {
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(selection.subscribe($selection => {
|
||||
this.unsubscribe.push(
|
||||
selection.subscribe(($selection) => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
@@ -123,24 +121,23 @@ export class GPXLayer {
|
||||
if (newSelected) {
|
||||
this.moveToFront();
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(currentTool.subscribe(tool => {
|
||||
this.unsubscribe.push(
|
||||
currentTool.subscribe((tool) => {
|
||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||
this.draggable = true;
|
||||
this.markers.forEach(marker => marker.setDraggable(true));
|
||||
this.markers.forEach((marker) => marker.setDraggable(true));
|
||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||
this.draggable = false;
|
||||
this.markers.forEach(marker => marker.setDraggable(false));
|
||||
this.markers.forEach((marker) => marker.setDraggable(false));
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
|
||||
if (inspectKeyDown === null) {
|
||||
inspectKeyDown = new KeyDown(inspectKey);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
@@ -149,7 +146,11 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
||||
if (
|
||||
file._data.style &&
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
@@ -161,7 +162,7 @@ export class GPXLayer {
|
||||
} else {
|
||||
this.map.addSource(this.fileId, {
|
||||
type: 'geojson',
|
||||
data: this.getGeoJSON()
|
||||
data: this.getGeoJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,13 +173,13 @@ export class GPXLayer {
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'weight'],
|
||||
'line-opacity': ['get', 'opacity']
|
||||
}
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
|
||||
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
@@ -190,7 +191,8 @@ export class GPXLayer {
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.addLayer({
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
@@ -208,9 +210,11 @@ export class GPXLayer {
|
||||
'text-color': 'white',
|
||||
'text-opacity': 0.7,
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white'
|
||||
}
|
||||
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
@@ -225,23 +229,53 @@ export class GPXLayer {
|
||||
}
|
||||
});
|
||||
|
||||
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleItems.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
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => { // Update markers
|
||||
file.wpt.forEach((waypoint) => {
|
||||
// Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
||||
symbolKey,
|
||||
this.layerColor
|
||||
);
|
||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
||||
value: waypoint,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
@@ -249,7 +283,7 @@ export class GPXLayer {
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
anchor: 'bottom'
|
||||
anchor: 'bottom',
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
@@ -271,11 +305,21 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(verticalFileView)) {
|
||||
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
|
||||
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
if (get(treeFileView)) {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
get(selection).hasAnyChildren(
|
||||
new ListWaypointsItem(this.fileId),
|
||||
false
|
||||
)
|
||||
) {
|
||||
addSelectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
} else {
|
||||
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
selectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
}
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
@@ -298,12 +342,12 @@ export class GPXLayer {
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng
|
||||
lon: latLng.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
});
|
||||
dragEndTimestamp = Date.now()
|
||||
dragEndTimestamp = Date.now();
|
||||
});
|
||||
this.markers.push(marker);
|
||||
}
|
||||
@@ -311,7 +355,8 @@ export class GPXLayer {
|
||||
});
|
||||
}
|
||||
|
||||
while (markerIndex < this.markers.length) { // Remove extra markers
|
||||
while (markerIndex < this.markers.length) {
|
||||
// Remove extra markers
|
||||
this.markers.pop()?.remove();
|
||||
}
|
||||
|
||||
@@ -364,7 +409,10 @@ export class GPXLayer {
|
||||
this.map.moveLayer(this.fileId);
|
||||
}
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
this.map.moveLayer(
|
||||
this.fileId + '-direction',
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +420,12 @@ export class GPXLayer {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
setScissorsCursor();
|
||||
} else {
|
||||
setPointerCursor();
|
||||
@@ -384,28 +437,42 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
layerOnMouseMove(e: any) {
|
||||
if (inspectKeyDown?.isDown()) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
const closest = getClosestLinePoint(
|
||||
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
|
||||
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
|
||||
);
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -415,8 +482,12 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
let item = undefined;
|
||||
if (get(verticalFileView) && file.getSegments().length > 1) { // Select inner item
|
||||
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
|
||||
if (get(treeFileView) && file.getSegments().length > 1) {
|
||||
// Select inner item
|
||||
item =
|
||||
file.children[trackIndex].children.length > 1
|
||||
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
: new ListTrackItem(this.fileId, trackIndex);
|
||||
} else {
|
||||
item = new ListFileItem(this.fileId);
|
||||
}
|
||||
@@ -439,13 +510,14 @@ export class GPXLayer {
|
||||
if (!file) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
let data = file.toGeoJSON();
|
||||
|
||||
let trackIndex = 0, segmentIndex = 0;
|
||||
let trackIndex = 0,
|
||||
segmentIndex = 0;
|
||||
for (let feature of data.features) {
|
||||
if (!feature.properties) {
|
||||
feature.properties = {};
|
||||
@@ -453,14 +525,19 @@ export class GPXLayer {
|
||||
if (!feature.properties.color) {
|
||||
feature.properties.color = this.layerColor;
|
||||
}
|
||||
if (!feature.properties.weight) {
|
||||
feature.properties.weight = get(defaultWeight);
|
||||
}
|
||||
if (!feature.properties.opacity) {
|
||||
feature.properties.opacity = get(defaultOpacity);
|
||||
}
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
||||
feature.properties.weight = feature.properties.weight + 2;
|
||||
if (!feature.properties.width) {
|
||||
feature.properties.width = get(defaultWidth);
|
||||
}
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
) ||
|
||||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
|
||||
) {
|
||||
feature.properties.width = feature.properties.width + 2;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { dbUtils } from "$lib/db";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
@@ -11,14 +11,14 @@ export function createPopups(map: mapboxgl.Map) {
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
bottom: [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [10, -15],
|
||||
'right': [-10, -15],
|
||||
left: [10, -15],
|
||||
right: [-10, -15],
|
||||
},
|
||||
});
|
||||
trackpointPopup = new MapPopup(map, {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get } from "svelte/store";
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export class StartEndMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@@ -16,7 +16,8 @@ export class StartEndMarkers {
|
||||
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';
|
||||
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 });
|
||||
@@ -31,7 +32,11 @@ export class StartEndMarkers {
|
||||
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
||||
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(this.map);
|
||||
} else {
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
@@ -39,7 +44,7 @@ export class StartEndMarkers {
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
|
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
</script>
|
||||
@@ -32,5 +34,10 @@
|
||||
{df.format(trackpoint.item.time)}
|
||||
</div>
|
||||
{/if}
|
||||
<CopyCoordinates
|
||||
coordinates={trackpoint.item.attributes}
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
@@ -2,6 +2,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
||||
import { deleteWaypoint } from './GPXLayerPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
@@ -25,8 +26,8 @@
|
||||
allowedTags: ['a', 'br', 'img'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target'],
|
||||
img: ['src']
|
||||
}
|
||||
img: ['src'],
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
</script>
|
||||
@@ -61,7 +62,9 @@
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item.getLongitude().toFixed(6)}°
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
@@ -75,9 +78,11 @@
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
class="w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||
>
|
||||
@@ -86,6 +91,7 @@
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
@@ -15,7 +15,7 @@
|
||||
Trash2,
|
||||
Move,
|
||||
Map,
|
||||
Layers2
|
||||
Layers2,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
@@ -34,7 +34,7 @@
|
||||
currentOverlays,
|
||||
previousOverlays,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder
|
||||
customOverlayOrder,
|
||||
} = settings;
|
||||
|
||||
let name: string = '';
|
||||
@@ -68,7 +68,7 @@
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
@@ -77,7 +77,7 @@
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
@@ -118,7 +118,7 @@
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
value: '',
|
||||
};
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
@@ -131,16 +131,16 @@
|
||||
type: 'raster',
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: is512 ? 512 : 256,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
maxzoom: maxZoom,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
source: layerId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
@@ -230,7 +230,10 @@
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
@@ -247,7 +250,10 @@
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
|
||||
@@ -367,7 +373,8 @@
|
||||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
on:click={() =>
|
||||
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
@@ -387,7 +394,14 @@
|
||||
{/each}
|
||||
{#if resourceType === 'raster'}
|
||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
||||
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={maxZoom}
|
||||
id="maxZoom"
|
||||
min={0}
|
||||
max={22}
|
||||
class="h-8"
|
||||
/>
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
|
@@ -26,7 +26,7 @@
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
function setStyle() {
|
||||
@@ -41,7 +41,7 @@
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap
|
||||
data: basemap,
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
@@ -70,12 +70,12 @@
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
return layer;
|
||||
})
|
||||
}),
|
||||
};
|
||||
}
|
||||
$map.addImport({
|
||||
id,
|
||||
data: overlay
|
||||
data: overlay,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
@@ -14,7 +14,7 @@
|
||||
defaultBasemap,
|
||||
overlays,
|
||||
overlayTree,
|
||||
overpassTree
|
||||
overpassTree,
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
||||
import { settings } from '$lib/db';
|
||||
@@ -31,7 +31,7 @@
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
export let open: boolean;
|
||||
@@ -137,7 +137,9 @@
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
|
||||
<Select.Item value={id}
|
||||
>{$_(`layers.label.${id}`)}</Select.Item
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
@@ -159,7 +161,13 @@
|
||||
disabled={$selectedOverlay === undefined}
|
||||
onValueChange={(value) => {
|
||||
if ($selectedOverlay) {
|
||||
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
|
||||
if (
|
||||
$map &&
|
||||
isSelected(
|
||||
$currentOverlays,
|
||||
$selectedOverlay.value
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$map.removeImport($selectedOverlay.value);
|
||||
} catch (e) {
|
||||
|
@@ -49,7 +49,13 @@
|
||||
aria-label={$_(`layers.label.${id}`)}
|
||||
/>
|
||||
{:else}
|
||||
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
|
||||
<input
|
||||
id="{name}-{id}"
|
||||
type="radio"
|
||||
{name}
|
||||
value={id}
|
||||
bind:group={selected}
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
@@ -64,7 +70,13 @@
|
||||
<CollapsibleTreeNode {id}>
|
||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
||||
<div slot="content">
|
||||
<svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
|
||||
<svelte:self
|
||||
node={node[id]}
|
||||
{name}
|
||||
bind:selected
|
||||
{multiple}
|
||||
bind:checked={checked[id]}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
{/if}
|
||||
|
@@ -1,14 +1,12 @@
|
||||
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||
import { getLayers } from "./utils";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { liveQuery } from "dexie";
|
||||
import { db, settings } from "$lib/db";
|
||||
import { overpassQueryData } from "$lib/assets/layers";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db, settings } from '$lib/db';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
const {
|
||||
currentOverpassQueries
|
||||
} = settings;
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
@@ -29,7 +27,7 @@ export class OverpassLayer {
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
@@ -50,10 +48,12 @@ export class OverpassLayer {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
this.update();
|
||||
}
|
||||
@@ -126,8 +126,8 @@ export class OverpassLayer {
|
||||
this.popup.setItem({
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
}
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,8 +146,19 @@ export class OverpassLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
|
||||
db.overpasstiles
|
||||
.where('[x+y]')
|
||||
.equals([x, y])
|
||||
.toArray()
|
||||
.then((querytiles) => {
|
||||
let missingQueries = queries.filter(
|
||||
(query) =>
|
||||
!querytiles.some(
|
||||
(querytile) =>
|
||||
querytile.query === query &&
|
||||
time - querytile.time < this.expirationTime
|
||||
)
|
||||
);
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
@@ -165,13 +176,16 @@ export class OverpassLayer {
|
||||
|
||||
const bounds = mercator.bbox(x, y, this.queryZoom);
|
||||
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
||||
.then((response) => {
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
return Promise.reject();
|
||||
}, () => (this.currentQueries.delete(`${x},${y}`)))
|
||||
},
|
||||
() => this.currentQueries.delete(`${x},${y}`)
|
||||
)
|
||||
.then((data) => this.storeOverpassData(x, y, queries, data))
|
||||
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||
}
|
||||
@@ -179,7 +193,7 @@ export class OverpassLayer {
|
||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
return;
|
||||
@@ -195,7 +209,9 @@ export class OverpassLayer {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||
coordinates: element.center
|
||||
? [element.center.lon, element.center.lat]
|
||||
: [element.lon, element.lat],
|
||||
},
|
||||
properties: {
|
||||
id: element.id,
|
||||
@@ -203,9 +219,10 @@ export class OverpassLayer {
|
||||
lon: element.center ? element.center.lon : element.lon,
|
||||
query: query,
|
||||
icon: `overpass-${query}`,
|
||||
tags: element.tags
|
||||
tags: element.tags,
|
||||
type: element.type,
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -228,11 +245,13 @@ export class OverpassLayer {
|
||||
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(`
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||
<g transform="translate(8 8)">
|
||||
@@ -264,9 +283,14 @@ function getQuery(query: string) {
|
||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
|
||||
return arrayEntry[1]
|
||||
.map(
|
||||
(val) =>
|
||||
`nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||
.join('')};`).join('');
|
||||
.join('')};`
|
||||
)
|
||||
.join('');
|
||||
} else {
|
||||
return `nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${value}]`)
|
||||
@@ -283,8 +307,9 @@ function belongsToQuery(element: any, query: string) {
|
||||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
return Object.entries(tags)
|
||||
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentQueries() {
|
||||
@@ -293,5 +318,7 @@ function getCurrentQueries() {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||
return Object.entries(getLayers(currentQueries))
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([query, _]) => query);
|
||||
}
|
@@ -7,10 +7,11 @@
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { WaypointType } from 'gpx';
|
||||
|
||||
export let poi: PopupItem<any>;
|
||||
|
||||
let tags = {};
|
||||
let tags: { [key: string]: string } = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
@@ -20,6 +21,30 @@
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addToFile() {
|
||||
const desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
let wpt: WaypointType = {
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon,
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym,
|
||||
};
|
||||
if (tags.website) {
|
||||
wpt.link = {
|
||||
attributes: {
|
||||
href: tags.website,
|
||||
},
|
||||
};
|
||||
}
|
||||
dbUtils.addOrUpdateWaypoint(wpt);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
@@ -35,7 +60,8 @@
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
|
||||
'node'}={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
@@ -72,21 +98,7 @@
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym
|
||||
});
|
||||
}}
|
||||
on:click={addToFile}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import type { LayerTreeType } from "$lib/assets/layers";
|
||||
import { writable } from "svelte/store";
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
return (
|
||||
Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
}
|
||||
@@ -13,12 +14,16 @@ export function anySelectedLayer(node: LayerTreeType) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) !== undefined;
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
|
||||
export function getLayers(
|
||||
node: LayerTreeType,
|
||||
layers: { [key: string]: boolean } = {}
|
||||
): { [key: string]: boolean } {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
layers[id] = node[id];
|
||||
} else {
|
||||
getLayers(node[id], layers);
|
||||
@@ -32,7 +37,7 @@ export function isSelected(node: LayerTreeType, id: string) {
|
||||
if (key === id) {
|
||||
return node[key];
|
||||
}
|
||||
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
|
||||
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -43,7 +48,7 @@ export function toggle(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
node[key] = !node[key];
|
||||
} else if (typeof node[key] !== "boolean") {
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
toggle(node[key], id);
|
||||
}
|
||||
});
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { resetCursor, setCrosshairCursor } from "$lib/utils";
|
||||
import type mapboxgl from "mapbox-gl";
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
|
@@ -1,12 +1,14 @@
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from "mapbox-gl";
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { resetCursor, setPointerCursor } from "$lib/utils";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { resetCursor, setPointerCursor } from '$lib/utils';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
|
||||
tiles: [
|
||||
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
],
|
||||
minzoom: 6,
|
||||
maxzoom: 14,
|
||||
};
|
||||
@@ -70,7 +72,7 @@ export class MapillaryLayer {
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element
|
||||
element,
|
||||
});
|
||||
|
||||
this.viewer.on('position', async () => {
|
||||
|
@@ -10,7 +10,7 @@
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors,
|
||||
MountainSnow
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
@@ -24,7 +24,7 @@
|
||||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined
|
||||
maxWidth: undefined,
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" context="module">
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside'
|
||||
OUTSIDE = 'outside',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
|
||||
]
|
||||
]
|
||||
}
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
@@ -52,7 +52,7 @@
|
||||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
@@ -62,8 +62,8 @@
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -161,12 +161,12 @@
|
||||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
}
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
|
@@ -7,7 +7,7 @@
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" context="module">
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents'
|
||||
CONTENTS = 'contents',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -3,7 +3,11 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
@@ -18,10 +22,13 @@
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
const minTolerance = 0.1;
|
||||
const maxTolerance = 10000;
|
||||
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
|
||||
$: tolerance = 2 ** (sliderValue[0] / (100 / Math.log2(10000)));
|
||||
$: tolerance =
|
||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
||||
|
||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||
let unsubscribes = new Map<string, () => void>();
|
||||
@@ -32,7 +39,7 @@
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
features: [],
|
||||
};
|
||||
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
@@ -49,10 +56,10 @@
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude()
|
||||
])
|
||||
point.point.getLatitude(),
|
||||
]),
|
||||
},
|
||||
properties: {}
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +70,7 @@
|
||||
} else {
|
||||
$map.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('simplified')) {
|
||||
@@ -73,8 +80,8 @@
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3
|
||||
}
|
||||
'line-width': 3,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
$map.moveLayer('simplified');
|
||||
@@ -91,17 +98,23 @@
|
||||
});
|
||||
$fileObservers.forEach((fileStore, fileId) => {
|
||||
if (!unsubscribes.has(fileId)) {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [fs, sel]).subscribe(
|
||||
([fs, sel]) => {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
|
||||
fs,
|
||||
sel,
|
||||
]).subscribe(([fs, sel]) => {
|
||||
if (fs) {
|
||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex);
|
||||
let segmentItem = new ListTrackSegmentItem(
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
);
|
||||
if (sel.hasAnyParent(segmentItem)) {
|
||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||
simplified.set(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, 1)
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
]);
|
||||
update();
|
||||
} else if (simplified.has(segmentItem.getFullId())) {
|
||||
@@ -110,8 +123,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
unsubscribes.set(fileId, unsubscribe);
|
||||
}
|
||||
});
|
||||
@@ -154,7 +166,7 @@
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={3} class="font-normal" />
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
|
@@ -11,7 +11,7 @@
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers
|
||||
nauticalMilesToKilometers,
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
@@ -23,7 +23,7 @@
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
@@ -69,14 +69,14 @@
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
if ($gpxStatistics.global.time.moving && $gpxStatistics.global.speed.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else if ($gpxStatistics.global.time.total && $gpxStatistics.global.speed.total) {
|
||||
movingTime = $gpxStatistics.global.time.total;
|
||||
setSpeed($gpxStatistics.global.speed.total);
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,11 @@
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -325,13 +329,20 @@
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(getDate(startDate, startTime), movingTime);
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
@@ -346,7 +357,7 @@
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
|
@@ -31,14 +31,14 @@
|
||||
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
};
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
$: canCreate = $selection.size > 0;
|
||||
|
||||
$: if ($verticalFileView && $selection) {
|
||||
$: if ($treeFileView && $selection) {
|
||||
selectedWaypoint.update(() => {
|
||||
if ($selection.size === 1) {
|
||||
let item = $selection.getSelected()[0];
|
||||
@@ -74,12 +74,12 @@
|
||||
if (symbolKey) {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: $_(`gpx.symbol.${symbolKey}`)
|
||||
label: $_(`gpx.symbol.${symbolKey}`),
|
||||
};
|
||||
} else {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: ''
|
||||
label: '',
|
||||
};
|
||||
}
|
||||
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
||||
@@ -99,7 +99,7 @@
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
@@ -134,13 +134,13 @@
|
||||
{
|
||||
attributes: {
|
||||
lat: latitude,
|
||||
lon: longitude
|
||||
lon: longitude,
|
||||
},
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
|
||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined,
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
@@ -195,7 +195,11 @@
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
|
||||
<Select.Trigger
|
||||
id="symbol"
|
||||
class="w-full h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
@@ -218,7 +222,12 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
||||
<Input bind:value={link} id="link" class="h-8" disabled={!canCreate && !$selectedWaypoint} />
|
||||
<Input
|
||||
bind:value={link}
|
||||
id="link"
|
||||
class="h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
|
@@ -19,7 +19,7 @@
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight
|
||||
SquareArrowOutDownRight,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||
@@ -37,7 +37,7 @@
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
@@ -68,7 +68,10 @@
|
||||
// add controls for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!routingControls.has(fileId)) {
|
||||
routingControls.set(fileId, new RoutingControls($map, fileId, file, popup, popupElement));
|
||||
routingControls.set(
|
||||
fileId,
|
||||
new RoutingControls($map, fileId, file, popup, popupElement)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -82,9 +85,9 @@
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
}
|
||||
})
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
file._data.id = getFileIds(1)[0];
|
||||
dbUtils.add(file);
|
||||
@@ -195,7 +198,8 @@
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
|
||||
?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import type { Coordinates } from "gpx";
|
||||
import { TrackPoint, distance } from "gpx";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { settings } from "$lib/db";
|
||||
import { _, isLoading, locale } from "svelte-i18n";
|
||||
import { getElevation } from "$lib/utils";
|
||||
import type { Coordinates } from 'gpx';
|
||||
import { TrackPoint, distance } from 'gpx';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
import { _, isLoading, locale } from 'svelte-i18n';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
@@ -15,22 +15,31 @@ export const brouterProfiles: { [key: string]: string } = {
|
||||
foot: 'Hiking-Alpine-SAC6',
|
||||
motorcycle: 'Car-FastEco',
|
||||
water: 'river',
|
||||
railway: 'rail'
|
||||
railway: 'rail',
|
||||
};
|
||||
export const routingProfileSelectItem = writable({
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
});
|
||||
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
|
||||
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(
|
||||
([profile, l, i]) => {
|
||||
if (
|
||||
!i &&
|
||||
profile !== '' &&
|
||||
(profile !== get(routingProfileSelectItem).value ||
|
||||
get(_)(`toolbar.routing.activities.${profile}`) !==
|
||||
get(routingProfileSelectItem).label) &&
|
||||
l !== null
|
||||
) {
|
||||
routingProfileSelectItem.update((item) => {
|
||||
item.value = profile;
|
||||
item.label = get(_)(`toolbar.routing.activities.${profile}`);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
routingProfileSelectItem.subscribe((item) => {
|
||||
if (item.value !== '' && item.value !== get(routingProfile)) {
|
||||
routingProfile.set(item.value);
|
||||
@@ -45,8 +54,12 @@ export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoute(points: Coordinates[], brouterProfile: string, privateRoads: boolean): Promise<TrackPoint[]> {
|
||||
let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
async function getRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string,
|
||||
privateRoads: boolean
|
||||
): Promise<TrackPoint[]> {
|
||||
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
|
||||
@@ -61,25 +74,29 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
let coordinates = geojson.features[0].geometry.coordinates;
|
||||
let messages = geojson.features[0].properties.messages;
|
||||
|
||||
const lngIdx = messages[0].indexOf("Longitude");
|
||||
const latIdx = messages[0].indexOf("Latitude");
|
||||
const tagIdx = messages[0].indexOf("WayTags");
|
||||
const lngIdx = messages[0].indexOf('Longitude');
|
||||
const latIdx = messages[0].indexOf('Latitude');
|
||||
const tagIdx = messages[0].indexOf('WayTags');
|
||||
let messageIdx = 1;
|
||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
let coord = coordinates[i];
|
||||
route.push(new TrackPoint({
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coord[1],
|
||||
lon: coord[0]
|
||||
lon: coord[0],
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
|
||||
}));
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
})
|
||||
);
|
||||
|
||||
if (messageIdx < messages.length &&
|
||||
if (
|
||||
messageIdx < messages.length &&
|
||||
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000
|
||||
) {
|
||||
messageIdx++;
|
||||
|
||||
if (messageIdx == messages.length) tags = {};
|
||||
@@ -93,10 +110,10 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
}
|
||||
|
||||
function getTags(message: string): { [key: string]: string } {
|
||||
const fields = message.split(" ");
|
||||
const fields = message.split(' ');
|
||||
let tags: { [key: string]: string } = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let [key, value] = fields[i].split("=");
|
||||
let [key, value] = fields[i].split('=');
|
||||
key = key.replace(/:/g, '_');
|
||||
tags[key] = value;
|
||||
}
|
||||
@@ -107,26 +124,31 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
let route: TrackPoint[] = [];
|
||||
let step = 0.05;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) { // Add intermediate points between each pair of points
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Add intermediate points between each pair of points
|
||||
let dist = distance(points[i], points[i + 1]) / 1000;
|
||||
for (let d = 0; d < dist; d += step) {
|
||||
let lat = points[i].lat + d / dist * (points[i + 1].lat - points[i].lat);
|
||||
let lon = points[i].lon + d / dist * (points[i + 1].lon - points[i].lon);
|
||||
route.push(new TrackPoint({
|
||||
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
|
||||
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: lat,
|
||||
lon: lon
|
||||
}
|
||||
}));
|
||||
lon: lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
route.push(new TrackPoint({
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon
|
||||
}
|
||||
}));
|
||||
lon: points[points.length - 1].lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return getElevation(route).then((elevations) => {
|
||||
route.forEach((point, i) => {
|
||||
|
@@ -1,14 +1,18 @@
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
|
||||
import { get, writable, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { route } from "./Routing";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { dbUtils, settings, type GPXFileWithStatistics } from "$lib/db";
|
||||
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
||||
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
|
||||
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from "$lib/utils";
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
|
||||
import { get, writable, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { route } from './Routing';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { currentTool, streetViewEnabled, Tool } from '$lib/stores';
|
||||
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
export const canChangeStart = writable(false);
|
||||
@@ -28,15 +32,22 @@ export class RoutingControls {
|
||||
popupElement: HTMLElement;
|
||||
temporaryAnchor: AnchorWithMarker;
|
||||
lastDragEvent = 0;
|
||||
fileUnsubscribe: () => void = () => { };
|
||||
fileUnsubscribe: () => void = () => {};
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleAnchorsForZoomLevelAndBoundsBinded: () => void = this.toggleAnchorsForZoomLevelAndBounds.bind(this);
|
||||
toggleAnchorsForZoomLevelAndBoundsBinded: () => void =
|
||||
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);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>, popup: mapboxgl.Popup, popupElement: HTMLElement) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>,
|
||||
popup: mapboxgl.Popup,
|
||||
popupElement: HTMLElement
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
@@ -46,8 +57,8 @@ export class RoutingControls {
|
||||
let point = new TrackPoint({
|
||||
attributes: {
|
||||
lat: 0,
|
||||
lon: 0
|
||||
}
|
||||
lon: 0,
|
||||
},
|
||||
});
|
||||
this.temporaryAnchor = this.createAnchor(point, new TrackSegment(), 0, 0);
|
||||
this.temporaryAnchor.marker.getElement().classList.remove('z-10'); // Show below the other markers
|
||||
@@ -65,7 +76,9 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, ['waypoints']);
|
||||
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, [
|
||||
'waypoints',
|
||||
]);
|
||||
if (selected) {
|
||||
if (this.active) {
|
||||
this.updateControls();
|
||||
@@ -88,7 +101,8 @@ export class RoutingControls {
|
||||
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the file changes
|
||||
updateControls() {
|
||||
// Update the markers when the file changes
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
@@ -96,8 +110,13 @@ export class RoutingControls {
|
||||
|
||||
let anchorIndex = 0;
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
for (let point of segment.trkpt) { // Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let point of segment.trkpt) {
|
||||
// Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
|
||||
if (point._data.anchor) {
|
||||
if (anchorIndex < this.anchors.length) {
|
||||
this.anchors[anchorIndex].point = point;
|
||||
@@ -106,7 +125,9 @@ export class RoutingControls {
|
||||
this.anchors[anchorIndex].segmentIndex = segmentIndex;
|
||||
this.anchors[anchorIndex].marker.setLngLat(point.getCoordinates());
|
||||
} else {
|
||||
this.anchors.push(this.createAnchor(point, segment, trackIndex, segmentIndex));
|
||||
this.anchors.push(
|
||||
this.createAnchor(point, segment, trackIndex, segmentIndex)
|
||||
);
|
||||
}
|
||||
anchorIndex++;
|
||||
}
|
||||
@@ -114,7 +135,8 @@ export class RoutingControls {
|
||||
}
|
||||
});
|
||||
|
||||
while (anchorIndex < this.anchors.length) { // Remove the extra anchors
|
||||
while (anchorIndex < this.anchors.length) {
|
||||
// Remove the extra anchors
|
||||
this.anchors.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
@@ -141,14 +163,19 @@ export class RoutingControls {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
|
||||
createAnchor(
|
||||
point: TrackPoint,
|
||||
segment: TrackSegment,
|
||||
trackIndex: number,
|
||||
segmentIndex: number
|
||||
): AnchorWithMarker {
|
||||
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({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let anchor = {
|
||||
@@ -157,7 +184,7 @@ export class RoutingControls {
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false
|
||||
inZoom: false,
|
||||
};
|
||||
|
||||
marker.on('dragstart', (e) => {
|
||||
@@ -185,7 +212,8 @@ export class RoutingControls {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
|
||||
if (Date.now() - this.lastDragEvent < 100) {
|
||||
// Prevent click event during drag
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,7 +232,12 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
let segment = anchor.segment;
|
||||
if (distance(segment.trkpt[0].getCoordinates(), segment.trkpt[segment.trkpt.length - 1].getCoordinates()) > 1000) {
|
||||
if (
|
||||
distance(
|
||||
segment.trkpt[0].getCoordinates(),
|
||||
segment.trkpt[segment.trkpt.length - 1].getCoordinates()
|
||||
) > 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -224,7 +257,8 @@ export class RoutingControls {
|
||||
};
|
||||
}
|
||||
|
||||
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
toggleAnchorsForZoomLevelAndBounds() {
|
||||
// Show markers only if they are in the current zoom level and bounds
|
||||
this.shownAnchors.splice(0, this.shownAnchors.length);
|
||||
|
||||
let center = this.map.getCenter();
|
||||
@@ -245,7 +279,8 @@ export class RoutingControls {
|
||||
}
|
||||
|
||||
showTemporaryAnchor(e: any) {
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not not change the source point if it is already being dragged
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
|
||||
// Do not not change the source point if it is already being dragged
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,7 +288,15 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, e.features[0].properties.trackIndex, e.features[0].properties.segmentIndex))) {
|
||||
if (
|
||||
!get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(
|
||||
this.fileId,
|
||||
e.features[0].properties.trackIndex,
|
||||
e.features[0].properties.segmentIndex
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,7 +306,7 @@ export class RoutingControls {
|
||||
|
||||
this.temporaryAnchor.point.setCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
this.temporaryAnchor.marker.setLngLat(e.lngLat).addTo(this.map);
|
||||
|
||||
@@ -271,12 +314,17 @@ export class RoutingControls {
|
||||
}
|
||||
|
||||
updateTemporaryAnchor(e: any) {
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not hide if it is being dragged, and stop listening for mousemove
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
|
||||
// Do not hide if it is being dragged, and stop listening for mousemove
|
||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 || this.temporaryAnchorCloseToOtherAnchor(e)) { // Hide if too far from the layer
|
||||
if (
|
||||
e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 ||
|
||||
this.temporaryAnchorCloseToOtherAnchor(e)
|
||||
) {
|
||||
// Hide if too far from the layer
|
||||
this.temporaryAnchor.marker.remove();
|
||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
return;
|
||||
@@ -294,14 +342,16 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
|
||||
async moveAnchor(anchorWithMarker: AnchorWithMarker) { // Move the anchor and update the route from and to the neighbouring anchors
|
||||
async moveAnchor(anchorWithMarker: AnchorWithMarker) {
|
||||
// Move the anchor and update the route from and to the neighbouring anchors
|
||||
let coordinates = {
|
||||
lat: anchorWithMarker.marker.getLngLat().lat,
|
||||
lon: anchorWithMarker.marker.getLngLat().lng
|
||||
lon: anchorWithMarker.marker.getLngLat().lng,
|
||||
};
|
||||
|
||||
let anchor = anchorWithMarker as Anchor;
|
||||
if (anchorWithMarker === this.temporaryAnchor) { // Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
||||
if (anchorWithMarker === this.temporaryAnchor) {
|
||||
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
||||
this.temporaryAnchor.marker.remove();
|
||||
anchor = this.getPermanentAnchor();
|
||||
}
|
||||
@@ -326,7 +376,8 @@ export class RoutingControls {
|
||||
|
||||
let success = await this.routeBetweenAnchors(anchors, targetCoordinates);
|
||||
|
||||
if (!success) { // Route failed, revert the anchor to the previous position
|
||||
if (!success) {
|
||||
// Route failed, revert the anchor to the previous position
|
||||
anchorWithMarker.marker.setLngLat(anchorWithMarker.point.getCoordinates());
|
||||
}
|
||||
}
|
||||
@@ -338,16 +389,24 @@ export class RoutingControls {
|
||||
let minDetails: any = { distance: Number.MAX_VALUE };
|
||||
let minAnchor = this.temporaryAnchor as Anchor;
|
||||
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
let details: any = {};
|
||||
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
let closest = getClosestLinePoint(
|
||||
segment.trkpt,
|
||||
this.temporaryAnchor.point,
|
||||
details
|
||||
);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
minAnchor = {
|
||||
point: closest,
|
||||
segment,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
segmentIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -374,41 +433,67 @@ export class RoutingControls {
|
||||
point: this.temporaryAnchor.point,
|
||||
trackIndex: -1,
|
||||
segmentIndex: -1,
|
||||
trkptIndex: -1
|
||||
trkptIndex: -1,
|
||||
};
|
||||
|
||||
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
let details: any = {};
|
||||
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
let before = details.before ? details.index : details.index - 1;
|
||||
|
||||
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
|
||||
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
|
||||
let projectedPt = projectedPoint(
|
||||
segment.trkpt[before],
|
||||
segment.trkpt[before + 1],
|
||||
this.temporaryAnchor.point
|
||||
);
|
||||
let ratio =
|
||||
distance(segment.trkpt[before], projectedPt) /
|
||||
distance(segment.trkpt[before], segment.trkpt[before + 1]);
|
||||
|
||||
let point = segment.trkpt[before].clone();
|
||||
point.setCoordinates(projectedPt);
|
||||
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
|
||||
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
|
||||
point.ele =
|
||||
(1 - ratio) * (segment.trkpt[before].ele ?? 0) +
|
||||
ratio * (segment.trkpt[before + 1].ele ?? 0);
|
||||
point.time =
|
||||
segment.trkpt[before].time && segment.trkpt[before + 1].time
|
||||
? new Date(
|
||||
(1 - ratio) * segment.trkpt[before].time.getTime() +
|
||||
ratio * segment.trkpt[before + 1].time.getTime()
|
||||
)
|
||||
: undefined;
|
||||
point._data = {
|
||||
anchor: true,
|
||||
zoom: 0
|
||||
zoom: 0,
|
||||
};
|
||||
|
||||
minInfo = {
|
||||
point,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
trkptIndex: before + 1
|
||||
trkptIndex: before + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (minInfo.trackIndex !== -1) {
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
minInfo.trackIndex,
|
||||
minInfo.segmentIndex,
|
||||
minInfo.trkptIndex,
|
||||
minInfo.trkptIndex - 1,
|
||||
[minInfo.point]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,22 +501,46 @@ export class RoutingControls {
|
||||
return () => this.deleteAnchor(anchor);
|
||||
}
|
||||
|
||||
async deleteAnchor(anchor: Anchor) { // Remove the anchor and route between the neighbouring anchors if they exist
|
||||
async deleteAnchor(anchor: Anchor) {
|
||||
// Remove the anchor and route between the neighbouring anchors if they exist
|
||||
this.popup.remove();
|
||||
|
||||
let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
||||
|
||||
if (previousAnchor === null && nextAnchor === null) { // Only one point, remove it
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, []));
|
||||
} else if (previousAnchor === null) { // First point, remove trackpoints until nextAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, nextAnchor.point._data.index - 1, []));
|
||||
} else if (nextAnchor === null) { // Last point, remove trackpoints from previousAnchor
|
||||
if (previousAnchor === null && nextAnchor === null) {
|
||||
// Only one point, remove it
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, [])
|
||||
);
|
||||
} else if (previousAnchor === null) {
|
||||
// First point, remove trackpoints until nextAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
0,
|
||||
nextAnchor.point._data.index - 1,
|
||||
[]
|
||||
)
|
||||
);
|
||||
} else if (nextAnchor === null) {
|
||||
// Last point, remove trackpoints from previousAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
let segment = file.getSegment(anchor.trackIndex, anchor.segmentIndex);
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, previousAnchor.point._data.index + 1, segment.trkpt.length - 1, []);
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
previousAnchor.point._data.index + 1,
|
||||
segment.trkpt.length - 1,
|
||||
[]
|
||||
);
|
||||
});
|
||||
} else { // Route between previousAnchor and nextAnchor
|
||||
this.routeBetweenAnchors([previousAnchor, nextAnchor], [previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]);
|
||||
} else {
|
||||
// Route between previousAnchor and nextAnchor
|
||||
this.routeBetweenAnchors(
|
||||
[previousAnchor, nextAnchor],
|
||||
[previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,27 +556,43 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)).global.speed.moving;
|
||||
let speed = fileWithStats.statistics.getStatisticsFor(
|
||||
new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)
|
||||
).global.speed.moving;
|
||||
|
||||
let segment = anchor.segment;
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, segment.trkpt.length, segment.trkpt.length - 1, segment.trkpt.slice(0, anchor.point._data.index), speed > 0 ? speed : undefined);
|
||||
file.crop(anchor.point._data.index, anchor.point._data.index + segment.trkpt.length - 1, [anchor.trackIndex], [anchor.segmentIndex]);
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
segment.trkpt.length,
|
||||
segment.trkpt.length - 1,
|
||||
segment.trkpt.slice(0, anchor.point._data.index),
|
||||
speed > 0 ? speed : undefined
|
||||
);
|
||||
file.crop(
|
||||
anchor.point._data.index,
|
||||
anchor.point._data.index + segment.trkpt.length - 1,
|
||||
[anchor.trackIndex],
|
||||
[anchor.segmentIndex]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async appendAnchor(e: mapboxgl.MapMouseEvent) { // Add a new anchor to the end of the last segment
|
||||
async appendAnchor(e: mapboxgl.MapMouseEvent) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appendAnchorWithCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
}
|
||||
|
||||
async appendAnchorWithCoordinates(coordinates: Coordinates) { // Add a new anchor to the end of the last segment
|
||||
async appendAnchorWithCoordinates(coordinates: Coordinates) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
let selected = getOrderedSelection();
|
||||
if (selected.length === 0 || selected[selected.length - 1].getFileId() !== this.fileId) {
|
||||
return;
|
||||
@@ -477,7 +602,7 @@ export class RoutingControls {
|
||||
let lastAnchor = this.anchors[this.anchors.length - 1];
|
||||
|
||||
let newPoint = new TrackPoint({
|
||||
attributes: coordinates
|
||||
attributes: coordinates,
|
||||
});
|
||||
newPoint._data.anchor = true;
|
||||
newPoint._data.zoom = 0;
|
||||
@@ -488,7 +613,10 @@ export class RoutingControls {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
trackIndex = item.getTrackIndex();
|
||||
}
|
||||
let segmentIndex = (file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0) ? file.trk[trackIndex].trkseg.length - 1 : 0;
|
||||
let segmentIndex =
|
||||
file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0
|
||||
? file.trk[trackIndex].trkseg.length - 1
|
||||
: 0;
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
segmentIndex = item.getSegmentIndex();
|
||||
}
|
||||
@@ -512,10 +640,13 @@ export class RoutingControls {
|
||||
point: newPoint,
|
||||
segment: lastAnchor.segment,
|
||||
trackIndex: lastAnchor.trackIndex,
|
||||
segmentIndex: lastAnchor.segmentIndex
|
||||
segmentIndex: lastAnchor.segmentIndex,
|
||||
};
|
||||
|
||||
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]);
|
||||
await this.routeBetweenAnchors(
|
||||
[lastAnchor, newAnchor],
|
||||
[lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]
|
||||
);
|
||||
}
|
||||
|
||||
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
||||
@@ -525,11 +656,17 @@ export class RoutingControls {
|
||||
for (let i = 0; i < this.anchors.length; i++) {
|
||||
if (this.anchors[i].segment === anchor.segment && this.anchors[i].inZoom) {
|
||||
if (this.anchors[i].point._data.index < anchor.point._data.index) {
|
||||
if (!previousAnchor || this.anchors[i].point._data.index > previousAnchor.point._data.index) {
|
||||
if (
|
||||
!previousAnchor ||
|
||||
this.anchors[i].point._data.index > previousAnchor.point._data.index
|
||||
) {
|
||||
previousAnchor = this.anchors[i];
|
||||
}
|
||||
} else if (this.anchors[i].point._data.index > anchor.point._data.index) {
|
||||
if (!nextAnchor || this.anchors[i].point._data.index < nextAnchor.point._data.index) {
|
||||
if (
|
||||
!nextAnchor ||
|
||||
this.anchors[i].point._data.index < nextAnchor.point._data.index
|
||||
) {
|
||||
nextAnchor = this.anchors[i];
|
||||
}
|
||||
}
|
||||
@@ -539,7 +676,10 @@ export class RoutingControls {
|
||||
return [previousAnchor, nextAnchor];
|
||||
}
|
||||
|
||||
async routeBetweenAnchors(anchors: Anchor[], targetCoordinates: Coordinates[]): Promise<boolean> {
|
||||
async routeBetweenAnchors(
|
||||
anchors: Anchor[],
|
||||
targetCoordinates: Coordinates[]
|
||||
): Promise<boolean> {
|
||||
let segment = anchors[0].segment;
|
||||
|
||||
let fileWithStats = get(this.file);
|
||||
@@ -547,10 +687,15 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchors.length === 1) { // Only one anchor, update the point in the segment
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [new TrackPoint({
|
||||
if (anchors.length === 1) {
|
||||
// Only one anchor, update the point in the segment
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [
|
||||
new TrackPoint({
|
||||
attributes: targetCoordinates[0],
|
||||
})]));
|
||||
}),
|
||||
])
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -559,23 +704,28 @@ export class RoutingControls {
|
||||
response = await route(targetCoordinates);
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('from-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.from"));
|
||||
toast.error(get(_)('toolbar.routing.error.from'));
|
||||
} else if (e.message.includes('via1-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.via"));
|
||||
toast.error(get(_)('toolbar.routing.error.via'));
|
||||
} else if (e.message.includes('to-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.to"));
|
||||
toast.error(get(_)('toolbar.routing.error.to'));
|
||||
} else if (e.message.includes('Time-out')) {
|
||||
toast.error(get(_)("toolbar.routing.error.timeout"));
|
||||
toast.error(get(_)('toolbar.routing.error.timeout'));
|
||||
} else {
|
||||
toast.error(e.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
|
||||
if (anchors[0].point._data.index === 0) {
|
||||
// First anchor is the first point of the segment
|
||||
anchors[0].point = response[0]; // replace the first anchor
|
||||
anchors[0].point._data.index = 0;
|
||||
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
|
||||
} else if (
|
||||
anchors[0].point._data.index === segment.trkpt.length - 1 &&
|
||||
distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1
|
||||
) {
|
||||
// First anchor is the last point of the segment, and the new point is close enough
|
||||
anchors[0].point = response[0]; // replace the first anchor
|
||||
anchors[0].point._data.index = segment.trkpt.length - 1;
|
||||
} else {
|
||||
@@ -583,7 +733,8 @@ export class RoutingControls {
|
||||
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
|
||||
}
|
||||
|
||||
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) { // Last anchor is the last point of the segment
|
||||
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) {
|
||||
// Last anchor is the last point of the segment
|
||||
anchors[anchors.length - 1].point = response[response.length - 1]; // replace the last anchor
|
||||
anchors[anchors.length - 1].point._data.index = segment.trkpt.length - 1;
|
||||
} else {
|
||||
@@ -594,7 +745,7 @@ export class RoutingControls {
|
||||
for (let i = 1; i < anchors.length - 1; i++) {
|
||||
// Find the closest point to the intermediate anchor
|
||||
// and transfer the marker to that point
|
||||
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
|
||||
anchors[i].point = getClosestLinePoint(response.slice(1, -1), targetCoordinates[i]);
|
||||
}
|
||||
|
||||
anchors.forEach((anchor) => {
|
||||
@@ -602,36 +753,64 @@ export class RoutingControls {
|
||||
anchor.point._data.zoom = 0; // Make these anchors permanent
|
||||
});
|
||||
|
||||
let stats = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex));
|
||||
let stats = fileWithStats.statistics.getStatisticsFor(
|
||||
new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex)
|
||||
);
|
||||
let speed: number | undefined = undefined;
|
||||
let startTime = anchors[0].point.time;
|
||||
|
||||
if (stats.global.speed.moving > 0) {
|
||||
let replacingDistance = 0;
|
||||
for (let i = 1; i < response.length; i++) {
|
||||
replacingDistance += distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
||||
replacingDistance +=
|
||||
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
||||
}
|
||||
let replacedDistance = stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - stats.local.distance.moving[anchors[0].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];
|
||||
|
||||
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
|
||||
let newTime = newDistance / stats.global.speed.moving * 3600;
|
||||
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]);
|
||||
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]);
|
||||
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];
|
||||
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];
|
||||
}
|
||||
|
||||
speed = replacingDistance / replacingTime * 3600;
|
||||
speed = (replacingDistance / replacingTime) * 3600;
|
||||
|
||||
if (startTime === undefined) { // Replacing the first point
|
||||
if (startTime === undefined) {
|
||||
// Replacing the first point
|
||||
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]) * 1000);
|
||||
startTime = new Date(
|
||||
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
|
||||
(replacingTime +
|
||||
stats.local.time.total[endIndex] -
|
||||
stats.local.time.moving[endIndex]) *
|
||||
1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, anchors[0].point._data.index, anchors[anchors.length - 1].point._data.index, response, speed, startTime));
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
anchors[0].trackIndex,
|
||||
anchors[0].segmentIndex,
|
||||
anchors[0].point._data.index,
|
||||
anchors[anchors.length - 1].point._data.index,
|
||||
response,
|
||||
speed,
|
||||
startTime
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from "gpx";
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
@@ -17,7 +17,8 @@ export function updateAnchorPoints(file: GPXFile) {
|
||||
let segments = file.getSegments();
|
||||
|
||||
for (let segment of segments) {
|
||||
if (!segment._data.anchors) { // New segment, compute anchor points for it
|
||||
if (!segment._data.anchors) {
|
||||
// New segment, compute anchor points for it
|
||||
computeAnchorPoints(segment);
|
||||
continue;
|
||||
}
|
||||
@@ -42,4 +43,3 @@ function computeAnchorPoints(segment: TrackSegment) {
|
||||
});
|
||||
segment._data.anchors = true;
|
||||
}
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
export enum SplitType {
|
||||
FILES = 'files',
|
||||
TRACKS = 'tracks',
|
||||
SEGMENTS = 'segments'
|
||||
SEGMENTS = 'segments',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1]
|
||||
sliderValues[1],
|
||||
];
|
||||
} else {
|
||||
$slicedGPXStatistics = undefined;
|
||||
@@ -93,10 +93,10 @@
|
||||
const splitTypes = [
|
||||
{ value: SplitType.FILES, label: $_('gpx.files') },
|
||||
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
|
||||
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') }
|
||||
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') },
|
||||
];
|
||||
|
||||
let splitType = splitTypes[0];
|
||||
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
|
||||
|
||||
$: splitAs.set(splitType.value);
|
||||
|
||||
@@ -111,7 +111,12 @@
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValues} max={maxSliderValue} step={1} disabled={!validSelection} />
|
||||
<Slider
|
||||
bind:value={sliderValues}
|
||||
max={maxSliderValue}
|
||||
step={1}
|
||||
disabled={!validSelection}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import { TrackPoint, TrackSegment } from "gpx";
|
||||
import { get } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { applyToOrderedSelectedItemsFromFile, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
||||
import { currentTool, gpxStatistics, Tool } from "$lib/stores";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Scissors } from "lucide-static";
|
||||
import { TrackPoint, TrackSegment } from 'gpx';
|
||||
import { get } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import {
|
||||
applyToOrderedSelectedItemsFromFile,
|
||||
selection,
|
||||
} from '$lib/components/file-list/Selection';
|
||||
import { ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||
import { currentTool, gpxStatistics, Tool } from '$lib/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { Scissors } from 'lucide-static';
|
||||
|
||||
export class SplitControls {
|
||||
active: boolean = false;
|
||||
@@ -15,7 +18,8 @@ export class SplitControls {
|
||||
shownControls: ControlWithMarker[] = [];
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void =
|
||||
this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
@@ -48,15 +52,21 @@ export class SplitControls {
|
||||
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the files change
|
||||
updateControls() {
|
||||
// Update the markers when the files change
|
||||
let controlIndex = 0;
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = getFile(fileId);
|
||||
|
||||
if (file) {
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(fileId, trackIndex, segmentIndex))) {
|
||||
for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?)
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let point of segment.trkpt.slice(1, -1)) {
|
||||
// Update the existing controls (could be improved by matching the existing controls with the new ones?)
|
||||
if (point._data.anchor) {
|
||||
if (controlIndex < this.controls.length) {
|
||||
this.controls[controlIndex].fileId = fileId;
|
||||
@@ -64,20 +74,30 @@ export class SplitControls {
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
this.controls[controlIndex].segmentIndex = segmentIndex;
|
||||
this.controls[controlIndex].marker.setLngLat(point.getCoordinates());
|
||||
this.controls[controlIndex].marker.setLngLat(
|
||||
point.getCoordinates()
|
||||
);
|
||||
} else {
|
||||
this.controls.push(this.createControl(point, segment, fileId, trackIndex, segmentIndex));
|
||||
this.controls.push(
|
||||
this.createControl(
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
controlIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}, false);
|
||||
|
||||
while (controlIndex < this.controls.length) { // Remove the extra controls
|
||||
while (controlIndex < this.controls.length) {
|
||||
// Remove the extra controls
|
||||
this.controls.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
@@ -94,7 +114,8 @@ export class SplitControls {
|
||||
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
toggleControlsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
toggleControlsForZoomLevelAndBounds() {
|
||||
// Show markers only if they are in the current zoom level and bounds
|
||||
this.shownControls.splice(0, this.shownControls.length);
|
||||
|
||||
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
|
||||
@@ -113,15 +134,23 @@ export class SplitControls {
|
||||
});
|
||||
}
|
||||
|
||||
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
|
||||
createControl(
|
||||
point: TrackPoint,
|
||||
segment: TrackSegment,
|
||||
fileId: string,
|
||||
trackIndex: number,
|
||||
segmentIndex: number
|
||||
): ControlWithMarker {
|
||||
let element = document.createElement('div');
|
||||
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
|
||||
element.innerHTML = Scissors.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', 'stroke="black"');
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let control = {
|
||||
@@ -131,12 +160,18 @@ export class SplitControls {
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false
|
||||
inZoom: false,
|
||||
};
|
||||
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dbUtils.split(control.fileId, control.trackIndex, control.segmentIndex, control.point.getCoordinates(), control.point._data.index);
|
||||
dbUtils.split(
|
||||
control.fileId,
|
||||
control.trackIndex,
|
||||
control.segmentIndex,
|
||||
control.point.getCoordinates(),
|
||||
control.point._data.index
|
||||
);
|
||||
});
|
||||
|
||||
return control;
|
||||
|
@@ -1,11 +1,58 @@
|
||||
import Dexie, { liveQuery } from 'dexie';
|
||||
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension, type WaypointType } from 'gpx';
|
||||
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, freeze, produceWithPatches } from 'immer';
|
||||
import {
|
||||
GPXFile,
|
||||
GPXStatistics,
|
||||
Track,
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
TrackPoint,
|
||||
type Coordinates,
|
||||
distance,
|
||||
type LineStyleExtension,
|
||||
type WaypointType,
|
||||
} from 'gpx';
|
||||
import {
|
||||
enableMapSet,
|
||||
enablePatches,
|
||||
applyPatches,
|
||||
type Patch,
|
||||
type WritableDraft,
|
||||
freeze,
|
||||
produceWithPatches,
|
||||
} from 'immer';
|
||||
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { gpxStatistics, initTargetMapBounds, map, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
|
||||
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer, defaultOpacities, defaultOverpassQueries, defaultOverpassTree } from './assets/layers';
|
||||
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
gpxStatistics,
|
||||
initTargetMapBounds,
|
||||
map,
|
||||
splitAs,
|
||||
updateAllHidden,
|
||||
updateTargetMapBounds,
|
||||
} from './stores';
|
||||
import {
|
||||
defaultBasemap,
|
||||
defaultBasemapTree,
|
||||
defaultOverlayTree,
|
||||
defaultOverlays,
|
||||
type CustomLayer,
|
||||
defaultOpacities,
|
||||
defaultOverpassQueries,
|
||||
defaultOverpassTree,
|
||||
} from './assets/layers';
|
||||
import {
|
||||
applyToOrderedItemsFromFile,
|
||||
applyToOrderedSelectedItemsFromFile,
|
||||
selection,
|
||||
} from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListTrackItem,
|
||||
ListLevel,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
||||
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
@@ -15,17 +62,22 @@ enableMapSet();
|
||||
enablePatches();
|
||||
|
||||
class Database extends Dexie {
|
||||
|
||||
fileids!: Dexie.Table<string, string>;
|
||||
files!: Dexie.Table<GPXFile, string>;
|
||||
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
|
||||
patches!: Dexie.Table<{ patch: Patch[]; inversePatch: Patch[]; index: number }, number>;
|
||||
settings!: Dexie.Table<any, string>;
|
||||
overpasstiles!: Dexie.Table<{ query: string, x: number, y: number, time: number }, [string, number, number]>;
|
||||
overpassdata!: Dexie.Table<{ query: string, id: number, poi: GeoJSON.Feature }, [string, number]>;
|
||||
overpasstiles!: Dexie.Table<
|
||||
{ query: string; x: number; y: number; time: number },
|
||||
[string, number, number]
|
||||
>;
|
||||
overpassdata!: Dexie.Table<
|
||||
{ query: string; id: number; poi: GeoJSON.Feature },
|
||||
[string, number]
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
super("Database", {
|
||||
cache: 'immutable'
|
||||
super('Database', {
|
||||
cache: 'immutable',
|
||||
});
|
||||
this.version(1).stores({
|
||||
fileids: ',&fileid',
|
||||
@@ -41,10 +93,15 @@ class Database extends Dexie {
|
||||
export const db = new Database();
|
||||
|
||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
|
||||
export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K, initial: V, initialize: boolean = true): Writable<V | undefined> {
|
||||
export function bidirectionalDexieStore<K, V>(
|
||||
table: Dexie.Table<V, K>,
|
||||
key: K,
|
||||
initial: V,
|
||||
initialize: boolean = true
|
||||
): Writable<V | undefined> {
|
||||
let first = true;
|
||||
let store = writable<V | undefined>(initialize ? initial : undefined);
|
||||
liveQuery(() => table.get(key)).subscribe(value => {
|
||||
liveQuery(() => table.get(key)).subscribe((value) => {
|
||||
if (value === undefined) {
|
||||
if (first) {
|
||||
if (!initialize) {
|
||||
@@ -70,11 +127,15 @@ export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K,
|
||||
if (typeof newValue === 'object' || newValue !== get(store)) {
|
||||
table.put(newValue, key);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function dexieSettingStore<T>(key: string, initial: T, initialize: boolean = true): Writable<T> {
|
||||
export function dexieSettingStore<T>(
|
||||
key: string,
|
||||
initial: T,
|
||||
initialize: boolean = true
|
||||
): Writable<T> {
|
||||
return bidirectionalDexieStore(db.settings, key, initial, initialize);
|
||||
}
|
||||
|
||||
@@ -85,7 +146,7 @@ export const settings = {
|
||||
elevationProfile: dexieSettingStore('elevationProfile', true),
|
||||
additionalDatasets: dexieSettingStore<string[]>('additionalDatasets', []),
|
||||
elevationFill: dexieSettingStore<'slope' | 'surface' | undefined>('elevationFill', undefined),
|
||||
verticalFileView: dexieSettingStore<boolean>('fileView', false),
|
||||
treeFileView: dexieSettingStore<boolean>('fileView', false),
|
||||
minimizeRoutingMenu: dexieSettingStore('minimizeRoutingMenu', false),
|
||||
routing: dexieSettingStore('routing', true),
|
||||
routingProfile: dexieSettingStore('routingProfile', 'bike'),
|
||||
@@ -96,7 +157,11 @@ export const settings = {
|
||||
currentOverlays: dexieSettingStore('currentOverlays', defaultOverlays, false),
|
||||
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
|
||||
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
|
||||
currentOverpassQueries: dexieSettingStore('currentOverpassQueries', defaultOverpassQueries, false),
|
||||
currentOverpassQueries: dexieSettingStore(
|
||||
'currentOverpassQueries',
|
||||
defaultOverpassQueries,
|
||||
false
|
||||
),
|
||||
selectedOverpassTree: dexieSettingStore('selectedOverpassTree', defaultOverpassTree),
|
||||
opacities: dexieSettingStore('opacities', defaultOpacities),
|
||||
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
|
||||
@@ -107,7 +172,7 @@ export const settings = {
|
||||
streetViewSource: dexieSettingStore('streetViewSource', 'mapillary'),
|
||||
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
|
||||
defaultOpacity: dexieSettingStore('defaultOpacity', 0.7),
|
||||
defaultWeight: dexieSettingStore('defaultWeight', (browser && window.innerWidth < 600) ? 8 : 5),
|
||||
defaultWidth: dexieSettingStore('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
|
||||
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
|
||||
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
|
||||
};
|
||||
@@ -115,7 +180,7 @@ export const settings = {
|
||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
|
||||
function dexieStore<T>(querier: () => T | Promise<T>, initial?: T): Readable<T> {
|
||||
let store = writable<T>(initial);
|
||||
liveQuery(querier).subscribe(value => {
|
||||
liveQuery(querier).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
store.set(value);
|
||||
}
|
||||
@@ -149,7 +214,7 @@ export class GPXStatisticsTree {
|
||||
let statistics = new GPXStatistics();
|
||||
let id = item.getIdAtLevel(this.level);
|
||||
if (id === undefined || id === 'waypoints') {
|
||||
Object.keys(this.statistics).forEach(key => {
|
||||
Object.keys(this.statistics).forEach((key) => {
|
||||
if (this.statistics[key] instanceof GPXStatistics) {
|
||||
statistics.mergeWith(this.statistics[key]);
|
||||
} else {
|
||||
@@ -166,26 +231,30 @@ export class GPXStatisticsTree {
|
||||
}
|
||||
return statistics;
|
||||
}
|
||||
};
|
||||
export type GPXFileWithStatistics = { file: GPXFile, statistics: GPXStatisticsTree };
|
||||
}
|
||||
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };
|
||||
|
||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, also takes care of the conversion to a GPXFile object
|
||||
function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { destroy: () => void } {
|
||||
let store = writable<GPXFileWithStatistics>(undefined);
|
||||
let query = liveQuery(() => db.files.get(id)).subscribe(value => {
|
||||
let query = liveQuery(() => db.files.get(id)).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
let gpx = new GPXFile(value);
|
||||
updateAnchorPoints(gpx);
|
||||
|
||||
let statistics = new GPXStatisticsTree(gpx);
|
||||
if (!fileState.has(id)) { // Update the map bounds for new files
|
||||
updateTargetMapBounds(id, statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
|
||||
if (!fileState.has(id)) {
|
||||
// Update the map bounds for new files
|
||||
updateTargetMapBounds(
|
||||
id,
|
||||
statistics.getStatisticsFor(new ListFileItem(id)).global.bounds
|
||||
);
|
||||
}
|
||||
|
||||
fileState.set(id, gpx);
|
||||
store.set({
|
||||
file: gpx,
|
||||
statistics
|
||||
statistics,
|
||||
});
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(id))) {
|
||||
@@ -198,7 +267,7 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
|
||||
destroy: () => {
|
||||
fileState.delete(id);
|
||||
query.unsubscribe();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,22 +279,30 @@ function updateSelection(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
||||
if (file) {
|
||||
items.forEach((item) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
let newTrackIndex = file.trk.findIndex((track) => track._data.trackIndex === item.getTrackIndex());
|
||||
let newTrackIndex = file.trk.findIndex(
|
||||
(track) => track._data.trackIndex === item.getTrackIndex()
|
||||
);
|
||||
if (newTrackIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
let newTrackIndex = file.trk.findIndex((track) => track._data.trackIndex === item.getTrackIndex());
|
||||
let newTrackIndex = file.trk.findIndex(
|
||||
(track) => track._data.trackIndex === item.getTrackIndex()
|
||||
);
|
||||
if (newTrackIndex === -1) {
|
||||
removedItems.push(item);
|
||||
} else {
|
||||
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex((segment) => segment._data.segmentIndex === item.getSegmentIndex());
|
||||
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
|
||||
(segment) => segment._data.segmentIndex === item.getSegmentIndex()
|
||||
);
|
||||
if (newSegmentIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
let newWaypointIndex = file.wpt.findIndex((wpt) => wpt._data.index === item.getWaypointIndex());
|
||||
let newWaypointIndex = file.wpt.findIndex(
|
||||
(wpt) => wpt._data.index === item.getWaypointIndex()
|
||||
);
|
||||
if (newWaypointIndex === -1) {
|
||||
removedItems.push(item);
|
||||
}
|
||||
@@ -255,9 +332,10 @@ function updateSelection(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
||||
// Commit the changes to the file state to the database
|
||||
function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
|
||||
let changedFileIds = getChangedFileIds(patch);
|
||||
let updatedFileIds: string[] = [], deletedFileIds: string[] = [];
|
||||
let updatedFileIds: string[] = [],
|
||||
deletedFileIds: string[] = [];
|
||||
|
||||
changedFileIds.forEach(id => {
|
||||
changedFileIds.forEach((id) => {
|
||||
if (newFileState.has(id)) {
|
||||
updatedFileIds.push(id);
|
||||
} else {
|
||||
@@ -265,8 +343,10 @@ function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch
|
||||
}
|
||||
});
|
||||
|
||||
let updatedFiles = updatedFileIds.map(id => newFileState.get(id)).filter(file => file !== undefined) as GPXFile[];
|
||||
updatedFileIds = updatedFiles.map(file => file._data.id);
|
||||
let updatedFiles = updatedFileIds
|
||||
.map((id) => newFileState.get(id))
|
||||
.filter((file) => file !== undefined) as GPXFile[];
|
||||
updatedFileIds = updatedFiles.map((file) => file._data.id);
|
||||
|
||||
updateSelection(updatedFiles, deletedFileIds);
|
||||
|
||||
@@ -282,13 +362,15 @@ function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch
|
||||
});
|
||||
}
|
||||
|
||||
export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy?: () => void }>> = writable(new Map());
|
||||
export const fileObservers: Writable<
|
||||
Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy?: () => void }>
|
||||
> = writable(new Map());
|
||||
const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
|
||||
|
||||
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files
|
||||
export function observeFilesFromDatabase(fitBounds: boolean) {
|
||||
let initialize = true;
|
||||
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
|
||||
liveQuery(() => db.fileids.toArray()).subscribe((dbFileIds) => {
|
||||
if (initialize) {
|
||||
if (fitBounds && dbFileIds.length > 0) {
|
||||
initTargetMapBounds(dbFileIds);
|
||||
@@ -296,17 +378,21 @@ export function observeFilesFromDatabase(fitBounds: boolean) {
|
||||
initialize = false;
|
||||
}
|
||||
// Find new files to observe
|
||||
let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id)).sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
|
||||
let newFiles = dbFileIds
|
||||
.filter((id) => !get(fileObservers).has(id))
|
||||
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
|
||||
// Find deleted files to stop observing
|
||||
let deletedFiles = Array.from(get(fileObservers).keys()).filter(id => !dbFileIds.find(fileId => fileId === id));
|
||||
let deletedFiles = Array.from(get(fileObservers).keys()).filter(
|
||||
(id) => !dbFileIds.find((fileId) => fileId === id)
|
||||
);
|
||||
|
||||
// Update the store
|
||||
if (newFiles.length > 0 || deletedFiles.length > 0) {
|
||||
fileObservers.update($files => {
|
||||
newFiles.forEach(id => {
|
||||
fileObservers.update(($files) => {
|
||||
newFiles.forEach((id) => {
|
||||
$files.set(id, dexieGPXFileStore(id));
|
||||
});
|
||||
deletedFiles.forEach(id => {
|
||||
deletedFiles.forEach((id) => {
|
||||
$files.get(id)?.destroy?.();
|
||||
$files.delete(id);
|
||||
});
|
||||
@@ -341,15 +427,28 @@ export function getStatistics(fileId: string): GPXStatisticsTree | undefined {
|
||||
}
|
||||
|
||||
const patchIndex: Readable<number> = dexieStore(() => db.settings.get('patchIndex'), -1);
|
||||
const patchMinMaxIndex: Readable<{ min: number, max: number }> = dexieStore(() => db.patches.orderBy(':id').keys().then(keys => {
|
||||
const patchMinMaxIndex: Readable<{ min: number; max: number }> = dexieStore(
|
||||
() =>
|
||||
db.patches
|
||||
.orderBy(':id')
|
||||
.keys()
|
||||
.then((keys) => {
|
||||
if (keys.length === 0) {
|
||||
return { min: 0, max: 0 };
|
||||
} else {
|
||||
return { min: keys[0], max: keys[keys.length - 1] + 1 };
|
||||
}
|
||||
}), { min: 0, max: 0 });
|
||||
export const canUndo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex >= $patchMinMaxIndex.min);
|
||||
export const canRedo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex < $patchMinMaxIndex.max - 1);
|
||||
}),
|
||||
{ min: 0, max: 0 }
|
||||
);
|
||||
export const canUndo: Readable<boolean> = derived(
|
||||
[patchIndex, patchMinMaxIndex],
|
||||
([$patchIndex, $patchMinMaxIndex]) => $patchIndex >= $patchMinMaxIndex.min
|
||||
);
|
||||
export const canRedo: Readable<boolean> = derived(
|
||||
[patchIndex, patchMinMaxIndex],
|
||||
([$patchIndex, $patchMinMaxIndex]) => $patchIndex < $patchMinMaxIndex.max - 1
|
||||
);
|
||||
|
||||
// Helper function to apply a callback to the global file state
|
||||
function applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
|
||||
@@ -377,7 +476,12 @@ function applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>
|
||||
}
|
||||
|
||||
// Helper function to apply different callbacks to multiple files
|
||||
function applyEachToFilesAndGlobal(fileIds: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) {
|
||||
function applyEachToFilesAndGlobal(
|
||||
fileIds: string[],
|
||||
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
|
||||
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
|
||||
context?: any
|
||||
) {
|
||||
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, (draft) => {
|
||||
fileIds.forEach((fileId, index) => {
|
||||
let file = draft.get(fileId);
|
||||
@@ -400,16 +504,22 @@ async function storePatches(patch: Patch[], inversePatch: Patch[]) {
|
||||
db.patches.where(':id').above(get(patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
|
||||
let minmax = get(patchMinMaxIndex);
|
||||
if (minmax.max - minmax.min + 1 > MAX_PATCHES) {
|
||||
db.patches.where(':id').belowOrEqual(get(patchMinMaxIndex).max - MAX_PATCHES).delete();
|
||||
db.patches
|
||||
.where(':id')
|
||||
.belowOrEqual(get(patchMinMaxIndex).max - MAX_PATCHES)
|
||||
.delete();
|
||||
}
|
||||
}
|
||||
db.transaction('rw', db.patches, db.settings, async () => {
|
||||
let index = get(patchIndex) + 1;
|
||||
await db.patches.put({
|
||||
await db.patches.put(
|
||||
{
|
||||
patch,
|
||||
inversePatch,
|
||||
index,
|
||||
},
|
||||
index
|
||||
}, index);
|
||||
);
|
||||
await db.settings.put(index, 'patchIndex');
|
||||
});
|
||||
}
|
||||
@@ -467,7 +577,12 @@ export const dbUtils = {
|
||||
applyToFiles: (ids: string[], callback: (file: WritableDraft<GPXFile>) => void) => {
|
||||
applyToFiles(ids, callback);
|
||||
},
|
||||
applyEachToFilesAndGlobal: (ids: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) => {
|
||||
applyEachToFilesAndGlobal: (
|
||||
ids: string[],
|
||||
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
|
||||
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
|
||||
context?: any
|
||||
) => {
|
||||
applyEachToFilesAndGlobal(ids, callbacks, globalCallback, context);
|
||||
},
|
||||
duplicateSelection: () => {
|
||||
@@ -491,20 +606,33 @@ export const dbUtils = {
|
||||
if (level === ListLevel.TRACK) {
|
||||
for (let item of items) {
|
||||
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||
file.replaceTracks(trackIndex + 1, trackIndex, [file.trk[trackIndex].clone()]);
|
||||
file.replaceTracks(trackIndex + 1, trackIndex, [
|
||||
file.trk[trackIndex].clone(),
|
||||
]);
|
||||
}
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
for (let item of items) {
|
||||
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||
file.replaceTrackSegments(trackIndex, segmentIndex + 1, segmentIndex, [file.trk[trackIndex].trkseg[segmentIndex].clone()]);
|
||||
file.replaceTrackSegments(
|
||||
trackIndex,
|
||||
segmentIndex + 1,
|
||||
segmentIndex,
|
||||
[file.trk[trackIndex].trkseg[segmentIndex].clone()]
|
||||
);
|
||||
}
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, file.wpt.map((wpt) => wpt.clone()));
|
||||
file.replaceWaypoints(
|
||||
file.wpt.length,
|
||||
file.wpt.length - 1,
|
||||
file.wpt.map((wpt) => wpt.clone())
|
||||
);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
for (let item of items) {
|
||||
let waypointIndex = (item as ListWaypointItem).getWaypointIndex();
|
||||
file.replaceWaypoints(waypointIndex + 1, waypointIndex, [file.wpt[waypointIndex].clone()]);
|
||||
file.replaceWaypoints(waypointIndex + 1, waypointIndex, [
|
||||
file.wpt[waypointIndex].clone(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,16 +641,23 @@ export const dbUtils = {
|
||||
});
|
||||
},
|
||||
addNewTrack: (fileId: string) => {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceTracks(file.trk.length, file.trk.length, [new Track()]));
|
||||
dbUtils.applyToFile(fileId, (file) =>
|
||||
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
|
||||
);
|
||||
},
|
||||
addNewSegment: (fileId: string, trackIndex: number) => {
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
let track = file.trk[trackIndex];
|
||||
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [new TrackSegment()]);
|
||||
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [
|
||||
new TrackSegment(),
|
||||
]);
|
||||
});
|
||||
},
|
||||
reverseSelection: () => {
|
||||
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || get(gpxStatistics).local.points?.length <= 1) {
|
||||
if (
|
||||
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
|
||||
get(gpxStatistics).local.points?.length <= 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyGlobal((draft) => {
|
||||
@@ -579,13 +714,13 @@ export const dbUtils = {
|
||||
let target: ListItem = new ListRootItem();
|
||||
let targetFile: GPXFile | undefined = undefined;
|
||||
let toMerge: {
|
||||
trk: Track[],
|
||||
trkseg: TrackSegment[],
|
||||
wpt: Waypoint[]
|
||||
trk: Track[];
|
||||
trkseg: TrackSegment[];
|
||||
wpt: Waypoint[];
|
||||
} = {
|
||||
trk: [],
|
||||
trkseg: [],
|
||||
wpt: []
|
||||
wpt: [],
|
||||
};
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = draft.get(fileId);
|
||||
@@ -593,7 +728,11 @@ export const dbUtils = {
|
||||
if (file && originalFile) {
|
||||
if (level === ListLevel.FILE) {
|
||||
toMerge.trk.push(...originalFile.trk.map((track) => track.clone()));
|
||||
toMerge.wpt.push(...originalFile.wpt.map((wpt) => wpt.clone()));
|
||||
for (const wpt of originalFile.wpt) {
|
||||
if (!toMerge.wpt.some((w) => w.equals(wpt))) {
|
||||
toMerge.wpt.push(wpt.clone());
|
||||
}
|
||||
}
|
||||
if (first) {
|
||||
target = items[0];
|
||||
targetFile = file;
|
||||
@@ -604,8 +743,15 @@ export const dbUtils = {
|
||||
if (level === ListLevel.TRACK) {
|
||||
items.forEach((item, index) => {
|
||||
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||
toMerge.trkseg.splice(0, 0, ...originalFile.trk[trackIndex].trkseg.map((segment) => segment.clone()));
|
||||
if (index === items.length - 1) { // Order is reversed, so the last track is the first one and the one to keep
|
||||
toMerge.trkseg.splice(
|
||||
0,
|
||||
0,
|
||||
...originalFile.trk[trackIndex].trkseg.map((segment) =>
|
||||
segment.clone()
|
||||
)
|
||||
);
|
||||
if (index === items.length - 1) {
|
||||
// Order is reversed, so the last track is the first one and the one to keep
|
||||
target = item;
|
||||
file.trk[trackIndex].trkseg = [];
|
||||
} else {
|
||||
@@ -616,10 +762,15 @@ export const dbUtils = {
|
||||
items.forEach((item, index) => {
|
||||
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||
if (index === items.length - 1) { // Order is reversed, so the last segment is the first one and the one to keep
|
||||
if (index === items.length - 1) {
|
||||
// Order is reversed, so the last segment is the first one and the one to keep
|
||||
target = item;
|
||||
}
|
||||
toMerge.trkseg.splice(0, 0, originalFile.trk[trackIndex].trkseg[segmentIndex].clone());
|
||||
toMerge.trkseg.splice(
|
||||
0,
|
||||
0,
|
||||
originalFile.trk[trackIndex].trkseg[segmentIndex].clone()
|
||||
);
|
||||
file.trk[trackIndex].trkseg.splice(segmentIndex, 1);
|
||||
});
|
||||
}
|
||||
@@ -631,15 +782,24 @@ export const dbUtils = {
|
||||
|
||||
if (mergeTraces) {
|
||||
let statistics = get(gpxStatistics);
|
||||
let speed = statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
|
||||
let speed =
|
||||
statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
|
||||
let startTime: Date | undefined = undefined;
|
||||
if (speed !== undefined) {
|
||||
if (statistics.local.points.length > 0 && statistics.local.points[0].time !== undefined) {
|
||||
if (
|
||||
statistics.local.points.length > 0 &&
|
||||
statistics.local.points[0].time !== undefined
|
||||
) {
|
||||
startTime = statistics.local.points[0].time;
|
||||
} else {
|
||||
let index = statistics.local.points.findIndex((point) => point.time !== undefined);
|
||||
let index = statistics.local.points.findIndex(
|
||||
(point) => point.time !== undefined
|
||||
);
|
||||
if (index !== -1) {
|
||||
startTime = new Date(statistics.local.points[index].time.getTime() - 1000 * 3600 * statistics.local.distance.total[index] / speed);
|
||||
startTime = new Date(
|
||||
statistics.local.points[index].time.getTime() -
|
||||
(1000 * 3600 * statistics.local.distance.total[index]) / speed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,7 +808,14 @@ export const dbUtils = {
|
||||
let s = new TrackSegment();
|
||||
toMerge.trk.map((track) => {
|
||||
track.trkseg.forEach((segment) => {
|
||||
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
|
||||
s.replaceTrackPoints(
|
||||
s.trkpt.length,
|
||||
s.trkpt.length,
|
||||
segment.trkpt.slice(),
|
||||
speed,
|
||||
startTime,
|
||||
removeGaps
|
||||
);
|
||||
});
|
||||
});
|
||||
toMerge.trk = [toMerge.trk[0]];
|
||||
@@ -657,7 +824,14 @@ export const dbUtils = {
|
||||
if (toMerge.trkseg.length > 0) {
|
||||
let s = new TrackSegment();
|
||||
toMerge.trkseg.forEach((segment) => {
|
||||
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
|
||||
s.replaceTrackPoints(
|
||||
s.trkpt.length,
|
||||
s.trkpt.length,
|
||||
segment.trkpt.slice(),
|
||||
speed,
|
||||
startTime,
|
||||
removeGaps
|
||||
);
|
||||
});
|
||||
toMerge.trkseg = [s];
|
||||
}
|
||||
@@ -673,7 +847,12 @@ export const dbUtils = {
|
||||
} else if (target instanceof ListTrackSegmentItem) {
|
||||
let trackIndex = target.getTrackIndex();
|
||||
let segmentIndex = target.getSegmentIndex();
|
||||
targetFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex - 1, toMerge.trkseg);
|
||||
targetFile.replaceTrackSegments(
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
segmentIndex - 1,
|
||||
toMerge.trkseg
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -696,11 +875,15 @@ export const dbUtils = {
|
||||
start -= length;
|
||||
end -= length;
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
let trackIndices = items.map((item) =>
|
||||
(item as ListTrackItem).getTrackIndex()
|
||||
);
|
||||
file.crop(start, end, trackIndices);
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
let segmentIndices = items.map((item) =>
|
||||
(item as ListTrackSegmentItem).getSegmentIndex()
|
||||
);
|
||||
file.crop(start, end, trackIndices, segmentIndices);
|
||||
}
|
||||
}
|
||||
@@ -720,14 +903,17 @@ export const dbUtils = {
|
||||
return {
|
||||
wptIndex: wptIndex,
|
||||
index: [0],
|
||||
distance: Number.MAX_VALUE
|
||||
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());
|
||||
let dist = distance(
|
||||
point.getCoordinates(),
|
||||
wpt.getCoordinates()
|
||||
);
|
||||
if (dist < closest[wptIndex].distance) {
|
||||
closest[wptIndex].distance = dist;
|
||||
closest[wptIndex].index = [index];
|
||||
@@ -735,7 +921,7 @@ export const dbUtils = {
|
||||
closest[wptIndex].index.push(index);
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -750,9 +936,16 @@ export const dbUtils = {
|
||||
return t;
|
||||
});
|
||||
newFile.replaceTracks(0, file.trk.length - 1, tracks);
|
||||
newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]));
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name = track.name ?? `${file.metadata.name} (${index + 1})`;
|
||||
newFile.metadata.name =
|
||||
track.name ?? `${file.metadata.name} (${index + 1})`;
|
||||
draft.set(newFile._data.id, freeze(newFile));
|
||||
});
|
||||
} else if (file.trk.length === 1) {
|
||||
@@ -762,13 +955,16 @@ export const dbUtils = {
|
||||
return {
|
||||
wptIndex: wptIndex,
|
||||
index: [0],
|
||||
distance: Number.MAX_VALUE
|
||||
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());
|
||||
let dist = distance(
|
||||
point.getCoordinates(),
|
||||
wpt.getCoordinates()
|
||||
);
|
||||
if (dist < closest[wptIndex].distance) {
|
||||
closest[wptIndex].distance = dist;
|
||||
closest[wptIndex].index = [index];
|
||||
@@ -781,8 +977,16 @@ export const dbUtils = {
|
||||
|
||||
file.trk[0].trkseg.forEach((segment, index) => {
|
||||
let newFile = file.clone();
|
||||
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [segment]);
|
||||
newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]));
|
||||
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
|
||||
segment,
|
||||
]);
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
|
||||
draft.set(newFile._data.id, freeze(newFile));
|
||||
@@ -811,7 +1015,13 @@ export const dbUtils = {
|
||||
});
|
||||
});
|
||||
},
|
||||
split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates, trkptIndex?: number) {
|
||||
split(
|
||||
fileId: string,
|
||||
trackIndex: number,
|
||||
segmentIndex: number,
|
||||
coordinates: Coordinates,
|
||||
trkptIndex?: number
|
||||
) {
|
||||
let splitType = get(splitAs);
|
||||
return applyGlobal((draft) => {
|
||||
let file = getFile(fileId);
|
||||
@@ -829,7 +1039,10 @@ export const dbUtils = {
|
||||
|
||||
let absoluteIndex = minIndex;
|
||||
file.forEachSegment((seg, trkIndex, segIndex) => {
|
||||
if ((trkIndex < trackIndex && splitType === SplitType.FILES) || (trkIndex === trackIndex && segIndex < segmentIndex)) {
|
||||
if (
|
||||
(trkIndex < trackIndex && splitType === SplitType.FILES) ||
|
||||
(trkIndex === trackIndex && segIndex < segmentIndex)
|
||||
) {
|
||||
absoluteIndex += seg.trkpt.length;
|
||||
}
|
||||
});
|
||||
@@ -859,13 +1072,21 @@ export const dbUtils = {
|
||||
start.crop(0, minIndex);
|
||||
let end = segment.clone();
|
||||
end.crop(minIndex, segment.trkpt.length - 1);
|
||||
newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [start, end]);
|
||||
newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [
|
||||
start,
|
||||
end,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
cleanSelection: (bounds: [Coordinates, Coordinates], inside: boolean, deleteTrackPoints: boolean, deleteWaypoints: boolean) => {
|
||||
cleanSelection: (
|
||||
bounds: [Coordinates, Coordinates],
|
||||
inside: boolean,
|
||||
deleteTrackPoints: boolean,
|
||||
deleteWaypoints: boolean
|
||||
) => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -876,16 +1097,35 @@ export const dbUtils = {
|
||||
if (level === ListLevel.FILE) {
|
||||
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices);
|
||||
let trackIndices = items.map((item) =>
|
||||
(item as ListTrackItem).getTrackIndex()
|
||||
);
|
||||
file.clean(
|
||||
bounds,
|
||||
inside,
|
||||
deleteTrackPoints,
|
||||
deleteWaypoints,
|
||||
trackIndices
|
||||
);
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices, segmentIndices);
|
||||
let segmentIndices = items.map((item) =>
|
||||
(item as ListTrackSegmentItem).getSegmentIndex()
|
||||
);
|
||||
file.clean(
|
||||
bounds,
|
||||
inside,
|
||||
deleteTrackPoints,
|
||||
deleteWaypoints,
|
||||
trackIndices,
|
||||
segmentIndices
|
||||
);
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.clean(bounds, inside, false, deleteWaypoints);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
let waypointIndices = items.map((item) =>
|
||||
(item as ListWaypointItem).getWaypointIndex()
|
||||
);
|
||||
file.clean(bounds, inside, false, deleteWaypoints, [], [], waypointIndices);
|
||||
}
|
||||
}
|
||||
@@ -907,7 +1147,15 @@ export const dbUtils = {
|
||||
let segmentIndex = item.getSegmentIndex();
|
||||
let points = itemsAndPoints.get(item);
|
||||
if (points) {
|
||||
file.replaceTrackPoints(trackIndex, segmentIndex, 0, file.trk[trackIndex].trkseg[segmentIndex].getNumberOfTrackPoints() - 1, points);
|
||||
file.replaceTrackPoints(
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
0,
|
||||
file.trk[trackIndex].trkseg[
|
||||
segmentIndex
|
||||
].getNumberOfTrackPoints() - 1,
|
||||
points
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,7 +1182,9 @@ export const dbUtils = {
|
||||
});
|
||||
} else {
|
||||
let fileIds = new Set<string>();
|
||||
get(selection).getSelected().forEach((item) => {
|
||||
get(selection)
|
||||
.getSelected()
|
||||
.forEach((item) => {
|
||||
fileIds.add(item.getFileId());
|
||||
});
|
||||
let wpt = new Waypoint(waypoint);
|
||||
@@ -980,16 +1230,22 @@ export const dbUtils = {
|
||||
if (level === ListLevel.FILE) {
|
||||
file.setHidden(hidden);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
let trackIndices = items.map((item) =>
|
||||
(item as ListTrackItem).getTrackIndex()
|
||||
);
|
||||
file.setHidden(hidden, trackIndices);
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
let segmentIndices = items.map((item) =>
|
||||
(item as ListTrackSegmentItem).getSegmentIndex()
|
||||
);
|
||||
file.setHidden(hidden, trackIndices, segmentIndices);
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.setHiddenWaypoints(hidden);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
let waypointIndices = items.map((item) =>
|
||||
(item as ListWaypointItem).getWaypointIndex()
|
||||
);
|
||||
file.setHiddenWaypoints(hidden, waypointIndices);
|
||||
}
|
||||
}
|
||||
@@ -1016,7 +1272,12 @@ export const dbUtils = {
|
||||
for (let item of items) {
|
||||
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||
file.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, []);
|
||||
file.replaceTrackSegments(
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
segmentIndex,
|
||||
[]
|
||||
);
|
||||
}
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
||||
@@ -1049,14 +1310,18 @@ export const dbUtils = {
|
||||
});
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndex = (items[0] as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
let segmentIndices = items.map((item) =>
|
||||
(item as ListTrackSegmentItem).getSegmentIndex()
|
||||
);
|
||||
segmentIndices.forEach((segmentIndex) => {
|
||||
points.push(...file.trk[trackIndex].trkseg[segmentIndex].getTrackPoints());
|
||||
});
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
points.push(...file.wpt);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
let waypointIndices = items.map((item) =>
|
||||
(item as ListWaypointItem).getWaypointIndex()
|
||||
);
|
||||
points.push(...waypointIndices.map((waypointIndex) => file.wpt[waypointIndex]));
|
||||
}
|
||||
}
|
||||
@@ -1074,16 +1339,22 @@ export const dbUtils = {
|
||||
if (level === ListLevel.FILE) {
|
||||
file.addElevation(elevations);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
let trackIndices = items.map((item) =>
|
||||
(item as ListTrackItem).getTrackIndex()
|
||||
);
|
||||
file.addElevation(elevations, trackIndices, undefined, []);
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
let segmentIndices = items.map((item) =>
|
||||
(item as ListTrackSegmentItem).getSegmentIndex()
|
||||
);
|
||||
file.addElevation(elevations, trackIndices, segmentIndices, []);
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.addElevation(elevations, [], [], undefined);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
let waypointIndices = items.map((item) =>
|
||||
(item as ListWaypointItem).getWaypointIndex()
|
||||
);
|
||||
file.addElevation(elevations, [], [], waypointIndices);
|
||||
}
|
||||
}
|
||||
@@ -1110,7 +1381,7 @@ export const dbUtils = {
|
||||
undo: () => {
|
||||
if (get(canUndo)) {
|
||||
let index = get(patchIndex);
|
||||
db.patches.get(index).then(patch => {
|
||||
db.patches.get(index).then((patch) => {
|
||||
if (patch) {
|
||||
applyPatch(patch.inversePatch);
|
||||
db.settings.put(index - 1, 'patchIndex');
|
||||
@@ -1121,12 +1392,12 @@ export const dbUtils = {
|
||||
redo: () => {
|
||||
if (get(canRedo)) {
|
||||
let index = get(patchIndex) + 1;
|
||||
db.patches.get(index).then(patch => {
|
||||
db.patches.get(index).then((patch) => {
|
||||
if (patch) {
|
||||
applyPatch(patch.patch);
|
||||
db.settings.put(index, 'patchIndex');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@@ -2,9 +2,18 @@
|
||||
title: Files and statistics
|
||||
---
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { ChartNoAxesColumn } from 'lucide-svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable<'slope' | 'surface' | undefined>(undefined);
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
@@ -37,11 +46,11 @@ You can also navigate through the files using the arrow keys on your keyboard, a
|
||||
|
||||
By right-clicking on a file tab, you can access the same actions as in the [edit menu](./menu/edit).
|
||||
|
||||
### Vertical layout
|
||||
### Tree layout
|
||||
|
||||
As mentioned in the [view options section](./menu/view), you can switch between a horizontal and a vertical layout for the file list.
|
||||
The vertical file list is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](./gpx).
|
||||
Indeed, this layout allows you to inspect the content of the files through collapsible sections.
|
||||
As mentioned in the [view options section](./menu/view), you can switch to a tree layout for the files list.
|
||||
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
||||
In addition, the file tree view enables you to inspect the [tracks, segments, and points of interest](./gpx) contained inside the files through collapsible sections.
|
||||
|
||||
You can also apply [edit actions](./menu/edit) and [tools](./toolbar) to internal file items.
|
||||
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
|
||||
@@ -71,6 +80,25 @@ Click on the profile to reset the selection.
|
||||
|
||||
You can also use the mouse wheel to zoom in and out on the elevation profile, and move left and right by dragging the profile while holding the <kbd>Shift</kbd> key.
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
additionalDatasets={$additionalDatasets}
|
||||
elevationFill={$elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Additional data
|
||||
|
||||
Using the <kbd><ChartNoAxesColumn size="16" class="inline-block" style="margin-bottom: 2px"/></kbd> button at the bottom-right of the elevation profile, you can optionally color the elevation profile by:
|
||||
|
@@ -25,7 +25,7 @@ This is where you can access common actions such as opening, closing, and export
|
||||
|
||||
At the bottom of the interface, you will find the list of files currently open in the application.
|
||||
You can click on a file to select it and display its statistics below the list.
|
||||
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a vertical layout for advanced file management.
|
||||
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a tree layout for advanced file management.
|
||||
|
||||
## Toolbar
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: GPX file format
|
||||
title: Фармат файла GPX
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -8,27 +8,27 @@ title: GPX file format
|
||||
|
||||
# { title }
|
||||
|
||||
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
|
||||
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
|
||||
<a href="https://www.topografix.com/gpx.asp" target="_blank">Фармат файла GPX</a> - гэта адкрыты стандарт для абмену дадзенымі GPS паміж праграмамі і прыладамі GPS.
|
||||
Па сутнасці, ён складаецца з серыі кропак GPS, якія кадзіруюць адну або некалькі слядоў GPS, і, па жаданні, некаторыя кропкі цікавасці.
|
||||
|
||||
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
|
||||
Файлы GPX могуць таксама ўтрымліваць метададзеныя, з якіх палі **імя** і **апісанне** найбольш карысныя для карыстальнікаў.
|
||||
|
||||
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracks, segments, and GPS points
|
||||
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Трэкі, сегменты і кропкі GPS
|
||||
|
||||
As mentioned above, a GPX file can contain multiple GPS traces.
|
||||
These are organized in a hierarchical structure, with tracks at the top level.
|
||||
Як згадвалася вышэй, файл GPX можа ўтрымліваць некалькі слядоў GPS.
|
||||
Яны арганізаваны ў іерархічнай структуры з трэкамі на верхнім узроўні.
|
||||
|
||||
- A **track** is made of a sequence of disconnected segments.
|
||||
Furthermore, it can contain metadata such as a **name**, a **description**, and **appearance properties**.
|
||||
- A **segment** is a sequence of GPS points that form a continuous path.
|
||||
- A **GPS point** is a location with a latitude, a longitude, and optionally a timestamp and an altitude.
|
||||
Some devices also store additional information such as heart rate, cadence, temperature, and power.
|
||||
- **Трэк** складаецца з паслядоўнасці раз'яднаных сегментаў.
|
||||
Акрамя таго, ён можа ўтрымліваць метададзеныя, такія як **імя**, **апісанне** і **знешнія ўласцівасці**.
|
||||
- **Сегмент** - гэта паслядоўнасць GPS кропак, якія ўтвараюць бесперапынны шлях.
|
||||
- **Кропка GPS** - гэта месцазнаходжанне з шыратой, даўгатой і, магчыма, пазнакай часу і вышыні.
|
||||
Некаторыя прылады таксама захоўваюць дадатковую інфармацыю, такую як пульс, кадэнцыя, тэмпература і магутнасць.
|
||||
|
||||
In most cases, GPX files contain a single track with a single segment.
|
||||
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
|
||||
У большасці выпадкаў файлы GPX утрымліваюць адзін трэк з адным сегментам.
|
||||
Аднак іерархія, апісаная вышэй, дазваляе выкарыстоўваць больш складаныя выпадкі, напрыклад, планаваць шматдзённыя паездкі з некалькімі варыянтамі на кожны дзень.
|
||||
|
||||
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
|
||||
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Кропкі цікавасці
|
||||
|
||||
**Points of interest** (technically called _waypoints_) represent locations of interest to show either on a GPS device or on a digital map.
|
||||
**Кропкі цікавасці** (тэхнічна званыя _маршрутнымі кропкамі_) уяўляюць цікавыя месцы, якія можна паказаць альбо на прыладзе GPS, альбо на лічбавай карце.
|
||||
|
||||
In addition to its coordinates, a point of interest can have a **name** and a **description**.
|
||||
У дадатак да каардынатаў кропка цікавасці можа мець **імя** і **апісанне**.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Integration
|
||||
title: Інтэграцыя
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -9,18 +9,18 @@ title: Integration
|
||||
|
||||
# { title }
|
||||
|
||||
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
|
||||
Вы можаце выкарыстоўваць **gpx.studio** для стварэння карт, якія паказваюць вашыя файлы GPX, і ўбудаваць іх у свой сайт.
|
||||
|
||||
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
|
||||
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
|
||||
1. <a href="https://account.mapbox.com/auth/signup" target="_blank">Ключ доступу Mapbox</a> для загрузкі карты і
|
||||
2. Файлы GPX, размешчаныя на вашым серверы або на Google Drive, або даступныя праз публічны URL.
|
||||
|
||||
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
|
||||
Затым вы можаце пагуляць з канфігуратарам ніжэй, каб наладзіць сваю карту і стварыць адпаведны HTML-код.
|
||||
|
||||
<DocsNote type="warning">
|
||||
|
||||
You will need to set up <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> headers on your server to allow <b>gpx.studio</b> to load your GPX files.
|
||||
Вам трэба будзе наладзіць загалоўкі <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> на вашым серверы, каб дазволіць <b>gpx.studio</b> загружаць вашы файлы GPX.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
|
@@ -10,61 +10,61 @@ title: Map controls
|
||||
|
||||
# { title }
|
||||
|
||||
The map controls are located on the right side of the interface.
|
||||
Элементы кіравання картай знаходзяцца ў правай частцы інтэрфэйсу.
|
||||
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.
|
||||
|
||||
### <Diff size="16" class="inline-block" style="margin-bottom: 2px" /> Map navigation
|
||||
### <Diff size="16" class="inline-block" style="margin-bottom: 2px" /> Навігацыя па карце
|
||||
|
||||
The controls at the top allow you to zoom in <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> and out <Minus size="16" class="inline-block" style="margin-bottom: 2px" />, and to change the orientation and tilt of the map <Compass size="16" class="inline-block" style="margin-bottom: 2px" />.
|
||||
Элементы кіравання ўверсе дазваляюць павялічваць <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> і памяншаць <Minus size="16" class="inline-block" style="margin-bottom: 2px" />, а таксама змяняць арыентацыю і нахіл карты <Compass size="16" class="inline-block" style="margin-bottom: 2px" />.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
|
||||
Каб кіраваць арыентацыяй і нахілам карты, вы таксама можаце перацягнуць карту, утрымліваючы <kbd>Ctrl</kbd>.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### <Search size="16" class="inline-block" style="margin-bottom: 2px" /> Search bar
|
||||
### <Search size="16" class="inline-block" style="margin-bottom: 2px" /> Пошукавы радок
|
||||
|
||||
You can use the search bar to look for an address and navigate to it on the map.
|
||||
Вы можаце выкарыстоўваць пошукавы радок, каб знайсці адрас і перайсці да яго на карце.
|
||||
|
||||
### <LocateFixed size="16" class="inline-block" style="margin-bottom: 2px" /> Locate button
|
||||
### <LocateFixed size="16" class="inline-block" style="margin-bottom: 2px" /> Кнопка месцазнаходжання
|
||||
|
||||
The locate button centers the map on your current location.
|
||||
Кнопка цэнтруе карту на вашым бягучым месцазнаходжанні.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This only works if you have allowed your browser and <b>gpx.studio</b> to access your location.
|
||||
Гэта працуе, толькі калі вы дазволілі вашаму браўзеру і <b>gpx.studio</b> доступ да вашага месцазнаходжання.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view
|
||||
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Прагляд вуліц
|
||||
|
||||
This button can be used to enable street view mode on the map.
|
||||
Depending on the street view source chosen in the [settings](./menu/settings), street view imagery can be accessed differently.
|
||||
Гэтую кнопку можна выкарыстоўваць для ўключэння рэжыму прагляду вуліц на карце.
|
||||
У залежнасці ад крыніцы прагляду вуліц, абранай у [наладах](./menu/settings), да прагляду вуліц можна атрымаць доступ па-рознаму.
|
||||
|
||||
- <a href="https://www.mapillary.com/" target="_blank">Mapillary</a>: the street view coverage will appear as green lines on the map. When zoomed in enough, green dots will show the exact locations where street view imagery is available. Hovering over a green dot will show the street view image at that location.
|
||||
- <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>: click on the map to open a new tab with the street view imagery at that location.
|
||||
- <a href="https://www.mapillary.com/" target="_blank">Mapillary</a>: прагляд вуліц будзе адлюстроўвацца на карце ў выглядзе зялёных ліній. Пры дастатковым павелічэнні зялёныя кропкі будуць паказваць дакладныя месцы, дзе даступныя здымкі вуліц. Пры навядзенні курсора на зялёную кропку будзе паказаны здымак вуліцы ў гэтым месцы.
|
||||
- <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>: націсніце на карту, каб адкрыць новую ўкладку са здымкамі вуліц у гэтым месцы.
|
||||
|
||||
### <Layers size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers
|
||||
### <Layers size="16" class="inline-block" style="margin-bottom: 2px" /> Слаі карты
|
||||
|
||||
The map layers button allows you to switch between different basemaps, and toggle map overlays and categories of points of interest.
|
||||
Кнопка слаёў карты дазваляе вам пераключацца паміж рознымі базавымі картамі, а таксама пераключаць слаі карты і катэгорыі кропак цікавасці.
|
||||
|
||||
- **Basemaps** are background maps that present the main geographic features of the world.
|
||||
Depending on their purpose, basemaps have different styles and levels of detail.
|
||||
Only one basemap can be displayed at a time.
|
||||
- **Overlays** are additional layers that can be displayed on top of the basemap to provide complementary information.
|
||||
- **Points of interest** can be added to the map to show different categories of places, such as shops, restaurants, or accommodations.
|
||||
- **Базавыя карты** - гэта фонавыя карты, якія прадстаўляюць асноўныя геаграфічныя аб'екты свету.
|
||||
У залежнасці ад прызначэння базавыя карты маюць розныя стылі і ўзроўні дэталізацыі.
|
||||
Адначасова можа быць адлюстравана толькі адна базавая карта.
|
||||
- **Накладкі** - гэта дадатковыя слаі, якія могуць адлюстроўвацца паверх базавай карты для атрымання дадатковай інфармацыі.
|
||||
- **Кропкі цікавасці** можна дадаць на карту, каб паказаць розныя катэгорыі месцаў, такіх як крамы, рэстараны або жыллё.
|
||||
|
||||
<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.
|
||||
Навядзіце курсор мышы на карту, каб паказаць накладанне <a href="https://hiking.waymarkedtrails.org" target="_blank">Пешаходных Сцежак</a> на базавай карце <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
A large collection of global and local basemaps and overlays is available in **gpx.studio**, as well as a selection of point-of-interest categories.
|
||||
They can be enabled in the [map layer settings dialog](./menu/settings).
|
||||
Вялікая калекцыя глабальных і лакальных базавых карт і накладанняў даступная ў **gpx.studio**, а таксама выбар катэгорый кропак цікавасці.
|
||||
Іх можна ўключыць у [дыялогавым акне налад слаёў карты](./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.
|
||||
Для прасунутых карыстальнікаў можна дадаваць карыстальніцкія базавыя карты і накладкі, дадаўшы URL-адрасы <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> або <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">JSON у стылі Mapbox</a>.
|
||||
|
@@ -10,7 +10,7 @@ title: Edit actions
|
||||
# { title }
|
||||
|
||||
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
|
||||
Moreover, when the vertical 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).
|
||||
Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
|
||||
Therefore, we will refer to the elements that can be modified by these actions as _file items_.
|
||||
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.
|
||||
|
||||
@@ -37,7 +37,7 @@ Create a new track in the selected file.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
This action is only available when the tree layout of the files list is enabled.
|
||||
Additionally, the selection must be a single file.
|
||||
|
||||
</DocsNote>
|
||||
@@ -48,7 +48,7 @@ Create a new segment in the selected track.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
This action is only available when the tree layout of the files list is enabled.
|
||||
Additionally, the selection must be a single track.
|
||||
|
||||
</DocsNote>
|
||||
@@ -67,7 +67,7 @@ Copy the selected file items to the clipboard.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
This action is only available when the tree layout of the files list is enabled.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -77,7 +77,7 @@ Cut the selected file items to the clipboard.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
This action is only available when the tree layout of the files list is enabled.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -87,7 +87,7 @@ Paste the file items from the clipboard to the current hierarchy level if they a
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
This action is only available when the tree layout of the files list is enabled.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
|
@@ -3,7 +3,7 @@ title: View options
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
import { ChartArea, GalleryVertical, Map, Layers2, Coins, Milestone, Box } from 'lucide-svelte';
|
||||
import { ChartArea, ListTree, Map, Layers2, Coins, Milestone, Box } from 'lucide-svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
@@ -15,10 +15,11 @@ This menu provides options to rearrange the interface and the map view.
|
||||
|
||||
Hide the elevation profile to make room for the map, or show it to inspect the current selection.
|
||||
|
||||
### <GalleryVertical size="16" class="inline-block" style="margin-bottom: 2px" /> Vertical file list
|
||||
### <ListTree size="16" class="inline-block" style="margin-bottom: 2px" /> File tree
|
||||
|
||||
Switch between a vertical and a horizontal layout for the file list.
|
||||
The [vertical file list](../files-and-stats) is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
|
||||
Toggle the tree layout for the [file list](../files-and-stats).
|
||||
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
||||
In addition, the file tree view enables you to inspect the [tracks, segments, and points of interest](../gpx) contained inside the files through collapsible sections.
|
||||
|
||||
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
|
||||
|
||||
@@ -43,6 +44,6 @@ Enter or exit the 3D map view.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
|
||||
Каб кіраваць арыентацыяй і нахілам карты, вы таксама можаце перацягнуць карту, утрымліваючы <kbd>Ctrl</kbd>.
|
||||
|
||||
</DocsNote>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: FAQ
|
||||
title: Preguntes freqüents (FAQ)
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -8,28 +8,28 @@ title: FAQ
|
||||
|
||||
# { title }
|
||||
|
||||
### Do I need to donate to use the website?
|
||||
### S'ha de donar per fer servir la web?
|
||||
|
||||
No.
|
||||
The website is free to use and always will be (as long as it is financially sustainable).
|
||||
However, donations are appreciated and help keep the website running.
|
||||
La pàgina web és d'ús gratuït i sempre ho serà (sempre que sigui econòmicament sostenible).
|
||||
Tanmateix, s'aprecien les donacions que ajuden a mantenir la pàgina web.
|
||||
|
||||
### Why is this route chosen over that one? _Or_ how can I add something to the map?
|
||||
### Per què es tria aquesta ruta en lloc de l'altre? _O_ com puc afegir quelcom al mapa?
|
||||
|
||||
**gpx.studio** uses data from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, which is an open and collaborative world map.
|
||||
This means you can contribute to the map by adding or editing data on OpenStreetMap.
|
||||
**gpx.studio** utilitza dades de <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, que és un mapa mundial obert i col·laboratiu.
|
||||
Això vol dir que pots contribuir al mapa afegint o editant dades a l'OpenStreetMap.
|
||||
|
||||
If you have never contributed to OpenStreetMap before, here is how you can suggest changes:
|
||||
Si mai has contribuït a OpenStreetMap abans, així és com pots suggerir canvis:
|
||||
|
||||
1. Go to the location where you want to add or edit data on the <a href="https://www.openstreetmap.org/" target="_blank">map</a>.
|
||||
2. Use the <button>Query features</button> tool on the right to inspect the existing data.
|
||||
3. Right-click on the location and select <button>Add a note here</button>.
|
||||
4. Explain what is incorrect or missing in the note and click <button>Add note</button> to submit it.
|
||||
1. Ves a la ubicació on vols afegir o editar dades en el <a href="https://www.openstreetmap.org/" target="_blank">mapa</a>.
|
||||
2. Utilitza l'eina <button>Consulta</button> de la dreta per inspeccionar les dades existents.
|
||||
3. Clica amb el botó de la dreta sobre la ubicació i selecciona <button>Afegir nota aquí</button>.
|
||||
4. Explica que és incorrecte o falta a la nota i fes clic a <button>Afegir nota</button> per enviar-la.
|
||||
|
||||
Someone more experienced with OpenStreetMap will then review your note and make the necessary changes.
|
||||
Algú amb més experiència amb OpenStreetMap revisarà la teva nota i farà els canvis necessaris.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
|
||||
Pots trobar més informació en com contribuir a OpenStreetMap <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">Aquí</a>.
|
||||
|
||||
</DocsNote>
|
||||
|
@@ -1,82 +1,110 @@
|
||||
---
|
||||
title: Files and statistics
|
||||
title: Fitxers i estadístiques
|
||||
---
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { ChartNoAxesColumn } from 'lucide-svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
let additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable<'slope' | 'surface' | undefined>(undefined);
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
|
||||
## File list
|
||||
## Llista de fitxers
|
||||
|
||||
Once you have [opened](./menu/file) files, they will be shown as tabs in the file list located at the bottom of the map.
|
||||
You can reorder them by dragging and dropping the tabs.
|
||||
And when many files are open, you can scroll through the list of tabs to navigate between them.
|
||||
Un cop heu [obert](./menu/file) els fitxers, es mostraran com a pestanyes a la llista de fitxers de sota el mapa.
|
||||
Podeu reordenar-los arrossegant i soltant les pestanyes.
|
||||
I quan hi ha molts fitxers oberts, podeu desplaçar-vos en la llista de pestanyes i navegar entre ells.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
When using a mouse, you need to hold <kbd>Shift</kbd> to scroll horizontally.
|
||||
Quan utilitzeu un ratolí, haureu de mantenir premut <kbd>Maj</kbd> per desplaçar-vos horitzontalment.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### File selection
|
||||
### Selecció de fitxers
|
||||
|
||||
By clicking on a tab, you can switch between the files to inspect their statistics, and apply [edit actions](./menu/edit) and [tools](./toolbar) to them.
|
||||
By holding the <kbd>Ctrl/Cmd</kbd> key, you can add files to the selection or remove them, and by holding <kbd>Shift</kbd>, you can select a range of files.
|
||||
Most of the [edit actions](./menu/edit) and [tools](./toolbar) can be applied to multiple files at once.
|
||||
Clicant en una pestanya, podeu canviar entre diversos fitxers per veures les seves estadísticques i aplicar-los [edit actions](./menu/edit) i [tools](./toolbar).
|
||||
Mantenint la tecla <kbd>Ctrl/Cmd</kbd>, podeu afegir fitxers a la selecció o eliminar-los, i mantenint la tecla <kbd>Maj</kbd>, podeu seleccionar un conjunt de fitxers.
|
||||
La majoria de les [accions d'edició](./menu/edit) i [eines](./toolbar) es poden aplicar a diversos arxius alhora.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
You can also navigate through the files using the arrow keys on your keyboard, and use <kbd>Shift</kbd> to add files to the selection.
|
||||
També pots navegar a través dels arxius utilitzant les fletxes del teu teclat, i prémer <kbd>Shift</kbd> per afegir arxius a la selecció.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### Edit actions
|
||||
### Accions d'edició
|
||||
|
||||
By right-clicking on a file tab, you can access the same actions as in the [edit menu](./menu/edit).
|
||||
Clicant el botó dret en la pestanya d'un arxiu, pots accedir a les mateixes accions que en el [menú d'edició](./menu/edit).
|
||||
|
||||
### Vertical layout
|
||||
### Disposició de l'arbre
|
||||
|
||||
As mentioned in the [view options section](./menu/view), you can switch between a horizontal and a vertical layout for the file list.
|
||||
The vertical file list is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](./gpx).
|
||||
Indeed, this layout allows you to inspect the content of the files through collapsible sections.
|
||||
Tal i com es menciona en la [secció d'opcions de vista](./menu/view), pots canviar a un esquema d'arbre per a la llista d'arxius.
|
||||
Aquesta disposició és ideal per tal d'administrar un gran nombre d'arxius oberts, ja que els organitza en una llista vertical en la part dreta del mapa.
|
||||
A més, la vista d'arbre d'arxius permet inspeccionar [tracs, segments i punts d'interès](./gpx) continguts dins dels arxius a través de seccions plegables.
|
||||
|
||||
You can also apply [edit actions](./menu/edit) and [tools](./toolbar) to internal file items.
|
||||
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
|
||||
També pots aplicar [accions d'edició](./menu/edit) i [eines](./toolbar) a elements interns de l'arxiu.
|
||||
A més, pots arrossegar i deixar anar els elements interns per reordenar-los, o moure'ls en la jerarquia o fins i tot a un altre arxiu.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
The size of the file list can be adjusted by dragging the separator between the map and the file list.
|
||||
La mida de la llista d'arxius es pot ajustar arrossegant el separador entre el mapa i la llista d'arxius.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
## Elevation profile and statistics
|
||||
## Perfil d'elevació i estadístiques
|
||||
|
||||
At the bottom of the interface, you can find the elevation profile and statistics for the current selection.
|
||||
En la part inferior de la interfície, pots trobar el perfil d'elevació i estadístiques de la selecció actual.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
The size of the elevation profile can be adjusted by dragging the separator between the map and the elevation profile.
|
||||
La mida del perfil d'elevació es pot ajustar arrossegant el separador entre el mapa i el perfil d'elevació.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### Interactive statistics
|
||||
### Estadístiques interactives
|
||||
|
||||
When hovering over the elevation profile, a tooltip will show statistics at the cursor position.
|
||||
Quan es passa sobre el perfil d'elevació, un globus emergent mostrarà estadístiques en la posició del cursor.
|
||||
|
||||
To get the statistics for a specific section of the elevation profile, you can drag a selection rectangle on the profile.
|
||||
Click on the profile to reset the selection.
|
||||
Per obtenir estadístiques d'una secció específica del perfil d'elevació, pots crear un rectangle de selecció en el perfil.
|
||||
Clica en el perfil per restablir la selecció.
|
||||
|
||||
You can also use the mouse wheel to zoom in and out on the elevation profile, and move left and right by dragging the profile while holding the <kbd>Shift</kbd> key.
|
||||
També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'elevació i moure't cap a l'esquerra i dreta arrossegant el perfil, tot prement la tecla <kbd>Shift</kbd>.
|
||||
|
||||
### Additional data
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
additionalDatasets={$additionalDatasets}
|
||||
elevationFill={$elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center -mt-6">
|
||||
<div class="h-10 w-fit">
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={120}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Using the <kbd><ChartNoAxesColumn size="16" class="inline-block" style="margin-bottom: 2px"/></kbd> button at the bottom-right of the elevation profile, you can optionally color the elevation profile by:
|
||||
### Dades addicionals
|
||||
|
||||
- **slope** information computed from the elevation data, or
|
||||
- **surface** or **category** data coming from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>'s <a href="https://wiki.openstreetmap.org/wiki/Key:surface" target="_blank">surface</a> and <a href="https://wiki.openstreetmap.org/wiki/Key:highway" target="_blank">highway</a> tags.
|
||||
This is only available for files created with **gpx.studio**.
|
||||
Utilitzant el botó <kbd><ChartNoAxesColumn size="16" class="inline-block" style="margin-bottom: 2px"/></kbd> de la part inferior dreta del perfil d'elevació, pots pintar el perfil d'elevació en funció de:
|
||||
|
||||
If your selection includes it, you can also visualize: **speed**, **heart rate**, **cadence**, **temperature** and **power** data on the elevation profile.
|
||||
- informació de **pendent** calculada a partir de les dades d'elevació, o
|
||||
- dades de **superfície** o **categoria** provinents de <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>'s <a href="https://wiki.openstreetmap.org/wiki/Key:surface" target="_blank">superfície</a> i <a href="https://wiki.openstreetmap.org/wiki/Key:highway" target="_blank">autopista</a> etiquetes.
|
||||
Només disponible per arxius creats amb **gpx.studio**.
|
||||
|
||||
Si la teva selecció en té, també pots visualitzar: dades de **velocitat**, **ritme cardíac**, **cadència**, **temperatura** i **potència** en el perfil d'elevació.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Getting started
|
||||
title: Primers passos
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
@@ -8,30 +8,30 @@ title: Getting started
|
||||
|
||||
# { title }
|
||||
|
||||
Welcome to the official guide for **gpx.studio**!
|
||||
This guide will walk you through all the components and tools of the interface, helping you become a proficient user of the application.
|
||||
Benvinguts a la guia oficial de **gpx.studio**!
|
||||
Aquesta guia et portarà a través de tots els components i eines de la interfície, ajudant-te a convertir-te en un usuari competent de l'aplicació.
|
||||
|
||||
<DocsImage src="getting-started/interface" alt="The gpx.studio interface." />
|
||||
<DocsImage src="getting-started/interface" alt="La interfície de gpx.studio." />
|
||||
|
||||
As shown in the screenshot above, the interface is divided into four main sections organized around the map.
|
||||
Before we dive into the details of each section, let's have a quick overview of the interface.
|
||||
Com es mostra en la captura de pantalla anterior, l'interfície es divideix en quatre seccions principals organitzades al voltant del mapa.
|
||||
Abans d'entrar en detalls de cada secció, fem una ullada a la interfície.
|
||||
|
||||
## Menu
|
||||
## Menú
|
||||
|
||||
At the top of the interface, you will find the [main menu](./menu).
|
||||
This is where you can access common actions such as opening, closing, and exporting files, undoing and redoing actions, and adjusting the application settings.
|
||||
A la part superior de la interfície, trobaràs el [menú principal](./menu).
|
||||
Des d'aquí pots accedir a accions comunes tals com obrir, tancar i exportar arxius, desfer i refer accions i ajustar les configuracions de l'aplicació.
|
||||
|
||||
## Files and statistics
|
||||
## Fitxers i estadístiques
|
||||
|
||||
At the bottom of the interface, you will find the list of files currently open in the application.
|
||||
You can click on a file to select it and display its statistics below the list.
|
||||
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a vertical layout for advanced file management.
|
||||
En la part inferior de la interfície, trobaràs la llista d'arxius actualment oberts en l'aplicació.
|
||||
Pots clicar en un arxiu per seleccionar-lo i mostrar les seves estadístiques sota la llista.
|
||||
En la [secció dedicada](./files-and-stats), explicarem com seleccionar diversos arxius i canviar a un esquema d'arbre per a la gestió avançada d'arxius.
|
||||
|
||||
## Toolbar
|
||||
## Barra d'eines
|
||||
|
||||
On the left side of the interface, you will find the [toolbar](./toolbar), which contains all the tools you can use to edit your files.
|
||||
A la banda esquerra de la interfície, trobaràs la [barra d'eines](./toolbar), que conté totes les eines que pots utilitzar per tal d'editar els teus arxius.
|
||||
|
||||
## Map controls
|
||||
## Controls del mapa
|
||||
|
||||
Finally, on the right side of the interface, you will find the [map controls](./map-controls).
|
||||
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.
|
||||
Finalment, a la banda dreta de la interfície, trobaràs els [controls del mapa](./map-controls).
|
||||
Aquests controls et permetran navegar pel mapa, apropar-te, allunyar-te i canviar entre diferents estils de mapa.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: GPX file format
|
||||
title: Format d'arxiu GPX
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -8,27 +8,27 @@ title: GPX file format
|
||||
|
||||
# { title }
|
||||
|
||||
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
|
||||
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
|
||||
El <a href="https://www.topografix.com/gpx.asp" target="_blank">format d'arxiu GPX</a> és un estàndard obert per intercanviar dades GPS entre aplicacions i aparells GPS.
|
||||
Bàsicament consisteix en una sèrie de punts GPS que codifiquen una o diverses traces GPS i, opcionalment, alguns punts d'interès.
|
||||
|
||||
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
|
||||
Els arxius GPX poden contenir també metadades, de les quals els camps **nom** i **descripció** són els més útils pels usuaris.
|
||||
|
||||
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracks, segments, and GPS points
|
||||
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracs, segments i punts GPS
|
||||
|
||||
As mentioned above, a GPX file can contain multiple GPS traces.
|
||||
These are organized in a hierarchical structure, with tracks at the top level.
|
||||
Com es menciona a dalt, un arxiu GPX pot contenir diverses traces GPS.
|
||||
Aquests estan organitzats en una estructura jeràrquica, amb els tracs en el nivell superior.
|
||||
|
||||
- A **track** is made of a sequence of disconnected segments.
|
||||
Furthermore, it can contain metadata such as a **name**, a **description**, and **appearance properties**.
|
||||
- A **segment** is a sequence of GPS points that form a continuous path.
|
||||
- A **GPS point** is a location with a latitude, a longitude, and optionally a timestamp and an altitude.
|
||||
Some devices also store additional information such as heart rate, cadence, temperature, and power.
|
||||
- Un **trac** està fet amb una seqüència de segments desconnectats.
|
||||
Tanmateix, pot contenir metadades com ara un **nom**, una **descripció** i **propietats d'aparença**.
|
||||
- Un **segment** és una seqüència de punts GPS que forma un camí continu.
|
||||
- Un **punt GPS** és una ubicació amb una latitud, una longitud i opcionalment una marca temporal i una altitud.
|
||||
Alguns aparells també emmagatzemen informació addicional com ara ritme cardíac, cadència, temperatura i potència.
|
||||
|
||||
In most cases, GPX files contain a single track with a single segment.
|
||||
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
|
||||
En la majoria dels casos, els arxius GPX contenen un sol trac amb un sol segment.
|
||||
Tanmateix, la jerarquia descrita a dalt permet casos d'ús més avançat, com la planificació de viatges de diversos dies amb variants per cada dia.
|
||||
|
||||
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
|
||||
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Punts d'interès
|
||||
|
||||
**Points of interest** (technically called _waypoints_) represent locations of interest to show either on a GPS device or on a digital map.
|
||||
**Punts d'interès** (tècnicament anomenats _waypoints_) representen ubicacions d'interès per mostrar en un dispositiu GPS o en mapa digital.
|
||||
|
||||
In addition to its coordinates, a point of interest can have a **name** and a **description**.
|
||||
A més de les seves coordenades, un punt d'interès pot tenir un **nom** i una **descripció**.
|
||||
|
@@ -5,7 +5,7 @@
|
||||
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
|
||||
|
||||
Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
|
||||
També utilitzen la API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
|
||||
També utilitzen l'API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
|
||||
|
||||
Desafortunadament, això és car.
|
||||
Si gaudeixes aquesta eina i la trobes valuosa, si us plau, considera fer una petita donació per ajudar a mantenir la pàgina web gratuïta i sense anuncis.
|
||||
|
@@ -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 és l'empresa que ofereix alguns dels mapes d'aquest lloc web.
|
||||
Ells també desenvolupen el <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">motor de mapes</a> el qual recolza **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.
|
||||
Som increïblement afortunats i estem agraïts de formar part del seu programa <a href="https://mapbox.com/community" target="_blank">comunitari</a>, que dona suport a organitzacions sense ànim de lucre, institucions educatives i organitzacions d'impacte positiu.
|
||||
Aquesta associació permet a **gpx.studio** beneficiar-se de les eines de Mapbox a preus amb descompte, contribuint en gran mesura a la viabilitat financera del projecte i permetent oferir la millor experiència d'usuari possible.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Integration
|
||||
title: Integració
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -9,18 +9,18 @@ title: Integration
|
||||
|
||||
# { title }
|
||||
|
||||
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
|
||||
Pots utilitzar **gpx.studio** per crear mapes que mostrin els teus arxius GPX i incrustar-los en la teva web.
|
||||
|
||||
All you need is:
|
||||
Tot el que necessites és:
|
||||
|
||||
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
|
||||
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
|
||||
1. Un <a href="https://account.mapbox.com/auth/signup" target="_blank"> token d'accés a Mapbox</a> per carregar el mapa i
|
||||
2. Arxius GPX allotjats en el teu servidor, a Google Drive o accessibles a través d'una URL pública.
|
||||
|
||||
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
|
||||
Aleshores pots jugar amb el configurador de sota per personalitzar el teu mapa i generar el corresponent codi HTML.
|
||||
|
||||
<DocsNote type="warning">
|
||||
|
||||
You will need to set up <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> headers on your server to allow <b>gpx.studio</b> to load your GPX files.
|
||||
Hauràs de configurar les capçaleres <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank"> Cross-Origin Resource Sharing (CORS)</a> en el teu servidor per permetre <b>gpx.studio</b> carregar els teus arxius GPX.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user