custom layers

This commit is contained in:
vcoppe
2024-06-26 17:19:41 +02:00
parent 0d9da3475c
commit c7fd2fe6b5
8 changed files with 335 additions and 26 deletions

View File

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

View File

@@ -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();

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

View File

@@ -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] }
: {})

View File

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

View File

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

View File

@@ -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'),

View File

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