From af919c731671227d919e9ddff0ab55c1a5fc7a01 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Tue, 16 Jul 2024 23:57:17 +0200 Subject: [PATCH] overpass layer progress --- website/package-lock.json | 18 ++ website/package.json | 2 + .../layer-control/LayerControl.svelte | 10 + .../components/layer-control/OverpassLayer.ts | 193 ++++++++++++++++++ website/src/lib/db.ts | 47 ++--- 5 files changed, 241 insertions(+), 29 deletions(-) create mode 100644 website/src/lib/components/layer-control/OverpassLayer.ts diff --git a/website/package-lock.json b/website/package-lock.json index 76a349ba..9701457d 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@internationalized/date": "^3.5.4", "@mapbox/mapbox-gl-geocoder": "^5.0.2", + "@mapbox/sphericalmercator": "^1.2.0", + "@types/mapbox__sphericalmercator": "^1.2.3", "bits-ui": "^0.21.12", "chart.js": "^4.4.3", "clsx": "^2.1.1", @@ -1422,6 +1424,17 @@ "polyline": "bin/polyline.bin.js" } }, + "node_modules/@mapbox/sphericalmercator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz", + "integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==", + "bin": { + "bbox": "bin/bbox.js", + "to4326": "bin/to4326.js", + "to900913": "bin/to900913.js", + "xyz": "bin/xyz.js" + } + }, "node_modules/@mapbox/tiny-sdf": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", @@ -1962,6 +1975,11 @@ "@types/mapbox-gl": "*" } }, + "node_modules/@types/mapbox__sphericalmercator": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/mapbox__sphericalmercator/-/mapbox__sphericalmercator-1.2.3.tgz", + "integrity": "sha512-gBXMMNhRTA8HzAzLdBzVYET0dH1p8jDPYZoT9+KnfFRYIRwHnbW+3IyiSlwS7kvr97PMn501QY+Dd3kjxb2dAA==" + }, "node_modules/@types/mapbox-gl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.1.0.tgz", diff --git a/website/package.json b/website/package.json index 8b6376d5..c74219fc 100644 --- a/website/package.json +++ b/website/package.json @@ -48,6 +48,8 @@ "dependencies": { "@internationalized/date": "^3.5.4", "@mapbox/mapbox-gl-geocoder": "^5.0.2", + "@mapbox/sphericalmercator": "^1.2.0", + "@types/mapbox__sphericalmercator": "^1.2.3", "bits-ui": "^0.21.12", "chart.js": "^4.4.3", "clsx": "^2.1.1", diff --git a/website/src/lib/components/layer-control/LayerControl.svelte b/website/src/lib/components/layer-control/LayerControl.svelte index 0d82b9e8..54c3e33d 100644 --- a/website/src/lib/components/layer-control/LayerControl.svelte +++ b/website/src/lib/components/layer-control/LayerControl.svelte @@ -12,8 +12,10 @@ import { map } from '$lib/stores'; import { get, writable } from 'svelte/store'; import { getLayers } from './utils'; + import { OverpassLayer } from './OverpassLayer'; let container: HTMLDivElement; + let overpassLayer: OverpassLayer; const { currentBasemap, @@ -54,6 +56,14 @@ }); } + $: if ($map) { + if (overpassLayer) { + overpassLayer.remove(); + } + overpassLayer = new OverpassLayer($map); + overpassLayer.add(); + } + let selectedBasemap = writable(get(currentBasemap)); selectedBasemap.subscribe((value) => { // Updates coming from radio buttons diff --git a/website/src/lib/components/layer-control/OverpassLayer.ts b/website/src/lib/components/layer-control/OverpassLayer.ts new file mode 100644 index 00000000..814eb591 --- /dev/null +++ b/website/src/lib/components/layer-control/OverpassLayer.ts @@ -0,0 +1,193 @@ +import { type LayerTreeType } from "$lib/assets/layers"; +import SphericalMercator from "@mapbox/sphericalmercator"; +import { getLayers } from "./utils"; +import mapboxgl from "mapbox-gl"; +import { get, writable } from "svelte/store"; +import { liveQuery } from "dexie"; +import { db } from "$lib/db"; + +const poiSelection: LayerTreeType = { + transport: { + tram: true, + }, +}; + +type PoiQuery = Record; + +const poiQueries: Record = { + tram: { + railway: 'tram_stop', + }, +}; + +const mercator = new SphericalMercator({ + size: 256, +}); + +let data = writable({ 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-api.de/api/interpreter'; + minZoom = 12; + queryZoom = 14; + map: mapboxgl.Map; + + currentQueries: Set = new Set(); + + unsubscribes: (() => void)[] = []; + queryIfNeededBinded = this.queryIfNeeded.bind(this); + updateBinded = this.update.bind(this); + + constructor(map: mapboxgl.Map) { + this.map = map; + } + + add() { + this.map.on('moveend', this.queryIfNeededBinded); + this.map.on('style.load', this.updateBinded); + this.unsubscribes.push(data.subscribe(this.updateBinded)); + + this.map.showTileBoundaries = true; + + 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() { + 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: { + 'text-field': ['get', 'name'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': 'black', + } + }); + } + } 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.load', this.updateBinded); + this.unsubscribes.forEach((unsubscribe) => unsubscribe()); + + if (this.map.getLayer('overpass')) { + this.map.removeLayer('overpass'); + } + + if (this.map.getSource('overpass')) { + this.map.removeSource('overpass'); + } + } + + query(bbox: [number, number, number, number]) { + let layers = getLayers(poiSelection); + let tileLimits = mercator.xyz(bbox, this.queryZoom); + + 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.overpasslayertiles.where('[x+y]').equals([x, y]).toArray().then((layertiles) => { + let missingLayers = Object.keys(layers).filter((layer) => !layertiles.some((layertile) => layertile.layer === layer)); + if (missingLayers.length > 0) { + this.queryTileForLayers(x, y, missingLayers); + } + }); + } + } + } + + queryTileForLayers(x: number, y: number, layers: string[]) { + this.currentQueries.add(`${x},${y}`); + + const bounds = mercator.bbox(x, y, this.queryZoom); + fetch(`${this.overpassUrl}?data=${getQueryForBoundsAndLayers(bounds, layers)}`) + .then((response) => response.json()) + .then((data) => this.storeOverpassData(x, y, layers, data)); + } + + storeOverpassData(x: number, y: number, layers: string[], data: any) { + let layerTiles = layers.map((layer) => ({ x, y, layer })); + let pois: { layer: string, id: number, poi: GeoJSON.Feature }[] = []; + + for (let element of data.elements) { + for (let layer of layers) { + if (belongsToLayer(element, layer)) { + pois.push({ + layer, + id: element.id, + poi: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [element.lon, element.lat], + }, + properties: { ...element.tags, layer } + } + }); + + break; + } + } + } + + db.transaction('rw', db.overpasslayertiles, db.overpassdata, async () => { + await db.overpasslayertiles.bulkPut(layerTiles); + await db.overpassdata.bulkPut(pois); + }); + + this.currentQueries.delete(`${x},${y}`); + } +} + +function getQueryForBoundsAndLayers(bounds: [number, number, number, number], layers: string[]) { + return `[bbox:${bounds[1]},${bounds[0]},${bounds[3]},${bounds[2]}][out:json];(${getQueryForLayers(layers)});out;`; +} + +function getQueryForLayers(layers: string[]) { + return layers.map((layer) => `node${getQueryForLayer(layer)};`).join(''); +} + +function getQueryForLayer(layer: string) { + return Object.entries(poiQueries[layer]) + .map(([tag, value]) => value ? `[${tag}=${value}]` : `[${tag}]`) + .join(''); +} + +function belongsToLayer(element: any, layer: string) { + return Object.entries(poiQueries[layer]) + .every(([tag, value]) => value ? element.tags[tag] === value : element.tags[tag]); +} \ No newline at end of file diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts index e5b42575..18ab6510 100644 --- a/website/src/lib/db.ts +++ b/website/src/lib/db.ts @@ -18,6 +18,8 @@ class Database extends Dexie { files!: Dexie.Table; patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>; settings!: Dexie.Table; + overpasslayertiles!: Dexie.Table<{ layer: string, x: number, y: number }, [string, number, number]>; + overpassdata!: Dexie.Table<{ layer: string, id: number, poi: GeoJSON.Feature }, [string, number]>; constructor() { super("Database", { @@ -27,19 +29,22 @@ class Database extends Dexie { fileids: ',&fileid', files: '', patches: ',patch', - settings: '' + settings: '', + overpasslayertiles: '[layer+x+y],[x+y]', + overpassdata: '[layer+id]', }); - this.files.add } } -const db = new Database(); +export const db = new Database(); // Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB -function dexieSettingStore(setting: string, initial: T): Writable { - let store = writable(initial); - liveQuery(() => db.settings.get(setting)).subscribe(value => { - if (value !== undefined) { +export function bidirectionalDexieStore(table: Dexie.Table, key: K, initial: V, initialize: boolean = true): Writable { + let store = writable(initialize ? initial : undefined); + liveQuery(() => table.get(key)).subscribe(value => { + if (value === undefined && !initialize) { + store.set(initial); + } else if (value !== undefined) { store.set(value); } }); @@ -47,36 +52,20 @@ function dexieSettingStore(setting: string, initial: T): Writable { subscribe: store.subscribe, set: (value: any) => { if (typeof value === 'object' || value !== get(store)) { - db.settings.put(value, setting); + table.put(value, key); } }, update: (callback: (value: any) => any) => { let newValue = callback(get(store)); if (typeof newValue === 'object' || newValue !== get(store)) { - db.settings.put(newValue, setting); + table.put(newValue, key); } } }; } -// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB -function dexieUninitializedSettingStore(setting: string, initial: any): Writable { - let store = writable(undefined); - liveQuery(() => db.settings.get(setting)).subscribe(value => { - if (value !== undefined) { - store.set(value); - } else { - store.set(initial); - } - }); - return { - subscribe: store.subscribe, - set: (value: any) => db.settings.put(value, setting), - update: (callback: (value: any) => any) => { - let newValue = callback(get(store)); - db.settings.put(newValue, setting); - } - }; +export function dexieSettingStore(key: string, initial: T, initialize: boolean = true): Writable { + return bidirectionalDexieStore(db.settings, key, initial, initialize); } export const settings = { @@ -94,7 +83,7 @@ export const settings = { currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap), previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap), selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree), - currentOverlays: dexieUninitializedSettingStore('currentOverlays', defaultOverlays), + currentOverlays: dexieSettingStore('currentOverlays', defaultOverlays, false), previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays), selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree), opacities: dexieSettingStore('opacities', defaultOpacities), @@ -108,7 +97,7 @@ export const settings = { defaultWeight: dexieSettingStore('defaultWeight', 5), bottomPanelSize: dexieSettingStore('bottomPanelSize', 170), rightPanelSize: dexieSettingStore('rightPanelSize', 240), - showWelcomeMessage: dexieUninitializedSettingStore('showWelcomeMessage', true), + showWelcomeMessage: dexieSettingStore('showWelcomeMessage', true, false), }; // Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber