514 lines
20 KiB
JavaScript
514 lines
20 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
// Polyfill for shpjs which expects self.DecompressionStream
|
|
global.self = global;
|
|
const proj4 = require('proj4');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const https = require('https');
|
|
|
|
const app = express();
|
|
const PORT = 3000;
|
|
|
|
app.use(cors());
|
|
app.use(express.static('dist'));
|
|
|
|
// Define Projections
|
|
proj4.defs("EPSG:25832", "+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
|
|
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
|
|
|
|
const sourceProj = "EPSG:25832";
|
|
const destProj = "EPSG:4326";
|
|
|
|
const ALKIS_WFS_URL = 'https://www.wfs.nrw.de/geobasis/wfs_nw_alkis_vereinfacht';
|
|
const SHP_PATH = path.join(__dirname, 'public/Daten/260130_Wohngebäude.shp');
|
|
|
|
app.get('/api/buildings', async (req, res) => {
|
|
let { bbox, limit } = req.query;
|
|
|
|
// Quick test endpoint via browser simply loading /api/buildings
|
|
if (!bbox) return res.send('Worker is ready. Use ?bbox=minLon,minLat,maxLon,maxLat');
|
|
|
|
const bounds = bbox.split(',').map(Number);
|
|
if (bounds.length !== 4) return res.status(400).json({ error: 'Invalid bbox' });
|
|
|
|
console.time("Query");
|
|
|
|
// 1. Project Query BBOX to UTM32
|
|
const corners = [
|
|
[bounds[0], bounds[1]], [bounds[2], bounds[3]],
|
|
[bounds[0], bounds[3]], [bounds[2], bounds[1]]
|
|
].map(c => proj4(destProj, sourceProj, c));
|
|
|
|
const minX = Math.min(...corners.map(c => c[0]));
|
|
const maxX = Math.max(...corners.map(c => c[0]));
|
|
const minY = Math.min(...corners.map(c => c[1]));
|
|
const maxY = Math.max(...corners.map(c => c[1]));
|
|
|
|
const queryBbox = [minX - 50, minY - 50, maxX + 50, maxY + 50];
|
|
const maxFeatures = limit ? parseInt(limit) : 2000;
|
|
const results = [];
|
|
|
|
// 2. Buffered Scanning
|
|
const BUFFER_SIZE = 10 * 1024 * 1024; // 10MB chunk
|
|
|
|
// We open the file and read large chunks.
|
|
// We maintain a "buffer cursor" and a "file position".
|
|
|
|
let fd;
|
|
try {
|
|
fd = fs.openSync(SHP_PATH, 'r');
|
|
const fileSize = fs.statSync(SHP_PATH).size;
|
|
|
|
let buf = Buffer.alloc(BUFFER_SIZE);
|
|
// Initial Read: Skip 100 bytes header
|
|
let filePos = 100;
|
|
let bytesRead = fs.readSync(fd, buf, 0, BUFFER_SIZE, filePos);
|
|
let bufPos = 0; // Position within current buffer
|
|
|
|
while (filePos < fileSize && results.length < maxFeatures && bytesRead > 0) {
|
|
// Need at least 8 bytes for Record Header
|
|
if (bufPos + 8 > bytesRead) {
|
|
// Not enough bytes left in buffer for header.
|
|
// Move remaining to start, read more.
|
|
// Or simply: Refill logic.
|
|
// Simplest: If near end, just read a new chunk from filePos.
|
|
// But filePos is tricky if we processed partial buffer.
|
|
// Let's track absolute `currentRecordPos` = filePos + bufPos
|
|
}
|
|
|
|
// SIMPLER LOGIC:
|
|
// Valid data is in buf[bufPos ... bytesRead]
|
|
const remaining = bytesRead - bufPos;
|
|
|
|
if (remaining < 8) {
|
|
// Fetch next chunk
|
|
// Determine where we are in file: filePos + bufPos
|
|
filePos += bufPos;
|
|
bytesRead = fs.readSync(fd, buf, 0, BUFFER_SIZE, filePos);
|
|
bufPos = 0;
|
|
if (bytesRead < 8) break; // EOF
|
|
}
|
|
|
|
// Read Record Header (8 bytes)
|
|
// Record Num (4 bytes BE) - ignore
|
|
const recLenWords = buf.readInt32BE(bufPos + 4);
|
|
const recLenBytes = recLenWords * 2;
|
|
|
|
// Check if we have the full record content in buffer
|
|
// 8 header + recLenBytes content
|
|
const totalRecSize = 8 + recLenBytes;
|
|
|
|
if (bufPos + totalRecSize > bytesRead) {
|
|
// Record spans across buffer boundary (or is larger than buffer remaining)
|
|
// Check if it's larger than entire buffer?
|
|
if (totalRecSize > BUFFER_SIZE) {
|
|
// Huge record. Rare for buildings. Skip or alloc special.
|
|
// Just skip logic:
|
|
filePos += bufPos + totalRecSize;
|
|
// Refill
|
|
bytesRead = fs.readSync(fd, buf, 0, BUFFER_SIZE, filePos);
|
|
bufPos = 0;
|
|
continue;
|
|
}
|
|
|
|
// Move pointer to filePos += bufPos
|
|
filePos += bufPos;
|
|
// Read fresh chunk starting at this record
|
|
bytesRead = fs.readSync(fd, buf, 0, BUFFER_SIZE, filePos);
|
|
bufPos = 0;
|
|
|
|
if (bytesRead < totalRecSize) break; // Should not happen unless EOF
|
|
}
|
|
|
|
const recOffset = filePos + bufPos;
|
|
|
|
// Now buf[bufPos] is start of header.
|
|
// buf[bufPos+8] is start of content.
|
|
const contentOffset = bufPos + 8;
|
|
|
|
// Parse BBox (32 bytes) at Content+4 (Type)
|
|
// Type is at contentOffset (4 bytes LE)
|
|
const type = buf.readInt32LE(contentOffset);
|
|
|
|
// BBox: offset + 4
|
|
const bXmin = buf.readDoubleLE(contentOffset + 4);
|
|
const bYmin = buf.readDoubleLE(contentOffset + 12);
|
|
const bXmax = buf.readDoubleLE(contentOffset + 20);
|
|
const bYmax = buf.readDoubleLE(contentOffset + 28);
|
|
|
|
const disjoint = (bXmin > queryBbox[2] || bXmax < queryBbox[0] || bYmin > queryBbox[3] || bYmax < queryBbox[1]);
|
|
|
|
if (!disjoint) {
|
|
// Match! Parse Geometry.
|
|
// Only if Type 5 (Polygon)
|
|
if (type === 5) {
|
|
const numParts = buf.readInt32LE(contentOffset + 36);
|
|
const numPoints = buf.readInt32LE(contentOffset + 40);
|
|
|
|
if (numPoints > 0 && numPoints < 10000) {
|
|
const parts = [];
|
|
for (let i = 0; i < numParts; i++) {
|
|
parts.push(buf.readInt32LE(contentOffset + 44 + i * 4));
|
|
}
|
|
parts.push(numPoints);
|
|
|
|
const pointsOffset = contentOffset + 44 + numParts * 4;
|
|
const coords = [];
|
|
|
|
for (let p = 0; p < numParts; p++) {
|
|
const start = parts[p];
|
|
const end = parts[p + 1];
|
|
const ring = [];
|
|
for (let k = start; k < end; k++) {
|
|
const px = buf.readDoubleLE(pointsOffset + k * 16);
|
|
const py = buf.readDoubleLE(pointsOffset + k * 16 + 8);
|
|
const [lon, lat] = proj4(sourceProj, destProj, [px, py]);
|
|
ring.push([lon, lat]);
|
|
}
|
|
coords.push(ring);
|
|
}
|
|
|
|
results.push({
|
|
type: "Feature",
|
|
properties: { id: "SHP_" + recOffset },
|
|
geometry: { type: "Polygon", coordinates: coords }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Advance
|
|
bufPos += totalRecSize;
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error("Fehler beim Scannen des Buffers:", e);
|
|
} finally {
|
|
if (fd) fs.closeSync(fd);
|
|
}
|
|
|
|
console.timeEnd("Query");
|
|
console.log(`BBox-Abfrage fand ${results.length} Gebäude`);
|
|
res.json({ type: "FeatureCollection", features: results });
|
|
});
|
|
|
|
/**
|
|
* API: Ruft detaillierte ALKIS-Informationen für einen Punkt ab (GML-Parsing)
|
|
*/
|
|
app.get('/api/alkis-info', async (req, res) => {
|
|
const { lat, lng } = req.query;
|
|
if (!lat || !lng) return res.status(400).json({ error: 'Koordinaten fehlen' });
|
|
|
|
const lats = parseFloat(lat);
|
|
const lngs = parseFloat(lng);
|
|
const size = 0.0001;
|
|
const bbox = `${lats - size},${lngs - size},${lats + size},${lngs + size},urn:ogc:def:crs:EPSG::4326`;
|
|
const url = `${ALKIS_WFS_URL}?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature&TYPENAMES=ave:Flurstueck&BBOX=${bbox}&COUNT=1`;
|
|
|
|
https.get(url, (wfsRes) => {
|
|
let data = '';
|
|
wfsRes.on('data', (chunk) => data += chunk);
|
|
wfsRes.on('end', () => {
|
|
try {
|
|
// Einfaches Regex-basiertes Parsing für GML (da kein schwerer XML-Parser installiert ist)
|
|
const getTag = (tag) => {
|
|
const match = data.match(new RegExp(`<${tag}[^>]*>(.*?)<\/${tag}>`, 's'));
|
|
return match ? match[1].trim() : null;
|
|
};
|
|
|
|
const info = {
|
|
gemarkung: getTag('gemarkung'),
|
|
gemeinde: getTag('gemeinde'),
|
|
kreis: getTag('kreis'),
|
|
regbezirk: getTag('regbezirk'),
|
|
flur: getTag('flur'),
|
|
flstnrzae: getTag('flstnrzae'),
|
|
flstnrnen: getTag('flstnrnen'),
|
|
flaeche: getTag('flaeche'),
|
|
aktualit: getTag('aktualit'),
|
|
lagebeztxt: getTag('lagebeztxt'),
|
|
flstkennz: getTag('flstkennz')
|
|
};
|
|
|
|
if (!info.flstkennz) {
|
|
return res.json({ found: false });
|
|
}
|
|
|
|
res.json({ found: true, properties: info });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Parsing Fehler', details: err.message });
|
|
}
|
|
});
|
|
}).on('error', (e) => {
|
|
res.status(500).json({ error: 'WFS Fehler', details: e.message });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* API: Ruft ALKIS-Nutzungsinformationen für einen Punkt ab
|
|
*/
|
|
app.get('/api/alkis-usage', async (req, res) => {
|
|
const { lat, lng } = req.query;
|
|
if (!lat || !lng) return res.status(400).json({ error: 'Koordinaten fehlen' });
|
|
|
|
const lats = parseFloat(lat);
|
|
const lngs = parseFloat(lng);
|
|
const size = 0.00001; // Sehr kleiner BBOX für präzise Punkt-Abfrage (~1m)
|
|
const bbox = `${lats - size},${lngs - size},${lats + size},${lngs + size},urn:ogc:def:crs:EPSG::4326`;
|
|
const url = `${ALKIS_WFS_URL}?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature&TYPENAMES=ave:Nutzung&BBOX=${bbox}&COUNT=1`;
|
|
|
|
https.get(url, (wfsRes) => {
|
|
let data = '';
|
|
wfsRes.on('data', (chunk) => data += chunk);
|
|
wfsRes.on('end', () => {
|
|
try {
|
|
// Regex-Parsing für nutzart und bez
|
|
const nutzartMatch = data.match(/<nutzart[^>]*>(.*?)<\/nutzart>/s);
|
|
const bezMatch = data.match(/<bez[^>]*>(.*?)<\/bez>/s);
|
|
|
|
const nutzart = nutzartMatch ? nutzartMatch[1].trim() : null;
|
|
const bez = bezMatch ? bezMatch[1].trim() : null;
|
|
|
|
if (!nutzart && !bez) {
|
|
return res.json({ found: false });
|
|
}
|
|
|
|
res.json({ found: true, nutzart, bez });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Parsing Fehler', details: err.message });
|
|
}
|
|
});
|
|
}).on('error', (e) => {
|
|
res.status(500).json({ error: 'WFS Fehler', details: e.message });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* API: Ruft Windenergieanlagen in der Nähe ab (WFS Proxy)
|
|
*/
|
|
app.get('/api/wind-turbines', async (req, res) => {
|
|
const { lat, lng } = req.query;
|
|
if (!lat || !lng) return res.status(400).json({ error: 'Koordinaten fehlen' });
|
|
|
|
const lats = parseFloat(lat);
|
|
const lngs = parseFloat(lng);
|
|
const size = 0.05; // ~5.5km BBOX für absolute Sicherheit
|
|
const bbox = `${lats - size},${lngs - size},${lats + size},${lngs + size},urn:ogc:def:crs:EPSG::4326`;
|
|
|
|
// Wir nutzen WFS 2.0.0 da dieser stabil Daten liefert
|
|
const url = `https://www.wfs.nrw.de/umwelt/erneuerbare_energien_wfs?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature&TYPENAMES=erneuerbare_energien_wfs:WFS_Windenergie&BBOX=${bbox}&COUNT=100`;
|
|
|
|
console.log(`[WindProxy] Fetching: ${url}`);
|
|
|
|
https.get(url, (wfsRes) => {
|
|
console.log(`[WindProxy] Status: ${wfsRes.statusCode}`);
|
|
let data = '';
|
|
wfsRes.on('data', (chunk) => data += chunk);
|
|
wfsRes.on('end', () => {
|
|
try {
|
|
if (data.includes('ExceptionReport')) {
|
|
console.error(`[WindProxy] WFS Exception: ${data.substring(0, 500)}`);
|
|
return res.status(500).json({ error: 'WFS Exception', details: 'Der Dienst hat einen Fehler gemeldet.' });
|
|
}
|
|
|
|
const features = [];
|
|
// Robustes Splitten (Regex handles attributes in member tag)
|
|
const blocks = data.split(/<wfs:member[^>]*>/);
|
|
|
|
for (let i = 1; i < blocks.length; i++) {
|
|
const block = blocks[i];
|
|
|
|
// Extrahiere UTM Koordinaten aus gml:pos
|
|
const posMatch = block.match(/<gml:pos[^>]*>(.*?)<\/gml:pos>/);
|
|
const ibjahrMatch = block.match(/<erneuerbare_energien_wfs:ibjahr[^>]*>(.*?)<\/erneuerbare_energien_wfs:ibjahr>/);
|
|
|
|
if (posMatch) {
|
|
const coords = posMatch[1].trim().split(/\s+/).map(Number);
|
|
const [lon, lat] = proj4(sourceProj, destProj, [coords[0], coords[1]]);
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: [lon, lat] // [Lon, Lat] in WGS84
|
|
},
|
|
properties: {
|
|
ibjahr: ibjahrMatch ? ibjahrMatch[1].trim() : "Unbekannt"
|
|
}
|
|
});
|
|
}
|
|
}
|
|
console.log(`[WindProxy] Found ${features.length} turbines for ${lat},${lng}`);
|
|
res.json({ features });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Parsing Fehler', details: err.message });
|
|
}
|
|
});
|
|
}).on('error', (e) => {
|
|
res.status(500).json({ error: 'WFS Fehler', details: e.message });
|
|
});
|
|
});
|
|
|
|
// --- Logik für Potenzialflächen & Regionalplanung ---
|
|
const POTENTIAL_ZIP_PATH = path.join(__dirname, 'public/daten/Potenzialkarten-Windenergieanlagen-NRW_EPSG25832_Shape.zip');
|
|
const PLANNING_ZIP_PATH = path.join(__dirname, 'public/daten/Windenergiebereiche_NRW.zip');
|
|
|
|
let potentialFeatures = [];
|
|
let potentialLoaded = false;
|
|
let planningFeatures = [];
|
|
let planningLoaded = false;
|
|
let planningError = null;
|
|
let turf = null;
|
|
|
|
/**
|
|
* Prüft, ob ein Punkt innerhalb eines Polygons liegt (unter Verwendung von Turf.js)
|
|
*/
|
|
function checkPointInFeature(pt, feature) {
|
|
if (!turf) return false;
|
|
if (!feature || !feature.geometry) return false;
|
|
try {
|
|
const point = turf.point(pt); // pt muss [lng, lat] sein
|
|
if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') {
|
|
return turf.booleanPointInPolygon(point, feature);
|
|
}
|
|
} catch (e) { }
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Lädt die notwendigen Bibliotheken und Geodaten beim Serverstart
|
|
*/
|
|
(async () => {
|
|
try {
|
|
console.log("Lade Bibliotheken & Daten...");
|
|
const { default: shp } = await import('shpjs');
|
|
const turfModule = await import('@turf/turf');
|
|
turf = turfModule.default || turfModule;
|
|
|
|
// 1. Regionalplanung (Windenergiebereiche) laden
|
|
if (fs.existsSync(PLANNING_ZIP_PATH)) {
|
|
console.log(`[${new Date().toISOString()}] Lade Planungsdaten von: ${PLANNING_ZIP_PATH}`);
|
|
try {
|
|
const buffer = fs.readFileSync(PLANNING_ZIP_PATH);
|
|
const geojson = await shp(buffer);
|
|
|
|
if (Array.isArray(geojson)) {
|
|
geojson.forEach(fc => { if (fc.features) planningFeatures.push(...fc.features); });
|
|
} else {
|
|
planningFeatures = geojson.features;
|
|
}
|
|
planningLoaded = true;
|
|
console.log(`[${new Date().toISOString()}] Planungsdaten geladen: ${planningFeatures.length} Features.`);
|
|
} catch (err) {
|
|
planningError = err.message;
|
|
console.warn("Konnte Planungs-ZIP nicht laden:", err);
|
|
}
|
|
} else {
|
|
console.warn("Planungs-ZIP nicht gefunden:", PLANNING_ZIP_PATH);
|
|
planningError = "Datei nicht gefunden: " + PLANNING_ZIP_PATH;
|
|
}
|
|
|
|
// 2. Potenzialkarten laden
|
|
if (fs.existsSync(POTENTIAL_ZIP_PATH)) {
|
|
console.log(`[${new Date().toISOString()}] Lade Potenzialdaten...`);
|
|
const buffer = fs.readFileSync(POTENTIAL_ZIP_PATH);
|
|
const geojson = await shp(buffer);
|
|
if (Array.isArray(geojson)) {
|
|
geojson.forEach(fc => { if (fc.features) potentialFeatures.push(...fc.features); });
|
|
} else {
|
|
potentialFeatures = geojson.features;
|
|
}
|
|
potentialLoaded = true;
|
|
console.log(`[${new Date().toISOString()}] Potenzialdaten geladen: ${potentialFeatures.length} Features.`);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error("Fehler beim Laden der Daten / Libs:", e);
|
|
planningError = "Globaler Ladefehler: " + e.message;
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* API: Prüft, ob ein Standort in einer LANUV-Potenzialfläche liegt
|
|
*/
|
|
app.get('/api/check-potential', (req, res) => {
|
|
const { lat, lng } = req.query;
|
|
if (!potentialLoaded) return res.json({ result: 'error', message: 'Daten nicht geladen' });
|
|
if (!lat || !lng) return res.json({ result: 'error', message: 'Koordinaten fehlen' });
|
|
|
|
try {
|
|
const pt = [parseFloat(lng), parseFloat(lat)];
|
|
if (isNaN(pt[0]) || isNaN(pt[1])) throw new Error("Ungültige Koordinaten");
|
|
|
|
let found = false;
|
|
let props = {};
|
|
|
|
for (const f of potentialFeatures) {
|
|
if (checkPointInFeature(pt, f)) {
|
|
found = true;
|
|
props = f.properties;
|
|
break;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
found: found,
|
|
properties: found ? props : null
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.toString() });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* API: Prüft, ob ein Standort in einem Windenergiebereich liegt
|
|
*/
|
|
app.get('/api/check-planning', (req, res) => {
|
|
const { lat, lng } = req.query;
|
|
if (!planningLoaded) return res.json({ found: false, message: 'Daten nicht geladen' });
|
|
if (!lat || !lng) return res.json({ found: false, message: 'Koordinaten fehlen' });
|
|
|
|
try {
|
|
const pt = [parseFloat(lng), parseFloat(lat)];
|
|
let found = false;
|
|
let props = {};
|
|
|
|
for (const f of planningFeatures) {
|
|
if (checkPointInFeature(pt, f)) {
|
|
found = true;
|
|
props = f.properties;
|
|
break;
|
|
}
|
|
}
|
|
|
|
res.json({ found: found, properties: found ? props : null });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.toString() });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* API: Liefert alle Potenzialflächen als GeoJSON
|
|
*/
|
|
app.get('/api/potential-data', (req, res) => {
|
|
if (!potentialLoaded) return res.status(503).json({ error: 'Daten noch nicht bereit' });
|
|
res.json({ type: 'FeatureCollection', features: potentialFeatures });
|
|
});
|
|
|
|
/**
|
|
* API: Liefert alle Windenergiebereiche als GeoJSON
|
|
*/
|
|
app.get('/api/planning-data', (req, res) => {
|
|
if (!planningLoaded) {
|
|
if (planningError) {
|
|
return res.status(500).json({ error: 'Laden der Planungsdaten fehlgeschlagen: ' + planningError });
|
|
}
|
|
return res.status(503).json({ error: 'Daten werden geladen... bitte erneut versuchen.', retry: true });
|
|
}
|
|
res.json({ type: 'FeatureCollection', features: planningFeatures });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Geodaten-Server läuft auf http://localhost:${PORT} (Buffered Mode)`);
|
|
});
|