mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-02 08:42:31 +00:00
Merge branch 'overpass-layer'
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
export * from './gpx';
|
export * from './gpx';
|
||||||
export { Coordinates, LineStyleExtension } from './types';
|
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||||
export { parseGPX, buildGPX } from './io';
|
export { parseGPX, buildGPX } from './io';
|
||||||
export * from './simplify';
|
export * from './simplify';
|
||||||
|
|
||||||
|
24
website/package-lock.json
generated
24
website/package-lock.json
generated
@@ -10,12 +10,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.5.4",
|
"@internationalized/date": "^3.5.4",
|
||||||
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
||||||
|
"@mapbox/sphericalmercator": "^1.2.0",
|
||||||
|
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||||
"bits-ui": "^0.21.12",
|
"bits-ui": "^0.21.12",
|
||||||
"chart.js": "^4.4.3",
|
"chart.js": "^4.4.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.7",
|
"dexie": "^4.0.7",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
|
"lucide-static": "^0.408.0",
|
||||||
"lucide-svelte": "^0.395.0",
|
"lucide-svelte": "^0.395.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
@@ -1422,6 +1425,17 @@
|
|||||||
"polyline": "bin/polyline.bin.js"
|
"polyline": "bin/polyline.bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/sphericalmercator": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==",
|
||||||
|
"bin": {
|
||||||
|
"bbox": "bin/bbox.js",
|
||||||
|
"to4326": "bin/to4326.js",
|
||||||
|
"to900913": "bin/to900913.js",
|
||||||
|
"xyz": "bin/xyz.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/tiny-sdf": {
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
|
||||||
@@ -1962,6 +1976,11 @@
|
|||||||
"@types/mapbox-gl": "*"
|
"@types/mapbox-gl": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mapbox__sphericalmercator": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mapbox__sphericalmercator/-/mapbox__sphericalmercator-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-gBXMMNhRTA8HzAzLdBzVYET0dH1p8jDPYZoT9+KnfFRYIRwHnbW+3IyiSlwS7kvr97PMn501QY+Dd3kjxb2dAA=="
|
||||||
|
},
|
||||||
"node_modules/@types/mapbox-gl": {
|
"node_modules/@types/mapbox-gl": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.1.0.tgz",
|
||||||
@@ -4353,6 +4372,11 @@
|
|||||||
"es5-ext": "~0.10.2"
|
"es5-ext": "~0.10.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-static": {
|
||||||
|
"version": "0.408.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.408.0.tgz",
|
||||||
|
"integrity": "sha512-XJioz3vKagiyA6qMDWkYqU1RUS/bMjqio0/TCOItievnV/C4wwgJZGAbk6eVDe6Wv+d0e9NbhS7Y8yMEpGkElQ=="
|
||||||
|
},
|
||||||
"node_modules/lucide-svelte": {
|
"node_modules/lucide-svelte": {
|
||||||
"version": "0.395.0",
|
"version": "0.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.395.0.tgz",
|
||||||
|
@@ -48,12 +48,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.5.4",
|
"@internationalized/date": "^3.5.4",
|
||||||
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
||||||
|
"@mapbox/sphericalmercator": "^1.2.0",
|
||||||
|
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||||
"bits-ui": "^0.21.12",
|
"bits-ui": "^0.21.12",
|
||||||
"chart.js": "^4.4.3",
|
"chart.js": "^4.4.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.7",
|
"dexie": "^4.0.7",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
|
"lucide-static": "^0.408.0",
|
||||||
"lucide-svelte": "^0.395.0",
|
"lucide-svelte": "^0.395.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Telescope, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant } from 'lucide-static';
|
||||||
import { type AnySourceData, type Style } from 'mapbox-gl';
|
import { type AnySourceData, type Style } from 'mapbox-gl';
|
||||||
|
|
||||||
export const basemaps: { [key: string]: string | Style; } = {
|
export const basemaps: { [key: string]: string | Style; } = {
|
||||||
@@ -540,6 +541,45 @@ export const overlayTree: LayerTreeType = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hierachy containing all Overpass layers
|
||||||
|
export const overpassTree: LayerTreeType = {
|
||||||
|
points_of_interest: {
|
||||||
|
food: {
|
||||||
|
bakery: true,
|
||||||
|
"food-store": true,
|
||||||
|
"eat-and-drink": true,
|
||||||
|
},
|
||||||
|
amenities: {
|
||||||
|
toilets: true,
|
||||||
|
"water": true,
|
||||||
|
"water-spring": true,
|
||||||
|
shower: true,
|
||||||
|
"fuel-station": true,
|
||||||
|
parking: true,
|
||||||
|
barrier: true
|
||||||
|
},
|
||||||
|
tourism: {
|
||||||
|
attraction: true,
|
||||||
|
viewpoint: true,
|
||||||
|
accommodation: true,
|
||||||
|
summit: true,
|
||||||
|
pass: true,
|
||||||
|
climbing: true,
|
||||||
|
},
|
||||||
|
bicycle: {
|
||||||
|
"bicycle-parking": true,
|
||||||
|
"bicycle-rental": true,
|
||||||
|
"bicycle-shop": true
|
||||||
|
},
|
||||||
|
"public-transport": {
|
||||||
|
"railway-station": true,
|
||||||
|
"tram-stop": true,
|
||||||
|
"bus-stop": true,
|
||||||
|
ferry: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Default basemap used
|
// Default basemap used
|
||||||
export const defaultBasemap = 'mapboxOutdoors';
|
export const defaultBasemap = 'mapboxOutdoors';
|
||||||
|
|
||||||
@@ -585,6 +625,45 @@ export const defaultOverlays = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default Overpass queries used (none)
|
||||||
|
export const defaultOverpassQueries: LayerTreeType = {
|
||||||
|
points_of_interest: {
|
||||||
|
"food": {
|
||||||
|
bakery: false,
|
||||||
|
"food-store": false,
|
||||||
|
"eat-and-drink": false,
|
||||||
|
},
|
||||||
|
amenities: {
|
||||||
|
toilets: false,
|
||||||
|
"water": false,
|
||||||
|
"water-spring": false,
|
||||||
|
shower: false,
|
||||||
|
"fuel-station": false,
|
||||||
|
parking: false,
|
||||||
|
barrier: false
|
||||||
|
},
|
||||||
|
tourism: {
|
||||||
|
attraction: false,
|
||||||
|
viewpoint: false,
|
||||||
|
accommodation: false,
|
||||||
|
summit: false,
|
||||||
|
pass: false,
|
||||||
|
climbing: false
|
||||||
|
},
|
||||||
|
bicycle: {
|
||||||
|
"bicycle-parking": false,
|
||||||
|
"bicycle-rental": false,
|
||||||
|
"bicycle-shop": false
|
||||||
|
},
|
||||||
|
"public-transport": {
|
||||||
|
"railway-station": false,
|
||||||
|
"tram-stop": false,
|
||||||
|
"bus-stop": false,
|
||||||
|
ferry: false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Default basemaps shown in the layer menu
|
// Default basemaps shown in the layer menu
|
||||||
export const defaultBasemapTree: LayerTreeType = {
|
export const defaultBasemapTree: LayerTreeType = {
|
||||||
basemaps: {
|
basemaps: {
|
||||||
@@ -680,6 +759,45 @@ export const defaultOverlayTree: LayerTreeType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default Overpass queries shown in the layer menu
|
||||||
|
export const defaultOverpassTree: LayerTreeType = {
|
||||||
|
points_of_interest: {
|
||||||
|
"food": {
|
||||||
|
bakery: true,
|
||||||
|
"food-store": true,
|
||||||
|
"eat-and-drink": true,
|
||||||
|
},
|
||||||
|
amenities: {
|
||||||
|
toilets: true,
|
||||||
|
"water": true,
|
||||||
|
"water-spring": false,
|
||||||
|
shower: false,
|
||||||
|
"fuel-station": false,
|
||||||
|
parking: false,
|
||||||
|
barrier: false
|
||||||
|
},
|
||||||
|
tourism: {
|
||||||
|
attraction: false,
|
||||||
|
viewpoint: false,
|
||||||
|
accommodation: true,
|
||||||
|
summit: true,
|
||||||
|
pass: true,
|
||||||
|
climbing: false
|
||||||
|
},
|
||||||
|
bicycle: {
|
||||||
|
"bicycle-parking": false,
|
||||||
|
"bicycle-rental": false,
|
||||||
|
"bicycle-shop": true
|
||||||
|
},
|
||||||
|
"public-transport": {
|
||||||
|
"railway-station": true,
|
||||||
|
"tram-stop": true,
|
||||||
|
"bus-stop": true,
|
||||||
|
ferry: false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type CustomLayer = {
|
export type CustomLayer = {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -690,6 +808,226 @@ export type CustomLayer = {
|
|||||||
value: string | {},
|
value: string | {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OverpassQueryData = {
|
||||||
|
icon: {
|
||||||
|
svg: string,
|
||||||
|
color: string,
|
||||||
|
},
|
||||||
|
tags: Record<string, string | boolean | string[]>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||||
|
"bakery": {
|
||||||
|
icon: {
|
||||||
|
svg: Croissant,
|
||||||
|
color: "Coral",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
shop: "bakery"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"food-store": {
|
||||||
|
icon: {
|
||||||
|
svg: ShoppingBasket,
|
||||||
|
color: "Coral",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
shop: ["supermarket", "convenience"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eat-and-drink": {
|
||||||
|
icon: {
|
||||||
|
svg: Utensils,
|
||||||
|
color: "Coral",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: ["restaurant", "fast_food", "cafe", "pub", "bar"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toilets": {
|
||||||
|
icon: {
|
||||||
|
svg: Droplet,
|
||||||
|
color: "DeepSkyBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "toilets"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
water: {
|
||||||
|
icon: {
|
||||||
|
svg: Droplet,
|
||||||
|
color: "DeepSkyBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: ["drinking_water", "water_point"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"water-spring": {
|
||||||
|
icon: {
|
||||||
|
svg: Droplet,
|
||||||
|
color: "DeepSkyBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
natural: "spring",
|
||||||
|
drinking_water: "yes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shower: {
|
||||||
|
icon: {
|
||||||
|
svg: ShowerHead,
|
||||||
|
color: "DeepSkyBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "shower"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fuel-station": {
|
||||||
|
icon: {
|
||||||
|
svg: Fuel,
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "fuel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parking: {
|
||||||
|
icon: {
|
||||||
|
svg: CircleParking,
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "parking"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
barrier: {
|
||||||
|
icon: {
|
||||||
|
svg: Fence,
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
barrier: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attraction: {
|
||||||
|
icon: {
|
||||||
|
svg: FerrisWheel,
|
||||||
|
color: "Green",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
tourism: "attraction"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
viewpoint: {
|
||||||
|
icon: {
|
||||||
|
svg: Telescope,
|
||||||
|
color: "Green",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
tourism: "viewpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accommodation: {
|
||||||
|
icon: {
|
||||||
|
svg: Bed,
|
||||||
|
color: "#e6c100",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
tourism: ["hotel", "hostel", "guest_house", "motel", "camp_site", "alpine_hut", "wilderness_hut"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
summit: {
|
||||||
|
icon: {
|
||||||
|
svg: Mountain,
|
||||||
|
color: "Green",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
natural: "peak"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pass: {
|
||||||
|
icon: {
|
||||||
|
svg: Mountain,
|
||||||
|
color: "Green",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
mountain_pass: "yes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
climbing: {
|
||||||
|
icon: {
|
||||||
|
svg: Pickaxe,
|
||||||
|
color: "Green",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
sport: "climbing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bicycle-parking": {
|
||||||
|
icon: {
|
||||||
|
svg: CircleParking,
|
||||||
|
color: "HotPink",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "bicycle_parking"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bicycle-rental": {
|
||||||
|
icon: {
|
||||||
|
svg: Store,
|
||||||
|
color: "HotPink",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "bicycle_rental"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bicycle-shop": {
|
||||||
|
icon: {
|
||||||
|
svg: Store,
|
||||||
|
color: "HotPink",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
shop: "bicycle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"railway-station": {
|
||||||
|
icon: {
|
||||||
|
svg: TrainFront,
|
||||||
|
color: "DarkBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
railway: "station"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tram-stop": {
|
||||||
|
icon: {
|
||||||
|
svg: TramFront,
|
||||||
|
color: 'DarkBlue',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
railway: "tram_stop"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bus-stop": {
|
||||||
|
icon: {
|
||||||
|
svg: Bus,
|
||||||
|
color: "DarkBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
"public_transport": ["stop_position", "platform"],
|
||||||
|
bus: "yes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ferry: {
|
||||||
|
icon: {
|
||||||
|
svg: Ship,
|
||||||
|
color: "DarkBlue",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: "ferry_terminal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const stravaHeatmapServers = ['https://heatmap-external-a.strava.com/tiles-auth', 'https://heatmap-external-b.strava.com/tiles-auth', 'https://heatmap-external-c.strava.com/tiles-auth'];
|
export const stravaHeatmapServers = ['https://heatmap-external-a.strava.com/tiles-auth', 'https://heatmap-external-b.strava.com/tiles-auth', 'https://heatmap-external-c.strava.com/tiles-auth'];
|
||||||
export const stravaHeatmapActivityIds: { [key: string]: string } = {
|
export const stravaHeatmapActivityIds: { [key: string]: string } = {
|
||||||
stravaHeatmapRun: 'sport_Run',
|
stravaHeatmapRun: 'sport_Run',
|
||||||
|
@@ -34,16 +34,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $currentPopupWaypoint[0].desc}
|
{#if $currentPopupWaypoint[0].desc}
|
||||||
<span>{$currentPopupWaypoint[0].desc}</span>
|
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].desc}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
||||||
<span>{$currentPopupWaypoint[0].cmt}</span>
|
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].cmt}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $currentTool === Tool.WAYPOINT}
|
{#if $currentTool === Tool.WAYPOINT}
|
||||||
<div class="mt-2">
|
|
||||||
<Button
|
<Button
|
||||||
class="w-full px-2 py-1 h-6 justify-start"
|
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
|
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
|
||||||
>
|
>
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
{$_('menu.delete')}
|
{$_('menu.delete')}
|
||||||
<Shortcut key="" shift={true} click={true} />
|
<Shortcut key="" shift={true} click={true} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
@@ -12,15 +12,20 @@
|
|||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/stores';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { getLayers } from './utils';
|
import { getLayers } from './utils';
|
||||||
|
import { OverpassLayer } from './OverpassLayer';
|
||||||
|
import OverpassPopup from './OverpassPopup.svelte';
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
let overpassLayer: OverpassLayer;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
previousBasemap,
|
previousBasemap,
|
||||||
currentOverlays,
|
currentOverlays,
|
||||||
|
currentOverpassQueries,
|
||||||
selectedBasemapTree,
|
selectedBasemapTree,
|
||||||
selectedOverlayTree,
|
selectedOverlayTree,
|
||||||
|
selectedOverpassTree,
|
||||||
customLayers,
|
customLayers,
|
||||||
opacities
|
opacities
|
||||||
} = settings;
|
} = settings;
|
||||||
@@ -54,6 +59,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if ($map) {
|
||||||
|
if (overpassLayer) {
|
||||||
|
overpassLayer.remove();
|
||||||
|
}
|
||||||
|
overpassLayer = new OverpassLayer($map);
|
||||||
|
overpassLayer.add();
|
||||||
|
}
|
||||||
|
|
||||||
let selectedBasemap = writable(get(currentBasemap));
|
let selectedBasemap = writable(get(currentBasemap));
|
||||||
selectedBasemap.subscribe((value) => {
|
selectedBasemap.subscribe((value) => {
|
||||||
// Updates coming from radio buttons
|
// Updates coming from radio buttons
|
||||||
@@ -157,12 +170,25 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<Separator class="w-full" />
|
||||||
|
<div class="p-2">
|
||||||
|
{#if $currentOverpassQueries}
|
||||||
|
<LayerTree
|
||||||
|
layerTree={$selectedOverpassTree}
|
||||||
|
name="overpass"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$currentOverpassQueries}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomControl>
|
</CustomControl>
|
||||||
|
|
||||||
|
<OverpassPopup />
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:click={(e) => {
|
on:click={(e) => {
|
||||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
|
||||||
import { basemapTree, overlays, overlayTree } from '$lib/assets/layers';
|
import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers';
|
||||||
import { isSelected } from '$lib/components/layer-control/utils';
|
import { isSelected } from '$lib/components/layer-control/utils';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/db';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
const {
|
const {
|
||||||
selectedBasemapTree,
|
selectedBasemapTree,
|
||||||
selectedOverlayTree,
|
selectedOverlayTree,
|
||||||
|
selectedOverpassTree,
|
||||||
stravaHeatmapColor,
|
stravaHeatmapColor,
|
||||||
currentOverlays,
|
currentOverlays,
|
||||||
customLayers,
|
customLayers,
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
export let open: boolean;
|
export let open: boolean;
|
||||||
|
let accordionValue = 'layer-selection';
|
||||||
|
|
||||||
let selectedOverlay = writable(undefined);
|
let selectedOverlay = writable(undefined);
|
||||||
let overlayOpacity = writable([1]);
|
let overlayOpacity = writable([1]);
|
||||||
@@ -113,30 +115,40 @@
|
|||||||
<Sheet.Content>
|
<Sheet.Content>
|
||||||
<Sheet.Header class="h-full">
|
<Sheet.Header class="h-full">
|
||||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
||||||
|
<ScrollArea class="w-[105%] pr-4">
|
||||||
<Sheet.Description>
|
<Sheet.Description>
|
||||||
{$_('layers.settings_help')}
|
{$_('layers.settings_help')}
|
||||||
</Sheet.Description>
|
</Sheet.Description>
|
||||||
<Accordion.Root class="flex flex-col overflow-hidden" value="layer-selection">
|
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
||||||
<Accordion.Item value="layer-selection" class="flex flex-col overflow-hidden">
|
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||||
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="grow flex flex-col border rounded">
|
<Accordion.Content class="grow flex flex-col border rounded">
|
||||||
<ScrollArea class="py-2 pl-1 pr-2 min-h-9">
|
<div class="py-2 pl-1 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={basemapTree}
|
layerTree={basemapTree}
|
||||||
name="basemapSettings"
|
name="basemapSettings"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
bind:checked={$selectedBasemapTree}
|
bind:checked={$selectedBasemapTree}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ScrollArea class="py-2 pl-1 pr-2 min-h-9">
|
<div class="py-2 pl-1 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={overlayTree}
|
layerTree={overlayTree}
|
||||||
name="overlaySettings"
|
name="overlaySettings"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
bind:checked={$selectedOverlayTree}
|
bind:checked={$selectedOverlayTree}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="py-2 pl-1 pr-2">
|
||||||
|
<LayerTree
|
||||||
|
layerTree={overpassTree}
|
||||||
|
name="overpassSettings"
|
||||||
|
multiple={true}
|
||||||
|
bind:checked={$selectedOverpassTree}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="overlay-opacity">
|
<Accordion.Item value="overlay-opacity">
|
||||||
@@ -197,10 +209,6 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="pois" class="hidden">
|
|
||||||
<Accordion.Trigger>{$_('layers.pois')}</Accordion.Trigger>
|
|
||||||
<Accordion.Content></Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
<Accordion.Item value="heatmap-color" class="hidden">
|
<Accordion.Item value="heatmap-color" class="hidden">
|
||||||
<Accordion.Trigger>{$_('layers.heatmap')}</Accordion.Trigger>
|
<Accordion.Trigger>{$_('layers.heatmap')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="overflow-visible">
|
<Accordion.Content class="overflow-visible">
|
||||||
@@ -222,6 +230,7 @@
|
|||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
</Accordion.Root>
|
</Accordion.Root>
|
||||||
|
</ScrollArea>
|
||||||
</Sheet.Header>
|
</Sheet.Header>
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
</Sheet.Root>
|
</Sheet.Root>
|
||||||
|
287
website/src/lib/components/layer-control/OverpassLayer.ts
Normal file
287
website/src/lib/components/layer-control/OverpassLayer.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||||
|
import { getLayers } from "./utils";
|
||||||
|
import mapboxgl from "mapbox-gl";
|
||||||
|
import { get, writable } from "svelte/store";
|
||||||
|
import { liveQuery } from "dexie";
|
||||||
|
import { db, settings } from "$lib/db";
|
||||||
|
import { overpassQueryData } from "$lib/assets/layers";
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentOverpassQueries
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
const mercator = new SphericalMercator({
|
||||||
|
size: 256,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
export const overpassPopup = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
maxWidth: undefined,
|
||||||
|
offset: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
|
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||||
|
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
|
||||||
|
});
|
||||||
|
|
||||||
|
export class OverpassLayer {
|
||||||
|
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
|
||||||
|
minZoom = 12;
|
||||||
|
queryZoom = 12;
|
||||||
|
map: mapboxgl.Map;
|
||||||
|
|
||||||
|
currentQueries: Set<string> = new Set();
|
||||||
|
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||||
|
|
||||||
|
unsubscribes: (() => void)[] = [];
|
||||||
|
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||||
|
updateBinded = this.update.bind(this);
|
||||||
|
onHoverBinded = this.onHover.bind(this);
|
||||||
|
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
|
||||||
|
|
||||||
|
constructor(map: mapboxgl.Map) {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
add() {
|
||||||
|
this.map.on('moveend', this.queryIfNeededBinded);
|
||||||
|
this.map.on('style.load', this.updateBinded);
|
||||||
|
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||||
|
this.updateBinded();
|
||||||
|
this.queryIfNeededBinded();
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
queryIfNeeded() {
|
||||||
|
if (this.map.getZoom() >= this.minZoom) {
|
||||||
|
const bounds = this.map.getBounds().toArray();
|
||||||
|
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.loadIcons();
|
||||||
|
|
||||||
|
let d = get(data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let source = this.map.getSource('overpass');
|
||||||
|
if (source) {
|
||||||
|
source.setData(d);
|
||||||
|
} else {
|
||||||
|
this.map.addSource('overpass', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: d,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.map.getLayer('overpass')) {
|
||||||
|
this.map.addLayer({
|
||||||
|
id: 'overpass',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'overpass',
|
||||||
|
layout: {
|
||||||
|
'icon-image': ['get', 'icon'],
|
||||||
|
'icon-size': 0.25,
|
||||||
|
'icon-padding': 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||||
|
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.map.off('moveend', this.queryIfNeededBinded);
|
||||||
|
this.map.off('style.load', this.updateBinded);
|
||||||
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
|
if (this.map.getLayer('overpass')) {
|
||||||
|
this.map.removeLayer('overpass');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.map.getSource('overpass')) {
|
||||||
|
this.map.removeSource('overpass');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onHover(e: any) {
|
||||||
|
overpassPopupPOI.set(e.features[0].properties);
|
||||||
|
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
|
||||||
|
overpassPopup.addTo(this.map);
|
||||||
|
this.map.on('mousemove', this.maybeHidePopupBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeHidePopup(e: any) {
|
||||||
|
let poi = get(overpassPopupPOI);
|
||||||
|
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) {
|
||||||
|
this.hideWaypointPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideWaypointPopup() {
|
||||||
|
overpassPopupPOI.set(null);
|
||||||
|
overpassPopup.remove();
|
||||||
|
|
||||||
|
this.map.off('mousemove', this.maybeHidePopupBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
query(bbox: [number, number, number, number]) {
|
||||||
|
let queries = getCurrentQueries();
|
||||||
|
if (queries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileLimits = mercator.xyz(bbox, this.queryZoom);
|
||||||
|
|
||||||
|
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
|
||||||
|
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
|
||||||
|
if (this.currentQueries.has(`${x},${y}`)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.overpassquerytiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||||
|
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query));
|
||||||
|
if (missingQueries.length > 0) {
|
||||||
|
this.queryTile(x, y, missingQueries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryTile(x: number, y: number, queries: string[]) {
|
||||||
|
if (this.currentQueries.size > 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentQueries.add(`${x},${y}`);
|
||||||
|
|
||||||
|
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((data) => this.storeOverpassData(x, y, queries, data))
|
||||||
|
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||||
|
let queryTiles = queries.map((query) => ({ x, y, query }));
|
||||||
|
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||||
|
|
||||||
|
if (data.elements === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let element of data.elements) {
|
||||||
|
for (let query of queries) {
|
||||||
|
if (belongsToQuery(element, query)) {
|
||||||
|
pois.push({
|
||||||
|
query,
|
||||||
|
id: element.id,
|
||||||
|
poi: {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: element.id,
|
||||||
|
lat: element.center ? element.center.lat : element.lat,
|
||||||
|
lon: element.center ? element.center.lon : element.lon,
|
||||||
|
query: query,
|
||||||
|
icon: `overpass-${query}`,
|
||||||
|
tags: element.tags
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction('rw', db.overpassquerytiles, db.overpassdata, async () => {
|
||||||
|
await db.overpassquerytiles.bulkPut(queryTiles);
|
||||||
|
await db.overpassdata.bulkPut(pois);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentQueries.delete(`${x},${y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIcons() {
|
||||||
|
let currentQueries = getCurrentQueries();
|
||||||
|
currentQueries.forEach((query) => {
|
||||||
|
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||||
|
let icon = new Image(100, 100);
|
||||||
|
icon.onload = () => {
|
||||||
|
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||||
|
this.map.addImage(`overpass-${query}`, icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lucide icons are SVG files with a 24x24 viewBox
|
||||||
|
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||||
|
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
|
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||||
|
<g transform="translate(8 8)">
|
||||||
|
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryForBounds(bounds: [number, number, number, number], queries: string[]) {
|
||||||
|
return `[bbox:${bounds[1]},${bounds[0]},${bounds[3]},${bounds[2]}][out:json];(${getQueries(queries)});out center;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueries(queries: string[]) {
|
||||||
|
return queries.map((query) => getQuery(query)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuery(query: string) {
|
||||||
|
let arrayEntry = Object.entries(overpassQueryData[query].tags).find(([_, value]) => Array.isArray(value));
|
||||||
|
if (arrayEntry !== undefined) {
|
||||||
|
return arrayEntry[1].map((val) => `nwr${Object.entries(overpassQueryData[query].tags)
|
||||||
|
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||||
|
.join('')};`).join('');
|
||||||
|
} else {
|
||||||
|
return `nwr${Object.entries(overpassQueryData[query].tags)
|
||||||
|
.map(([tag, value]) => `[${tag}=${value}]`)
|
||||||
|
.join('')};`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function belongsToQuery(element: any, query: string) {
|
||||||
|
return Object.entries(overpassQueryData[query].tags)
|
||||||
|
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentQueries() {
|
||||||
|
let currentQueries = get(currentOverpassQueries);
|
||||||
|
if (currentQueries === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||||
|
}
|
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { overpassPopup, overpassPopupPOI } from './OverpassLayer';
|
||||||
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
|
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { dbUtils } from '$lib/db';
|
||||||
|
|
||||||
|
let popupElement: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
overpassPopup.setDOMContent(popupElement);
|
||||||
|
popupElement.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
let tags = {};
|
||||||
|
$: if ($overpassPopupPOI) {
|
||||||
|
tags = JSON.parse($overpassPopupPOI.tags);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={popupElement} class="hidden">
|
||||||
|
{#if $overpassPopupPOI}
|
||||||
|
<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">
|
||||||
|
{tags.name ?? ''}
|
||||||
|
<div class="text-muted-foreground text-sm font-normal">
|
||||||
|
{$overpassPopupPOI.lat.toFixed(6)}° {$overpassPopupPOI.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={$overpassPopupPOI.id}"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<PencilLine size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
{#if tags.image || tags['image:0']}
|
||||||
|
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||||
|
<img src={tags.image ?? tags['image:0']} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||||
|
<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 === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||||
|
<a href={value} target="_blank" class="text-blue-500 underline">{value}</a>
|
||||||
|
{:else if key === 'phone' || key === 'contact:phone'}
|
||||||
|
<a href={'tel:' + value} class="text-blue-500 underline">{value}</a>
|
||||||
|
{:else if key === 'email' || key === 'contact:email'}
|
||||||
|
<a href={'mailto:' + value} class="text-blue-500 underline">{value}</a>
|
||||||
|
{:else}
|
||||||
|
<span>{value}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<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: $overpassPopupPOI.lat,
|
||||||
|
lon: $overpassPopupPOI.lon
|
||||||
|
},
|
||||||
|
name: tags.name ?? '',
|
||||||
|
desc: desc,
|
||||||
|
cmt: desc
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size="16" class="mr-1" />
|
||||||
|
{$_('toolbar.waypoint.add')}
|
||||||
|
</Button>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
@@ -102,39 +102,22 @@
|
|||||||
}
|
}
|
||||||
latitude = parseFloat(latitude.toFixed(6));
|
latitude = parseFloat(latitude.toFixed(6));
|
||||||
longitude = parseFloat(longitude.toFixed(6));
|
longitude = parseFloat(longitude.toFixed(6));
|
||||||
if ($selectedWaypoint) {
|
|
||||||
dbUtils.applyToFile($selectedWaypoint[1], (file) => {
|
dbUtils.addOrUpdateWaypoint(
|
||||||
let wpt = file.wpt[$selectedWaypoint[0]._data.index];
|
{
|
||||||
wpt.name = name;
|
|
||||||
wpt.desc = description;
|
|
||||||
wpt.cmt = description;
|
|
||||||
wpt.setCoordinates({
|
|
||||||
lat: latitude,
|
|
||||||
lon: longitude
|
|
||||||
});
|
|
||||||
wpt.ele =
|
|
||||||
get(map)?.queryTerrainElevation([longitude, latitude], { exaggerated: false }) ?? 0;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let fileIds = new Set<string>();
|
|
||||||
$selection.getSelected().forEach((item) => {
|
|
||||||
fileIds.add(item.getFileId());
|
|
||||||
});
|
|
||||||
let waypoint = new Waypoint({
|
|
||||||
name,
|
|
||||||
desc: description,
|
|
||||||
cmt: description,
|
|
||||||
attributes: {
|
attributes: {
|
||||||
lat: latitude,
|
lat: latitude,
|
||||||
lon: longitude
|
lon: longitude
|
||||||
}
|
},
|
||||||
});
|
name,
|
||||||
waypoint.ele =
|
desc: description,
|
||||||
get(map)?.queryTerrainElevation([longitude, latitude], { exaggerated: false }) ?? 0;
|
cmt: description
|
||||||
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
|
},
|
||||||
file.replaceWaypoints(file.wpt.length, file.wpt.length, [waypoint])
|
$selectedWaypoint
|
||||||
|
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
}
|
|
||||||
selectedWaypoint.set(undefined);
|
selectedWaypoint.set(undefined);
|
||||||
resetWaypointData();
|
resetWaypointData();
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import Dexie, { liveQuery } from 'dexie';
|
import Dexie, { liveQuery } from 'dexie';
|
||||||
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension } from 'gpx';
|
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension, type WaypointType } from 'gpx';
|
||||||
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, freeze, produceWithPatches } from 'immer';
|
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, freeze, produceWithPatches } from 'immer';
|
||||||
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
||||||
import { Tool, currentTool, gpxStatistics, initTargetMapBounds, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
|
import { Tool, currentTool, gpxStatistics, initTargetMapBounds, map, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
|
||||||
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer, defaultOpacities } from './assets/layers';
|
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer, defaultOpacities, defaultOverpassQueries, defaultOverpassTree } from './assets/layers';
|
||||||
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
|
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
|
||||||
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
|
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
|
||||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
||||||
@@ -18,6 +18,8 @@ class Database extends Dexie {
|
|||||||
files!: Dexie.Table<GPXFile, string>;
|
files!: Dexie.Table<GPXFile, string>;
|
||||||
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
|
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
|
||||||
settings!: Dexie.Table<any, string>;
|
settings!: Dexie.Table<any, string>;
|
||||||
|
overpassquerytiles!: Dexie.Table<{ query: string, x: number, y: number }, [string, number, number]>;
|
||||||
|
overpassdata!: Dexie.Table<{ query: string, id: number, poi: GeoJSON.Feature }, [string, number]>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Database", {
|
super("Database", {
|
||||||
@@ -27,19 +29,22 @@ class Database extends Dexie {
|
|||||||
fileids: ',&fileid',
|
fileids: ',&fileid',
|
||||||
files: '',
|
files: '',
|
||||||
patches: ',patch',
|
patches: ',patch',
|
||||||
settings: ''
|
settings: '',
|
||||||
|
overpassquerytiles: '[query+x+y],[x+y]',
|
||||||
|
overpassdata: '[query+id]',
|
||||||
});
|
});
|
||||||
this.files.add
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database();
|
export const db = new Database();
|
||||||
|
|
||||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
|
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
|
||||||
function dexieSettingStore<T>(setting: string, initial: T): Writable<T> {
|
export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K, initial: V, initialize: boolean = true): Writable<V> {
|
||||||
let store = writable(initial);
|
let store = writable(initialize ? initial : undefined);
|
||||||
liveQuery(() => db.settings.get(setting)).subscribe(value => {
|
liveQuery(() => table.get(key)).subscribe(value => {
|
||||||
if (value !== undefined) {
|
if (value === undefined && !initialize) {
|
||||||
|
store.set(initial);
|
||||||
|
} else if (value !== undefined) {
|
||||||
store.set(value);
|
store.set(value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -47,36 +52,20 @@ function dexieSettingStore<T>(setting: string, initial: T): Writable<T> {
|
|||||||
subscribe: store.subscribe,
|
subscribe: store.subscribe,
|
||||||
set: (value: any) => {
|
set: (value: any) => {
|
||||||
if (typeof value === 'object' || value !== get(store)) {
|
if (typeof value === 'object' || value !== get(store)) {
|
||||||
db.settings.put(value, setting);
|
table.put(value, key);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: (callback: (value: any) => any) => {
|
update: (callback: (value: any) => any) => {
|
||||||
let newValue = callback(get(store));
|
let newValue = callback(get(store));
|
||||||
if (typeof newValue === 'object' || newValue !== get(store)) {
|
if (typeof newValue === 'object' || newValue !== get(store)) {
|
||||||
db.settings.put(newValue, setting);
|
table.put(newValue, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
|
export function dexieSettingStore<T>(key: string, initial: T, initialize: boolean = true): Writable<T> {
|
||||||
function dexieUninitializedSettingStore(setting: string, initial: any): Writable<any> {
|
return bidirectionalDexieStore(db.settings, key, initial, initialize);
|
||||||
let store = writable(undefined);
|
|
||||||
liveQuery(() => db.settings.get(setting)).subscribe(value => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
store.set(value);
|
|
||||||
} else {
|
|
||||||
store.set(initial);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
set: (value: any) => db.settings.put(value, setting),
|
|
||||||
update: (callback: (value: any) => any) => {
|
|
||||||
let newValue = callback(get(store));
|
|
||||||
db.settings.put(newValue, setting);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
@@ -94,9 +83,11 @@ export const settings = {
|
|||||||
currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap),
|
currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap),
|
||||||
previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap),
|
previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap),
|
||||||
selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree),
|
selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree),
|
||||||
currentOverlays: dexieUninitializedSettingStore('currentOverlays', defaultOverlays),
|
currentOverlays: dexieSettingStore('currentOverlays', defaultOverlays, false),
|
||||||
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
|
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
|
||||||
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
|
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
|
||||||
|
currentOverpassQueries: dexieSettingStore('currentOverpassQueries', defaultOverpassQueries, false),
|
||||||
|
selectedOverpassTree: dexieSettingStore('selectedOverpassTree', defaultOverpassTree),
|
||||||
opacities: dexieSettingStore('opacities', defaultOpacities),
|
opacities: dexieSettingStore('opacities', defaultOpacities),
|
||||||
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
|
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
|
||||||
directionMarkers: dexieSettingStore('directionMarkers', false),
|
directionMarkers: dexieSettingStore('directionMarkers', false),
|
||||||
@@ -108,7 +99,7 @@ export const settings = {
|
|||||||
defaultWeight: dexieSettingStore('defaultWeight', 5),
|
defaultWeight: dexieSettingStore('defaultWeight', 5),
|
||||||
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
|
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
|
||||||
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
|
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
|
||||||
showWelcomeMessage: dexieUninitializedSettingStore('showWelcomeMessage', true),
|
showWelcomeMessage: dexieSettingStore('showWelcomeMessage', true, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
|
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
|
||||||
@@ -902,6 +893,29 @@ export const dbUtils = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
addOrUpdateWaypoint: (waypoint: WaypointType, item?: ListWaypointItem) => {
|
||||||
|
let ele = get(map)?.queryTerrainElevation([waypoint.attributes.lon, waypoint.attributes.lat], { exaggerated: false }) ?? 0;
|
||||||
|
if (item) {
|
||||||
|
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||||
|
let wpt = file.wpt[item.getWaypointIndex()];
|
||||||
|
wpt.name = waypoint.name;
|
||||||
|
wpt.desc = waypoint.desc;
|
||||||
|
wpt.cmt = waypoint.cmt;
|
||||||
|
wpt.setCoordinates(waypoint.attributes);
|
||||||
|
wpt.ele = ele;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let fileIds = new Set<string>();
|
||||||
|
get(selection).getSelected().forEach((item) => {
|
||||||
|
fileIds.add(item.getFileId());
|
||||||
|
});
|
||||||
|
let wpt = new Waypoint(waypoint);
|
||||||
|
wpt.ele = ele;
|
||||||
|
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
|
||||||
|
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
setStyleToSelection: (style: LineStyleExtension) => {
|
setStyleToSelection: (style: LineStyleExtension) => {
|
||||||
if (get(selection).size === 0) {
|
if (get(selection).size === 0) {
|
||||||
return;
|
return;
|
||||||
|
@@ -182,6 +182,7 @@
|
|||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"create": "Create point of interest",
|
"create": "Create point of interest",
|
||||||
|
"add": "Add point of interest to file",
|
||||||
"help": "Fill in the form to create a new point of interest, or click on an existing one to edit it. Click on the map to fill the coordinates, or drag points of interest to move them.",
|
"help": "Fill in the form to create a new point of interest, or click on an existing one to edit it. Click on the map to fill the coordinates, or drag points of interest to move them.",
|
||||||
"help_no_selection": "Select a file item to create or edit points of interest."
|
"help_no_selection": "Select a file item to create or edit points of interest."
|
||||||
},
|
},
|
||||||
@@ -223,7 +224,6 @@
|
|||||||
},
|
},
|
||||||
"opacity": "Overlay opacity",
|
"opacity": "Overlay opacity",
|
||||||
"heatmap": "Strava Heatmap",
|
"heatmap": "Strava Heatmap",
|
||||||
"pois": "Points of interest",
|
|
||||||
"label": {
|
"label": {
|
||||||
"basemaps": "Basemaps",
|
"basemaps": "Basemaps",
|
||||||
"overlays": "Overlays",
|
"overlays": "Overlays",
|
||||||
@@ -285,7 +285,36 @@
|
|||||||
"waymarkedTrailsMTB": "MTB",
|
"waymarkedTrailsMTB": "MTB",
|
||||||
"waymarkedTrailsSkating": "Skating",
|
"waymarkedTrailsSkating": "Skating",
|
||||||
"waymarkedTrailsHorseRiding": "Horse Riding",
|
"waymarkedTrailsHorseRiding": "Horse Riding",
|
||||||
"waymarkedTrailsWinter": "Winter"
|
"waymarkedTrailsWinter": "Winter",
|
||||||
|
"points_of_interest": "Points of interest",
|
||||||
|
"food": "Food",
|
||||||
|
"bakery": "Bakery",
|
||||||
|
"food-store": "Food Store",
|
||||||
|
"eat-and-drink": "Eat and Drink",
|
||||||
|
"amenities": "Amenities",
|
||||||
|
"toilets": "Toilets",
|
||||||
|
"water": "Water",
|
||||||
|
"water-spring": "Water Spring",
|
||||||
|
"shower": "Shower",
|
||||||
|
"fuel-station": "Fuel Station",
|
||||||
|
"parking": "Parking",
|
||||||
|
"barrier": "Barrier",
|
||||||
|
"tourism": "Tourism",
|
||||||
|
"attraction": "Attraction",
|
||||||
|
"viewpoint": "Viewpoint",
|
||||||
|
"accommodation": "Accommodation",
|
||||||
|
"summit": "Summit",
|
||||||
|
"pass": "Pass",
|
||||||
|
"climbing": "Climbing",
|
||||||
|
"bicycle": "Bicycle",
|
||||||
|
"bicycle-parking": "Bicycle Parking",
|
||||||
|
"bicycle-rental": "Bicycle Rental",
|
||||||
|
"bicycle-shop": "Bicycle Shop",
|
||||||
|
"public-transport": "Public Transport",
|
||||||
|
"railway-station": "Railway Station",
|
||||||
|
"tram-stop": "Tram Stop",
|
||||||
|
"bus-stop": "Bus Stop",
|
||||||
|
"ferry": "Ferry"
|
||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"blue": "Blue",
|
"blue": "Blue",
|
||||||
|
Reference in New Issue
Block a user