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:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
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)}&deg; {poi.item.lon.toFixed(6)}&deg;
{poi.item.lat.toFixed(6)}&deg; {poi.item.lng.toFixed(6)}&deg;
</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 (