1 Commits

Author SHA1 Message Date
vcoppe
15f7aea300 preserve timestamps option 2024-10-21 22:56:18 +02:00
1101 changed files with 43713 additions and 42626 deletions

View File

@@ -1,63 +1,63 @@
name: Deploy to GitHub Pages
on:
push:
branches: 'main'
push:
branches: 'main'
jobs:
build_site:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
build_site:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: |
gpx/package-lock.json
website/package-lock.json
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
gpx/package-lock.json
website/package-lock.json
- name: Install dependencies for gpx
run: npm install --prefix gpx
- name: Install dependencies for gpx
run: npm install --prefix gpx
- name: Build gpx
run: npm run build --prefix gpx
- name: Build gpx
run: npm run build --prefix gpx
- name: Install dependencies for website
run: npm install --prefix website
- name: Install dependencies for website
run: npm install --prefix website
- name: Create env file
run: |
touch website/.env
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
cat website/.env
- name: Create env file
run: |
touch website/.env
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
cat website/.env
- name: Build website
env:
BASE_PATH: ''
run: |
npm run build --prefix website
- name: Build website
env:
BASE_PATH: ''
run: |
npm run build --prefix website
- name: Upload Artifacts
uses: actions/upload-pages-artifact@v3
with:
path: 'website/build/'
- name: Upload Artifacts
uses: actions/upload-pages-artifact@v4
with:
path: 'website/build/'
deploy:
needs: build_site
runs-on: ubuntu-latest
deploy:
needs: build_site
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,16 +0,0 @@
{
"useTabs": false,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"overrides": [
{
"files": "**/*.svelte",
"options": {
"plugins": ["prettier-plugin-svelte"],
"parser": "svelte"
}
}
]
}

View File

@@ -1,7 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"svelte.svelte-vscode"
]
}

13
.vscode/settings.json vendored
View File

