mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-03-14 00:32:59 +00:00
Compare commits
1 Commits
117c46341b
...
layer-hove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de1933350 |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build website
|
- name: Build website
|
||||||
env:
|
env:
|
||||||
BASE_PATH: ''
|
BASE_PATH: '/${{ github.event.repository.name }}'
|
||||||
run: |
|
run: |
|
||||||
npm run build --prefix website
|
npm run build --prefix website
|
||||||
|
|
||||||
|
|||||||
16
.prettierrc
16
.prettierrc
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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
7
.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"svelte.svelte-vscode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[svelte]": {
|
|
||||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
46
README.md
46
README.md
@@ -3,11 +3,11 @@
|
|||||||
<img alt="Logo of gpx.studio." src="website/static/logo.svg">
|
<img alt="Logo of gpx.studio." src="website/static/logo.svg">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
|
**gpx.studio** is an online tool for creating and editing GPX files.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This repository contains the source code of the website.
|
This repository contains the source code of the new website, currently available [here](https://gpx.studio/gpx.studio).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -26,9 +26,8 @@ Any help is greatly appreciated!
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
The code is split into two parts:
|
The code is split into two parts:
|
||||||
|
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
|
||||||
|
|
||||||
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
||||||
|
|
||||||
@@ -55,25 +54,24 @@ npm run dev
|
|||||||
|
|
||||||
This project has been made possible thanks to the following open source projects:
|
This project has been made possible thanks to the following open source projects:
|
||||||
|
|
||||||
- Development:
|
- Development:
|
||||||
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
||||||
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
||||||
- Design:
|
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
|
||||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
- Design:
|
||||||
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
|
||||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||||
- Logic:
|
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
- Logic:
|
||||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
||||||
- Mapping:
|
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
- Mapping:
|
||||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||||
- Search:
|
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
1673
gpx/package-lock.json
generated
1673
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,21 +11,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-parser": "^4.5.0",
|
"fast-xml-parser": "^4.4.0",
|
||||||
"immer": "^10.1.1"
|
"immer": "^10.1.1",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/geojson": "^7946.0.14",
|
"@types/geojson": "^7946.0.14",
|
||||||
"@types/node": "^20.16.10",
|
"@types/node": "^20.14.6",
|
||||||
"@typescript-eslint/parser": "^8.22.0",
|
"typescript": "^5.4.5"
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.6.2"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"postinstall": "npm run build",
|
|
||||||
"lint": "prettier --check . && eslint .",
|
|
||||||
"format": "prettier --write ."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1297
gpx/src/gpx.ts
1297
gpx/src/gpx.ts
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ export * from './gpx';
|
|||||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||||
export { parseGPX, buildGPX } from './io';
|
export { parseGPX, buildGPX } from './io';
|
||||||
export * from './simplify';
|
export * from './simplify';
|
||||||
|
|
||||||
|
|||||||
134
gpx/src/io.ts
134
gpx/src/io.ts
@@ -1,68 +1,25 @@
|
|||||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
||||||
import { GPXFileType } from './types';
|
import { GPXFileType } from "./types";
|
||||||
import { GPXFile } from './gpx';
|
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 {
|
export function parseGPX(gpxData: string): GPXFile {
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: '',
|
attributeNamePrefix: "",
|
||||||
attributesGroupName: 'attributes',
|
attributesGroupName: 'attributes',
|
||||||
removeNSPrefix: true,
|
|
||||||
isArray(name: string) {
|
isArray(name: string) {
|
||||||
return (
|
return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
|
||||||
name === 'trk' ||
|
|
||||||
name === 'trkseg' ||
|
|
||||||
name === 'trkpt' ||
|
|
||||||
name === 'wpt' ||
|
|
||||||
name === 'rte' ||
|
|
||||||
name === 'rtept' ||
|
|
||||||
name === 'gpxx:rpt'
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
attributeValueProcessor(attrName, attrValue, jPath) {
|
attributeValueProcessor(attrName, attrValue, jPath) {
|
||||||
if (attrName === 'lat' || attrName === 'lon') {
|
if (attrName === 'lat' || attrName === 'lon') {
|
||||||
return safeParseFloat(attrValue);
|
return parseFloat(attrValue);
|
||||||
}
|
}
|
||||||
return attrValue;
|
return attrValue;
|
||||||
},
|
},
|
||||||
transformTagName(tagName: string) {
|
transformTagName(tagName: string) {
|
||||||
if (attributesWithNamespace[tagName]) {
|
if (tagName === 'power') {
|
||||||
return attributesWithNamespace[tagName];
|
// 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';
|
||||||
}
|
}
|
||||||
return tagName;
|
return tagName;
|
||||||
},
|
},
|
||||||
@@ -70,29 +27,22 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
|
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
|
||||||
if (isLeafNode) {
|
if (isLeafNode) {
|
||||||
if (tagName === 'ele') {
|
if (tagName === 'ele') {
|
||||||
return safeParseFloat(tagValue);
|
return parseFloat(tagValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'time') {
|
if (tagName === 'time') {
|
||||||
return new Date(tagValue);
|
return new Date(tagValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxtpx:atemp' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
|
||||||
tagName === 'gpxtpx:atemp' ||
|
return parseFloat(tagValue);
|
||||||
tagName === 'gpxtpx:hr' ||
|
|
||||||
tagName === 'gpxtpx:cad' ||
|
|
||||||
tagName === 'gpxpx:PowerInWatts' ||
|
|
||||||
tagName === 'gpx_style:opacity' ||
|
|
||||||
tagName === 'gpx_style:width'
|
|
||||||
) {
|
|
||||||
return safeParseFloat(tagValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'gpxpx:PowerExtension') {
|
if (tagName === 'gpxpx:PowerExtension') {
|
||||||
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
// 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
|
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
||||||
return {
|
return {
|
||||||
'gpxpx:PowerInWatts': safeParseFloat(tagValue),
|
'gpxpx:PowerInWatts': parseFloat(tagValue)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +54,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (parsed.metadata === '') {
|
if (parsed.metadata === "") {
|
||||||
parsed.metadata = {};
|
parsed.metadata = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,67 +64,49 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||||||
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||||
const gpx = file.toGPXFileType(exclude);
|
const gpx = file.toGPXFileType(exclude);
|
||||||
|
|
||||||
let lastDate = undefined;
|
|
||||||
const builder = new XMLBuilder({
|
const builder = new XMLBuilder({
|
||||||
format: true,
|
format: true,
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: '',
|
attributeNamePrefix: "",
|
||||||
attributesGroupName: 'attributes',
|
attributesGroupName: 'attributes',
|
||||||
suppressEmptyNode: true,
|
suppressEmptyNode: true,
|
||||||
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => {
|
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
|
||||||
if (tagValue instanceof Date) {
|
if (tagValue instanceof Date) {
|
||||||
if (isNaN(tagValue.getTime())) {
|
|
||||||
return lastDate?.toISOString();
|
|
||||||
}
|
|
||||||
lastDate = tagValue;
|
|
||||||
return tagValue.toISOString();
|
return tagValue.toISOString();
|
||||||
}
|
}
|
||||||
return tagValue.toString();
|
return tagValue.toString();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!gpx.attributes) gpx.attributes = {};
|
gpx.attributes.creator = gpx.attributes.creator ?? 'https://gpx.studio';
|
||||||
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
|
|
||||||
gpx.attributes['version'] = '1.1';
|
gpx.attributes['version'] = '1.1';
|
||||||
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/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['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||||
gpx.attributes['xsi:schemaLocation'] =
|
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';
|
||||||
'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:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
||||||
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
||||||
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
||||||
gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2';
|
gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2';
|
||||||
|
gpx.metadata.author = {
|
||||||
|
name: 'gpx.studio',
|
||||||
|
link: {
|
||||||
|
attributes: {
|
||||||
|
href: 'https://gpx.studio',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) {
|
if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) {
|
||||||
gpx.trk[0].name = gpx.metadata.name;
|
gpx.trk[0].name = gpx.metadata.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build({
|
return builder.build({
|
||||||
'?xml': {
|
"?xml": {
|
||||||
attributes: {
|
attributes: {
|
||||||
version: '1.0',
|
version: "1.0",
|
||||||
encoding: 'UTF-8',
|
encoding: "UTF-8",
|
||||||
},
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
delete obj[key];
|
|
||||||
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
|
||||||
removeEmptyElements(obj[key]);
|
|
||||||
if (Object.keys(obj[key]).length === 0) {
|
|
||||||
delete obj[key];
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
gpx
|
||||||
return obj;
|
});
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,33 @@
|
|||||||
import { TrackPoint } from './gpx';
|
import { TrackPoint } from "./gpx";
|
||||||
import { Coordinates } from './types';
|
import { Coordinates } from "./types";
|
||||||
|
|
||||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
|
||||||
|
|
||||||
const earthRadius = 6371008.8;
|
const earthRadius = 6371008.8;
|
||||||
|
|
||||||
export function ramerDouglasPeucker(
|
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
|
||||||
points: TrackPoint[],
|
|
||||||
epsilon: number = 50,
|
|
||||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
|
|
||||||
): SimplifiedTrackPoint[] {
|
|
||||||
if (points.length == 0) {
|
if (points.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
} else if (points.length == 1) {
|
} else if (points.length == 1) {
|
||||||
return [
|
return [{
|
||||||
{
|
point: points[0]
|
||||||
point: points[0],
|
}];
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let simplified = [
|
let simplified = [{
|
||||||
{
|
point: points[0]
|
||||||
point: points[0],
|
}];
|
||||||
},
|
|
||||||
];
|
|
||||||
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
||||||
simplified.push({
|
simplified.push({
|
||||||
point: points[points.length - 1],
|
point: points[points.length - 1]
|
||||||
});
|
});
|
||||||
return simplified;
|
return simplified;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ramerDouglasPeuckerRecursive(
|
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
|
||||||
points: TrackPoint[],
|
|
||||||
epsilon: number,
|
|
||||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
|
|
||||||
start: number,
|
|
||||||
end: number,
|
|
||||||
simplified: SimplifiedTrackPoint[]
|
|
||||||
) {
|
|
||||||
let largest = {
|
let largest = {
|
||||||
index: 0,
|
index: 0,
|
||||||
distance: 0,
|
distance: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = start + 1; i < end; i++) {
|
for (let i = start + 1; i < end; i++) {
|
||||||
@@ -60,20 +45,12 @@ function ramerDouglasPeuckerRecursive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function crossarcDistance(
|
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
|
||||||
point1: TrackPoint,
|
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||||
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 {
|
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||||
// Calculates the shortest distance in meters
|
// Calculates the shortest distance in meters
|
||||||
// between an arc (defined by p1 and p2) and a third point, p3.
|
// between an arc (defined by p1 and p2) and a third point, p3.
|
||||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
||||||
|
|
||||||
@@ -97,7 +74,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
// Is relative bearing obtuse?
|
||||||
if (diff > Math.PI / 2) {
|
if (diff > (Math.PI / 2)) {
|
||||||
return dis13;
|
return dis13;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +83,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||||||
|
|
||||||
// Is p4 beyond the arc?
|
// Is p4 beyond the arc?
|
||||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||||
let dis14 =
|
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
|
||||||
if (dis14 > dis12) {
|
if (dis14 > dis12) {
|
||||||
return distance(lat2, lon2, lat3, lon3);
|
return distance(lat2, lon2, lat3, lon3);
|
||||||
} else {
|
} else {
|
||||||
@@ -117,85 +93,12 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||||||
|
|
||||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||||
// Finds the distance between two lat / lon points.
|
// Finds the distance between two lat / lon points.
|
||||||
return (
|
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
|
||||||
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 {
|
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||||
// Finds the bearing from one lat / lon point to another.
|
// Finds the bearing from one lat / lon point to another.
|
||||||
return Math.atan2(
|
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
|
||||||
Math.sin(lonB - lonA) * Math.cos(latB),
|
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
|
||||||
// Calculates the point on the line defined by p1 and p2
|
|
||||||
// that is closest to the third point, p3.
|
|
||||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
|
||||||
|
|
||||||
const rad = Math.PI / 180;
|
|
||||||
const lat1 = coord1.lat * rad;
|
|
||||||
const lat2 = coord2.lat * rad;
|
|
||||||
const lat3 = coord3.lat * rad;
|
|
||||||
|
|
||||||
const lon1 = coord1.lon * rad;
|
|
||||||
const lon2 = coord2.lon * rad;
|
|
||||||
const lon3 = coord3.lon * rad;
|
|
||||||
|
|
||||||
// Prerequisites for the formulas
|
|
||||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
|
||||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
|
||||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
|
||||||
|
|
||||||
let diff = Math.abs(bear13 - bear12);
|
|
||||||
if (diff > Math.PI) {
|
|
||||||
diff = 2 * Math.PI - diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
|
||||||
if (diff > Math.PI / 2) {
|
|
||||||
return coord1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the cross-track distance.
|
|
||||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,8 +58,8 @@ export type TrackType = {
|
|||||||
src?: string;
|
src?: string;
|
||||||
link?: Link;
|
link?: Link;
|
||||||
type?: string;
|
type?: string;
|
||||||
extensions?: TrackExtensions;
|
|
||||||
trkseg: TrackSegmentType[];
|
trkseg: TrackSegmentType[];
|
||||||
|
extensions?: TrackExtensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackExtensions = {
|
export type TrackExtensions = {
|
||||||
@@ -67,9 +67,9 @@ export type TrackExtensions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type LineStyleExtension = {
|
export type LineStyleExtension = {
|
||||||
'gpx_style:color'?: string;
|
color?: string;
|
||||||
'gpx_style:opacity'?: number;
|
opacity?: number;
|
||||||
'gpx_style:width'?: number;
|
weight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackSegmentType = {
|
export type TrackSegmentType = {
|
||||||
@@ -89,15 +89,17 @@ export type TrackPointExtensions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TrackPointExtension = {
|
export type TrackPointExtension = {
|
||||||
'gpxtpx:atemp'?: number;
|
|
||||||
'gpxtpx:hr'?: number;
|
'gpxtpx:hr'?: number;
|
||||||
'gpxtpx:cad'?: number;
|
'gpxtpx:cad'?: number;
|
||||||
'gpxtpx:Extensions'?: Record<string, string>;
|
'gpxtpx:atemp'?: number;
|
||||||
};
|
'gpxtpx:Extensions'?: {
|
||||||
|
surface?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type PowerExtension = {
|
export type PowerExtension = {
|
||||||
'gpxpx:PowerInWatts'?: number;
|
'gpxpx:PowerInWatts'?: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type Author = {
|
export type Author = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -114,12 +116,12 @@ export type RouteType = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
extensions?: TrackExtensions;
|
extensions?: TrackExtensions;
|
||||||
rtept: WaypointType[];
|
rtept: WaypointType[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export type RoutePointExtension = {
|
export type RoutePointExtension = {
|
||||||
'gpxx:rpt'?: GPXXRoutePoint[];
|
'gpxx:rpt'?: GPXXRoutePoint[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export type GPXXRoutePoint = {
|
export type GPXXRoutePoint = {
|
||||||
attributes: Coordinates;
|
attributes: Coordinates;
|
||||||
};
|
}
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xmlns="http://www.topografix.com/GPX/1/1" 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.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
|
||||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
|
||||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
|
||||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
|
||||||
<metadata>
|
|
||||||
<name>with_routes</name>
|
|
||||||
<author>
|
|
||||||
<name>gpx.studio</name>
|
|
||||||
<link href="https://gpx.studio"></link>
|
|
||||||
</author>
|
|
||||||
</metadata>
|
|
||||||
<rte>
|
|
||||||
<name>route 1</name>
|
|
||||||
<type>Cycling</type>
|
|
||||||
<rtept lat="50.790867" lon="4.404968">
|
|
||||||
<ele>109.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.790714" lon="4.405036">
|
|
||||||
<ele>110.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.790336" lon="4.405259">
|
|
||||||
<ele>110.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.790165" lon="4.405331">
|
|
||||||
<ele>110.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.790008" lon="4.405359">
|
|
||||||
<ele>110.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.789818" lon="4.405359">
|
|
||||||
<ele>109.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.789409" lon="4.40534">
|
|
||||||
<ele>107.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.789105" lon="4.405411">
|
|
||||||
<ele>106.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.788799" lon="4.405527">
|
|
||||||
<ele>108.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.788645" lon="4.405606">
|
|
||||||
<ele>109.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.7885" lon="4.405711">
|
|
||||||
<ele>110.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.78822" lon="4.405959">
|
|
||||||
<ele>112.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787956" lon="4.406092">
|
|
||||||
<ele>112.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787814" lon="4.406143">
|
|
||||||
<ele>113.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787674" lon="4.406177">
|
|
||||||
<ele>114.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787451" lon="4.406199">
|
|
||||||
<ele>115.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787297" lon="4.406177">
|
|
||||||
<ele>114.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.78716" lon="4.406098">
|
|
||||||
<ele>114.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.787045" lon="4.405984">
|
|
||||||
<ele>114.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786683" lon="4.405653">
|
|
||||||
<ele>114.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786538" lon="4.405543">
|
|
||||||
<ele>115.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.78635" lon="4.405441">
|
|
||||||
<ele>115.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786275" lon="4.40542">
|
|
||||||
<ele>115.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786182" lon="4.405435">
|
|
||||||
<ele>116.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786121" lon="4.405475">
|
|
||||||
<ele>115.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.786042" lon="4.405558">
|
|
||||||
<ele>115.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.785821" lon="4.405925">
|
|
||||||
<ele>114.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.785672" lon="4.406119">
|
|
||||||
<ele>112.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.785516" lon="4.406256">
|
|
||||||
<ele>110.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.785384" lon="4.406364">
|
|
||||||
<ele>109.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.785126" lon="4.406475">
|
|
||||||
<ele>106.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784697" lon="4.406537">
|
|
||||||
<ele>104.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784591" lon="4.40657">
|
|
||||||
<ele>104.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784507" lon="4.406612">
|
|
||||||
<ele>103.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784435" lon="4.40669">
|
|
||||||
<ele>103.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784209" lon="4.407148">
|
|
||||||
<ele>103.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784162" lon="4.407257">
|
|
||||||
<ele>103.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784077" lon="4.407372">
|
|
||||||
<ele>104.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.784006" lon="4.407435">
|
|
||||||
<ele>105.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783924" lon="4.407471">
|
|
||||||
<ele>106.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783837" lon="4.407486">
|
|
||||||
<ele>107.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783771" lon="4.407472">
|
|
||||||
<ele>108.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783697" lon="4.407428">
|
|
||||||
<ele>109.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783626" lon="4.407363">
|
|
||||||
<ele>110.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783548" lon="4.407274">
|
|
||||||
<ele>110.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783458" lon="4.407134">
|
|
||||||
<ele>110.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.783123" lon="4.406435">
|
|
||||||
<ele>111.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.782982" lon="4.406168">
|
|
||||||
<ele>112.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.782871" lon="4.406044">
|
|
||||||
<ele>113.3</ele>
|
|
||||||
</rtept>
|
|
||||||
</rte>
|
|
||||||
<rte>
|
|
||||||
<name>route 2</name>
|
|
||||||
<type>Cycling</type>
|
|
||||||
<rtept lat="50.782212" lon="4.406377">
|
|
||||||
<ele>115.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.782175" lon="4.406413">
|
|
||||||
<ele>115.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781749" lon="4.407018">
|
|
||||||
<ele>118.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781654" lon="4.407316">
|
|
||||||
<ele>119.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781563" lon="4.407764">
|
|
||||||
<ele>121.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781487" lon="4.407984">
|
|
||||||
<ele>122.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781422" lon="4.408216">
|
|
||||||
<ele>122.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781395" lon="4.408508">
|
|
||||||
<ele>123.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781399" lon="4.409114">
|
|
||||||
<ele>126.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781367" lon="4.409428">
|
|
||||||
<ele>128.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.781286" lon="4.409607">
|
|
||||||
<ele>129.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.78116" lon="4.409789">
|
|
||||||
<ele>130.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.780804" lon="4.409993">
|
|
||||||
<ele>130.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.780389" lon="4.410334">
|
|
||||||
<ele>131.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.780232" lon="4.410563">
|
|
||||||
<ele>132.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.780094" lon="4.410827">
|
|
||||||
<ele>132.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.779723" lon="4.411582">
|
|
||||||
<ele>135.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.779591" lon="4.411791">
|
|
||||||
<ele>135.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.779125" lon="4.412435">
|
|
||||||
<ele>132.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.778676" lon="4.412979">
|
|
||||||
<ele>134.0</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.778194" lon="4.413466">
|
|
||||||
<ele>136.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.777427" lon="4.414302">
|
|
||||||
<ele>137.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.777165" lon="4.414736">
|
|
||||||
<ele>137.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.776927" lon="4.415201">
|
|
||||||
<ele>137.5</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.776778" lon="4.415613">
|
|
||||||
<ele>137.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.776553" lon="4.416425">
|
|
||||||
<ele>134.8</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.776326" lon="4.417304">
|
|
||||||
<ele>132.3</ele>
|
|
||||||
</rtept>
|
|
||||||
<rtept lat="50.776129" lon="4.418383">
|
|
||||||
<ele>129.5</ele>
|
|
||||||
</rtept>
|
|
||||||
</rte>
|
|
||||||
</gpx>
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
<type>Cycling</type>
|
<type>Cycling</type>
|
||||||
<extensions>
|
<extensions>
|
||||||
<gpx_style:line>
|
<gpx_style:line>
|
||||||
<gpx_style:color>2d3ee9</gpx_style:color>
|
<color>#2d3ee9</color>
|
||||||
<gpx_style:opacity>0.5</gpx_style:opacity>
|
<opacity>0.5</opacity>
|
||||||
<gpx_style:width>6</gpx_style:width>
|
<weight>6</weight>
|
||||||
</gpx_style:line>
|
</gpx_style:line>
|
||||||
</extensions>
|
</extensions>
|
||||||
<trkseg>
|
<trkseg>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"target": "ES2015",
|
"target": "ES2015",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"moduleResolution": "node"
|
"moduleResolution": "node",
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": [
|
||||||
}
|
"src"
|
||||||
|
],
|
||||||
|
}
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "gpx.studio",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
/** @type { import("eslint").Linter.Config } */
|
/** @type { import("eslint").Linter.Config } */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:svelte/recommended',
|
'plugin:svelte/recommended',
|
||||||
'prettier',
|
'prettier'
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
extraFileExtensions: ['.svelte'],
|
extraFileExtensions: ['.svelte']
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
es2017: true,
|
es2017: true,
|
||||||
node: true,
|
node: true
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.svelte'],
|
files: ['*.svelte'],
|
||||||
parser: 'svelte-eslint-parser',
|
parser: 'svelte-eslint-parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser'
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,5 +2,3 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
src/lib/components/ui
|
|
||||||
*.mdx
|
|
||||||
8
website/.prettierrc
Normal file
8
website/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
@@ -2,16 +2,13 @@
|
|||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils"
|
||||||
"ui": "$lib/components/ui",
|
|
||||||
"hooks": "$lib/hooks",
|
|
||||||
"lib": "$lib"
|
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
}
|
||||||
}
|
|
||||||
7524
website/package-lock.json
generated
7524
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
"postbuild": "npx tsx src/lib/sitemap.ts",
|
||||||
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
@@ -14,70 +13,61 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^0.544.0",
|
"@sveltejs/adapter-auto": "^3.2.2",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.2",
|
||||||
"@sveltejs/enhanced-img": "^0.6.0",
|
"@sveltejs/enhanced-img": "^0.3.0",
|
||||||
"@sveltejs/kit": "^2.21.2",
|
"@sveltejs/kit": "^2.5.17",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@types/eslint": "^8.56.10",
|
||||||
"@types/eslint": "^9.6.1",
|
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
|
||||||
"@types/mapbox__tilebelt": "^1.0.4",
|
"@types/mapbox-gl": "^3.1.0",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
"@types/node": "^20.14.6",
|
||||||
"@types/node": "^22.15.30",
|
"@types/sanitize-html": "^2.11.0",
|
||||||
"@types/png.js": "^0.2.3",
|
|
||||||
"@types/sanitize-html": "^2.16.0",
|
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
"@typescript-eslint/parser": "^8.33.1",
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
"bits-ui": "^2.12.0",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^3.9.1",
|
"eslint-plugin-svelte": "^2.40.0",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"glob": "^11.0.2",
|
"glob": "^10.4.3",
|
||||||
"lucide-static": "^0.513.0",
|
"mdsvex": "^0.11.2",
|
||||||
"mdsvex": "^0.12.6",
|
"postcss": "^8.4.38",
|
||||||
"mode-watcher": "^1.1.0",
|
"prettier": "^3.3.2",
|
||||||
"paneforge": "^1.0.0-next.5",
|
"prettier-plugin-svelte": "^3.2.4",
|
||||||
"postcss": "^8.4.47",
|
"svelte": "^4.2.18",
|
||||||
"prettier": "^3.5.3",
|
"svelte-check": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"tailwindcss": "^3.4.4",
|
||||||
"svelte": "^5.33.18",
|
"tslib": "^2.6.3",
|
||||||
"svelte-check": "^4.0.0",
|
"tsx": "^4.15.7",
|
||||||
"svelte-sonner": "^1.0.5",
|
"typescript": "^5.4.5",
|
||||||
"tailwind-variants": "^3.1.1",
|
"vite": "^5.3.1"
|
||||||
"tailwindcss": "^4.1.8",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"tw-animate-css": "^1.3.4",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vaul-svelte": "^1.0.0-next.7",
|
|
||||||
"vite": "^6.3.5",
|
|
||||||
"vite-plugin-node-polyfills": "^0.23.0"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/js": "^3.9.0",
|
"@internationalized/date": "^3.5.4",
|
||||||
"@internationalized/date": "^3.8.2",
|
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
||||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
"@mapbox/sphericalmercator": "^1.2.0",
|
||||||
"@mapbox/sphericalmercator": "^2.0.1",
|
|
||||||
"@mapbox/tilebelt": "^2.0.2",
|
|
||||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||||
"chart.js": "^4.4.9",
|
"bits-ui": "^0.21.12",
|
||||||
"chartjs-plugin-zoom": "^2.2.0",
|
"chart.js": "^4.4.3",
|
||||||
|
"chartjs-plugin-zoom": "^2.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.7",
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"jszip": "^3.10.1",
|
"lucide-static": "^0.427.0",
|
||||||
"mapbox-gl": "^3.12.0",
|
"lucide-svelte": "^0.427.0",
|
||||||
|
"mapbox-gl": "^3.4.0",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"png.js": "^0.2.1",
|
"mode-watcher": "^0.3.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.13.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.2",
|
||||||
"tailwind-merge": "^3.3.0"
|
"svelte-i18n": "^4.0.0",
|
||||||
|
"svelte-sonner": "^0.3.24",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"tailwind-variants": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
website/postcss.config.js
Normal file
6
website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
|
||||||
--foreground: hsl(240 10% 3.9%);
|
|
||||||
--muted: hsl(240 4.8% 95.9%);
|
|
||||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
|
||||||
--popover: hsl(0 0% 100%);
|
|
||||||
--popover-foreground: hsl(240 10% 3.9%);
|
|
||||||
--card: hsl(0 0% 100%);
|
|
||||||
--card-foreground: hsl(240 10% 3.9%);
|
|
||||||
--border: hsl(240 5.9% 90%);
|
|
||||||
--input: hsl(240 5.9% 90%);
|
|
||||||
--primary: hsl(240 5.9% 10%);
|
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
|
||||||
--secondary: hsl(240 4.8% 95.9%);
|
|
||||||
--secondary-foreground: hsl(240 5.9% 10%);
|
|
||||||
--accent: hsl(240 4.8% 95.9%);
|
|
||||||
--accent-foreground: hsl(240 5.9% 10%);
|
|
||||||
--destructive: hsl(0 72.2% 50.6%);
|
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
|
||||||
--ring: hsl(240 10% 3.9%);
|
|
||||||
--sidebar: hsl(0 0% 98%);
|
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
|
||||||
--sidebar-primary: hsl(240 5.9% 10%);
|
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
|
||||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
|
||||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
|
||||||
--sidebar-border: hsl(220 13% 91%);
|
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
|
|
||||||
--support: rgb(220 15 130);
|
|
||||||
--link: rgb(0 110 180);
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: hsl(240 10% 3.9%);
|
|
||||||
--foreground: hsl(0 0% 98%);
|
|
||||||
--muted: hsl(240 3.7% 15.9%);
|
|
||||||
--muted-foreground: hsl(240 5% 64.9%);
|
|
||||||
--popover: hsl(240 10% 3.9%);
|
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
|
||||||
--card: hsl(240 10% 3.9%);
|
|
||||||
--card-foreground: hsl(0 0% 98%);
|
|
||||||
--border: hsl(240 3.7% 15.9%);
|
|
||||||
--input: hsl(240 3.7% 15.9%);
|
|
||||||
--primary: hsl(0 0% 98%);
|
|
||||||
--primary-foreground: hsl(240 5.9% 10%);
|
|
||||||
--secondary: hsl(240 3.7% 15.9%);
|
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
|
||||||
--accent: hsl(240 3.7% 15.9%);
|
|
||||||
--accent-foreground: hsl(0 0% 98%);
|
|
||||||
--destructive: hsl(0 62.8% 30.6%);
|
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
|
||||||
--ring: hsl(240 4.9% 83.9%);
|
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
|
||||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
|
||||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
|
||||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
|
||||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
|
|
||||||
--support: rgb(255 110 190);
|
|
||||||
--link: rgb(80 190 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
/* Radius (for rounded-*) */
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
|
|
||||||
/* Colors */
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-radius: var(--radius);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-support: var(--support);
|
|
||||||
--color-link: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
website/src/app.d.ts
vendored
14
website/src/app.d.ts
vendored
@@ -1,13 +1,13 @@
|
|||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
<head>
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<meta charset="utf-8" />
|
||||||
</body>
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
</html>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
82
website/src/app.pcss
Normal file
82
website/src/app.pcss
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--accent: 210 40% 92%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 72.2% 50.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--support: 220 15 130;
|
||||||
|
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 30%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--support: 255 110 190;
|
||||||
|
|
||||||
|
--ring: hsl(212.7,26.8%,83.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { base } from '$app/paths';
|
|
||||||
import { languages } from '$lib/languages';
|
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
|
||||||
|
|
||||||
export async function handle({ event, resolve }) {
|
|
||||||
const language = event.params.language ?? 'en';
|
|
||||||
const strings = await import(`./locales/${language}.json`);
|
|
||||||
|
|
||||||
const path = event.url.pathname;
|
|
||||||
const page = event.route.id?.replace('/[[language]]', '').split('/')[1] ?? 'home';
|
|
||||||
|
|
||||||
let title = strings.metadata[`${page}_title`];
|
|
||||||
const description = strings.metadata[`description`];
|
|
||||||
|
|
||||||
if (page === 'help' && event.params.guide) {
|
|
||||||
const [guide, subguide] = event.params.guide.split('/');
|
|
||||||
const guideModule = subguide
|
|
||||||
? await import(`./lib/docs/${language}/${guide}/${subguide}.mdx`)
|
|
||||||
: await import(`./lib/docs/${language}/${guide}.mdx`);
|
|
||||||
title = `${title} | ${guideModule.metadata.title}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlTag = `<html lang="${language}" translate="no">`;
|
|
||||||
|
|
||||||
let headTag = `<head>
|
|
||||||
<title>gpx.studio — ${title}</title>
|
|
||||||
<meta name="description" content="${description}" />
|
|
||||||
<meta property="og:title" content="gpx.studio — ${title}" />
|
|
||||||
<meta property="og:description" content="${description}" />
|
|
||||||
<meta name="twitter:title" content="gpx.studio — ${title}" />
|
|
||||||
<meta name="twitter:description" content="${description}" />
|
|
||||||
<meta property="og:image" content="https://gpx.studio${base}/og_logo.png" />
|
|
||||||
<meta property="og:url" content="https://gpx.studio/" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:site_name" content="gpx.studio" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:image" content="https://gpx.studio${base}/og_logo.png" />
|
|
||||||
<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="manifest" href="/${language}.manifest.webmanifest" />`;
|
|
||||||
|
|
||||||
if (page !== '404') {
|
|
||||||
for (let lang of Object.keys(languages)) {
|
|
||||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await resolve(event, {
|
|
||||||
transformPageChunk: ({ html }) =>
|
|
||||||
html.replace('<html>', htmlTag).replace('<head>', headTag),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getSurfaceColor(surface: string): string {
|
|
||||||
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
let ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.fillStyle = backgroundColor;
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
|
|
||||||
const halfSize = size / 2;
|
|
||||||
const halfLineWidth = lineWidth / 2;
|
|
||||||
if (sacScaleColor) {
|
|
||||||
ctx.strokeStyle = sacScaleColor;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
|
|
||||||
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
|
|
||||||
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
if (mtbScaleColor) {
|
|
||||||
ctx.strokeStyle = mtbScaleColor;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(halfSize - halfLineWidth, size + halfLineWidth);
|
|
||||||
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
|
|
||||||
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ctx?.createPattern(canvas, 'repeat') || backgroundColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patterns: Record<string, string | CanvasPattern> = {};
|
|
||||||
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('-')}`;
|
|
||||||
if (!patterns[patternId]) {
|
|
||||||
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
|
||||||
}
|
|
||||||
return patterns[patternId];
|
|
||||||
}
|
|
||||||
return backgroundColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSlope = 20;
|
|
||||||
export function getSlopeColor(slope: number): string {
|
|
||||||
if (slope > maxSlope) {
|
|
||||||
slope = maxSlope;
|
|
||||||
} else if (slope < -maxSlope) {
|
|
||||||
slope = -maxSlope;
|
|
||||||
}
|
|
||||||
|
|
||||||
let v = slope / maxSlope;
|
|
||||||
v = 1 / (1 + Math.exp(-6 * v));
|
|
||||||
v = v - 0.5;
|
|
||||||
|
|
||||||
let hue = ((0.5 - v) * 120).toString(10);
|
|
||||||
let lightness = 90 - Math.abs(v) * 70;
|
|
||||||
|
|
||||||
return `hsl(${hue},70%,${lightness}%)`;
|
|
||||||
}
|
|
||||||
@@ -1,864 +0,0 @@
|
|||||||
{
|
|
||||||
"_info": "Taken from https://github.com/mjaschen/gravel-overlay, with prior authorization from the author (https://github.com/gpxstudio/gpx.studio/issues/32#issuecomment-2320219804).",
|
|
||||||
"version": 8,
|
|
||||||
"name": "Gravel Overlay",
|
|
||||||
"metadata": {
|
|
||||||
"mapbox:autocomposite": false,
|
|
||||||
"mapbox:type": "template",
|
|
||||||
"maputnik:renderer": "mbgljs",
|
|
||||||
"openmaptiles:version": "3.x",
|
|
||||||
"openmaptiles:mapbox:owner": "openmaptiles",
|
|
||||||
"openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t"
|
|
||||||
},
|
|
||||||
"sources": {
|
|
||||||
"openmaptiles": {
|
|
||||||
"type": "vector",
|
|
||||||
"url": "https://tiles.bikerouter.de/services/gravel/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
|
|
||||||
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
|
|
||||||
"layers": [
|
|
||||||
{
|
|
||||||
"id": "background",
|
|
||||||
"type": "background",
|
|
||||||
"layout": {
|
|
||||||
"visibility": "none"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"background-color": "rgba(145, 211, 164, 1)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "debug_rail",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"filter": ["all", ["==", "$type", "LineString"], ["in", "class", "rail"]],
|
|
||||||
"layout": {
|
|
||||||
"visibility": "none"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(144, 144, 144, 1)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "debug_road",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
[
|
|
||||||
"in",
|
|
||||||
"class",
|
|
||||||
"motorway",
|
|
||||||
"trunk",
|
|
||||||
"primary",
|
|
||||||
"secondary",
|
|
||||||
"tertiary",
|
|
||||||
"minor",
|
|
||||||
"residential",
|
|
||||||
"track",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"visibility": "none"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(204, 204, 204, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_X_g45-bg",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["match", ["get", "class"], ["track"], true, false],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
["match", ["get", "tracktype"], ["grade5"], true, false],
|
|
||||||
[
|
|
||||||
"all",
|
|
||||||
["match", ["get", "tracktype"], ["grade4"], true, false],
|
|
||||||
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.3],
|
|
||||||
[14, 1.7]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-dasharray": [1],
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.8],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_X_g45",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["match", ["get", "class"], ["track"], true, false],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
["match", ["get", "tracktype"], ["grade5"], true, false],
|
|
||||||
[
|
|
||||||
"all",
|
|
||||||
["match", ["get", "tracktype"], ["grade4"], true, false],
|
|
||||||
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.3],
|
|
||||||
[14, 1.7]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-dasharray": [2, 2],
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.8],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_B_g3",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "track"],
|
|
||||||
["match", ["get", "tracktype"], ["grade3"], true, false],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false],
|
|
||||||
[
|
|
||||||
"match",
|
|
||||||
["get", "surface"],
|
|
||||||
["compacted", "fine_gravel", "gravel"],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.3],
|
|
||||||
[14, 1.7]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
},
|
|
||||||
"line-dasharray": [3, 1.5]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_A_g2-plain-case",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "track"],
|
|
||||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
|
||||||
["!", ["has", "surface"]],
|
|
||||||
["!", ["has", "smoothness"]]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 255, 0.6)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.8],
|
|
||||||
[12, 3],
|
|
||||||
[14, 4]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.5],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_A_g2-plain",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "track"],
|
|
||||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
|
||||||
["!", ["has", "surface"]],
|
|
||||||
["!", ["has", "smoothness"]]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 0.6)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.5],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
},
|
|
||||||
"line-dasharray": [5, 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_A_g2-case",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "track"],
|
|
||||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
[
|
|
||||||
"match",
|
|
||||||
["get", "surface"],
|
|
||||||
["compacted", "fine_gravel", "gravel"],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 255, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.8],
|
|
||||||
[12, 3],
|
|
||||||
[14, 4]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tr_A_g2",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "track"],
|
|
||||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
[
|
|
||||||
"match",
|
|
||||||
["get", "surface"],
|
|
||||||
["compacted", "fine_gravel", "gravel"],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "p_X-bg",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "path"],
|
|
||||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
|
||||||
["!in", "tracktype", "grade5", "grade4"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.1],
|
|
||||||
[14, 1.5]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.8],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "p_X",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "path"],
|
|
||||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
|
||||||
["!in", "tracktype", "grade5", "grade4"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.1],
|
|
||||||
[14, 1.5]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-dasharray": [2, 2],
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 1.8],
|
|
||||||
[15, 3],
|
|
||||||
[16, 4]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "p_B",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["==", "class", "path"],
|
|
||||||
["in", "smoothness", "good", "intermediate", "bad"],
|
|
||||||
[
|
|
||||||
"!in",
|
|
||||||
"surface",
|
|
||||||
"gravel",
|
|
||||||
"fine_gravel",
|
|
||||||
"compacted",
|
|
||||||
"cobblestone",
|
|
||||||
"sett",
|
|
||||||
"unhewn_cobblestone",
|
|
||||||
"paving_stones"
|
|
||||||
],
|
|
||||||
["!in", "bicycle", "no"],
|
|
||||||
["!in", "access", "no"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.1],
|
|
||||||
[14, 1.5]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
},
|
|
||||||
"line-dasharray": [1.5, 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "p_A-case",
|
|
||||||
"type": "line",
|
|
||||||
"metadata": {
|
|
||||||
"maputnik:comment": "Gravel surface with ok-ish smoothness"
|
|
||||||
},
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "path"],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
|
|
||||||
[
|
|
||||||
"all",
|
|
||||||
["match", ["get", "surface"], ["gravel"], true, false],
|
|
||||||
[
|
|
||||||
"match",
|
|
||||||
["get", "smoothness"],
|
|
||||||
["bad", "good", "intermediate"],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
["match", ["get", "bicycle"], ["no"], false, true],
|
|
||||||
["match", ["get", "access"], ["no"], false, true]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 255, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.7],
|
|
||||||
[12, 2.5],
|
|
||||||
[14, 3.2]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "p_A",
|
|
||||||
"type": "line",
|
|
||||||
"metadata": {
|
|
||||||
"maputnik:comment": "Gravel surface with ok-ish smoothness"
|
|
||||||
},
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", ["geometry-type"], "LineString"],
|
|
||||||
["==", ["get", "class"], "path"],
|
|
||||||
[
|
|
||||||
"any",
|
|
||||||
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
|
|
||||||
[
|
|
||||||
"all",
|
|
||||||
["match", ["get", "surface"], ["gravel"], true, false],
|
|
||||||
[
|
|
||||||
"match",
|
|
||||||
["get", "smoothness"],
|
|
||||||
["bad", "good", "intermediate"],
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
["match", ["get", "bicycle"], ["no"], false, true],
|
|
||||||
["match", ["get", "access"], ["no"], false, true]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-offset": {
|
|
||||||
"stops": [
|
|
||||||
[12, 0],
|
|
||||||
[13, 2],
|
|
||||||
[15, 4],
|
|
||||||
[16, 5]
|
|
||||||
],
|
|
||||||
"base": 1.55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_X_cobbles-case",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor", "service", "track", "path", "residential"],
|
|
||||||
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
|
|
||||||
["!in", "service", "driveway"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(0, 0, 0, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_X_cobbles",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor", "service", "track", "path", "residential"],
|
|
||||||
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
|
|
||||||
["!in", "service", "driveway"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "butt",
|
|
||||||
"line-join": "miter",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(245, 255, 0, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-dasharray": [1.5, 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_X-bg",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor"],
|
|
||||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
|
||||||
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.1],
|
|
||||||
[14, 1.5]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_X",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor"],
|
|
||||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
|
||||||
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.4],
|
|
||||||
[12, 1.1],
|
|
||||||
[14, 1.5]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"line-dasharray": [2, 2]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_A_case",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor", "residential", "service"],
|
|
||||||
["in", "surface", "gravel", "compacted", "fine_gravel"],
|
|
||||||
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(255, 255, 255, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.8],
|
|
||||||
[12, 3],
|
|
||||||
[14, 4]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "r_A",
|
|
||||||
"type": "line",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "transportation",
|
|
||||||
"minzoom": 10,
|
|
||||||
"filter": [
|
|
||||||
"all",
|
|
||||||
["==", "$type", "LineString"],
|
|
||||||
["in", "class", "minor", "residential", "service"],
|
|
||||||
["in", "surface", "gravel", "compacted", "fine_gravel"],
|
|
||||||
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"line-cap": "square",
|
|
||||||
"line-join": "bevel",
|
|
||||||
"visibility": "visible"
|
|
||||||
},
|
|
||||||
"paint": {
|
|
||||||
"line-color": "rgba(235, 6, 158, 1)",
|
|
||||||
"line-width": {
|
|
||||||
"base": 1.55,
|
|
||||||
"stops": [
|
|
||||||
[10, 0.5],
|
|
||||||
[12, 1.5],
|
|
||||||
[14, 2]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cemetery",
|
|
||||||
"type": "symbol",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "landuse",
|
|
||||||
"filter": ["all", ["==", "class", "cemetery"]],
|
|
||||||
"layout": {
|
|
||||||
"icon-image": "cemetery_11",
|
|
||||||
"icon-rotation-alignment": "map",
|
|
||||||
"icon-size": 1.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "drinking_water",
|
|
||||||
"type": "symbol",
|
|
||||||
"source": "openmaptiles",
|
|
||||||
"source-layer": "poi",
|
|
||||||
"minzoom": 9,
|
|
||||||
"maxzoom": 20,
|
|
||||||
"filter": [
|
|
||||||
"any",
|
|
||||||
["==", "class", "drinking_water"],
|
|
||||||
["==", "subclass", "drinking_water"]
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"icon-image": "drinking_water_11",
|
|
||||||
"visibility": "visible",
|
|
||||||
"icon-rotation-alignment": "map",
|
|
||||||
"icon-size": 1.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "basic",
|
|
||||||
"owner": "Marcus Jaschen"
|
|
||||||
}
|
|
||||||
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
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.4 MiB |
File diff suppressed because it is too large
Load Diff
31
website/src/lib/assets/surfaces.ts
Normal file
31
website/src/lib/assets/surfaces.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const surfaceColors: { [key: string]: string } = {
|
||||||
|
'missing': '#d1d1d1',
|
||||||
|
'paved': '#8c8c8c',
|
||||||
|
'unpaved': '#6b443a',
|
||||||
|
'asphalt': '#8c8c8c',
|
||||||
|
'concrete': '#8c8c8c',
|
||||||
|
'chipseal': '#8c8c8c',
|
||||||
|
'cobblestone': '#ffd991',
|
||||||
|
'unhewn_cobblestone': '#ffd991',
|
||||||
|
'paving_stones': '#8c8c8c',
|
||||||
|
'stepping_stones': '#c7b2db',
|
||||||
|
'sett': '#ffd991',
|
||||||
|
'metal': '#8c8c8c',
|
||||||
|
'wood': '#6b443a',
|
||||||
|
'compacted': '#ffffa8',
|
||||||
|
'fine_gravel': '#ffffa8',
|
||||||
|
'gravel': '#ffffa8',
|
||||||
|
'pebblestone': '#ffffa8',
|
||||||
|
'rock': '#ffd991',
|
||||||
|
'dirt': '#ffffa8',
|
||||||
|
'ground': '#6b443a',
|
||||||
|
'earth': '#6b443a',
|
||||||
|
'snow': '#bdfffc',
|
||||||
|
'ice': '#bdfffc',
|
||||||
|
'salt': '#b6c0f2',
|
||||||
|
'mud': '#6b443a',
|
||||||
|
'sand': '#ffffc4',
|
||||||
|
'woodchips': '#6b443a',
|
||||||
|
'grass': '#61b55c',
|
||||||
|
'grass_paver': '#61b55c'
|
||||||
|
}
|
||||||
@@ -1,72 +1,10 @@
|
|||||||
import {
|
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";
|
||||||
Landmark,
|
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";
|
||||||
Icon,
|
import type { ComponentType } from "svelte";
|
||||||
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,
|
|
||||||
type IconProps,
|
|
||||||
} 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 { Component } from 'svelte';
|
|
||||||
|
|
||||||
export type Symbol = {
|
export type Symbol = {
|
||||||
value: string;
|
value: string;
|
||||||
icon?: Component<IconProps>;
|
icon?: ComponentType<Icon>;
|
||||||
iconSvg?: string;
|
iconSvg?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,28 +20,16 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
||||||
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
||||||
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
||||||
convenience_store: {
|
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||||
value: 'Convenience Store',
|
|
||||||
icon: ShoppingBasket,
|
|
||||||
iconSvg: ShoppingBasketSvg,
|
|
||||||
},
|
|
||||||
crossing: { value: 'Crossing' },
|
crossing: { value: 'Crossing' },
|
||||||
department_store: {
|
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||||
value: 'Department Store',
|
|
||||||
icon: ShoppingBasket,
|
|
||||||
iconSvg: ShoppingBasketSvg,
|
|
||||||
},
|
|
||||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
||||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||||
ground_transportation: {
|
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
|
||||||
value: 'Ground Transportation',
|
|
||||||
icon: TrainFront,
|
|
||||||
iconSvg: TrainFrontSvg,
|
|
||||||
},
|
|
||||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
||||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||||
@@ -113,7 +39,7 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
||||||
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
||||||
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
||||||
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg },
|
restroom: { value: 'Restroom' },
|
||||||
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
||||||
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
||||||
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
||||||
@@ -129,6 +55,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
|
|||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
} else {
|
} else {
|
||||||
return Object.keys(symbols).find((key) => symbols[key].value === value);
|
return Object.keys(symbols).find(key => symbols[key].value === value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import docsearch from '@docsearch/js';
|
|
||||||
import '@docsearch/css';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
|
|
||||||
let props: {
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let mounted = false;
|
|
||||||
|
|
||||||
function initDocsearch() {
|
|
||||||
docsearch({
|
|
||||||
appId: '21XLD94PE3',
|
|
||||||
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
|
|
||||||
indexName: 'gpx',
|
|
||||||
container: '#docsearch',
|
|
||||||
searchParameters: {
|
|
||||||
facetFilters: ['lang:' + i18n.lang],
|
|
||||||
},
|
|
||||||
placeholder: i18n._('docs.search.search'),
|
|
||||||
disableUserPersonalization: true,
|
|
||||||
translations: {
|
|
||||||
button: {
|
|
||||||
buttonText: i18n._('docs.search.search'),
|
|
||||||
buttonAriaLabel: i18n._('docs.search.search'),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
searchBox: {
|
|
||||||
resetButtonTitle: i18n._('docs.search.clear'),
|
|
||||||
resetButtonAriaLabel: i18n._('docs.search.clear'),
|
|
||||||
cancelButtonText: i18n._('docs.search.cancel'),
|
|
||||||
cancelButtonAriaLabel: i18n._('docs.search.cancel'),
|
|
||||||
searchInputLabel: i18n._('docs.search.search'),
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
selectText: i18n._('docs.search.to_select'),
|
|
||||||
navigateText: i18n._('docs.search.to_navigate'),
|
|
||||||
closeText: i18n._('docs.search.to_close'),
|
|
||||||
},
|
|
||||||
noResultsScreen: {
|
|
||||||
noResultsText: i18n._('docs.search.no_results'),
|
|
||||||
suggestedQueryText: i18n._('docs.search.no_results_suggestion'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mounted && i18n.lang && !i18n.isLoading) {
|
|
||||||
initDocsearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div id="docsearch" class={props.class ?? ''}></div>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
const {
|
|
||||||
variant = 'default',
|
|
||||||
label,
|
|
||||||
side = 'top',
|
|
||||||
disabled = false,
|
|
||||||
class: className = '',
|
|
||||||
children,
|
|
||||||
onclick,
|
|
||||||
}: {
|
|
||||||
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost';
|
|
||||||
label: string;
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
disabled?: boolean;
|
|
||||||
class?: string;
|
|
||||||
children: Snippet;
|
|
||||||
onclick?: (event: MouseEvent) => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Tooltip.Provider>
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<Button {...props} {variant} class={className} {onclick}>
|
|
||||||
{@render children()}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content {side}>
|
|
||||||
<span>{label}</span>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
File diff suppressed because it is too large
Load Diff
181
website/src/lib/components/Export.svelte
Normal file
181
website/src/lib/components/Export.svelte
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
import {
|
||||||
|
currentTool,
|
||||||
|
exportAllFiles,
|
||||||
|
exportSelectedFiles,
|
||||||
|
ExportState,
|
||||||
|
exportState,
|
||||||
|
gpxStatistics
|
||||||
|
} from '$lib/stores';
|
||||||
|
import { fileObservers } from '$lib/db';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Zap,
|
||||||
|
BrickWall,
|
||||||
|
HeartPulse,
|
||||||
|
Orbit,
|
||||||
|
Thermometer,
|
||||||
|
SquareActivity
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { selection } from './file-list/Selection';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { GPXStatistics } from 'gpx';
|
||||||
|
import { ListRootItem } from './file-list/FileList';
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
let exportOptions: Record<string, boolean> = {
|
||||||
|
time: true,
|
||||||
|
surface: true,
|
||||||
|
hr: true,
|
||||||
|
cad: true,
|
||||||
|
atemp: true,
|
||||||
|
power: true
|
||||||
|
};
|
||||||
|
let hide: Record<string, boolean> = {
|
||||||
|
time: false,
|
||||||
|
surface: false,
|
||||||
|
hr: false,
|
||||||
|
cad: false,
|
||||||
|
atemp: false,
|
||||||
|
power: false
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if ($exportState !== ExportState.NONE) {
|
||||||
|
open = true;
|
||||||
|
$currentTool = null;
|
||||||
|
|
||||||
|
let statistics = $gpxStatistics;
|
||||||
|
if ($exportState === ExportState.ALL) {
|
||||||
|
statistics = Array.from($fileObservers.values())
|
||||||
|
.map((file) => get(file)?.statistics)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
if (cur !== undefined) {
|
||||||
|
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new GPXStatistics());
|
||||||
|
}
|
||||||
|
|
||||||
|
hide.time = statistics.global.time.total === 0;
|
||||||
|
hide.hr = statistics.global.hr.count === 0;
|
||||||
|
hide.cad = statistics.global.cad.count === 0;
|
||||||
|
hide.atemp = statistics.global.atemp.count === 0;
|
||||||
|
hide.power = statistics.global.power.count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
bind:open
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
$exportState = ExportState.NONE;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Trigger class="hidden" />
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
|
||||||
|
>
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span class="max-w-96 text-sm">
|
||||||
|
{$_('menu.support_message')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||||
|
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||||
|
{$_('menu.support_button')}
|
||||||
|
<span class="ml-2">🙏</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="grow"
|
||||||
|
on:click={() => {
|
||||||
|
if ($exportState === ExportState.SELECTION) {
|
||||||
|
exportSelectedFiles(exclude);
|
||||||
|
} else if ($exportState === ExportState.ALL) {
|
||||||
|
exportAllFiles(exclude);
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
$exportState = ExportState.NONE;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size="16" class="mr-1" />
|
||||||
|
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
||||||
|
{$_('menu.download_file')}
|
||||||
|
{:else}
|
||||||
|
{$_('menu.download_files')}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-xl flex flex-col items-center gap-2">
|
||||||
|
<div class="w-full flex flex-row items-center gap-3">
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<Label class="shrink-0">
|
||||||
|
{$_('menu.export_options')}
|
||||||
|
</Label>
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||||
|
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||||
|
<Zap size="16" />
|
||||||
|
{$_('quantities.time')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5">
|
||||||
|
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
|
||||||
|
<Label for="export-surface" class="flex flex-row items-center gap-1">
|
||||||
|
<BrickWall size="16" />
|
||||||
|
{$_('quantities.surface')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||||
|
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||||
|
<HeartPulse size="16" />
|
||||||
|
{$_('quantities.heartrate')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||||
|
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||||
|
<Orbit size="16" />
|
||||||
|
{$_('quantities.cadence')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||||
|
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||||
|
<Thermometer size="16" />
|
||||||
|
{$_('quantities.temperature')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||||
|
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||||
|
<SquareActivity size="16" />
|
||||||
|
{$_('quantities.power')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -1,125 +1,116 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { AtSign, BookOpenText, Heart, Home, Map } from '@lucide/svelte';
|
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer class="w-full">
|
<footer class="w-full">
|
||||||
<div class="mx-6 border-t">
|
<div class="mx-6 border-t">
|
||||||
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||||
<div class="grow flex flex-col items-start">
|
<div class="grow flex flex-col items-start">
|
||||||
<Logo class="h-8" width="153" />
|
<Logo class="h-8" />
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
MIT © 2024 gpx.studio
|
MIT © 2024 gpx.studio
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageSelect class="w-40 mt-3" />
|
<LanguageSelect class="w-40 mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
<span class="font-semibold">{$_('homepage.website')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage(i18n.lang, '/')}
|
href={getURLForLanguage($locale, '/')}
|
||||||
>
|
>
|
||||||
<Home size="16" />
|
<Home size="16" class="mr-1" />
|
||||||
{i18n._('homepage.home')}
|
{$_('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage(i18n.lang, '/app')}
|
href={getURLForLanguage($locale, '/app')}
|
||||||
>
|
>
|
||||||
<Map size="16" />
|
<Map size="16" class="mr-1" />
|
||||||
{i18n._('homepage.app')}
|
{$_('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage(i18n.lang, '/help')}
|
href={getURLForLanguage($locale, '/help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="16" />
|
<BookOpenText size="16" class="mr-1" />
|
||||||
{i18n._('menu.help')}
|
{$_('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1" id="contact">
|
<div class="flex flex-col items-start gap-1" id="contact">
|
||||||
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
<span class="font-semibold">{$_('homepage.contact')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://www.reddit.com/r/gpxstudio/"
|
href="https://facebook.com/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
||||||
{i18n._('homepage.reddit')}
|
{$_('homepage.facebook')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://facebook.com/gpx.studio"
|
href="https://x.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
||||||
{i18n._('homepage.facebook')}
|
{$_('homepage.x')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://x.com/gpxstudio"
|
href="mailto:hello@gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="x" class="h-4 fill-muted-foreground" />
|
<AtSign size="16" class="mr-1" />
|
||||||
{i18n._('homepage.x')}
|
{$_('homepage.email')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
variant="link"
|
<div class="flex flex-col items-start gap-1">
|
||||||
class="h-6 px-0 text-muted-foreground"
|
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
||||||
href="mailto:hello@gpx.studio"
|
<Button
|
||||||
target="_blank"
|
variant="link"
|
||||||
>
|
class="h-6 px-0 text-muted-foreground"
|
||||||
<AtSign size="16" />
|
href="https://ko-fi.com/gpxstudio"
|
||||||
{i18n._('homepage.email')}
|
target="_blank"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<Heart size="16" class="mr-1" />
|
||||||
<div class="flex flex-col items-start gap-1">
|
{$_('menu.donate')}
|
||||||
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://crowdin.com/project/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Heart size="16" />
|
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
||||||
{i18n._('menu.donate')}
|
{$_('homepage.crowdin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 text-muted-foreground"
|
||||||
href="https://crowdin.com/project/gpxstudio"
|
href="https://github.com/gpxstudio/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
||||||
{i18n._('homepage.crowdin')}
|
{$_('homepage.github')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
variant="link"
|
</div>
|
||||||
class="h-6 px-0 text-muted-foreground"
|
</div>
|
||||||
href="https://github.com/gpxstudio/gpx.studio"
|
</div>
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Logo company="github" class="h-4 fill-muted-foreground" />
|
|
||||||
{i18n._('homepage.github')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,93 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
|
|
||||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
||||||
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { GPXStatistics } from 'gpx';
|
import type { GPXStatistics } from 'gpx';
|
||||||
import type { Readable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/db';
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
export let gpxStatistics: Writable<GPXStatistics>;
|
||||||
|
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
export let orientation: 'horizontal' | 'vertical';
|
||||||
|
export let panelSize: number;
|
||||||
|
|
||||||
let {
|
const { velocityUnits } = settings;
|
||||||
gpxStatistics,
|
|
||||||
slicedGPXStatistics,
|
|
||||||
orientation,
|
|
||||||
panelSize,
|
|
||||||
}: {
|
|
||||||
gpxStatistics: Readable<GPXStatistics>;
|
|
||||||
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
|
||||||
orientation: 'horizontal' | 'vertical';
|
|
||||||
panelSize: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let statistics = $derived(
|
let statistics: GPXStatistics;
|
||||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
|
||||||
);
|
$: if ($slicedGPXStatistics !== undefined) {
|
||||||
|
statistics = $slicedGPXStatistics[0];
|
||||||
|
} else {
|
||||||
|
statistics = $gpxStatistics;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="h-full {orientation === 'vertical'
|
class="h-full {orientation === 'vertical'
|
||||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
? 'min-w-44 sm:min-w-52 text-sm sm:text-base'
|
||||||
: 'w-full'} border-none shadow-none"
|
: 'w-full'} border-none shadow-none"
|
||||||
>
|
>
|
||||||
<Card.Content
|
<Card.Content
|
||||||
class="h-full flex {orientation === 'vertical'
|
class="h-full flex {orientation === 'vertical'
|
||||||
? 'flex-col justify-center'
|
? 'flex-col justify-center'
|
||||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||||
>
|
>
|
||||||
<Tooltip label={i18n._('quantities.distance')}>
|
<Tooltip>
|
||||||
<span class="flex flex-row items-center">
|
<span slot="data" class="flex flex-row items-center">
|
||||||
<Ruler size="16" class="mr-1" />
|
<Ruler size="18" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
<span slot="tooltip">{$_('quantities.distance')}</span>
|
||||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
</Tooltip>
|
||||||
<span class="flex flex-row items-center">
|
<Tooltip>
|
||||||
<MoveUpRight size="16" class="mr-1" />
|
<span slot="data" class="flex flex-row items-center">
|
||||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
<MoveUpRight size="18" class="mr-1" />
|
||||||
<MoveDownRight size="16" class="mx-1" />
|
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
<MoveDownRight size="18" class="mx-1" />
|
||||||
</span>
|
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||||
</Tooltip>
|
</span>
|
||||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
<span slot="tooltip">{$_('quantities.elevation')}</span>
|
||||||
<Tooltip
|
</Tooltip>
|
||||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||||
label="{$velocityUnits === 'speed'
|
<Tooltip class={orientation === 'horizontal' ? 'hidden xs:block' : ''}>
|
||||||
? i18n._('quantities.speed')
|
<span slot="data" class="flex flex-row items-center">
|
||||||
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
<Zap size="18" class="mr-1" />
|
||||||
'quantities.total'
|
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
|
||||||
)})"
|
<span class="mx-1">/</span>
|
||||||
>
|
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||||
<span class="flex flex-row items-center">
|
</span>
|
||||||
<Zap size="16" class="mr-1" />
|
<span slot="tooltip"
|
||||||
<WithUnits
|
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
||||||
value={statistics.global.speed.moving}
|
'quantities.moving'
|
||||||
type="speed"
|
)} / {$_('quantities.total')})</span
|
||||||
showUnits={false}
|
>
|
||||||
/>
|
</Tooltip>
|
||||||
<span class="mx-1">/</span>
|
{/if}
|
||||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||||
</span>
|
<Tooltip class={orientation === 'horizontal' ? 'hidden md:block' : ''}>
|
||||||
</Tooltip>
|
<span slot="data" class="flex flex-row items-center">
|
||||||
{/if}
|
<Timer size="18" class="mr-1" />
|
||||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||||
<Tooltip
|
<span class="mx-1">/</span>
|
||||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
<WithUnits value={statistics.global.time.total} type="time" />
|
||||||
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
</span>
|
||||||
'quantities.total'
|
<span slot="tooltip"
|
||||||
)})"
|
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span
|
||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
</Tooltip>
|
||||||
<Timer size="16" class="mr-1" />
|
{/if}
|
||||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
</Card.Content>
|
||||||
<span class="mx-1">/</span>
|
|
||||||
<WithUnits value={statistics.global.time.total} type="time" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
70
website/src/lib/components/Head.svelte
Normal file
70
website/src/lib/components/Head.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { languages } from '$lib/languages';
|
||||||
|
import { _, isLoading } from 'svelte-i18n';
|
||||||
|
|
||||||
|
let location: string;
|
||||||
|
let title: string;
|
||||||
|
|
||||||
|
$: if ($page.route.id) {
|
||||||
|
location = $page.route.id;
|
||||||
|
Object.keys($page.params).forEach((param) => {
|
||||||
|
if (param !== 'language') {
|
||||||
|
location = location.replace(`[${param}]`, $page.params[param]);
|
||||||
|
location = location.replace(`[...${param}]`, $page.params[param]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
title = location.replace('/[...language]', '').split('/')[1] ?? 'home';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
{#if $isLoading}
|
||||||
|
<title>gpx.studio — the online GPX file editor</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||||
|
/>
|
||||||
|
<meta property="og:title" content="gpx.studio — the online GPX file editor" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:title" content="gpx.studio — the online GPX file editor" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<title>gpx.studio — {$_(`metadata.${title}_title`)}</title>
|
||||||
|
<meta name="description" content={$_('metadata.description')} />
|
||||||
|
<meta property="og:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
|
||||||
|
<meta property="og:description" content={$_('metadata.description')} />
|
||||||
|
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
|
||||||
|
<meta name="twitter:description" content={$_('metadata.description')} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<meta property="og:image" content="https://gpx.studio/og_logo.png" />
|
||||||
|
<meta property="og:url" content="https://gpx.studio/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="gpx.studio" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:image" content="https://gpx.studio/og_logo.png" />
|
||||||
|
<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{base}{location.replace('/[...language]', '')}"
|
||||||
|
/>
|
||||||
|
{#each Object.keys(languages) as lang}
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
hreflang={lang}
|
||||||
|
href="https://gpx.studio{base}{location.replace('[...language]', lang)}"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</svelte:head>
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleQuestionMark } from '@lucide/svelte';
|
import { CircleHelp } from 'lucide-svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
export let link: string | undefined = undefined;
|
export let link: string | undefined = undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}">
|
||||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||||
>
|
<div>
|
||||||
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
<slot />
|
||||||
<div>
|
{#if link}
|
||||||
<slot />
|
<a
|
||||||
{#if link}
|
href={link}
|
||||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
target="_blank"
|
||||||
{i18n._('menu.more')}
|
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
|
||||||
</a>
|
>
|
||||||
{/if}
|
{$_('menu.more')}
|
||||||
</div>
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import { languages } from '$lib/languages';
|
||||||
import { languages } from '$lib/languages';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { Languages } from 'lucide-svelte';
|
||||||
import { Languages } from '@lucide/svelte';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
|
let selected = {
|
||||||
|
value: '',
|
||||||
|
label: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if ($locale) {
|
||||||
|
selected = {
|
||||||
|
value: $locale,
|
||||||
|
label: languages[$locale]
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root type="single" value={i18n.lang}>
|
<Select.Root bind:selected>
|
||||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={i18n._('menu.language')}>
|
<Select.Trigger class="w-[180px] {$$props.class ?? ''}">
|
||||||
<Languages size="16" />
|
<Languages size="16" />
|
||||||
<span class="ml-2 mr-auto">
|
<Select.Value class="ml-2 mr-auto" />
|
||||||
{languages[i18n.lang]}
|
</Select.Trigger>
|
||||||
</span>
|
<Select.Content>
|
||||||
</Select.Trigger>
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
<Select.Content>
|
<a href={getURLForLanguage(lang)}>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
{#if page.url.pathname.includes('404')}
|
</a>
|
||||||
<a href={getURLForLanguage(lang, '/')}>
|
{/each}
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
</Select.Content>
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
|
|
||||||
<!-- hidden links for svelte crawling -->
|
<!-- hidden links for svelte crawling -->
|
||||||
<div class="hidden">
|
<div class="hidden">
|
||||||
{#if !page.url.pathname.includes('404')}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
<a href={getURLForLanguage(lang)}>
|
||||||
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
{label}
|
||||||
{label}
|
</a>
|
||||||
</a>
|
{/each}
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode, systemPrefersMode } from 'mode-watcher';
|
||||||
|
|
||||||
export let iconOnly = false;
|
export let iconOnly = false;
|
||||||
export let company = 'gpx.studio';
|
export let company = 'gpx.studio';
|
||||||
|
|
||||||
|
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if company === 'gpx.studio'}
|
{#if company === 'gpx.studio'}
|
||||||
<img
|
<img
|
||||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
|
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
||||||
alt="Logo of gpx.studio."
|
alt="Logo of gpx.studio."
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'mapbox'}
|
{:else if company === 'mapbox'}
|
||||||
<img
|
<img
|
||||||
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
||||||
alt="Logo of Mapbox."
|
alt="Logo of Mapbox."
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'github'}
|
{:else if company === 'github'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {$$restProps.class ?? ''}"
|
||||||
><title>GitHub</title><path
|
><title>GitHub</title><path
|
||||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
{:else if company === 'crowdin'}
|
{:else if company === 'crowdin'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {$$restProps.class ?? ''}"
|
||||||
><title>Crowdin</title><path
|
><title>Crowdin</title><path
|
||||||
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
{:else if company === 'facebook'}
|
{:else if company === 'facebook'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {$$restProps.class ?? ''}"
|
||||||
><title>Facebook</title><path
|
><title>Facebook</title><path
|
||||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
{:else if company === 'x'}
|
{:else if company === 'x'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {$$restProps.class ?? ''}"
|
||||||
><title>X</title><path
|
><title>X</title><path
|
||||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
{:else if company === 'reddit'}
|
|
||||||
<svg
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
|
||||||
><title>Reddit</title><path
|
|
||||||
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
331
website/src/lib/components/Map.svelte
Normal file
331
website/src/lib/components/Map.svelte
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
|
||||||
|
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||||
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||||
|
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||||
|
export let geolocate = true;
|
||||||
|
export let geocoder = true;
|
||||||
|
export let hash = true;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = accessToken;
|
||||||
|
|
||||||
|
let webgl2Supported = true;
|
||||||
|
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||||
|
maxZoom: 15,
|
||||||
|
linear: true,
|
||||||
|
easing: () => 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||||
|
settings;
|
||||||
|
let scaleControl = new mapboxgl.ScaleControl({
|
||||||
|
unit: $distanceUnits
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let gl = document.createElement('canvas').getContext('webgl2');
|
||||||
|
if (!gl) {
|
||||||
|
webgl2Supported = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let language = $page.params.language;
|
||||||
|
if (language === 'zh') {
|
||||||
|
language = 'zh-Hans';
|
||||||
|
} else if (language?.includes('-')) {
|
||||||
|
language = language.split('-')[0];
|
||||||
|
} else if (language === '' || language === undefined) {
|
||||||
|
language = 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
let newMap = new mapboxgl.Map({
|
||||||
|
container: 'map',
|
||||||
|
style: { version: 8, sources: {}, layers: [] },
|
||||||
|
zoom: 0,
|
||||||
|
hash: hash,
|
||||||
|
language,
|
||||||
|
attributionControl: false,
|
||||||
|
logoPosition: 'bottom-right',
|
||||||
|
boxZoom: false
|
||||||
|
});
|
||||||
|
newMap.on('load', () => {
|
||||||
|
$map = newMap; // only set the store after the map has loaded
|
||||||
|
scaleControl.setUnit($distanceUnits);
|
||||||
|
});
|
||||||
|
|
||||||
|
newMap.addControl(
|
||||||
|
new mapboxgl.AttributionControl({
|
||||||
|
compact: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
newMap.addControl(
|
||||||
|
new mapboxgl.NavigationControl({
|
||||||
|
visualizePitch: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (geocoder) {
|
||||||
|
newMap.addControl(
|
||||||
|
new MapboxGeocoder({
|
||||||
|
accessToken: mapboxgl.accessToken,
|
||||||
|
mapboxgl: mapboxgl,
|
||||||
|
collapsed: true,
|
||||||
|
flyTo: fitBoundsOptions,
|
||||||
|
language
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geolocate) {
|
||||||
|
newMap.addControl(
|
||||||
|
new mapboxgl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true
|
||||||
|
},
|
||||||
|
fitBoundsOptions,
|
||||||
|
trackUserLocation: true,
|
||||||
|
showUserHeading: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
newMap.addControl(scaleControl);
|
||||||
|
|
||||||
|
newMap.on('style.load', () => {
|
||||||
|
newMap.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14
|
||||||
|
});
|
||||||
|
newMap.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||||
|
});
|
||||||
|
newMap.setFog({
|
||||||
|
color: 'rgb(186, 210, 235)',
|
||||||
|
'high-color': 'rgb(36, 92, 223)',
|
||||||
|
'horizon-blend': 0.1,
|
||||||
|
'space-color': 'rgb(156, 240, 255)'
|
||||||
|
});
|
||||||
|
newMap.on('pitch', () => {
|
||||||
|
if (newMap.getPitch() > 0) {
|
||||||
|
newMap.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newMap.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// add dummy layer to place the overlay layers below
|
||||||
|
newMap.addLayer({
|
||||||
|
id: 'overlays',
|
||||||
|
type: 'background',
|
||||||
|
paint: {
|
||||||
|
'background-color': 'rgba(0, 0, 0, 0)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($map) {
|
||||||
|
$map.remove();
|
||||||
|
$map = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
$map &&
|
||||||
|
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||||
|
) {
|
||||||
|
$map.resize();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...$$restProps}>
|
||||||
|
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
|
||||||
|
>
|
||||||
|
<p>{$_('webgl2_required')}</p>
|
||||||
|
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||||
|
{$_('enable_webgl2')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
div :global(.mapboxgl-map) {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-icon) {
|
||||||
|
@apply dark:brightness-[4.7];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder) {
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-row;
|
||||||
|
@apply w-fit;
|
||||||
|
@apply min-w-fit;
|
||||||
|
@apply items-center;
|
||||||
|
@apply shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.suggestions) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply hover:text-accent-foreground;
|
||||||
|
@apply hover:bg-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||||
|
@apply bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply hover:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||||
|
@apply fill-foreground;
|
||||||
|
@apply hover:fill-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||||
|
@apply relative;
|
||||||
|
@apply top-0;
|
||||||
|
@apply left-0;
|
||||||
|
@apply my-2;
|
||||||
|
@apply w-[29px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply relative;
|
||||||
|
@apply w-64;
|
||||||
|
@apply py-0;
|
||||||
|
@apply pl-2;
|
||||||
|
@apply focus:outline-none;
|
||||||
|
@apply transition-[width];
|
||||||
|
@apply duration-200;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply w-0;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right) {
|
||||||
|
@apply z-40;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-col;
|
||||||
|
@apply items-end;
|
||||||
|
@apply h-full;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup) {
|
||||||
|
@apply w-fit;
|
||||||
|
@apply z-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-content) {
|
||||||
|
@apply p-0;
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-r-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-l-background;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Moon, Sun } from '@lucide/svelte';
|
import { Moon, Sun } from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
export let size = '20';
|
export let size = '20';
|
||||||
|
|
||||||
|
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
on:click={() => {
|
||||||
onclick={() => {
|
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||||
setMode(mode.current === 'light' ? 'dark' : 'light');
|
}}
|
||||||
}}
|
|
||||||
aria-label={i18n._('menu.mode')}
|
|
||||||
>
|
>
|
||||||
{#if mode.current === 'light'}
|
{#if selectedMode === 'light'}
|
||||||
<Sun {size} />
|
<Sun {size} />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon {size} />
|
<Moon {size} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
||||||
import { BookOpenText, Home, Map } from '@lucide/svelte';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full sticky top-0 bg-background z-50">
|
<nav class="w-full sticky top-0 bg-background z-50">
|
||||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||||
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
|
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
<Logo class="h-8 sm:hidden" iconOnly={true} />
|
||||||
<Logo class="h-8 hidden sm:block" width="153" />
|
<Logo class="h-8 hidden sm:block" />
|
||||||
</a>
|
</a>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
||||||
<Home size="18" />
|
<Home size="18" class="mr-1.5" />
|
||||||
{i18n._('homepage.home')}
|
{$_('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/app')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
||||||
<Map size="18" />
|
<Map size="18" class="mr-1.5" />
|
||||||
{i18n._('homepage.app')}
|
{$_('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage(i18n.lang, '/help')}>
|
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
||||||
<BookOpenText size="18" />
|
<BookOpenText size="18" class="mr-1.5" />
|
||||||
{i18n._('menu.help')}
|
{$_('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
<AlgoliaDocSearch class="ml-auto" />
|
<ModeSwitch class="ml-auto" />
|
||||||
<ModeSwitch class="hidden xs:block" />
|
</div>
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,48 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
export let orientation: 'col' | 'row' = 'col';
|
||||||
orientation = 'col',
|
|
||||||
after = $bindable(),
|
|
||||||
minAfter = 0,
|
|
||||||
maxAfter = Number.MAX_SAFE_INTEGER,
|
|
||||||
}: {
|
|
||||||
orientation?: 'col' | 'row';
|
|
||||||
after: number;
|
|
||||||
minAfter?: number;
|
|
||||||
maxAfter?: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function handleMouseDown(event: PointerEvent) {
|
export let after: number;
|
||||||
const startX = event.clientX;
|
export let minAfter: number = 0;
|
||||||
const startY = event.clientY;
|
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
||||||
const startAfter = after;
|
|
||||||
|
|
||||||
const handleMouseMove = (event: PointerEvent) => {
|
function handleMouseDown(event: PointerEvent) {
|
||||||
const newAfter =
|
const startX = event.clientX;
|
||||||
startAfter +
|
const startY = event.clientY;
|
||||||
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
const startAfter = after;
|
||||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
|
||||||
after = newAfter;
|
|
||||||
} else if (newAfter < minAfter && after !== minAfter) {
|
|
||||||
after = minAfter;
|
|
||||||
} else if (newAfter > maxAfter && after !== maxAfter) {
|
|
||||||
after = maxAfter;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseMove = (event: PointerEvent) => {
|
||||||
window.removeEventListener('pointermove', handleMouseMove);
|
const newAfter =
|
||||||
window.removeEventListener('pointerup', handleMouseUp);
|
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||||
};
|
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||||
|
after = newAfter;
|
||||||
|
} else if (newAfter < minAfter && after !== minAfter) {
|
||||||
|
after = minAfter;
|
||||||
|
} else if (newAfter > maxAfter && after !== maxAfter) {
|
||||||
|
after = maxAfter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('pointermove', handleMouseMove);
|
const handleMouseUp = () => {
|
||||||
window.addEventListener('pointerup', handleMouseUp);
|
window.removeEventListener('pointermove', handleMouseMove);
|
||||||
}
|
window.removeEventListener('pointerup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handleMouseMove);
|
||||||
|
window.addEventListener('pointerup', handleMouseUp);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
class="{orientation === 'col'
|
class="{orientation === 'col'
|
||||||
? 'w-1 h-full cursor-col-resize border-l'
|
? 'w-1 h-full cursor-col-resize border-l'
|
||||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||||
onpointerdown={handleMouseDown}
|
on:pointerdown={handleMouseDown}
|
||||||
></div>
|
/>
|
||||||
|
|||||||
@@ -1,43 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMac, isSafari } from '$lib/utils';
|
import { onMount } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { _ } from 'svelte-i18n';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
|
||||||
|
|
||||||
let {
|
export let key: string;
|
||||||
key = undefined,
|
export let shift: boolean = false;
|
||||||
shift = false,
|
export let ctrl: boolean = false;
|
||||||
ctrl = false,
|
export let click: boolean = false;
|
||||||
click = false,
|
|
||||||
class: className = '',
|
|
||||||
}: {
|
|
||||||
key?: string;
|
|
||||||
shift?: boolean;
|
|
||||||
ctrl?: boolean;
|
|
||||||
click?: boolean;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let mac = $state(false);
|
let isMac = false;
|
||||||
let safari = $state(false);
|
let isSafari = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mac = isMac();
|
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
|
||||||
safari = isSafari();
|
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Kbd.Root class="ml-auto {className}">
|
<div
|
||||||
{#if shift}
|
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||||
⇧
|
>
|
||||||
{/if}
|
<span>{shift ? '⇧' : ''}</span>
|
||||||
{#if ctrl}
|
<span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span>
|
||||||
{mac && !safari ? '⌘' : i18n._('menu.ctrl')}
|
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||||
{/if}
|
<span>{click ? $_('menu.click') : ''}</span>
|
||||||
{#if key}
|
</div>
|
||||||
{key}
|
|
||||||
{/if}
|
|
||||||
{#if click}
|
|
||||||
{i18n._('menu.click')}
|
|
||||||
{/if}
|
|
||||||
</Kbd.Root>
|
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
let {
|
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||||
label,
|
|
||||||
side = 'top',
|
|
||||||
children,
|
|
||||||
extra,
|
|
||||||
class: className = '',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
children: Snippet;
|
|
||||||
extra?: Snippet;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Provider>
|
<Tooltip.Root>
|
||||||
<Tooltip.Root>
|
<Tooltip.Trigger {...$$restProps}>
|
||||||
<Tooltip.Trigger class={className} aria-label={label}>
|
<slot name="data" />
|
||||||
{@render children()}
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Trigger>
|
<Tooltip.Content {side}>
|
||||||
<Tooltip.Content {side}>
|
<slot name="tooltip" />
|
||||||
<div class="flex flex-row items-center gap-2">
|
</Tooltip.Content>
|
||||||
<span>{label}</span>
|
</Tooltip.Root>
|
||||||
{@render extra?.()}
|
|
||||||
</div>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
|
|||||||
29
website/src/lib/components/Welcome.svelte
Normal file
29
website/src/lib/components/Welcome.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
|
||||||
|
const { showWelcomeMessage } = settings;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialog.Root
|
||||||
|
open={$showWelcomeMessage === true}
|
||||||
|
closeOnEscape={false}
|
||||||
|
closeOnOutsideClick={false}
|
||||||
|
onOpenChange={() => ($showWelcomeMessage = false)}
|
||||||
|
>
|
||||||
|
<AlertDialog.Trigger class="hidden"></AlertDialog.Trigger>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>
|
||||||
|
Welcome to the new version of <b>gpx.studio</b>!
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description class="space-y-1">
|
||||||
|
<p>The website is still under development and may contain bugs.</p>
|
||||||
|
<p>Please report any issues you find by email or on GitHub.</p>
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Action>Let's go!</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
@@ -1,56 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { settings } from '$lib/db';
|
||||||
celsiusToFahrenheit,
|
import {
|
||||||
getConvertedDistance,
|
celsiusToFahrenheit,
|
||||||
getConvertedElevation,
|
distancePerHourToSecondsPerDistance,
|
||||||
getConvertedVelocity,
|
kilometersToMiles,
|
||||||
getDistanceUnits,
|
metersToFeet,
|
||||||
getElevationUnits,
|
secondsToHHMMSS
|
||||||
getVelocityUnits,
|
} from '$lib/units';
|
||||||
secondsToHHMMSS,
|
|
||||||
} from '$lib/units';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
import { settings } from '$lib/logic/settings';
|
|
||||||
|
|
||||||
let {
|
import { _ } from 'svelte-i18n';
|
||||||
value,
|
|
||||||
type,
|
|
||||||
showUnits = true,
|
|
||||||
decimals = undefined,
|
|
||||||
class: className = '',
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
|
||||||
showUnits?: boolean;
|
|
||||||
decimals?: number;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
export let value: number;
|
||||||
|
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||||
|
export let showUnits: boolean = true;
|
||||||
|
export let decimals: number | undefined = undefined;
|
||||||
|
|
||||||
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class={className}>
|
<span class={$$props.class}>
|
||||||
{#if type === 'distance'}
|
{#if type === 'distance'}
|
||||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
{#if $distanceUnits === 'metric'}
|
||||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
|
||||||
{:else if type === 'elevation'}
|
{:else}
|
||||||
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
|
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
|
||||||
{showUnits ? getElevationUnits($distanceUnits) : ''}
|
{/if}
|
||||||
{:else if type === 'speed'}
|
{:else if type === 'elevation'}
|
||||||
{#if $velocityUnits === 'speed'}
|
{#if $distanceUnits === 'metric'}
|
||||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
|
||||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
{:else}
|
||||||
{:else}
|
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
|
||||||
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
|
{/if}
|
||||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
{:else if type === 'speed'}
|
||||||
{/if}
|
{#if $distanceUnits === 'metric'}
|
||||||
{:else if type === 'temperature'}
|
{#if $velocityUnits === 'speed'}
|
||||||
{#if $temperatureUnits === 'celsius'}
|
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
|
||||||
{value} {showUnits ? i18n._('units.celsius') : ''}
|
{:else}
|
||||||
{:else}
|
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
|
||||||
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
|
{showUnits ? $_('units.minutes_per_kilometer') : ''}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'time'}
|
{:else if $velocityUnits === 'speed'}
|
||||||
{secondsToHHMMSS(value)}
|
{kilometersToMiles(value).toFixed(decimals ?? 2)}
|
||||||
{/if}
|
{showUnits ? $_('units.miles_per_hour') : ''}
|
||||||
|
{:else}
|
||||||
|
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
|
||||||
|
{showUnits ? $_('units.minutes_per_mile') : ''}
|
||||||
|
{/if}
|
||||||
|
{:else if type === 'temperature'}
|
||||||
|
{#if $temperatureUnits === 'celsius'}
|
||||||
|
{value} {showUnits ? $_('units.celsius') : ''}
|
||||||
|
{:else}
|
||||||
|
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
||||||
|
{/if}
|
||||||
|
{:else if type === 'time'}
|
||||||
|
{secondsToHHMMSS(value)}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext, type Snippet } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
import { CollapsibleTreeState } from './utils.svelte';
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
const {
|
export let defaultState: 'open' | 'closed' = 'open';
|
||||||
defaultState = 'open',
|
export let side: 'left' | 'right' = 'right';
|
||||||
side = 'right',
|
export let nohover: boolean = false;
|
||||||
nohover = false,
|
export let slotInsideTrigger: boolean = true;
|
||||||
slotInsideTrigger = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
defaultState?: 'open' | 'closed';
|
|
||||||
side?: 'left' | 'right';
|
|
||||||
nohover?: boolean;
|
|
||||||
slotInsideTrigger?: boolean;
|
|
||||||
children: Snippet;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let open = $state(new CollapsibleTreeState(defaultState));
|
let open = writable<Record<string, boolean>>({});
|
||||||
|
|
||||||
setContext('collapsible-tree-state', open);
|
setContext('collapsible-tree-default-state', defaultState);
|
||||||
setContext('collapsible-tree-side', side);
|
setContext('collapsible-tree-state', open);
|
||||||
setContext('collapsible-tree-nohover', nohover);
|
setContext('collapsible-tree-side', side);
|
||||||
setContext('collapsible-tree-parent-id', 'root');
|
setContext('collapsible-tree-nohover', nohover);
|
||||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
setContext('collapsible-tree-parent-id', 'root');
|
||||||
|
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
<slot />
|
||||||
|
|||||||
@@ -1,92 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||||
import { getContext, setContext, type Snippet } from 'svelte';
|
import { getContext, onMount, setContext } from 'svelte';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import { get, type Writable } from 'svelte/store';
|
||||||
import type { CollapsibleTreeState } from './utils.svelte';
|
|
||||||
|
|
||||||
const props: {
|
export let id: string | number;
|
||||||
id: string | number;
|
|
||||||
class?: ClassValue;
|
|
||||||
trigger: Snippet;
|
|
||||||
content: Snippet;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
|
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
||||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
||||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||||
|
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||||
|
|
||||||
let fullId = `${parentId}.${props.id}`;
|
let fullId = `${parentId}.${id}`;
|
||||||
setContext('collapsible-tree-parent-id', fullId);
|
setContext('collapsible-tree-parent-id', fullId);
|
||||||
|
|
||||||
let open = state.get(fullId);
|
onMount(() => {
|
||||||
|
if (!get(open).hasOwnProperty(fullId)) {
|
||||||
|
open.update((value) => {
|
||||||
|
value[fullId] = defaultState === 'open';
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function openNode() {
|
export function openNode() {
|
||||||
open.current = true;
|
open.update((value) => {
|
||||||
}
|
value[fullId] = true;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open={open.current} class={props.class}>
|
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
|
||||||
{#if slotInsideTrigger}
|
{#if slotInsideTrigger}
|
||||||
<Collapsible.Trigger class="w-full">
|
<Collapsible.Trigger class="w-full">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full flex flex-row {side === 'right'
|
class="w-full flex flex-row {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover
|
: 'justify-start'} py-0 px-1 h-fit {nohover
|
||||||
? 'hover:bg-background'
|
? 'hover:bg-background'
|
||||||
: ''} pointer-events-none"
|
: ''} pointer-events-none"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
{#if open.current}
|
{#if $open[fullId]}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{@render props.trigger()}
|
<slot name="trigger" />
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
{#if open.current}
|
{#if $open[fullId]}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full flex flex-row {side === 'right'
|
class="w-full flex flex-row {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover ? 'hover:bg-background' : ''}"
|
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if open.current}
|
{#if $open[fullId]}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{/if}
|
{/if}
|
||||||
{@render props.trigger()}
|
<slot name="trigger" />
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if open.current}
|
{#if $open[fullId]}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Collapsible.Content>
|
<Collapsible.Content class="ml-2">
|
||||||
{@render props.content()}
|
<slot name="content" />
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as CollapsibleTree } from './CollapsibleTree.svelte';
|
export { default as CollapsibleTree } from './CollapsibleTree.svelte';
|
||||||
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
|
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export class CollapsibleNodeState {
|
|
||||||
private _open: boolean;
|
|
||||||
|
|
||||||
constructor(defaultState: 'open' | 'closed') {
|
|
||||||
this._open = $state(defaultState === 'open');
|
|
||||||
}
|
|
||||||
|
|
||||||
get current(): boolean {
|
|
||||||
return this._open;
|
|
||||||
}
|
|
||||||
|
|
||||||
set current(value: boolean) {
|
|
||||||
this._open = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CollapsibleTreeState {
|
|
||||||
private _open: Record<string, CollapsibleNodeState> = {};
|
|
||||||
private _defaultState: 'open' | 'closed';
|
|
||||||
|
|
||||||
constructor(defaultState: 'open' | 'closed') {
|
|
||||||
this._defaultState = defaultState;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(id: string): CollapsibleNodeState {
|
|
||||||
if (this._open[id] === undefined) {
|
|
||||||
this._open[id] = new CollapsibleNodeState(this._defaultState);
|
|
||||||
}
|
|
||||||
return this._open[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from './CustomControl';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
|
||||||
|
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let control: CustomControl | undefined = undefined;
|
||||||
|
|
||||||
|
$: if ($map && container) {
|
||||||
|
if (position.includes('right')) container.classList.add('float-right');
|
||||||
|
else container.classList.add('float-left');
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
if (control === undefined) {
|
||||||
|
control = new CustomControl(container);
|
||||||
|
}
|
||||||
|
$map.addControl(control, position);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{$$props.class ||
|
||||||
|
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
@@ -17,4 +17,4 @@ export default class CustomControl implements IControl {
|
|||||||
this._container?.parentNode?.removeChild(this._container);
|
this._container?.parentNode?.removeChild(this._container);
|
||||||
this._map = undefined;
|
this._map = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from 'svelte';
|
|
||||||
|
|
||||||
let { module: Module }: { module: Component } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="markdown flex flex-col gap-3">
|
|
||||||
<Module />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
@reference "../../../app.css";
|
|
||||||
|
|
||||||
:global(.markdown) {
|
|
||||||
@apply text-muted-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown h1) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply text-3xl;
|
|
||||||
@apply font-semibold;
|
|
||||||
@apply mb-3 pt-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown h2) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply text-2xl;
|
|
||||||
@apply font-semibold;
|
|
||||||
@apply pt-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown h3) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply text-lg;
|
|
||||||
@apply font-semibold;
|
|
||||||
@apply pt-1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown p > button, .markdown li > button) {
|
|
||||||
@apply border;
|
|
||||||
@apply rounded-md;
|
|
||||||
@apply px-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown > a) {
|
|
||||||
@apply text-link;
|
|
||||||
@apply hover:underline;
|
|
||||||
@apply contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown p > a) {
|
|
||||||
@apply text-link;
|
|
||||||
@apply hover:underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown li > a) {
|
|
||||||
@apply text-link;
|
|
||||||
@apply hover:underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown kbd) {
|
|
||||||
@apply p-1;
|
|
||||||
@apply rounded-md;
|
|
||||||
@apply border;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown ul) {
|
|
||||||
@apply list-disc;
|
|
||||||
@apply pl-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown ol) {
|
|
||||||
@apply list-decimal;
|
|
||||||
@apply pl-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown li) {
|
|
||||||
@apply mt-1;
|
|
||||||
@apply first:mt-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown hr) {
|
|
||||||
@apply my-5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
export let src;
|
||||||
src,
|
export let alt: string;
|
||||||
alt,
|
|
||||||
}: {
|
|
||||||
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
|
||||||
alt: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center py-6 w-full">
|
<div class="flex flex-col items-center py-6 w-full">
|
||||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
<div class="rounded-md overflow-clip shadow-xl mx-auto">
|
||||||
{#if src === 'getting-started/interface'}
|
<enhanced:img {src} {alt} class="w-full max-w-3xl" />
|
||||||
<enhanced:img
|
</div>
|
||||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||||
{alt}
|
|
||||||
class="w-full max-w-3xl"
|
|
||||||
/>
|
|
||||||
{:else if src === 'tools/routing'}
|
|
||||||
<enhanced:img
|
|
||||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
|
||||||
{alt}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
src={waymarkedMap}
|
src={waymarkedMap}
|
||||||
alt="Waymarked Trails map screenshot."
|
alt="Waymarked Trails map screenshot."
|
||||||
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
|
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
website/src/lib/components/docs/DocsLoader.svelte
Normal file
112
website/src/lib/components/docs/DocsLoader.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export let path: string;
|
||||||
|
export let titleOnly: boolean = false;
|
||||||
|
|
||||||
|
let module = undefined;
|
||||||
|
let metadata: Record<string, any> = {};
|
||||||
|
|
||||||
|
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||||
|
|
||||||
|
function loadModule(path: string) {
|
||||||
|
modules[path]?.().then((mod) => {
|
||||||
|
module = mod.default;
|
||||||
|
metadata = mod.metadata;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($locale) {
|
||||||
|
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
|
||||||
|
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||||
|
} else if (browser) {
|
||||||
|
goto(`${base}/404`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if module !== undefined}
|
||||||
|
{#if titleOnly}
|
||||||
|
{metadata.title}
|
||||||
|
{:else}
|
||||||
|
<div class="markdown flex flex-col gap-3">
|
||||||
|
<svelte:component this={module} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
:global(.markdown) {
|
||||||
|
@apply text-muted-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown h1) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply text-3xl;
|
||||||
|
@apply font-semibold;
|
||||||
|
@apply mb-3 pt-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown h2) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply text-2xl;
|
||||||
|
@apply font-semibold;
|
||||||
|
@apply pt-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown h3) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply text-lg;
|
||||||
|
@apply font-semibold;
|
||||||
|
@apply pt-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown p > button) {
|
||||||
|
@apply border;
|
||||||
|
@apply rounded-md;
|
||||||
|
@apply px-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown > a) {
|
||||||
|
@apply text-blue-500;
|
||||||
|
@apply hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown p > a) {
|
||||||
|
@apply text-blue-500;
|
||||||
|
@apply hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown li > a) {
|
||||||
|
@apply text-blue-500;
|
||||||
|
@apply hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown kbd) {
|
||||||
|
@apply p-1;
|
||||||
|
@apply rounded-md;
|
||||||
|
@apply border;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown ul) {
|
||||||
|
@apply list-disc;
|
||||||
|
@apply pl-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown ol) {
|
||||||
|
@apply list-decimal;
|
||||||
|
@apply pl-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown li) {
|
||||||
|
@apply mt-1;
|
||||||
|
@apply first:mt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.markdown hr) {
|
||||||
|
@apply my-5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
export let type: 'note' | 'warning' = 'note';
|
||||||
|
|
||||||
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-secondary border-l-8 {type === 'note'
|
class="bg-accent border-l-8 {type === 'note'
|
||||||
? 'border-link'
|
? 'border-blue-500'
|
||||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||||
>
|
>
|
||||||
{@render children()}
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
@reference "../../../app.css";
|
div :global(a) {
|
||||||
|
@apply text-blue-500;
|
||||||
div :global(a) {
|
@apply hover:underline;
|
||||||
@apply text-link;
|
}
|
||||||
@apply hover:underline;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,64 +1,36 @@
|
|||||||
import {
|
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte";
|
||||||
File,
|
import type { ComponentType } from "svelte";
|
||||||
FilePen,
|
|
||||||
View,
|
|
||||||
Settings,
|
|
||||||
Pencil,
|
|
||||||
MapPin,
|
|
||||||
Scissors,
|
|
||||||
CalendarClock,
|
|
||||||
Group,
|
|
||||||
Ungroup,
|
|
||||||
Funnel,
|
|
||||||
SquareDashedMousePointer,
|
|
||||||
MountainSnow,
|
|
||||||
type IconProps,
|
|
||||||
} from '@lucide/svelte';
|
|
||||||
import type { Component } from 'svelte';
|
|
||||||
|
|
||||||
export const guides: Record<string, string[]> = {
|
export const guides: Record<string, string[]> = {
|
||||||
'getting-started': [],
|
'getting-started': [],
|
||||||
menu: ['file', 'edit', 'view', 'settings'],
|
menu: ['file', 'edit', 'view', 'settings'],
|
||||||
'files-and-stats': [],
|
'files-and-stats': [],
|
||||||
toolbar: [
|
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'],
|
||||||
'routing',
|
|
||||||
'poi',
|
|
||||||
'scissors',
|
|
||||||
'time',
|
|
||||||
'merge',
|
|
||||||
'extract',
|
|
||||||
'elevation',
|
|
||||||
'minify',
|
|
||||||
'clean',
|
|
||||||
],
|
|
||||||
'map-controls': [],
|
'map-controls': [],
|
||||||
gpx: [],
|
'gpx': [],
|
||||||
integration: [],
|
'integration': [],
|
||||||
faq: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guideIcons: Record<string, string | Component<IconProps>> = {
|
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||||
'getting-started': '🚀',
|
"getting-started": "🚀",
|
||||||
menu: '📂 ⚙️',
|
"menu": "📂 ⚙️",
|
||||||
file: File,
|
"file": File,
|
||||||
edit: FilePen,
|
"edit": FilePen,
|
||||||
view: View,
|
"view": View,
|
||||||
settings: Settings,
|
"settings": Settings,
|
||||||
'files-and-stats': '🗂 📈',
|
"files-and-stats": "🗂 📈",
|
||||||
toolbar: '🧰',
|
"toolbar": "🧰",
|
||||||
routing: Pencil,
|
"routing": Pencil,
|
||||||
poi: MapPin,
|
"poi": MapPin,
|
||||||
scissors: Scissors,
|
"scissors": Scissors,
|
||||||
time: CalendarClock,
|
"time": CalendarClock,
|
||||||
merge: Group,
|
"merge": Group,
|
||||||
extract: Ungroup,
|
"extract": Ungroup,
|
||||||
elevation: MountainSnow,
|
"minify": Filter,
|
||||||
minify: Funnel,
|
"clean": SquareDashedMousePointer,
|
||||||
clean: SquareDashedMousePointer,
|
"map-controls": "🗺",
|
||||||
'map-controls': '🗺',
|
"gpx": "💾",
|
||||||
gpx: '💾',
|
"integration": "{ 👩💻 }",
|
||||||
integration: '{ 👩💻 }',
|
|
||||||
faq: '🔮',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||||
@@ -121,4 +93,4 @@ export function getNextGuide(currentGuide: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,262 +1,264 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
||||||
// import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||||
// import FileList from '$lib/components/file-list/FileList.svelte';
|
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||||
// import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||||
import Map from '$lib/components/map/Map.svelte';
|
import Map from '$lib/components/Map.svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
||||||
// import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
import {
|
||||||
import {
|
gpxStatistics,
|
||||||
gpxStatistics,
|
slicedGPXStatistics,
|
||||||
slicedGPXStatistics,
|
embedding,
|
||||||
embedding,
|
loadFile,
|
||||||
loadFile,
|
map,
|
||||||
updateGPXData,
|
updateGPXData
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import { onDestroy, onMount, setContext } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { readable } from 'svelte/store';
|
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||||
import type { GPXFile } from 'gpx';
|
import { readable } from 'svelte/store';
|
||||||
import { ListFileItem } from '$lib/components/file-list/file-list';
|
import type { GPXFile } from 'gpx';
|
||||||
import {
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
allowedEmbeddingBasemaps,
|
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||||
getFilesFromEmbeddingOptions,
|
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
|
||||||
type EmbeddingOptions,
|
import { mode, setMode } from 'mode-watcher';
|
||||||
} from './Embedding';
|
|
||||||
import { mode, setMode } from 'mode-watcher';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { settings } from '$lib/logic/settings';
|
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
|
||||||
|
|
||||||
let {
|
$embedding = true;
|
||||||
useHash = true,
|
|
||||||
options = $bindable(),
|
|
||||||
hash,
|
|
||||||
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
|
||||||
|
|
||||||
setContext('embedding', true);
|
const {
|
||||||
|
currentBasemap,
|
||||||
|
distanceUnits,
|
||||||
|
velocityUnits,
|
||||||
|
temperatureUnits,
|
||||||
|
fileOrder,
|
||||||
|
distanceMarkers,
|
||||||
|
directionMarkers
|
||||||
|
} = settings;
|
||||||
|
|
||||||
const {
|
export let useHash = true;
|
||||||
currentBasemap,
|
export let options: EmbeddingOptions;
|
||||||
distanceUnits,
|
export let hash: string;
|
||||||
velocityUnits,
|
|
||||||
temperatureUnits,
|
|
||||||
fileOrder,
|
|
||||||
distanceMarkers,
|
|
||||||
directionMarkers,
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
let prevSettings: {
|
let prevSettings = {
|
||||||
distanceMarkers: boolean;
|
distanceMarkers: false,
|
||||||
directionMarkers: boolean;
|
directionMarkers: false,
|
||||||
distanceUnits: 'metric' | 'imperial' | 'nautical';
|
distanceUnits: 'metric',
|
||||||
velocityUnits: 'speed' | 'pace';
|
velocityUnits: 'speed',
|
||||||
temperatureUnits: 'celsius' | 'fahrenheit';
|
temperatureUnits: 'celsius',
|
||||||
theme: 'light' | 'dark' | 'system';
|
theme: 'system'
|
||||||
} = {
|
};
|
||||||
distanceMarkers: false,
|
|
||||||
directionMarkers: false,
|
|
||||||
distanceUnits: 'metric',
|
|
||||||
velocityUnits: 'speed',
|
|
||||||
temperatureUnits: 'celsius',
|
|
||||||
theme: 'system',
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyOptions() {
|
function applyOptions() {
|
||||||
// fileObservers.update(($fileObservers) => {
|
fileObservers.update(($fileObservers) => {
|
||||||
// $fileObservers.clear();
|
$fileObservers.clear();
|
||||||
// return $fileObservers;
|
return $fileObservers;
|
||||||
// });
|
});
|
||||||
// let downloads: Promise<GPXFile | null>[] = [];
|
|
||||||
// getFilesFromEmbeddingOptions(options).forEach((url) => {
|
|
||||||
// downloads.push(
|
|
||||||
// fetch(url)
|
|
||||||
// .then((response) => response.blob())
|
|
||||||
// .then((blob) => new File([blob], url.split('/').pop() ?? url))
|
|
||||||
// .then(loadFile)
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// Promise.all(downloads).then((files) => {
|
|
||||||
// let ids: string[] = [];
|
|
||||||
// let bounds = {
|
|
||||||
// southWest: {
|
|
||||||
// lat: 90,
|
|
||||||
// lon: 180,
|
|
||||||
// },
|
|
||||||
// northEast: {
|
|
||||||
// lat: -90,
|
|
||||||
// lon: -180,
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// fileObservers.update(($fileObservers) => {
|
|
||||||
// files.forEach((file, index) => {
|
|
||||||
// if (file === null) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// let id = `gpx-${index}-embed`;
|
|
||||||
// file._data.id = id;
|
|
||||||
// let statistics = new GPXStatisticsTree(file);
|
|
||||||
// $fileObservers.set(
|
|
||||||
// id,
|
|
||||||
// readable({
|
|
||||||
// file,
|
|
||||||
// statistics,
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
// ids.push(id);
|
|
||||||
// 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);
|
|
||||||
// bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
|
||||||
// bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
|
||||||
// });
|
|
||||||
// return $fileObservers;
|
|
||||||
// });
|
|
||||||
// $fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
|
||||||
// selection.update(($selection) => {
|
|
||||||
// $selection.clear();
|
|
||||||
// ids.forEach((id) => {
|
|
||||||
// $selection.toggle(new ListFileItem(id));
|
|
||||||
// });
|
|
||||||
// return $selection;
|
|
||||||
// });
|
|
||||||
// if (hash.length === 0) {
|
|
||||||
// map.subscribe(($map) => {
|
|
||||||
// if ($map) {
|
|
||||||
// $map.fitBounds(
|
|
||||||
// [
|
|
||||||
// bounds.southWest.lon,
|
|
||||||
// bounds.southWest.lat,
|
|
||||||
// bounds.northEast.lon,
|
|
||||||
// bounds.northEast.lat,
|
|
||||||
// ],
|
|
||||||
// {
|
|
||||||
// padding: 80,
|
|
||||||
// linear: true,
|
|
||||||
// easing: () => 1,
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// if (
|
|
||||||
// options.basemap !== $currentBasemap &&
|
|
||||||
// allowedEmbeddingBasemaps.includes(options.basemap)
|
|
||||||
// ) {
|
|
||||||
// $currentBasemap = options.basemap;
|
|
||||||
// }
|
|
||||||
// if (options.distanceMarkers !== $distanceMarkers) {
|
|
||||||
// $distanceMarkers = options.distanceMarkers;
|
|
||||||
// }
|
|
||||||
// if (options.directionMarkers !== $directionMarkers) {
|
|
||||||
// $directionMarkers = options.directionMarkers;
|
|
||||||
// }
|
|
||||||
// if (options.distanceUnits !== $distanceUnits) {
|
|
||||||
// $distanceUnits = options.distanceUnits;
|
|
||||||
// }
|
|
||||||
// if (options.velocityUnits !== $velocityUnits) {
|
|
||||||
// $velocityUnits = options.velocityUnits;
|
|
||||||
// }
|
|
||||||
// if (options.temperatureUnits !== $temperatureUnits) {
|
|
||||||
// $temperatureUnits = options.temperatureUnits;
|
|
||||||
// }
|
|
||||||
// if (options.theme !== $mode) {
|
|
||||||
// setMode(options.theme);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
let downloads: Promise<GPXFile | null>[] = [];
|
||||||
prevSettings.distanceMarkers = distanceMarkers.value;
|
options.files.forEach((url) => {
|
||||||
prevSettings.directionMarkers = directionMarkers.value;
|
downloads.push(
|
||||||
prevSettings.distanceUnits = distanceUnits.value;
|
fetch(url)
|
||||||
prevSettings.velocityUnits = velocityUnits.value;
|
.then((response) => response.blob())
|
||||||
prevSettings.temperatureUnits = temperatureUnits.value;
|
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||||
prevSettings.theme = mode.current ?? 'system';
|
.then(loadFile)
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// $: if (browser && options) {
|
Promise.all(downloads).then((files) => {
|
||||||
// applyOptions();
|
let ids: string[] = [];
|
||||||
// }
|
let bounds = {
|
||||||
|
southWest: {
|
||||||
|
lat: 90,
|
||||||
|
lon: 180
|
||||||
|
},
|
||||||
|
northEast: {
|
||||||
|
lat: -90,
|
||||||
|
lon: -180
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// $: if ($fileOrder) {
|
fileObservers.update(($fileObservers) => {
|
||||||
// updateGPXData();
|
files.forEach((file, index) => {
|
||||||
// }
|
if (file === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
let id = `gpx-${index}-embed`;
|
||||||
if (distanceMarkers.value !== prevSettings.distanceMarkers) {
|
file._data.id = id;
|
||||||
distanceMarkers.value = prevSettings.distanceMarkers;
|
let statistics = new GPXStatisticsTree(file);
|
||||||
}
|
|
||||||
|
|
||||||
if (directionMarkers.value !== prevSettings.directionMarkers) {
|
$fileObservers.set(
|
||||||
directionMarkers.value = prevSettings.directionMarkers;
|
id,
|
||||||
}
|
readable({
|
||||||
|
file,
|
||||||
|
statistics
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (distanceUnits.value !== prevSettings.distanceUnits) {
|
ids.push(id);
|
||||||
distanceUnits.value = prevSettings.distanceUnits;
|
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||||
}
|
|
||||||
|
|
||||||
if (velocityUnits.value !== prevSettings.velocityUnits) {
|
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||||
velocityUnits.value = prevSettings.velocityUnits;
|
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||||
}
|
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
||||||
|
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
||||||
|
});
|
||||||
|
|
||||||
if (temperatureUnits.value !== prevSettings.temperatureUnits) {
|
return $fileObservers;
|
||||||
temperatureUnits.value = prevSettings.temperatureUnits;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (mode.current !== prevSettings.theme) {
|
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||||
setMode(prevSettings.theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// $selection.clear();
|
selection.update(($selection) => {
|
||||||
// $fileObservers.clear();
|
$selection.clear();
|
||||||
fileOrder.value = fileOrder.value.filter((id) => !id.includes('embed'));
|
ids.forEach((id) => {
|
||||||
});
|
$selection.toggle(new ListFileItem(id));
|
||||||
|
});
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hash.length === 0) {
|
||||||
|
map.subscribe(($map) => {
|
||||||
|
if ($map) {
|
||||||
|
$map.fitBounds(
|
||||||
|
[
|
||||||
|
bounds.southWest.lon,
|
||||||
|
bounds.southWest.lat,
|
||||||
|
bounds.northEast.lon,
|
||||||
|
bounds.northEast.lat
|
||||||
|
],
|
||||||
|
{
|
||||||
|
padding: 80,
|
||||||
|
linear: true,
|
||||||
|
easing: () => 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||||
|
$currentBasemap = options.basemap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.distanceMarkers !== $distanceMarkers) {
|
||||||
|
$distanceMarkers = options.distanceMarkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.directionMarkers !== $directionMarkers) {
|
||||||
|
$directionMarkers = options.directionMarkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.distanceUnits !== $distanceUnits) {
|
||||||
|
$distanceUnits = options.distanceUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.velocityUnits !== $velocityUnits) {
|
||||||
|
$velocityUnits = options.velocityUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.temperatureUnits !== $temperatureUnits) {
|
||||||
|
$temperatureUnits = options.temperatureUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.theme !== $mode) {
|
||||||
|
setMode(options.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
prevSettings.distanceMarkers = $distanceMarkers;
|
||||||
|
prevSettings.directionMarkers = $directionMarkers;
|
||||||
|
prevSettings.distanceUnits = $distanceUnits;
|
||||||
|
prevSettings.velocityUnits = $velocityUnits;
|
||||||
|
prevSettings.temperatureUnits = $temperatureUnits;
|
||||||
|
prevSettings.theme = $mode ?? 'system';
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (options) {
|
||||||
|
applyOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($fileOrder) {
|
||||||
|
updateGPXData();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
||||||
|
$distanceMarkers = prevSettings.distanceMarkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($directionMarkers !== prevSettings.directionMarkers) {
|
||||||
|
$directionMarkers = prevSettings.directionMarkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($distanceUnits !== prevSettings.distanceUnits) {
|
||||||
|
$distanceUnits = prevSettings.distanceUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($velocityUnits !== prevSettings.velocityUnits) {
|
||||||
|
$velocityUnits = prevSettings.velocityUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
||||||
|
$temperatureUnits = prevSettings.temperatureUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode !== prevSettings.theme) {
|
||||||
|
setMode(prevSettings.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection.clear();
|
||||||
|
$fileObservers.clear();
|
||||||
|
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||||
<div class="grow relative">
|
<div class="grow relative">
|
||||||
<Map
|
<Map
|
||||||
class="h-full {fileStateCollection.files.size > 1 ? 'horizontal' : ''}"
|
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
||||||
accessToken={options.token}
|
accessToken={options.token}
|
||||||
geocoder={false}
|
geocoder={false}
|
||||||
geolocate={false}
|
geolocate={false}
|
||||||
hash={useHash}
|
hash={useHash}
|
||||||
/>
|
/>
|
||||||
<OpenIn files={options.files} ids={options.ids} />
|
<OpenIn bind:files={options.files} />
|
||||||
<!-- <LayerControl /> -->
|
<LayerControl />
|
||||||
<!-- <GPXLayers /> -->
|
<GPXLayers />
|
||||||
{#if fileStateCollection.files.size > 1}
|
{#if $fileObservers.size > 1}
|
||||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||||
<!-- <FileList orientation="horizontal" /> -->
|
<FileList orientation="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
||||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||||
>
|
>
|
||||||
<!-- <GPXStatistics
|
<GPXStatistics
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
panelSize={options.elevation.height}
|
panelSize={options.elevation.height}
|
||||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||||
/> -->
|
/>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<!-- <ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
additionalDatasets={[
|
additionalDatasets={[
|
||||||
options.elevation.speed ? 'speed' : null,
|
options.elevation.speed ? 'speed' : null,
|
||||||
options.elevation.hr ? 'hr' : null,
|
options.elevation.hr ? 'hr' : null,
|
||||||
options.elevation.cad ? 'cad' : null,
|
options.elevation.cad ? 'cad' : null,
|
||||||
options.elevation.temp ? 'temp' : null,
|
options.elevation.temp ? 'temp' : null,
|
||||||
options.elevation.power ? 'power' : null,
|
options.elevation.power ? 'power' : null
|
||||||
].filter((dataset) => dataset !== null)}
|
].filter((dataset) => dataset !== null)}
|
||||||
elevationFill={options.elevation.fill}
|
elevationFill={options.elevation.fill}
|
||||||
showControls={options.elevation.controls}
|
panelSize={options.elevation.height}
|
||||||
/> -->
|
showControls={options.elevation.controls}
|
||||||
{/if}
|
class="py-2"
|
||||||
</div>
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
import { basemaps } from "$lib/assets/layers";
|
||||||
import { basemaps } from '$lib/assets/layers';
|
|
||||||
|
|
||||||
export type EmbeddingOptions = {
|
export type EmbeddingOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
files: string[];
|
files: string[];
|
||||||
ids: string[];
|
|
||||||
basemap: string;
|
basemap: string;
|
||||||
elevation: {
|
elevation: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
height: number;
|
height: number,
|
||||||
controls: boolean;
|
controls: boolean,
|
||||||
fill: 'slope' | 'surface' | 'highway' | undefined;
|
fill: 'slope' | 'surface' | undefined,
|
||||||
speed: boolean;
|
speed: boolean,
|
||||||
hr: boolean;
|
hr: boolean,
|
||||||
cad: boolean;
|
cad: boolean,
|
||||||
temp: boolean;
|
temp: boolean,
|
||||||
power: boolean;
|
power: boolean,
|
||||||
};
|
},
|
||||||
distanceMarkers: boolean;
|
distanceMarkers: boolean,
|
||||||
directionMarkers: boolean;
|
directionMarkers: boolean,
|
||||||
distanceUnits: 'metric' | 'imperial' | 'nautical';
|
distanceUnits: 'metric' | 'imperial',
|
||||||
velocityUnits: 'speed' | 'pace';
|
velocityUnits: 'speed' | 'pace',
|
||||||
temperatureUnits: 'celsius' | 'fahrenheit';
|
temperatureUnits: 'celsius' | 'fahrenheit',
|
||||||
theme: 'system' | 'light' | 'dark';
|
theme: 'system' | 'light' | 'dark',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultEmbeddingOptions = {
|
export const defaultEmbeddingOptions = {
|
||||||
token: '',
|
token: '',
|
||||||
files: [],
|
files: [],
|
||||||
ids: [],
|
|
||||||
basemap: 'mapboxOutdoors',
|
basemap: 'mapboxOutdoors',
|
||||||
elevation: {
|
elevation: {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -53,17 +50,10 @@ export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
|||||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMergedEmbeddingOptions(
|
export function getMergedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): EmbeddingOptions {
|
||||||
options: any,
|
|
||||||
defaultOptions: any = defaultEmbeddingOptions
|
|
||||||
): EmbeddingOptions {
|
|
||||||
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
||||||
for (const key in options) {
|
for (const key in options) {
|
||||||
if (
|
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
|
||||||
typeof options[key] === 'object' &&
|
|
||||||
options[key] !== null &&
|
|
||||||
!Array.isArray(options[key])
|
|
||||||
) {
|
|
||||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||||
} else {
|
} else {
|
||||||
mergedOptions[key] = options[key];
|
mergedOptions[key] = options[key];
|
||||||
@@ -72,21 +62,11 @@ export function getMergedEmbeddingOptions(
|
|||||||
return mergedOptions;
|
return mergedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCleanedEmbeddingOptions(
|
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
|
||||||
options: any,
|
|
||||||
defaultOptions: any = defaultEmbeddingOptions
|
|
||||||
): any {
|
|
||||||
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
||||||
for (const key in cleanedOptions) {
|
for (const key in cleanedOptions) {
|
||||||
if (
|
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
|
||||||
typeof cleanedOptions[key] === 'object' &&
|
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||||
cleanedOptions[key] !== null &&
|
|
||||||
!Array.isArray(cleanedOptions[key])
|
|
||||||
) {
|
|
||||||
cleanedOptions[key] = getCleanedEmbeddingOptions(
|
|
||||||
cleanedOptions[key],
|
|
||||||
defaultOptions[key]
|
|
||||||
);
|
|
||||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||||
delete cleanedOptions[key];
|
delete cleanedOptions[key];
|
||||||
}
|
}
|
||||||
@@ -97,59 +77,4 @@ export function getCleanedEmbeddingOptions(
|
|||||||
return cleanedOptions;
|
return cleanedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
|
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
|
||||||
(basemap) => !['ordnanceSurvey'].includes(basemap)
|
|
||||||
);
|
|
||||||
|
|
||||||
export function getFilesFromEmbeddingOptions(options: EmbeddingOptions): string[] {
|
|
||||||
return options.files.concat(options.ids.map((id) => getURLForGoogleDriveFile(id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getURLForGoogleDriveFile(fileId: string): string {
|
|
||||||
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
|
||||||
let newOptions: any = {
|
|
||||||
token: PUBLIC_MAPBOX_TOKEN,
|
|
||||||
files: [],
|
|
||||||
ids: [],
|
|
||||||
};
|
|
||||||
if (options.has('state')) {
|
|
||||||
let state = JSON.parse(options.get('state')!);
|
|
||||||
if (state.ids) {
|
|
||||||
newOptions.ids.push(...state.ids);
|
|
||||||
}
|
|
||||||
if (state.urls) {
|
|
||||||
newOptions.files.push(...state.urls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.has('source')) {
|
|
||||||
let basemap = options.get('source')!;
|
|
||||||
if (basemap === 'satellite') {
|
|
||||||
newOptions.basemap = 'mapboxSatellite';
|
|
||||||
} else if (basemap === 'otm') {
|
|
||||||
newOptions.basemap = 'openTopoMap';
|
|
||||||
} else if (basemap === 'ohm') {
|
|
||||||
newOptions.basemap = 'openHikingMap';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.has('imperial')) {
|
|
||||||
newOptions.distanceUnits = 'imperial';
|
|
||||||
}
|
|
||||||
if (options.has('running')) {
|
|
||||||
newOptions.velocityUnits = 'pace';
|
|
||||||
}
|
|
||||||
if (options.has('distance')) {
|
|
||||||
newOptions.distanceMarkers = true;
|
|
||||||
}
|
|
||||||
if (options.has('direction')) {
|
|
||||||
newOptions.directionMarkers = true;
|
|
||||||
}
|
|
||||||
if (options.has('slope')) {
|
|
||||||
newOptions.elevation = {
|
|
||||||
fill: 'slope',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return newOptions;
|
|
||||||
}
|
|
||||||
@@ -1,347 +1,313 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import {
|
import {
|
||||||
Zap,
|
Zap,
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
Orbit,
|
Orbit,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
SquareActivity,
|
SquareActivity,
|
||||||
Coins,
|
Coins,
|
||||||
Milestone,
|
Milestone,
|
||||||
Video,
|
Video
|
||||||
} from '@lucide/svelte';
|
} from 'lucide-svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _ } from 'svelte-i18n';
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
getCleanedEmbeddingOptions,
|
getCleanedEmbeddingOptions,
|
||||||
getDefaultEmbeddingOptions,
|
getDefaultEmbeddingOptions
|
||||||
} from './Embedding';
|
} from './Embedding';
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
import Embedding from './Embedding.svelte';
|
import Embedding from './Embedding.svelte';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/stores';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
let options = getDefaultEmbeddingOptions();
|
let options = getDefaultEmbeddingOptions();
|
||||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||||
options.files = [
|
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];
|
let files = options.files[0];
|
||||||
$: {
|
$: if (files) {
|
||||||
let urls = files.split(',');
|
let urls = files.split(',');
|
||||||
urls = urls.filter((url) => url.length > 0);
|
urls = urls.filter((url) => url.length > 0);
|
||||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||||
options.files = urls;
|
options.files = urls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let driveIds = '';
|
|
||||||
$: {
|
|
||||||
let ids = driveIds.split(',');
|
|
||||||
ids = ids.filter((id) => id.length > 0);
|
|
||||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
|
||||||
options.ids = ids;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let manualCamera = false;
|
let manualCamera = false;
|
||||||
|
|
||||||
let zoom = '0';
|
let zoom = '0';
|
||||||
let lat = '0';
|
let lat = '0';
|
||||||
let lon = '0';
|
let lon = '0';
|
||||||
let bearing = '0';
|
let bearing = '0';
|
||||||
let pitch = '0';
|
let pitch = '0';
|
||||||
|
|
||||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||||
|
|
||||||
$: iframeOptions =
|
$: iframeOptions =
|
||||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||||
: options;
|
: options;
|
||||||
|
|
||||||
async function resizeMap() {
|
async function resizeMap() {
|
||||||
if ($map) {
|
if ($map) {
|
||||||
await tick();
|
await tick();
|
||||||
$map.resize();
|
$map.resize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (options.elevation.height || options.elevation.show) {
|
$: if (options.elevation.height || options.elevation.show) {
|
||||||
resizeMap();
|
resizeMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCamera() {
|
function updateCamera() {
|
||||||
if ($map) {
|
if ($map) {
|
||||||
let center = $map.getCenter();
|
let center = $map.getCenter();
|
||||||
lat = center.lat.toFixed(4);
|
lat = center.lat.toFixed(4);
|
||||||
lon = center.lng.toFixed(4);
|
lon = center.lng.toFixed(4);
|
||||||
zoom = $map.getZoom().toFixed(2);
|
zoom = $map.getZoom().toFixed(2);
|
||||||
bearing = $map.getBearing().toFixed(1);
|
bearing = $map.getBearing().toFixed(1);
|
||||||
pitch = $map.getPitch().toFixed(0);
|
pitch = $map.getPitch().toFixed(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map) {
|
$: if ($map) {
|
||||||
$map.on('moveend', updateCamera);
|
$map.on('moveend', updateCamera);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root id="embedding-playground">
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{i18n._('embedding.title')}</Card.Title>
|
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<fieldset class="flex flex-col gap-3">
|
<fieldset class="flex flex-col gap-3">
|
||||||
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||||
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
||||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||||
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
||||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
<Select.Root
|
||||||
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
|
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||||
<Select.Root
|
onSelectedChange={(selected) => {
|
||||||
selected={{
|
if (selected?.value) {
|
||||||
value: options.basemap,
|
options.basemap = selected?.value;
|
||||||
label: i18n._(`layers.label.${options.basemap}`),
|
}
|
||||||
}}
|
}}
|
||||||
onSelectedChange={(selected) => {
|
>
|
||||||
if (selected?.value) {
|
<Select.Trigger id="basemap" class="w-full h-8">
|
||||||
options.basemap = selected?.value;
|
<Select.Value />
|
||||||
}
|
</Select.Trigger>
|
||||||
}}
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
>
|
{#each allowedEmbeddingBasemaps as basemap}
|
||||||
<Select.Trigger id="basemap" class="w-full h-8">
|
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||||
<Select.Value />
|
{/each}
|
||||||
</Select.Trigger>
|
</Select.Content>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
</Select.Root>
|
||||||
{#each allowedEmbeddingBasemaps as basemap}
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Select.Item value={basemap}
|
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
||||||
>{i18n._(`layers.label.${basemap}`)}</Select.Item
|
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||||
>
|
</div>
|
||||||
{/each}
|
{#if options.elevation.show}
|
||||||
</Select.Content>
|
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||||
</Select.Root>
|
<Label class="flex flex-row items-center gap-2">
|
||||||
<div class="flex flex-row items-center gap-2">
|
{$_('embedding.height')}
|
||||||
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
|
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
|
||||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
</Label>
|
||||||
</div>
|
<div class="flex flex-row items-center gap-2">
|
||||||
{#if options.elevation.show}
|
<span class="shrink-0">
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
{$_('embedding.fill_by')}
|
||||||
<Label class="flex flex-row items-center gap-2">
|
</span>
|
||||||
{i18n._('embedding.height')}
|
<Select.Root
|
||||||
<Input
|
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||||
type="number"
|
onSelectedChange={(selected) => {
|
||||||
bind:value={options.elevation.height}
|
let value = selected?.value;
|
||||||
class="h-8 w-20"
|
if (value === 'none') {
|
||||||
/>
|
options.elevation.fill = undefined;
|
||||||
</Label>
|
} else if (value === 'slope' || value === 'surface') {
|
||||||
<div class="flex flex-row items-center gap-2">
|
options.elevation.fill = value;
|
||||||
<span class="shrink-0">
|
}
|
||||||
{i18n._('embedding.fill_by')}
|
}}
|
||||||
</span>
|
>
|
||||||
<Select.Root
|
<Select.Trigger class="grow h-8">
|
||||||
selected={{ value: 'none', label: i18n._('embedding.none') }}
|
<Select.Value />
|
||||||
onSelectedChange={(selected) => {
|
</Select.Trigger>
|
||||||
let value = selected?.value;
|
<Select.Content>
|
||||||
if (value === 'none') {
|
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||||
options.elevation.fill = undefined;
|
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
||||||
} else if (
|
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||||
value === 'slope' ||
|
</Select.Content>
|
||||||
value === 'surface' ||
|
</Select.Root>
|
||||||
value === 'highway'
|
</div>
|
||||||
) {
|
<div class="flex flex-row items-center gap-2">
|
||||||
options.elevation.fill = value;
|
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||||
}
|
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||||
}}
|
</div>
|
||||||
>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Select.Trigger class="grow h-8">
|
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||||
<Select.Value />
|
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||||
</Select.Trigger>
|
<Zap size="16" />
|
||||||
<Select.Content>
|
{$_('chart.show_speed')}
|
||||||
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
|
</Label>
|
||||||
>
|
</div>
|
||||||
<Select.Item value="surface"
|
<div class="flex flex-row items-center gap-2">
|
||||||
>{i18n._('quantities.surface')}</Select.Item
|
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||||
>
|
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||||
<Select.Item value="highway"
|
<HeartPulse size="16" />
|
||||||
>{i18n._('quantities.highway')}</Select.Item
|
{$_('chart.show_heartrate')}
|
||||||
>
|
</Label>
|
||||||
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
|
</div>
|
||||||
</Select.Content>
|
<div class="flex flex-row items-center gap-2">
|
||||||
</Select.Root>
|
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||||
</div>
|
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||||
<div class="flex flex-row items-center gap-2">
|
<Orbit size="16" />
|
||||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
{$_('chart.show_cadence')}
|
||||||
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Thermometer size="16" />
|
||||||
{i18n._('quantities.speed')}
|
{$_('chart.show_temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<SquareActivity size="16" />
|
||||||
{i18n._('quantities.heartrate')}
|
{$_('chart.show_power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
</div>
|
||||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
{/if}
|
||||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Orbit size="16" />
|
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||||
{i18n._('quantities.cadence')}
|
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||||
</Label>
|
<Coins size="16" />
|
||||||
</div>
|
{$_('menu.distance_markers')}
|
||||||
<div class="flex flex-row items-center gap-2">
|
</Label>
|
||||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
</div>
|
||||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Thermometer size="16" />
|
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||||
{i18n._('quantities.temperature')}
|
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||||
</Label>
|
<Milestone size="16" />
|
||||||
</div>
|
{$_('menu.direction_markers')}
|
||||||
<div class="flex flex-row items-center gap-2">
|
</Label>
|
||||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
</div>
|
||||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||||
<SquareActivity size="16" />
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{i18n._('quantities.power')}
|
{$_('menu.distance_units')}
|
||||||
</Label>
|
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||||
</div>
|
<div class="flex items-center space-x-2">
|
||||||
</div>
|
<RadioGroup.Item value="metric" id="metric" />
|
||||||
{/if}
|
<Label for="metric">{$_('menu.metric')}</Label>
|
||||||
<div class="flex flex-row items-center gap-2">
|
</div>
|
||||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
<div class="flex items-center space-x-2">
|
||||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
<RadioGroup.Item value="imperial" id="imperial" />
|
||||||
<Coins size="16" />
|
<Label for="imperial">{$_('menu.imperial')}</Label>
|
||||||
{i18n._('menu.distance_markers')}
|
</div>
|
||||||
</Label>
|
</RadioGroup.Root>
|
||||||
</div>
|
</Label>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
{$_('menu.velocity_units')}
|
||||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||||
<Milestone size="16" />
|
<div class="flex items-center space-x-2">
|
||||||
{i18n._('menu.direction_markers')}
|
<RadioGroup.Item value="speed" id="speed" />
|
||||||
</Label>
|
<Label for="speed">{$_('quantities.speed')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
<div class="flex items-center space-x-2">
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<RadioGroup.Item value="pace" id="pace" />
|
||||||
{i18n._('menu.distance_units')}
|
<Label for="pace">{$_('quantities.pace')}</Label>
|
||||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
</RadioGroup.Root>
|
||||||
<RadioGroup.Item value="metric" id="metric" />
|
</Label>
|
||||||
<Label for="metric">{i18n._('menu.metric')}</Label>
|
<Label class="flex flex-col items-start gap-2">
|
||||||
</div>
|
{$_('menu.temperature_units')}
|
||||||
<div class="flex items-center space-x-2">
|
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||||
<RadioGroup.Item value="imperial" id="imperial" />
|
<div class="flex items-center space-x-2">
|
||||||
<Label for="imperial">{i18n._('menu.imperial')}</Label>
|
<RadioGroup.Item value="celsius" id="celsius" />
|
||||||
</div>
|
<Label for="celsius">{$_('menu.celsius')}</Label>
|
||||||
<div class="flex items-center space-x-2">
|
</div>
|
||||||
<RadioGroup.Item value="nautical" id="nautical" />
|
<div class="flex items-center space-x-2">
|
||||||
<Label for="nautical">{i18n._('menu.nautical')}</Label>
|
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||||
</div>
|
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
||||||
</RadioGroup.Root>
|
</div>
|
||||||
</Label>
|
</RadioGroup.Root>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
</Label>
|
||||||
{i18n._('menu.velocity_units')}
|
</div>
|
||||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
<Label class="flex flex-col items-start gap-2">
|
||||||
<div class="flex items-center space-x-2">
|
{$_('menu.mode')}
|
||||||
<RadioGroup.Item value="speed" id="speed" />
|
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||||
<Label for="speed">{i18n._('quantities.speed')}</Label>
|
<div class="flex items-center space-x-2">
|
||||||
</div>
|
<RadioGroup.Item value="system" id="system" />
|
||||||
<div class="flex items-center space-x-2">
|
<Label for="system">{$_('menu.system')}</Label>
|
||||||
<RadioGroup.Item value="pace" id="pace" />
|
</div>
|
||||||
<Label for="pace">{i18n._('quantities.pace')}</Label>
|
<div class="flex items-center space-x-2">
|
||||||
</div>
|
<RadioGroup.Item value="light" id="light" />
|
||||||
</RadioGroup.Root>
|
<Label for="light">{$_('menu.light')}</Label>
|
||||||
</Label>
|
</div>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<div class="flex items-center space-x-2">
|
||||||
{i18n._('menu.temperature_units')}
|
<RadioGroup.Item value="dark" id="dark" />
|
||||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
<Label for="dark">{$_('menu.dark')}</Label>
|
||||||
<div class="flex items-center space-x-2">
|
</div>
|
||||||
<RadioGroup.Item value="celsius" id="celsius" />
|
</RadioGroup.Root>
|
||||||
<Label for="celsius">{i18n._('menu.celsius')}</Label>
|
</Label>
|
||||||
</div>
|
<div class="flex flex-col gap-3 p-3 border rounded-md">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||||
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
|
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||||
</div>
|
<Video size="16" />
|
||||||
</RadioGroup.Root>
|
{$_('embedding.manual_camera')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<p class="text-sm text-muted-foreground">
|
||||||
{i18n._('menu.mode')}
|
{$_('embedding.manual_camera_description')}
|
||||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
</p>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||||
<RadioGroup.Item value="system" id="system" />
|
<Label class="flex flex-col gap-1">
|
||||||
<Label for="system">{i18n._('menu.system')}</Label>
|
<span>{$_('embedding.latitude')}</span>
|
||||||
</div>
|
<span>{lat}</span>
|
||||||
<div class="flex items-center space-x-2">
|
</Label>
|
||||||
<RadioGroup.Item value="light" id="light" />
|
<Label class="flex flex-col gap-1">
|
||||||
<Label for="light">{i18n._('menu.light')}</Label>
|
<span>{$_('embedding.longitude')}</span>
|
||||||
</div>
|
<span>{lon}</span>
|
||||||
<div class="flex items-center space-x-2">
|
</Label>
|
||||||
<RadioGroup.Item value="dark" id="dark" />
|
<Label class="flex flex-col gap-1">
|
||||||
<Label for="dark">{i18n._('menu.dark')}</Label>
|
<span>{$_('embedding.zoom')}</span>
|
||||||
</div>
|
<span>{zoom}</span>
|
||||||
</RadioGroup.Root>
|
</Label>
|
||||||
</Label>
|
<Label class="flex flex-col gap-1">
|
||||||
<div class="flex flex-col gap-3 p-3 border rounded-md">
|
<span>{$_('embedding.bearing')}</span>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<span>{bearing}</span>
|
||||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
</Label>
|
||||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<Video size="16" />
|
<span>{$_('embedding.pitch')}</span>
|
||||||
{i18n._('embedding.manual_camera')}
|
<span>{pitch}</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
</div>
|
||||||
{i18n._('embedding.manual_camera_description')}
|
<Label>
|
||||||
</p>
|
{$_('embedding.preview')}
|
||||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<div class="relative h-[600px]">
|
||||||
<span>{i18n._('embedding.latitude')}</span>
|
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||||
<span>{lat}</span>
|
</div>
|
||||||
</Label>
|
<Label>
|
||||||
<Label class="flex flex-col gap-1">
|
{$_('embedding.code')}
|
||||||
<span>{i18n._('embedding.longitude')}</span>
|
</Label>
|
||||||
<span>{lon}</span>
|
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-col gap-1">
|
|
||||||
<span>{i18n._('embedding.zoom')}</span>
|
|
||||||
<span>{zoom}</span>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-col gap-1">
|
|
||||||
<span>{i18n._('embedding.bearing')}</span>
|
|
||||||
<span>{bearing}</span>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-col gap-1">
|
|
||||||
<span>{i18n._('embedding.pitch')}</span>
|
|
||||||
<span>{pitch}</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Label>
|
|
||||||
{i18n._('embedding.preview')}
|
|
||||||
</Label>
|
|
||||||
<div class="relative h-[600px]">
|
|
||||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
|
||||||
</div>
|
|
||||||
<Label>
|
|
||||||
{i18n._('embedding.code')}
|
|
||||||
</Label>
|
|
||||||
<pre
|
|
||||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
|
||||||
<code class="language-html">
|
<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;"/>`}
|
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
|
||||||
let {
|
export let files: string[];
|
||||||
files,
|
|
||||||
ids,
|
|
||||||
}: {
|
|
||||||
files: string[];
|
|
||||||
ids: string[];
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||||
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
|
href="{getURLForLanguage($locale, '/app')}?files={encodeURIComponent(JSON.stringify(files))}"
|
||||||
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
target="_blank"
|
||||||
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
|
||||||
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
|
||||||
: ''}"
|
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
{i18n._('menu.open_in')}
|
{$_('menu.open_in')}
|
||||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
import { Dialog } from 'bits-ui';
|
|
||||||
import {
|
|
||||||
exportAllFiles,
|
|
||||||
exportSelectedFiles,
|
|
||||||
ExportState,
|
|
||||||
exportState,
|
|
||||||
} from '$lib/components/export/utils.svelte';
|
|
||||||
import { currentTool } from '$lib/components/toolbar/tools';
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Zap,
|
|
||||||
Earth,
|
|
||||||
HeartPulse,
|
|
||||||
Orbit,
|
|
||||||
Thermometer,
|
|
||||||
SquareActivity,
|
|
||||||
} from '@lucide/svelte';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
import { GPXStatistics } from 'gpx';
|
|
||||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
|
||||||
import { selection } from '$lib/logic/selection';
|
|
||||||
import { gpxStatistics } from '$lib/logic/statistics';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let open = $derived(exportState.current !== ExportState.NONE);
|
|
||||||
let exportOptions: Record<string, boolean> = $state({
|
|
||||||
time: true,
|
|
||||||
hr: true,
|
|
||||||
cad: true,
|
|
||||||
atemp: true,
|
|
||||||
power: true,
|
|
||||||
extensions: false,
|
|
||||||
});
|
|
||||||
let hide: Record<string, boolean> = $derived.by(() => {
|
|
||||||
if (exportState.current === ExportState.NONE) {
|
|
||||||
return {
|
|
||||||
time: false,
|
|
||||||
hr: false,
|
|
||||||
cad: false,
|
|
||||||
atemp: false,
|
|
||||||
power: false,
|
|
||||||
extensions: false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let statistics = $gpxStatistics;
|
|
||||||
if (exportState.current === ExportState.ALL) {
|
|
||||||
statistics = Array.from(get(fileStateCollection).values())
|
|
||||||
.map((file) => file.statistics)
|
|
||||||
.reduce((acc, cur) => {
|
|
||||||
if (cur !== undefined) {
|
|
||||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, new GPXStatistics());
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
time: statistics.global.time.total === 0,
|
|
||||||
hr: statistics.global.hr.count === 0,
|
|
||||||
cad: statistics.global.cad.count === 0,
|
|
||||||
atemp: statistics.global.atemp.count === 0,
|
|
||||||
power: statistics.global.power.count === 0,
|
|
||||||
extensions: Object.keys(statistics.global.extensions).length === 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (open) {
|
|
||||||
currentTool.set(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog.Root
|
|
||||||
bind:open
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
exportState.current = ExportState.NONE;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger class="hidden" />
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Content
|
|
||||||
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
|
||||||
>
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span class="max-w-[80%] text-sm">
|
|
||||||
{i18n._('menu.support_message')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
|
||||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
|
||||||
{i18n._('menu.support_button')}
|
|
||||||
<span class="ml-2">🙏</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="grow"
|
|
||||||
onclick={() => {
|
|
||||||
if (exportState.current === ExportState.SELECTION) {
|
|
||||||
exportSelectedFiles(exclude);
|
|
||||||
} else if (exportState.current === ExportState.ALL) {
|
|
||||||
exportAllFiles(exclude);
|
|
||||||
}
|
|
||||||
open = false;
|
|
||||||
exportState.current = ExportState.NONE;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size="16" class="mr-1" />
|
|
||||||
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
|
|
||||||
{i18n._('menu.download_file')}
|
|
||||||
{:else}
|
|
||||||
{i18n._('menu.download_files')}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
|
|
||||||
(v) => !v
|
|
||||||
)
|
|
||||||
? ''
|
|
||||||
: 'hidden'}"
|
|
||||||
>
|
|
||||||
<div class="w-full flex flex-row items-center gap-3">
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<Label class="shrink-0">
|
|
||||||
{i18n._('menu.export_options')}
|
|
||||||
</Label>
|
|
||||||
<div class="grow">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
|
||||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
|
||||||
<Zap size="16" />
|
|
||||||
{i18n._('quantities.time')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
|
||||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
|
||||||
<HeartPulse size="16" />
|
|
||||||
{i18n._('quantities.heartrate')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
|
||||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
|
||||||
<Orbit size="16" />
|
|
||||||
{i18n._('quantities.cadence')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
|
||||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
|
||||||
<Thermometer size="16" />
|
|
||||||
{i18n._('quantities.temperature')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
|
||||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
|
||||||
<SquareActivity size="16" />
|
|
||||||
{i18n._('quantities.power')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
{i18n._('quantities.osm_extensions')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { selection } from '$lib/logic/selection';
|
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
|
||||||
import { settings } from '$lib/logic/settings';
|
|
||||||
import { buildGPX, type GPXFile } from 'gpx';
|
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
export enum ExportState {
|
|
||||||
NONE,
|
|
||||||
SELECTION,
|
|
||||||
ALL,
|
|
||||||
}
|
|
||||||
export const exportState = $state({
|
|
||||||
current: ExportState.NONE,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function exportFiles(fileIds: string[], exclude: string[]) {
|
|
||||||
if (fileIds.length > 1) {
|
|
||||||
await exportFilesAsZip(fileIds, exclude);
|
|
||||||
} else {
|
|
||||||
const firstFileId = fileIds.at(0);
|
|
||||||
if (firstFileId != null) {
|
|
||||||
const file = fileStateCollection.getFile(firstFileId);
|
|
||||||
if (file) {
|
|
||||||
exportFile(file, exclude);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportSelectedFiles(exclude: string[]) {
|
|
||||||
const fileIds: string[] = [];
|
|
||||||
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
|
|
||||||
fileIds.push(fileId);
|
|
||||||
});
|
|
||||||
await exportFiles(fileIds, exclude);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportAllFiles(exclude: string[]) {
|
|
||||||
await exportFiles(get(settings.fileOrder), exclude);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportFile(file: GPXFile, exclude: string[]) {
|
|
||||||
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
|
|
||||||
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
|
|
||||||
const zip = new JSZip();
|
|
||||||
for (const fileId of fileIds) {
|
|
||||||
const file = fileStateCollection.getFile(fileId);
|
|
||||||
if (file) {
|
|
||||||
const gpx = buildGPX(file, exclude);
|
|
||||||
let filename = file.metadata.name;
|
|
||||||
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
|
||||||
filename = file.metadata.name + `-${i}`;
|
|
||||||
}
|
|
||||||
zip.file(filename + '.gpx', gpx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(zip.files).length > 0) {
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
FileSaver.saveAs(blob, 'gpx-files.zip');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +1,89 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNode from './FileListNode.svelte';
|
||||||
import { setContext } from 'svelte';
|
import { fileObservers, settings } from '$lib/db';
|
||||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './file-list';
|
import { setContext } from 'svelte';
|
||||||
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
import { _ } from 'svelte-i18n';
|
||||||
import { createFile, pasteSelection } from '$lib/logic/file-actions';
|
import { createFile } from '$lib/stores';
|
||||||
import { selection, copied } from '$lib/logic/selection';
|
|
||||||
|
|
||||||
let {
|
export let orientation: 'vertical' | 'horizontal';
|
||||||
orientation,
|
export let recursive = false;
|
||||||
recursive = false,
|
|
||||||
class: className = '',
|
|
||||||
style = '',
|
|
||||||
}: {
|
|
||||||
orientation: 'vertical' | 'horizontal';
|
|
||||||
recursive?: boolean;
|
|
||||||
class?: string;
|
|
||||||
style?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
setContext('orientation', orientation);
|
setContext('orientation', orientation);
|
||||||
setContext('recursive', recursive);
|
setContext('recursive', recursive);
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
const { verticalFileView } = settings;
|
||||||
|
|
||||||
// treeFileView.subscribe(($vertical) => {
|
verticalFileView.subscribe(($vertical) => {
|
||||||
// if ($vertical) {
|
if ($vertical) {
|
||||||
// selection.update(($selection) => {
|
selection.update(($selection) => {
|
||||||
// $selection.forEach((item) => {
|
$selection.forEach((item) => {
|
||||||
// if ($selection.hasAnyChildren(item, false)) {
|
if ($selection.hasAnyChildren(item, false)) {
|
||||||
// $selection.toggle(item);
|
$selection.toggle(item);
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
// return $selection;
|
return $selection;
|
||||||
// });
|
});
|
||||||
// } else {
|
} else {
|
||||||
// selection.update(($selection) => {
|
selection.update(($selection) => {
|
||||||
// $selection.forEach((item) => {
|
$selection.forEach((item) => {
|
||||||
// if (!(item instanceof ListFileItem)) {
|
if (!(item instanceof ListFileItem)) {
|
||||||
// $selection.toggle(item);
|
$selection.toggle(item);
|
||||||
// $selection.set(new ListFileItem(item.getFileId()), true);
|
$selection.set(new ListFileItem(item.getFileId()), true);
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
// return $selection;
|
return $selection;
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||||
{orientation}
|
{orientation}
|
||||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
||||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex {orientation === 'vertical'
|
class="flex {orientation === 'vertical'
|
||||||
? 'flex-col py-1 pl-1 min-h-screen'
|
? 'flex-col py-1 pl-1 min-h-screen'
|
||||||
: 'flex-row'} {className ?? ''}"
|
: 'flex-row'} {$$props.class ?? ''}"
|
||||||
{style}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
|
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger class="grow" />
|
<ContextMenu.Trigger class="grow" />
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item onclick={createFile}>
|
<ContextMenu.Item on:click={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" class="mr-1" />
|
||||||
{i18n._('menu.new_file')}
|
{$_('menu.new_file')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
||||||
onclick={() => selection.selectAll()}
|
<FileStack size="16" class="mr-1" />
|
||||||
disabled={$fileStateCollection.size === 0}
|
{$_('menu.select_all')}
|
||||||
>
|
<Shortcut key="A" ctrl={true} />
|
||||||
<FileStack size="16" class="mr-1" />
|
</ContextMenu.Item>
|
||||||
{i18n._('menu.select_all')}
|
<ContextMenu.Separator />
|
||||||
<Shortcut key="A" ctrl={true} />
|
<ContextMenu.Item
|
||||||
</ContextMenu.Item>
|
disabled={$copied === undefined ||
|
||||||
<ContextMenu.Separator />
|
$copied.length === 0 ||
|
||||||
<ContextMenu.Item
|
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||||
disabled={$copied === undefined ||
|
on:click={pasteSelection}
|
||||||
$copied.length === 0 ||
|
>
|
||||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
<ClipboardPaste size="16" class="mr-1" />
|
||||||
onclick={pasteSelection}
|
{$_('menu.paste')}
|
||||||
>
|
<Shortcut key="V" ctrl={true} />
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
</ContextMenu.Item>
|
||||||
{i18n._('menu.paste')}
|
</ContextMenu.Content>
|
||||||
<Shortcut key="V" ctrl={true} />
|
</ContextMenu.Root>
|
||||||
</ContextMenu.Item>
|
{/if}
|
||||||
</ContextMenu.Content>
|
</div>
|
||||||
</ContextMenu.Root>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
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 {
|
export enum ListLevel {
|
||||||
ROOT,
|
ROOT,
|
||||||
FILE,
|
FILE,
|
||||||
TRACK,
|
TRACK,
|
||||||
SEGMENT,
|
SEGMENT,
|
||||||
WAYPOINTS,
|
WAYPOINTS,
|
||||||
WAYPOINT,
|
WAYPOINT
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||||
@@ -13,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
|||||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||||
@@ -22,11 +28,10 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
|||||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
[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 {
|
export abstract class ListItem {
|
||||||
[x: string]: any;
|
|
||||||
level: ListLevel;
|
level: ListLevel;
|
||||||
|
|
||||||
constructor(level: ListLevel) {
|
constructor(level: ListLevel) {
|
||||||
@@ -316,3 +321,120 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
|||||||
items.reverse();
|
items.reverse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
||||||
|
if (fromItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortItems(fromItems, false);
|
||||||
|
sortItems(toItems, false);
|
||||||
|
|
||||||
|
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
|
||||||
|
fromItems.forEach((item) => {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
if (file) {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
context.push(file.wpt[item.getWaypointIndex()].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remove && !(fromParent instanceof ListRootItem)) {
|
||||||
|
sortItems(fromItems, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = [fromParent.getFileId(), toParent.getFileId()];
|
||||||
|
let callbacks = [
|
||||||
|
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||||
|
fromItems.forEach((item) => {
|
||||||
|
if (item instanceof ListTrackItem) {
|
||||||
|
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
||||||
|
} else if (item instanceof ListTrackSegmentItem) {
|
||||||
|
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) {
|
||||||
|
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||||
|
toItems.forEach((item, i) => {
|
||||||
|
if (item instanceof ListTrackItem) {
|
||||||
|
if (context[i] instanceof Track) {
|
||||||
|
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]]
|
||||||
|
})]);
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
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]]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fromParent instanceof ListRootItem) {
|
||||||
|
files = [];
|
||||||
|
callbacks = [];
|
||||||
|
} else if (!remove) {
|
||||||
|
files.splice(0, 1);
|
||||||
|
callbacks.splice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||||
|
toItems.forEach((item, i) => {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
if (context[i] instanceof GPXFile) {
|
||||||
|
let newFile = context[i];
|
||||||
|
if (remove) {
|
||||||
|
files.delete(newFile._data.id);
|
||||||
|
}
|
||||||
|
newFile._data.id = item.getFileId();
|
||||||
|
files.set(item.getFileId(), freeze(newFile));
|
||||||
|
} else if (context[i] instanceof Track) {
|
||||||
|
let newFile = newGPXFile();
|
||||||
|
newFile._data.id = item.getFileId();
|
||||||
|
if (context[i].name) {
|
||||||
|
newFile.metadata.name = context[i].name;
|
||||||
|
}
|
||||||
|
newFile.replaceTracks(0, 0, [context[i]]);
|
||||||
|
files.set(item.getFileId(), freeze(newFile));
|
||||||
|
} else if (context[i] instanceof TrackSegment) {
|
||||||
|
let newFile = newGPXFile();
|
||||||
|
newFile._data.id = item.getFileId();
|
||||||
|
newFile.replaceTracks(0, 0, [new Track({
|
||||||
|
trkseg: [context[i]]
|
||||||
|
})]);
|
||||||
|
files.set(item.getFileId(), freeze(newFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, context);
|
||||||
|
|
||||||
|
selection.update(($selection) => {
|
||||||
|
$selection.clear();
|
||||||
|
toItems.forEach((item) => {
|
||||||
|
$selection.set(item, true);
|
||||||
|
});
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,95 +1,83 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
GPXFile,
|
GPXFile,
|
||||||
Track,
|
Track,
|
||||||
TrackSegment,
|
TrackSegment,
|
||||||
Waypoint,
|
Waypoint,
|
||||||
type AnyGPXTreeElement,
|
type AnyGPXTreeElement,
|
||||||
type GPXTreeElement,
|
type GPXTreeElement
|
||||||
} from 'gpx';
|
} from 'gpx';
|
||||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||||
import { type Readable } from 'svelte/store';
|
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
||||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
import { get, type Readable } from 'svelte/store';
|
||||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
import { getContext } from 'svelte';
|
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||||
import {
|
import { afterUpdate, getContext } from 'svelte';
|
||||||
ListFileItem,
|
import {
|
||||||
ListTrackSegmentItem,
|
ListFileItem,
|
||||||
ListWaypointItem,
|
ListTrackSegmentItem,
|
||||||
ListWaypointsItem,
|
ListWaypointItem,
|
||||||
type ListItem,
|
ListWaypointsItem,
|
||||||
type ListTrackItem,
|
type ListItem,
|
||||||
} from './file-list';
|
type ListTrackItem
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
} from './FileList';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
import { selection } from './Selection';
|
||||||
import { selection } from '$lib/logic/selection';
|
|
||||||
|
|
||||||
let {
|
export let node:
|
||||||
node,
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
item,
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
}: {
|
| Waypoint[]
|
||||||
node:
|
| Waypoint;
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
export let item: ListItem;
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
|
||||||
| Waypoint[]
|
|
||||||
| Waypoint;
|
|
||||||
item: ListItem;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
|
|
||||||
let collapsible: CollapsibleTreeNode | undefined = $state();
|
let collapsible: CollapsibleTreeNode;
|
||||||
|
|
||||||
let label = $derived(
|
$: label =
|
||||||
node instanceof GPXFile && item instanceof ListFileItem
|
node instanceof GPXFile && item instanceof ListFileItem
|
||||||
? node.metadata.name
|
? node.metadata.name
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
|
||||||
: node instanceof TrackSegment
|
: node instanceof TrackSegment
|
||||||
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||||
: node instanceof Waypoint
|
: node instanceof Waypoint
|
||||||
? (node.name ??
|
? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
|
||||||
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
? $_('gpx.waypoints')
|
||||||
? i18n._('gpx.waypoints')
|
: '';
|
||||||
: ''
|
|
||||||
);
|
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
const { verticalFileView } = settings;
|
||||||
|
|
||||||
function openIfSelectedChild() {
|
function openIfSelectedChild() {
|
||||||
if (collapsible && treeFileView.value && $selection.hasAnyChildren(item, false)) {
|
if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
|
||||||
collapsible.openNode();
|
collapsible.openNode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($selection) {
|
if ($selection) {
|
||||||
openIfSelectedChild();
|
openIfSelectedChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
// afterUpdate(openIfSelectedChild);
|
afterUpdate(openIfSelectedChild);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if node instanceof Map}
|
{#if node instanceof Map}
|
||||||
<FileListNodeContent {node} {item} />
|
<FileListNodeContent {node} {item} />
|
||||||
{:else if node instanceof TrackSegment}
|
{:else if node instanceof TrackSegment}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{:else if node instanceof Waypoint}
|
{:else if node instanceof Waypoint}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{:else if recursive}
|
{:else if recursive}
|
||||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||||
{#snippet trigger()}
|
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<div slot="content" class="ml-2">
|
||||||
{/snippet}
|
{#key node}
|
||||||
{#snippet content()}
|
<FileListNodeContent {node} {item} />
|
||||||
<div class="ml-2">
|
{/key}
|
||||||
{#key node}
|
</div>
|
||||||
<FileListNodeContent {node} {item} />
|
</CollapsibleTreeNode>
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</CollapsibleTreeNode>
|
|
||||||
{:else}
|
{:else}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,379 +1,364 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
let dragging: Writable<ListLevel | null> = writable(null);
|
let dragging: Writable<ListLevel | null> = writable(null);
|
||||||
|
|
||||||
let updating = false;
|
let updating = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||||
import { getContext, onDestroy, onMount } from 'svelte';
|
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
import Sortable from 'sortablejs/Sortable';
|
||||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||||
import {
|
import FileListNode from './FileListNode.svelte';
|
||||||
ListFileItem,
|
import {
|
||||||
ListLevel,
|
ListFileItem,
|
||||||
ListRootItem,
|
ListLevel,
|
||||||
ListWaypointsItem,
|
ListRootItem,
|
||||||
allowedMoves,
|
ListWaypointsItem,
|
||||||
type ListItem,
|
allowedMoves,
|
||||||
} from './file-list';
|
moveItems,
|
||||||
import { isMac } from '$lib/utils';
|
type ListItem
|
||||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
} from './FileList';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { selection } from './Selection';
|
||||||
import { getFileIds, moveItems } from '$lib/logic/file-actions';
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
let {
|
export let node:
|
||||||
node,
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
item,
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
waypointRoot = false,
|
| Waypoint;
|
||||||
}: {
|
export let item: ListItem;
|
||||||
node:
|
export let waypointRoot: boolean = false;
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
|
||||||
| Waypoint;
|
|
||||||
item: ListItem;
|
|
||||||
waypointRoot?: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
let elements: { [id: string]: HTMLElement } = {};
|
let elements: { [id: string]: HTMLElement } = {};
|
||||||
let sortableLevel: ListLevel =
|
let sortableLevel: ListLevel =
|
||||||
node instanceof Map
|
node instanceof Map
|
||||||
? ListLevel.FILE
|
? ListLevel.FILE
|
||||||
: node instanceof GPXFile
|
: node instanceof GPXFile
|
||||||
? waypointRoot
|
? waypointRoot
|
||||||
? ListLevel.WAYPOINTS
|
? ListLevel.WAYPOINTS
|
||||||
: item instanceof ListWaypointsItem
|
: item instanceof ListWaypointsItem
|
||||||
? ListLevel.WAYPOINT
|
? ListLevel.WAYPOINT
|
||||||
: ListLevel.TRACK
|
: ListLevel.TRACK
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? ListLevel.SEGMENT
|
? ListLevel.SEGMENT
|
||||||
: ListLevel.WAYPOINT;
|
: ListLevel.WAYPOINT;
|
||||||
let sortable: Sortable;
|
let sortable: Sortable;
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let lastUpdateStart = 0;
|
let lastUpdateStart = 0;
|
||||||
function updateToSelection(e) {
|
function updateToSelection(e) {
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdateStart = Date.now();
|
lastUpdateStart = Date.now();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (Date.now() - lastUpdateStart >= 40) {
|
if (Date.now() - lastUpdateStart >= 40) {
|
||||||
if (updating) {
|
if (updating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updating = true;
|
updating = true;
|
||||||
// Sortable updates selection
|
// Sortable updates selection
|
||||||
let changed = getChangedIds();
|
let changed = getChangedIds();
|
||||||
if (changed.length > 0) {
|
if (changed.length > 0) {
|
||||||
selection.update(($selection) => {
|
selection.update(($selection) => {
|
||||||
$selection.clear();
|
$selection.clear();
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
Object.entries(elements).forEach(([id, element]) => {
|
||||||
$selection.set(
|
$selection.set(
|
||||||
item.extend(getRealId(id)),
|
item.extend(getRealId(id)),
|
||||||
element.classList.contains('sortable-selected')
|
element.classList.contains('sortable-selected')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.originalEvent &&
|
e.originalEvent &&
|
||||||
!(
|
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||||
e.originalEvent.ctrlKey ||
|
($selection.size > 1 || !$selection.has(item.extend(getRealId(changed[0]))))
|
||||||
e.originalEvent.metaKey ||
|
) {
|
||||||
e.originalEvent.shiftKey
|
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||||
) &&
|
$selection.clear();
|
||||||
($selection.size > 1 ||
|
$selection.set(item.extend(getRealId(changed[0])), true);
|
||||||
!$selection.has(item.extend(getRealId(changed[0]))))
|
}
|
||||||
) {
|
|
||||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
|
||||||
$selection.clear();
|
|
||||||
$selection.set(item.extend(getRealId(changed[0])), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $selection;
|
return $selection;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updating = false;
|
updating = false;
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFromSelection() {
|
function updateFromSelection() {
|
||||||
if (destroyed || updating) {
|
if (destroyed || updating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updating = true;
|
updating = true;
|
||||||
// Selection updates sortable
|
// Selection updates sortable
|
||||||
let changed = getChangedIds();
|
let changed = getChangedIds();
|
||||||
for (let id of changed) {
|
for (let id of changed) {
|
||||||
let element = elements[id];
|
let element = elements[id];
|
||||||
if (element) {
|
if (element) {
|
||||||
if ($selection.has(item.extend(id))) {
|
if ($selection.has(item.extend(id))) {
|
||||||
Sortable.utils.select(element);
|
Sortable.utils.select(element);
|
||||||
element.scrollIntoView({
|
element.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'nearest',
|
block: 'nearest'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Sortable.utils.deselect(element);
|
Sortable.utils.deselect(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updating = false;
|
updating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($selection) {
|
$: if ($selection) {
|
||||||
updateFromSelection();
|
updateFromSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncFileOrder(order: string[]) {
|
const { fileOrder } = settings;
|
||||||
if (!sortable || sortableLevel !== ListLevel.FILE) {
|
function syncFileOrder() {
|
||||||
return;
|
if (!sortable || sortableLevel !== ListLevel.FILE) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentOrder = sortable.toArray();
|
const currentOrder = sortable.toArray();
|
||||||
if (currentOrder.length !== order.length) {
|
if (currentOrder.length !== $fileOrder.length) {
|
||||||
sortable.sort(order);
|
sortable.sort($fileOrder);
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < currentOrder.length; i++) {
|
for (let i = 0; i < currentOrder.length; i++) {
|
||||||
if (currentOrder[i] !== order[i]) {
|
if (currentOrder[i] !== $fileOrder[i]) {
|
||||||
sortable.sort(order);
|
sortable.sort($fileOrder);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fileOrder } = settings;
|
$: if ($fileOrder) {
|
||||||
$effect(() => syncFileOrder(fileOrder.value));
|
syncFileOrder();
|
||||||
|
}
|
||||||
|
|
||||||
function createSortable() {
|
function createSortable() {
|
||||||
sortable = Sortable.create(container, {
|
sortable = Sortable.create(container, {
|
||||||
group: {
|
group: {
|
||||||
name: sortableLevel,
|
name: sortableLevel,
|
||||||
pull: allowedMoves[sortableLevel],
|
pull: allowedMoves[sortableLevel],
|
||||||
put: true,
|
put: true
|
||||||
},
|
},
|
||||||
direction: orientation,
|
direction: orientation,
|
||||||
forceAutoScrollFallback: true,
|
forceAutoScrollFallback: true,
|
||||||
multiDrag: true,
|
multiDrag: true,
|
||||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
multiDragKey: 'Meta',
|
||||||
avoidImplicitDeselect: true,
|
avoidImplicitDeselect: true,
|
||||||
onSelect: updateToSelection,
|
onSelect: updateToSelection,
|
||||||
onDeselect: updateToSelection,
|
onDeselect: updateToSelection,
|
||||||
onStart: () => {
|
onStart: () => {
|
||||||
dragging.set(sortableLevel);
|
dragging.set(sortableLevel);
|
||||||
},
|
},
|
||||||
onEnd: () => {
|
onEnd: () => {
|
||||||
dragging.set(null);
|
dragging.set(null);
|
||||||
},
|
},
|
||||||
onSort: (e) => {
|
onSort: (e) => {
|
||||||
if (sortableLevel === ListLevel.FILE) {
|
if (sortableLevel === ListLevel.FILE) {
|
||||||
let newFileOrder = sortable.toArray();
|
let newFileOrder = sortable.toArray();
|
||||||
if (newFileOrder.length !== fileOrder.value.length) {
|
if (newFileOrder.length !== get(fileOrder).length) {
|
||||||
fileOrder.value = newFileOrder;
|
fileOrder.set(newFileOrder);
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < newFileOrder.length; i++) {
|
for (let i = 0; i < newFileOrder.length; i++) {
|
||||||
if (newFileOrder[i] !== fileOrder.value[i]) {
|
if (newFileOrder[i] !== get(fileOrder)[i]) {
|
||||||
fileOrder.value = newFileOrder;
|
fileOrder.set(newFileOrder);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fromItem = Sortable.get(e.from)._item;
|
let fromItem = Sortable.get(e.from)._item;
|
||||||
let toItem = Sortable.get(e.to)._item;
|
let toItem = Sortable.get(e.to)._item;
|
||||||
|
|
||||||
if (item === toItem && !(fromItem instanceof ListRootItem)) {
|
if (item === toItem && !(fromItem instanceof ListRootItem)) {
|
||||||
// Event is triggered on source and destination list, only handle it once
|
// Event is triggered on source and destination list, only handle it once
|
||||||
let fromItems = [];
|
let fromItems = [];
|
||||||
let toItems = [];
|
let toItems = [];
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot) {
|
if (Sortable.get(e.from)._waypointRoot) {
|
||||||
fromItems = [fromItem.extend('waypoints')];
|
fromItems = [fromItem.extend('waypoints')];
|
||||||
} else {
|
} else {
|
||||||
let oldIndices: number[] =
|
let oldIndices: number[] =
|
||||||
e.oldIndicies.length > 0
|
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
|
||||||
? e.oldIndicies.map((i) => i.index)
|
oldIndices = oldIndices.filter((i) => i >= 0);
|
||||||
: [e.oldIndex];
|
oldIndices.sort((a, b) => a - b);
|
||||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
|
||||||
oldIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
|
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
|
||||||
toItems = [toItem.extend('waypoints')];
|
toItems = [toItem.extend('waypoints')];
|
||||||
} else {
|
} else {
|
||||||
if (Sortable.get(e.to)._waypointRoot) {
|
if (Sortable.get(e.to)._waypointRoot) {
|
||||||
toItem = toItem.extend('waypoints');
|
toItem = toItem.extend('waypoints');
|
||||||
}
|
}
|
||||||
|
|
||||||
let newIndices: number[] =
|
let newIndices: number[] =
|
||||||
e.newIndicies.length > 0
|
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
|
||||||
? e.newIndicies.map((i) => i.index)
|
newIndices = newIndices.filter((i) => i >= 0);
|
||||||
: [e.newIndex];
|
newIndices.sort((a, b) => a - b);
|
||||||
newIndices = newIndices.filter((i) => i >= 0);
|
|
||||||
newIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
if (toItem instanceof ListRootItem) {
|
if (toItem instanceof ListRootItem) {
|
||||||
let newFileIds = getFileIds(newIndices.length);
|
let newFileIds = getFileIds(newIndices.length);
|
||||||
toItems = newIndices.map((i, index) => {
|
toItems = newIndices.map((i, index) => {
|
||||||
fileOrder.value.splice(i, 0, newFileIds[index]);
|
$fileOrder.splice(i, 0, newFileIds[index]);
|
||||||
return item.extend(newFileIds[index]);
|
return item.extend(newFileIds[index]);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toItems = newIndices.map((i) => toItem.extend(i));
|
toItems = newIndices.map((i) => toItem.extend(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moveItems(fromItem, toItem, fromItems, toItems);
|
moveItems(fromItem, toItem, fromItems, toItems);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
Object.defineProperty(sortable, '_item', {
|
Object.defineProperty(sortable, '_item', {
|
||||||
value: item,
|
value: item,
|
||||||
writable: true,
|
writable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(sortable, '_waypointRoot', {
|
Object.defineProperty(sortable, '_waypointRoot', {
|
||||||
value: waypointRoot,
|
value: waypointRoot,
|
||||||
writable: true,
|
writable: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createSortable();
|
createSortable();
|
||||||
destroyed = false;
|
destroyed = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
elements = {};
|
elements = {};
|
||||||
container.childNodes.forEach((element) => {
|
container.childNodes.forEach((element) => {
|
||||||
if (element instanceof HTMLElement) {
|
if (element instanceof HTMLElement) {
|
||||||
let attr = element.getAttribute('data-id');
|
let attr = element.getAttribute('data-id');
|
||||||
if (attr) {
|
if (attr) {
|
||||||
if (node instanceof Map && !node.has(attr)) {
|
if (node instanceof Map && !node.has(attr)) {
|
||||||
element.remove();
|
element.remove();
|
||||||
} else {
|
} else {
|
||||||
elements[attr] = element;
|
elements[attr] = element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
syncFileOrder();
|
syncFileOrder();
|
||||||
updateFromSelection();
|
updateFromSelection();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getChangedIds() {
|
function getChangedIds() {
|
||||||
let changed: (string | number)[] = [];
|
let changed: (string | number)[] = [];
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
Object.entries(elements).forEach(([id, element]) => {
|
||||||
let realId = getRealId(id);
|
let realId = getRealId(id);
|
||||||
let realItem = item.extend(realId);
|
let realItem = item.extend(realId);
|
||||||
let inSelection = get(selection).has(realItem);
|
let inSelection = get(selection).has(realItem);
|
||||||
let isSelected = element.classList.contains('sortable-selected');
|
let isSelected = element.classList.contains('sortable-selected');
|
||||||
if (inSelection !== isSelected) {
|
if (inSelection !== isSelected) {
|
||||||
changed.push(realId);
|
changed.push(realId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRealId(id: string | number) {
|
function getRealId(id: string | number) {
|
||||||
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
|
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
|
||||||
? id
|
? id
|
||||||
: parseInt(id);
|
: parseInt(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
|
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="sortable {orientation} flex {orientation === 'vertical'
|
class="sortable {orientation} flex {orientation === 'vertical'
|
||||||
? 'flex-col'
|
? 'flex-col'
|
||||||
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
|
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
|
||||||
>
|
>
|
||||||
{#if node instanceof Map}
|
{#if node instanceof Map}
|
||||||
{#each node as [fileId, file] (fileId)}
|
{#each node as [fileId, file] (fileId)}
|
||||||
<div data-id={fileId}>
|
<div data-id={fileId}>
|
||||||
<FileListNodeStore {file} />
|
<FileListNodeStore {file} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if node instanceof GPXFile}
|
{:else if node instanceof GPXFile}
|
||||||
{#if item instanceof ListWaypointsItem}
|
{#if item instanceof ListWaypointsItem}
|
||||||
{#each node.wpt as wpt, i (wpt)}
|
{#each node.wpt as wpt, i (wpt)}
|
||||||
<div data-id={i} class="ml-1">
|
<div data-id={i} class="ml-1">
|
||||||
<FileListNode node={wpt} item={item.extend(i)} />
|
<FileListNode node={wpt} item={item.extend(i)} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if waypointRoot}
|
{:else if waypointRoot}
|
||||||
{#if node.wpt.length > 0}
|
{#if node.wpt.length > 0}
|
||||||
<div data-id="waypoints">
|
<div data-id="waypoints">
|
||||||
<FileListNode {node} item={item.extend('waypoints')} />
|
<FileListNode {node} item={item.extend('waypoints')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each node.children as child, i (child)}
|
{#each node.children as child, i (child)}
|
||||||
<div data-id={i}>
|
<div data-id={i}>
|
||||||
<FileListNode node={child} item={item.extend(i)} />
|
<FileListNode node={child} item={item.extend(i)} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if node instanceof Track}
|
{:else if node instanceof Track}
|
||||||
{#each node.children as child, i (child)}
|
{#each node.children as child, i (child)}
|
||||||
<div data-id={i} class="ml-1">
|
<div data-id={i} class="ml-1">
|
||||||
<FileListNode node={child} item={item.extend(i)} />
|
<FileListNode node={child} item={item.extend(i)} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
||||||
{#if !waypointRoot}
|
{#if !waypointRoot}
|
||||||
<svelte:self {node} {item} waypointRoot={true} />
|
<svelte:self {node} {item} waypointRoot={true} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
@reference "../../../app.css";
|
.sortable > div {
|
||||||
|
@apply rounded-md;
|
||||||
|
@apply h-fit;
|
||||||
|
@apply leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
.sortable > div {
|
.vertical :global(button) {
|
||||||
@apply rounded-md;
|
@apply hover:bg-muted;
|
||||||
@apply h-fit;
|
}
|
||||||
@apply leading-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical :global(button) {
|
.vertical :global(.sortable-selected button) {
|
||||||
@apply hover:bg-muted;
|
@apply hover:bg-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(.sortable-selected button) {
|
.vertical :global(.sortable-selected) {
|
||||||
@apply hover:bg-accent;
|
@apply bg-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(.sortable-selected) {
|
.horizontal :global(button) {
|
||||||
@apply bg-accent;
|
@apply bg-accent;
|
||||||
}
|
@apply hover:bg-muted;
|
||||||
|
}
|
||||||
|
|
||||||
.horizontal :global(button) {
|
.horizontal :global(.sortable-selected button) {
|
||||||
@apply bg-accent;
|
@apply bg-background;
|
||||||
@apply hover:bg-muted;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.sortable-selected button) {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,335 +1,321 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import {
|
import { dbUtils, getFile } from '$lib/db';
|
||||||
Copy,
|
import {
|
||||||
Info,
|
Copy,
|
||||||
MapPin,
|
Info,
|
||||||
PaintBucket,
|
MapPin,
|
||||||
Plus,
|
PaintBucket,
|
||||||
Trash2,
|
Plus,
|
||||||
Waypoints,
|
Trash2,
|
||||||
Eye,
|
Waypoints,
|
||||||
EyeOff,
|
Eye,
|
||||||
ClipboardCopy,
|
EyeOff,
|
||||||
ClipboardPaste,
|
ClipboardCopy,
|
||||||
Maximize,
|
ClipboardPaste,
|
||||||
Scissors,
|
Scissors,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX,
|
FileX
|
||||||
} from '@lucide/svelte';
|
} from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListLevel,
|
ListLevel,
|
||||||
ListTrackItem,
|
ListTrackItem,
|
||||||
ListWaypointItem,
|
ListWaypointItem,
|
||||||
allowedPastes,
|
allowedPastes,
|
||||||
type ListItem,
|
type ListItem
|
||||||
} from './file-list';
|
} from './FileList';
|
||||||
import { getContext } from 'svelte';
|
import {
|
||||||
import { get } from 'svelte/store';
|
copied,
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
copySelection,
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
cut,
|
||||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
cutSelection,
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
pasteSelection,
|
||||||
import StyleDialog from './style/StyleDialog.svelte';
|
selectAll,
|
||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
selectItem,
|
||||||
import { waypointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
|
selection
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
} from './Selection';
|
||||||
import { selection, copied, cut } from '$lib/logic/selection';
|
import { getContext } from 'svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { get } from 'svelte/store';
|
||||||
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
|
||||||
import { allHidden } from '$lib/logic/hidden';
|
import {
|
||||||
|
GPXTreeElement,
|
||||||
|
Track,
|
||||||
|
TrackSegment,
|
||||||
|
type AnyGPXTreeElement,
|
||||||
|
Waypoint,
|
||||||
|
GPXFile
|
||||||
|
} from 'gpx';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import MetadataDialog from './MetadataDialog.svelte';
|
||||||
|
import StyleDialog from './StyleDialog.svelte';
|
||||||
|
|
||||||
let {
|
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
node,
|
export let item: ListItem;
|
||||||
item,
|
export let label: string | undefined;
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
|
||||||
item: ListItem;
|
|
||||||
label: string | undefined;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
let embedding = getContext<boolean>('embedding');
|
|
||||||
|
|
||||||
let singleSelection = $derived($selection.size === 1);
|
$: singleSelection = $selection.size === 1;
|
||||||
|
|
||||||
let nodeColors: string[] = []; /* $derived.by(() => {
|
let nodeColors: string[] = [];
|
||||||
let colors: string[] = [];
|
|
||||||
if (node && map.value) {
|
|
||||||
if (node instanceof GPXFile) {
|
|
||||||
let defaultColor = undefined;
|
|
||||||
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
$: if (node && $map) {
|
||||||
if (layer) {
|
nodeColors = [];
|
||||||
defaultColor = layer.layerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
let style = node.getStyle(defaultColor);
|
if (node instanceof GPXFile) {
|
||||||
style.color.forEach((c) => {
|
let style = node.getStyle();
|
||||||
if (!colors.includes(c)) {
|
|
||||||
colors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (node instanceof Track) {
|
|
||||||
let style = node.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (
|
|
||||||
style['gpx_style:color'] &&
|
|
||||||
!nodeColors.includes(style['gpx_style:color'])
|
|
||||||
) {
|
|
||||||
nodeColors.push(style['gpx_style:color']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (colors.length === 0) {
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (layer) {
|
|
||||||
colors.push(layer.layerColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return colors;
|
|
||||||
});*/
|
|
||||||
|
|
||||||
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (layer) {
|
||||||
|
style.color.push(layer.layerColor);
|
||||||
|
}
|
||||||
|
|
||||||
let openEditMetadata: boolean = $derived(
|
style.color.forEach((c) => {
|
||||||
editMetadata.current && singleSelection && $selection.has(item)
|
if (!nodeColors.includes(c)) {
|
||||||
);
|
nodeColors.push(c);
|
||||||
let openEditStyle: boolean = $derived(
|
}
|
||||||
editStyle.current &&
|
});
|
||||||
$selection.has(item) &&
|
} else if (node instanceof Track) {
|
||||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
|
let style = node.getStyle();
|
||||||
);
|
if (style) {
|
||||||
|
if (style.color && !nodeColors.includes(style.color)) {
|
||||||
|
nodeColors.push(style.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeColors.length === 0) {
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (layer) {
|
||||||
|
nodeColors.push(layer.layerColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let hidden = $derived(
|
let openEditMetadata: boolean = false;
|
||||||
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
|
let openEditStyle: boolean = false;
|
||||||
);
|
|
||||||
|
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
||||||
|
$: openEditStyle =
|
||||||
|
$editStyle &&
|
||||||
|
$selection.has(item) &&
|
||||||
|
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
||||||
|
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (!get(selection).has(item)) {
|
if (!get(selection).has(item)) {
|
||||||
selectItem(item);
|
selectItem(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContextMenu.Trigger class="grow truncate">
|
<ContextMenu.Trigger class="grow truncate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||||
'vertical'
|
'vertical'
|
||||||
? 'h-fit'
|
? 'h-fit'
|
||||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||||
>
|
>
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
||||||
<StyleDialog bind:open={openEditStyle} {item} />
|
<StyleDialog bind:open={openEditStyle} {item} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
||||||
<div
|
<div
|
||||||
class="absolute {orientation === 'vertical'
|
class="absolute {orientation === 'vertical'
|
||||||
? 'top-0 bottom-0 right-1 w-1'
|
? 'top-0 bottom-0 right-1 w-1'
|
||||||
: 'top-0 h-1 left-0 right-0'}"
|
: 'top-0 h-1 left-0 right-0'}"
|
||||||
style="background:linear-gradient(to {orientation === 'vertical'
|
style="background:linear-gradient(to {orientation === 'vertical'
|
||||||
? 'bottom'
|
? 'bottom'
|
||||||
: 'right'},{nodeColors
|
: 'right'},{nodeColors
|
||||||
.map(
|
.map(
|
||||||
(c, i) =>
|
(c, i) =>
|
||||||
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
||||||
)
|
)
|
||||||
.join(',')})"
|
.join(',')})"
|
||||||
></div>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
oncontextmenu={(e) => {
|
on:contextmenu={(e) => {
|
||||||
if (embedding) {
|
if ($embedding) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey) {
|
||||||
// Add to selection instead of opening context menu
|
// Add to selection instead of opening context menu
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
$selection.toggle(item);
|
$selection.toggle(item);
|
||||||
$selection = $selection;
|
$selection = $selection;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onmouseenter={() => {
|
on:mouseenter={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
let file = getFile(item.getFileId());
|
let file = getFile(item.getFileId());
|
||||||
if (layer && file) {
|
if (layer && file) {
|
||||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||||
if (waypoint) {
|
if (waypoint) {
|
||||||
waypointPopup?.setItem({
|
layer.showWaypointPopup(waypoint);
|
||||||
item: waypoint,
|
}
|
||||||
fileId: item.getFileId(),
|
}
|
||||||
});
|
}
|
||||||
}
|
}}
|
||||||
}
|
on:mouseleave={() => {
|
||||||
}
|
if (item instanceof ListWaypointItem) {
|
||||||
}}
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
onmouseleave={() => {
|
if (layer) {
|
||||||
if (item instanceof ListWaypointItem) {
|
layer.hideWaypointPopup();
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
}
|
||||||
if (layer) {
|
}
|
||||||
waypointPopup?.setItem(null);
|
}}
|
||||||
}
|
>
|
||||||
}
|
{#if item.level === ListLevel.SEGMENT}
|
||||||
}}
|
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||||
>
|
{:else if item.level === ListLevel.WAYPOINT}
|
||||||
{#if item.level === ListLevel.SEGMENT}
|
<MapPin size="16" class="mr-1 shrink-0" />
|
||||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
{/if}
|
||||||
{:else if item.level === ListLevel.WAYPOINT}
|
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
|
||||||
{#if symbolKey && symbols[symbolKey].icon}
|
{label}
|
||||||
{@const SymbolIcon = symbols[symbolKey].icon}
|
</span>
|
||||||
<SymbolIcon size="16" class="mr-1 shrink-0" />
|
{#if hidden}
|
||||||
{:else}
|
<EyeOff
|
||||||
<MapPin size="16" class="mr-1 shrink-0" />
|
size="12"
|
||||||
{/if}
|
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
|
||||||
{/if}
|
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
|
||||||
<span
|
? 'mr-3'
|
||||||
class="grow select-none truncate {orientation === 'vertical'
|
: ''}"
|
||||||
? 'last:mr-2'
|
/>
|
||||||
: ''}"
|
{/if}
|
||||||
>
|
</span>
|
||||||
{label}
|
</Button>
|
||||||
</span>
|
</ContextMenu.Trigger>
|
||||||
{#if hidden}
|
<ContextMenu.Content>
|
||||||
<EyeOff
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
size="12"
|
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
||||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
<Info size="16" class="mr-1" />
|
||||||
? 'mr-2'
|
{$_('menu.metadata.button')}
|
||||||
: ''} {item.level === ListLevel.SEGMENT ||
|
<Shortcut key="I" ctrl={true} />
|
||||||
item.level === ListLevel.WAYPOINT
|
</ContextMenu.Item>
|
||||||
? 'mr-3'
|
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
||||||
: ''}"
|
<PaintBucket size="16" class="mr-1" />
|
||||||
/>
|
{$_('menu.style.button')}
|
||||||
{/if}
|
</ContextMenu.Item>
|
||||||
</span>
|
{/if}
|
||||||
</Button>
|
<ContextMenu.Item
|
||||||
</ContextMenu.Trigger>
|
on:click={() => {
|
||||||
<ContextMenu.Content>
|
if ($allHidden) {
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
dbUtils.setHiddenToSelection(false);
|
||||||
<ContextMenu.Item
|
} else {
|
||||||
disabled={!singleSelection}
|
dbUtils.setHiddenToSelection(true);
|
||||||
onclick={() => (editMetadata.current = true)}
|
}
|
||||||
>
|
}}
|
||||||
<Info size="16" class="mr-1" />
|
>
|
||||||
{i18n._('menu.metadata.button')}
|
{#if $allHidden}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Eye size="16" class="mr-1" />
|
||||||
</ContextMenu.Item>
|
{$_('menu.unhide')}
|
||||||
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
{:else}
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<EyeOff size="16" class="mr-1" />
|
||||||
{i18n._('menu.style.button')}
|
{$_('menu.hide')}
|
||||||
</ContextMenu.Item>
|
{/if}
|
||||||
{/if}
|
<Shortcut key="H" ctrl={true} />
|
||||||
<ContextMenu.Item
|
</ContextMenu.Item>
|
||||||
onclick={() => {
|
<ContextMenu.Separator />
|
||||||
if ($allHidden) {
|
{#if orientation === 'vertical'}
|
||||||
fileActions.setHiddenToSelection(false);
|
{#if item instanceof ListFileItem}
|
||||||
} else {
|
<ContextMenu.Item
|
||||||
fileActions.setHiddenToSelection(true);
|
disabled={!singleSelection}
|
||||||
}
|
on:click={() =>
|
||||||
}}
|
dbUtils.applyToFile(item.getFileId(), (file) =>
|
||||||
>
|
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
|
||||||
{#if $allHidden}
|
)}
|
||||||
<Eye size="16" class="mr-1" />
|
>
|
||||||
{i18n._('menu.unhide')}
|
<Plus size="16" class="mr-1" />
|
||||||
{:else}
|
{$_('menu.new_track')}
|
||||||
<EyeOff size="16" class="mr-1" />
|
</ContextMenu.Item>
|
||||||
{i18n._('menu.hide')}
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{:else if item instanceof ListTrackItem}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<ContextMenu.Item
|
||||||
</ContextMenu.Item>
|
disabled={!singleSelection}
|
||||||
<ContextMenu.Separator />
|
on:click={() => {
|
||||||
{#if orientation === 'vertical'}
|
let trackIndex = item.getTrackIndex();
|
||||||
{#if item instanceof ListFileItem}
|
dbUtils.applyToFile(item.getFileId(), (file) =>
|
||||||
<ContextMenu.Item
|
file.replaceTrackSegments(
|
||||||
disabled={!singleSelection}
|
trackIndex,
|
||||||
onclick={() => fileActions.addNewTrack(item.getFileId())}
|
file.trk[trackIndex].trkseg.length,
|
||||||
>
|
file.trk[trackIndex].trkseg.length,
|
||||||
<Plus size="16" class="mr-1" />
|
[new TrackSegment()]
|
||||||
{i18n._('menu.new_track')}
|
)
|
||||||
</ContextMenu.Item>
|
);
|
||||||
<ContextMenu.Separator />
|
}}
|
||||||
{:else if item instanceof ListTrackItem}
|
>
|
||||||
<ContextMenu.Item
|
<Plus size="16" class="mr-1" />
|
||||||
disabled={!singleSelection}
|
{$_('menu.new_segment')}
|
||||||
onclick={() =>
|
</ContextMenu.Item>
|
||||||
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
<ContextMenu.Separator />
|
||||||
>
|
{/if}
|
||||||
<Plus size="16" class="mr-1" />
|
{/if}
|
||||||
{i18n._('menu.new_segment')}
|
{#if item.level !== ListLevel.WAYPOINTS}
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item on:click={selectAll}>
|
||||||
<ContextMenu.Separator />
|
<FileStack size="16" class="mr-1" />
|
||||||
{/if}
|
{$_('menu.select_all')}
|
||||||
{/if}
|
<Shortcut key="A" ctrl={true} />
|
||||||
{#if item.level !== ListLevel.WAYPOINTS}
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item onclick={() => selection.selectAll()}>
|
<ContextMenu.Separator />
|
||||||
<FileStack size="16" class="mr-1" />
|
{/if}
|
||||||
{i18n._('menu.select_all')}
|
{#if orientation === 'vertical'}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||||
</ContextMenu.Item>
|
<Copy size="16" class="mr-1" />
|
||||||
{/if}
|
{$_('menu.duplicate')}
|
||||||
<ContextMenu.Item onclick={centerMapOnSelection}>
|
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||||
<Maximize size="16" class="mr-1" />
|
>
|
||||||
{i18n._('menu.center')}
|
{#if orientation === 'vertical'}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<ContextMenu.Item on:click={copySelection}>
|
||||||
</ContextMenu.Item>
|
<ClipboardCopy size="16" class="mr-1" />
|
||||||
<ContextMenu.Separator />
|
{$_('menu.copy')}
|
||||||
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
|
<Shortcut key="C" ctrl={true} />
|
||||||
<Copy size="16" class="mr-1" />
|
</ContextMenu.Item>
|
||||||
{i18n._('menu.duplicate')}
|
<ContextMenu.Item on:click={cutSelection}>
|
||||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
<Scissors size="16" class="mr-1" />
|
||||||
>
|
{$_('menu.cut')}
|
||||||
{#if orientation === 'vertical'}
|
<Shortcut key="X" ctrl={true} />
|
||||||
<ContextMenu.Item onclick={() => selection.copySelection()}>
|
</ContextMenu.Item>
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
<ContextMenu.Item
|
||||||
{i18n._('menu.copy')}
|
disabled={$copied === undefined ||
|
||||||
<Shortcut key="C" ctrl={true} />
|
$copied.length === 0 ||
|
||||||
</ContextMenu.Item>
|
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||||
<ContextMenu.Item onclick={() => selection.cutSelection()}>
|
on:click={pasteSelection}
|
||||||
<Scissors size="16" class="mr-1" />
|
>
|
||||||
{i18n._('menu.cut')}
|
<ClipboardPaste size="16" class="mr-1" />
|
||||||
<Shortcut key="X" ctrl={true} />
|
{$_('menu.paste')}
|
||||||
</ContextMenu.Item>
|
<Shortcut key="V" ctrl={true} />
|
||||||
<ContextMenu.Item
|
</ContextMenu.Item>
|
||||||
disabled={$copied === undefined ||
|
{/if}
|
||||||
$copied.length === 0 ||
|
<ContextMenu.Separator />
|
||||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
{/if}
|
||||||
onclick={pasteSelection}
|
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
||||||
>
|
{#if item instanceof ListFileItem}
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<FileX size="16" class="mr-1" />
|
||||||
{i18n._('menu.paste')}
|
{$_('menu.close')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
{:else}
|
||||||
</ContextMenu.Item>
|
<Trash2 size="16" class="mr-1" />
|
||||||
{/if}
|
{$_('menu.delete')}
|
||||||
<ContextMenu.Separator />
|
{/if}
|
||||||
<ContextMenu.Item onclick={fileActions.deleteSelection}>
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
{#if item instanceof ListFileItem}
|
</ContextMenu.Item>
|
||||||
<FileX size="16" class="mr-1" />
|
</ContextMenu.Content>
|
||||||
{i18n._('menu.close')}
|
|
||||||
{:else}
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
|
||||||
{i18n._('menu.delete')}
|
|
||||||
{/if}
|
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
||||||
|
|
||||||
import { getContext } from 'svelte';
|
import type { GPXFileWithStatistics } from '$lib/db';
|
||||||
import type { Readable } from 'svelte/store';
|
import { getContext } from 'svelte';
|
||||||
import { ListFileItem } from './file-list';
|
import type { Readable } from 'svelte/store';
|
||||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
import { ListFileItem } from './FileList';
|
||||||
|
|
||||||
let {
|
export let file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
file,
|
|
||||||
}: {
|
|
||||||
file: Readable<GPXFileWithStatistics | undefined>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $file}
|
{#if $file}
|
||||||
{#if recursive}
|
{#if recursive}
|
||||||
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
||||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||||
</CollapsibleTree>
|
</CollapsibleTree>
|
||||||
{:else}
|
{:else}
|
||||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
62
website/src/lib/components/file-list/MetadataDialog.svelte
Normal file
62
website/src/lib/components/file-list/MetadataDialog.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { dbUtils } from '$lib/db';
|
||||||
|
import { Save } from 'lucide-svelte';
|
||||||
|
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||||
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { editMetadata } from '$lib/stores';
|
||||||
|
|
||||||
|
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
export let item: ListItem;
|
||||||
|
export let open = false;
|
||||||
|
|
||||||
|
let name: string =
|
||||||
|
node instanceof GPXFile
|
||||||
|
? node.metadata.name ?? ''
|
||||||
|
: node instanceof Track
|
||||||
|
? node.name ?? ''
|
||||||
|
: '';
|
||||||
|
let description: string =
|
||||||
|
node instanceof GPXFile
|
||||||
|
? node.metadata.desc ?? ''
|
||||||
|
: node instanceof Track
|
||||||
|
? node.desc ?? ''
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$: if (!open) {
|
||||||
|
$editMetadata = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger />
|
||||||
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
|
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||||
|
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||||
|
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||||
|
<Textarea bind:value={description} id="description" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
on:click={() => {
|
||||||
|
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||||
|
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||||
|
file.metadata.name = name;
|
||||||
|
file.metadata.desc = description;
|
||||||
|
} else if (item instanceof ListTrackItem && node instanceof Track) {
|
||||||
|
file.trk[item.getTrackIndex()].name = name;
|
||||||
|
file.trk[item.getTrackIndex()].desc = description;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save size="16" class="mr-1" />
|
||||||
|
{$_('menu.metadata.save')}
|
||||||
|
</Button>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
315
website/src/lib/components/file-list/Selection.ts
Normal file
315
website/src/lib/components/file-list/Selection.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
size: number = 0;
|
||||||
|
|
||||||
|
constructor(item: ListItem) {
|
||||||
|
this.item = item;
|
||||||
|
this.selected = false;
|
||||||
|
this.children = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.selected = false;
|
||||||
|
for (let key in this.children) {
|
||||||
|
this.children[key].clear();
|
||||||
|
}
|
||||||
|
this.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setOrToggle(item: ListItem, value?: boolean) {
|
||||||
|
if (item.level === this.item.level) {
|
||||||
|
let newSelected = value === undefined ? !this.selected : value;
|
||||||
|
if (this.selected !== newSelected) {
|
||||||
|
this.selected = newSelected;
|
||||||
|
this.size += this.selected ? 1 : -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = item.getIdAtLevel(this.item.level);
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (!this.children.hasOwnProperty(id)) {
|
||||||
|
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
||||||
|
}
|
||||||
|
this.size -= this.children[id].size;
|
||||||
|
this.children[id]._setOrToggle(item, value);
|
||||||
|
this.size += this.children[id].size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(item: ListItem, value: boolean) {
|
||||||
|
this._setOrToggle(item, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(item: ListItem) {
|
||||||
|
this._setOrToggle(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(item: ListItem): boolean {
|
||||||
|
if (item.level === this.item.level) {
|
||||||
|
return this.selected;
|
||||||
|
} else {
|
||||||
|
let id = item.getIdAtLevel(this.item.level);
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (this.children.hasOwnProperty(id)) {
|
||||||
|
return this.children[id].has(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||||
|
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
||||||
|
return this.selected;
|
||||||
|
}
|
||||||
|
let id = item.getIdAtLevel(this.item.level);
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (this.children.hasOwnProperty(id)) {
|
||||||
|
return this.children[id].hasAnyParent(item, self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||||
|
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
||||||
|
return this.selected;
|
||||||
|
}
|
||||||
|
let id = item.getIdAtLevel(this.item.level);
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
||||||
|
if (this.children.hasOwnProperty(id)) {
|
||||||
|
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let key in this.children) {
|
||||||
|
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
||||||
|
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelected(selection: ListItem[] = []): ListItem[] {
|
||||||
|
if (this.selected) {
|
||||||
|
selection.push(this.item);
|
||||||
|
}
|
||||||
|
for (let key in this.children) {
|
||||||
|
this.children[key].getSelected(selection);
|
||||||
|
}
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(callback: (item: ListItem) => void) {
|
||||||
|
if (this.selected) {
|
||||||
|
callback(this.item);
|
||||||
|
}
|
||||||
|
for (let key in this.children) {
|
||||||
|
this.children[key].forEach(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getChild(id: string | number): SelectionTreeType | undefined {
|
||||||
|
return this.children[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChild(id: string | number) {
|
||||||
|
if (this.children.hasOwnProperty(id)) {
|
||||||
|
this.size -= this.children[id].size;
|
||||||
|
delete this.children[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||||
|
|
||||||
|
export function selectItem(item: ListItem) {
|
||||||
|
selection.update(($selection) => {
|
||||||
|
$selection.clear();
|
||||||
|
$selection.set(item, true);
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectFile(fileId: string) {
|
||||||
|
selectItem(new ListFileItem(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSelectItem(item: ListItem) {
|
||||||
|
selection.update(($selection) => {
|
||||||
|
$selection.toggle(item);
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSelectFile(fileId: string) {
|
||||||
|
addSelectItem(new ListFileItem(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectAll() {
|
||||||
|
selection.update(($selection) => {
|
||||||
|
let item: ListItem = new ListRootItem();
|
||||||
|
$selection.forEach((i) => {
|
||||||
|
item = i;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item instanceof ListRootItem || item instanceof ListFileItem) {
|
||||||
|
$selection.clear();
|
||||||
|
get(fileObservers).forEach((_file, fileId) => {
|
||||||
|
$selection.set(new ListFileItem(fileId), true);
|
||||||
|
});
|
||||||
|
} else if (item instanceof ListTrackItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
if (file) {
|
||||||
|
file.trk.forEach((_track, trackId) => {
|
||||||
|
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListTrackSegmentItem) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListWaypointItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
if (file) {
|
||||||
|
file.wpt.forEach((_waypoint, waypointId) => {
|
||||||
|
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||||
|
let selected: ListItem[] = [];
|
||||||
|
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||||
|
selected.push(...items);
|
||||||
|
}, reverse);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
sortItems(items, reverse);
|
||||||
|
callback(fileId, level, items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||||
|
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const copied = writable<ListItem[] | undefined>(undefined);
|
||||||
|
export const cut = writable(false);
|
||||||
|
|
||||||
|
export function copySelection(): boolean {
|
||||||
|
let selected = get(selection).getSelected();
|
||||||
|
if (selected.length > 0) {
|
||||||
|
copied.set(selected);
|
||||||
|
cut.set(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cutSelection() {
|
||||||
|
if (copySelection()) {
|
||||||
|
cut.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCopied() {
|
||||||
|
copied.set(undefined);
|
||||||
|
cut.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pasteSelection() {
|
||||||
|
let fromItems = get(copied);
|
||||||
|
if (fromItems === undefined || fromItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = get(selection).getSelected();
|
||||||
|
if (selected.length === 0) {
|
||||||
|
selected = [new ListRootItem()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromParent = fromItems[0].getParent();
|
||||||
|
let toParent = selected[selected.length - 1];
|
||||||
|
|
||||||
|
let startIndex: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (fromItems[0].level === toParent.level) {
|
||||||
|
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
||||||
|
startIndex = toParent.getId() + 1;
|
||||||
|
}
|
||||||
|
toParent = toParent.getParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
let toItems: ListItem[] = [];
|
||||||
|
if (toParent.level === ListLevel.ROOT) {
|
||||||
|
let fileIds = getFileIds(fromItems.length);
|
||||||
|
fileIds.forEach((fileId) => {
|
||||||
|
toItems.push(new ListFileItem(fileId));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let toFile = getFile(toParent.getFileId());
|
||||||
|
if (toFile) {
|
||||||
|
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));
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
} else if (toParent instanceof ListWaypointsItem) {
|
||||||
|
if (item instanceof ListWaypointItem) {
|
||||||
|
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromItems.length === toItems.length) {
|
||||||
|
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
||||||
|
resetCopied();
|
||||||
|
}
|
||||||
|
}
|
||||||
167
website/src/lib/components/file-list/StyleDialog.svelte
Normal file
167
website/src/lib/components/file-list/StyleDialog.svelte
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { dbUtils, getFile, settings } from '$lib/db';
|
||||||
|
import { Save } from 'lucide-svelte';
|
||||||
|
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||||
|
import { selection } from './Selection';
|
||||||
|
import { editStyle, gpxLayers } from '$lib/stores';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export let item: ListItem;
|
||||||
|
export let open = false;
|
||||||
|
|
||||||
|
const { defaultOpacity, defaultWeight } = settings;
|
||||||
|
|
||||||
|
let colors: string[] = [];
|
||||||
|
let color: string | undefined = undefined;
|
||||||
|
let opacity: number[] = [];
|
||||||
|
let weight: number[] = [];
|
||||||
|
let colorChanged = false;
|
||||||
|
let opacityChanged = false;
|
||||||
|
let weightChanged = false;
|
||||||
|
|
||||||
|
function setStyleInputs() {
|
||||||
|
colors = [];
|
||||||
|
opacity = [];
|
||||||
|
weight = [];
|
||||||
|
|
||||||
|
$selection.forEach((item) => {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
let style = file.getStyle();
|
||||||
|
style.color.push(layer.layerColor);
|
||||||
|
|
||||||
|
style.color.forEach((c) => {
|
||||||
|
if (!colors.includes(c)) {
|
||||||
|
colors.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
style.opacity.forEach((o) => {
|
||||||
|
if (!opacity.includes(o)) {
|
||||||
|
opacity.push(o);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
style.weight.forEach((w) => {
|
||||||
|
if (!weight.includes(w)) {
|
||||||
|
weight.push(w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListTrackItem) {
|
||||||
|
let file = getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.get(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
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.opacity && !opacity.includes(style.opacity)) {
|
||||||
|
opacity.push(style.opacity);
|
||||||
|
}
|
||||||
|
if (style.weight && !weight.includes(style.weight)) {
|
||||||
|
weight.push(style.weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!colors.includes(layer.layerColor)) {
|
||||||
|
colors.push(layer.layerColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
color = colors[0];
|
||||||
|
opacity = [opacity[0] ?? $defaultOpacity];
|
||||||
|
weight = [weight[0] ?? $defaultWeight];
|
||||||
|
|
||||||
|
colorChanged = false;
|
||||||
|
opacityChanged = false;
|
||||||
|
weightChanged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($selection && open) {
|
||||||
|
setStyleInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (!open) {
|
||||||
|
$editStyle = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger />
|
||||||
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{$_('menu.style.color')}
|
||||||
|
<Input
|
||||||
|
bind:value={color}
|
||||||
|
type="color"
|
||||||
|
class="p-0 h-6 w-40"
|
||||||
|
on:change={() => (colorChanged = true)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{$_('menu.style.opacity')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={opacity}
|
||||||
|
min={0.3}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={() => (opacityChanged = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{$_('menu.style.width')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={weight}
|
||||||
|
id="weight"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
onValueChange={() => (weightChanged = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!colorChanged && !opacityChanged && !weightChanged}
|
||||||
|
on:click={() => {
|
||||||
|
let style = {};
|
||||||
|
if (colorChanged) {
|
||||||
|
style.color = color;
|
||||||
|
}
|
||||||
|
if (opacityChanged) {
|
||||||
|
style.opacity = opacity[0];
|
||||||
|
}
|
||||||
|
if (weightChanged) {
|
||||||
|
style.weight = weight[0];
|
||||||
|
}
|
||||||
|
dbUtils.setStyleToSelection(style);
|
||||||
|
|
||||||
|
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||||
|
if (style.opacity) {
|
||||||
|
$defaultOpacity = style.opacity;
|
||||||
|
}
|
||||||
|
if (style.weight) {
|
||||||
|
$defaultWeight = style.weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save size="16" class="mr-1" />
|
||||||
|
{$_('menu.metadata.save')}
|
||||||
|
</Button>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { dbUtils } from '$lib/db';
|
|
||||||
import { Save } from '@lucide/svelte';
|
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
node,
|
|
||||||
item,
|
|
||||||
open = $bindable(),
|
|
||||||
}: {
|
|
||||||
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
|
||||||
item: ListItem;
|
|
||||||
open: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let name: string = $derived(
|
|
||||||
node instanceof GPXFile
|
|
||||||
? (node.metadata.name ?? '')
|
|
||||||
: node instanceof Track
|
|
||||||
? (node.name ?? '')
|
|
||||||
: ''
|
|
||||||
);
|
|
||||||
let description: string = $derived(
|
|
||||||
node instanceof GPXFile
|
|
||||||
? (node.metadata.desc ?? '')
|
|
||||||
: node instanceof Track
|
|
||||||
? (node.desc ?? '')
|
|
||||||
: ''
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) {
|
|
||||||
editMetadata.current = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger />
|
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
|
||||||
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
|
||||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
|
||||||
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
|
||||||
<Textarea bind:value={description} id="description" />
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onclick={() => {
|
|
||||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
|
||||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
|
||||||
file.metadata.name = name;
|
|
||||||
file.metadata.desc = description;
|
|
||||||
if (file.trk.length === 1) {
|
|
||||||
file.trk[0].name = name;
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackItem && node instanceof Track) {
|
|
||||||
file.trk[item.getTrackIndex()].name = name;
|
|
||||||
file.trk[item.getTrackIndex()].desc = description;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
open = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{i18n._('menu.metadata.save')}
|
|
||||||
</Button>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const editMetadata = $state({
|
|
||||||
current: false,
|
|
||||||
});
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { dbUtils, getFile, settings } from '$lib/db';
|
|
||||||
import { Save } from '@lucide/svelte';
|
|
||||||
import {
|
|
||||||
ListFileItem,
|
|
||||||
ListTrackItem,
|
|
||||||
type ListItem,
|
|
||||||
} from '$lib/components/file-list/file-list';
|
|
||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
|
||||||
import { selection } from '../Selection';
|
|
||||||
import { gpxLayers } from '$lib/stores';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
|
||||||
import type { LineStyleExtension } from 'gpx';
|
|
||||||
|
|
||||||
let {
|
|
||||||
item,
|
|
||||||
open = $bindable(),
|
|
||||||
}: {
|
|
||||||
item: ListItem;
|
|
||||||
open: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const { defaultOpacity, defaultWidth } = settings;
|
|
||||||
|
|
||||||
let color: string = $state('');
|
|
||||||
let opacity: number = $state(0);
|
|
||||||
let width: number = $state(0);
|
|
||||||
let colorChanged = $state(false);
|
|
||||||
let opacityChanged = $state(false);
|
|
||||||
let widthChanged = $state(false);
|
|
||||||
|
|
||||||
function setStyleInputs() {
|
|
||||||
opacity = $defaultOpacity;
|
|
||||||
width = $defaultWidth;
|
|
||||||
|
|
||||||
$selection.forEach((item) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let style = file.getStyle();
|
|
||||||
color = layer.layerColor;
|
|
||||||
if (style.opacity.length > 0) {
|
|
||||||
opacity = style.opacity[0];
|
|
||||||
}
|
|
||||||
if (style.width.length > 0) {
|
|
||||||
width = style.width[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
color = layer.layerColor;
|
|
||||||
let track = file.trk[item.getTrackIndex()];
|
|
||||||
let style = track.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (style['gpx_style:color']) {
|
|
||||||
color = style['gpx_style:color'];
|
|
||||||
}
|
|
||||||
if (style['gpx_style:opacity']) {
|
|
||||||
opacity = style['gpx_style:opacity'];
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width']) {
|
|
||||||
width = style['gpx_style:width'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
colorChanged = false;
|
|
||||||
opacityChanged = false;
|
|
||||||
widthChanged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($selection && open) {
|
|
||||||
setStyleInputs();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) {
|
|
||||||
editStyle.current = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyStyle() {
|
|
||||||
let style: LineStyleExtension = {};
|
|
||||||
if (colorChanged) {
|
|
||||||
style['gpx_style:color'] = color;
|
|
||||||
}
|
|
||||||
if (opacityChanged) {
|
|
||||||
style['gpx_style:opacity'] = opacity;
|
|
||||||
}
|
|
||||||
if (widthChanged) {
|
|
||||||
style['gpx_style:width'] = width;
|
|
||||||
}
|
|
||||||
dbUtils.setStyleToSelection(style);
|
|
||||||
|
|
||||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
|
||||||
if (style['gpx_style:opacity']) {
|
|
||||||
$defaultOpacity = style['gpx_style:opacity'];
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width']) {
|
|
||||||
$defaultWidth = style['gpx_style:width'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger />
|
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{i18n._('menu.style.color')}
|
|
||||||
<Input
|
|
||||||
bind:value={color}
|
|
||||||
type="color"
|
|
||||||
class="p-0 h-6 w-40"
|
|
||||||
onchange={() => (colorChanged = true)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{i18n._('menu.style.opacity')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={opacity}
|
|
||||||
min={0.3}
|
|
||||||
max={1}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={() => (opacityChanged = true)}
|
|
||||||
type="single"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{i18n._('menu.style.width')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={width}
|
|
||||||
id="width"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
onValueChange={() => (widthChanged = true)}
|
|
||||||
type="single"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
|
||||||
onclick={applyStyle}
|
|
||||||
>
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{i18n._('menu.metadata.save')}
|
|
||||||
</Button>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const editStyle = $state({
|
|
||||||
current: false,
|
|
||||||
});
|
|
||||||
96
website/src/lib/components/gpx-layer/DistanceMarkers.ts
Normal file
96
website/src/lib/components/gpx-layer/DistanceMarkers.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
import { font } from "$lib/assets/layers";
|
||||||
|
import { settings } from "$lib/db";
|
||||||
|
import { gpxStatistics } from "$lib/stores";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
const { distanceMarkers, distanceUnits, currentBasemap } = settings;
|
||||||
|
|
||||||
|
export class DistanceMarkers {
|
||||||
|
map: mapboxgl.Map;
|
||||||
|
updateBinded: () => void = this.update.bind(this);
|
||||||
|
unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(map: mapboxgl.Map) {
|
||||||
|
this.map = map;
|
||||||
|
|
||||||
|
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
|
||||||
|
this.map.on('style.load', this.updateBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
try {
|
||||||
|
if (get(distanceMarkers)) {
|
||||||
|
let distanceSource = this.map.getSource('distance-markers');
|
||||||
|
if (distanceSource) {
|
||||||
|
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
||||||
|
} else {
|
||||||
|
this.map.addSource('distance-markers', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.getDistanceMarkersGeoJSON()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!this.map.getLayer('distance-markers')) {
|
||||||
|
this.map.addLayer({
|
||||||
|
id: 'distance-markers',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'distance-markers',
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'distance'],
|
||||||
|
'text-size': 14,
|
||||||
|
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
|
||||||
|
'text-padding': 20,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'black',
|
||||||
|
'text-halo-width': 2,
|
||||||
|
'text-halo-color': 'white',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.map.moveLayer('distance-markers');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.map.getLayer('distance-markers')) {
|
||||||
|
this.map.removeLayer('distance-markers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||||
|
let statistics = get(gpxStatistics);
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
let distance = currentTargetDistance.toFixed(0);
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
distance,
|
||||||
|
}
|
||||||
|
} as GeoJSON.Feature);
|
||||||
|
currentTargetDistance += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
484
website/src/lib/components/gpx-layer/GPXLayer.ts
Normal file
484
website/src/lib/components/gpx-layer/GPXLayer.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import { currentTool, hoveredTrackPoint, 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 { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointPopup";
|
||||||
|
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||||
|
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||||
|
import type { Waypoint } from "gpx";
|
||||||
|
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||||
|
import { font } from "$lib/assets/layers";
|
||||||
|
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',
|
||||||
|
'#0000ff',
|
||||||
|
'#46e646',
|
||||||
|
'#00ccff',
|
||||||
|
'#ff9900',
|
||||||
|
'#ff00ff',
|
||||||
|
'#ffff32',
|
||||||
|
'#288228',
|
||||||
|
'#9933ff',
|
||||||
|
'#50f0be',
|
||||||
|
'#8c645a'
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorCount: { [key: string]: number } = {};
|
||||||
|
for (let color of colors) {
|
||||||
|
colorCount[color] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the color with the least amount of uses
|
||||||
|
function getColor() {
|
||||||
|
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
||||||
|
colorCount[color]++;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementColor(color: string) {
|
||||||
|
if (colorCount.hasOwnProperty(color)) {
|
||||||
|
colorCount[color]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"')
|
||||||
|
.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"', '')
|
||||||
|
.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('height="24"', 'height="10"')
|
||||||
|
.replace('stroke="currentColor"', 'stroke="white"')
|
||||||
|
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
|
||||||
|
|
||||||
|
export class GPXLayer {
|
||||||
|
map: mapboxgl.Map;
|
||||||
|
fileId: string;
|
||||||
|
file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
|
layerColor: string;
|
||||||
|
markers: mapboxgl.Marker[] = [];
|
||||||
|
selected: boolean = false;
|
||||||
|
draggable: boolean;
|
||||||
|
unsubscribe: Function[] = [];
|
||||||
|
|
||||||
|
updateBinded: () => void = this.update.bind(this);
|
||||||
|
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||||
|
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||||
|
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||||
|
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||||
|
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||||
|
if (this.selected || newSelected) {
|
||||||
|
this.selected = newSelected;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
if (newSelected) {
|
||||||
|
this.moveToFront();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribe.push(currentTool.subscribe(tool => {
|
||||||
|
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||||
|
this.draggable = true;
|
||||||
|
this.markers.forEach(marker => marker.setDraggable(true));
|
||||||
|
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||||
|
this.draggable = false;
|
||||||
|
this.markers.forEach(marker => marker.setDraggable(false));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||||
|
|
||||||
|
this.map.on('style.load', this.updateBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
||||||
|
decrementColor(this.layerColor);
|
||||||
|
this.layerColor = `#${file._data.style.color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let source = this.map.getSource(this.fileId);
|
||||||
|
if (source) {
|
||||||
|
source.setData(this.getGeoJSON());
|
||||||
|
} else {
|
||||||
|
this.map.addSource(this.fileId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.getGeoJSON()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.map.getLayer(this.fileId)) {
|
||||||
|
this.map.addLayer({
|
||||||
|
id: this.fileId,
|
||||||
|
type: 'line',
|
||||||
|
source: this.fileId,
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': ['get', 'weight'],
|
||||||
|
'line-opacity': ['get', 'opacity']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
||||||
|
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
|
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
|
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get(directionMarkers)) {
|
||||||
|
if (!this.map.getLayer(this.fileId + '-direction')) {
|
||||||
|
this.map.addLayer({
|
||||||
|
id: this.fileId + '-direction',
|
||||||
|
type: 'symbol',
|
||||||
|
source: this.fileId,
|
||||||
|
layout: {
|
||||||
|
'text-field': '»',
|
||||||
|
'text-offset': [0, -0.1],
|
||||||
|
'text-keep-upright': false,
|
||||||
|
'text-max-angle': 361,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'symbol-spacing': 20,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'white',
|
||||||
|
'text-opacity': 0.7,
|
||||||
|
'text-halo-width': 0.2,
|
||||||
|
'text-halo-color': 'white'
|
||||||
|
}
|
||||||
|
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||||
|
this.map.removeLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleItems: [number, number][] = [];
|
||||||
|
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||||
|
if (!segment._data.hidden) {
|
||||||
|
visibleItems.push([trackIndex, segmentIndex]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
let symbolKey = getSymbolKey(waypoint.sym);
|
||||||
|
if (markerIndex < this.markers.length) {
|
||||||
|
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||||
|
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||||
|
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
|
||||||
|
} else {
|
||||||
|
let element = document.createElement('div');
|
||||||
|
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||||
|
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||||
|
let marker = new mapboxgl.Marker({
|
||||||
|
draggable: this.draggable,
|
||||||
|
element,
|
||||||
|
anchor: 'bottom'
|
||||||
|
}).setLngLat(waypoint.getCoordinates());
|
||||||
|
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||||
|
let dragEndTimestamp = 0;
|
||||||
|
marker.getElement().addEventListener('mouseover', (e) => {
|
||||||
|
if (marker._isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showWaypointPopup(marker._waypoint);
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
marker.getElement().addEventListener('click', (e) => {
|
||||||
|
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
|
||||||
|
deleteWaypoint(this.fileId, marker._waypoint._data.index);
|
||||||
|
e.stopPropagation();
|
||||||
|
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));
|
||||||
|
} else {
|
||||||
|
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||||
|
}
|
||||||
|
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||||
|
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||||
|
} else {
|
||||||
|
this.showWaypointPopup(marker._waypoint);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
marker.on('dragstart', () => {
|
||||||
|
setGrabbingCursor();
|
||||||
|
marker.getElement().style.cursor = 'grabbing';
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
});
|
||||||
|
marker.on('dragend', (e) => {
|
||||||
|
resetCursor();
|
||||||
|
marker.getElement().style.cursor = '';
|
||||||
|
dbUtils.applyToFile(this.fileId, (file) => {
|
||||||
|
let latLng = marker.getLngLat();
|
||||||
|
let wpt = file.wpt[marker._waypoint._data.index];
|
||||||
|
wpt.setCoordinates({
|
||||||
|
lat: latLng.lat,
|
||||||
|
lon: latLng.lng
|
||||||
|
});
|
||||||
|
wpt.ele = getElevation(this.map, wpt.getCoordinates());
|
||||||
|
});
|
||||||
|
dragEndTimestamp = Date.now()
|
||||||
|
});
|
||||||
|
this.markers.push(marker);
|
||||||
|
}
|
||||||
|
markerIndex++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while (markerIndex < this.markers.length) { // Remove extra markers
|
||||||
|
this.markers.pop()?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markers.forEach((marker) => {
|
||||||
|
if (!marker._waypoint._data.hidden) {
|
||||||
|
marker.addTo(this.map);
|
||||||
|
} else {
|
||||||
|
marker.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMap(map: mapboxgl.Map) {
|
||||||
|
this.map = map;
|
||||||
|
this.map.on('style.load', this.updateBinded);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
if (get(map)) {
|
||||||
|
this.map.off('click', this.fileId, this.layerOnClickBinded);
|
||||||
|
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
|
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
|
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
|
this.map.off('style.load', this.updateBinded);
|
||||||
|
|
||||||
|
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||||
|
this.map.removeLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
if (this.map.getLayer(this.fileId)) {
|
||||||
|
this.map.removeLayer(this.fileId);
|
||||||
|
}
|
||||||
|
if (this.map.getSource(this.fileId)) {
|
||||||
|
this.map.removeSource(this.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markers.forEach((marker) => {
|
||||||
|
marker.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
|
decrementColor(this.layerColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToFront() {
|
||||||
|
if (this.map.getLayer(this.fileId)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseEnter(e: any) {
|
||||||
|
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))) {
|
||||||
|
setScissorsCursor();
|
||||||
|
} else {
|
||||||
|
setPointerCursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseMove(e: any) {
|
||||||
|
let trackIndex = e.features[0].properties.trackIndex;
|
||||||
|
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||||
|
|
||||||
|
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (file) {
|
||||||
|
let segment = file.trk[trackIndex].trkseg[segmentIndex];
|
||||||
|
let point = getClosestLinePoint(segment.trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||||
|
hoveredTrackPoint.set({
|
||||||
|
fileId: this.fileId,
|
||||||
|
trackIndex,
|
||||||
|
segmentIndex,
|
||||||
|
point
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseLeave() {
|
||||||
|
resetCursor();
|
||||||
|
hoveredTrackPoint.set(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnClick(e: any) {
|
||||||
|
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 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
item = new ListFileItem(this.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
||||||
|
addSelectItem(item);
|
||||||
|
} else {
|
||||||
|
selectItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showWaypointPopup(waypoint: Waypoint) {
|
||||||
|
if (get(currentPopupWaypoint) !== null) {
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
}
|
||||||
|
let marker = this.markers[waypoint._data.index];
|
||||||
|
if (marker) {
|
||||||
|
currentPopupWaypoint.set([waypoint, this.fileId]);
|
||||||
|
marker.setPopup(waypointPopup);
|
||||||
|
marker.togglePopup();
|
||||||
|
this.map.on('mousemove', this.maybeHideWaypointPopupBinded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeHideWaypointPopup(e: any) {
|
||||||
|
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||||
|
if (waypoint) {
|
||||||
|
let marker = this.markers[waypoint._data.index];
|
||||||
|
if (marker) {
|
||||||
|
if (this.map.project(marker.getLngLat()).dist(this.map.project(e.lngLat)) > 100) {
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideWaypointPopup() {
|
||||||
|
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||||
|
if (waypoint) {
|
||||||
|
let marker = this.markers[waypoint._data.index];
|
||||||
|
marker?.getPopup()?.remove();
|
||||||
|
currentPopupWaypoint.set(null);
|
||||||
|
this.map.off('mousemove', this.maybeHideWaypointPopupBinded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = file.toGeoJSON();
|
||||||
|
|
||||||
|
let trackIndex = 0, segmentIndex = 0;
|
||||||
|
for (let feature of data.features) {
|
||||||
|
if (!feature.properties) {
|
||||||
|
feature.properties = {};
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||||
|
}
|
||||||
|
feature.properties.trackIndex = trackIndex;
|
||||||
|
feature.properties.segmentIndex = segmentIndex;
|
||||||
|
|
||||||
|
segmentIndex++;
|
||||||
|
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||||
|
segmentIndex = 0;
|
||||||
|
trackIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
website/src/lib/components/gpx-layer/GPXLayers.svelte
Normal file
58
website/src/lib/components/gpx-layer/GPXLayers.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { map, gpxLayers } from '$lib/stores';
|
||||||
|
import { GPXLayer } from './GPXLayer';
|
||||||
|
import WaypointPopup from './WaypointPopup.svelte';
|
||||||
|
import { fileObservers } from '$lib/db';
|
||||||
|
import { DistanceMarkers } from './DistanceMarkers';
|
||||||
|
import { StartEndMarkers } from './StartEndMarkers';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
||||||
|
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
||||||
|
|
||||||
|
$: if ($map && $fileObservers) {
|
||||||
|
// remove layers for deleted files
|
||||||
|
gpxLayers.forEach((layer, fileId) => {
|
||||||
|
if (!$fileObservers.has(fileId)) {
|
||||||
|
layer.remove();
|
||||||
|
gpxLayers.delete(fileId);
|
||||||
|
} else if ($map !== layer.map) {
|
||||||
|
layer.updateMap($map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// add layers for new files
|
||||||
|
$fileObservers.forEach((file, fileId) => {
|
||||||
|
if (!gpxLayers.has(fileId)) {
|
||||||
|
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($map) {
|
||||||
|
if (distanceMarkers) {
|
||||||
|
distanceMarkers.remove();
|
||||||
|
}
|
||||||
|
if (startEndMarkers) {
|
||||||
|
startEndMarkers.remove();
|
||||||
|
}
|
||||||
|
distanceMarkers = new DistanceMarkers($map);
|
||||||
|
startEndMarkers = new StartEndMarkers($map);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
gpxLayers.forEach((layer) => layer.remove());
|
||||||
|
gpxLayers.clear();
|
||||||
|
|
||||||
|
if (distanceMarkers) {
|
||||||
|
distanceMarkers.remove();
|
||||||
|
distanceMarkers = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startEndMarkers) {
|
||||||
|
startEndMarkers.remove();
|
||||||
|
startEndMarkers = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<WaypointPopup />
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
|
||||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
import mapboxgl from "mapbox-gl";
|
||||||
import mapboxgl from 'mapbox-gl';
|
import { get } from "svelte/store";
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { map } from '$lib/components/map/map';
|
|
||||||
|
|
||||||
export class StartEndMarkers {
|
export class StartEndMarkers {
|
||||||
|
map: mapboxgl.Map;
|
||||||
start: mapboxgl.Marker;
|
start: mapboxgl.Marker;
|
||||||
end: mapboxgl.Marker;
|
end: mapboxgl.Marker;
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
unsubscribes: (() => void)[] = [];
|
unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor(map: mapboxgl.Map) {
|
||||||
|
this.map = map;
|
||||||
|
|
||||||
let startElement = document.createElement('div');
|
let startElement = document.createElement('div');
|
||||||
let endElement = document.createElement('div');
|
let endElement = document.createElement('div');
|
||||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
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.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||||
endElement.style.background =
|
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
|
||||||
|
|
||||||
this.start = new mapboxgl.Marker({ element: startElement });
|
this.start = new mapboxgl.Marker({ element: startElement });
|
||||||
this.end = new mapboxgl.Marker({ element: endElement });
|
this.end = new mapboxgl.Marker({ element: endElement });
|
||||||
@@ -27,18 +27,11 @@ export class StartEndMarkers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
const map_ = get(map);
|
let tool = get(currentTool);
|
||||||
if (!map_) return;
|
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||||
|
|
||||||
const tool = get(currentTool);
|
|
||||||
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
|
||||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
|
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
||||||
this.end
|
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
|
||||||
.setLngLat(
|
|
||||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
|
||||||
)
|
|
||||||
.addTo(map_);
|
|
||||||
} else {
|
} else {
|
||||||
this.start.remove();
|
this.start.remove();
|
||||||
this.end.remove();
|
this.end.remove();
|
||||||
@@ -46,9 +39,9 @@ export class StartEndMarkers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||||
|
|
||||||
this.start.remove();
|
this.start.remove();
|
||||||
this.end.remove();
|
this.end.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
105
website/src/lib/components/gpx-layer/WaypointPopup.svelte
Normal file
105
website/src/lib/components/gpx-layer/WaypointPopup.svelte
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
|
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
|
||||||
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
|
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Tool, currentTool } from '$lib/stores';
|
||||||
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
let popupElement: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
waypointPopup.setDOMContent(popupElement);
|
||||||
|
popupElement.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
|
||||||
|
|
||||||
|
function sanitize(text: string | undefined): string {
|
||||||
|
if (text === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let sanitized = sanitizeHtml(text, {
|
||||||
|
allowedTags: ['a', 'br'],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'target']
|
||||||
|
}
|
||||||
|
}).trim();
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={popupElement} class="hidden">
|
||||||
|
{#if $currentPopupWaypoint}
|
||||||
|
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
|
||||||
|
<Card.Header class="p-0">
|
||||||
|
<Card.Title class="text-md">
|
||||||
|
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
|
||||||
|
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
|
||||||
|
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
|
||||||
|
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
|
||||||
|
{/if}
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex flex-col p-0 text-sm">
|
||||||
|
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{#if symbolKey}
|
||||||
|
<span>
|
||||||
|
{#if symbols[symbolKey].icon}
|
||||||
|
<svelte:component
|
||||||
|
this={symbols[symbolKey].icon}
|
||||||
|
size="12"
|
||||||
|
class="inline-block mb-0.5"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="w-4 inline-block" />
|
||||||
|
{/if}
|
||||||
|
{$_(`gpx.symbol.${symbolKey}`)}
|
||||||
|
</span>
|
||||||
|
<Dot size="16" />
|
||||||
|
{/if}
|
||||||
|
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}° {$currentPopupWaypoint[0]
|
||||||
|
.getLongitude()
|
||||||
|
.toFixed(6)}°
|
||||||
|
{#if $currentPopupWaypoint[0].ele !== undefined}
|
||||||
|
<Dot size="16" />
|
||||||
|
<WithUnits value={$currentPopupWaypoint[0].ele} type="elevation" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if $currentPopupWaypoint[0].desc}
|
||||||
|
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
||||||
|
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if $currentTool === Tool.WAYPOINT}
|
||||||
|
<Button
|
||||||
|
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||||
|
variant="outline"
|
||||||
|
on:click={() =>
|
||||||
|
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
|
||||||
|
>
|
||||||
|
<Trash2 size="16" class="mr-1" />
|
||||||
|
{$_('menu.delete')}
|
||||||
|
<Shortcut key="" shift={true} click={true} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
div :global(a) {
|
||||||
|
@apply text-blue-500 dark:text-blue-300;
|
||||||
|
@apply hover:underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
website/src/lib/components/gpx-layer/WaypointPopup.ts
Normal file
25
website/src/lib/components/gpx-layer/WaypointPopup.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { dbUtils } from "$lib/db";
|
||||||
|
import type { Waypoint } from "gpx";
|
||||||
|
import mapboxgl from "mapbox-gl";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const currentPopupWaypoint = writable<[Waypoint, string] | null>(null);
|
||||||
|
|
||||||
|
export const waypointPopup = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
maxWidth: undefined,
|
||||||
|
offset: {
|
||||||
|
'top': [0, 0],
|
||||||
|
'top-left': [0, 0],
|
||||||
|
'top-right': [0, 0],
|
||||||
|
'bottom': [0, -30],
|
||||||
|
'bottom-left': [0, -30],
|
||||||
|
'bottom-right': [0, -30],
|
||||||
|
'left': [0, 0],
|
||||||
|
'right': [0, 0]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||||
|
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||||
|
}
|
||||||
417
website/src/lib/components/layer-control/CustomLayers.svelte
Normal file
417
website/src/lib/components/layer-control/CustomLayers.svelte
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
|
import {
|
||||||
|
CirclePlus,
|
||||||
|
CircleX,
|
||||||
|
Minus,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Move,
|
||||||
|
Map,
|
||||||
|
Layers2
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import Sortable from 'sortablejs/Sortable';
|
||||||
|
|
||||||
|
const {
|
||||||
|
customLayers,
|
||||||
|
selectedBasemapTree,
|
||||||
|
selectedOverlayTree,
|
||||||
|
currentBasemap,
|
||||||
|
previousBasemap,
|
||||||
|
currentOverlays,
|
||||||
|
previousOverlays,
|
||||||
|
customBasemapOrder,
|
||||||
|
customOverlayOrder
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
let name: string = '';
|
||||||
|
let tileUrls: string[] = [''];
|
||||||
|
let maxZoom: number = 20;
|
||||||
|
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||||
|
let resourceType: 'raster' | 'vector' = 'raster';
|
||||||
|
|
||||||
|
let basemapContainer: HTMLElement;
|
||||||
|
let overlayContainer: HTMLElement;
|
||||||
|
|
||||||
|
let basemapSortable: Sortable;
|
||||||
|
let overlaySortable: Sortable;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if ($customBasemapOrder.length === 0) {
|
||||||
|
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||||
|
(id) => $customLayers[id].layerType === 'basemap'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($customOverlayOrder.length === 0) {
|
||||||
|
$customOverlayOrder = Object.keys($customLayers).filter(
|
||||||
|
(id) => $customLayers[id].layerType === 'overlay'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
basemapSortable = Sortable.create(basemapContainer, {
|
||||||
|
onSort: (e) => {
|
||||||
|
$customBasemapOrder = basemapSortable.toArray();
|
||||||
|
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||||
|
acc[id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlaySortable = Sortable.create(overlayContainer, {
|
||||||
|
onSort: (e) => {
|
||||||
|
$customOverlayOrder = overlaySortable.toArray();
|
||||||
|
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||||
|
acc[id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
basemapSortable.sort($customBasemapOrder);
|
||||||
|
overlaySortable.sort($customOverlayOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
basemapSortable.destroy();
|
||||||
|
overlaySortable.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (tileUrls[0].length > 0) {
|
||||||
|
if (
|
||||||
|
tileUrls[0].includes('.json') ||
|
||||||
|
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||||
|
) {
|
||||||
|
resourceType = 'vector';
|
||||||
|
layerType = 'basemap';
|
||||||
|
} else {
|
||||||
|
resourceType = 'raster';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayer() {
|
||||||
|
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||||
|
deleteLayer(selectedLayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxZoom === 'string') {
|
||||||
|
maxZoom = parseInt(maxZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
let layerId = selectedLayerId ?? getLayerId();
|
||||||
|
let layer: CustomLayer = {
|
||||||
|
id: layerId,
|
||||||
|
name: name,
|
||||||
|
tileUrls: tileUrls,
|
||||||
|
maxZoom: maxZoom,
|
||||||
|
layerType: layerType,
|
||||||
|
resourceType: resourceType,
|
||||||
|
value: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resourceType === 'vector') {
|
||||||
|
layer.value = tileUrls[0];
|
||||||
|
} else {
|
||||||
|
if (layerType === 'basemap') {
|
||||||
|
layer.value = extendBasemap({
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
[layerId]: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: tileUrls,
|
||||||
|
maxzoom: maxZoom
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: layerId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
layer.value = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: tileUrls,
|
||||||
|
maxzoom: maxZoom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$customLayers[layerId] = layer;
|
||||||
|
addLayer(layerId);
|
||||||
|
selectedLayerId = undefined;
|
||||||
|
setDataFromSelectedLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayerId() {
|
||||||
|
for (let id = 0; ; id++) {
|
||||||
|
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||||
|
return `custom-${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLayer(layerId: string) {
|
||||||
|
if (layerType === 'basemap') {
|
||||||
|
selectedBasemapTree.update(($tree) => {
|
||||||
|
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||||
|
$tree.basemaps['custom'] = {};
|
||||||
|
}
|
||||||
|
$tree.basemaps['custom'][layerId] = true;
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
|
||||||
|
$currentBasemap = layerId;
|
||||||
|
|
||||||
|
if (!$customBasemapOrder.includes(layerId)) {
|
||||||
|
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedOverlayTree.update(($tree) => {
|
||||||
|
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||||
|
$tree.overlays['custom'] = {};
|
||||||
|
}
|
||||||
|
$tree.overlays['custom'][layerId] = true;
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($map && $map.getSource(layerId)) {
|
||||||
|
// Reset source when updating an existing layer
|
||||||
|
if ($map.getLayer(layerId)) {
|
||||||
|
$map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
$map.removeSource(layerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||||
|
$currentOverlays.overlays['custom'] = {};
|
||||||
|
}
|
||||||
|
$currentOverlays.overlays['custom'][layerId] = true;
|
||||||
|
|
||||||
|
if (!$customOverlayOrder.includes(layerId)) {
|
||||||
|
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryDeleteLayer(node: any, id: string): any {
|
||||||
|
if (node.hasOwnProperty(id)) {
|
||||||
|
delete node[id];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLayer(layerId: string) {
|
||||||
|
let layer = $customLayers[layerId];
|
||||||
|
if (layer.layerType === 'basemap') {
|
||||||
|
if (layerId === $currentBasemap) {
|
||||||
|
$currentBasemap = defaultBasemap;
|
||||||
|
}
|
||||||
|
if (layerId === $previousBasemap) {
|
||||||
|
$previousBasemap = defaultBasemap;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||||
|
$selectedBasemapTree.basemaps['custom'],
|
||||||
|
layerId
|
||||||
|
);
|
||||||
|
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||||
|
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||||
|
}
|
||||||
|
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||||
|
} else {
|
||||||
|
$currentOverlays.overlays['custom'][layerId] = false;
|
||||||
|
if ($previousOverlays.overlays['custom']) {
|
||||||
|
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
||||||
|
$previousOverlays.overlays['custom'],
|
||||||
|
layerId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||||
|
$selectedOverlayTree.overlays['custom'],
|
||||||
|
layerId
|
||||||
|
);
|
||||||
|
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||||
|
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||||
|
}
|
||||||
|
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||||
|
|
||||||
|
if ($map) {
|
||||||
|
if ($map.getLayer(layerId)) {
|
||||||
|
$map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
if ($map.getSource(layerId)) {
|
||||||
|
$map.removeSource(layerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedLayerId: string | undefined = undefined;
|
||||||
|
|
||||||
|
function setDataFromSelectedLayer() {
|
||||||
|
if (selectedLayerId) {
|
||||||
|
const layer = $customLayers[selectedLayerId];
|
||||||
|
name = layer.name;
|
||||||
|
tileUrls = layer.tileUrls;
|
||||||
|
maxZoom = layer.maxZoom;
|
||||||
|
layerType = layer.layerType;
|
||||||
|
resourceType = layer.resourceType;
|
||||||
|
} else {
|
||||||
|
name = '';
|
||||||
|
tileUrls = [''];
|
||||||
|
maxZoom = 20;
|
||||||
|
layerType = 'basemap';
|
||||||
|
resourceType = 'raster';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: selectedLayerId, setDataFromSelectedLayer();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
{#if $customBasemapOrder.length > 0}
|
||||||
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
|
<Map size="16" />
|
||||||
|
{$_('layers.label.basemaps')}
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
bind:this={basemapContainer}
|
||||||
|
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
>
|
||||||
|
{#each $customBasemapOrder as id (id)}
|
||||||
|
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||||
|
<Move size="12" />
|
||||||
|
<span class="grow">{$customLayers[id].name}</span>
|
||||||
|
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||||
|
<Pencil size="16" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||||
|
<Trash2 size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if $customOverlayOrder.length > 0}
|
||||||
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
|
<Layers2 size="16" />
|
||||||
|
{$_('layers.label.overlays')}
|
||||||
|
<div class="grow">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
bind:this={overlayContainer}
|
||||||
|
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
>
|
||||||
|
{#each $customOverlayOrder as id (id)}
|
||||||
|
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||||
|
<Move size="12" />
|
||||||
|
<span class="grow">{$customLayers[id].name}</span>
|
||||||
|
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||||
|
<Pencil size="16" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||||
|
<Trash2 size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header class="p-3">
|
||||||
|
<Card.Title class="text-base">
|
||||||
|
{#if selectedLayerId}
|
||||||
|
{$_('layers.custom_layers.edit')}
|
||||||
|
{:else}
|
||||||
|
{$_('layers.custom_layers.new')}
|
||||||
|
{/if}
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="p-3 pt-0">
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||||
|
<Input bind:value={name} id="name" class="h-8" />
|
||||||
|
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
||||||
|
{#each tileUrls as url, i}
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<Input
|
||||||
|
bind:value={tileUrls[i]}
|
||||||
|
id="url"
|
||||||
|
class="h-8"
|
||||||
|
placeholder={$_('layers.custom_layers.url_placeholder')}
|
||||||
|
/>
|
||||||
|
{#if tileUrls.length > 1}
|
||||||
|
<Button
|
||||||
|
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||||
|
variant="outline"
|
||||||
|
class="p-1 h-8"
|
||||||
|
>
|
||||||
|
<Minus size="16" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if i === tileUrls.length - 1}
|
||||||
|
<Button
|
||||||
|
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||||
|
variant="outline"
|
||||||
|
class="p-1 h-8"
|
||||||
|
>
|
||||||
|
<Plus size="16" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/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" />
|
||||||
|
{/if}
|
||||||
|
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||||
|
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="basemap" id="basemap" />
|
||||||
|
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
|
||||||
|
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
{#if selectedLayerId}
|
||||||
|
<div class="mt-2 flex flex-row gap-2">
|
||||||
|
<Button variant="outline" on:click={createLayer} class="grow">
|
||||||
|
<Save size="16" class="mr-1" />
|
||||||
|
{$_('layers.custom_layers.update')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||||
|
<CircleX size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||||
|
<CirclePlus size="16" class="mr-1" />
|
||||||
|
{$_('layers.custom_layers.create')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
203
website/src/lib/components/layer-control/LayerControl.svelte
Normal file
203
website/src/lib/components/layer-control/LayerControl.svelte
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||||
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
|
||||||
|
import { Layers } from 'lucide-svelte';
|
||||||
|
|
||||||
|
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
import { get, writable } from 'svelte/store';
|
||||||
|
import { getLayers } from './utils';
|
||||||
|
import { OverpassLayer } from './OverpassLayer';
|
||||||
|
import OverpassPopup from './OverpassPopup.svelte';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let overpassLayer: OverpassLayer;
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentBasemap,
|
||||||
|
previousBasemap,
|
||||||
|
currentOverlays,
|
||||||
|
currentOverpassQueries,
|
||||||
|
selectedBasemapTree,
|
||||||
|
selectedOverlayTree,
|
||||||
|
selectedOverpassTree,
|
||||||
|
customLayers,
|
||||||
|
opacities
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
function setStyle() {
|
||||||
|
if ($map) {
|
||||||
|
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||||
|
? basemaps[$currentBasemap]
|
||||||
|
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||||
|
$map.setStyle(basemap, {
|
||||||
|
diff: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($map && $currentBasemap) {
|
||||||
|
setStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($map && $currentOverlays) {
|
||||||
|
// Add or remove overlay layers depending on the current overlays
|
||||||
|
let overlayLayers = getLayers($currentOverlays);
|
||||||
|
Object.keys(overlayLayers).forEach((id) => {
|
||||||
|
if (overlayLayers[id]) {
|
||||||
|
if (!addOverlayLayer.hasOwnProperty(id)) {
|
||||||
|
addOverlayLayer[id] = addOverlayLayerForId(id);
|
||||||
|
}
|
||||||
|
if (!$map.getLayer(id)) {
|
||||||
|
addOverlayLayer[id]();
|
||||||
|
$map.on('style.load', addOverlayLayer[id]);
|
||||||
|
}
|
||||||
|
} else if ($map.getLayer(id)) {
|
||||||
|
$map.removeLayer(id);
|
||||||
|
$map.off('style.load', addOverlayLayer[id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($map) {
|
||||||
|
if (overpassLayer) {
|
||||||
|
overpassLayer.remove();
|
||||||
|
}
|
||||||
|
overpassLayer = new OverpassLayer($map);
|
||||||
|
overpassLayer.add();
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedBasemap = writable(get(currentBasemap));
|
||||||
|
selectedBasemap.subscribe((value) => {
|
||||||
|
// Updates coming from radio buttons
|
||||||
|
if (value !== get(currentBasemap)) {
|
||||||
|
previousBasemap.set(get(currentBasemap));
|
||||||
|
currentBasemap.set(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentBasemap.subscribe((value) => {
|
||||||
|
// Updates coming from the database, or from the user swapping basemaps
|
||||||
|
selectedBasemap.set(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
let addOverlayLayer: { [key: string]: () => void } = {};
|
||||||
|
function addOverlayLayerForId(id: string) {
|
||||||
|
return () => {
|
||||||
|
if ($map) {
|
||||||
|
try {
|
||||||
|
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||||
|
if (!$map.getSource(id)) {
|
||||||
|
$map.addSource(id, overlay);
|
||||||
|
}
|
||||||
|
$map.addLayer(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: overlay.type === 'raster' ? 'raster' : 'line',
|
||||||
|
source: id,
|
||||||
|
paint: {
|
||||||
|
...(id in $opacities
|
||||||
|
? overlay.type === 'raster'
|
||||||
|
? { 'raster-opacity': $opacities[id] }
|
||||||
|
: { 'line-opacity': $opacities[id] }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'overlays'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
function openLayerControl() {
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
function closeLayerControl() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
let cancelEvents = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="h-full w-full"
|
||||||
|
on:mouseenter={openLayerControl}
|
||||||
|
on:mouseleave={closeLayerControl}
|
||||||
|
on:pointerenter={() => {
|
||||||
|
if (!open) {
|
||||||
|
cancelEvents = true;
|
||||||
|
openLayerControl();
|
||||||
|
setTimeout(() => {
|
||||||
|
cancelEvents = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||||
|
? 'opacity-0 w-0 h-0 delay-0'
|
||||||
|
: 'w-[29px] h-[29px]'}"
|
||||||
|
>
|
||||||
|
<Layers size="20" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||||
|
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||||
|
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
<ScrollArea>
|
||||||
|
<div class="h-fit">
|
||||||
|
<div class="p-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={$selectedBasemapTree}
|
||||||
|
name="basemaps"
|
||||||
|
bind:selected={$selectedBasemap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator class="w-full" />
|
||||||
|
<div class="p-2">
|
||||||
|
{#if $currentOverlays}
|
||||||
|
<LayerTree
|
||||||
|
layerTree={$selectedOverlayTree}
|
||||||
|
name="overlays"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$currentOverlays}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Separator class="w-full" />
|
||||||
|
<div class="p-2">
|
||||||
|
{#if $currentOverpassQueries}
|
||||||
|
<LayerTree
|
||||||
|
layerTree={$selectedOverpassTree}
|
||||||
|
name="overpass"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$currentOverpassQueries}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomControl>
|
||||||
|
|
||||||
|
<OverpassPopup />
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:click={(e) => {
|
||||||
|
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||||
|
closeLayerControl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
|
import * as Accordion from '$lib/components/ui/accordion';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
|
||||||
|
import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers';
|
||||||
|
import { isSelected } from '$lib/components/layer-control/utils';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { map } from '$lib/stores';
|
||||||
|
import CustomLayers from './CustomLayers.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedBasemapTree,
|
||||||
|
selectedOverlayTree,
|
||||||
|
selectedOverpassTree,
|
||||||
|
currentOverlays,
|
||||||
|
customLayers,
|
||||||
|
opacities
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
export let open: boolean;
|
||||||
|
let accordionValue: string | string[] | undefined = undefined;
|
||||||
|
|
||||||
|
let selectedOverlay = writable(undefined);
|
||||||
|
let overlayOpacity = writable([1]);
|
||||||
|
|
||||||
|
function setOpacityFromSelection() {
|
||||||
|
if ($selectedOverlay) {
|
||||||
|
let overlayId = $selectedOverlay.value;
|
||||||
|
if ($opacities.hasOwnProperty(overlayId)) {
|
||||||
|
$overlayOpacity = [$opacities[overlayId]];
|
||||||
|
} else {
|
||||||
|
$overlayOpacity = [1];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$overlayOpacity = [1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($selectedOverlay) {
|
||||||
|
setOpacityFromSelection();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet.Root bind:open>
|
||||||
|
<Sheet.Trigger class="hidden" />
|
||||||
|
<Sheet.Content>
|
||||||
|
<Sheet.Header class="h-full">
|
||||||
|
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
||||||
|
<ScrollArea class="w-[105%] min-h-full pr-4">
|
||||||
|
<Sheet.Description>
|
||||||
|
{$_('layers.settings_help')}
|
||||||
|
</Sheet.Description>
|
||||||
|
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
||||||
|
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||||
|
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
||||||
|
<Accordion.Content class="grow flex flex-col border rounded">
|
||||||
|
<div class="py-2 pl-1 pr-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={basemapTree}
|
||||||
|
name="basemapSettings"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$selectedBasemapTree}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="py-2 pl-1 pr-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={overlayTree}
|
||||||
|
name="overlaySettings"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$selectedOverlayTree}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="py-2 pl-1 pr-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={overpassTree}
|
||||||
|
name="overpassSettings"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$selectedOverpassTree}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item value="overlay-opacity">
|
||||||
|
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
|
||||||
|
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
||||||
|
<div class="flex flex-row gap-6 items-center">
|
||||||
|
<Label>
|
||||||
|
{$_('layers.custom_layers.overlay')}
|
||||||
|
</Label>
|
||||||
|
<Select.Root bind:selected={$selectedOverlay}>
|
||||||
|
<Select.Trigger class="h-8 mr-1">
|
||||||
|
<Select.Value />
|
||||||
|
</Select.Trigger>
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each Object.entries($customLayers) as [id, layer]}
|
||||||
|
{#if layer.layerType === 'overlay'}
|
||||||
|
<Select.Item value={id}>{layer.name}</Select.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Label class="flex flex-row gap-6 items-center">
|
||||||
|
{$_('menu.style.opacity')}
|
||||||
|
<div class="p-2 pr-3 grow">
|
||||||
|
<Slider
|
||||||
|
bind:value={$overlayOpacity}
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
disabled={$selectedOverlay === undefined}
|
||||||
|
onValueChange={() => {
|
||||||
|
if ($selectedOverlay) {
|
||||||
|
$opacities[$selectedOverlay.value] = $overlayOpacity[0];
|
||||||
|
if ($map) {
|
||||||
|
if ($map.getLayer($selectedOverlay.value)) {
|
||||||
|
$map.removeLayer($selectedOverlay.value);
|
||||||
|
$currentOverlays = $currentOverlays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item value="custom-layers">
|
||||||
|
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
||||||
|
<Accordion.Content>
|
||||||
|
<ScrollArea>
|
||||||
|
<CustomLayers />
|
||||||
|
</ScrollArea>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion.Root>
|
||||||
|
</ScrollArea>
|
||||||
|
</Sheet.Header>
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
20
website/src/lib/components/layer-control/LayerTree.svelte
Normal file
20
website/src/lib/components/layer-control/LayerTree.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||||
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
|
|
||||||
|
export let layerTree: LayerTreeType;
|
||||||
|
export let name: string;
|
||||||
|
export let selected: string | undefined = undefined;
|
||||||
|
export let multiple: boolean = false;
|
||||||
|
|
||||||
|
export let checked: LayerTreeType = {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<fieldset class="min-w-64 mb-1">
|
||||||
|
<CollapsibleTree nohover={true}>
|
||||||
|
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
||||||
|
</CollapsibleTree>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
||||||
|
|
||||||
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
|
import { anySelectedLayer } from './utils';
|
||||||
|
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { settings } from '$lib/db';
|
||||||
|
import { beforeUpdate } from 'svelte';
|
||||||
|
|
||||||
|
export let name: string;
|
||||||
|
export let node: LayerTreeType;
|
||||||
|
export let selected: string | undefined = undefined;
|
||||||
|
export let multiple: boolean = false;
|
||||||
|
|
||||||
|
export let checked: LayerTreeType;
|
||||||
|
|
||||||
|
const { customLayers } = settings;
|
||||||
|
|
||||||
|
beforeUpdate(() => {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
Object.keys(node).forEach((id) => {
|
||||||
|
if (!checked.hasOwnProperty(id)) {
|
||||||
|
if (typeof node[id] == 'boolean') {
|
||||||
|
checked[id] = false;
|
||||||
|
} else {
|
||||||
|
checked[id] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-[3px]">
|
||||||
|
{#each Object.keys(node) as id}
|
||||||
|
{#if typeof node[id] == 'boolean'}
|
||||||
|
{#if node[id]}
|
||||||
|
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
|
||||||
|
{#if multiple}
|
||||||
|
<Checkbox
|
||||||
|
id="{name}-{id}"
|
||||||
|
{name}
|
||||||
|
value={id}
|
||||||
|
bind:checked={checked[id]}
|
||||||
|
class="scale-90"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<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)}
|
||||||
|
{$customLayers[id].name}
|
||||||
|
{:else}
|
||||||
|
{$_(`layers.label.${id}`)}
|
||||||
|
{/if}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if anySelectedLayer(node[id])}
|
||||||
|
<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]} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTreeNode>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
div :global(input[type='radio']) {
|
||||||
|
@apply appearance-none;
|
||||||
|
@apply w-4 h-4;
|
||||||
|
@apply border-[1.5px] border-primary;
|
||||||
|
@apply rounded-full;
|
||||||
|
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||||
|
@apply cursor-pointer;
|
||||||
|
@apply checked:bg-primary;
|
||||||
|
@apply checked:bg-clip-content;
|
||||||
|
@apply checked:p-0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||||
import { getLayers } from './utils';
|
import { getLayers } from "./utils";
|
||||||
import { get, writable } from 'svelte/store';
|
import mapboxgl from "mapbox-gl";
|
||||||
import { liveQuery } from 'dexie';
|
import { get, writable } from "svelte/store";
|
||||||
import { overpassQueryData } from '$lib/assets/layers';
|
import { liveQuery } from "dexie";
|
||||||
import { MapPopup } from '$lib/components/map/map-popup';
|
import { db, settings } from "$lib/db";
|
||||||
import { settings } from '$lib/logic/settings';
|
import { overpassQueryData } from "$lib/assets/layers";
|
||||||
import { db } from '$lib/db';
|
|
||||||
|
|
||||||
const { currentOverpassQueries } = settings;
|
const {
|
||||||
|
currentOverpassQueries
|
||||||
|
} = settings;
|
||||||
|
|
||||||
const mercator = new SphericalMercator({
|
const mercator = new SphericalMercator({
|
||||||
size: 256,
|
size: 256,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
export const overpassPopup = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
maxWidth: undefined,
|
||||||
|
offset: 15,
|
||||||
|
});
|
||||||
|
|
||||||
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||||
@@ -25,46 +34,36 @@ export class OverpassLayer {
|
|||||||
queryZoom = 12;
|
queryZoom = 12;
|
||||||
expirationTime = 7 * 24 * 3600 * 1000;
|
expirationTime = 7 * 24 * 3600 * 1000;
|
||||||
map: mapboxgl.Map;
|
map: mapboxgl.Map;
|
||||||
popup: MapPopup;
|
|
||||||
|
|
||||||
currentQueries: Set<string> = new Set();
|
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)[] = [];
|
unsubscribes: (() => void)[] = [];
|
||||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||||
updateBinded = this.update.bind(this);
|
updateBinded = this.update.bind(this);
|
||||||
onHoverBinded = this.onHover.bind(this);
|
onHoverBinded = this.onHover.bind(this);
|
||||||
|
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map) {
|
constructor(map: mapboxgl.Map) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
this.popup = new MapPopup(map, {
|
|
||||||
closeButton: false,
|
|
||||||
focusAfterOpen: false,
|
|
||||||
maxWidth: undefined,
|
|
||||||
offset: 15,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add() {
|
add() {
|
||||||
this.map.on('moveend', this.queryIfNeededBinded);
|
this.map.on('moveend', this.queryIfNeededBinded);
|
||||||
this.map.on('style.import.load', this.updateBinded);
|
this.map.on('style.load', this.updateBinded);
|
||||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(
|
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||||
currentOverpassQueries.subscribe(() => {
|
this.updateBinded();
|
||||||
this.updateBinded();
|
this.queryIfNeededBinded();
|
||||||
this.queryIfNeededBinded();
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
queryIfNeeded() {
|
queryIfNeeded() {
|
||||||
if (this.map.getZoom() >= this.minZoom) {
|
if (this.map.getZoom() >= this.minZoom) {
|
||||||
const bounds = this.map.getBounds()?.toArray();
|
const bounds = this.map.getBounds().toArray();
|
||||||
if (bounds) {
|
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
|
||||||
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,29 +108,40 @@ export class OverpassLayer {
|
|||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.map.off('moveend', this.queryIfNeededBinded);
|
this.map.off('moveend', this.queryIfNeededBinded);
|
||||||
this.map.off('style.import.load', this.updateBinded);
|
this.map.off('style.load', this.updateBinded);
|
||||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
try {
|
if (this.map.getLayer('overpass')) {
|
||||||
if (this.map.getLayer('overpass')) {
|
this.map.removeLayer('overpass');
|
||||||
this.map.removeLayer('overpass');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (this.map.getSource('overpass')) {
|
if (this.map.getSource('overpass')) {
|
||||||
this.map.removeSource('overpass');
|
this.map.removeSource('overpass');
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onHover(e: any) {
|
onHover(e: any) {
|
||||||
this.popup.setItem({
|
overpassPopupPOI.set({
|
||||||
item: {
|
...e.features[0].properties,
|
||||||
...e.features[0].properties,
|
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
|
||||||
|
overpassPopup.addTo(this.map);
|
||||||
|
this.map.on('mousemove', this.maybeHidePopupBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeHidePopup(e: any) {
|
||||||
|
let poi = get(overpassPopupPOI);
|
||||||
|
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) {
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideWaypointPopup() {
|
||||||
|
overpassPopupPOI.set(null);
|
||||||
|
overpassPopup.remove();
|
||||||
|
|
||||||
|
this.map.off('mousemove', this.maybeHidePopupBinded);
|
||||||
}
|
}
|
||||||
|
|
||||||
query(bbox: [number, number, number, number]) {
|
query(bbox: [number, number, number, number]) {
|
||||||
@@ -149,23 +159,12 @@ export class OverpassLayer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.overpasstiles
|
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||||
.where('[x+y]')
|
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
|
||||||
.equals([x, y])
|
if (missingQueries.length > 0) {
|
||||||
.toArray()
|
this.queryTile(x, y, missingQueries);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,16 +178,13 @@ export class OverpassLayer {
|
|||||||
|
|
||||||
const bounds = mercator.bbox(x, y, this.queryZoom);
|
const bounds = mercator.bbox(x, y, this.queryZoom);
|
||||||
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
||||||
.then(
|
.then((response) => {
|
||||||
(response) => {
|
if (response.ok) {
|
||||||
if (response.ok) {
|
return response.json();
|
||||||
return response.json();
|
}
|
||||||
}
|
this.currentQueries.delete(`${x},${y}`);
|
||||||
this.currentQueries.delete(`${x},${y}`);
|
return Promise.reject();
|
||||||
return Promise.reject();
|
}, () => (this.currentQueries.delete(`${x},${y}`)))
|
||||||
},
|
|
||||||
() => this.currentQueries.delete(`${x},${y}`)
|
|
||||||
)
|
|
||||||
.then((data) => this.storeOverpassData(x, y, queries, data))
|
.then((data) => this.storeOverpassData(x, y, queries, data))
|
||||||
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||||
}
|
}
|
||||||
@@ -196,7 +192,7 @@ export class OverpassLayer {
|
|||||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||||
let time = Date.now();
|
let time = Date.now();
|
||||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
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) {
|
if (data.elements === undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -212,9 +208,7 @@ export class OverpassLayer {
|
|||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'Point',
|
type: 'Point',
|
||||||
coordinates: element.center
|
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||||
? [element.center.lon, element.center.lat]
|
|
||||||
: [element.lon, element.lat],
|
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
id: element.id,
|
id: element.id,
|
||||||
@@ -222,10 +216,9 @@ export class OverpassLayer {
|
|||||||
lon: element.center ? element.center.lon : element.lon,
|
lon: element.center ? element.center.lon : element.lon,
|
||||||
query: query,
|
query: query,
|
||||||
icon: `overpass-${query}`,
|
icon: `overpass-${query}`,
|
||||||
tags: element.tags,
|
tags: element.tags
|
||||||
type: element.type,
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,13 +241,11 @@ export class OverpassLayer {
|
|||||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||||
this.map.addImage(`overpass-${query}`, icon);
|
this.map.addImage(`overpass-${query}`, icon);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Lucide icons are SVG files with a 24x24 viewBox
|
// Lucide icons are SVG files with a 24x24 viewBox
|
||||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||||
icon.src =
|
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
||||||
'data:image/svg+xml,' +
|
|
||||||
encodeURIComponent(`
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||||
<g transform="translate(8 8)">
|
<g transform="translate(8 8)">
|
||||||
@@ -286,14 +277,9 @@ function getQuery(query: string) {
|
|||||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||||
if (arrayEntry !== undefined) {
|
if (arrayEntry !== undefined) {
|
||||||
return arrayEntry[1]
|
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
|
||||||
.map(
|
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||||
(val) =>
|
.join('')};`).join('');
|
||||||
`nwr${Object.entries(tags)
|
|
||||||
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
|
||||||
.join('')};`
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
} else {
|
} else {
|
||||||
return `nwr${Object.entries(tags)
|
return `nwr${Object.entries(tags)
|
||||||
.map(([tag, value]) => `[${tag}=${value}]`)
|
.map(([tag, value]) => `[${tag}=${value}]`)
|
||||||
@@ -310,9 +296,8 @@ function belongsToQuery(element: any, query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||||
return Object.entries(tags).every(([tag, value]) =>
|
return Object.entries(tags)
|
||||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentQueries() {
|
function getCurrentQueries() {
|
||||||
@@ -321,7 +306,5 @@ function getCurrentQueries() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(getLayers(currentQueries))
|
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||||
.filter(([_, selected]) => selected)
|
}
|
||||||
.map(([query, _]) => query);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user