prettier config + format all, closes #175
This commit is contained in:
parent
01cfd448f0
commit
0b457f9a1e
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.svelte",
|
||||
"options": {
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"svelte.svelte-vscode"
|
||||
]
|
||||
}
|
||||
|
||||
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
1
gpx/.prettierignore
Normal file
1
gpx/.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
package-lock.json
|
||||
1573
gpx/package-lock.json
generated
1573
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,10 +18,14 @@
|
||||
"devDependencies": {
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/node": "^20.16.10",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build"
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
}
|
||||
}
|
||||
|
||||
991
gpx/src/gpx.ts
991
gpx/src/gpx.ts
File diff suppressed because it is too large
Load Diff
@ -2,4 +2,3 @@ export * from './gpx';
|
||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
|
||||
@ -1,32 +1,40 @@
|
||||
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
||||
import { GPXFileType } from "./types";
|
||||
import { GPXFile } from "./gpx";
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { GPXFileType } from './types';
|
||||
import { GPXFile } from './gpx';
|
||||
|
||||
const attributesWithNamespace = {
|
||||
'RoutePointExtension': 'gpxx:RoutePointExtension',
|
||||
'rpt': 'gpxx:rpt',
|
||||
'TrackPointExtension': 'gpxtpx:TrackPointExtension',
|
||||
'PowerExtension': 'gpxpx:PowerExtension',
|
||||
'atemp': 'gpxtpx:atemp',
|
||||
'hr': 'gpxtpx:hr',
|
||||
'cad': 'gpxtpx:cad',
|
||||
'Extensions': 'gpxtpx:Extensions',
|
||||
'PowerInWatts': 'gpxpx:PowerInWatts',
|
||||
'power': 'gpxpx:PowerExtension',
|
||||
'line': 'gpx_style:line',
|
||||
'color': 'gpx_style:color',
|
||||
'opacity': 'gpx_style:opacity',
|
||||
'width': 'gpx_style:width',
|
||||
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',
|
||||
};
|
||||
|
||||
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') {
|
||||
@ -51,8 +59,14 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
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') {
|
||||
if (
|
||||
tagName === 'gpxtpx:atemp' ||
|
||||
tagName === 'gpxtpx:hr' ||
|
||||
tagName === 'gpxtpx:cad' ||
|
||||
tagName === 'gpxpx:PowerInWatts' ||
|
||||
tagName === 'gpx_style:opacity' ||
|
||||
tagName === 'gpx_style:width'
|
||||
) {
|
||||
return parseFloat(tagValue);
|
||||
}
|
||||
|
||||
@ -60,7 +74,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
||||
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
||||
return {
|
||||
'gpxpx:PowerInWatts': parseFloat(tagValue)
|
||||
'gpxpx:PowerInWatts': parseFloat(tagValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -72,7 +86,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
||||
|
||||
// @ts-ignore
|
||||
if (parsed.metadata === "") {
|
||||
if (parsed.metadata === '') {
|
||||
parsed.metadata = {};
|
||||
}
|
||||
|
||||
@ -85,7 +99,7 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
const builder = new XMLBuilder({
|
||||
format: true,
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
attributeNamePrefix: '',
|
||||
attributesGroupName: 'attributes',
|
||||
suppressEmptyNode: true,
|
||||
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
|
||||
@ -96,13 +110,13 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
},
|
||||
});
|
||||
|
||||
if (!gpx.attributes)
|
||||
gpx.attributes = {};
|
||||
if (!gpx.attributes) gpx.attributes = {};
|
||||
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
|
||||
gpx.attributes['version'] = '1.1';
|
||||
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
|
||||
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
gpx.attributes['xsi:schemaLocation'] = 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['xsi:schemaLocation'] =
|
||||
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
||||
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
|
||||
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
|
||||
@ -113,19 +127,24 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
}
|
||||
|
||||
return builder.build({
|
||||
"?xml": {
|
||||
'?xml': {
|
||||
attributes: {
|
||||
version: "1.0",
|
||||
encoding: "UTF-8",
|
||||
}
|
||||
version: '1.0',
|
||||
encoding: 'UTF-8',
|
||||
},
|
||||
},
|
||||
gpx: removeEmptyElements(gpx)
|
||||
gpx: removeEmptyElements(gpx),
|
||||
});
|
||||
}
|
||||
|
||||
function removeEmptyElements(obj: GPXFileType): GPXFileType {
|
||||
for (const key in obj) {
|
||||
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
|
||||
if (
|
||||
obj[key] === null ||
|
||||
obj[key] === undefined ||
|
||||
obj[key] === '' ||
|
||||
(Array.isArray(obj[key]) && obj[key].length === 0)
|
||||
) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
||||
removeEmptyElements(obj[key]);
|
||||
|
||||
@ -1,33 +1,48 @@
|
||||
import { TrackPoint } from "./gpx";
|
||||
import { Coordinates } from "./types";
|
||||
import { TrackPoint } from './gpx';
|
||||
import { Coordinates } from './types';
|
||||
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
|
||||
export function ramerDouglasPeucker(
|
||||
points: TrackPoint[],
|
||||
epsilon: number = 50,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
|
||||
): SimplifiedTrackPoint[] {
|
||||
if (points.length == 0) {
|
||||
return [];
|
||||
} else if (points.length == 1) {
|
||||
return [{
|
||||
point: points[0]
|
||||
}];
|
||||
return [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let simplified = [{
|
||||
point: points[0]
|
||||
}];
|
||||
let simplified = [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
||||
simplified.push({
|
||||
point: points[points.length - 1]
|
||||
point: points[points.length - 1],
|
||||
});
|
||||
return simplified;
|
||||
}
|
||||
|
||||
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
|
||||
function ramerDouglasPeuckerRecursive(
|
||||
points: TrackPoint[],
|
||||
epsilon: number,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
|
||||
start: number,
|
||||
end: number,
|
||||
simplified: SimplifiedTrackPoint[]
|
||||
) {
|
||||
let largest = {
|
||||
index: 0,
|
||||
distance: 0
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
@ -45,8 +60,16 @@ function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, mea
|
||||
}
|
||||
}
|
||||
|
||||
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
|
||||
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||
@ -74,7 +97,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > (Math.PI / 2)) {
|
||||
if (diff > Math.PI / 2) {
|
||||
return dis13;
|
||||
}
|
||||
|
||||
@ -83,7 +106,8 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return distance(lat2, lon2, lat3, lon3);
|
||||
} else {
|
||||
@ -93,18 +117,32 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
||||
|
||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the distance between two lat / lon points.
|
||||
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
|
||||
return (
|
||||
Math.acos(
|
||||
Math.sin(latA) * Math.sin(latB) +
|
||||
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
) * earthRadius
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the bearing from one lat / lon point to another.
|
||||
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
|
||||
return Math.atan2(
|
||||
Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
);
|
||||
}
|
||||
|
||||
export function projectedPoint(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
|
||||
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||
export function projectedPoint(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): Coordinates {
|
||||
return projected(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
||||
@ -132,7 +170,7 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > (Math.PI / 2)) {
|
||||
if (diff > Math.PI / 2) {
|
||||
return coord1;
|
||||
}
|
||||
|
||||
@ -141,14 +179,22 @@ function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates
|
||||
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return coord2;
|
||||
} else {
|
||||
// Determine the closest point (p4) on the great circle
|
||||
const f = dis14 / earthRadius;
|
||||
const lat4 = Math.asin(Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12));
|
||||
const lon4 = lon1 + Math.atan2(Math.sin(bear12) * Math.sin(f) * Math.cos(lat1), Math.cos(f) - Math.sin(lat1) * Math.sin(lat4));
|
||||
const lat4 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
|
||||
);
|
||||
const lon4 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
|
||||
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
|
||||
);
|
||||
|
||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -4,9 +4,7 @@
|
||||
"target": "ES2015",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -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,3 +2,5 @@
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
"typescript": true
|
||||
"$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"
|
||||
},
|
||||
"typescript": true
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
14
website/src/app.d.ts
vendored
14
website/src/app.d.ts
vendored
@ -1,13 +1,13 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// 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 {};
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -3,84 +3,84 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 45%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 45%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-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%;
|
||||
--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%;
|
||||
--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%;
|
||||
--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%;
|
||||
--accent: 210 40% 92%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--support: 220 15 130;
|
||||
--support: 220 15 130;
|
||||
|
||||
--link: 0 110 180;
|
||||
--link: 0 110 180;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
.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%;
|
||||
--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%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-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%;
|
||||
--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%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 217.2 32.6% 30%;
|
||||
--accent-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%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--support: 255 110 190;
|
||||
--support: 255 110 190;
|
||||
|
||||
--link: 80 190 255;
|
||||
--link: 80 190 255;
|
||||
|
||||
--ring: hsl(212.7,26.8%,83.9);
|
||||
}
|
||||
--ring: hsl(212.7, 26.8%, 83.9);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,8 @@ export async function handle({ event, resolve }) {
|
||||
}
|
||||
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
export const surfaceColors: { [key: string]: string } = {
|
||||
"missing": "#d1d1d1",
|
||||
"paved": "#8c8c8c",
|
||||
"unpaved": "#6b443a",
|
||||
"asphalt": "#8c8c8c",
|
||||
"concrete": "#8c8c8c",
|
||||
"cobblestone": "#ffd991",
|
||||
"paving_stones": "#8c8c8c",
|
||||
"sett": "#ffd991",
|
||||
"metal": "#8c8c8c",
|
||||
"wood": "#6b443a",
|
||||
"compacted": "#ffffa8",
|
||||
"fine_gravel": "#ffffa8",
|
||||
"gravel": "#ffffa8",
|
||||
"pebblestone": "#ffffa8",
|
||||
"rock": "#ffd991",
|
||||
"dirt": "#ffffa8",
|
||||
"ground": "#6b443a",
|
||||
"earth": "#6b443a",
|
||||
"mud": "#6b443a",
|
||||
"sand": "#ffffc4",
|
||||
"grass": "#61b55c",
|
||||
"grass_paver": "#61b55c",
|
||||
"clay": "#6b443a",
|
||||
"stone": "#ffd991",
|
||||
missing: '#d1d1d1',
|
||||
paved: '#8c8c8c',
|
||||
unpaved: '#6b443a',
|
||||
asphalt: '#8c8c8c',
|
||||
concrete: '#8c8c8c',
|
||||
cobblestone: '#ffd991',
|
||||
paving_stones: '#8c8c8c',
|
||||
sett: '#ffd991',
|
||||
metal: '#8c8c8c',
|
||||
wood: '#6b443a',
|
||||
compacted: '#ffffa8',
|
||||
fine_gravel: '#ffffa8',
|
||||
gravel: '#ffffa8',
|
||||
pebblestone: '#ffffa8',
|
||||
rock: '#ffd991',
|
||||
dirt: '#ffffa8',
|
||||
ground: '#6b443a',
|
||||
earth: '#6b443a',
|
||||
mud: '#6b443a',
|
||||
sand: '#ffffc4',
|
||||
grass: '#61b55c',
|
||||
grass_paver: '#61b55c',
|
||||
clay: '#6b443a',
|
||||
stone: '#ffd991',
|
||||
};
|
||||
|
||||
export function getSurfaceColor(surface: string): string {
|
||||
@ -30,66 +30,72 @@ export function getSurfaceColor(surface: string): string {
|
||||
}
|
||||
|
||||
export const highwayColors: { [key: string]: string } = {
|
||||
"missing": "#d1d1d1",
|
||||
"motorway": "#ff4d33",
|
||||
"motorway_link": "#ff4d33",
|
||||
"trunk": "#ff5e4d",
|
||||
"trunk_link": "#ff947f",
|
||||
"primary": "#ff6e5c",
|
||||
"primary_link": "#ff6e5c",
|
||||
"secondary": "#ff8d7b",
|
||||
"secondary_link": "#ff8d7b",
|
||||
"tertiary": "#ffd75f",
|
||||
"tertiary_link": "#ffd75f",
|
||||
"unclassified": "#f1f2a5",
|
||||
"road": "#f1f2a5",
|
||||
"residential": "#73b2ff",
|
||||
"living_street": "#73b2ff",
|
||||
"service": "#9c9cd9",
|
||||
"track": "#a8e381",
|
||||
"footway": "#a8e381",
|
||||
"path": "#a8e381",
|
||||
"pedestrian": "#a8e381",
|
||||
"cycleway": "#9de2ff",
|
||||
"construction": "#e09a4a",
|
||||
"bridleway": "#946f43",
|
||||
"raceway": "#ff0000",
|
||||
"rest_area": "#9c9cd9",
|
||||
"services": "#9c9cd9",
|
||||
"corridor": "#474747",
|
||||
"elevator": "#474747",
|
||||
"steps": "#474747",
|
||||
"bus_stop": "#8545a3",
|
||||
"busway": "#8545a3",
|
||||
"via_ferrata": "#474747"
|
||||
missing: '#d1d1d1',
|
||||
motorway: '#ff4d33',
|
||||
motorway_link: '#ff4d33',
|
||||
trunk: '#ff5e4d',
|
||||
trunk_link: '#ff947f',
|
||||
primary: '#ff6e5c',
|
||||
primary_link: '#ff6e5c',
|
||||
secondary: '#ff8d7b',
|
||||
secondary_link: '#ff8d7b',
|
||||
tertiary: '#ffd75f',
|
||||
tertiary_link: '#ffd75f',
|
||||
unclassified: '#f1f2a5',
|
||||
road: '#f1f2a5',
|
||||
residential: '#73b2ff',
|
||||
living_street: '#73b2ff',
|
||||
service: '#9c9cd9',
|
||||
track: '#a8e381',
|
||||
footway: '#a8e381',
|
||||
path: '#a8e381',
|
||||
pedestrian: '#a8e381',
|
||||
cycleway: '#9de2ff',
|
||||
construction: '#e09a4a',
|
||||
bridleway: '#946f43',
|
||||
raceway: '#ff0000',
|
||||
rest_area: '#9c9cd9',
|
||||
services: '#9c9cd9',
|
||||
corridor: '#474747',
|
||||
elevator: '#474747',
|
||||
steps: '#474747',
|
||||
bus_stop: '#8545a3',
|
||||
busway: '#8545a3',
|
||||
via_ferrata: '#474747',
|
||||
};
|
||||
|
||||
export const sacScaleColors: { [key: string]: string } = {
|
||||
"hiking": "#007700",
|
||||
"mountain_hiking": "#1843ad",
|
||||
"demanding_mountain_hiking": "#ffff00",
|
||||
"alpine_hiking": "#ff9233",
|
||||
"demanding_alpine_hiking": "#ff0000",
|
||||
"difficult_alpine_hiking": "#000000",
|
||||
hiking: '#007700',
|
||||
mountain_hiking: '#1843ad',
|
||||
demanding_mountain_hiking: '#ffff00',
|
||||
alpine_hiking: '#ff9233',
|
||||
demanding_alpine_hiking: '#ff0000',
|
||||
difficult_alpine_hiking: '#000000',
|
||||
};
|
||||
|
||||
export const mtbScaleColors: { [key: string]: string } = {
|
||||
"0-": "#007700",
|
||||
"0": "#007700",
|
||||
"0+": "#007700",
|
||||
"1-": "#1843ad",
|
||||
"1": "#1843ad",
|
||||
"1+": "#1843ad",
|
||||
"2-": "#ffff00",
|
||||
"2": "#ffff00",
|
||||
"2+": "#ffff00",
|
||||
"3": "#ff0000",
|
||||
"4": "#00ff00",
|
||||
"5": "#000000",
|
||||
"6": "#b105eb",
|
||||
'0-': '#007700',
|
||||
'0': '#007700',
|
||||
'0+': '#007700',
|
||||
'1-': '#1843ad',
|
||||
'1': '#1843ad',
|
||||
'1+': '#1843ad',
|
||||
'2-': '#ffff00',
|
||||
'2': '#ffff00',
|
||||
'2+': '#ffff00',
|
||||
'3': '#ff0000',
|
||||
'4': '#00ff00',
|
||||
'5': '#000000',
|
||||
'6': '#b105eb',
|
||||
};
|
||||
|
||||
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
|
||||
function createPattern(
|
||||
backgroundColor: string,
|
||||
sacScaleColor: string | undefined,
|
||||
mtbScaleColor: string | undefined,
|
||||
size: number = 16,
|
||||
lineWidth: number = 4
|
||||
) {
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
@ -104,11 +110,11 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
if (sacScaleColor) {
|
||||
ctx.strokeStyle = sacScaleColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
|
||||
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
|
||||
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
@ -119,8 +125,8 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
|
||||
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
@ -128,12 +134,16 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
|
||||
}
|
||||
|
||||
const patterns: Record<string, string | CanvasPattern> = {};
|
||||
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
|
||||
export function getHighwayColor(
|
||||
highway: string,
|
||||
sacScale: string | undefined,
|
||||
mtbScale: string | undefined
|
||||
) {
|
||||
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
|
||||
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
|
||||
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
|
||||
if (sacScale || mtbScale) {
|
||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
|
||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
|
||||
if (!patterns[patternId]) {
|
||||
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,67 @@
|
||||
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor, Toilet } from "lucide-svelte";
|
||||
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg, Toilet as ToiletSvg } from "lucide-static";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
Landmark,
|
||||
Icon,
|
||||
Shell,
|
||||
Bike,
|
||||
Building,
|
||||
Tent,
|
||||
Car,
|
||||
Wrench,
|
||||
ShoppingBasket,
|
||||
Droplet,
|
||||
DoorOpen,
|
||||
Trees,
|
||||
Fuel,
|
||||
Home,
|
||||
Info,
|
||||
TreeDeciduous,
|
||||
CircleParking,
|
||||
Cross,
|
||||
Utensils,
|
||||
Construction,
|
||||
BrickWall,
|
||||
ShowerHead,
|
||||
Mountain,
|
||||
Phone,
|
||||
TrainFront,
|
||||
Bed,
|
||||
Binoculars,
|
||||
TriangleAlert,
|
||||
Anchor,
|
||||
Toilet,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
Landmark as LandmarkSvg,
|
||||
Shell as ShellSvg,
|
||||
Bike as BikeSvg,
|
||||
Building as BuildingSvg,
|
||||
Tent as TentSvg,
|
||||
Car as CarSvg,
|
||||
Wrench as WrenchSvg,
|
||||
ShoppingBasket as ShoppingBasketSvg,
|
||||
Droplet as DropletSvg,
|
||||
DoorOpen as DoorOpenSvg,
|
||||
Trees as TreesSvg,
|
||||
Fuel as FuelSvg,
|
||||
Home as HomeSvg,
|
||||
Info as InfoSvg,
|
||||
TreeDeciduous as TreeDeciduousSvg,
|
||||
CircleParking as CircleParkingSvg,
|
||||
Cross as CrossSvg,
|
||||
Utensils as UtensilsSvg,
|
||||
Construction as ConstructionSvg,
|
||||
BrickWall as BrickWallSvg,
|
||||
ShowerHead as ShowerHeadSvg,
|
||||
Mountain as MountainSvg,
|
||||
Phone as PhoneSvg,
|
||||
TrainFront as TrainFrontSvg,
|
||||
Bed as BedSvg,
|
||||
Binoculars as BinocularsSvg,
|
||||
TriangleAlert as TriangleAlertSvg,
|
||||
Anchor as AnchorSvg,
|
||||
Toilet as ToiletSvg,
|
||||
} from 'lucide-static';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export type Symbol = {
|
||||
value: string;
|
||||
@ -20,16 +81,28 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
||||
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
||||
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
||||
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
convenience_store: {
|
||||
value: 'Convenience Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
crossing: { value: 'Crossing' },
|
||||
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
department_store: {
|
||||
value: 'Department Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
|
||||
ground_transportation: {
|
||||
value: 'Ground Transportation',
|
||||
icon: TrainFront,
|
||||
iconSvg: TrainFrontSvg,
|
||||
},
|
||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||
@ -55,6 +128,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return Object.keys(symbols).find(key => symbols[key].value === value);
|
||||
return Object.keys(symbols).find((key) => symbols[key].value === value);
|
||||
}
|
||||
}
|
||||
@ -1,60 +1,60 @@
|
||||
<script lang="ts">
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
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:' + ($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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: if (mounted && $locale) {
|
||||
waitLocale().then(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" {...$$restProps}></div>
|
||||
|
||||
@ -1,28 +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 { Builder } from 'bits-ui';
|
||||
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';
|
||||
|
||||
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[] = [];
|
||||
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.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.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>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,186 +1,190 @@
|
||||
<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';
|
||||
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
|
||||
};
|
||||
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;
|
||||
$: 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());
|
||||
}
|
||||
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;
|
||||
}
|
||||
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]);
|
||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
$exportState = ExportState.NONE;
|
||||
}
|
||||
}}
|
||||
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.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>
|
||||
|
||||
@ -1,125 +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, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
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 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>
|
||||
<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>
|
||||
|
||||
@ -1,82 +1,88 @@
|
||||
<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 { _ } from 'svelte-i18n';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
export let orientation: 'horizontal' | 'vertical';
|
||||
export let panelSize: number;
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
export let orientation: 'horizontal' | 'vertical';
|
||||
export let panelSize: number;
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
let statistics: GPXStatistics;
|
||||
let statistics: GPXStatistics;
|
||||
|
||||
$: if ($slicedGPXStatistics !== undefined) {
|
||||
statistics = $slicedGPXStatistics[0];
|
||||
} else {
|
||||
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"
|
||||
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={$_('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.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>
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { CircleHelp } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { CircleHelp } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let link: string | undefined = undefined;
|
||||
export let link: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
>
|
||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{#if link}
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{$_('menu.more')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
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 selected = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
let selected = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale]
|
||||
};
|
||||
}
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<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.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}
|
||||
{#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>
|
||||
|
||||
@ -1,73 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
||||
import { base } from '$app/paths';
|
||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
||||
|
||||
export let iconOnly = false;
|
||||
export let company = 'gpx.studio';
|
||||
export let iconOnly = false;
|
||||
export let company = 'gpx.studio';
|
||||
|
||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
</script>
|
||||
|
||||
{#if company === 'gpx.studio'}
|
||||
<img
|
||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of gpx.studio."
|
||||
{...$$restProps}
|
||||
/>
|
||||
<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-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
{...$$restProps}
|
||||
/>
|
||||
<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 {$$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
|
||||
>
|
||||
<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 {$$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
|
||||
>
|
||||
<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 {$$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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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 {$$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
|
||||
>
|
||||
<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}
|
||||
|
||||
@ -1,392 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import 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 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';
|
||||
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;
|
||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||
export let geolocate = true;
|
||||
export let geocoder = true;
|
||||
export let hash = true;
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
language = 'zh-Hans';
|
||||
} else if (language?.includes('-')) {
|
||||
language = language.split('-')[0];
|
||||
} else if (language === '' || language === undefined) {
|
||||
language = 'en';
|
||||
}
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
language = 'zh-Hans';
|
||||
} else if (language?.includes('-')) {
|
||||
language = language.split('-')[0];
|
||||
} else if (language === '' || language === undefined) {
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
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: []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
projection: 'globe',
|
||||
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);
|
||||
});
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
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.AttributionControl({
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: 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 (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
|
||||
})
|
||||
);
|
||||
}
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.addControl(scaleControl);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...$$restProps}>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported && !embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||
!embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
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-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-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(.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(.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 > 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 .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
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) {
|
||||
@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--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--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-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;
|
||||
}
|
||||
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-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
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-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show .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-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-50;
|
||||
}
|
||||
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-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 .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-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-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 .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-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-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-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,25 +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';
|
||||
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;
|
||||
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}
|
||||
{#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>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { TrackPoint, Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { tick } from "svelte";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import MapPopupComponent from "./MapPopup.svelte";
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { tick } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from './MapPopup.svelte';
|
||||
|
||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
item: T;
|
||||
@ -23,16 +23,15 @@ export class MapPopup {
|
||||
let component = new MapPopupComponent({
|
||||
target: document.body,
|
||||
props: {
|
||||
item: this.item
|
||||
}
|
||||
item: this.item,
|
||||
},
|
||||
});
|
||||
|
||||
tick().then(() => this.popup.setDOMContent(component.container));
|
||||
}
|
||||
|
||||
setItem(item: PopupItem | null) {
|
||||
if (item)
|
||||
item.hide = () => this.hide();
|
||||
if (item) item.hide = () => this.hide();
|
||||
this.item.set(item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@ -76,6 +75,8 @@ export class MapPopup {
|
||||
if (i === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
||||
? i.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let size = '20';
|
||||
export let size = '20';
|
||||
|
||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||
on:click={() => {
|
||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
aria-label={$_('menu.mode')}
|
||||
variant="ghost"
|
||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||
on:click={() => {
|
||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
aria-label={$_('menu.mode')}
|
||||
>
|
||||
{#if selectedMode === 'light'}
|
||||
<Sun {size} />
|
||||
{:else}
|
||||
<Moon {size} />
|
||||
{/if}
|
||||
{#if selectedMode === 'light'}
|
||||
<Sun {size} />
|
||||
{:else}
|
||||
<Moon {size} />
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@ -1,32 +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, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
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($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>
|
||||
<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>
|
||||
|
||||
@ -1,41 +1,42 @@
|
||||
<script lang="ts">
|
||||
export let orientation: 'col' | 'row' = 'col';
|
||||
export let orientation: 'col' | 'row' = 'col';
|
||||
|
||||
export let after: number;
|
||||
export let minAfter: number = 0;
|
||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
||||
export let after: number;
|
||||
export let minAfter: number = 0;
|
||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
function handleMouseDown(event: PointerEvent) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startAfter = after;
|
||||
function handleMouseDown(event: PointerEvent) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startAfter = after;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener('pointermove', handleMouseMove);
|
||||
window.removeEventListener('pointerup', handleMouseUp);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener('pointermove', handleMouseMove);
|
||||
window.removeEventListener('pointerup', handleMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMouseMove);
|
||||
window.addEventListener('pointerup', handleMouseUp);
|
||||
}
|
||||
window.addEventListener('pointermove', handleMouseMove);
|
||||
window.addEventListener('pointerup', handleMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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}"
|
||||
on:pointerdown={handleMouseDown}
|
||||
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}
|
||||
/>
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let key: string | undefined = undefined;
|
||||
export let shift: boolean = false;
|
||||
export let ctrl: boolean = false;
|
||||
export let click: boolean = false;
|
||||
export let key: string | undefined = undefined;
|
||||
export let shift: boolean = false;
|
||||
export let ctrl: boolean = false;
|
||||
export let click: boolean = false;
|
||||
|
||||
let mac = false;
|
||||
let safari = false;
|
||||
let mac = false;
|
||||
let safari = false;
|
||||
|
||||
onMount(() => {
|
||||
mac = isMac();
|
||||
safari = isSafari();
|
||||
});
|
||||
onMount(() => {
|
||||
mac = isMac();
|
||||
safari = isSafari();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||
{...$$props}
|
||||
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}
|
||||
{#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>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
</script>
|
||||
|
||||
<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.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>
|
||||
|
||||
@ -1,48 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS
|
||||
} from '$lib/units';
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS,
|
||||
} from '$lib/units';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let value: number;
|
||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||
export let showUnits: boolean = true;
|
||||
export let decimals: number | undefined = undefined;
|
||||
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;
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
</script>
|
||||
|
||||
<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}
|
||||
{#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>
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export let defaultState: 'open' | 'closed' = 'open';
|
||||
export let side: 'left' | 'right' = 'right';
|
||||
export let nohover: boolean = false;
|
||||
export let slotInsideTrigger: boolean = true;
|
||||
export let defaultState: 'open' | 'closed' = 'open';
|
||||
export let side: 'left' | 'right' = 'right';
|
||||
export let nohover: boolean = false;
|
||||
export let slotInsideTrigger: boolean = true;
|
||||
|
||||
let open = writable<Record<string, boolean>>({});
|
||||
let open = writable<Record<string, boolean>>({});
|
||||
|
||||
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);
|
||||
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>
|
||||
|
||||
<slot />
|
||||
|
||||
@ -1,97 +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, onMount, setContext } from 'svelte';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
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';
|
||||
|
||||
export let id: string | number;
|
||||
export let id: string | number;
|
||||
|
||||
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 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}.${id}`;
|
||||
setContext('collapsible-tree-parent-id', fullId);
|
||||
let fullId = `${parentId}.${id}`;
|
||||
setContext('collapsible-tree-parent-id', fullId);
|
||||
|
||||
onMount(() => {
|
||||
if (!get(open).hasOwnProperty(fullId)) {
|
||||
open.update((value) => {
|
||||
value[fullId] = defaultState === 'open';
|
||||
return value;
|
||||
});
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (!get(open).hasOwnProperty(fullId)) {
|
||||
open.update((value) => {
|
||||
value[fullId] = defaultState === 'open';
|
||||
return value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function openNode() {
|
||||
open.update((value) => {
|
||||
value[fullId] = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
export function openNode() {
|
||||
open.update((value) => {
|
||||
value[fullId] = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<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}
|
||||
{#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.Content class="ml-2">
|
||||
<slot name="content" />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from './CustomControl';
|
||||
import { map } from '$lib/stores';
|
||||
import CustomControl from './CustomControl';
|
||||
import { map } from '$lib/stores';
|
||||
|
||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let control: CustomControl | undefined = undefined;
|
||||
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);
|
||||
}
|
||||
$: 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"
|
||||
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 />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@ -1,82 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let module;
|
||||
export let module;
|
||||
</script>
|
||||
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
: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;
|
||||
}
|
||||
|
||||
: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>
|
||||
|
||||
@ -1,25 +1,29 @@
|
||||
<script lang="ts">
|
||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||
export let alt: string;
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let type: 'note' | 'warning' = 'note';
|
||||
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"
|
||||
>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,39 +1,64 @@
|
||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
File,
|
||||
FilePen,
|
||||
View,
|
||||
type Icon,
|
||||
Settings,
|
||||
Pencil,
|
||||
MapPin,
|
||||
Scissors,
|
||||
CalendarClock,
|
||||
Group,
|
||||
Ungroup,
|
||||
Filter,
|
||||
SquareDashedMousePointer,
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
|
||||
toolbar: [
|
||||
'routing',
|
||||
'poi',
|
||||
'scissors',
|
||||
'time',
|
||||
'merge',
|
||||
'extract',
|
||||
'elevation',
|
||||
'minify',
|
||||
'clean',
|
||||
],
|
||||
'map-controls': [],
|
||||
'gpx': [],
|
||||
'integration': [],
|
||||
'faq': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"getting-started": "🚀",
|
||||
"menu": "📂 ⚙️",
|
||||
"file": File,
|
||||
"edit": FilePen,
|
||||
"view": View,
|
||||
"settings": Settings,
|
||||
"files-and-stats": "🗂 📈",
|
||||
"toolbar": "🧰",
|
||||
"routing": Pencil,
|
||||
"poi": MapPin,
|
||||
"scissors": Scissors,
|
||||
"time": CalendarClock,
|
||||
"merge": Group,
|
||||
"extract": Ungroup,
|
||||
"elevation": MountainSnow,
|
||||
"minify": Filter,
|
||||
"clean": SquareDashedMousePointer,
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
"faq": "🔮",
|
||||
'getting-started': '🚀',
|
||||
menu: '📂 ⚙️',
|
||||
file: File,
|
||||
edit: FilePen,
|
||||
view: View,
|
||||
settings: Settings,
|
||||
'files-and-stats': '🗂 📈',
|
||||
toolbar: '🧰',
|
||||
routing: Pencil,
|
||||
poi: MapPin,
|
||||
scissors: Scissors,
|
||||
time: CalendarClock,
|
||||
merge: Group,
|
||||
extract: Ungroup,
|
||||
elevation: MountainSnow,
|
||||
minify: Filter,
|
||||
clean: SquareDashedMousePointer,
|
||||
'map-controls': '🗺',
|
||||
gpx: '💾',
|
||||
integration: '{ 👩💻 }',
|
||||
faq: '🔮',
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
|
||||
@ -1,267 +1,271 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
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';
|
||||
|
||||
$embedding = true;
|
||||
$embedding = true;
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers
|
||||
} = settings;
|
||||
const {
|
||||
currentBasemap,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers,
|
||||
} = settings;
|
||||
|
||||
export let useHash = true;
|
||||
export let options: EmbeddingOptions;
|
||||
export let hash: string;
|
||||
export let useHash = true;
|
||||
export let options: EmbeddingOptions;
|
||||
export let hash: string;
|
||||
|
||||
let prevSettings = {
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
};
|
||||
let prevSettings = {
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function applyOptions() {
|
||||
fileObservers.update(($fileObservers) => {
|
||||
$fileObservers.clear();
|
||||
return $fileObservers;
|
||||
});
|
||||
function applyOptions() {
|
||||
fileObservers.update(($fileObservers) => {
|
||||
$fileObservers.clear();
|
||||
return $fileObservers;
|
||||
});
|
||||
|
||||
let downloads: Promise<GPXFile | null>[] = [];
|
||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
downloads.push(
|
||||
fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
.then(loadFile)
|
||||
);
|
||||
});
|
||||
let downloads: Promise<GPXFile | null>[] = [];
|
||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
downloads.push(
|
||||
fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
.then(loadFile)
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(downloads).then((files) => {
|
||||
let ids: string[] = [];
|
||||
let bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
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);
|
||||
let id = `gpx-${index}-embed`;
|
||||
file._data.id = id;
|
||||
let statistics = new GPXStatisticsTree(file);
|
||||
|
||||
$fileObservers.set(
|
||||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics
|
||||
})
|
||||
);
|
||||
$fileObservers.set(
|
||||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics,
|
||||
})
|
||||
);
|
||||
|
||||
ids.push(id);
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
});
|
||||
return $fileObservers;
|
||||
});
|
||||
|
||||
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
ids.forEach((id) => {
|
||||
$selection.toggle(new ListFileItem(id));
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
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 (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.basemap !== $currentBasemap &&
|
||||
allowedEmbeddingBasemaps.includes(options.basemap)
|
||||
) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
|
||||
if (options.distanceMarkers !== $distanceMarkers) {
|
||||
$distanceMarkers = options.distanceMarkers;
|
||||
}
|
||||
if (options.distanceMarkers !== $distanceMarkers) {
|
||||
$distanceMarkers = options.distanceMarkers;
|
||||
}
|
||||
|
||||
if (options.directionMarkers !== $directionMarkers) {
|
||||
$directionMarkers = options.directionMarkers;
|
||||
}
|
||||
if (options.directionMarkers !== $directionMarkers) {
|
||||
$directionMarkers = options.directionMarkers;
|
||||
}
|
||||
|
||||
if (options.distanceUnits !== $distanceUnits) {
|
||||
$distanceUnits = options.distanceUnits;
|
||||
}
|
||||
if (options.distanceUnits !== $distanceUnits) {
|
||||
$distanceUnits = options.distanceUnits;
|
||||
}
|
||||
|
||||
if (options.velocityUnits !== $velocityUnits) {
|
||||
$velocityUnits = options.velocityUnits;
|
||||
}
|
||||
if (options.velocityUnits !== $velocityUnits) {
|
||||
$velocityUnits = options.velocityUnits;
|
||||
}
|
||||
|
||||
if (options.temperatureUnits !== $temperatureUnits) {
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
}
|
||||
if (options.temperatureUnits !== $temperatureUnits) {
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
}
|
||||
|
||||
if (options.theme !== $mode) {
|
||||
setMode(options.theme);
|
||||
}
|
||||
}
|
||||
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';
|
||||
});
|
||||
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 (browser && options) {
|
||||
applyOptions();
|
||||
}
|
||||
|
||||
$: if ($fileOrder) {
|
||||
updateGPXData();
|
||||
}
|
||||
$: if ($fileOrder) {
|
||||
updateGPXData();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
||||
$distanceMarkers = prevSettings.distanceMarkers;
|
||||
}
|
||||
onDestroy(() => {
|
||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
||||
$distanceMarkers = prevSettings.distanceMarkers;
|
||||
}
|
||||
|
||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
||||
$directionMarkers = prevSettings.directionMarkers;
|
||||
}
|
||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
||||
$directionMarkers = prevSettings.directionMarkers;
|
||||
}
|
||||
|
||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
||||
$distanceUnits = prevSettings.distanceUnits;
|
||||
}
|
||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
||||
$distanceUnits = prevSettings.distanceUnits;
|
||||
}
|
||||
|
||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
||||
$velocityUnits = prevSettings.velocityUnits;
|
||||
}
|
||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
||||
$velocityUnits = prevSettings.velocityUnits;
|
||||
}
|
||||
|
||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
||||
$temperatureUnits = prevSettings.temperatureUnits;
|
||||
}
|
||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
||||
$temperatureUnits = prevSettings.temperatureUnits;
|
||||
}
|
||||
|
||||
if ($mode !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
if ($mode !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
|
||||
$selection.clear();
|
||||
$fileObservers.clear();
|
||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
||||
});
|
||||
$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 {$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 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>
|
||||
|
||||
@ -39,14 +39,14 @@ export const defaultEmbeddingOptions = {
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false
|
||||
power: false,
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
@ -59,7 +59,11 @@ export function getMergedEmbeddingOptions(
|
||||
): EmbeddingOptions {
|
||||
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
||||
for (const key in options) {
|
||||
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
|
||||
if (
|
||||
typeof options[key] === 'object' &&
|
||||
options[key] !== null &&
|
||||
!Array.isArray(options[key])
|
||||
) {
|
||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||
} else {
|
||||
mergedOptions[key] = options[key];
|
||||
@ -79,7 +83,10 @@ export function getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key] !== null &&
|
||||
!Array.isArray(cleanedOptions[key])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key],
|
||||
defaultOptions[key]
|
||||
);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
@ -141,7 +148,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
}
|
||||
if (options.has('slope')) {
|
||||
newOptions.elevation = {
|
||||
fill: 'slope'
|
||||
fill: 'slope',
|
||||
};
|
||||
}
|
||||
return newOptions;
|
||||
|
||||
@ -1,328 +1,339 @@
|
||||
<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 { _ } 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';
|
||||
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 = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
];
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
||||
];
|
||||
|
||||
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 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 = false;
|
||||
let manualCamera = false;
|
||||
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
$: 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root id="embedding-playground">
|
||||
<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">
|
||||
<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(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
</code>
|
||||
</pre>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@ -1,89 +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 { 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';
|
||||
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';
|
||||
|
||||
export let orientation: 'vertical' | 'horizontal';
|
||||
export let recursive = false;
|
||||
export let orientation: 'vertical' | 'horizontal';
|
||||
export let recursive = false;
|
||||
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { treeFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
treeFileView.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
treeFileView.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' ? '' : 'mt-1 h-2'}
|
||||
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'} {$$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>
|
||||
<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>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { freeze } from "immer";
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
|
||||
import { selection } from "./Selection";
|
||||
import { newGPXFile } from "$lib/stores";
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { freeze } from 'immer';
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
|
||||
import { selection } from './Selection';
|
||||
import { newGPXFile } from '$lib/stores';
|
||||
|
||||
export enum ListLevel {
|
||||
ROOT,
|
||||
@ -10,7 +10,7 @@ export enum ListLevel {
|
||||
TRACK,
|
||||
SEGMENT,
|
||||
WAYPOINTS,
|
||||
WAYPOINT
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export abstract class ListItem {
|
||||
@ -322,7 +322,13 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
||||
export function moveItems(
|
||||
fromParent: ListItem,
|
||||
toParent: ListItem,
|
||||
fromItems: ListItem[],
|
||||
toItems: ListItem[],
|
||||
remove: boolean = true
|
||||
) {
|
||||
if (fromItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -338,11 +344,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
context.push(file.clone());
|
||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
||||
context.push(file.trk[item.getTrackIndex()].clone());
|
||||
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
item.getTrackIndex() < file.trk.length &&
|
||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
||||
) {
|
||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
||||
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
|
||||
} else if (
|
||||
item instanceof ListWaypointItem &&
|
||||
item.getWaypointIndex() < file.wpt.length
|
||||
) {
|
||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
||||
}
|
||||
}
|
||||
@ -359,7 +372,12 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
if (item instanceof ListTrackItem) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex(),
|
||||
[]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@ -371,25 +389,43 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
if (context[i] instanceof Track) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
context[i] instanceof TrackSegment
|
||||
) {
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex() - 1,
|
||||
[context[i]]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
|
||||
if (
|
||||
Array.isArray(context[i]) &&
|
||||
context[i].length > 0 &&
|
||||
context[i][0] instanceof Waypoint
|
||||
) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
||||
} else if (context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (fromParent instanceof ListRootItem) {
|
||||
@ -400,35 +436,42 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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);
|
||||
});
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
|
||||
@ -1,83 +1,84 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
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';
|
||||
|
||||
export let node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
export let item: ListItem;
|
||||
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;
|
||||
let collapsible: CollapsibleTreeNode;
|
||||
|
||||
$: 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')
|
||||
: '';
|
||||
$: 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 { treeFileView } = settings;
|
||||
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
|
||||
if ($selection) {
|
||||
openIfSelectedChild();
|
||||
}
|
||||
if ($selection) {
|
||||
openIfSelectedChild();
|
||||
}
|
||||
|
||||
afterUpdate(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}>
|
||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
||||
<div slot="content" class="ml-2">
|
||||
{#key node}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{/key}
|
||||
</div>
|
||||
</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}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
ListWaypointsItem,
|
||||
allowedMoves,
|
||||
moveItems,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { isMac } from '$lib/utils';
|
||||
@ -113,7 +113,7 @@
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
@ -155,7 +155,7 @@
|
||||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
@ -233,16 +233,16 @@
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
value: item,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,322 +1,335 @@
|
||||
<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 { 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';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
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';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let label: string | undefined;
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let label: string | undefined;
|
||||
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
$: singleSelection = $selection.size === 1;
|
||||
$: singleSelection = $selection.size === 1;
|
||||
|
||||
let nodeColors: string[] = [];
|
||||
let nodeColors: string[] = [];
|
||||
|
||||
$: if (node && $map) {
|
||||
nodeColors = [];
|
||||
$: if (node && $map) {
|
||||
nodeColors = [];
|
||||
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
nodeColors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
nodeColors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
|
||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
||||
$: openEditStyle =
|
||||
$editStyle &&
|
||||
$selection.has(item) &&
|
||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
||||
$: 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 -->
|
||||
<ContextMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (!get(selection).has(item)) {
|
||||
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 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}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component this={symbols[symbolKey].icon} size="16" class="mr-1 shrink-0" />
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
{/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.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}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="16"
|
||||
class="mr-1 shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
{/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>
|
||||
|
||||
@ -1,23 +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 type { GPXFileWithStatistics } from '$lib/db';
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { ListFileItem } from './FileList';
|
||||
import type { GPXFileWithStatistics } from '$lib/db';
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { ListFileItem } from './FileList';
|
||||
|
||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
||||
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}
|
||||
|
||||
@ -17,15 +17,15 @@
|
||||
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
? (node.metadata.name ?? '')
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
? (node.name ?? '')
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
? (node.metadata.desc ?? '')
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
? (node.desc ?? '')
|
||||
: '';
|
||||
|
||||
$: if (!open) {
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
|
||||
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
|
||||
import { get, writable } from 'svelte/store';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
moveItems,
|
||||
} from './FileList';
|
||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
@ -67,7 +78,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@ -80,7 +95,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@ -131,7 +150,7 @@ export class SelectionTreeType {
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||
|
||||
@ -181,7 +200,10 @@ export function selectAll() {
|
||||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
|
||||
$selection.set(
|
||||
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@ -205,14 +227,24 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
get(settings.fileOrder).forEach((fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
@ -225,7 +257,10 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
|
||||
});
|
||||
}
|
||||
|
||||
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
@ -270,7 +305,11 @@ export function pasteSelection() {
|
||||
let startIndex: number | undefined = undefined;
|
||||
|
||||
if (fromItems[0].level === toParent.level) {
|
||||
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
||||
if (
|
||||
toParent instanceof ListTrackItem ||
|
||||
toParent instanceof ListTrackSegmentItem ||
|
||||
toParent instanceof ListWaypointItem
|
||||
) {
|
||||
startIndex = toParent.getId() + 1;
|
||||
}
|
||||
toParent = toParent.getParent();
|
||||
@ -288,20 +327,41 @@ export function pasteSelection() {
|
||||
fromItems.forEach((item, index) => {
|
||||
if (toParent instanceof ListFileItem) {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.trk.length) + index
|
||||
)
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListTrackItem) {
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
let toTrackIndex = toParent.getTrackIndex();
|
||||
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackSegmentItem(
|
||||
toParent.getFileId(),
|
||||
toTrackIndex,
|
||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListWaypointsItem) {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,167 +1,173 @@
|
||||
<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';
|
||||
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;
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let widthChanged = false;
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let widthChanged = false;
|
||||
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
width = [];
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
width = [];
|
||||
|
||||
$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);
|
||||
$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.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.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['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (style['gpx_style:opacity'] && !opacity.includes(style['gpx_style:opacity'])) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
colors.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.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.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['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (
|
||||
style['gpx_style:opacity'] &&
|
||||
!opacity.includes(style['gpx_style:opacity'])
|
||||
) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
colors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
|
||||
$: if ($selection && open) {
|
||||
setStyleInputs();
|
||||
}
|
||||
$: if ($selection && open) {
|
||||
setStyleInputs();
|
||||
}
|
||||
|
||||
$: if (!open) {
|
||||
$editStyle = false;
|
||||
}
|
||||
$: 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={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
<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={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
|
||||
import { settings } from "$lib/db";
|
||||
import { gpxStatistics } from "$lib/stores";
|
||||
import { get } from "svelte/store";
|
||||
import { settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
|
||||
export class DistanceMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@ -30,7 +36,7 @@ export class DistanceMarkers {
|
||||
} else {
|
||||
this.map.addSource('distance-markers', {
|
||||
type: 'geojson',
|
||||
data: this.getDistanceMarkersGeoJSON()
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
@ -39,7 +45,14 @@ export class DistanceMarkers {
|
||||
id: `distance-markers-${d}`,
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
layout: {
|
||||
@ -51,7 +64,7 @@ export class DistanceMarkers {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.map.moveLayer(`distance-markers-${d}`);
|
||||
@ -64,13 +77,14 @@ export class DistanceMarkers {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
@ -79,20 +93,28 @@ export class DistanceMarkers {
|
||||
let features = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
|
||||
) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
level,
|
||||
minzoom,
|
||||
}
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
@ -100,7 +122,7 @@ export class DistanceMarkers {
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
features,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,28 @@
|
||||
import { currentTool, map, Tool } from "$lib/stores";
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
import { currentTool, map, Tool } from '$lib/stores';
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
|
||||
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
ListTrackItem,
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
getClosestLinePoint,
|
||||
getElevation,
|
||||
resetCursor,
|
||||
setGrabbingCursor,
|
||||
setPointerCursor,
|
||||
setScissorsCursor,
|
||||
} from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@ -21,7 +35,7 @@ const colors = [
|
||||
'#288228',
|
||||
'#9933ff',
|
||||
'#50f0be',
|
||||
'#8c645a'
|
||||
'#8c645a',
|
||||
];
|
||||
|
||||
const colorCount: { [key: string]: number } = {};
|
||||
@ -56,12 +70,12 @@ class KeyDown {
|
||||
if (e.key === this.key) {
|
||||
this.down = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
isDown() {
|
||||
return this.down;
|
||||
}
|
||||
@ -70,22 +84,26 @@ class KeyDown {
|
||||
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"')
|
||||
${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"') ?? ''}
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
||||
}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@ -108,32 +126,40 @@ export class GPXLayer {
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(selection.subscribe($selection => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.update();
|
||||
}
|
||||
if (newSelected) {
|
||||
this.moveToFront();
|
||||
}
|
||||
}));
|
||||
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.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);
|
||||
@ -149,7 +175,11 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
||||
if (
|
||||
file._data.style &&
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
@ -161,7 +191,7 @@ export class GPXLayer {
|
||||
} else {
|
||||
this.map.addSource(this.fileId, {
|
||||
type: 'geojson',
|
||||
data: this.getGeoJSON()
|
||||
data: this.getGeoJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -172,13 +202,13 @@ export class GPXLayer {
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity']
|
||||
}
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
|
||||
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
@ -190,27 +220,30 @@ export class GPXLayer {
|
||||
|
||||
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,
|
||||
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',
|
||||
},
|
||||
},
|
||||
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);
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
@ -225,23 +258,53 @@ export class GPXLayer {
|
||||
}
|
||||
});
|
||||
|
||||
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => { // Update markers
|
||||
file.wpt.forEach((waypoint) => {
|
||||
// Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
||||
symbolKey,
|
||||
this.layerColor
|
||||
);
|
||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
||||
value: waypoint,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
@ -249,7 +312,7 @@ export class GPXLayer {
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
anchor: 'bottom'
|
||||
anchor: 'bottom',
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
@ -272,10 +335,20 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
if (get(treeFileView)) {
|
||||
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
|
||||
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
get(selection).hasAnyChildren(
|
||||
new ListWaypointsItem(this.fileId),
|
||||
false
|
||||
)
|
||||
) {
|
||||
addSelectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
} else {
|
||||
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
selectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
}
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
@ -298,12 +371,12 @@ export class GPXLayer {
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng
|
||||
lon: latLng.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
});
|
||||
dragEndTimestamp = Date.now()
|
||||
dragEndTimestamp = Date.now();
|
||||
});
|
||||
this.markers.push(marker);
|
||||
}
|
||||
@ -311,7 +384,8 @@ export class GPXLayer {
|
||||
});
|
||||
}
|
||||
|
||||
while (markerIndex < this.markers.length) { // Remove extra markers
|
||||
while (markerIndex < this.markers.length) {
|
||||
// Remove extra markers
|
||||
this.markers.pop()?.remove();
|
||||
}
|
||||
|
||||
@ -364,7 +438,10 @@ export class GPXLayer {
|
||||
this.map.moveLayer(this.fileId);
|
||||
}
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
this.map.moveLayer(
|
||||
this.fileId + '-direction',
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +449,12 @@ export class GPXLayer {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
setScissorsCursor();
|
||||
} else {
|
||||
setPointerCursor();
|
||||
@ -390,22 +472,36 @@ export class GPXLayer {
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
const closest = getClosestLinePoint(
|
||||
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
|
||||
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
|
||||
);
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -415,8 +511,12 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
let item = undefined;
|
||||
if (get(treeFileView) && file.getSegments().length > 1) { // Select inner item
|
||||
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
|
||||
if (get(treeFileView) && file.getSegments().length > 1) {
|
||||
// Select inner item
|
||||
item =
|
||||
file.children[trackIndex].children.length > 1
|
||||
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
: new ListTrackItem(this.fileId, trackIndex);
|
||||
} else {
|
||||
item = new ListFileItem(this.fileId);
|
||||
}
|
||||
@ -439,13 +539,14 @@ export class GPXLayer {
|
||||
if (!file) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
let data = file.toGeoJSON();
|
||||
|
||||
let trackIndex = 0, segmentIndex = 0;
|
||||
let trackIndex = 0,
|
||||
segmentIndex = 0;
|
||||
for (let feature of data.features) {
|
||||
if (!feature.properties) {
|
||||
feature.properties = {};
|
||||
@ -459,7 +560,12 @@ export class GPXLayer {
|
||||
if (!feature.properties.width) {
|
||||
feature.properties.width = get(defaultWidth);
|
||||
}
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
) ||
|
||||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
|
||||
) {
|
||||
feature.properties.width = feature.properties.width + 2;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { dbUtils } from "$lib/db";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
@ -11,14 +11,14 @@ export function createPopups(map: mapboxgl.Map) {
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
bottom: [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [10, -15],
|
||||
'right': [-10, -15],
|
||||
left: [10, -15],
|
||||
right: [-10, -15],
|
||||
},
|
||||
});
|
||||
trackpointPopup = new MapPopup(map, {
|
||||
|
||||
@ -1,56 +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';
|
||||
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;
|
||||
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 && $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);
|
||||
}
|
||||
$: 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;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
gpxLayers.forEach((layer) => layer.remove());
|
||||
gpxLayers.clear();
|
||||
removePopups();
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
distanceMarkers = undefined;
|
||||
}
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
startEndMarkers = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get } from "svelte/store";
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export class StartEndMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@ -16,7 +16,8 @@ export class StartEndMarkers {
|
||||
let endElement = document.createElement('div');
|
||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
endElement.style.background =
|
||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
|
||||
this.start = new mapboxgl.Marker({ element: startElement });
|
||||
this.end = new mapboxgl.Marker({ element: endElement });
|
||||
@ -31,7 +32,11 @@ export class StartEndMarkers {
|
||||
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
||||
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(this.map);
|
||||
} else {
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
@ -39,7 +44,7 @@ export class StartEndMarkers {
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
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)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</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}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
|
||||
);
|
||||
trackpoint.hide?.();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
<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)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</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}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
|
||||
);
|
||||
trackpoint.hide?.();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@ -1,102 +1,104 @@
|
||||
<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';
|
||||
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>;
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
$: 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();
|
||||
}
|
||||
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)}° {waypoint.item.getLongitude().toFixed(6)}°
|
||||
{#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.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)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#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(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,422 +1,436 @@
|
||||
<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';
|
||||
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;
|
||||
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 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 basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
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'
|
||||
);
|
||||
}
|
||||
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 = 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);
|
||||
});
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
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';
|
||||
}
|
||||
}
|
||||
$: 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);
|
||||
}
|
||||
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'));
|
||||
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: ''
|
||||
};
|
||||
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();
|
||||
}
|
||||
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 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;
|
||||
});
|
||||
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 ($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 (!$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['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 (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
$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);
|
||||
$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);
|
||||
}
|
||||
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;
|
||||
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';
|
||||
}
|
||||
}
|
||||
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();
|
||||
$: 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>
|
||||
{#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>
|
||||
<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>
|
||||
|
||||
@ -1,222 +1,222 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
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 { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
import { Layers } from 'lucide-svelte';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
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;
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
} = settings;
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
$: 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
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 && $currentOverlays && $opacities) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', 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 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;
|
||||
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>
|
||||
<!-- 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();
|
||||
}
|
||||
}}
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,189 +1,197 @@
|
||||
<script lang="ts">
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
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 { 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 {
|
||||
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';
|
||||
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;
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
export let open: boolean;
|
||||
let accordionValue: string | string[] | undefined = undefined;
|
||||
export let open: boolean;
|
||||
let accordionValue: string | string[] | undefined = undefined;
|
||||
|
||||
let selectedOverlay = writable(undefined);
|
||||
let overlayOpacity = writable([1]);
|
||||
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];
|
||||
}
|
||||
}
|
||||
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 ($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 ($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();
|
||||
}
|
||||
$: 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.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>
|
||||
|
||||
@ -1,20 +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';
|
||||
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 layerTree: LayerTreeType;
|
||||
export let name: string;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
|
||||
export let checked: LayerTreeType = {};
|
||||
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>
|
||||
<fieldset class="min-w-64 mb-1">
|
||||
<CollapsibleTree nohover={true}>
|
||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
||||
</CollapsibleTree>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@ -1,86 +1,98 @@
|
||||
<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 { 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 { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { anySelectedLayer } from './utils';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { beforeUpdate } from 'svelte';
|
||||
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 name: string;
|
||||
export let node: LayerTreeType;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
|
||||
export let checked: LayerTreeType;
|
||||
export let checked: LayerTreeType;
|
||||
|
||||
const { customLayers } = settings;
|
||||
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] = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
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}
|
||||
{#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;
|
||||
}
|
||||
div :global(input[type='radio']) {
|
||||
@apply appearance-none;
|
||||
@apply w-4 h-4;
|
||||
@apply border-[1.5px] border-primary;
|
||||
@apply rounded-full;
|
||||
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
@apply cursor-pointer;
|
||||
@apply checked:bg-primary;
|
||||
@apply checked:bg-clip-content;
|
||||
@apply checked:p-0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||
import { getLayers } from "./utils";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { liveQuery } from "dexie";
|
||||
import { db, settings } from "$lib/db";
|
||||
import { overpassQueryData } from "$lib/assets/layers";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db, settings } from '$lib/db';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
const {
|
||||
currentOverpassQueries
|
||||
} = settings;
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
@ -29,7 +27,7 @@ export class OverpassLayer {
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
@ -50,10 +48,12 @@ export class OverpassLayer {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
}));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
})
|
||||
);
|
||||
|
||||
this.update();
|
||||
}
|
||||
@ -126,8 +126,8 @@ export class OverpassLayer {
|
||||
this.popup.setItem({
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
}
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -146,12 +146,23 @@ 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -165,13 +176,16 @@ 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}`));
|
||||
}
|
||||
@ -179,7 +193,7 @@ export class OverpassLayer {
|
||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
return;
|
||||
@ -195,7 +209,9 @@ export class OverpassLayer {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||
coordinates: element.center
|
||||
? [element.center.lon, element.center.lat]
|
||||
: [element.lon, element.lat],
|
||||
},
|
||||
properties: {
|
||||
id: element.id,
|
||||
@ -203,9 +219,9 @@ export class OverpassLayer {
|
||||
lon: element.center ? element.center.lon : element.lon,
|
||||
query: query,
|
||||
icon: `overpass-${query}`,
|
||||
tags: element.tags
|
||||
tags: element.tags,
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -228,11 +244,13 @@ export class OverpassLayer {
|
||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
this.map.addImage(`overpass-${query}`, icon);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||
<g transform="translate(8 8)">
|
||||
@ -264,9 +282,14 @@ function getQuery(query: string) {
|
||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
|
||||
.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}]`)
|
||||
@ -283,8 +306,9 @@ function belongsToQuery(element: any, query: string) {
|
||||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
return Object.entries(tags)
|
||||
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentQueries() {
|
||||
@ -293,5 +317,7 @@ function getCurrentQueries() {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||
return Object.entries(getLayers(currentQueries))
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([query, _]) => query);
|
||||
}
|
||||
@ -1,95 +1,95 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
export let poi: PopupItem<any>;
|
||||
export let poi: PopupItem<any>;
|
||||
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon,
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@ -1,24 +1,29 @@
|
||||
import type { LayerTreeType } from "$lib/assets/layers";
|
||||
import { writable } from "svelte/store";
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
return (
|
||||
Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (anySelectedLayer(node[id])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (anySelectedLayer(node[id])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) !== undefined;
|
||||
return false;
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
|
||||
export function getLayers(
|
||||
node: LayerTreeType,
|
||||
layers: { [key: string]: boolean } = {}
|
||||
): { [key: string]: boolean } {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
layers[id] = node[id];
|
||||
} else {
|
||||
getLayers(node[id], layers);
|
||||
@ -32,7 +37,7 @@ export function isSelected(node: LayerTreeType, id: string) {
|
||||
if (key === id) {
|
||||
return node[key];
|
||||
}
|
||||
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
|
||||
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -43,7 +48,7 @@ export function toggle(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
node[key] = !node[key];
|
||||
} else if (typeof node[key] !== "boolean") {
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
toggle(node[key], id);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { resetCursor, setCrosshairCursor } from "$lib/utils";
|
||||
import type mapboxgl from "mapbox-gl";
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from "mapbox-gl";
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { resetCursor, setPointerCursor } from "$lib/utils";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { resetCursor, setPointerCursor } from '$lib/utils';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
|
||||
tiles: [
|
||||
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
],
|
||||
minzoom: 6,
|
||||
maxzoom: 14,
|
||||
};
|
||||
@ -70,7 +72,7 @@ export class MapillaryLayer {
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element
|
||||
element,
|
||||
});
|
||||
|
||||
this.viewer.on('position', async () => {
|
||||
|
||||
@ -1,74 +1,74 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import { PersonStanding, X } from 'lucide-svelte';
|
||||
import { MapillaryLayer } from './Mapillary';
|
||||
import { GoogleRedirect } from './Google';
|
||||
import { map, streetViewEnabled } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import { PersonStanding, X } from 'lucide-svelte';
|
||||
import { MapillaryLayer } from './Mapillary';
|
||||
import { GoogleRedirect } from './Google';
|
||||
import { map, streetViewEnabled } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
const { streetViewSource } = settings;
|
||||
|
||||
let googleRedirect: GoogleRedirect;
|
||||
let mapillaryLayer: MapillaryLayer;
|
||||
let mapillaryOpen = writable(false);
|
||||
let container: HTMLElement;
|
||||
let googleRedirect: GoogleRedirect;
|
||||
let mapillaryLayer: MapillaryLayer;
|
||||
let mapillaryOpen = writable(false);
|
||||
let container: HTMLElement;
|
||||
|
||||
$: if ($map) {
|
||||
googleRedirect = new GoogleRedirect($map);
|
||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
||||
}
|
||||
$: if ($map) {
|
||||
googleRedirect = new GoogleRedirect($map);
|
||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
||||
}
|
||||
|
||||
$: if (mapillaryLayer) {
|
||||
if ($streetViewSource === 'mapillary') {
|
||||
googleRedirect.remove();
|
||||
if ($streetViewEnabled) {
|
||||
mapillaryLayer.add();
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
}
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
if ($streetViewEnabled) {
|
||||
googleRedirect.add();
|
||||
} else {
|
||||
googleRedirect.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
$: if (mapillaryLayer) {
|
||||
if ($streetViewSource === 'mapillary') {
|
||||
googleRedirect.remove();
|
||||
if ($streetViewEnabled) {
|
||||
mapillaryLayer.add();
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
}
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
if ($streetViewEnabled) {
|
||||
googleRedirect.add();
|
||||
} else {
|
||||
googleRedirect.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
||||
<Toggle
|
||||
bind:pressed={$streetViewEnabled}
|
||||
class="w-full h-full rounded p-0"
|
||||
aria-label={$_('menu.toggle_street_view')}
|
||||
>
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
||||
<Toggle
|
||||
bind:pressed={$streetViewEnabled}
|
||||
class="w-full h-full rounded p-0"
|
||||
aria-label={$_('menu.toggle_street_view')}
|
||||
>
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
</CustomControl>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="{$mapillaryOpen
|
||||
? ''
|
||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||
bind:this={container}
|
||||
class="{$mapillaryOpen
|
||||
? ''
|
||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||
on:click={() => {
|
||||
if (mapillaryLayer) {
|
||||
mapillaryLayer.closePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size="16" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||
on:click={() => {
|
||||
if (mapillaryLayer) {
|
||||
mapillaryLayer.closePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,54 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { Tool } from '$lib/stores';
|
||||
import ToolbarItem from './ToolbarItem.svelte';
|
||||
import {
|
||||
Group,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
SquareDashedMousePointer,
|
||||
Ungroup,
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors,
|
||||
MountainSnow
|
||||
} from 'lucide-svelte';
|
||||
import { Tool } from '$lib/stores';
|
||||
import ToolbarItem from './ToolbarItem.svelte';
|
||||
import {
|
||||
Group,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
SquareDashedMousePointer,
|
||||
Ungroup,
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors,
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row w-full items-center pr-12">
|
||||
<div
|
||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
||||
''}"
|
||||
>
|
||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
||||
<Pencil slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
||||
<MapPin slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
||||
<Scissors slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
||||
<CalendarClock slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
||||
<Group slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
||||
<Ungroup slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
||||
<MountainSnow slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
||||
<Filter slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
||||
<SquareDashedMousePointer slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
</div>
|
||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
||||
<div
|
||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
||||
''}"
|
||||
>
|
||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
||||
<Pencil slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
||||
<MapPin slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
||||
<Scissors slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
||||
<CalendarClock slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
||||
<Group slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
||||
<Ungroup slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
||||
<MountainSnow slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
||||
<Filter slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
||||
<SquareDashedMousePointer slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
</div>
|
||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
||||
</div>
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { currentTool, type Tool } from '$lib/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { currentTool, type Tool } from '$lib/stores';
|
||||
|
||||
export let tool: Tool;
|
||||
export let label: string;
|
||||
export let tool: Tool;
|
||||
export let label: string;
|
||||
|
||||
function toggleTool() {
|
||||
currentTool.update((current) => (current === tool ? null : tool));
|
||||
}
|
||||
function toggleTool() {
|
||||
currentTool.update((current) => (current === tool ? null : tool));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tooltip.Root openDelay={300}>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button
|
||||
builders={[builder]}
|
||||
variant="ghost"
|
||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
||||
on:click={toggleTool}
|
||||
aria-label={label}
|
||||
>
|
||||
<slot name="icon" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button
|
||||
builders={[builder]}
|
||||
variant="ghost"
|
||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
||||
on:click={toggleTool}
|
||||
aria-label={label}
|
||||
>
|
||||
<slot name="icon" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@ -1,75 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { flyAndScale } from '$lib/utils';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import Time from '$lib/components/toolbar/tools/Time.svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { flyAndScale } from '$lib/utils';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import Time from '$lib/components/toolbar/tools/Time.svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
|
||||
let popupElement: HTMLElement;
|
||||
let popup: mapboxgl.Popup;
|
||||
let popupElement: HTMLElement;
|
||||
let popup: mapboxgl.Popup;
|
||||
|
||||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $currentTool !== null}
|
||||
<div
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
|
||||
class="translate-x-1 h-full {$$props.class ?? ''}"
|
||||
>
|
||||
<div class="rounded-md shadow-md pointer-events-auto">
|
||||
<Card.Root class="rounded-md border-none">
|
||||
<Card.Content class="p-2.5">
|
||||
{#if $currentTool === Tool.ROUTING}
|
||||
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
||||
{:else if $currentTool === Tool.SCISSORS}
|
||||
<Scissors />
|
||||
{:else if $currentTool === Tool.WAYPOINT}
|
||||
<Waypoint />
|
||||
{:else if $currentTool === Tool.TIME}
|
||||
<Time />
|
||||
{:else if $currentTool === Tool.MERGE}
|
||||
<Merge />
|
||||
{:else if $currentTool === Tool.ELEVATION}
|
||||
<Elevation />
|
||||
{:else if $currentTool === Tool.EXTRACT}
|
||||
<Extract />
|
||||
{:else if $currentTool === Tool.CLEAN}
|
||||
<Clean />
|
||||
{:else if $currentTool === Tool.REDUCE}
|
||||
<Reduce />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
|
||||
class="translate-x-1 h-full {$$props.class ?? ''}"
|
||||
>
|
||||
<div class="rounded-md shadow-md pointer-events-auto">
|
||||
<Card.Root class="rounded-md border-none">
|
||||
<Card.Content class="p-2.5">
|
||||
{#if $currentTool === Tool.ROUTING}
|
||||
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
||||
{:else if $currentTool === Tool.SCISSORS}
|
||||
<Scissors />
|
||||
{:else if $currentTool === Tool.WAYPOINT}
|
||||
<Waypoint />
|
||||
{:else if $currentTool === Tool.TIME}
|
||||
<Time />
|
||||
{:else if $currentTool === Tool.MERGE}
|
||||
<Merge />
|
||||
{:else if $currentTool === Tool.ELEVATION}
|
||||
<Elevation />
|
||||
{:else if $currentTool === Tool.EXTRACT}
|
||||
<Extract />
|
||||
{:else if $currentTool === Tool.CLEAN}
|
||||
<Clean />
|
||||
{:else if $currentTool === Tool.REDUCE}
|
||||
<Reduce />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ($currentTool !== null && e.key === 'Escape') {
|
||||
currentTool.set(null);
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if ($currentTool !== null && e.key === 'Escape') {
|
||||
currentTool.set(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<RoutingControlPopup bind:element={popupElement} />
|
||||
|
||||
@ -1,188 +1,188 @@
|
||||
<script lang="ts" context="module">
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside'
|
||||
}
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { dbUtils } from '$lib/db';
|
||||
|
||||
let cleanType = CleanType.INSIDE;
|
||||
let deleteTrackpoints = true;
|
||||
let deleteWaypoints = true;
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
||||
let cleanType = CleanType.INSIDE;
|
||||
let deleteTrackpoints = true;
|
||||
let deleteWaypoints = true;
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
||||
|
||||
function updateRectangle() {
|
||||
if ($map) {
|
||||
if (rectangleCoordinates.length != 2) {
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
} else {
|
||||
let data = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateRectangle() {
|
||||
if ($map) {
|
||||
if (rectangleCoordinates.length != 2) {
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
} else {
|
||||
let data = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if (rectangleCoordinates) {
|
||||
updateRectangle();
|
||||
}
|
||||
$: if (rectangleCoordinates) {
|
||||
updateRectangle();
|
||||
}
|
||||
|
||||
let mousedown = false;
|
||||
function onMouseDown(e: any) {
|
||||
mousedown = true;
|
||||
rectangleCoordinates = [e.lngLat, e.lngLat];
|
||||
}
|
||||
let mousedown = false;
|
||||
function onMouseDown(e: any) {
|
||||
mousedown = true;
|
||||
rectangleCoordinates = [e.lngLat, e.lngLat];
|
||||
}
|
||||
|
||||
function onMouseMove(e: any) {
|
||||
if (mousedown) {
|
||||
rectangleCoordinates[1] = e.lngLat;
|
||||
}
|
||||
}
|
||||
function onMouseMove(e: any) {
|
||||
if (mousedown) {
|
||||
rectangleCoordinates[1] = e.lngLat;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(e: any) {
|
||||
mousedown = false;
|
||||
}
|
||||
function onMouseUp(e: any) {
|
||||
mousedown = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
});
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
});
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('mousedown', onMouseDown);
|
||||
$map.on('mousemove', onMouseMove);
|
||||
$map.on('mouseup', onMouseUp);
|
||||
$map.on('touchstart', onMouseDown);
|
||||
$map.on('touchmove', onMouseMove);
|
||||
$map.on('touchend', onMouseUp);
|
||||
$map.dragPan.disable();
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('mousedown', onMouseDown);
|
||||
$map.on('mousemove', onMouseMove);
|
||||
$map.on('mouseup', onMouseUp);
|
||||
$map.on('touchstart', onMouseDown);
|
||||
$map.on('touchmove', onMouseMove);
|
||||
$map.on('touchend', onMouseUp);
|
||||
$map.dragPan.disable();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
if ($map) {
|
||||
$map.off('mousedown', onMouseDown);
|
||||
$map.off('mousemove', onMouseMove);
|
||||
$map.off('mouseup', onMouseUp);
|
||||
$map.off('touchstart', onMouseDown);
|
||||
$map.off('touchmove', onMouseMove);
|
||||
$map.off('touchend', onMouseUp);
|
||||
$map.dragPan.enable();
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
if ($map) {
|
||||
$map.off('mousedown', onMouseDown);
|
||||
$map.off('mousemove', onMouseMove);
|
||||
$map.off('mouseup', onMouseUp);
|
||||
$map.off('touchstart', onMouseDown);
|
||||
$map.off('touchmove', onMouseMove);
|
||||
$map.off('touchend', onMouseUp);
|
||||
$map.dragPan.enable();
|
||||
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
if ($map.getSource('rectangle')) {
|
||||
$map.removeSource('rectangle');
|
||||
}
|
||||
}
|
||||
});
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
if ($map.getSource('rectangle')) {
|
||||
$map.removeSource('rectangle');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$: validSelection = $selection.size > 0;
|
||||
$: validSelection = $selection.size > 0;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 items-center {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
||||
<Label for="delete-trkpt">
|
||||
{$_('toolbar.clean.delete_trackpoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
||||
<Label for="delete-wpt">
|
||||
{$_('toolbar.clean.delete_waypoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<RadioGroup.Root bind:value={cleanType}>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.INSIDE} />
|
||||
{$_('toolbar.clean.delete_inside')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
||||
{$_('toolbar.clean.delete_outside')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
on:click={() => {
|
||||
dbUtils.cleanSelection(
|
||||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
}
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
);
|
||||
rectangleCoordinates = [];
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
{$_('toolbar.clean.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
||||
<Label for="delete-trkpt">
|
||||
{$_('toolbar.clean.delete_trackpoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
||||
<Label for="delete-wpt">
|
||||
{$_('toolbar.clean.delete_waypoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<RadioGroup.Root bind:value={cleanType}>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.INSIDE} />
|
||||
{$_('toolbar.clean.delete_inside')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
||||
{$_('toolbar.clean.delete_outside')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
on:click={() => {
|
||||
dbUtils.cleanSelection(
|
||||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
);
|
||||
rectangleCoordinates = [];
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
{$_('toolbar.clean.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection = $selection.size > 0;
|
||||
$: validSelection = $selection.size > 0;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
on:click={async () => {
|
||||
if ($map) {
|
||||
dbUtils.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.elevation.help')}
|
||||
{:else}
|
||||
{$_('toolbar.elevation.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
on:click={async () => {
|
||||
if ($map) {
|
||||
dbUtils.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.elevation.help')}
|
||||
{:else}
|
||||
{$_('toolbar.elevation.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,53 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from 'lucide-svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from 'lucide-svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection =
|
||||
$selection.size > 0 &&
|
||||
$selection.getSelected().every((item) => {
|
||||
if (
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem ||
|
||||
item instanceof ListTrackSegmentItem
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
if (item instanceof ListFileItem) {
|
||||
return file.getSegments().length > 1;
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (item.getTrackIndex() < file.trk.length) {
|
||||
return file.trk[item.getTrackIndex()].getSegments().length > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
$: validSelection =
|
||||
$selection.size > 0 &&
|
||||
$selection.getSelected().every((item) => {
|
||||
if (
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem ||
|
||||
item instanceof ListTrackSegmentItem
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
if (item instanceof ListFileItem) {
|
||||
return file.getSegments().length > 1;
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (item.getTrackIndex() < file.trk.length) {
|
||||
return file.trk[item.getTrackIndex()].getSegments().length > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
{$_('toolbar.extract.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
{$_('toolbar.extract.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,117 +1,117 @@
|
||||
<script lang="ts" context="module">
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents'
|
||||
}
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { Group } from 'lucide-svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { Group } from 'lucide-svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
let removeGaps = false;
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
let removeGaps = false;
|
||||
|
||||
$: if ($selection.size > 1) {
|
||||
canMergeTraces = true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file) {
|
||||
canMergeTraces = file.getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else {
|
||||
canMergeContents = false;
|
||||
}
|
||||
}
|
||||
$: if ($selection.size > 1) {
|
||||
canMergeTraces = true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file) {
|
||||
canMergeTraces = file.getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else {
|
||||
canMergeContents = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: canMergeContents =
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
|
||||
$: canMergeContents =
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
|
||||
|
||||
let mergeType = MergeType.TRACES;
|
||||
let mergeType = MergeType.TRACES;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<RadioGroup.Root bind:value={mergeType}>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.TRACES} />
|
||||
{$_('toolbar.merge.merge_traces')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||
{$_('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||
{$_('toolbar.merge.help_merge_contents')}
|
||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{/if}
|
||||
</Help>
|
||||
<RadioGroup.Root bind:value={mergeType}>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.TRACES} />
|
||||
{$_('toolbar.merge.merge_traces')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||
{$_('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||
{$_('toolbar.merge.help_merge_contents')}
|
||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,178 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { dbUtils, fileObservers } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import { derived } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { dbUtils, fileObservers } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import { derived } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
const minTolerance = 0.1;
|
||||
const maxTolerance = 10000;
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
const minTolerance = 0.1;
|
||||
const maxTolerance = 10000;
|
||||
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
|
||||
$: tolerance =
|
||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
||||
$: tolerance =
|
||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
||||
|
||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||
let unsubscribes = new Map<string, () => void>();
|
||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||
let unsubscribes = new Map<string, () => void>();
|
||||
|
||||
function update() {
|
||||
maxPoints = 0;
|
||||
currentPoints = 0;
|
||||
function update() {
|
||||
maxPoints = 0;
|
||||
currentPoints = 0;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
};
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
maxPoints += maxPts;
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
maxPoints += maxPts;
|
||||
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= tolerance
|
||||
);
|
||||
currentPoints += current.length;
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= tolerance
|
||||
);
|
||||
currentPoints += current.length;
|
||||
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude()
|
||||
])
|
||||
},
|
||||
properties: {}
|
||||
});
|
||||
});
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude(),
|
||||
]),
|
||||
},
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
if ($map) {
|
||||
let source = $map.getSource('simplified');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('simplified')) {
|
||||
$map.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$map.moveLayer('simplified');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($map) {
|
||||
let source = $map.getSource('simplified');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('simplified')) {
|
||||
$map.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
$map.moveLayer('simplified');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($fileObservers) {
|
||||
unsubscribes.forEach((unsubscribe, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
unsubscribe();
|
||||
unsubscribes.delete(fileId);
|
||||
}
|
||||
});
|
||||
$fileObservers.forEach((fileStore, fileId) => {
|
||||
if (!unsubscribes.has(fileId)) {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [fs, sel]).subscribe(
|
||||
([fs, sel]) => {
|
||||
if (fs) {
|
||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex);
|
||||
if (sel.hasAnyParent(segmentItem)) {
|
||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||
simplified.set(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance)
|
||||
]);
|
||||
update();
|
||||
} else if (simplified.has(segmentItem.getFullId())) {
|
||||
simplified.delete(segmentItem.getFullId());
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
unsubscribes.set(fileId, unsubscribe);
|
||||
}
|
||||
});
|
||||
}
|
||||
$: if ($fileObservers) {
|
||||
unsubscribes.forEach((unsubscribe, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
unsubscribe();
|
||||
unsubscribes.delete(fileId);
|
||||
}
|
||||
});
|
||||
$fileObservers.forEach((fileStore, fileId) => {
|
||||
if (!unsubscribes.has(fileId)) {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
|
||||
fs,
|
||||
sel,
|
||||
]).subscribe(([fs, sel]) => {
|
||||
if (fs) {
|
||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
);
|
||||
if (sel.hasAnyParent(segmentItem)) {
|
||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||
simplified.set(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
]);
|
||||
update();
|
||||
} else if (simplified.has(segmentItem.getFullId())) {
|
||||
simplified.delete(segmentItem.getFullId());
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubscribes.set(fileId, unsubscribe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: if (tolerance) {
|
||||
update();
|
||||
}
|
||||
$: if (tolerance) {
|
||||
update();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
if ($map.getLayer('simplified')) {
|
||||
$map.removeLayer('simplified');
|
||||
}
|
||||
if ($map.getSource('simplified')) {
|
||||
$map.removeSource('simplified');
|
||||
}
|
||||
}
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
simplified.clear();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
if ($map.getLayer('simplified')) {
|
||||
$map.removeLayer('simplified');
|
||||
}
|
||||
if ($map.getSource('simplified')) {
|
||||
$map.removeSource('simplified');
|
||||
}
|
||||
}
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
simplified.clear();
|
||||
});
|
||||
|
||||
function reduce() {
|
||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
itemsAndPoints.set(
|
||||
item,
|
||||
points
|
||||
.filter((point) => point.distance === undefined || point.distance >= tolerance)
|
||||
.map((point) => point.point)
|
||||
);
|
||||
});
|
||||
dbUtils.reduce(itemsAndPoints);
|
||||
}
|
||||
function reduce() {
|
||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
itemsAndPoints.set(
|
||||
item,
|
||||
points
|
||||
.filter((point) => point.distance === undefined || point.distance >= tolerance)
|
||||
.map((point) => point.point)
|
||||
);
|
||||
});
|
||||
dbUtils.reduce(itemsAndPoints);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
||||
<Filter size="16" class="mr-1" />
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
||||
<Filter size="16" class="mr-1" />
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
{$_('toolbar.reduce.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
{$_('toolbar.reduce.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,393 +1,404 @@
|
||||
<script lang="ts">
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers,
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
let endTime: string | undefined = undefined;
|
||||
let movingTime: number | undefined = undefined;
|
||||
let speed: number | undefined = undefined;
|
||||
let artificial = false;
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
let endTime: string | undefined = undefined;
|
||||
let movingTime: number | undefined = undefined;
|
||||
let speed: number | undefined = undefined;
|
||||
let artificial = false;
|
||||
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
|
||||
function toTimeString(date: Date): string {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
function toTimeString(date: Date): string {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = toTimeString($gpxStatistics.global.time.start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = toTimeString($gpxStatistics.global.time.start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
if (seconds === undefined) {
|
||||
seconds = 0;
|
||||
}
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
if (seconds === undefined) {
|
||||
seconds = 0;
|
||||
}
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = toTimeString(end);
|
||||
}
|
||||
}
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = toTimeString(end);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = toTimeString(start);
|
||||
}
|
||||
}
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = toTimeString(start);
|
||||
}
|
||||
}
|
||||
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
|
||||
updateEnd();
|
||||
}
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.knots')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.minutes_per_nautical_mile')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:change={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
|
||||
return;
|
||||
}
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.knots')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.minutes_per_nautical_mile')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:change={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(getDate(startDate, startTime), movingTime);
|
||||
} else {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(input[type='time']) {
|
||||
/*
|
||||
div :global(input[type='time']) {
|
||||
/*
|
||||
Style copy-pasted from shadcn-svelte Input.
|
||||
Needed to use native time input to avoid a bug with 2-level bind:value.
|
||||
*/
|
||||
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,283 +1,292 @@
|
||||
<script lang="ts" context="module">
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
|
||||
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Waypoint } from 'gpx';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { ListWaypointItem } from '$lib/components/file-list/FileList';
|
||||
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get } from 'svelte/store';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from 'lucide-svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Waypoint } from 'gpx';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { ListWaypointItem } from '$lib/components/file-list/FileList';
|
||||
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get } from 'svelte/store';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from 'lucide-svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
let name: string;
|
||||
let description: string;
|
||||
let link: string;
|
||||
let longitude: number;
|
||||
let latitude: number;
|
||||
let name: string;
|
||||
let description: string;
|
||||
let link: string;
|
||||
let longitude: number;
|
||||
let latitude: number;
|
||||
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
|
||||
const { treeFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
$: canCreate = $selection.size > 0;
|
||||
$: canCreate = $selection.size > 0;
|
||||
|
||||
$: if ($treeFileView && $selection) {
|
||||
selectedWaypoint.update(() => {
|
||||
if ($selection.size === 1) {
|
||||
let item = $selection.getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let waypoint = file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
$: if ($treeFileView && $selection) {
|
||||
selectedWaypoint.update(() => {
|
||||
if ($selection.size === 1) {
|
||||
let item = $selection.getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let waypoint = file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined = undefined;
|
||||
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
|
||||
if ($selectedWaypoint) {
|
||||
if (fileStore) {
|
||||
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
|
||||
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
|
||||
name = $selectedWaypoint[0].name ?? '';
|
||||
description = $selectedWaypoint[0].desc ?? '';
|
||||
if (
|
||||
$selectedWaypoint[0].cmt !== undefined &&
|
||||
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
|
||||
) {
|
||||
description += '\n\n' + $selectedWaypoint[0].cmt;
|
||||
}
|
||||
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
|
||||
let symbol = $selectedWaypoint[0].sym ?? '';
|
||||
let symbolKey = getSymbolKey(symbol);
|
||||
if (symbolKey) {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: $_(`gpx.symbol.${symbolKey}`)
|
||||
};
|
||||
} else {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: ''
|
||||
};
|
||||
}
|
||||
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
||||
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
let unsubscribe: (() => void) | undefined = undefined;
|
||||
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
|
||||
if ($selectedWaypoint) {
|
||||
if (fileStore) {
|
||||
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
|
||||
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
|
||||
name = $selectedWaypoint[0].name ?? '';
|
||||
description = $selectedWaypoint[0].desc ?? '';
|
||||
if (
|
||||
$selectedWaypoint[0].cmt !== undefined &&
|
||||
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
|
||||
) {
|
||||
description += '\n\n' + $selectedWaypoint[0].cmt;
|
||||
}
|
||||
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
|
||||
let symbol = $selectedWaypoint[0].sym ?? '';
|
||||
let symbolKey = getSymbolKey(symbol);
|
||||
if (symbolKey) {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: $_(`gpx.symbol.${symbolKey}`),
|
||||
};
|
||||
} else {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: '',
|
||||
};
|
||||
}
|
||||
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
||||
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetWaypointData() {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
function resetWaypointData() {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
if ($selectedWaypoint) {
|
||||
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
|
||||
if (fileStore) {
|
||||
unsubscribe = fileStore.subscribe(updateWaypointData);
|
||||
}
|
||||
} else {
|
||||
resetWaypointData();
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
if ($selectedWaypoint) {
|
||||
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
|
||||
if (fileStore) {
|
||||
unsubscribe = fileStore.subscribe(updateWaypointData);
|
||||
}
|
||||
} else {
|
||||
resetWaypointData();
|
||||
}
|
||||
}
|
||||
|
||||
function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
if (typeof longitude === 'string') {
|
||||
longitude = parseFloat(longitude);
|
||||
}
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
if (typeof longitude === 'string') {
|
||||
longitude = parseFloat(longitude);
|
||||
}
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
|
||||
dbUtils.addOrUpdateWaypoint(
|
||||
{
|
||||
attributes: {
|
||||
lat: latitude,
|
||||
lon: longitude
|
||||
},
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
: undefined
|
||||
);
|
||||
dbUtils.addOrUpdateWaypoint(
|
||||
{
|
||||
attributes: {
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
},
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined,
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
: undefined
|
||||
);
|
||||
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}
|
||||
|
||||
function setCoordinates(e: any) {
|
||||
latitude = e.lngLat.lat.toFixed(6);
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
function setCoordinates(e: any) {
|
||||
latitude = e.lngLat.lat.toFixed(6);
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
|
||||
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
|
||||
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
|
||||
});
|
||||
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
|
||||
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let m = get(map);
|
||||
m?.on('click', setCoordinates);
|
||||
setCrosshairCursor();
|
||||
});
|
||||
onMount(() => {
|
||||
let m = get(map);
|
||||
m?.on('click', setCoordinates);
|
||||
setCrosshairCursor();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
let m = get(map);
|
||||
m?.off('click', setCoordinates);
|
||||
resetCursor();
|
||||
onDestroy(() => {
|
||||
let m = get(map);
|
||||
m?.off('click', setCoordinates);
|
||||
resetCursor();
|
||||
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
});
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input
|
||||
bind:value={name}
|
||||
id="name"
|
||||
class="font-semibold h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
<Select.Item value={symbol.value}>
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
<svelte:component
|
||||
this={symbol.icon}
|
||||
size="14"
|
||||
class="inline-block align-sub mr-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${key}`)}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
||||
<Input bind:value={link} id="link" class="h-8" disabled={!canCreate && !$selectedWaypoint} />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
<Input
|
||||
bind:value={latitude}
|
||||
type="number"
|
||||
id="latitude"
|
||||
step={1e-6}
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
||||
<Input
|
||||
bind:value={longitude}
|
||||
type="number"
|
||||
id="longitude"
|
||||
step={1e-6}
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
{$_('menu.metadata.save')}
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}}
|
||||
>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
{$_('toolbar.waypoint.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input
|
||||
bind:value={name}
|
||||
id="name"
|
||||
class="font-semibold h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger
|
||||
id="symbol"
|
||||
class="w-full h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
<Select.Item value={symbol.value}>
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
<svelte:component
|
||||
this={symbol.icon}
|
||||
size="14"
|
||||
class="inline-block align-sub mr-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${key}`)}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
||||
<Input
|
||||
bind:value={link}
|
||||
id="link"
|
||||
class="h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
<Input
|
||||
bind:value={latitude}
|
||||
type="number"
|
||||
id="latitude"
|
||||
step={1e-6}
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
||||
<Input
|
||||
bind:value={longitude}
|
||||
type="number"
|
||||
id="longitude"
|
||||
step={1e-6}
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
{$_('menu.metadata.save')}
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}}
|
||||
>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
{$_('toolbar.waypoint.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@ -1,249 +1,253 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import {
|
||||
Bike,
|
||||
Footprints,
|
||||
Waves,
|
||||
TrainFront,
|
||||
Route,
|
||||
TriangleAlert,
|
||||
ArrowRightLeft,
|
||||
Home,
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight
|
||||
} from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import {
|
||||
Bike,
|
||||
Footprints,
|
||||
Waves,
|
||||
TrainFront,
|
||||
Route,
|
||||
TriangleAlert,
|
||||
ArrowRightLeft,
|
||||
Home,
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { RoutingControls } from './RoutingControls';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { RoutingControls } from './RoutingControls';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
export let minimized = false;
|
||||
export let minimizable = true;
|
||||
export let popup: mapboxgl.Popup | undefined = undefined;
|
||||
export let popupElement: HTMLElement | undefined = undefined;
|
||||
let selectedItem: ListItem | null = null;
|
||||
export let minimized = false;
|
||||
export let minimizable = true;
|
||||
export let popup: mapboxgl.Popup | undefined = undefined;
|
||||
export let popupElement: HTMLElement | undefined = undefined;
|
||||
let selectedItem: ListItem | null = null;
|
||||
|
||||
const { privateRoads, routing } = settings;
|
||||
const { privateRoads, routing } = settings;
|
||||
|
||||
$: if ($map && popup && popupElement) {
|
||||
// remove controls for deleted files
|
||||
routingControls.forEach((controls, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
controls.destroy();
|
||||
routingControls.delete(fileId);
|
||||
$: if ($map && popup && popupElement) {
|
||||
// remove controls for deleted files
|
||||
routingControls.forEach((controls, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
controls.destroy();
|
||||
routingControls.delete(fileId);
|
||||
|
||||
if (selectedItem && selectedItem.getFileId() === fileId) {
|
||||
selectedItem = null;
|
||||
}
|
||||
} else if ($map !== controls.map) {
|
||||
controls.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add controls for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!routingControls.has(fileId)) {
|
||||
routingControls.set(fileId, new RoutingControls($map, fileId, file, popup, popupElement));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (selectedItem && selectedItem.getFileId() === fileId) {
|
||||
selectedItem = null;
|
||||
}
|
||||
} else if ($map !== controls.map) {
|
||||
controls.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add controls for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!routingControls.has(fileId)) {
|
||||
routingControls.set(
|
||||
fileId,
|
||||
new RoutingControls($map, fileId, file, popup, popupElement)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
|
||||
function createFileWithPoint(e: any) {
|
||||
if ($selection.size === 0) {
|
||||
let file = newGPXFile();
|
||||
file.replaceTrackPoints(0, 0, 0, 0, [
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
}
|
||||
})
|
||||
]);
|
||||
file._data.id = getFileIds(1)[0];
|
||||
dbUtils.add(file);
|
||||
selectFileWhenLoaded(file._data.id);
|
||||
}
|
||||
}
|
||||
function createFileWithPoint(e: any) {
|
||||
if ($selection.size === 0) {
|
||||
let file = newGPXFile();
|
||||
file.replaceTrackPoints(0, 0, 0, 0, [
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
file._data.id = getFileIds(1)[0];
|
||||
dbUtils.add(file);
|
||||
selectFileWhenLoaded(file._data.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
$map?.on('click', createFileWithPoint);
|
||||
});
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
$map?.on('click', createFileWithPoint);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
$map?.off('click', createFileWithPoint);
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
$map?.off('click', createFileWithPoint);
|
||||
|
||||
routingControls.forEach((controls) => controls.destroy());
|
||||
routingControls.clear();
|
||||
});
|
||||
routingControls.forEach((controls) => controls.destroy());
|
||||
routingControls.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if minimizable && minimized}
|
||||
<div class="-m-1.5 -mb-2">
|
||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
||||
<SquareArrowOutDownRight size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="-m-1.5 -mb-2">
|
||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
||||
<SquareArrowOutDownRight size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
<Shortcut slot="extra" key="F5" />
|
||||
</Tooltip>
|
||||
</Label>
|
||||
{#if $routing}
|
||||
<div class="flex flex-col gap-3" in:slide>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
||||
<Bike size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('foot')}
|
||||
<Footprints size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('water')}
|
||||
<Waves size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('railway')}
|
||||
<TrainFront size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root bind:selected={$routingProfileSelectItem}>
|
||||
<Select.Trigger class="h-8 grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
|
||||
>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
<TriangleAlert size="16" />
|
||||
{$_('toolbar.routing.allow_private')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.reverse.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
<Shortcut slot="extra" key="F5" />
|
||||
</Tooltip>
|
||||
</Label>
|
||||
{#if $routing}
|
||||
<div class="flex flex-col gap-3" in:slide>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
||||
<Bike size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('foot')}
|
||||
<Footprints size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('water')}
|
||||
<Waves size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('railway')}
|
||||
<TrainFront size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root bind:selected={$routingProfileSelectItem}>
|
||||
<Select.Trigger class="h-8 grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
|
||||
>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
<TriangleAlert size="16" />
|
||||
{$_('toolbar.routing.allow_private')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.reverse.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
|
||||
?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
||||
{#if !validSelection}
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
{$_('toolbar.routing.help')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-1 h-6"
|
||||
on:click={() => {
|
||||
if (minimizable) {
|
||||
minimized = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SquareArrowUpLeft size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
||||
{#if !validSelection}
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
{$_('toolbar.routing.help')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-1 h-6"
|
||||
on:click={() => {
|
||||
if (minimizable) {
|
||||
minimized = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SquareArrowUpLeft size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { Coordinates } from "gpx";
|
||||
import { TrackPoint, distance } from "gpx";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { settings } from "$lib/db";
|
||||
import { _, isLoading, locale } from "svelte-i18n";
|
||||
import { getElevation } from "$lib/utils";
|
||||
import type { Coordinates } from 'gpx';
|
||||
import { TrackPoint, distance } from 'gpx';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
import { _, isLoading, locale } from 'svelte-i18n';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
@ -15,22 +15,31 @@ export const brouterProfiles: { [key: string]: string } = {
|
||||
foot: 'Hiking-Alpine-SAC6',
|
||||
motorcycle: 'Car-FastEco',
|
||||
water: 'river',
|
||||
railway: 'rail'
|
||||
railway: 'rail',
|
||||
};
|
||||
export const routingProfileSelectItem = writable({
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
});
|
||||
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
|
||||
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
|
||||
routingProfileSelectItem.update((item) => {
|
||||
item.value = profile;
|
||||
item.label = get(_)(`toolbar.routing.activities.${profile}`);
|
||||
return item;
|
||||
});
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(
|
||||
([profile, l, i]) => {
|
||||
if (
|
||||
!i &&
|
||||
profile !== '' &&
|
||||
(profile !== get(routingProfileSelectItem).value ||
|
||||
get(_)(`toolbar.routing.activities.${profile}`) !==
|
||||
get(routingProfileSelectItem).label) &&
|
||||
l !== null
|
||||
) {
|
||||
routingProfileSelectItem.update((item) => {
|
||||
item.value = profile;
|
||||
item.label = get(_)(`toolbar.routing.activities.${profile}`);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
routingProfileSelectItem.subscribe((item) => {
|
||||
if (item.value !== '' && item.value !== get(routingProfile)) {
|
||||
routingProfile.set(item.value);
|
||||
@ -45,8 +54,12 @@ export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoute(points: Coordinates[], brouterProfile: string, privateRoads: boolean): Promise<TrackPoint[]> {
|
||||
let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
async function getRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string,
|
||||
privateRoads: boolean
|
||||
): Promise<TrackPoint[]> {
|
||||
let url = `https://routing.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
|
||||
@ -61,25 +74,29 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
let coordinates = geojson.features[0].geometry.coordinates;
|
||||
let messages = geojson.features[0].properties.messages;
|
||||
|
||||
const lngIdx = messages[0].indexOf("Longitude");
|
||||
const latIdx = messages[0].indexOf("Latitude");
|
||||
const tagIdx = messages[0].indexOf("WayTags");
|
||||
const lngIdx = messages[0].indexOf('Longitude');
|
||||
const latIdx = messages[0].indexOf('Latitude');
|
||||
const tagIdx = messages[0].indexOf('WayTags');
|
||||
let messageIdx = 1;
|
||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
let coord = coordinates[i];
|
||||
route.push(new TrackPoint({
|
||||
attributes: {
|
||||
lat: coord[1],
|
||||
lon: coord[0]
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
|
||||
}));
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coord[1],
|
||||
lon: coord[0],
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
})
|
||||
);
|
||||
|
||||
if (messageIdx < messages.length &&
|
||||
if (
|
||||
messageIdx < messages.length &&
|
||||
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000
|
||||
) {
|
||||
messageIdx++;
|
||||
|
||||
if (messageIdx == messages.length) tags = {};
|
||||
@ -93,10 +110,10 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
}
|
||||
|
||||
function getTags(message: string): { [key: string]: string } {
|
||||
const fields = message.split(" ");
|
||||
const fields = message.split(' ');
|
||||
let tags: { [key: string]: string } = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let [key, value] = fields[i].split("=");
|
||||
let [key, value] = fields[i].split('=');
|
||||
key = key.replace(/:/g, '_');
|
||||
tags[key] = value;
|
||||
}
|
||||
@ -107,26 +124,31 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
let route: TrackPoint[] = [];
|
||||
let step = 0.05;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) { // Add intermediate points between each pair of points
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Add intermediate points between each pair of points
|
||||
let dist = distance(points[i], points[i + 1]) / 1000;
|
||||
for (let d = 0; d < dist; d += step) {
|
||||
let lat = points[i].lat + d / dist * (points[i + 1].lat - points[i].lat);
|
||||
let lon = points[i].lon + d / dist * (points[i + 1].lon - points[i].lon);
|
||||
route.push(new TrackPoint({
|
||||
attributes: {
|
||||
lat: lat,
|
||||
lon: lon
|
||||
}
|
||||
}));
|
||||
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
|
||||
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
route.push(new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon
|
||||
}
|
||||
}));
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return getElevation(route).then((elevations) => {
|
||||
route.forEach((point, i) => {
|
||||
|
||||
@ -1,37 +1,37 @@
|
||||
<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 { canChangeStart } from './RoutingControls';
|
||||
import { CirclePlay, Trash2 } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { canChangeStart } from './RoutingControls';
|
||||
import { CirclePlay, Trash2 } from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let element: HTMLElement;
|
||||
export let element: HTMLElement;
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="hidden">
|
||||
<Card.Root class="border-none shadow-md text-base">
|
||||
<Card.Content class="flex flex-col p-1">
|
||||
{#if $canChangeStart}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
||||
>
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.routing.start_loop_here')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-none shadow-md text-base">
|
||||
<Card.Content class="flex flex-col p-1">
|
||||
{#if $canChangeStart}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
||||
>
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.routing.start_loop_here')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user