@@ -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"
}
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 gpx.studio
Copyright (c) 2024 gpx.studio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -26,9 +26,8 @@ Any help is greatly appreciated!
## Development
The code is split into two parts:
- `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
- `gpx`: a Typescript library for parsing and manipulating GPX files,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
@@ -55,25 +54,26 @@ npm run dev
This project has been made possible thanks to the following open source projects:
- Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic:
- [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
- [brouter](https://github.com/abrensch/brouter) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
- Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
- Design:
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
- Logic:
- [immer](https://github.com/immerjs/immer) — complex state management
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
- [brouter](https://github.com/abrensch/brouter) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
## License

1617
gpx/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,16 @@
"private": true,
"dependencies": {
"fast-xml-parser": "^4.5.0",
"immer": "^10.1.1"
"immer": "^10.1.1",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/node": "^20.16.10",
"@typescript-eslint/parser": "^8.22.0",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
},
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
"postinstall": "npm run build"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,4 @@ export * from './gpx';
export { Coordinates, LineStyleExtension, WaypointType } from './types';
export { parseGPX, buildGPX } from './io';
export * from './simplify';

View File

@@ -1,68 +1,25 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import { GPXFileType } from './types';
import { GPXFile } from './gpx';
const attributesWithNamespace = {
RoutePointExtension: 'gpxx:RoutePointExtension',
rpt: 'gpxx:rpt',
TrackPointExtension: 'gpxtpx:TrackPointExtension',
PowerExtension: 'gpxpx:PowerExtension',
atemp: 'gpxtpx:atemp',
hr: 'gpxtpx:hr',
cad: 'gpxtpx:cad',
Extensions: 'gpxtpx:Extensions',
PowerInWatts: 'gpxpx:PowerInWatts',
power: 'gpxpx:PowerExtension',
line: 'gpx_style:line',
color: 'gpx_style:color',
opacity: 'gpx_style:opacity',
width: 'gpx_style:width',
};
const floatPatterns = [
/[-+]?\d*\.\d+$/, // decimal
/[-+]?\d+$/, // integer
];
function safeParseFloat(value: string): number {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
return parsed;
}
for (const pattern of floatPatterns) {
const match = value.match(pattern);
if (match) {
return parseFloat(match[0]);
}
}
return 0.0;
}
import { XMLParser, XMLBuilder } from "fast-xml-parser";
import { GPXFileType } from "./types";
import { GPXFile } from "./gpx";
export function parseGPX(gpxData: string): GPXFile {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
attributeNamePrefix: "",
attributesGroupName: 'attributes',
removeNSPrefix: true,
isArray(name: string) {
return (
name === 'trk' ||
name === 'trkseg' ||
name === 'trkpt' ||
name === 'wpt' ||
name === 'rte' ||
name === 'rtept' ||
name === 'gpxx:rpt'
);
return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
},
attributeValueProcessor(attrName, attrValue, jPath) {
if (attrName === 'lat' || attrName === 'lon') {
return safeParseFloat(attrValue);
return parseFloat(attrValue);
}
return attrValue;
},
transformTagName(tagName: string) {
if (attributesWithNamespace[tagName]) {
return attributesWithNamespace[tagName];
if (tagName === 'power') {
// Transform the simple <power> tag to the more complex <gpxpx:PowerExtension> tag, the nested <gpxpx:PowerInWatts> tag is then handled by the tagValueProcessor
return 'gpxpx:PowerExtension';
}
return tagName;
},
@@ -70,29 +27,22 @@ export function parseGPX(gpxData: string): GPXFile {
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
if (isLeafNode) {
if (tagName === 'ele') {
return safeParseFloat(tagValue);
return parseFloat(tagValue);
}
if (tagName === 'time') {
return new Date(tagValue);
}
if (
tagName === 'gpxtpx:atemp' ||
tagName === 'gpxtpx:hr' ||
tagName === 'gpxtpx:cad' ||
tagName === 'gpxpx:PowerInWatts' ||
tagName === 'gpx_style:opacity' ||
tagName === 'gpx_style:width'
) {
return safeParseFloat(tagValue);
if (tagName === 'gpxtpx:atemp' || tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
return parseFloat(tagValue);
}
if (tagName === 'gpxpx:PowerExtension') {
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
// Note that this only targets the transformed <power> tag, since it must be a leaf node
return {
'gpxpx:PowerInWatts': safeParseFloat(tagValue),
'gpxpx:PowerInWatts': parseFloat(tagValue)
};
}
}
@@ -104,7 +54,7 @@ export function parseGPX(gpxData: string): GPXFile {
const parsed: GPXFileType = parser.parse(gpxData).gpx;
// @ts-ignore
if (parsed.metadata === '') {
if (parsed.metadata === "") {
parsed.metadata = {};
}
@@ -114,32 +64,25 @@ export function parseGPX(gpxData: string): GPXFile {
export function buildGPX(file: GPXFile, exclude: string[]): string {
const gpx = file.toGPXFileType(exclude);
let lastDate = undefined;
const builder = new XMLBuilder({
format: true,
ignoreAttributes: false,
attributeNamePrefix: '',
attributeNamePrefix: "",
attributesGroupName: 'attributes',
suppressEmptyNode: true,
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => {
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
if (tagValue instanceof Date) {
if (isNaN(tagValue.getTime())) {
return lastDate?.toISOString();
}
lastDate = tagValue;
return tagValue.toISOString();
}
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['xmlns'] = 'http://www.topografix.com/GPX/1/1';
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
gpx.attributes['xsi:schemaLocation'] =
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
gpx.attributes['xsi:schemaLocation'] = 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
@@ -150,24 +93,19 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
}
return builder.build({
'?xml': {
"?xml": {
attributes: {
version: '1.0',
encoding: 'UTF-8',
},
version: "1.0",
encoding: "UTF-8",
}
},
gpx: removeEmptyElements(gpx),
gpx: removeEmptyElements(gpx)
});
}
function removeEmptyElements(obj: GPXFileType): GPXFileType {
for (const key in obj) {
if (
obj[key] === null ||
obj[key] === undefined ||
obj[key] === '' ||
(Array.isArray(obj[key]) && obj[key].length === 0)
) {
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
delete obj[key];
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
removeEmptyElements(obj[key]);
@@ -177,4 +115,4 @@ function removeEmptyElements(obj: GPXFileType): GPXFileType {
}
}
return obj;
}
}

View File

@@ -1,48 +1,33 @@
import { TrackPoint } from './gpx';
import { Coordinates } from './types';
import { TrackPoint } from "./gpx";
import { Coordinates } from "./types";
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
const earthRadius = 6371008.8;
export function ramerDouglasPeucker(
points: TrackPoint[],
epsilon: number = 50,
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
): SimplifiedTrackPoint[] {
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
if (points.length == 0) {
return [];
} else if (points.length == 1) {
return [
{
point: points[0],
},
];
return [{
point: points[0]
}];
}
let simplified = [
{
point: points[0],
},
];
let simplified = [{
point: points[0]
}];
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
simplified.push({
point: points[points.length - 1],
point: points[points.length - 1]
});
return simplified;
}
function ramerDouglasPeuckerRecursive(
points: TrackPoint[],
epsilon: number,
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
start: number,
end: number,
simplified: SimplifiedTrackPoint[]
) {
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
let largest = {
index: 0,
distance: 0,
distance: 0
};
for (let i = start + 1; i < end; i++) {
@@ -60,20 +45,12 @@ function ramerDouglasPeuckerRecursive(
}
}
export function crossarcDistance(
point1: TrackPoint,
point2: TrackPoint,
point3: TrackPoint | Coordinates
): number {
return crossarc(
point1.getCoordinates(),
point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
);
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
// 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.
// 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?
if (diff > Math.PI / 2) {
if (diff > (Math.PI / 2)) {
return dis13;
}
@@ -106,8 +83,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3);
} else {
@@ -117,32 +93,18 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points.
return (
Math.acos(
Math.sin(latA) * Math.sin(latB) +
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
) * earthRadius
);
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
}
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another.
return Math.atan2(
Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
);
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
}
export function projectedPoint(
point1: TrackPoint,
point2: TrackPoint,
point3: TrackPoint | Coordinates
): Coordinates {
return projected(
point1.getCoordinates(),
point2.getCoordinates(),
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
);
export function projectedPoint(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
}
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
@@ -170,7 +132,7 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
}
// Is relative bearing obtuse?
if (diff > Math.PI / 2) {
if (diff > (Math.PI / 2)) {
return coord1;
}
@@ -179,23 +141,15 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 =
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return coord2;
} else {
// Determine the closest point (p4) on the great circle
const f = dis14 / earthRadius;
const lat4 = Math.asin(
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
);
const lon4 =
lon1 +
Math.atan2(
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
);
const lat4 = Math.asin(Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12));
const lon4 = lon1 + Math.atan2(Math.sin(bear12) * Math.sin(f) * Math.cos(lat1), Math.cos(f) - Math.sin(lat1) * Math.sin(lat4));
return { lat: lat4 / rad, lon: lon4 / rad };
}
}
}

View File

@@ -67,9 +67,9 @@ export type TrackExtensions = {
};
export type LineStyleExtension = {
'gpx_style:color'?: string;
'gpx_style:opacity'?: number;
'gpx_style:width'?: number;
color?: string;
opacity?: number;
weight?: number;
};
export type TrackSegmentType = {
@@ -93,11 +93,11 @@ export type TrackPointExtension = {
'gpxtpx:hr'?: number;
'gpxtpx:cad'?: number;
'gpxtpx:Extensions'?: Record<string, string>;
};
}
export type PowerExtension = {
'gpxpx:PowerInWatts'?: number;
};
}
export type Author = {
name?: string;
@@ -114,12 +114,12 @@ export type RouteType = {
type?: string;
extensions?: TrackExtensions;
rtept: WaypointType[];
};
}
export type RoutePointExtension = {
'gpxx:rpt'?: GPXXRoutePoint[];
};
}
export type GPXXRoutePoint = {
attributes: Coordinates;
};
}

View File

@@ -16,9 +16,9 @@
<type>Cycling</type>
<extensions>
<gpx_style:line>
<gpx_style:color>2d3ee9</gpx_style:color>
<gpx_style:opacity>0.5</gpx_style:opacity>
<gpx_style:width>6</gpx_style:width>
<color>#2d3ee9</color>
<opacity>0.5</opacity>
<weight>6</weight>
</gpx_style:line>
</extensions>
<trkseg>

View File

@@ -4,7 +4,9 @@
"target": "ES2015",
"declaration": true,
"outDir": "./dist",
"moduleResolution": "node"
"moduleResolution": "node",
},
"include": ["src"]
}
"include": [
"src"
],
}

View File

@@ -1,31 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte'],
},
env: {
browser: true,
es2017: true,
node: true,
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
},
},
],
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

2
website/.gitignore vendored
View File

@@ -8,5 +8,3 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
static/*.webmanifest
!static/en.manifest.webmanifest

View File

@@ -2,5 +2,3 @@
pnpm-lock.yaml
package-lock.json
yarn.lock
src/lib/components/ui
*.mdx

8
website/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -2,16 +2,13 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
"utils": "$lib/utils"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
"typescript": true
}

6684
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,7 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"postbuild": "npx tsx src/lib/sitemap.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -14,71 +13,67 @@
"format": "prettier --write ."
},
"devDependencies": {
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/vite": "^4.1.8",
"@types/eslint": "^9.6.1",
"@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.8",
"@sveltejs/kit": "^2.6.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^8.56.12",
"@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.4.1",
"@types/node": "^22.15.30",
"@types/mapbox-gl": "^3.4.0",
"@types/node": "^20.16.10",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.16.0",
"@types/sanitize-html": "^2.13.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.12.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.44.1",
"events": "^3.3.0",
"glob": "^11.0.2",
"lucide-static": "^0.513.0",
"mdsvex": "^0.12.6",
"mode-watcher": "^1.1.0",
"paneforge": "^1.0.0-next.5",
"glob": "^10.4.5",
"mdsvex": "^0.11.2",
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.33.18",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.8",
"tslib": "^2.8.1",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.13",
"tslib": "^2.7.0",
"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"
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-node-polyfills": "^0.22.0"
},
"type": "module",
"dependencies": {
"@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2",
"@docsearch/js": "^3.6.2",
"@internationalized/date": "^3.5.5",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2",
"@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^1.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3",
"chart.js": "^4.4.9",
"chartjs-plugin-zoom": "^2.2.0",
"bits-ui": "^0.21.15",
"chart.js": "^4.4.4",
"chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"dexie": "^4.0.8",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapbox-gl": "^3.12.0",
"lucide-static": "^0.427.0",
"lucide-svelte": "^0.427.0",
"mapbox-gl": "^3.7.0",
"mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.0"
"sanitize-html": "^2.13.0",
"sortablejs": "^1.15.3",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,126 +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);
--selection: hsl(240 4.8% 93%);
--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);
--selection: hsl(240 3.7% 22%);
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-support: var(--support);
--color-link: var(--link);
--breakpoint-xs: 540px;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

14
website/src/app.d.ts vendored
View File

@@ -1,13 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,14 +1,15 @@
<!doctype html>
<html>
<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">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

86
website/src/app.pcss Normal file
View File

@@ -0,0 +1,86 @@
@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% 45%;
--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;
--link: 0 110 180;
--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;
--link: 80 190 255;
--ring: hsl(212.7,26.8%,83.9);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -38,20 +38,16 @@ export async function handle({ event, resolve }) {
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
if (page !== '404') {
for (let lang of Object.keys(languages)) {
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
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),
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag),
});
return response;
}
}

View File

@@ -1,28 +1,28 @@
export const surfaceColors: { [key: string]: string } = {
missing: '#d1d1d1',
paved: '#8c8c8c',
unpaved: '#6b443a',
asphalt: '#8c8c8c',
concrete: '#8c8c8c',
cobblestone: '#ffd991',
paving_stones: '#8c8c8c',
sett: '#ffd991',
metal: '#8c8c8c',
wood: '#6b443a',
compacted: '#ffffa8',
fine_gravel: '#ffffa8',
gravel: '#ffffa8',
pebblestone: '#ffffa8',
rock: '#ffd991',
dirt: '#ffffa8',
ground: '#6b443a',
earth: '#6b443a',
mud: '#6b443a',
sand: '#ffffc4',
grass: '#61b55c',
grass_paver: '#61b55c',
clay: '#6b443a',
stone: '#ffd991',
"missing": "#d1d1d1",
"paved": "#8c8c8c",
"unpaved": "#6b443a",
"asphalt": "#8c8c8c",
"concrete": "#8c8c8c",
"cobblestone": "#ffd991",
"paving_stones": "#8c8c8c",
"sett": "#ffd991",
"metal": "#8c8c8c",
"wood": "#6b443a",
"compacted": "#ffffa8",
"fine_gravel": "#ffffa8",
"gravel": "#ffffa8",
"pebblestone": "#ffffa8",
"rock": "#ffd991",
"dirt": "#ffffa8",
"ground": "#6b443a",
"earth": "#6b443a",
"mud": "#6b443a",
"sand": "#ffffc4",
"grass": "#61b55c",
"grass_paver": "#61b55c",
"clay": "#6b443a",
"stone": "#ffd991",
};
export function getSurfaceColor(surface: string): string {
@@ -30,72 +30,66 @@ export function getSurfaceColor(surface: string): string {
}
export const highwayColors: { [key: string]: string } = {
missing: '#d1d1d1',
motorway: '#ff4d33',
motorway_link: '#ff4d33',
trunk: '#ff5e4d',
trunk_link: '#ff947f',
primary: '#ff6e5c',
primary_link: '#ff6e5c',
secondary: '#ff8d7b',
secondary_link: '#ff8d7b',
tertiary: '#ffd75f',
tertiary_link: '#ffd75f',
unclassified: '#f1f2a5',
road: '#f1f2a5',
residential: '#73b2ff',
living_street: '#73b2ff',
service: '#9c9cd9',
track: '#a8e381',
footway: '#a8e381',
path: '#a8e381',
pedestrian: '#a8e381',
cycleway: '#9de2ff',
construction: '#e09a4a',
bridleway: '#946f43',
raceway: '#ff0000',
rest_area: '#9c9cd9',
services: '#9c9cd9',
corridor: '#474747',
elevator: '#474747',
steps: '#474747',
bus_stop: '#8545a3',
busway: '#8545a3',
via_ferrata: '#474747',
"missing": "#d1d1d1",
"motorway": "#ff4d33",
"motorway_link": "#ff4d33",
"trunk": "#ff5e4d",
"trunk_link": "#ff947f",
"primary": "#ff6e5c",
"primary_link": "#ff6e5c",
"secondary": "#ff8d7b",
"secondary_link": "#ff8d7b",
"tertiary": "#ffd75f",
"tertiary_link": "#ffd75f",
"unclassified": "#f1f2a5",
"road": "#f1f2a5",
"residential": "#73b2ff",
"living_street": "#73b2ff",
"service": "#9c9cd9",
"track": "#a8e381",
"footway": "#a8e381",
"path": "#a8e381",
"pedestrian": "#a8e381",
"cycleway": "#9de2ff",
"construction": "#e09a4a",
"bridleway": "#946f43",
"raceway": "#ff0000",
"rest_area": "#9c9cd9",
"services": "#9c9cd9",
"corridor": "#474747",
"elevator": "#474747",
"steps": "#474747",
"bus_stop": "#8545a3",
"busway": "#8545a3",
"via_ferrata": "#474747"
};
export const sacScaleColors: { [key: string]: string } = {
hiking: '#007700',
mountain_hiking: '#1843ad',
demanding_mountain_hiking: '#ffff00',
alpine_hiking: '#ff9233',
demanding_alpine_hiking: '#ff0000',
difficult_alpine_hiking: '#000000',
"hiking": "#007700",
"mountain_hiking": "#1843ad",
"demanding_mountain_hiking": "#ffff00",
"alpine_hiking": "#ff9233",
"demanding_alpine_hiking": "#ff0000",
"difficult_alpine_hiking": "#000000",
};
export const mtbScaleColors: { [key: string]: string } = {
'0-': '#007700',
'0': '#007700',
'0+': '#007700',
'1-': '#1843ad',
'1': '#1843ad',
'1+': '#1843ad',
'2-': '#ffff00',
'2': '#ffff00',
'2+': '#ffff00',
'3': '#ff0000',
'4': '#00ff00',
'5': '#000000',
'6': '#b105eb',
"0-": "#007700",
"0": "#007700",
"0+": "#007700",
"1-": "#1843ad",
"1": "#1843ad",
"1+": "#1843ad",
"2-": "#ffff00",
"2": "#ffff00",
"2+": "#ffff00",
"3": "#ff0000",
"4": "#00ff00",
"5": "#000000",
"6": "#b105eb",
};
function createPattern(
backgroundColor: string,
sacScaleColor: string | undefined,
mtbScaleColor: string | undefined,
size: number = 16,
lineWidth: number = 4
) {
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
@@ -110,11 +104,11 @@ function createPattern(
if (sacScaleColor) {
ctx.strokeStyle = sacScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
ctx.stroke();
}
@@ -125,8 +119,8 @@ function createPattern(
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
ctx.stroke();
}
}
@@ -134,16 +128,12 @@ function createPattern(
}
const patterns: Record<string, string | CanvasPattern> = {};
export function getHighwayColor(
highway: string,
sacScale: string | undefined,
mtbScale: string | undefined
) {
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
if (sacScale || mtbScale) {
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
if (!patterns[patternId]) {
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
}
@@ -168,4 +158,4 @@ export function getSlopeColor(slope: number): string {
let lightness = 90 - Math.abs(v) * 70;
return `hsl(${hue},70%,${lightness}%)`;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +1,10 @@
import {
Landmark,
Icon,
Shell,
Bike,
Building,
Tent,
Car,
Wrench,
ShoppingBasket,
Droplet,
DoorOpen,
Trees,
Fuel,
House,
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,
House as HouseSvg,
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';
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
import type { ComponentType } from "svelte";
export type Symbol = {
value: string;
icon?: Component<IconProps>;
icon?: ComponentType<Icon>;
iconSvg?: string;
};
@@ -82,30 +20,18 @@ export const symbols: { [key: string]: Symbol } = {
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
convenience_store: {
value: 'Convenience Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
crossing: { value: 'Crossing' },
department_store: {
value: 'Department Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
lodge: { value: 'Lodge', icon: House, iconSvg: HouseSvg },
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
ground_transportation: {
value: 'Ground Transportation',
icon: TrainFront,
iconSvg: TrainFrontSvg,
},
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
house: { value: 'House', icon: House, iconSvg: HouseSvg },
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
@@ -113,7 +39,7 @@ export const symbols: { [key: string]: Symbol } = {
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg },
restroom: { value: 'Restroom' },
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
@@ -129,6 +55,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
} else {
return Object.keys(symbols).find((key) => symbols[key].value === value);
return Object.keys(symbols).find(key => symbols[key].value === value);
}
}
}

View File

@@ -1,66 +1,60 @@
<script lang="ts">
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
let props: {
class?: string;
} = $props();
let mounted = false;
let mounted = false;
function initDocsearch() {
docsearch({
appId: '21XLD94PE3',
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')]
},
placeholder: $_('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search')
},
modal: {
searchBox: {
resetButtonTitle: $_('docs.search.clear'),
resetButtonAriaLabel: $_('docs.search.clear'),
cancelButtonText: $_('docs.search.cancel'),
cancelButtonAriaLabel: $_('docs.search.cancel'),
searchInputLabel: $_('docs.search.search')
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close')
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion')
}
}
}
});
}
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;
});
onMount(() => {
mounted = true;
});
$effect(() => {
if (mounted && i18n.lang && !i18n.isLoading) {
initDocsearch();
}
});
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
</svelte:head>
<div id="docsearch" class={props.class ?? ''}></div>
<div id="docsearch" {...$$restProps}></div>

View File

@@ -1,38 +1,28 @@
<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';
import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Builder } from 'bits-ui';
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();
export let variant:
| 'default'
| 'secondary'
| 'link'
| 'destructive'
| 'outline'
| 'ghost'
| undefined = 'default';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
export let builders: Builder[] = [];
</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>
<Tooltip.Root>
<Tooltip.Trigger asChild let:builder>
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
<slot />
</Button>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -0,0 +1,671 @@
<script lang="ts">
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
TriangleRight,
HeartPulse,
Orbit,
SquareActivity,
Thermometer,
Zap,
Circle,
Check,
ChartNoAxesColumn,
Construction
} from 'lucide-svelte';
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
import { _ } from 'svelte-i18n';
import {
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
getConvertedTemperature,
getConvertedVelocity,
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateWithUnits,
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits
} from '$lib/units';
import type { Writable } from 'svelte/store';
import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/db';
import { mode } from 'mode-watcher';
import { df } from '$lib/utils';
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let additionalDatasets: string[];
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
export let showControls: boolean = true;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
let canvas: HTMLCanvasElement;
let overlay: HTMLCanvasElement;
let chart: Chart;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
let marker: mapboxgl.Marker | null = null;
let dragging = false;
let panning = false;
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
ticks: {
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
},
align: 'inner',
maxRotation: 0
}
},
y: {
type: 'linear',
ticks: {
callback: function (value: number) {
return getElevationWithUnits(value, false);
}
}
}
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone'
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: false
},
decimation: {
enabled: true
},
tooltip: {
enabled: () => !dragging && !panning,
callbacks: {
title: function () {
return '';
},
label: function (context: Chart.TooltipContext) {
let point = context.raw;
if (context.datasetIndex === 0) {
if ($map && marker) {
if (dragging) {
marker.remove();
} else {
marker.setLngLat(point.coordinates);
marker.addTo($map);
}
}
return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) {
return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) {
return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) {
return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) {
return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
afterBody: function (contexts: Chart.TooltipContext[]) {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length)
};
let surface = point.extensions.surface ? point.extensions.surface : 'unknown';
let highway = point.extensions.highway ? point.extensions.highway : 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
let labels = [
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`
];
if (elevationFill === 'surface') {
labels.push(
` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
);
}
if (elevationFill === 'highway') {
labels.push(
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
sacScale ? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})` : ''
}`
);
if (mtbScale) {
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
}
}
if (point.time) {
labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
}
return labels;
}
}
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: function () {
// hide tooltip
panning = true;
$slicedGPXStatistics = undefined;
},
onPanComplete: function () {
panning = false;
}
},
zoom: {
wheel: {
enabled: true
},
mode: 'x',
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
if (
event.deltaY < 0 &&
Math.abs(
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
chart.getZoomLevel()
) < 0.01
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
$slicedGPXStatistics = undefined;
}
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1
}
}
}
},
stacked: false,
onResize: function () {
updateOverlay();
}
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
grid: {
display: false
},
reverse: () => id === 'speed' && $velocityUnits === 'pace',
display: false
};
});
onMount(async () => {
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
chart = new Chart(canvas, {
type: 'line',
data: {
datasets: []
},
options,
plugins: [
{
id: 'toggleMarker',
events: ['mouseout'],
afterEvent: function (chart: Chart, args: { event: Chart.ChartEvent }) {
if (args.event.type === 'mouseout') {
if ($map && marker) {
marker.remove();
}
}
}
}
]
});
// Map marker to show on hover
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
marker = new mapboxgl.Marker({
element
});
let startIndex = 0;
let endIndex = 0;
function getIndex(evt) {
const points = chart.getElementsAtEventForMode(
evt,
'x',
{
intersect: false
},
true
);
if (points.length === 0) {
const rect = canvas.getBoundingClientRect();
if (evt.x - rect.left <= chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= chart.chartArea.right) {
return $gpxStatistics.local.points.length - 1;
} else {
return undefined;
}
}
let point = points.find((point) => point.element.raw);
if (point) {
return point.element.raw.index;
} else {
return points[0].index;
}
}
let dragStarted = false;
function onMouseDown(evt) {
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
}
function onMouseMove(evt) {
if (dragStarted) {
dragging = true;
endIndex = getIndex(evt);
if (endIndex !== undefined) {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
$slicedGPXStatistics = [
$gpxStatistics.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
];
}
}
}
}
function onMouseUp(evt) {
dragStarted = false;
dragging = false;
canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
$slicedGPXStatistics = undefined;
}
}
canvas.addEventListener('pointerdown', onMouseDown);
canvas.addEventListener('pointermove', onMouseMove);
canvas.addEventListener('pointerup', onMouseUp);
});
$: if (chart && $distanceUnits && $velocityUnits && $temperatureUnits) {
let data = $gpxStatistics;
// update data
chart.data.datasets[0] = {
label: $_('quantities.elevation'),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index]
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index
};
}),
normalized: true,
fill: 'start',
order: 1
};
chart.data.datasets[1] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index
};
}),
normalized: true,
yAxisID: 'yspeed',
hidden: true
};
chart.data.datasets[2] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index
};
}),
normalized: true,
yAxisID: 'yhr',
hidden: true
};
chart.data.datasets[3] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index
};
}),
normalized: true,
yAxisID: 'ycad',
hidden: true
};
chart.data.datasets[4] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index
};
}),
normalized: true,
yAxisID: 'yatemp',
hidden: true
};
chart.data.datasets[5] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index
};
}),
normalized: true,
yAxisID: 'ypower',
hidden: true
};
chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
chart.update();
}
function slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment);
}
function surfaceFillCallback(context) {
return getSurfaceColor(context.p0.raw.extensions.surface);
}
function highwayFillCallback(context) {
return getHighwayColor(
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
}
$: if (chart) {
if (elevationFill === 'slope') {
chart.data.datasets[0]['segment'] = {
backgroundColor: slopeFillCallback
};
} else if (elevationFill === 'surface') {
chart.data.datasets[0]['segment'] = {
backgroundColor: surfaceFillCallback
};
} else if (elevationFill === 'highway') {
chart.data.datasets[0]['segment'] = {
backgroundColor: highwayFillCallback
};
} else {
chart.data.datasets[0]['segment'] = {};
}
chart.update();
}
$: if (additionalDatasets && chart) {
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (chart.data.datasets.length > 0) {
chart.data.datasets[1].hidden = !includeSpeed;
chart.data.datasets[2].hidden = !includeHeartRate;
chart.data.datasets[3].hidden = !includeCadence;
chart.data.datasets[4].hidden = !includeTemperature;
chart.data.datasets[5].hidden = !includePower;
}
chart.update();
}
function updateOverlay() {
if (!canvas) {
return;
}
overlay.width = canvas.width / window.devicePixelRatio;
overlay.height = canvas.height / window.devicePixelRatio;
overlay.style.width = `${overlay.width}px`;
overlay.style.height = `${overlay.height}px`;
if ($slicedGPXStatistics) {
let startIndex = $slicedGPXStatistics[1];
let endIndex = $slicedGPXStatistics[2];
// Draw selection rectangle
let selectionContext = overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
let startPixel = chart.scales.x.getPixelForValue(
getConvertedDistance($gpxStatistics.local.distance.total[startIndex])
);
let endPixel = chart.scales.x.getPixelForValue(
getConvertedDistance($gpxStatistics.local.distance.total[endIndex])
);
selectionContext.fillRect(
startPixel,
chart.chartArea.top,
endPixel - startPixel,
chart.chartArea.height
);
}
} else if (overlay) {
let selectionContext = overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
}
}
}
$: $slicedGPXStatistics, $mode, updateOverlay();
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div class="h-full grow min-w-0 relative py-2">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls}
<div class="absolute bottom-10 right-1.5">
<Popover.Root>
<Popover.Trigger asChild let:builder>
<ButtonWithTooltip
label={$_('chart.settings')}
builders={[builder]}
variant="outline"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
>
<ChartNoAxesColumn size="18" />
</ButtonWithTooltip>
</Popover.Trigger>
<Popover.Content class="w-fit p-0 flex flex-col divide-y" side="top" sideOffset={-32}>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
type="single"
bind:value={elevationFill}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'slope'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<TriangleRight size="15" class="mr-1" />
{$_('quantities.slope')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'surface'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<BrickWall size="15" class="mr-1" />
{$_('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if elevationFill === 'highway'}
<Circle class="h-1.5 w-1.5 fill-current text-current" />
{/if}
</div>
<Construction size="15" class="mr-1" />
{$_('quantities.highway')}
</ToggleGroup.Item>
</ToggleGroup.Root>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
type="multiple"
bind:value={additionalDatasets}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('speed')}
<Check size="14" />
{/if}
</div>
<Zap size="15" class="mr-1" />
{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('hr')}
<Check size="14" />
{/if}
</div>
<HeartPulse size="15" class="mr-1" />
{$_('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('cad')}
<Check size="14" />
{/if}
</div>
<Orbit size="15" class="mr-1" />
{$_('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('atemp')}
<Check size="14" />
{/if}
</div>
<Thermometer size="15" class="mr-1" />
{$_('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center">
{#if additionalDatasets.includes('power')}
<Check size="14" />
{/if}
</div>
<SquareActivity size="15" class="mr-1" />
{$_('quantities.power')}
</ToggleGroup.Item>
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div>
{/if}
</div>

View File

@@ -0,0 +1,186 @@
<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,
Earth,
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,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: true
};
let hide: Record<string, boolean> = {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: 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;
hide.extensions = Object.keys(statistics.global.extensions).length === 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-secondary"
>
<span>⚠️</span>
<span class="max-w-[80%] 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 {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">
{$_('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 {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" />
{$_('quantities.osm_extensions')}
</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>

View File

@@ -1,116 +1,125 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
</script>
<footer class="w-full">
<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="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2025 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<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">
<span class="font-semibold">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
</div>
</div>
</div>
<div 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="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2024 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<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">
<span class="font-semibold">{$_('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/')}
>
<Home size="16" class="mr-1" />
{$_('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/app')}
>
<Map size="16" class="mr-1" />
{$_('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/help')}
>
<BookOpenText size="16" class="mr-1" />
{$_('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.x')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" class="mr-1" />
{$_('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" class="mr-1" />
{$_('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.github')}
</Button>
</div>
</div>
</div>
</div>
</footer>

View File

@@ -1,93 +1,82 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import * as Card from '$lib/components/ui/card';
import Tooltip from '$lib/components/Tooltip.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 type { GPXStatistics } from 'gpx';
import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { _ } from 'svelte-i18n';
import type { GPXStatistics } from 'gpx';
import type { Writable } from 'svelte/store';
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 {
gpxStatistics,
slicedGPXStatistics,
orientation,
panelSize,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props();
const { velocityUnits } = settings;
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
);
let statistics: GPXStatistics;
$: if ($slicedGPXStatistics !== undefined) {
statistics = $slicedGPXStatistics[0];
} else {
statistics = $gpxStatistics;
}
</script>
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none p-0"
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none"
>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
</Tooltip>
{/if}
</Card.Content>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={$_('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={$_('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
</Tooltip>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -1,27 +1,20 @@
<script lang="ts">
import { CircleQuestionMark } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { Snippet } from 'svelte';
import { CircleHelp } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
let {
link,
class: className = '',
children,
}: {
link: string;
class?: string;
children: Snippet;
} = $props();
export let link: string | undefined = undefined;
</script>
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
{@render children()}
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{i18n._('menu.more')}
</a>
{/if}
</div>
<div
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>
<slot />
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{$_('menu.more')}
</a>
{/if}
</div>
</div>

View File

@@ -1,36 +1,51 @@
<script lang="ts">
import { page } from '$app/state';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
let {
class: className = '',
}: {
class?: string;
} = $props();
let selected = {
value: '',
label: ''
};
$: if ($locale) {
selected = {
value: $locale,
label: languages[$locale]
};
}
</script>
<Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
<Languages size="16" />
<span class="mr-auto">
{languages[i18n.lang]}
</span>
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#if page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each}
</Select.Content>
<Select.Root bind:selected>
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
<Languages size="16" />
<Select.Value class="ml-2 mr-auto" />
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#if $page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each}
</Select.Content>
</Select.Root>
<!-- hidden links for svelte crawling -->
<div class="hidden">
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
{label}
</a>
{/each}
{/if}
</div>

View File

@@ -1,68 +1,73 @@
<script lang="ts">
import { mode } from 'mode-watcher';
import { base } from '$app/paths';
import { base } from '$app/paths';
import { mode, systemPrefersMode } from 'mode-watcher';
let {
iconOnly = false,
company = 'gpx.studio',
...others
}: {
iconOnly?: boolean;
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
[key: string]: any;
} = $props();
export let iconOnly = false;
export let company = 'gpx.studio';
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
</script>
{#if company === 'gpx.studio'}
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio."
{...others}
/>
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio."
{...$$restProps}
/>
{:else if company === 'mapbox'}
<img
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...others}
/>
<img
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...$$restProps}
/>
{:else if company === 'github'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><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"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><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"
/></svg
>
{:else if company === 'crowdin'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><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"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><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"
/></svg
>
{:else if company === 'facebook'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.class ?? ''}"
><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"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><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"
/></svg
>
{:else if company === 'x'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
{:else if company === 'reddit'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {others.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
>
<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}

View File

@@ -0,0 +1,394 @@
<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';
}
const loadJson = mapboxgl.Style.prototype._load;
mapboxgl.Style.prototype._load = function (json, validate) {
if (
json['sources'] &&
json['sources']['mapbox-satellite'] &&
json['sources']['mapbox-satellite']['data'] &&
json['sources']['mapbox-satellite']['data']['data']
) {
// Temporary fix for https://github.com/gpxstudio/gpx.studio/issues/129
delete json['sources']['mapbox-satellite']['data']['data'];
}
loadJson.call(this, json, validate);
};
let newMap = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
}
},
{
id: 'basemap',
url: ''
},
{
id: 'overlays',
url: '',
data: {
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
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits);
});
newMap.addControl(
new mapboxgl.AttributionControl({
compact: true
})
);
newMap.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true
})
);
if (geocoder) {
let geocoder = new MapboxGeocoder({
mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
localGeocoder: () => [],
localGeocoderOnly: true,
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat]
},
place_name: result.display_name
};
});
})
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
}
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
});
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1
});
}
newMap.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)'
});
newMap.on('pitch', () => {
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1
});
} else {
newMap.setTerrain(null);
}
});
});
});
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-50;
}
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>

View File

@@ -0,0 +1,25 @@
<svelte:options accessors />
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import type { Writable } from 'svelte/store';
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
import type { PopupItem } from './MapPopup';
export let item: Writable<PopupItem | null>;
export let container: HTMLDivElement | null = null;
</script>
<div bind:this={container}>
{#if $item}
{#if $item.item instanceof Waypoint}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
{/if}
{/if}
</div>

View File

@@ -1,38 +1,35 @@
import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
import { TrackPoint, Waypoint } from "gpx";
import mapboxgl from "mapbox-gl";
import { tick } from "svelte";
import { get, writable, type Writable } from "svelte/store";
import MapPopupComponent from "./MapPopup.svelte";
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
fileId?: string;
hide?: () => void;
};
export class MapPopup {
map: mapboxgl.Map;
popup: mapboxgl.Popup;
item: Writable<PopupItem | null> = writable(null);
component: ReturnType<typeof mount>;
maybeHideBinded = this.maybeHide.bind(this);
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
this.map = map;
this.popup = new mapboxgl.Popup(options);
this.component = mount(MapPopupComponent, {
let component = new MapPopupComponent({
target: document.body,
props: {
item: this.item,
onContainerReady: (container: HTMLDivElement) => {
this.popup.setDOMContent(container);
},
},
item: this.item
}
});
tick().then(() => this.popup.setDOMContent(component.container));
}
setItem(item: PopupItem | null) {
if (item) item.hide = () => this.hide();
this.item.set(item);
if (item === null) {
this.hide();
@@ -42,8 +39,8 @@ export class MapPopup {
}
show() {
const item = get(this.item);
if (item === null) {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
@@ -52,8 +49,8 @@ export class MapPopup {
}
maybeHide(e: mapboxgl.MapMouseEvent) {
const item = get(this.item);
if (item === null) {
const i = get(this.item);
if (i === null) {
this.hide();
return;
}
@@ -69,16 +66,13 @@ export class MapPopup {
remove() {
this.popup.remove();
unmount(this.component);
}
getCoordinates() {
const item = get(this.item);
if (item === null) {
const i = get(this.item);
if (i === null) {
return new mapboxgl.LngLat(0, 0);
}
return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates()
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,25 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from '@lucide/svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _ } from 'svelte-i18n';
let {
class: className = '',
}: {
class?: string;
} = $props();
export let size = '20';
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
</script>
<Button
variant="ghost"
size="icon"
class={className}
onclick={() => {
setMode(mode.current === 'light' ? 'dark' : 'light');
}}
aria-label={i18n._('menu.mode')}
variant="ghost"
class="h-8 px-1.5 {$$props.class ?? ''}"
on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light');
}}
aria-label={$_('menu.mode')}
>
{#if mode.current === 'light'}
<Sun />
{:else}
<Moon />
{/if}
{#if selectedMode === 'light'}
<Sun {size} />
{:else}
<Moon {size} />
{/if}
</Button>

View File

@@ -1,44 +1,32 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
</script>
<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">
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden sm:block" width="153" />
</a>
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="18" />
{i18n._('homepage.home')}
</Button>
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="18" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="18" />
{i18n._('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:inline-flex" />
</div>
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<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 hidden sm:block" width="153" />
</a>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
<Home size="18" class="mr-1.5" />
{$_('homepage.home')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
<Map size="18" class="mr-1.5" />
{$_('homepage.app')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
<BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
</div>
</nav>

View File

@@ -1,48 +1,41 @@
<script lang="ts">
let {
orientation = 'col',
after = $bindable(),
minAfter = 0,
maxAfter = Number.MAX_SAFE_INTEGER,
}: {
orientation?: 'col' | 'row';
after: number;
minAfter?: number;
maxAfter?: number;
} = $props();
export let orientation: 'col' | 'row' = 'col';
function handleMouseDown(event: PointerEvent) {
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
export let after: number;
export let minAfter: number = 0;
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
const handleMouseMove = (event: PointerEvent) => {
const newAfter =
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;
}
};
function handleMouseDown(event: PointerEvent) {
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
};
const handleMouseMove = (event: PointerEvent) => {
const newAfter =
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);
window.addEventListener('pointerup', handleMouseUp);
}
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
};
window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
onpointerdown={handleMouseDown}
></div>
class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
on:pointerdown={handleMouseDown}
/>

View File

@@ -1,43 +1,36 @@
<script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import * as Kbd from '$lib/components/ui/kbd/index.js';
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
let {
key = undefined,
shift = false,
ctrl = false,
click = false,
class: className = '',
}: {
key?: string;
shift?: boolean;
ctrl?: boolean;
click?: boolean;
class?: string;
} = $props();
export let key: string | undefined = undefined;
export let shift: boolean = false;
export let ctrl: boolean = false;
export let click: boolean = false;
let mac = $state(false);
let safari = $state(false);
let mac = false;
let safari = false;
onMount(() => {
mac = isMac();
safari = isSafari();
});
onMount(() => {
mac = isMac();
safari = isSafari();
});
</script>
<Kbd.Root class="ml-auto {className}">
{#if shift}
{/if}
{#if ctrl}
{mac && !safari ? '⌘' : i18n._('menu.ctrl')}
{/if}
{#if key}
{key}
{/if}
{#if click}
{i18n._('menu.click')}
{/if}
</Kbd.Root>
<div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
>
{#if shift}
<span></span>
{/if}
{#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if}
{#if click}
<span>{$_('menu.click')}</span>
{/if}
</div>

View File

@@ -1,32 +1,18 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
let {
label,
side = 'top',
children,
extra,
class: className = '',
}: {
label: string;
side?: 'top' | 'right' | 'bottom' | 'left';
children: Snippet;
extra?: Snippet;
class?: string;
} = $props();
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger class={className} aria-label={label}>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center gap-2">
<span>{label}</span>
{@render extra?.()}
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot />
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,56 +1,48 @@
<script lang="ts">
import {
celsiusToFahrenheit,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS,
} from '$lib/units';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS
} from '$lib/units';
let {
value,
type,
showUnits = true,
decimals = undefined,
class: className = '',
}: {
value: number;
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
showUnits?: boolean;
decimals?: number;
class?: string;
} = $props();
import { _ } from 'svelte-i18n';
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>
<span class={className}>
{#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}
{value} {showUnits ? i18n._('units.celsius') : ''}
{:else}
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
{/if}
{:else if type === 'time'}
{secondsToHHMMSS(value)}
{/if}
<span class={$$props.class}>
{#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/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>

View File

@@ -1,28 +1,20 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte';
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
const {
defaultState = 'open',
side = 'right',
nohover = false,
slotInsideTrigger = true,
children,
}: {
defaultState?: 'open' | 'closed';
side?: 'left' | 'right';
nohover?: boolean;
slotInsideTrigger?: boolean;
children: Snippet;
} = $props();
export let defaultState: 'open' | 'closed' = 'open';
export let side: 'left' | 'right' = 'right';
export let nohover: boolean = false;
export let slotInsideTrigger: boolean = true;
let open = $state(new CollapsibleTreeState(defaultState));
let open = writable<Record<string, boolean>>({});
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
setContext('collapsible-tree-default-state', defaultState);
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
</script>
{@render children()}
<slot />

View File

@@ -1,93 +1,97 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getContext, onMount, setContext } from 'svelte';
import { get, type Writable } from 'svelte/store';
const props: {
id: string | number;
class?: ClassValue;
trigger: Snippet;
content: Snippet;
} = $props();
export let id: string | number;
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId);
let fullId = `${parentId}.${id}`;
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() {
open.current = true;
}
export function openNode() {
open.update((value) => {
value[fullId] = true;
return value;
});
}
</script>
<Collapsible.Root bind:open={open.current} class={props.class}>
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background'
: ''} pointer-events-none"
>
{#if side === 'left'}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
{/if}
{@render props.trigger()}
{#if side === 'right'}
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
{/if}
</Button>
</Collapsible.Trigger>
{:else}
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
>
{#if side === 'left'}
<Collapsible.Trigger>
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
{@render props.trigger()}
{#if side === 'right'}
<Collapsible.Trigger>
{#if open.current}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
</Button>
{/if}
<Collapsible.Content>
{@render props.content()}
</Collapsible.Content>
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover
? 'hover:bg-background'
: ''} pointer-events-none"
>
{#if side === 'left'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
{/if}
<slot name="trigger" />
{#if side === 'right'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
{/if}
</Button>
</Collapsible.Trigger>
{:else}
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
>
{#if side === 'left'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
<slot name="trigger" />
{#if side === 'right'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
</Button>
{/if}
<Collapsible.Content class="ml-2">
<slot name="content" />
</Collapsible.Content>
</Collapsible.Root>

View File

@@ -1,2 +1,2 @@
export { default as CollapsibleTree } from './CollapsibleTree.svelte';
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';

View File

@@ -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];
}
}

View File

@@ -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>

View File

@@ -17,4 +17,4 @@ export default class CustomControl implements IControl {
this._container?.parentNode?.removeChild(this._container);
this._map = undefined;
}
}
}

View File

@@ -1,85 +1,82 @@
<script lang="ts">
import type { Component } from 'svelte';
import { _ } from 'svelte-i18n';
let { module: Module }: { module: Component } = $props();
export let module;
</script>
<div class="markdown flex flex-col gap-3">
<Module />
<svelte:component this={module} />
</div>
<style lang="postcss">
@reference "../../../app.css";
:global(.markdown) {
@apply text-muted-foreground;
}
: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 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 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 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 p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown > a) {
@apply text-link;
@apply hover:underline;
@apply contents;
}
:global(.markdown p > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown p > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown li > 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 kbd) {
@apply p-1;
@apply rounded-md;
@apply border;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown hr) {
@apply my-5;
}
:global(.markdown hr) {
@apply my-5;
}
</style>

View File

@@ -1,34 +1,25 @@
<script lang="ts">
let {
src,
alt,
}: {
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
alt: string;
} = $props();
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
export let alt: string;
</script>
<div class="flex flex-col items-center py-6 w-full">
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{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 class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{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>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
</script>
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
/>
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
/>
</div>

View File

@@ -1,22 +1,18 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
export let type: 'note' | 'warning' = 'note';
</script>
<div
class="bg-secondary border-l-8 {type === 'note'
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
class="bg-secondary border-l-8 {type === 'note'
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
>
{@render children()}
<slot />
</div>
<style lang="postcss">
@reference "../../../app.css";
div :global(a) {
@apply text-link;
@apply hover:underline;
}
div :global(a) {
@apply text-link;
@apply hover:underline;
}
</style>

View File

@@ -1,64 +1,39 @@
import {
File,
FilePen,
View,
Settings,
Pencil,
MapPin,
Scissors,
CalendarClock,
Group,
Ungroup,
Funnel,
SquareDashedMousePointer,
MountainSnow,
type IconProps,
} from '@lucide/svelte';
import type { Component } from 'svelte';
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
import type { ComponentType } from "svelte";
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
'gpx': [],
'integration': [],
'faq': [],
};
export const guideIcons: Record<string, string | Component<IconProps>> = {
'getting-started': '🚀',
menu: '📂 ⚙️',
file: File,
edit: FilePen,
view: View,
settings: Settings,
'files-and-stats': '🗂 📈',
toolbar: '🧰',
routing: Pencil,
poi: MapPin,
scissors: Scissors,
time: CalendarClock,
merge: Group,
extract: Ungroup,
elevation: MountainSnow,
minify: Funnel,
clean: SquareDashedMousePointer,
'map-controls': '🗺',
gpx: '💾',
integration: '{ 👩‍💻 }',
faq: '🔮',
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"getting-started": "🚀",
"menu": "📂 ⚙️",
"file": File,
"edit": FilePen,
"view": View,
"settings": Settings,
"files-and-stats": "🗂 📈",
"toolbar": "🧰",
"routing": Pencil,
"poi": MapPin,
"scissors": Scissors,
"time": CalendarClock,
"merge": Group,
"extract": Ungroup,
"elevation": MountainSnow,
"minify": Filter,
"clean": SquareDashedMousePointer,
"map-controls": "🗺",
"gpx": "💾",
"integration": "{ 👩‍💻 }",
"faq": "🔮",
};
export function getPreviousGuide(currentGuide: string): string | undefined {
@@ -121,4 +96,4 @@ export function getNextGuide(currentGuide: string): string | undefined {
return undefined;
}
}
}
}

View File

@@ -1,203 +0,0 @@
<script lang="ts">
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
import Separator from '$lib/components/ui/separator/separator.svelte';
import { onDestroy, onMount } from 'svelte';
import {
BrickWall,
TriangleRight,
HeartPulse,
Orbit,
SquareActivity,
Thermometer,
Zap,
Circle,
Check,
ChartNoAxesColumn,
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
const { velocityUnits } = settings;
let {
gpxStatistics,
slicedGPXStatistics,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;
} = $props();
let canvas: HTMLCanvasElement;
let overlay: HTMLCanvasElement;
let elevationProfile: ElevationProfile | null = null;
onMount(() => {
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
additionalDatasets,
elevationFill,
canvas,
overlay
);
});
onDestroy(() => {
if (elevationProfile) {
elevationProfile.destroy();
}
});
</script>
<div class="h-full grow min-w-0 relative py-2">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls}
<div class="absolute bottom-10 right-1.5">
<Popover.Root>
<Popover.Trigger>
<ButtonWithTooltip
label={i18n._('chart.settings')}
variant="outline"
side="left"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
>
<ChartNoAxesColumn size="18" />
</ButtonWithTooltip>
</Popover.Trigger>
<Popover.Content
class="w-fit p-0 flex flex-col"
side="top"
align="end"
sideOffset={-32}
>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1 w-full border-none"
type="single"
bind:value={$elevationFill}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'slope'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<TriangleRight size="15" />
{i18n._('quantities.slope')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<BrickWall size="15" />
{i18n._('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'}
<Circle class="size-1.5 fill-current text-current" />
{/if}
</div>
<Construction size="15" />
{i18n._('quantities.highway')}
</ToggleGroup.Item>
</ToggleGroup.Root>
<Separator />
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
type="multiple"
bind:value={$additionalDatasets}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('speed')}
<Check size="14" />
{/if}
</div>
<Zap size="15" />
{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('hr')}
<Check size="14" />
{/if}
</div>
<HeartPulse size="15" />
{i18n._('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('cad')}
<Check size="14" />
{/if}
</div>
<Orbit size="15" />
{i18n._('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('atemp')}
<Check size="14" />
{/if}
</div>
<Thermometer size="15" />
{i18n._('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('power')}
<Check size="14" />
{/if}
</div>
<SquareActivity size="15" />
{i18n._('quantities.power')}
</ToggleGroup.Item>
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</div>
{/if}
</div>

View File

@@ -1,603 +0,0 @@
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import {
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
getConvertedTemperature,
getConvertedVelocity,
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateWithUnits,
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits,
} from '$lib/units';
import Chart from 'chart.js/auto';
import mapboxgl from 'mapbox-gl';
import { get, type Readable, type Writable } from 'svelte/store';
import { map } from '$lib/components/map/map';
import type { GPXStatistics } from 'gpx';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
export class ElevationProfile {
private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement;
private _marker: mapboxgl.Marker | null = null;
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatistics>;
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatistics>,
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
this._marker = new mapboxgl.Marker({
element,
});
import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default);
this.initialize();
this._gpxStatistics.subscribe(() => {
this.updateData();
});
this._slicedGPXStatistics.subscribe(() => {
this.updateOverlay();
});
distanceUnits.subscribe(() => {
this.updateData();
});
velocityUnits.subscribe(() => {
this.updateData();
});
temperatureUnits.subscribe(() => {
this.updateData();
});
this._additionalDatasets.subscribe(() => {
this.updateDataVisibility();
});
this._elevationFill.subscribe(() => {
this.updateFill();
});
});
}
initialize() {
let options = {
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
ticks: {
callback: function (value: number) {
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
},
align: 'inner',
maxRotation: 0,
},
},
y: {
type: 'linear',
ticks: {
callback: function (value: number) {
return getElevationWithUnits(value, false);
},
},
},
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone',
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
plugins: {
legend: {
display: false,
},
decimation: {
enabled: true,
},
tooltip: {
enabled: () => !this._dragging && !this._panning,
callbacks: {
title: () => {
return '';
},
label: (context: Chart.TooltipContext) => {
let point = context.raw;
if (context.datasetIndex === 0) {
const map_ = get(map);
if (map_ && this._marker) {
if (this._dragging) {
this._marker.remove();
} else {
this._marker.setLngLat(point.coordinates);
this._marker.addTo(map_);
}
}
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) {
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) {
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) {
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
afterBody: (contexts: Chart.TooltipContext[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length),
};
let surface = point.extensions.surface
? point.extensions.surface
: 'unknown';
let highway = point.extensions.highway
? point.extensions.highway
: 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
];
if (get(this._elevationFill) === 'surface') {
labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
);
}
if (get(this._elevationFill) === 'highway') {
labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
: ''
}`
);
if (mtbScale) {
labels.push(
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
);
}
}
if (point.time) {
labels.push(
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
);
}
return labels;
},
},
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: () => {
this._panning = true;
this._slicedGPXStatistics.set(undefined);
},
onPanComplete: () => {
this._panning = false;
},
},
zoom: {
wheel: {
enabled: true,
},
mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
if (
event.deltaY < 0 &&
Math.abs(
this._chart.getInitialScaleBounds().x.max /
this._chart.options.plugins.zoom.limits.x.minRange -
this._chart.getZoomLevel()
) < 0.01
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
this._slicedGPXStatistics.set(undefined);
},
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1,
},
},
},
},
stacked: false,
onResize: () => {
this.updateOverlay();
},
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales[`y${id}`] = {
type: 'linear',
position: 'right',
grid: {
display: false,
},
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
display: false,
};
});
this._chart = new Chart(this._canvas, {
type: 'line',
data: {
datasets: [],
},
options,
plugins: [
{
id: 'toggleMarker',
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
if (args.event.type === 'mouseout') {
const map_ = get(map);
if (map_ && this._marker) {
this._marker.remove();
}
}
},
},
],
});
let startIndex = 0;
let endIndex = 0;
const getIndex = (evt) => {
if (!this._chart) {
return undefined;
}
const points = this._chart.getElementsAtEventForMode(
evt,
'x',
{
intersect: false,
},
true
);
if (points.length === 0) {
const rect = this._canvas.getBoundingClientRect();
if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
return get(this._gpxStatistics).local.points.length - 1;
} else {
return undefined;
}
}
let point = points.find((point) => point.element.raw);
if (point) {
return point.element.raw.index;
} else {
return points[0].index;
}
};
let dragStarted = false;
const onMouseDown = (evt) => {
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
};
const onMouseMove = (evt) => {
if (dragStarted) {
this._dragging = true;
endIndex = getIndex(evt);
if (endIndex !== undefined) {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).slice(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
]);
}
}
}
};
const onMouseUp = (evt) => {
dragStarted = false;
this._dragging = false;
this._canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
this._slicedGPXStatistics.set(undefined);
}
};
this._canvas.addEventListener('pointerdown', onMouseDown);
this._canvas.addEventListener('pointermove', onMouseMove);
this._canvas.addEventListener('pointerup', onMouseUp);
}
updateData() {
if (!this._chart) {
return;
}
const data = get(this._gpxStatistics);
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
normalized: true,
fill: 'start',
order: 1,
segment: {},
};
this._chart.data.datasets[1] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedVelocity(data.local.speed[index]),
index: index,
};
}),
normalized: true,
yAxisID: 'yspeed',
};
this._chart.data.datasets[2] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getHeartRate(),
index: index,
};
}),
normalized: true,
yAxisID: 'yhr',
};
this._chart.data.datasets[3] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getCadence(),
index: index,
};
}),
normalized: true,
yAxisID: 'ycad',
};
this._chart.data.datasets[4] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: getConvertedTemperature(point.getTemperature()),
index: index,
};
}),
normalized: true,
yAxisID: 'yatemp',
};
this._chart.data.datasets[5] = {
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index]),
y: point.getPower(),
index: index,
};
}),
normalized: true,
yAxisID: 'ypower',
};
this._chart.options.scales.x['min'] = 0;
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
this.setVisibility();
this.setFill();
this._chart.update();
}
updateDataVisibility() {
if (!this._chart) {
return;
}
this.setVisibility();
this._chart.update();
}
setVisibility() {
if (!this._chart) {
return;
}
const additionalDatasets = get(this._additionalDatasets);
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (this._chart.data.datasets.length == 6) {
this._chart.data.datasets[1].hidden = !includeSpeed;
this._chart.data.datasets[2].hidden = !includeHeartRate;
this._chart.data.datasets[3].hidden = !includeCadence;
this._chart.data.datasets[4].hidden = !includeTemperature;
this._chart.data.datasets[5].hidden = !includePower;
}
}
updateFill() {
if (!this._chart) {
return;
}
this.setFill();
this._chart.update();
}
setFill() {
if (!this._chart) {
return;
}
const elevationFill = get(this._elevationFill);
if (elevationFill === 'slope') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.slopeFillCallback,
};
} else if (elevationFill === 'surface') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.surfaceFillCallback,
};
} else if (elevationFill === 'highway') {
this._chart.data.datasets[0]['segment'] = {
backgroundColor: this.highwayFillCallback,
};
} else {
this._chart.data.datasets[0]['segment'] = {};
}
}
updateOverlay() {
if (!this._chart) {
return;
}
this._overlay.width = this._canvas.width / window.devicePixelRatio;
this._overlay.height = this._canvas.height / window.devicePixelRatio;
this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`;
const slicedGPXStatistics = get(this._slicedGPXStatistics);
if (slicedGPXStatistics) {
let startIndex = slicedGPXStatistics[1];
let endIndex = slicedGPXStatistics[2];
// Draw selection rectangle
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
);
selectionContext.fillRect(
startPixel,
this._chart.chartArea.top,
endPixel - startPixel,
this._chart.chartArea.height
);
}
} else if (this._overlay) {
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
}
}
}
slopeFillCallback(context) {
return getSlopeColor(context.p0.raw.slope.segment);
}
surfaceFillCallback(context) {
return getSurfaceColor(context.p0.raw.extensions.surface);
}
highwayFillCallback(context) {
return getHighwayColor(
context.p0.raw.extensions.highway,
context.p0.raw.extensions.sac_scale,
context.p0.raw.extensions.mtb_scale
);
}
destroy() {
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
if (this._marker) {
this._marker.remove();
}
}
}

View File

@@ -1,139 +1,267 @@
<script lang="ts">
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/map/Map.svelte';
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions,
} from './embedding';
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/Map.svelte';
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import {
gpxStatistics,
slicedGPXStatistics,
embedding,
loadFile,
map,
updateGPXData
} from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
import { readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions
} from './Embedding';
import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
let {
useHash = true,
options = $bindable(),
hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
$embedding = true;
let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
const {
currentBasemap,
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers
} = settings;
const {
currentBasemap,
selectedBasemapTree,
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers,
} = settings;
export let useHash = true;
export let options: EmbeddingOptions;
export let hash: string;
settings.initialize();
let prevSettings = {
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system'
};
function applyOptions() {
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
});
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap;
}
if (!isSelected($selectedBasemapTree, options.basemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
}
$distanceMarkers = options.distanceMarkers;
$directionMarkers = options.directionMarkers;
$distanceUnits = options.distanceUnits;
$velocityUnits = options.velocityUnits;
$temperatureUnits = options.temperatureUnits;
if (options.theme != 'system') {
setMode(options.theme);
}
function applyOptions() {
fileObservers.update(($fileObservers) => {
$fileObservers.clear();
return $fileObservers;
});
additionalDatasets.set(
[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null,
].filter((dataset) => dataset !== null)
);
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
}
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)
);
});
$effect(() => {
options;
untrack(applyOptions);
});
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(() => {
prevSettings.distanceMarkers = $distanceMarkers;
prevSettings.directionMarkers = $directionMarkers;
prevSettings.distanceUnits = $distanceUnits;
prevSettings.velocityUnits = $velocityUnits;
prevSettings.temperatureUnits = $temperatureUnits;
prevSettings.theme = $mode ?? 'system';
});
$: if (browser && 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>
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative">
<Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
geocoder={false}
geolocate={true}
hash={useHash}
/>
<OpenIn files={options.files} ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileStateCollection.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
{/if}
</div>
<div
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` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{additionalDatasets}
{elevationFill}
showControls={options.elevation.controls}
/>
{/if}
</div>
<div class="grow relative">
<Map
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
geocoder={false}
geolocate={false}
hash={useHash}
/>
<OpenIn bind:files={options.files} bind:ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileObservers.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
{/if}
</div>
<div
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` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
additionalDatasets={[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null
].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill}
showControls={options.elevation.controls}
/>
{/if}
</div>
</div>

View File

@@ -10,7 +10,7 @@ export type EmbeddingOptions = {
show: boolean;
height: number;
controls: boolean;
fill: 'slope' | 'surface' | 'highway' | 'none';
fill: 'slope' | 'surface' | 'highway' | undefined;
speed: boolean;
hr: boolean;
cad: boolean;
@@ -34,32 +34,32 @@ export const defaultEmbeddingOptions = {
show: true,
height: 170,
controls: true,
fill: 'none',
fill: undefined,
speed: false,
hr: false,
cad: false,
temp: false,
power: false,
power: false
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system',
theme: 'system'
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
}
export function getMergedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) {
if (
typeof options[key] === 'object' &&
options[key] !== null &&
!Array.isArray(options[key])
) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else {
mergedOptions[key] = options[key];
@@ -79,10 +79,7 @@ export function getCleanedEmbeddingOptions(
cleanedOptions[key] !== null &&
!Array.isArray(cleanedOptions[key])
) {
cleanedOptions[key] = getCleanedEmbeddingOptions(
cleanedOptions[key],
defaultOptions[key]
);
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key];
}
@@ -144,7 +141,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope',
fill: 'slope'
};
}
return newOptions;

View File

@@ -1,329 +1,328 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
Zap,
HeartPulse,
Orbit,
Thermometer,
SquareActivity,
Coins,
Milestone,
Video,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import {
allowedEmbeddingBasemaps,
defaultEmbeddingOptions,
getCleanedEmbeddingOptions,
getMergedEmbeddingOptions,
} from './embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { onDestroy } from 'svelte';
import { base } from '$app/paths';
import { map } from '$lib/components/map/map';
import { mode } from 'mode-watcher';
import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
Zap,
HeartPulse,
Orbit,
Thermometer,
SquareActivity,
Coins,
Milestone,
Video
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import {
allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions
} from './Embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { map } from '$lib/stores';
import { tick } from 'svelte';
import { base } from '$app/paths';
let options = $state(
getMergedEmbeddingOptions(
{
token: 'YOUR_MAPBOX_TOKEN',
theme: mode.current,
},
defaultEmbeddingOptions
)
);
let files = $state(
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
);
let driveIds = $state('');
let options = getDefaultEmbeddingOptions();
options.token = 'YOUR_MAPBOX_TOKEN';
options.files = [
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
];
let iframeOptions = $derived(
getMergedEmbeddingOptions(
{
token:
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? PUBLIC_MAPBOX_TOKEN
: options.token,
files: files.split(',').filter((url) => url.length > 0),
ids: driveIds.split(',').filter((id) => id.length > 0),
elevation: {
fill: options.elevation.fill === 'none' ? undefined : options.elevation.fill,
},
},
options
)
);
let files = options.files[0];
$: {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
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 = $state(false);
let zoom = $state('0');
let lat = $state('0');
let lon = $state('0');
let bearing = $state('0');
let pitch = $state('0');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
let manualCamera = false;
$effect(() => {
if (options.elevation.show || options.elevation.height) {
map.resize();
}
});
let zoom = '0';
let lat = '0';
let lon = '0';
let bearing = '0';
let pitch = '0';
function updateCamera() {
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
}
}
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
map.onLoad((map_) => {
map_.on('moveend', updateCamera);
});
$: iframeOptions =
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
: options;
onDestroy(() => {
if ($map) {
$map.off('moveend', updateCamera);
}
});
async function resizeMap() {
if ($map) {
await tick();
$map.resize();
}
}
$: if (options.elevation.height || options.elevation.show) {
resizeMap();
}
function updateCamera() {
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
}
}
$: if ($map) {
$map.on('moveend', updateCamera);
}
</script>
<Card.Root id="embedding-playground">
<Card.Header>
<Card.Title>{i18n._('embedding.title')}</Card.Title>
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
<Select.Root type="single" bind:value={options.basemap}>
<Select.Trigger id="basemap" class="w-full h-8">
{i18n._(`layers.label.${options.basemap}`)}
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap}
>{i18n._(`layers.label.${basemap}`)}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
<div class="flex flex-row items-center gap-2">
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} />
</div>
{#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{i18n._('embedding.height')}
<Input
type="number"
bind:value={options.elevation.height}
class="h-8 w-20"
/>
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
{i18n._('embedding.fill_by')}
</span>
<Select.Root type="single" bind:value={options.elevation.fill}>
<Select.Trigger class="grow h-8">
{options.elevation.fill !== 'none'
? i18n._(`quantities.${options.elevation.fill}`)
: i18n._('embedding.none')}
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
>
<Select.Item value="surface"
>{i18n._('quantities.surface')}</Select.Item
>
<Select.Item value="highway"
>{i18n._('quantities.highway')}</Select.Item
>
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="controls" bind:checked={options.elevation.controls} />
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{i18n._('quantities.speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{i18n._('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{i18n._('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{i18n._('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{i18n._('quantities.power')}
</Label>
</div>
</div>
{/if}
<div class="flex flex-row items-center gap-2">
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" />
{i18n._('menu.distance_markers')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" />
{i18n._('menu.direction_markers')}
</Label>
</div>
<div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2">
{i18n._('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{i18n._('menu.metric')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{i18n._('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{i18n._('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{i18n._('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{i18n._('quantities.speed')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{i18n._('quantities.pace')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{i18n._('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{i18n._('menu.celsius')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
</div>
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{i18n._('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{i18n._('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{i18n._('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{i18n._('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
<div class="flex flex-col gap-3 p-3 border rounded-md">
<div class="flex flex-row items-center gap-2">
<Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" />
{i18n._('embedding.manual_camera')}
</Label>
</div>
<p class="text-sm text-muted-foreground">
{i18n._('embedding.manual_camera_description')}
</p>
<div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.latitude')}</span>
<span>{lat}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.longitude')}</span>
<span>{lon}</span>
</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 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">
<Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="token">{$_('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
onSelectedChange={(selected) => {
if (selected?.value) {
options.basemap = selected?.value;
}
}}
>
<Select.Trigger id="basemap" class="w-full h-8">
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex flex-row items-center gap-2">
<Label for="profile">{$_('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} />
</div>
{#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{$_('embedding.height')}
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
{$_('embedding.fill_by')}
</span>
<Select.Root
selected={{ value: 'none', label: $_('embedding.none') }}
onSelectedChange={(selected) => {
let value = selected?.value;
if (value === 'none') {
options.elevation.fill = undefined;
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
options.elevation.fill = value;
}
}}
>
<Select.Trigger class="grow h-8">
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-row items-center gap-2">
<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">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
{/if}
<div class="flex flex-row items-center gap-2">
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" />
{$_('menu.distance_markers')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" />
{$_('menu.direction_markers')}
</Label>
</div>
<div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2">
{$_('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{$_('menu.metric')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{$_('quantities.speed')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{$_('quantities.pace')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{$_('menu.celsius')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
</div>
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{$_('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{$_('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{$_('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{$_('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
<div class="flex flex-col gap-3 p-3 border rounded-md">
<div class="flex flex-row items-center gap-2">
<Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" />
{$_('embedding.manual_camera')}
</Label>
</div>
<p class="text-sm text-muted-foreground">
{$_('embedding.manual_camera_description')}
</p>
<div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1">
<span>{$_('embedding.latitude')}</span>
<span>{lat}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.longitude')}</span>
<span>{lon}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.zoom')}</span>
<span>{zoom}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.bearing')}</span>
<span>{bearing}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.pitch')}</span>
<span>{pitch}</span>
</Label>
</div>
</div>
<Label>
{$_('embedding.preview')}
</Label>
<div class="relative h-[600px]">
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
</div>
<Label>
{$_('embedding.code')}
</Label>
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<code class="language-html">
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(iframeOptions)))}${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>
</pre>
</fieldset>
</Card.Content>
</fieldset>
</Card.Content>
</Card.Root>

View File

@@ -2,27 +2,22 @@
import { Button } from '$lib/components/ui/button';
import Logo from '$lib/components/Logo.svelte';
import { getURLForLanguage } from '$lib/utils';
import { i18n } from '$lib/i18n.svelte';
import { _, locale } from 'svelte-i18n';
let {
files,
ids,
}: {
files: string[];
ids: string[];
} = $props();
export let files: string[];
export let ids: string[];
</script>
<Button
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"
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
href="{getURLForLanguage($locale, '/app')}?{files.length > 0
? `files=${encodeURIComponent(JSON.stringify(files))}`
: ''}{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]" />
</Button>

View File

@@ -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-col sm:flex-row items-center justify-center gap-1 sm:gap-2 border rounded-md p-2 bg-secondary"
>
<span class="w-12 shrink-0 text-center text-xl">⚠️</span>
<span class="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>🙏</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" />
{#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>

View File

@@ -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');
}
}

View File

@@ -1,91 +1,89 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte';
import { fileStateCollection } from '$lib/logic/file-state';
import { createFile, pasteSelection } from '$lib/logic/file-actions';
import { selection, copied } from '$lib/logic/selection';
import { allowedPastes } from './sortable-file-list';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selectAll, selection } from './Selection';
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores';
let {
orientation,
recursive = false,
class: className = '',
style = '',
}: {
orientation: 'vertical' | 'horizontal';
recursive?: boolean;
class?: string;
style?: string;
} = $props();
export let orientation: 'vertical' | 'horizontal';
export let recursive = false;
setContext('orientation', orientation);
setContext('recursive', recursive);
setContext('orientation', orientation);
setContext('recursive', recursive);
onMount(() => {
if (orientation === 'horizontal') {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
}
});
const { verticalFileView } = settings;
verticalFileView.subscribe(($vertical) => {
if ($vertical) {
selection.update(($selection) => {
$selection.forEach((item) => {
if ($selection.hasAnyChildren(item, false)) {
$selection.toggle(item);
}
});
return $selection;
});
} else {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
}
});
</script>
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {className ?? ''}"
{style}
>
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item onclick={createFile}>
<Plus size="16" />
{i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
onclick={() => selection.selectAll()}
disabled={$fileStateCollection.size === 0}
>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {$$props.class ?? ''}"
{...$$restProps}
>
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
</ScrollArea>

View File

@@ -1,14 +1,37 @@
import { dbUtils, getFile } from "$lib/db";
import { freeze } from "immer";
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
import { selection } from "./Selection";
import { newGPXFile } from "$lib/stores";
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
WAYPOINT
}
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
};
export abstract class ListItem {
[x: string]: any;
level: ListLevel;
constructor(level: ListLevel) {
@@ -298,3 +321,120 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
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;
});
}

View File

@@ -1,89 +1,83 @@
<script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './file-list';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { settings, type GPXFileWithStatistics } from '$lib/db';
import { get, type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { afterUpdate, getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem
} from './FileList';
import { _ } from 'svelte-i18n';
import { selection } from './Selection';
let {
node,
item,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
} = $props();
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
export let item: ListItem;
let recursive = getContext<boolean>('recursive');
let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode | undefined = $state();
let collapsible: CollapsibleTreeNode;
let label = $derived(
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ??
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? i18n._('gpx.waypoints')
: ''
);
$: label =
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
: node instanceof TrackSegment
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
: node instanceof GPXFile && item instanceof ListWaypointsItem
? $_('gpx.waypoints')
: '';
const { treeFileView } = settings;
const { verticalFileView } = settings;
$effect(() => {
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
collapsible.openNode();
}
});
function openIfSelectedChild() {
if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
collapsible.openNode();
}
}
if ($selection) {
openIfSelectedChild();
}
afterUpdate(openIfSelectedChild);
</script>
{#if node instanceof Map}
<FileListNodeContent {node} {item} />
<FileListNodeContent {node} {item} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
{#snippet trigger()}
<FileListNodeLabel {node} {item} {label} />
{/snippet}
{#snippet content()}
<div class="ml-4">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
{/snippet}
</CollapsibleTreeNode>
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<FileListNodeLabel {node} {item} {label} slot="trigger" />
<div slot="content" class="ml-2">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
</CollapsibleTreeNode>
{:else}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{/if}

View File

@@ -1,29 +1,39 @@
<script lang="ts" context="module">
let dragging: Writable<ListLevel | null> = writable(null);
let updating = false;
</script>
<script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } from 'svelte/store';
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import {
ListFileItem,
ListLevel,
ListRootItem,
ListWaypointsItem,
allowedMoves,
moveItems,
type ListItem
} from './FileList';
import { selection } from './Selection';
import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n';
let {
node,
item,
waypointRoot = false,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
waypointRoot?: boolean;
} = $props();
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint;
export let item: ListItem;
export let waypointRoot: boolean = false;
let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel =
node instanceof Map
? ListLevel.FILE
@@ -36,32 +46,255 @@
: node instanceof Track
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
let sortable: Sortable;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
let destroyed = false;
let lastUpdateStart = 0;
function updateToSelection(e) {
if (destroyed) {
return;
}
let sortable: SortableFileList;
lastUpdateStart = Date.now();
setTimeout(() => {
if (Date.now() - lastUpdateStart >= 40) {
if (updating) {
return;
}
updating = true;
// Sortable updates selection
let changed = getChangedIds();
if (changed.length > 0) {
selection.update(($selection) => {
$selection.clear();
Object.entries(elements).forEach(([id, element]) => {
$selection.set(
item.extend(getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(
e.originalEvent.ctrlKey ||
e.originalEvent.metaKey ||
e.originalEvent.shiftKey
) &&
($selection.size > 1 ||
!$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;
});
}
updating = false;
}
}, 50);
}
function updateFromSelection() {
if (destroyed || updating) {
return;
}
updating = true;
// Selection updates sortable
let changed = getChangedIds();
for (let id of changed) {
let element = elements[id];
if (element) {
if ($selection.has(item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
} else {
Sortable.utils.deselect(element);
}
}
}
updating = false;
}
$: if ($selection) {
updateFromSelection();
}
const { fileOrder } = settings;
function syncFileOrder() {
if (!sortable || sortableLevel !== ListLevel.FILE) {
return;
}
const currentOrder = sortable.toArray();
if (currentOrder.length !== $fileOrder.length) {
sortable.sort($fileOrder);
} else {
for (let i = 0; i < currentOrder.length; i++) {
if (currentOrder[i] !== $fileOrder[i]) {
sortable.sort($fileOrder);
break;
}
}
}
}
$: if ($fileOrder) {
syncFileOrder();
}
function createSortable() {
sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true,
onSelect: updateToSelection,
onDeselect: updateToSelection,
onStart: () => {
dragging.set(sortableLevel);
},
onEnd: () => {
dragging.set(null);
},
onSort: (e) => {
if (sortableLevel === ListLevel.FILE) {
let newFileOrder = sortable.toArray();
if (newFileOrder.length !== get(fileOrder).length) {
fileOrder.set(newFileOrder);
} else {
for (let i = 0; i < newFileOrder.length; i++) {
if (newFileOrder[i] !== get(fileOrder)[i]) {
fileOrder.set(newFileOrder);
break;
}
}
}
}
let fromItem = Sortable.get(e.from)._item;
let toItem = Sortable.get(e.to)._item;
if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (Sortable.get(e.from)._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0
? e.oldIndicies.map((i) => i.index)
: [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (Sortable.get(e.to)._waypointRoot) {
toItem = toItem.extend('waypoints');
}
let newIndices: number[] =
e.newIndicies.length > 0
? e.newIndicies.map((i) => i.index)
: [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
$fileOrder.splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
}
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true
});
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true
});
}
onMount(() => {
sortable = new SortableFileList(
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
createSortable();
destroyed = false;
});
$effect(() => {
if (sortable && node) {
sortable.updateElements();
}
afterUpdate(() => {
elements = {};
container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (node instanceof Map && !node.has(attr)) {
element.remove();
} else {
elements[attr] = element;
}
}
}
});
syncFileOrder();
updateFromSelection();
});
onDestroy(() => {
sortable.destroy();
destroyed = true;
});
function getChangedIds() {
let changed: (string | number)[] = [];
Object.entries(elements).forEach(([id, element]) => {
let realId = getRealId(id);
let realItem = item.extend(realId);
let inSelection = get(selection).has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
function getRealId(id: string | number) {
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id);
}
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script>
<div
@@ -107,13 +340,11 @@
{#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot}
<FileListNodeContent {node} {item} waypointRoot={true} />
<svelte:self {node} {item} waypointRoot={true} />
{/if}
{/if}
<style lang="postcss">
@reference "../../../app.css";
.sortable > div {
@apply rounded-md;
@apply h-fit;
@@ -121,16 +352,20 @@
}
.vertical :global(button) {
@apply hover:bg-[var(--selection)];
@apply hover:bg-muted;
}
.vertical :global(.sortable-selected button) {
@apply hover:bg-accent;
}
.vertical :global(.sortable-selected) {
@apply bg-[var(--selection)];
@apply bg-accent;
}
.horizontal :global(button) {
@apply bg-[var(--selection)];
@apply hover:bg-background;
@apply bg-accent;
@apply hover:bg-muted;
}
.horizontal :global(.sortable-selected button) {

View File

@@ -1,327 +1,314 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as ContextMenu from '$lib/components/ui/context-menu';
import Shortcut from '$lib/components/Shortcut.svelte';
import {
Copy,
Info,
MapPin,
PaintBucket,
Plus,
Trash2,
Waypoints,
Eye,
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
} from '@lucide/svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
type ListItem,
} from './file-list';
import { getContext } from 'svelte';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection';
import { map } from '$lib/components/map/map';
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list';
import { Button } from '$lib/components/ui/button';
import * as ContextMenu from '$lib/components/ui/context-menu';
import Shortcut from '$lib/components/Shortcut.svelte';
import { dbUtils, getFile } from '$lib/db';
import {
Copy,
Info,
MapPin,
PaintBucket,
Plus,
Trash2,
Waypoints,
Eye,
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
FileX
} from 'lucide-svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
allowedPastes,
type ListItem
} from './FileList';
import {
copied,
copySelection,
cut,
cutSelection,
pasteSelection,
selectAll,
selectItem,
selection
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import {
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n';
import MetadataDialog from './MetadataDialog.svelte';
import StyleDialog from './StyleDialog.svelte';
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
let {
node,
item,
label,
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
label: string | undefined;
} = $props();
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let label: string | undefined;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let singleSelection = $derived($selection.size === 1);
$: singleSelection = $selection.size === 1;
let nodeColors: string[] = $state([]);
let nodeColors: string[] = [];
$effect.pre(() => {
let colors: string[] = [];
if (node && $map) {
if (node instanceof GPXFile) {
let defaultColor = undefined;
$: if (node && $map) {
nodeColors = [];
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
if (node instanceof GPXFile) {
let style = node.getStyle();
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
colors.push(style['gpx_style:color']);
}
}
if (colors.length === 0) {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
colors.push(layer.layerColor);
}
}
}
}
nodeColors = colors;
});
let layer = gpxLayers.get(item.getFileId());
if (layer) {
style.color.push(layer.layerColor);
}
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
style.color.forEach((c) => {
if (!nodeColors.includes(c)) {
nodeColors.push(c);
}
});
} else if (node instanceof Track) {
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 openEditMetadata: boolean = $derived(
editMetadata.current && singleSelection && $selection.has(item)
);
let openEditStyle: boolean = $derived(
editStyle.current &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
);
let openEditMetadata: boolean = false;
let openEditStyle: boolean = false;
let hidden = $derived(
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
);
$: 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>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<ContextMenu.Root
onOpenChange={(open) => {
if (open) {
if (!$selection.has(item)) {
selection.selectItem(item);
}
}
}}
onOpenChange={(open) => {
if (open) {
if (!get(selection).has(item)) {
selectItem(item);
}
}
}}
>
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
: 'right'},{nodeColors
.map(
(c, i) =>
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
)
.join(',')})"
></div>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
oncontextmenu={(e) => {
if (embedding) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.ctrlKey) {
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
}
}}
onmouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
waypointPopup?.setItem({
item: waypoint,
fileId: item.getFileId(),
});
}
}
}
}}
onmouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
}
}}
>
{#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
{:else}
<MapPin size="16" class="mx-1 shrink-0" />
{/if}
{/if}
<span
class="grow select-none truncate {orientation === 'vertical'
? 'last:mr-2'
: ''}"
>
{label}
</span>
{#if hidden}
<EyeOff
size="10"
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
? 'mr-3'
: 'mt-0.5'}"
/>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" />
{i18n._('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
>
{#if $allHidden}
<Eye size="16" />
{i18n._('menu.unhide')}
{:else}
<EyeOff size="16" />
{i18n._('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() =>
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
<Maximize size="16" />
{i18n._('menu.center')}
<Shortcut key="" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</ContextMenu.Item>
{#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item onclick={() => selection.cutSelection()}>
<Scissors size="16" />
{i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
: 'right'},{nodeColors
.map(
(c, i) =>
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
)
.join(',')})"
/>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
on:contextmenu={(e) => {
if ($embedding) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.ctrlKey) {
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
}
}}
on:mouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
let file = getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
}
}
}
}}
on:mouseleave={() => {
if (item instanceof ListWaypointItem) {
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}
<MapPin size="16" class="mr-1 shrink-0" />
{/if}
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
{label}
</span>
{#if hidden}
<EyeOff
size="12"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
? 'mr-3'
: ''}"
/>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
<Info size="16" class="mr-1" />
{$_('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={() => ($editStyle = true)}>
<PaintBucket size="16" class="mr-1" />
{$_('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
on:click={() => {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
}
}}
>
{#if $allHidden}
<Eye size="16" class="mr-1" />
{$_('menu.unhide')}
{:else}
<EyeOff size="16" class="mr-1" />
{$_('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewTrack(item.getFileId())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={selectAll}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" />
{$_('menu.close')}
{:else}
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
{/if}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>

View File

@@ -1,27 +1,23 @@
<script lang="ts">
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { GPXFileWithStatistics } from '$lib/db';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './FileList';
let {
file,
}: {
file: Readable<GPXFileWithStatistics | undefined>;
} = $props();
export let file: Readable<GPXFileWithStatistics | undefined>;
let recursive = getContext<boolean>('recursive');
let recursive = getContext<boolean>('recursive');
</script>
{#if $file}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
</CollapsibleTree>
{:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
</CollapsibleTree>
{:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{/if}

View File

@@ -4,56 +4,46 @@
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 { Save } from '@lucide/svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
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 { i18n } from '$lib/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { _ } from 'svelte-i18n';
import { editMetadata } from '$lib/stores';
let {
node,
item,
open = $bindable(),
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
open: boolean;
} = $props();
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let open = false;
let name: string = $derived(
let name: string =
node instanceof GPXFile
? (node.metadata.name ?? '')
? node.metadata.name ?? ''
: node instanceof Track
? (node.name ?? '')
: ''
);
let description: string = $derived(
? node.name ?? ''
: '';
let description: string =
node instanceof GPXFile
? (node.metadata.desc ?? '')
? node.metadata.desc ?? ''
: node instanceof Track
? (node.desc ?? '')
: ''
);
? node.desc ?? ''
: '';
$effect(() => {
if (!open) {
editMetadata.current = false;
}
});
$: if (!open) {
$editMetadata = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger class="-mx-1" />
<Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" />
<Button
variant="outline"
onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => {
on:click={() => {
dbUtils.applyToFile(item.getFileId(), (file) => {
if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name;
file.metadata.desc = description;
@@ -68,8 +58,8 @@
open = false;
}}
>
<Save size="16" />
{i18n._('menu.metadata.save')}
<Save size="16" class="mr-1" />
{$_('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View 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();
}
}

View 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>

View File

@@ -1,3 +0,0 @@
export const editMetadata = $state({
current: false,
});

View File

@@ -1,284 +0,0 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list';
import { selection } from '$lib/logic/selection';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
import { get, writable, type Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
import { tick } from 'svelte';
const { fileOrder } = settings;
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const dragging = writable<ListLevel | null>(null);
export class SortableFileList {
private _node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _sortableLevel: ListLevel;
private _container: HTMLElement;
private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false;
private _unsubscribes: (() => void)[] = [];
constructor(
container: HTMLElement,
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
waypointRoot: boolean,
sortableLevel: ListLevel,
orientation: Direction
) {
this._node = node;
this._item = item;
this._sortableLevel = sortableLevel;
this._container = container;
this._sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true,
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true,
onSelect: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50),
onDeselect: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50),
onStart: () => dragging.set(sortableLevel),
onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
});
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
this._unsubscribes.push(
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
);
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
}
onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder();
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
if (!from || !to) {
return;
}
let fromItem = from._item;
let toItem = to._item;
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (from._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
if (from._waypointRoot && to._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (to._waypointRoot) {
toItem = toItem.extend('waypoints');
}
let newIndices: number[] =
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
get(fileOrder).splice(i, 0, newFileIds[index]);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
}
updateFromSelection() {
const changed = this.getChangedIds();
if (changed.length === 0) {
return;
}
const selection_ = get(selection);
for (let id of changed) {
let element = this._elements[id];
if (element) {
if (selection_.has(this._item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
} else {
Sortable.utils.deselect(element);
}
}
}
}
updateToSelection(e: Sortable.SortableEvent) {
if (!this._sortable) return;
if (this._updatingSelection) return;
this._updatingSelection = true;
const changed = this.getChangedIds();
if (changed.length == 0) {
this._updatingSelection = false;
return;
}
selection.update(($selection) => {
$selection.clear();
Object.entries(this._elements).forEach(([id, element]) => {
$selection.set(
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
}
return $selection;
});
this._updatingSelection = false;
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
this._sortable.sort(fileOrder_);
}
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
fileOrder.set(sortableOrder);
}
}
updateElements() {
this._elements = {};
this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) {
element.remove();
} else {
this._elements[attr] = element;
}
}
}
});
}
destroy() {
this._sortable = null;
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
getChangedIds() {
let changed: (string | number)[] = [];
const selection_ = get(selection);
Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id);
let realItem = this._item.extend(realId);
let inSelection = selection_.has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
}

View File

@@ -1,171 +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 { 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 { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
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 = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(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 = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(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) {
untrack(() => 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;
}
fileActions.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === fileStateCollection.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 class="-mx-1" />
<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" />
{i18n._('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -1,3 +0,0 @@
export const editStyle = $state({
current: false,
});

View File

@@ -1,69 +1,45 @@
import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units';
import type { GeoJSONSource } from 'mapbox-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store";
const { distanceMarkers, distanceUnits } = settings;
const stops = [
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
export class DistanceMarkers {
map: mapboxgl.Map;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
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.unsubscribes.push(allHidden.subscribe(this.updateBinded));
this.unsubscribes.push(
map.subscribe((map_) => {
if (map_) {
map_.on('style.import.load', this.updateBinded);
}
})
);
this.map.on('style.import.load', this.updateBinded);
}
update() {
const map_ = get(map);
if (!map_) return;
try {
if (get(distanceMarkers) && !get(allHidden)) {
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
if (get(distanceMarkers)) {
let distanceSource = this.map.getSource('distance-markers');
if (distanceSource) {
distanceSource.setData(this.getDistanceMarkersGeoJSON());
} else {
map_.addSource('distance-markers', {
this.map.addSource('distance-markers', {
type: 'geojson',
data: this.getDistanceMarkersGeoJSON(),
data: this.getDistanceMarkersGeoJSON()
});
}
stops.forEach(([d, minzoom, maxzoom]) => {
if (!map_.getLayer(`distance-markers-${d}`)) {
map_.addLayer({
if (!this.map.getLayer(`distance-markers-${d}`)) {
this.map.addLayer({
id: `distance-markers-${d}`,
type: 'symbol',
source: 'distance-markers',
filter:
d === 5
? [
'any',
['==', ['get', 'level'], 5],
['==', ['get', 'level'], 25],
]
: ['==', ['get', 'level'], d],
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
minzoom: minzoom,
maxzoom: maxzoom ?? 24,
layout: {
@@ -75,27 +51,26 @@ export class DistanceMarkers {
'text-color': 'black',
'text-halo-width': 2,
'text-halo-color': 'white',
},
}
});
} else {
map_.moveLayer(`distance-markers-${d}`);
this.map.moveLayer(`distance-markers-${d}`);
}
});
} else {
stops.forEach(([d]) => {
if (map_.getLayer(`distance-markers-${d}`)) {
map_.removeLayer(`distance-markers-${d}`);
if (this.map.getLayer(`distance-markers-${d}`)) {
this.map.removeLayer(`distance-markers-${d}`);
}
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
return;
}
}
remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
this.unsubscribes.forEach(unsubscribe => unsubscribe());
}
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
@@ -104,28 +79,20 @@ export class DistanceMarkers {
let features = [];
let currentTargetDistance = 1;
for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (
statistics.local.distance.total[i] >=
getConvertedDistanceToKilometers(currentTargetDistance)
) {
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
0, 0,
];
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
},
properties: {
distance,
level,
minzoom,
},
}
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
@@ -133,7 +100,7 @@ export class DistanceMarkers {
return {
type: 'FeatureCollection',
features,
features
};
}
}
}

View File

@@ -0,0 +1,477 @@
import { currentTool, map, Tool } from "$lib/stores";
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
import { get, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin, Square } from "lucide-static";
import { getSymbolKey, symbols } from "$lib/assets/symbols";
const colors = [
'#ff0000',
'#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]--;
}
}
const inspectKey = 'Shift';
let inspectKeyDown: KeyDown | null = null;
class KeyDown {
key: string;
down: boolean = false;
constructor(key: string) {
this.key = key;
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keyup', this.onKeyUp);
}
onKeyDown = (e: KeyboardEvent) => {
if (e.key === this.key) {
this.down = true;
}
}
onKeyUp = (e: KeyboardEvent) => {
if (e.key === this.key) {
this.down = false;
}
}
isDown() {
return this.down;
}
}
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${Square
.replace('width="24"', 'width="12"')
.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, 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);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
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.import.load', this.updateBinded);
if (inspectKeyDown === null) {
inspectKeyDown = new KeyDown(inspectKey);
}
}
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('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
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': ['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('mousemove', (e) => {
if (marker._isDragging) {
return;
}
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
e.stopPropagation();
});
marker.getElement().addEventListener('click', (e) => {
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
return;
}
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
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 {
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
}
e.stopPropagation();
});
marker.on('dragstart', () => {
setGrabbingCursor();
marker.getElement().style.cursor = 'grabbing';
waypointPopup?.hide();
});
marker.on('dragend', (e) => {
resetCursor();
marker.getElement().style.cursor = '';
getElevation([marker._waypoint]).then((ele) => {
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 = ele[0];
});
});
dragEndTimestamp = Date.now()
});
this.markers.push(marker);
}
markerIndex++;
});
}
while (markerIndex < this.markers.length) { // Remove extra markers
this.markers.pop()?.remove();
}
this.markers.forEach((marker) => {
if (!marker._waypoint._data.hidden) {
marker.addTo(this.map);
} else {
marker.remove();
}
});
}
updateMap(map: mapboxgl.Map) {
this.map = map;
this.map.on('style.import.load', this.updateBinded);
this.update();
}
remove() {
if (get(map)) {
this.map.off('click', this.fileId, this.layerOnClickBinded);
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
this.map.off('style.import.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();
}
}
layerOnMouseLeave() {
resetCursor();
}
layerOnMouseMove(e: any) {
if (inspectKeyDown?.isDown()) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
const file = get(this.file)?.file;
if (file) {
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
}
}
}
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);
}
}
layerOnContextMenu(e: any) {
if (e.originalEvent.ctrlKey) {
this.layerOnClick(e);
}
}
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;
}
}

View File

@@ -1,4 +1,5 @@
import { MapPopup } from '$lib/components/map/map-popup';
import { dbUtils } from "$lib/db";
import { MapPopup } from "$lib/components/MapPopup";
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
@@ -10,14 +11,14 @@ export function createPopups(map: mapboxgl.Map) {
focusAfterOpen: false,
maxWidth: undefined,
offset: {
top: [0, 0],
'top': [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
bottom: [0, -30],
'bottom': [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
left: [10, -15],
right: [-10, -15],
'left': [10, -15],
'right': [-10, -15],
},
});
trackpointPopup = new MapPopup(map, {
@@ -37,3 +38,7 @@ export function removePopups() {
trackpointPopup = null;
}
}
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer';
import { fileObservers } from '$lib/db';
import { DistanceMarkers } from './DistanceMarkers';
import { StartEndMarkers } from './StartEndMarkers';
import { onDestroy } from 'svelte';
import { createPopups, removePopups } from './GPXLayerPopup';
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();
}
createPopups($map);
distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map);
}
onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear();
removePopups();
if (distanceMarkers) {
distanceMarkers.remove();
distanceMarkers = undefined;
}
if (startEndMarkers) {
startEndMarkers.remove();
startEndMarkers = undefined;
}
});
</script>

View File

@@ -1,48 +1,37 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import mapboxgl from 'mapbox-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
import mapboxgl from "mapbox-gl";
import { get } from "svelte/store";
export class StartEndMarkers {
map: mapboxgl.Map;
start: mapboxgl.Marker;
end: mapboxgl.Marker;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
constructor(map: mapboxgl.Map) {
this.map = map;
let startElement = document.createElement('div');
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
map.onLoad(() => this.update());
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
}
update() {
const map_ = get(map);
if (!map_) return;
const tool = get(currentTool);
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
const hidden = get(allHidden);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
this.end
.setLngLat(
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
)
.addTo(map_);
let tool = get(currentTool);
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
} else {
this.start.remove();
this.end.remove();
@@ -50,9 +39,9 @@ export class StartEndMarkers {
}
remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
this.unsubscribes.forEach(unsubscribe => unsubscribe());
this.start.remove();
this.end.remove();
}
}
}

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer } from 'lucide-svelte';
import { df } from '$lib/utils';
export let trackpoint: PopupItem<TrackPoint>;
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<Card.Header class="p-0">
<Card.Title class="text-md"></Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1">
<Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
</div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,102 @@
<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 { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
export let waypoint: PopupItem<Waypoint>;
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
function sanitize(text: string | undefined): string {
if (text === undefined) {
return '';
}
return sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'],
allowedAttributes: {
a: ['href', 'target'],
img: ['src']
}
}).trim();
}
</script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{waypoint.item.name ?? $_('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col text-sm p-0">
<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}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item.getLongitude().toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
{#if $currentTool === Tool.WAYPOINT}
<Button
class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
{/if}
</Card.Content>
</Card.Root>
<style lang="postcss">
div :global(a) {
@apply text-link;
@apply hover:underline;
}
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style>

View File

@@ -0,0 +1,422 @@
<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, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
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';
} else {
resourceType = 'raster';
}
}
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId);
}
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
let is512 = tileUrls.some((url) => url.includes('512'));
let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
value: ''
};
if (resourceType === 'vector') {
layer.value = layer.tileUrls[0];
} else {
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: layer.tileUrls,
tileSize: is512 ? 512 : 256,
maxzoom: maxZoom
}
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId
}
]
};
}
$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;
});
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$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 (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
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 (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
$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" />
<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>

View File

@@ -0,0 +1,222 @@
<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 { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
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.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap
},
'overlays'
);
}
}
}
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
function addOverlay(id: string) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
})
};
}
$map.addImport({
id,
data: overlay
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
acc[i.id] = i;
}
return acc;
}, {});
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
}
$: if ($map) {
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer($map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
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
if (value !== get(selectedBasemap)) {
selectedBasemap.set(value);
}
});
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>
<svelte:window
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {
closeLayerControl();
}
}}
/>

View File

@@ -0,0 +1,189 @@
<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,
defaultBasemap,
overlays,
overlayTree,
overpassTree
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } 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,
currentBasemap,
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 ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
$: 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={(value) => {
if ($selectedOverlay) {
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
try {
$map.removeImport($selectedOverlay.value);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[$selectedOverlay.value] = value[0];
}
}}
/>
</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>

View 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>

View File

@@ -0,0 +1,86 @@
<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"
aria-label={$_(`layers.label.${id}`)}
/>
{: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>

View File

@@ -1,13 +1,14 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import SphericalMercator from "@mapbox/sphericalmercator";
import { getLayers } from "./utils";
import { get, writable } from "svelte/store";
import { liveQuery } from "dexie";
import { db, settings } from "$lib/db";
import { overpassQueryData } from "$lib/assets/layers";
import { MapPopup } from "$lib/components/MapPopup";
const { currentOverpassQueries } = settings;
const {
currentOverpassQueries
} = settings;
const mercator = new SphericalMercator({
size: 256,
@@ -28,7 +29,7 @@ export class OverpassLayer {
popup: MapPopup;
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -49,22 +50,18 @@ export class OverpassLayer {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
this.queryIfNeededBinded();
})
);
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
this.updateBinded();
this.queryIfNeededBinded();
}));
this.update();
}
queryIfNeeded() {
if (this.map.getZoom() >= this.minZoom) {
const bounds = this.map.getBounds()?.toArray();
if (bounds) {
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
}
const bounds = this.map.getBounds().toArray();
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
}
}
@@ -74,7 +71,7 @@ export class OverpassLayer {
let d = get(data);
try {
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
let source = this.map.getSource('overpass');
if (source) {
source.setData(d);
} else {
@@ -129,8 +126,8 @@ export class OverpassLayer {
this.popup.setItem({
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
},
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
}
});
}
@@ -149,23 +146,12 @@ export class OverpassLayer {
continue;
}
db.overpasstiles
.where('[x+y]')
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
}
}
}
@@ -179,16 +165,13 @@ export class OverpassLayer {
const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then(
(response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
},
() => this.currentQueries.delete(`${x},${y}`)
)
.then((response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
}, () => (this.currentQueries.delete(`${x},${y}`)))
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
@@ -196,7 +179,7 @@ export class OverpassLayer {
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
return;
@@ -212,9 +195,7 @@ export class OverpassLayer {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: element.center
? [element.center.lon, element.center.lat]
: [element.lon, element.lat],
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
},
properties: {
id: element.id,
@@ -222,10 +203,9 @@ export class OverpassLayer {
lon: element.center ? element.center.lon : element.lon,
query: query,
icon: `overpass-${query}`,
tags: element.tags,
type: element.type,
tags: element.tags
},
},
}
});
}
}
@@ -248,13 +228,11 @@ export class OverpassLayer {
if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon);
}
};
}
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)">
@@ -284,16 +262,11 @@ function getQuery(query: string) {
}
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
if (arrayEntry !== undefined) {
return arrayEntry
.map(
(val) =>
`nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
.join('')};`
)
.join('');
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
.join('')};`).join('');
} else {
return `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${value}]`)
@@ -310,9 +283,8 @@ function belongsToQuery(element: any, query: string) {
}
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
return Object.entries(tags).every(([tag, value]) =>
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
);
return Object.entries(tags)
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
}
function getCurrentQueries() {
@@ -321,7 +293,5 @@ function getCurrentQueries() {
return [];
}
return Object.entries(getLayers(currentQueries))
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
}
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
}

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