From 13d70e71cb5ac238f2e23b4c44211e319db91ddb Mon Sep 17 00:00:00 2001 From: Pascal Syma Date: Tue, 19 Mar 2024 21:03:07 +0100 Subject: [PATCH] Rework --- src/ExclusionCircle.ts | 81 ++++++++++++++++++++------- src/main.ts | 123 ++++++++++++++++++++++++++++------------- src/overpass.ts | 22 ++++++-- 3 files changed, 162 insertions(+), 64 deletions(-) diff --git a/src/ExclusionCircle.ts b/src/ExclusionCircle.ts index d1c6d1c..cacb6a4 100644 --- a/src/ExclusionCircle.ts +++ b/src/ExclusionCircle.ts @@ -1,37 +1,80 @@ -import * as L from 'leaflet'; -import { BannedArea, BannedAreas, NWRElement, tagsToHTML } from './overpass.ts'; +import { + BannedArea, + BannedAreas, + isNode, + isRelation, + isWay, + NWRElement, + tagsToHTML +} from './overpass.ts'; +import { Circle, FeatureGroup, Polygon } from 'leaflet'; -export class ExclusionCircle extends L.Circle { +export class ExclusionCircle extends FeatureGroup { readonly id: string; readonly element: NWRElement; readonly reason: BannedArea; - constructor(e: NWRElement, options?: L.CircleOptions) { - const latLng: [number, number] = - e.type === 'node' - ? [e.lat, e.lon] - : [ - (e.bounds.maxlat + e.bounds.minlat) / 2, - (e.bounds.maxlon + e.bounds.minlon) / 2 - ]; + constructor(e: NWRElement) { + super(); const reason = getReason(e); - super(latLng, { - radius: 100, - stroke: false, - fillOpacity: 0.3, - fillColor: colorMap[reason], - ...options - }); + const color = colorMap[getReason(e)]; + switch (e.type) { + case 'node': + this.addLayer(this.getCircle(e, color)); + break; + case 'way': + if (e.geometry.length > 3) { + this.addLayer(this.getPolygon(e.geometry, color)); + } else { + this.addLayer(this.getCircle(e.geometry[0], color)); + } + break; + case 'relation': + e.members + .filter((m) => !isRelation(m)) + .forEach((m) => { + if (isWay(m)) { + m.geometry.length > 3 + ? this.addLayer(this.getPolygon(m.geometry, color)) + : this.addLayer(this.getCircle(m.geometry[0], color)); + } else if (isNode(m)) { + this.addLayer(this.getCircle(m, color)); + } + }); + break; + } this.id = e.id; this.element = e; this.reason = reason; this.bindPopup( - `
${tagsToHTML({ reason, ...e.tags })}
`, + `
${tagsToHTML({ reason, ...e.tags })}↗ Auf OpenStreetMap öffnen
`, { maxWidth: 500 } ); } + + private getPolygon(g: { lat: number; lon: number }[], fillColor: string) { + return new Polygon( + g.map(({ lat, lon }) => ({ lat, lng: lon })), + { + fillColor, + fillOpacity: 0.4, + fill: true, + stroke: false + } + ); + } + + private getCircle(g: { lat: number; lon: number }, fillColor: string) { + return new Circle([g.lat, g.lon], { + radius: 10, + fillColor, + fillOpacity: 0.4, + fill: true, + stroke: false + }); + } } export const colorMap: { [reason in BannedArea]: string } = { diff --git a/src/main.ts b/src/main.ts index af8a1be..5122f57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,15 +6,21 @@ import { FeatureGroup, GeoJSON, Map as LMap, - Polygon, Popup, TileLayer } from 'leaflet'; import 'leaflet-easybutton'; import { BannedArea, BannedAreas, NWRElement } from './overpass.ts'; -import { colorMap, ExclusionCircle, getReason } from './ExclusionCircle.ts'; -import { buffer, dissolve, featureCollection } from '@turf/turf'; -import { Feature } from '@turf/helpers'; +import { colorMap, ExclusionCircle } from './ExclusionCircle.ts'; +import { + buffer, + dissolve, + featureCollection, + FeatureCollection, + truncate +} from '@turf/turf'; +// @ts-ignore +import type { Feature } from '@turf/helpers'; const m_mono = new TileLayer( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', @@ -31,13 +37,22 @@ const features: { [reason in BannedArea]: FeatureGroup } = { [BannedAreas.PEDESTRIAN]: new FeatureGroup(), [BannedAreas.OTHER]: new FeatureGroup() }; +const exclusionZone = new FeatureGroup(); -const center = [53.0552419, 8.7712428] as [number, number]; +const buffers: { [reason in BannedArea]: (FeatureCollection | Feature)[] } = { + [BannedAreas.SCHOOL]: [], + [BannedAreas.UNIVERSITY]: [], + [BannedAreas.SPORT]: [], + [BannedAreas.PEDESTRIAN]: [], + [BannedAreas.OTHER]: [] +}; + +const center = [53.0711829, 8.8087718] as [number, number]; const map = new LMap('map', { center, - zoom: 19, + zoom: 14, zoomControl: true, - layers: [m_mono, ...Object.values(features)] + layers: [m_mono, exclusionZone, ...Object.values(features)] }); new Popup() @@ -54,13 +69,51 @@ map.on('moveend', async () => { await fetchData(); }); let loading = false; -const areas = new FeatureGroup(); -let exclusionZone: GeoJSON | undefined; + +function showBuffer() { + console.time('prepare'); + map.attributionControl.setPrefix('Berechne Zone'); + const fs = Object.entries(buffers) + .filter(([k]) => map.hasLayer(features[k as keyof typeof features])) + .flatMap(([_, buff]) => { + const fs: Feature[] = buff.flatMap((f) => + f.type === 'Feature' ? [f] : f.features + ); + return fs; + }); + + if (fs.length > 0) { + exclusionZone.clearLayers(); + console.timeEnd('prepare'); + try { + console.time('dissolve'); + const b = dissolve(featureCollection(fs)); + const zone = new GeoJSON(b, { + style: { color: 'red', fillOpacity: 0.1, weight: 1 } + }); + console.timeEnd('dissolve'); + console.time('render'); + exclusionZone.addLayer(zone); + console.timeEnd('render'); + exclusionZone.bringToBack(); + } catch (err) { + console.error('dissolve', err); + } + } + map.attributionControl.setPrefix(''); +} + +Object.values(features).forEach((f) => { + f.on('add', showBuffer); + f.on('remove', showBuffer); +}); + async function fetchData() { if (map.getZoom() < 12 || loading) { return; } loading = true; + map.attributionControl.setPrefix('Lädt...'); const bounds = map.getBounds(); const query = `[out:json][timeout:25][bbox:${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}]; ( @@ -91,55 +144,47 @@ out geom;`; .catch(() => ({ elements: [] })) .then((r) => r.elements as NWRElement[]); + loading = false; + console.time('parse'); + map.attributionControl.setPrefix('Berechne Orte...'); const ids = elements.map((e) => e.id); - const exclude: string[] = []; + const exclude: ExclusionCircle[] = []; + Object.values(buffers).forEach((b) => b.splice(0)); Object.values(features).forEach((f) => f.eachLayer((l) => { const id = (l as ExclusionCircle).id; if (ids.includes(id)) { - exclude.push(id); + exclude.push(l as ExclusionCircle); return; } f.removeLayer(l); }) ); - const buffers: Feature[] = []; elements.forEach((e) => { - if (exclude.includes(e.id)) { - return; + let marker = exclude.find((it) => it.id === e.id); + if (!marker) { + marker = new ExclusionCircle(e); } - const marker = new ExclusionCircle(e); - features[marker.reason].addLayer(marker); - if (e.type === 'way') { - const area = new Polygon( - e.geometry.map(({ lat, lon }) => ({ lat, lng: lon })), - { - fillColor: colorMap[getReason(e)], - fillOpacity: 0.4, - fill: true, - stroke: false - } - ); - area.addTo(map); - const b = buffer(area.toGeoJSON(), 100, { units: 'meters' }); + features[marker.reason].addLayer(marker); + try { + const b = buffer(marker.toGeoJSON(), 100, { units: 'meters' }); // new GeoJSON(b).addTo(map); - buffers.push(b); + buffers[marker.reason].push(truncate(b)); + } catch (err) { + console.error( + `Buffer calc for id=${e.id}, type=${e.type} failed`, + e, + err + ); } }); + console.timeEnd('parse'); - // const forbidden = buffers.reduce((u, b) => union(u, b), buffers.pop()); - // new GeoJSON(forbidden).addTo(map); - const b = dissolve(featureCollection(buffers)); - exclusionZone?.removeFrom(map); - exclusionZone = new GeoJSON(b, { - style: { color: 'red', fillOpacity: 0.1, weight: 1 } - }); - exclusionZone.addTo(map); - - loading = false; + queueMicrotask(showBuffer); + map.attributionControl.setPrefix(''); } new Control.Layers( diff --git a/src/overpass.ts b/src/overpass.ts index ba3e56a..9cd931f 100644 --- a/src/overpass.ts +++ b/src/overpass.ts @@ -29,16 +29,26 @@ export interface RelationElement extends APIElement { maxlat: number; maxlon: number; }; - members: { - type: 'way'; - ref: number; - role: string; - geometry: { lat: number; lon: number }[]; - }[]; + members: (Omit< + NodeElement | WayElement | Omit, + 'id' | 'tags' | 'bounds' + > & { role: string })[]; } export type NWRElement = Readonly; +export function isWay(e: { type: NWRElement['type'] }): e is WayElement { + return e.type === 'way'; +} +export function isNode(e: { type: NWRElement['type'] }): e is NodeElement { + return e.type === 'node'; +} +export function isRelation(e: { + type: NWRElement['type']; +}): e is RelationElement { + return e.type === 'relation'; +} + export function tagsToHTML(tags: APIElement['tags']) { const entries = Object.entries(tags);