mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-01 16:22:32 +00:00
custom layers
This commit is contained in:
@@ -266,12 +266,15 @@ export const basemaps: { [key: string]: string | Style; } = {
|
||||
},
|
||||
};
|
||||
|
||||
Object.values(basemaps).forEach((basemap) => {
|
||||
export function extendBasemap(basemap: string | Style): string | Style {
|
||||
if (typeof basemap === 'object') {
|
||||
basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf";
|
||||
basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${mapboxAccessToken}`;
|
||||
}
|
||||
});
|
||||
return basemap;
|
||||
}
|
||||
|
||||
Object.values(basemaps).forEach(extendBasemap);
|
||||
|
||||
export const font: { [key: string]: string; } = {
|
||||
swisstopo: 'Frutiger Neue Condensed Regular',
|
||||
@@ -678,6 +681,16 @@ export const defaultOverlayTree: LayerTreeType = {
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomLayer = {
|
||||
id: string,
|
||||
name: string,
|
||||
tileUrls: string[],
|
||||
maxZoom: number,
|
||||
layerType: 'basemap' | 'overlay',
|
||||
resourceType: 'raster' | 'vector',
|
||||
value: string | {},
|
||||
};
|
||||
|
||||
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 } = {
|
||||
stravaHeatmapRun: 'sport_Run',
|
||||
|
@@ -395,8 +395,10 @@
|
||||
cutSelection();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
||||
pasteSelection();
|
||||
e.preventDefault();
|
||||
if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
||||
pasteSelection();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
exportAllFiles();
|
||||
|
264
website/src/lib/components/layer-control/CustomLayers.svelte
Normal file
264
website/src/lib/components/layer-control/CustomLayers.svelte
Normal file
@@ -0,0 +1,264 @@
|
||||
<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 * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { CirclePlus, CircleX, Minus, Pencil, Plus, Save, Trash2 } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
|
||||
const {
|
||||
customLayers,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
previousOverlays
|
||||
} = settings;
|
||||
|
||||
let name: string = '';
|
||||
let tileUrls: string[] = [''];
|
||||
let maxZoom: number = 20;
|
||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||
let resourceType: 'raster' | 'vector' = 'raster';
|
||||
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (tileUrls[0].includes('.json')) {
|
||||
resourceType = 'vector';
|
||||
layerType = 'basemap';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer() {
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls,
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = tileUrls[0];
|
||||
} else {
|
||||
if (layerType === 'basemap') {
|
||||
layer.value = extendBasemap({
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
layer.value = {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
};
|
||||
}
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
if (!$selectedBasemapTree.basemaps.hasOwnProperty('custom')) {
|
||||
$selectedBasemapTree.basemaps['custom'] = {};
|
||||
}
|
||||
$selectedBasemapTree.basemaps['custom'][layerId] = true;
|
||||
} else {
|
||||
if (!$selectedOverlayTree.overlays.hasOwnProperty('custom')) {
|
||||
$selectedOverlayTree.overlays['custom'] = {};
|
||||
}
|
||||
$selectedOverlayTree.overlays['custom'][layerId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
}
|
||||
} else {
|
||||
$currentOverlays = tryDeleteLayer($currentOverlays, layerId);
|
||||
$previousOverlays = tryDeleteLayer($previousOverlays, layerId);
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
}
|
||||
|
||||
if ($map) {
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
if ($map.getSource(layerId)) {
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
|
||||
function setDataFromSelectedLayer() {
|
||||
if (selectedLayerId) {
|
||||
const layer = $customLayers[selectedLayerId];
|
||||
name = layer.name;
|
||||
tileUrls = layer.tileUrls;
|
||||
maxZoom = layer.maxZoom;
|
||||
layerType = layer.layerType;
|
||||
resourceType = layer.resourceType;
|
||||
} else {
|
||||
name = '';
|
||||
tileUrls = [''];
|
||||
maxZoom = 20;
|
||||
layerType = 'basemap';
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
</script>
|
||||
|
||||
{#if Object.keys($customLayers).length > 0}
|
||||
<div class="flex flex-col gap-1 mb-3">
|
||||
{#each Object.entries($customLayers) as [id, layer] (id)}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="grow">{layer.name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-8">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-8">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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" />
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Minus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if i === tileUrls.length - 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Plus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if resourceType === 'raster'}
|
||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
||||
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="basemap" id="basemap" />
|
||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" on:click={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
@@ -18,12 +18,16 @@
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree
|
||||
selectedOverlayTree,
|
||||
customLayers
|
||||
} = settings;
|
||||
|
||||
$: if ($map) {
|
||||
// Set style depending on the current basemap
|
||||
$map.setStyle(basemaps[$currentBasemap], {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap].value;
|
||||
$map.setStyle(basemap, {
|
||||
diff: false
|
||||
});
|
||||
}
|
||||
@@ -65,17 +69,18 @@
|
||||
return () => {
|
||||
if ($map) {
|
||||
try {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (!$map.getSource(id)) {
|
||||
$map.addSource(id, overlays[id]);
|
||||
$map.addSource(id, overlay);
|
||||
}
|
||||
$map.addLayer(
|
||||
{
|
||||
id,
|
||||
type: overlays[id].type === 'raster' ? 'raster' : 'line',
|
||||
type: overlay.type === 'raster' ? 'raster' : 'line',
|
||||
source: id,
|
||||
paint: {
|
||||
...(id in opacities
|
||||
? overlays[id].type === 'raster'
|
||||
? overlay.type === 'raster'
|
||||
? { 'raster-opacity': opacities[id] }
|
||||
: { 'line-opacity': opacities[id] }
|
||||
: {})
|
||||
|
@@ -15,6 +15,7 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { map, setStravaHeatmapURLs } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
|
||||
const { selectedBasemapTree, selectedOverlayTree, stravaHeatmapColor, currentOverlays } =
|
||||
settings;
|
||||
@@ -111,9 +112,11 @@
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-2">
|
||||
<Accordion.Trigger>{$_('layers.custom_layers')}</Accordion.Trigger>
|
||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<ScrollArea></ScrollArea>
|
||||
<ScrollArea>
|
||||
<CustomLayers />
|
||||
</ScrollArea>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="item-3">
|
||||
|
@@ -7,6 +7,7 @@
|
||||
import { anySelectedLayer } from './utils';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
export let name: string;
|
||||
export let node: LayerTreeType;
|
||||
@@ -15,15 +16,19 @@
|
||||
|
||||
export let checked: LayerTreeType;
|
||||
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (!checked.hasOwnProperty(id)) {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
checked[id] = false;
|
||||
} else {
|
||||
checked[id] = {};
|
||||
const { customLayers } = settings;
|
||||
|
||||
$: 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]">
|
||||
@@ -42,9 +47,13 @@
|
||||
{: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"
|
||||
>{$_(`layers.label.${id}`)}</Label
|
||||
>
|
||||
<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])}
|
||||
|
@@ -4,7 +4,7 @@ import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDra
|
||||
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { gpxStatistics, initTargetMapBounds, splitAs, updateTargetMapBounds } from './stores';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers';
|
||||
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer } from './assets/layers';
|
||||
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
|
||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
||||
@@ -48,13 +48,13 @@ function dexieSettingStore<T>(setting: string, initial: T): Writable<T> {
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
set: (value: any) => {
|
||||
if (value !== get(store)) {
|
||||
if (typeof value === 'object' || value !== get(store)) {
|
||||
db.settings.put(value, setting);
|
||||
}
|
||||
},
|
||||
update: (callback: (value: any) => any) => {
|
||||
let newValue = callback(get(store));
|
||||
if (newValue !== get(store)) {
|
||||
if (typeof newValue === 'object' || newValue !== get(store)) {
|
||||
db.settings.put(newValue, setting);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export const settings = {
|
||||
currentOverlays: dexieUninitializedSettingStore('currentOverlays', defaultOverlays),
|
||||
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
|
||||
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
|
||||
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
|
||||
directionMarkers: dexieSettingStore('directionMarkers', false),
|
||||
distanceMarkers: dexieSettingStore('distanceMarkers', false),
|
||||
stravaHeatmapColor: dexieSettingStore('stravaHeatmapColor', 'bluered'),
|
||||
|
@@ -198,12 +198,24 @@
|
||||
"settings": "Layer settings",
|
||||
"settings_help": "Select the map layers you want to show in the interface, add custom ones, and adjust their settings.",
|
||||
"selection": "Layer selection",
|
||||
"custom_layers": "Custom layers",
|
||||
"custom_layers": {
|
||||
"title": "Custom layers",
|
||||
"new": "New custom layer",
|
||||
"edit": "Edit custom layer",
|
||||
"urls": "URL(s)",
|
||||
"max_zoom": "Max zoom",
|
||||
"layer_type": "Layer type",
|
||||
"basemap": "Basemap",
|
||||
"overlay": "Overlay",
|
||||
"create": "Create layer",
|
||||
"update": "Update layer"
|
||||
},
|
||||
"heatmap": "Strava Heatmap",
|
||||
"pois": "Points of interest",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
"custom": "Custom",
|
||||
"world": "World",
|
||||
"countries": "Countries",
|
||||
"belgium": "Belgium",
|
||||
|
Reference in New Issue
Block a user