mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -17,11 +17,11 @@
|
||||
Map,
|
||||
Layers2,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { defaultBasemap, layerGroup, type CustomLayer } from '$lib/data/layers';
|
||||
import { onMount } from 'svelte';
|
||||
import { remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
const {
|
||||
@@ -128,15 +128,16 @@
|
||||
],
|
||||
};
|
||||
}
|
||||
addLayer(layerId);
|
||||
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
if (!Object.hasOwn($customLayers, `custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
if (!Object.hasOwn($tree.basemaps, 'custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
@@ -161,7 +162,7 @@
|
||||
}
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
if (!Object.hasOwn($tree.overlays, 'custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$tree.overlays['custom'][layerId] = true;
|
||||
@@ -169,7 +170,7 @@
|
||||
});
|
||||
|
||||
currentOverlays.update(($overlays) => {
|
||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||
if (!Object.hasOwn($overlays.overlays, 'custom')) {
|
||||
$overlays.overlays['custom'] = {};
|
||||
}
|
||||
$overlays.overlays['custom'][layerId] = true;
|
||||
@@ -182,8 +183,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
function tryDeleteLayer<T>(node: Record<string, T>, id: string): Record<string, T> {
|
||||
if (Object.hasOwn(node, id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
@@ -255,13 +256,12 @@
|
||||
onfinalize={(e) => {
|
||||
customBasemapItems = e.detail.items;
|
||||
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
||||
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
layerGroup($tree.basemaps)['custom'] = Object.fromEntries(
|
||||
customBasemapItems.map((item) => [item.id, true])
|
||||
);
|
||||
return $tree;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#each customBasemapItems as item (item.id)}
|
||||
@@ -315,13 +315,12 @@
|
||||
onfinalize={(e) => {
|
||||
customOverlayItems = e.detail.items;
|
||||
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
||||
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
layerGroup($tree.overlays)['custom'] = Object.fromEntries(
|
||||
customOverlayItems.map((item) => [item.id, true])
|
||||
);
|
||||
return $tree;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#each customOverlayItems as item (item.id)}
|
||||
@@ -365,7 +364,7 @@
|
||||
<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}
|
||||
{#each tileUrls as _, i (i)}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input
|
||||
bind:value={tileUrls[i]}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Layers } from '@lucide/svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { Map } from 'maplibre-gl';
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
overlayTree,
|
||||
overpassTree,
|
||||
terrainSources,
|
||||
} from '$lib/assets/layers';
|
||||
} from '$lib/data/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { untrack } from 'svelte';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
function setOpacityFromSelection() {
|
||||
if (selectedOverlay) {
|
||||
if ($opacities.hasOwnProperty(selectedOverlay)) {
|
||||
if (Object.hasOwn($opacities, selectedOverlay)) {
|
||||
overlayOpacity = $opacities[selectedOverlay];
|
||||
} else {
|
||||
overlayOpacity = 1;
|
||||
@@ -169,7 +169,7 @@
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{#if $isLayerFromExtension(selectedOverlay)}
|
||||
{$getLayerName(selectedOverlay)}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{:else if Object.hasOwn($customLayers, selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
@@ -178,7 +178,7 @@
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#each Object.keys(overlays) as id (id)}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}>
|
||||
{#if $isLayerFromExtension(id)}
|
||||
@@ -189,7 +189,7 @@
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
{#each Object.entries($customLayers) as [id, layer] (id)}
|
||||
{#if layer.layerType === 'overlay'}
|
||||
<Select.Item value={id}>{layer.name}</Select.Item>
|
||||
{/if}
|
||||
@@ -218,7 +218,7 @@
|
||||
if ($map.getLayer(selectedOverlay)) {
|
||||
$map.removeLayer(selectedOverlay);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@
|
||||
{i18n._(`layers.label.${$terrainSource}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(terrainSources) as id}
|
||||
{#each Object.keys(terrainSources) as id (id)}
|
||||
<Select.Item value={id}>
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
</Select.Item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { type LayerTreeType } from '$lib/data/layers';
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
|
||||
let {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
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 { type LayerTreeType } from '$lib/data/layers';
|
||||
import { anySelectedLayer } from './utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
let {
|
||||
@@ -22,7 +22,7 @@
|
||||
selected?: string;
|
||||
onselect?: (value: string) => void;
|
||||
multiple: boolean;
|
||||
checked: LayerTreeType;
|
||||
checked?: LayerTreeType;
|
||||
} = $props();
|
||||
|
||||
const { customLayers } = settings;
|
||||
@@ -31,7 +31,7 @@
|
||||
$effect.pre(() => {
|
||||
if (checked !== undefined) {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (!checked.hasOwnProperty(id)) {
|
||||
if (!Object.hasOwn(checked, id)) {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
checked[id] = false;
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
{#each Object.keys(node) as id}
|
||||
{#each Object.keys(node) as id (id)}
|
||||
{#if typeof node[id] == 'boolean'}
|
||||
{#if node[id]}
|
||||
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
|
||||
@@ -53,7 +53,9 @@
|
||||
id="{name}-{id}"
|
||||
{name}
|
||||
value={id}
|
||||
bind:checked={checked[id]}
|
||||
bind:checked={
|
||||
() => checked[id] === true, (value) => (checked[id] = value)
|
||||
}
|
||||
class="scale-90"
|
||||
aria-label={i18n._(`layers.label.${id}`)}
|
||||
/>
|
||||
@@ -72,7 +74,7 @@
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
{#if Object.hasOwn($customLayers, id)}
|
||||
{$customLayers[id].name}
|
||||
{:else if $isLayerFromExtension(id)}
|
||||
{$getLayerName(id)}
|
||||
@@ -90,12 +92,18 @@
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
<Self
|
||||
node={node[id]}
|
||||
node={node[id] as LayerTreeType}
|
||||
{name}
|
||||
{selected}
|
||||
{onselect}
|
||||
{multiple}
|
||||
bind:checked={checked[id]}
|
||||
bind:checked={
|
||||
() => {
|
||||
const group = checked[id];
|
||||
return typeof group === 'object' ? group : undefined;
|
||||
},
|
||||
(value) => (checked[id] = value ?? {})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import type { WaypointType } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
let {
|
||||
poi,
|
||||
@@ -29,28 +29,22 @@
|
||||
return '';
|
||||
});
|
||||
|
||||
function addToFile() {
|
||||
const { selection } = engine;
|
||||
|
||||
async 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
fileActions.addOrUpdateWaypoint(wpt);
|
||||
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
|
||||
await engine.newWaypoint({
|
||||
lng: poi.item.lng,
|
||||
lat: poi.item.lat,
|
||||
ele,
|
||||
name,
|
||||
desc,
|
||||
icon: poi.item.sym ?? '',
|
||||
link: tags.website ?? '',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,7 +54,7 @@
|
||||
<div class="flex flex-col">
|
||||
<p>{name}</p>
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lng.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,11 +79,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#each Object.entries(tags) as [key, value] (key)}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
<a
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="external"
|
||||
class="text-link underline">{value}</a
|
||||
>
|
||||
{:else if key === 'wikipedia' || key.startsWith('wikipedia:') || key.endsWith(':wikipedia')}
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{value}"
|
||||
@@ -113,7 +112,7 @@
|
||||
size="sm"
|
||||
class="mt-1 justify-start"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
disabled={selectionSize($selection) === 0}
|
||||
onclick={addToFile}
|
||||
>
|
||||
<MapPin size="14" />
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||
import { engine } from '$lib/engine';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
||||
import { isSelected } from './utils';
|
||||
|
||||
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
||||
|
||||
/** The raster source that an overlay added by an extension uses. */
|
||||
function rasterSource(id: string) {
|
||||
const style = overlays[id];
|
||||
assert.ok(typeof style === 'object');
|
||||
const source = style.sources[id];
|
||||
assert.ok(source?.type === 'raster');
|
||||
return source;
|
||||
}
|
||||
const overlayTreeBefore = JSON.stringify(overlayTree);
|
||||
const overlayIdsBefore = Object.keys(overlays).sort();
|
||||
|
||||
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
|
||||
extensionName,
|
||||
id,
|
||||
name: `Overlay ${id}`,
|
||||
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
|
||||
});
|
||||
|
||||
let api: ExtensionAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
engine.setSetting = () => {};
|
||||
engine.deleteSetting = () => {};
|
||||
settings.connect({});
|
||||
settings.initialize();
|
||||
api = new ExtensionAPI();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// the layers are shared by the whole app: leave them as they were
|
||||
api.filterOverlays([]);
|
||||
for (const extension of ['My extension', 'Other extension']) {
|
||||
delete (overlayTree.overlays as LayerTreeType)[extension];
|
||||
}
|
||||
engine.setSetting = original.set;
|
||||
engine.deleteSetting = original.delete;
|
||||
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
|
||||
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
|
||||
});
|
||||
|
||||
describe('ExtensionAPI', () => {
|
||||
it('adds an overlay as a layer, in the tree of its extension', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
|
||||
const source = rasterSource('extension-one');
|
||||
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
|
||||
assert.equal(source.tileSize, 256);
|
||||
assert.equal(source.maxzoom, 22);
|
||||
|
||||
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
|
||||
assert.equal(isSelected(overlayTree, 'extension-one'), true);
|
||||
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
|
||||
// not shown until the user chooses to
|
||||
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
|
||||
assert.ok(get(api.isLayerFromExtension)('extension-one'));
|
||||
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
|
||||
assert.equal(get(api.getLayerName)('other'), '');
|
||||
});
|
||||
|
||||
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
|
||||
api.addOrUpdateOverlay({
|
||||
...overlay('big'),
|
||||
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
|
||||
maxZoom: 15,
|
||||
});
|
||||
const source = rasterSource('extension-big');
|
||||
assert.equal(source.tileSize, 512);
|
||||
assert.equal(source.maxzoom, 15);
|
||||
});
|
||||
|
||||
it('refuses an overlay that is not complete', () => {
|
||||
for (const bad of [
|
||||
{ ...overlay('a'), extensionName: '' },
|
||||
{ ...overlay('a'), id: '' },
|
||||
{ ...overlay('a'), name: '' },
|
||||
{ ...overlay('a'), tileUrls: [] },
|
||||
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
|
||||
]) {
|
||||
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
|
||||
}
|
||||
assert.ok(!('extension-a' in overlays));
|
||||
});
|
||||
|
||||
it('updates an overlay that is added again, keeping it shown', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
settings.currentOverlays.update((current) => {
|
||||
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
|
||||
return current;
|
||||
});
|
||||
|
||||
api.addOrUpdateOverlay({
|
||||
...overlay('one'),
|
||||
name: 'Renamed',
|
||||
tileUrls: ['https://other/{z}'],
|
||||
});
|
||||
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
|
||||
assert.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']);
|
||||
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
|
||||
});
|
||||
|
||||
it('removes the overlays that are not in the list anymore, everywhere', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
|
||||
|
||||
api.filterOverlays(['two']);
|
||||
assert.ok(!('extension-one' in overlays));
|
||||
assert.ok('extension-two' in overlays);
|
||||
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
|
||||
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
|
||||
for (const tree of [
|
||||
overlayTree,
|
||||
get(settings.selectedOverlayTree),
|
||||
get(settings.currentOverlays)!,
|
||||
]) {
|
||||
assert.equal(isSelected(tree, 'extension-one'), false);
|
||||
}
|
||||
assert.equal(isSelected(overlayTree, 'extension-two'), true);
|
||||
|
||||
api.filterOverlays([]);
|
||||
assert.ok(!('extension-two' in overlays));
|
||||
});
|
||||
|
||||
it('puts the overlays in the order it is given', () => {
|
||||
api.addOrUpdateOverlay(overlay('one'));
|
||||
api.addOrUpdateOverlay(overlay('two'));
|
||||
api.addOrUpdateOverlay(overlay('three'));
|
||||
|
||||
api.updateOverlaysOrder(['three', 'one', 'two']);
|
||||
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
|
||||
'My extension'
|
||||
];
|
||||
assert.deepEqual(Object.keys(extension as LayerTreeType), [
|
||||
'extension-three',
|
||||
'extension-one',
|
||||
'extension-two',
|
||||
]);
|
||||
// ids it does not know are ignored
|
||||
api.updateOverlaysOrder(['unknown', 'two']);
|
||||
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||
import { isSelected, remove, removeAll } from './utils';
|
||||
import { overlays, overlayTree } from '$lib/assets/layers';
|
||||
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||
import { browser } from '$app/environment';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ExtensionAPI {
|
||||
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
|
||||
|
||||
init() {
|
||||
if (browser && !window.hasOwnProperty('gpxstudio')) {
|
||||
if (browser && !Object.hasOwn(window, 'gpxstudio')) {
|
||||
Object.defineProperty(window, 'gpxstudio', {
|
||||
value: this,
|
||||
});
|
||||
@@ -84,14 +84,15 @@ export class ExtensionAPI {
|
||||
],
|
||||
};
|
||||
|
||||
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
overlayTree.overlays[overlay.extensionName] = {};
|
||||
const extensions = layerGroup(overlayTree.overlays);
|
||||
if (!Object.hasOwn(extensions, overlay.extensionName)) {
|
||||
extensions[overlay.extensionName] = {};
|
||||
}
|
||||
|
||||
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
|
||||
layerGroup(extensions[overlay.extensionName])[overlay.id] = true;
|
||||
|
||||
selectedOverlayTree.update((selected) => {
|
||||
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
if (!Object.hasOwn(selected.overlays, overlay.extensionName)) {
|
||||
selected.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
selected.overlays[overlay.extensionName][overlay.id] = true;
|
||||
@@ -104,13 +105,13 @@ export class ExtensionAPI {
|
||||
show = true;
|
||||
try {
|
||||
get(map)?.removeLayer(overlay.id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
if (!Object.hasOwn(current.overlays, overlay.extensionName)) {
|
||||
current.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
current.overlays[overlay.extensionName][overlay.id] = show;
|
||||
@@ -155,13 +156,13 @@ export class ExtensionAPI {
|
||||
updateOverlaysOrder(ids: string[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
selectedOverlayTree.update((selected) => {
|
||||
let isSelected: Record<string, boolean> = {};
|
||||
const isSelected: Record<string, boolean> = {};
|
||||
ids.forEach((id) => {
|
||||
const overlay = get(this._overlays).get(id);
|
||||
if (
|
||||
overlay &&
|
||||
selected.overlays.hasOwnProperty(overlay.extensionName) &&
|
||||
selected.overlays[overlay.extensionName].hasOwnProperty(id)
|
||||
Object.hasOwn(selected.overlays, overlay.extensionName) &&
|
||||
Object.hasOwn(selected.overlays[overlay.extensionName] as LayerTreeType, id)
|
||||
) {
|
||||
isSelected[id] = selected.overlays[overlay.extensionName][id];
|
||||
delete selected.overlays[overlay.extensionName][id];
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { overpassQueryData } from '$lib/data/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import type { Map as MapLibreMap, GeoJSONSource } from 'maplibre-gl';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import type { GeoJSONSource, MapLayerMouseEvent, Map as MapLibreMap } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { cachedFetch } from '$lib/storage/cached-fetch';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
/** The responses of Overpass are kept in the browser for a week. */
|
||||
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
|
||||
|
||||
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.gpx.studio/api/interpreter';
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: MapLibreMap;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
popup: MapPopup;
|
||||
|
||||
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
// What was queried this session: the queries that were made for each tile. The responses are
|
||||
// kept longer, see `cachedFetch`.
|
||||
queriedTiles = new Map<string, Set<string>>();
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
@@ -54,7 +53,7 @@ export class OverpassLayer {
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
@@ -77,7 +76,7 @@ export class OverpassLayer {
|
||||
update() {
|
||||
this.loadIcons();
|
||||
|
||||
const fullData = get(data);
|
||||
const fullData = get(this.data);
|
||||
const queries = getCurrentQueries();
|
||||
const d: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
@@ -87,7 +86,7 @@ export class OverpassLayer {
|
||||
};
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||
const source = this.map.getSource('overpass') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(d);
|
||||
} else {
|
||||
@@ -116,7 +115,7 @@ export class OverpassLayer {
|
||||
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
@@ -136,28 +135,28 @@ export class OverpassLayer {
|
||||
if (this.map.getSource('overpass')) {
|
||||
this.map.removeSource('overpass');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
onHover(e: any) {
|
||||
onHover(e: MapLayerMouseEvent) {
|
||||
this.popup.setItem({
|
||||
kind: 'overpass',
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
...e.features![0].properties,
|
||||
sym: overpassQueryData[e.features![0].properties.query].symbol ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
query(bbox: [number, number, number, number]) {
|
||||
let queries = getCurrentQueries();
|
||||
const queries = getCurrentQueries();
|
||||
if (queries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tileLimits = mercator.xyz(bbox, this.queryZoom);
|
||||
let time = Date.now();
|
||||
const tileLimits = mercator.xyz(bbox, this.queryZoom);
|
||||
|
||||
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
|
||||
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
|
||||
@@ -165,66 +164,35 @@ export class OverpassLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.overpasstiles
|
||||
.where('[x+y]')
|
||||
.equals([x, y])
|
||||
.toArray()
|
||||
.then((querytiles) => {
|
||||
let missingQueries = queries.filter(
|
||||
(query) =>
|
||||
!querytiles.some(
|
||||
(querytile) =>
|
||||
querytile.query === query &&
|
||||
time - querytile.time < this.expirationTime
|
||||
)
|
||||
);
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
});
|
||||
const queried = this.queriedTiles.get(`${x},${y}`);
|
||||
const missingQueries = queries.filter((query) => !queried?.has(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}`)
|
||||
)
|
||||
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject()))
|
||||
.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: {
|
||||
this.data.update((updatedData) => {
|
||||
for (const element of data.elements) {
|
||||
for (const query of queries) {
|
||||
if (belongsToQuery(element, query)) {
|
||||
updatedData.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
@@ -235,28 +203,28 @@ export class OverpassLayer {
|
||||
properties: {
|
||||
id: element.id,
|
||||
lat: element.center ? element.center.lat : element.lat,
|
||||
lon: element.center ? element.center.lon : element.lon,
|
||||
lng: 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);
|
||||
return updatedData;
|
||||
});
|
||||
|
||||
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
|
||||
queries.forEach((query) => queried.add(query));
|
||||
this.queriedTiles.set(`${x},${y}`, queried);
|
||||
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
}
|
||||
|
||||
loadIcons() {
|
||||
let currentQueries = getCurrentQueries();
|
||||
const currentQueries = getCurrentQueries();
|
||||
currentQueries.forEach((query) => {
|
||||
loadSVGIcon(
|
||||
this.map,
|
||||
@@ -289,7 +257,7 @@ function getQuery(query: string) {
|
||||
}
|
||||
|
||||
function getQueryItem(tags: Record<string, string | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
|
||||
const arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
|
||||
Array.isArray(entry[1])
|
||||
);
|
||||
if (arrayEntry !== undefined) {
|
||||
@@ -323,12 +291,12 @@ function belongsToQueryItem(element: any, tags: Record<string, string | string[]
|
||||
}
|
||||
|
||||
function getCurrentQueries() {
|
||||
let currentQueries = get(currentOverpassQueries);
|
||||
const currentQueries = get(currentOverpassQueries);
|
||||
if (currentQueries === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(getLayers(currentQueries))
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([query, _]) => query);
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([query]) => query);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { LayerTreeType } from '$lib/data/layers';
|
||||
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
|
||||
|
||||
// roads: bike (selected), foot
|
||||
// satellite: esri, ign: fr (selected)
|
||||
// osm
|
||||
const tree = (): LayerTreeType => ({
|
||||
roads: { bike: true, foot: false },
|
||||
satellite: { esri: false, ign: { fr: true } },
|
||||
osm: false,
|
||||
});
|
||||
|
||||
describe('layer trees', () => {
|
||||
it('tells if a layer is selected', () => {
|
||||
assert.equal(isSelected(tree(), 'bike'), true);
|
||||
assert.equal(isSelected(tree(), 'foot'), false);
|
||||
assert.equal(isSelected(tree(), 'fr'), true);
|
||||
assert.equal(isSelected(tree(), 'osm'), false);
|
||||
assert.equal(isSelected(tree(), 'unknown'), false);
|
||||
});
|
||||
|
||||
it('tells if anything is selected in a branch', () => {
|
||||
const t = tree();
|
||||
assert.equal(anySelectedLayer(t), true);
|
||||
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
|
||||
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
|
||||
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
|
||||
assert.equal(anySelectedLayer({}), false);
|
||||
});
|
||||
|
||||
it('flattens the layers of the leaves', () => {
|
||||
assert.deepEqual(getLayers(tree()), {
|
||||
bike: true,
|
||||
foot: false,
|
||||
esri: false,
|
||||
fr: true,
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(getLayers({}), {});
|
||||
});
|
||||
|
||||
it('toggles a layer wherever it is', () => {
|
||||
const t = toggle(tree(), 'fr');
|
||||
assert.equal(isSelected(t, 'fr'), false);
|
||||
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
|
||||
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
|
||||
// the tree is edited, and returned
|
||||
const own = tree();
|
||||
assert.equal(toggle(own, 'foot'), own);
|
||||
assert.equal((own.roads as LayerTreeType).foot, true);
|
||||
// an id that is not there changes nothing
|
||||
assert.deepEqual(toggle(tree(), 'unknown'), tree());
|
||||
});
|
||||
|
||||
it('removes layers and branches', () => {
|
||||
assert.deepEqual(remove(tree(), 'bike'), {
|
||||
roads: { foot: false },
|
||||
satellite: { esri: false, ign: { fr: true } },
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(remove(tree(), 'ign'), {
|
||||
roads: { bike: true, foot: false },
|
||||
satellite: { esri: false },
|
||||
osm: false,
|
||||
});
|
||||
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
|
||||
roads: { foot: false },
|
||||
satellite: { esri: false, ign: {} },
|
||||
});
|
||||
assert.deepEqual(removeAll(tree(), []), tree());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import type { LayerTreeType } from '$lib/data/layers';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user