Files
gpx.studio/website/src/lib/components/layer-control/OverpassLayer.ts

222 lines
7.3 KiB
TypeScript
Raw Normal View History

2024-07-16 23:57:17 +02:00
import SphericalMercator from "@mapbox/sphericalmercator";
import { getLayers } from "./utils";
import mapboxgl from "mapbox-gl";
import { get, writable } from "svelte/store";
import { liveQuery } from "dexie";
2024-07-17 14:19:04 +02:00
import { db, settings } from "$lib/db";
import { overpassIcons, overpassQueries } from "$lib/assets/layers";
2024-07-16 23:57:17 +02:00
2024-07-17 14:19:04 +02:00
const {
currentOverpassQueries
} = settings;
2024-07-16 23:57:17 +02:00
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 {
2024-07-17 14:19:04 +02:00
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
2024-07-16 23:57:17 +02:00
minZoom = 12;
2024-07-17 14:19:04 +02:00
queryZoom = 12;
2024-07-16 23:57:17 +02:00
map: mapboxgl.Map;
currentQueries: Set<string> = 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));
2024-07-17 14:19:04 +02:00
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
this.updateBinded();
this.queryIfNeededBinded();
}));
2024-07-16 23:57:17 +02:00
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() {
2024-07-17 14:19:04 +02:00
this.loadIcons();
2024-07-16 23:57:17 +02:00
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: {
2024-07-17 14:19:04 +02:00
'icon-image': ['get', 'query'],
'icon-size': 0.25,
2024-07-16 23:57:17 +02:00
},
});
}
2024-07-17 14:19:04 +02:00
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
2024-07-16 23:57:17 +02:00
} 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]) {
2024-07-17 14:19:04 +02:00
let queries = getCurrentQueries();
if (queries.length === 0) {
return;
}
2024-07-16 23:57:17 +02:00
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;
}
2024-07-17 14:19:04 +02:00
db.overpassquerytiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
2024-07-16 23:57:17 +02:00
}
});
}
}
}
2024-07-17 14:19:04 +02:00
queryTile(x: number, y: number, queries: string[]) {
2024-07-16 23:57:17 +02:00
this.currentQueries.add(`${x},${y}`);
const bounds = mercator.bbox(x, y, this.queryZoom);
2024-07-17 14:19:04 +02:00
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
2024-07-16 23:57:17 +02:00
.then((response) => response.json())
2024-07-17 14:19:04 +02:00
.then((data) => this.storeOverpassData(x, y, queries, data));
2024-07-16 23:57:17 +02:00
}
2024-07-17 14:19:04 +02:00
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let queryTiles = queries.map((query) => ({ x, y, query }));
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
2024-07-16 23:57:17 +02:00
for (let element of data.elements) {
2024-07-17 14:19:04 +02:00
for (let query of queries) {
if (belongsToQuery(element, query)) {
2024-07-16 23:57:17 +02:00
pois.push({
2024-07-17 14:19:04 +02:00
query,
2024-07-16 23:57:17 +02:00
id: element.id,
poi: {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [element.lon, element.lat],
},
2024-07-17 14:19:04 +02:00
properties: { query, tags: element.tags },
2024-07-16 23:57:17 +02:00
}
});
break;
}
}
}
2024-07-17 14:19:04 +02:00
db.transaction('rw', db.overpassquerytiles, db.overpassdata, async () => {
await db.overpassquerytiles.bulkPut(queryTiles);
2024-07-16 23:57:17 +02:00
await db.overpassdata.bulkPut(pois);
});
this.currentQueries.delete(`${x},${y}`);
}
2024-07-17 14:19:04 +02:00
loadIcons() {
let currentQueries = getCurrentQueries();
currentQueries.forEach((query) => {
if (!this.map.hasImage(query)) {
let icon = new Image(100, 100);
icon.onload = () => this.map.addImage(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="${overpassIcons[query].color}" />
<g transform="translate(8 8)">
${overpassIcons[query].svg.replace('stroke="currentColor"', 'stroke="white"')}
</g>
</svg>
`);
}
});
}
2024-07-16 23:57:17 +02:00
}
2024-07-17 14:19:04 +02:00
function getQueryForBounds(bounds: [number, number, number, number], queries: string[]) {
return `[bbox:${bounds[1]},${bounds[0]},${bounds[3]},${bounds[2]}][out:json];(${getQueries(queries)});out;`;
2024-07-16 23:57:17 +02:00
}
2024-07-17 14:19:04 +02:00
function getQueries(queries: string[]) {
return queries.map((query) => `node${getQuery(query)};`).join('');
2024-07-16 23:57:17 +02:00
}
2024-07-17 14:19:04 +02:00
function getQuery(query: string) {
return Object.entries(overpassQueries[query])
2024-07-16 23:57:17 +02:00
.map(([tag, value]) => value ? `[${tag}=${value}]` : `[${tag}]`)
.join('');
}
2024-07-17 14:19:04 +02:00
function belongsToQuery(element: any, query: string) {
return Object.entries(overpassQueries[query])
2024-07-16 23:57:17 +02:00
.every(([tag, value]) => value ? element.tags[tag] === value : element.tags[tag]);
2024-07-17 14:19:04 +02:00
}
function getCurrentQueries() {
let currentQueries = get(currentOverpassQueries);
if (currentQueries === undefined) {
return [];
}
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
2024-07-16 23:57:17 +02:00
}