This commit is contained in:
vcoppe
2025-06-21 21:07:36 +02:00
parent f0230d4634
commit 1cc07901f6
803 changed files with 7937 additions and 6329 deletions

View File

@@ -0,0 +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 { i18n } from '$lib/i18n.svelte';
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.svelte';
const {
customLayers,
selectedBasemapTree,
selectedOverlayTree,
currentBasemap,
previousBasemap,
currentOverlays,
previousOverlays,
customBasemapOrder,
customOverlayOrder,
} = settings;
let name: string = '';
let tileUrls: string[] = [''];
let maxZoom: number = 20;
let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster';
let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement;
let basemapSortable: Sortable;
let overlaySortable: Sortable;
onMount(() => {
if ($customBasemapOrder.length === 0) {
$customBasemapOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'basemap'
);
}
if ($customOverlayOrder.length === 0) {
$customOverlayOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'overlay'
);
}
basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder);
});
onDestroy(() => {
basemapSortable.destroy();
overlaySortable.destroy();
});
$: if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
} else {
resourceType = 'raster';
}
}
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId);
}
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
let is512 = tileUrls.some((url) => url.includes('512'));
let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
value: '',
};
if (resourceType === 'vector') {
layer.value = layer.tileUrls[0];
} else {
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: layer.tileUrls,
tileSize: is512 ? 512 : 256,
maxzoom: maxZoom,
},
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId,
},
],
};
}
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
function getLayerId() {
for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
return `custom-${id}`;
}
}
}
function addLayer(layerId: string) {
if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) {
$tree.basemaps['custom'] = {};
}
$tree.basemaps['custom'][layerId] = true;
return $tree;
});
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId];
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
$tree.overlays['custom'] = {};
}
$tree.overlays['custom'][layerId] = true;
return $tree;
});
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId];
}
}
}
function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) {
delete node[id];
}
return node;
}
function deleteLayer(layerId: string) {
let layer = $customLayers[layerId];
if (layer.layerType === 'basemap') {
if (layerId === $currentBasemap) {
$currentBasemap = defaultBasemap;
}
if (layerId === $previousBasemap) {
$previousBasemap = defaultBasemap;
}
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
$selectedBasemapTree.basemaps['custom'],
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer(
$selectedBasemapTree.basemaps,
'custom'
);
}
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else {
$currentOverlays.overlays['custom'][layerId] = false;
if ($previousOverlays.overlays['custom']) {
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer(
$selectedOverlayTree.overlays,
'custom'
);
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
$customLayers = tryDeleteLayer($customLayers, layerId);
}
let selectedLayerId: string | undefined = undefined;
function setDataFromSelectedLayer() {
if (selectedLayerId) {
const layer = $customLayers[selectedLayerId];
name = layer.name;
tileUrls = layer.tileUrls;
maxZoom = layer.maxZoom;
layerType = layer.layerType;
resourceType = layer.resourceType;
} else {
name = '';
tileUrls = [''];
maxZoom = 20;
layerType = 'basemap';
resourceType = 'raster';
}
}
$: selectedLayerId, setDataFromSelectedLayer();
</script>
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{i18n._('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" />
{i18n._('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}
{i18n._('layers.custom_layers.edit')}
{:else}
{i18n._('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">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{i18n._('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={i18n._('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">{i18n._('layers.custom_layers.max_zoom')}</Label>
<Input
type="number"
bind:value={maxZoom}
id="maxZoom"
min={0}
max={22}
class="h-8"
/>
{/if}
<Label>{i18n._('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">{i18n._('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{i18n._('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" />
{i18n._('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" />
{i18n._('layers.custom_layers.create')}
</Button>
{/if}
</fieldset>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
import LayerTree from './LayerTree.svelte';
// import { OverpassLayer } from './OverpassLayer';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from '@lucide/svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/logic/settings.svelte';
import { map } from '$lib/components/map/utils.svelte';
import { customBasemapUpdate, getLayers } from './utils.svelte';
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
let container: HTMLDivElement;
// let overpassLayer: OverpassLayer;
const {
currentBasemap,
previousBasemap,
currentOverlays,
currentOverpassQueries,
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
customLayers,
opacities,
} = settings;
function setStyle() {
if (!map.value) {
return;
}
let basemap = basemaps.hasOwnProperty(currentBasemap.value)
? basemaps[currentBasemap.value]
: (customLayers.value[currentBasemap.value]?.value ?? basemaps[defaultBasemap]);
map.value.removeImport('basemap');
if (typeof basemap === 'string') {
map.value.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
map.value.addImport(
{
id: 'basemap',
url: '',
data: basemap as StyleSpecification,
},
'overlays'
);
}
}
$effect(() => {
if (map.value && (currentBasemap.value || customBasemapUpdate.value)) {
setStyle();
}
});
function addOverlay(id: string) {
if (!map.value) {
return;
}
try {
let overlay = customLayers.value.hasOwnProperty(id)
? customLayers.value[id].value
: overlays[id];
if (typeof overlay === 'string') {
map.value.addImport({ id, url: overlay });
} else {
if (opacities.value.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: (overlay as StyleSpecification).layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacities.value[id];
}
return layer;
}),
};
}
map.value.addImport({
id,
url: '',
data: overlay as StyleSpecification,
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if (map.value && currentOverlays.value && opacities.value) {
let overlayLayers = getLayers(currentOverlays.value);
try {
let activeOverlays =
map.value
.getStyle()
?.imports?.reduce(
(
acc: Record<string, ImportSpecification>,
imprt: ImportSpecification
) => {
if (
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
) {
acc[imprt.id] = imprt;
}
return acc;
},
{}
) || {};
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
map.value?.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
}
}
}
$effect(() => {
if (map.value && currentOverlays.value && opacities.value) {
updateOverlays();
}
});
// map.onLoad((map: mapboxgl.Map) => {
// if (overpassLayer) {
// overpassLayer.remove();
// }
// overpassLayer = new OverpassLayer(map);
// overpassLayer.add();
// map.on('style.import.load', updateOverlays);
// });
let open = $state(false);
function openLayerControl() {
open = true;
}
function closeLayerControl() {
open = false;
}
let cancelEvents = $state(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="size-full"
onmouseenter={openLayerControl}
onmouseleave={closeLayerControl}
onpointerenter={() => {
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 size-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.value}
name="basemaps"
selected={currentBasemap.value}
onselect={(value) => {
previousBasemap.value = currentBasemap.value;
currentBasemap.value = value;
}}
/>
</div>
<Separator class="w-full" />
<div class="p-2">
{#if currentOverlays.value}
<LayerTree
layerTree={selectedOverlayTree.value}
name="overlays"
multiple={true}
bind:checked={currentOverlays.value}
/>
{/if}
</div>
<Separator class="w-full" />
<div class="p-2">
{#if currentOverpassQueries.value}
<LayerTree
layerTree={selectedOverpassTree.value}
name="overpass"
multiple={true}
bind:checked={currentOverpassQueries.value}
/>
{/if}
</div>
</div>
</ScrollArea>
</div>
</div>
</CustomControl>
<svelte:window
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {
closeLayerControl();
}
}}
/>

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import LayerTree from './LayerTree.svelte';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Sheet from '$lib/components/ui/sheet';
import * as Accordion from '$lib/components/ui/accordion';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider';
import {
basemapTree,
defaultBasemap,
overlays,
overlayTree,
overpassTree,
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils.svelte';
import { settings } from '$lib/db';
import { i18n } from '$lib/i18n.svelte';
import { map } from '$lib/components/map/map.svelte';
import CustomLayers from './CustomLayers.svelte';
const {
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
currentBasemap,
currentOverlays,
customLayers,
opacities,
} = settings;
let { open = $bindable() }: { open: boolean } = $props();
let accordionValue: string | undefined = $state(undefined);
let selectedOverlay = $state(undefined);
let overlayOpacity = $state(1);
function setOpacityFromSelection() {
if (selectedOverlay) {
if ($opacities.hasOwnProperty(selectedOverlay)) {
overlayOpacity = $opacities[selectedOverlay];
} else {
overlayOpacity = 1;
}
} else {
overlayOpacity = 1;
}
}
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
</script>
<Sheet.Root bind:open>
<Sheet.Trigger class="hidden" />
<Sheet.Content>
<Sheet.Header class="h-full">
<Sheet.Title>{i18n._('layers.settings')}</Sheet.Title>
<ScrollArea class="w-[105%] min-h-full pr-4">
<Sheet.Description>
{i18n._('layers.settings_help')}
</Sheet.Description>
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{i18n._('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>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center">
<Label>
{i18n._('layers.custom_layers.overlay')}
</Label>
<Select.Root
bind:value={selectedOverlay}
type="single"
onValueChange={setOpacityFromSelection}
>
<Select.Trigger class="h-8 mr-1">
{#if selectedOverlay}
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{i18n._(`layers.label.${selectedOverlay}`)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if}
{/if}
</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}
>{i18n._(`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">
{i18n._('menu.style.opacity')}
<div class="p-2 pr-3 grow">
<Slider
bind:value={overlayOpacity}
type="single"
min={0.1}
max={1}
step={0.1}
disabled={selectedOverlay === undefined}
onValueChange={(value) => {
if (selectedOverlay) {
if (
map.current &&
isSelected($currentOverlays, selectedOverlay)
) {
try {
map.current.removeImport(selectedOverlay);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[selectedOverlay] = value;
}
}}
/>
</div>
</Label>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
>
<Accordion.Content>
<ScrollArea>
<CustomLayers />
</ScrollArea>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</ScrollArea>
</Sheet.Header>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
let {
layerTree,
name,
selected = '',
onselect = () => {},
multiple = false,
checked = $bindable({}),
}: {
layerTree: LayerTreeType;
name: string;
selected?: string;
onselect?: (value: string) => void;
multiple?: boolean;
checked?: LayerTreeType;
} = $props();
</script>
<form>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} {selected} {onselect} {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
</form>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import Self from '$lib/components/map/layer-control/LayerTreeNode.svelte';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer } from './utils.svelte';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings.svelte';
let {
name,
node,
selected = '',
onselect = () => {},
multiple = false,
checked = $bindable({}),
}: {
name: string;
node: LayerTreeType;
selected?: string;
onselect?: (value: string) => void;
multiple: boolean;
checked: LayerTreeType;
} = $props();
const { customLayers } = settings;
$effect.pre(() => {
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={i18n._(`layers.label.${id}`)}
/>
{:else}
<input
id="{name}-{id}"
type="radio"
{name}
value={id}
checked={selected === id}
oninput={(e) => {
if ((e.target as HTMLInputElement)?.checked) {
onselect(id);
}
}}
/>
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if customLayers.value.hasOwnProperty(id)}
{customLayers.value[id].name}
{:else}
{i18n._(`layers.label.${id}`)}
{/if}
</Label>
</div>
{/if}
{:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}>
{#snippet trigger()}
<span>{i18n._(`layers.label.${id}`)}</span>
{/snippet}
{#snippet content()}
<div class="ml-2">
<Self
node={node[id]}
{name}
{selected}
{onselect}
{multiple}
bind:checked={checked[id]}
/>
</div>
{/snippet}
</CollapsibleTreeNode>
{/if}
{/each}
</div>
<style lang="postcss">
@reference "../../../../app.css";
div :global(input[type='radio']) {
@apply appearance-none;
@apply w-4 h-4;
@apply border-[1.5px] border-primary;
@apply rounded-full;
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
@apply cursor-pointer;
@apply checked:bg-primary;
@apply checked:bg-clip-content;
@apply checked:p-0.5;
}
</style>

View File

@@ -0,0 +1,324 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils.svelte';
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/map/map.svelte';
// const { currentOverpassQueries } = settings;
const mercator = new SphericalMercator({
size: 256,
});
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;
expirationTime = 7 * 24 * 3600 * 1000;
map: mapboxgl.Map;
popup: MapPopup;
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);
constructor(map: mapboxgl.Map) {
this.map = map;
this.popup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
maxWidth: undefined,
offset: 15,
});
}
add() {
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.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,
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
},
});
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.import.load', this.updateBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try {
if (this.map.getLayer('overpass')) {
this.map.removeLayer('overpass');
}
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
onHover(e: any) {
this.popup.setItem({
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
},
});
}
query(bbox: [number, number, number, number]) {
let queries = getCurrentQueries();
if (queries.length === 0) {
return;
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
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.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);
}
});
}
}
}
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 time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
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,
type: element.type,
},
},
});
}
}
}
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.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) {
if (Array.isArray(overpassQueryData[query].tags)) {
return overpassQueryData[query].tags.map((tags) => getQueryItem(tags)).join('');
} else {
return getQueryItem(overpassQueryData[query].tags);
}
}
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('');
} else {
return `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${value}]`)
.join('')};`;
}
}
function belongsToQuery(element: any, query: string) {
if (Array.isArray(overpassQueryData[query].tags)) {
return overpassQueryData[query].tags.some((tags) => belongsToQueryItem(element, tags));
} else {
return belongsToQueryItem(element, overpassQueryData[query].tags);
}
}
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
);
}
function getCurrentQueries() {
let currentQueries = get(currentOverpassQueries);
if (currentQueries === undefined) {
return [];
}
return Object.entries(getLayers(currentQueries))
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
}

View File

@@ -0,0 +1,107 @@
<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 { i18n } from '$lib/i18n.svelte';
import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
export let poi: PopupItem<any>;
let tags: { [key: string]: string } = {};
let name = '';
$: if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') {
name = tags.name;
} else {
name = i18n._(`layers.label.${poi.item.query}`);
}
}
function addToFile() {
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
let wpt: WaypointType = {
attributes: {
lat: poi.item.lat,
lon: poi.item.lon,
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym,
};
if (tags.website) {
wpt.link = {
attributes: {
href: tags.website,
},
};
}
dbUtils.addOrUpdateWaypoint(wpt);
}
</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)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</div>
<Button
class="ml-auto p-1.5 h-8"
variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
'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={addToFile}
>
<MapPin size="16" class="mr-1" />
{i18n._('toolbar.waypoint.add')}
</Button>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,60 @@
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;
}
} else {
if (anySelectedLayer(node[id])) {
return true;
}
}
return false;
}) !== undefined
);
}
export function getLayers(
node: LayerTreeType,
layers: { [key: string]: boolean } = {}
): { [key: string]: boolean } {
Object.keys(node).forEach((id) => {
if (typeof node[id] == 'boolean') {
layers[id] = node[id];
} else {
getLayers(node[id], layers);
}
});
return layers;
}
export function isSelected(node: LayerTreeType, id: string) {
return Object.keys(node).some((key) => {
if (key === id) {
return node[key];
}
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
return true;
}
return false;
});
}
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') {
toggle(node[key], id);
}
});
return node;
}
export const customBasemapUpdate = $state({
value: 0,
});