This commit is contained in:
2024-03-19 21:03:07 +01:00
parent 9070c26bff
commit 13d70e71cb
3 changed files with 162 additions and 64 deletions
+61 -18
View File
@@ -1,37 +1,80 @@
import * as L from 'leaflet'; import {
import { BannedArea, BannedAreas, NWRElement, tagsToHTML } from './overpass.ts'; 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<Polygon | Circle> {
readonly id: string; readonly id: string;
readonly element: NWRElement; readonly element: NWRElement;
readonly reason: BannedArea; readonly reason: BannedArea;
constructor(e: NWRElement, options?: L.CircleOptions) { constructor(e: NWRElement) {
const latLng: [number, number] = super();
e.type === 'node'
? [e.lat, e.lon]
: [
(e.bounds.maxlat + e.bounds.minlat) / 2,
(e.bounds.maxlon + e.bounds.minlon) / 2
];
const reason = getReason(e); const reason = getReason(e);
super(latLng, { const color = colorMap[getReason(e)];
radius: 100, switch (e.type) {
stroke: false, case 'node':
fillOpacity: 0.3, this.addLayer(this.getCircle(e, color));
fillColor: colorMap[reason], break;
...options 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.id = e.id;
this.element = e; this.element = e;
this.reason = reason; this.reason = reason;
this.bindPopup( this.bindPopup(
`<div class="whitespace-pre-wrap">${tagsToHTML({ reason, ...e.tags })}</div>`, `<div class="whitespace-pre-wrap flex flex-col gap-3">${tagsToHTML({ reason, ...e.tags })}<a href="https://www.openstreetmap.org/${e.type}/${e.id}" target="_blank">↗ Auf OpenStreetMap öffnen</a></div>`,
{ maxWidth: 500 } { 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 } = { export const colorMap: { [reason in BannedArea]: string } = {
+84 -39
View File
@@ -6,15 +6,21 @@ import {
FeatureGroup, FeatureGroup,
GeoJSON, GeoJSON,
Map as LMap, Map as LMap,
Polygon,
Popup, Popup,
TileLayer TileLayer
} from 'leaflet'; } from 'leaflet';
import 'leaflet-easybutton'; import 'leaflet-easybutton';
import { BannedArea, BannedAreas, NWRElement } from './overpass.ts'; import { BannedArea, BannedAreas, NWRElement } from './overpass.ts';
import { colorMap, ExclusionCircle, getReason } from './ExclusionCircle.ts'; import { colorMap, ExclusionCircle } from './ExclusionCircle.ts';
import { buffer, dissolve, featureCollection } from '@turf/turf'; import {
import { Feature } from '@turf/helpers'; buffer,
dissolve,
featureCollection,
FeatureCollection,
truncate
} from '@turf/turf';
// @ts-ignore
import type { Feature } from '@turf/helpers';
const m_mono = new TileLayer( const m_mono = new TileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -31,13 +37,22 @@ const features: { [reason in BannedArea]: FeatureGroup<ExclusionCircle> } = {
[BannedAreas.PEDESTRIAN]: new FeatureGroup(), [BannedAreas.PEDESTRIAN]: new FeatureGroup(),
[BannedAreas.OTHER]: new FeatureGroup() [BannedAreas.OTHER]: new FeatureGroup()
}; };
const exclusionZone = new FeatureGroup<GeoJSON>();
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', { const map = new LMap('map', {
center, center,
zoom: 19, zoom: 14,
zoomControl: true, zoomControl: true,
layers: [m_mono, ...Object.values(features)] layers: [m_mono, exclusionZone, ...Object.values(features)]
}); });
new Popup() new Popup()
@@ -54,13 +69,51 @@ map.on('moveend', async () => {
await fetchData(); await fetchData();
}); });
let loading = false; 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() { async function fetchData() {
if (map.getZoom() < 12 || loading) { if (map.getZoom() < 12 || loading) {
return; return;
} }
loading = true; loading = true;
map.attributionControl.setPrefix('Lädt...');
const bounds = map.getBounds(); const bounds = map.getBounds();
const query = `[out:json][timeout:25][bbox:${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}]; const query = `[out:json][timeout:25][bbox:${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}];
( (
@@ -91,55 +144,47 @@ out geom;`;
.catch(() => ({ elements: [] })) .catch(() => ({ elements: [] }))
.then((r) => r.elements as NWRElement[]); .then((r) => r.elements as NWRElement[]);
loading = false;
console.time('parse');
map.attributionControl.setPrefix('Berechne Orte...');
const ids = elements.map((e) => e.id); 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) => Object.values(features).forEach((f) =>
f.eachLayer((l) => { f.eachLayer((l) => {
const id = (l as ExclusionCircle).id; const id = (l as ExclusionCircle).id;
if (ids.includes(id)) { if (ids.includes(id)) {
exclude.push(id); exclude.push(l as ExclusionCircle);
return; return;
} }
f.removeLayer(l); f.removeLayer(l);
}) })
); );
const buffers: Feature[] = [];
elements.forEach((e) => { elements.forEach((e) => {
if (exclude.includes(e.id)) { let marker = exclude.find((it) => it.id === e.id);
return; if (!marker) {
marker = new ExclusionCircle(e);
} }
const marker = new ExclusionCircle(e);
features[marker.reason].addLayer(marker);
if (e.type === 'way') { features[marker.reason].addLayer(marker);
const area = new Polygon( try {
e.geometry.map(({ lat, lon }) => ({ lat, lng: lon })), const b = buffer(marker.toGeoJSON(), 100, { units: 'meters' });
{
fillColor: colorMap[getReason(e)],
fillOpacity: 0.4,
fill: true,
stroke: false
}
);
area.addTo(map);
const b = buffer(area.toGeoJSON(), 100, { units: 'meters' });
// new GeoJSON(b).addTo(map); // 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()); queueMicrotask(showBuffer);
// new GeoJSON(forbidden).addTo(map); map.attributionControl.setPrefix('');
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;
} }
new Control.Layers( new Control.Layers(
+16 -6
View File
@@ -29,16 +29,26 @@ export interface RelationElement extends APIElement {
maxlat: number; maxlat: number;
maxlon: number; maxlon: number;
}; };
members: { members: (Omit<
type: 'way'; NodeElement | WayElement | Omit<RelationElement, 'members'>,
ref: number; 'id' | 'tags' | 'bounds'
role: string; > & { role: string })[];
geometry: { lat: number; lon: number }[];
}[];
} }
export type NWRElement = Readonly<NodeElement | WayElement | RelationElement>; export type NWRElement = Readonly<NodeElement | WayElement | RelationElement>;
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']) { export function tagsToHTML(tags: APIElement['tags']) {
const entries = Object.entries(tags); const entries = Object.entries(tags);