bwsamern-ohne_standortplaner/app.js

1708 lines
73 KiB
JavaScript

/**
* WindPlaner - Core Logic
*/
document.addEventListener('DOMContentLoaded', async () => {
// Basic App State
const state = {
map: null,
config: null,
turbines: [],
activeVariant: 'A',
bakedData: {}, // Cache for standalone persistence
ownerMapping: null, // { firstName: '', lastName: '' }
ownerStatuses: {}, // { "Name Vorname": "status" }
showAuxiliary: true
};
// Removed fetch for config to prevent CORS errors on file:// protocol
console.log("Konfiguration geladen.");
// Initialize Map
state.map = L.map('map', {
center: [51.5, 7.5], // Center NRW roughly
zoom: 13,
zoomControl: false
});
// Add Zoom Control to the right
L.control.zoom({ position: 'topright' }).addTo(state.map);
// Standard Tile Layer (requires Internet, but we provide it as default)
// In a real offline scenario, this would be a local MBTiles layer or similar.
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(state.map);
// Layer Groups for Variants
const variantLayers = {
'A': L.layerGroup().addTo(state.map),
'B': L.layerGroup(),
'C': L.layerGroup()
};
// UI Elements
const variantTabs = document.querySelectorAll('.variant-tab');
const btnPlaceTurbine = document.getElementById('btnPlaceTurbine');
const btnMeasureDist = document.getElementById('btnMeasureDist');
const btnMeasureArea = document.getElementById('btnMeasureArea');
const editPanel = document.getElementById('editPanel');
const btnCloseEdit = document.getElementById('btnCloseEdit');
const btnSaveEdit = document.getElementById('btnSaveEdit');
const btnDeleteWEA = document.getElementById('btnDeleteWEA');
const editNr = document.getElementById('edit-wea-nr');
const editType = document.getElementById('edit-wea-type');
const editRd = document.getElementById('edit-wea-rd');
const editNh = document.getElementById('edit-wea-nh');
const editFr = document.getElementById('edit-wea-fr');
const editKsfAngle = document.getElementById('edit-wea-ksf-angle');
const editKsfMirrored = document.getElementById('edit-wea-ksf-mirrored');
// Legend Elements
const floatingLegend = document.getElementById('floatingLegend');
const legendContent = document.getElementById('legendContent');
const btnToggleLegend = document.getElementById('btnToggleLegend');
const legendHeader = document.getElementById('legendHeader');
const inputRotor = document.getElementById('rotorDiameter');
const inputHub = document.getElementById('hubHeight');
const inputFoundation = document.getElementById('foundationRadius');
const inputType = document.getElementById('turbineType');
let placementMode = false;
let measureMode = null; // 'dist' or 'area'
let activeTurbine = null;
// Proj4 Definition for UTM32 (EPSG:25832)
const utm32 = "+proj=utm +zone=32 +ellps=GRS80 +units=m +no_defs";
const wgs84 = "+proj=longlat +datum=WGS84 +no_defs";
// Calculation Functions
function calculateGeometries(latlng, rotorDiameter, hubHeight, foundationRadius, ksfAngle = 0, ksfMirrored = false) {
// Ensure valid numbers for Turf
const rd = parseFloat(rotorDiameter) || 160;
const hh = parseFloat(hubHeight) || 165;
const fr = parseFloat(foundationRadius) || 15;
const totalHeight = hh + (rd / 2);
const point = turf.point([latlng.lng, latlng.lat]);
const steps = 128; // Increased resolution for smoother circles/ellipses
// Convert to UTM32 FOR calculation and display
const utmCoords = proj4(wgs84, utm32, [latlng.lng, latlng.lat]);
const centerE = utmCoords[0];
const centerN = utmCoords[1];
// 1. Swept Area (Rotorüberstreichfläche)
const sweptArea = turf.circle(point, (rd / 2) / 1000, { units: 'kilometers', steps: steps });
// 2. Technical Distance 1 (Ellipse 2.5 x 4.0 RD)
const techDist = turf.ellipse(point, (rd * 4.0) / 1000, (rd * 2.5) / 1000, {
units: 'kilometers',
angle: 135,
steps: steps
});
// 2b. Technical Distance 2 (2.0 RD Circle)
const techDistSmall = turf.circle(point, (rd * 2.0) / 1000, {
units: 'kilometers',
steps: steps
});
// 3. Auflastenradius (0.3 x Gesamthöhe)
const loadRadius = turf.circle(point, (totalHeight * 0.3) / 1000, { units: 'kilometers', steps: steps });
// 3b. Fundament (configurable radius)
const foundation = turf.circle(point, (fr) / 1000, { units: 'kilometers', steps: steps });
// Helper for KSF Geometries
const spg = ksfMirrored ? -1 : 1;
// Turf uses CCW for positive angles. Most tools use CW.
// We'll use negative angle for CCW to achieve CW rotation if expected.
const angle = -ksfAngle;
const transform = (relCoords) => {
const rad = (-ksfAngle * Math.PI) / 180; // Negative for CW rotation if turf math is used, or just math
// Manual Cartesian Rotation (Clockwise)
// x' = x * cos(a) + y * sin(a)
// y' = -x * sin(a) + y * cos(a)
const a = (ksfAngle * Math.PI) / 180;
const cosA = Math.cos(a);
const sinA = Math.sin(a);
const finalCoords = relCoords.map(c => {
const x = c[0] * spg;
const y = c[1];
// Rotation CW
const xRot = x * cosA + y * sinA;
const yRot = -x * sinA + y * cosA;
const utmPoint = [centerE + xRot, centerN + yRot];
return proj4(utm32, wgs84, utmPoint);
});
return turf.polygon([finalCoords]);
};
// 4. Blattlagerfläche (BLF)
// Coords for "Nein" case (@spg=1): x is -41
const blfCoords = [[-41, 9], [-61, 9], [-61, -81], [-41, -81], [-41, 9]];
const blf = transform(blfCoords);
// 5. Kranstellfläche (KSF)
const ksfCoords = [[-8, 0], [-36, 0], [-36, -50], [-8, -50], [-8, 0]];
const ksf = transform(ksfCoords);
// 6. Montagefläche (MF)
const mfParts = [
[[-36, 0], [-36, 18], [-8, 18], [-8, 0], [-36, 0]],
[[12, -62], [-22, -62], [-22, -72], [12, -72], [12, -62]],
[[12, 0], [-8, 0], [-8, -50], [-36, -50], [-36, -72], [-22, -72], [-22, -62], [12, -62], [12, 0]],
[[-41, 18], [-47, 18], [-47, 9], [-41, 9], [-41, 18]],
[[-41, -81], [-47, -81], [-47, -96], [-41, -96], [-41, -81]],
[[-36, 18], [-41, 18], [-41, -72], [-36, -72], [-36, 18]]
];
const mf = turf.featureCollection(mfParts.map(part => transform(part)));
return { sweptArea, techDist, techDistSmall, loadRadius, foundation, blf, ksf, mf, totalHeight, utmCoords };
}
function updateLabel(turbine, geoms) {
const labelText = `
<div style="text-align: left; font-size: 11px; line-height: 1.3;">
<b>WEA ${turbine.nr}</b><br>
E: ${geoms.utmCoords[0].toFixed(0)} | N: ${geoms.utmCoords[1].toFixed(0)}<br>
NH: ${turbine.hh}m | RD: ${turbine.rd}m<br>
GH: ${geoms.totalHeight.toFixed(1)}m
</div>
`;
turbine.layers.marker.bindTooltip(labelText, {
permanent: true,
direction: 'right',
offset: [20, -10],
className: 'wea-label'
}).openTooltip();
}
function updateEditPanelPosition() {
if (!activeTurbine || editPanel.style.display === 'none') return;
const centerPx = state.map.latLngToContainerPoint(activeTurbine.latlng);
const sidebarWidth = 260;
const panelWidth = 210;
// Position it clearly to the LEFT of the turbine center
// Right edge of panel should be 120px to the left of the center
let left = (centerPx.x + sidebarWidth) - panelWidth - 120;
let top = centerPx.y - 120;
// Screen boundaries
if (left < sidebarWidth + 10) left = sidebarWidth + 10;
if (top < 10) top = 10;
if (left + panelWidth > window.innerWidth - 10) left = window.innerWidth - panelWidth - 10;
editPanel.style.left = `${left}px`;
editPanel.style.top = `${top}px`;
}
function openEditPanel(turbine) {
if (activeTurbine && activeTurbine !== turbine) {
variantLayers[activeTurbine.variant].removeLayer(activeTurbine.layers.rotationHandle);
}
activeTurbine = turbine;
editNr.value = turbine.nr;
editType.value = turbine.type;
editRd.value = turbine.rd;
editNh.value = turbine.hh;
editFr.value = turbine.fr || 15;
editKsfAngle.value = turbine.ksfAngle || 0;
editKsfMirrored.checked = !!turbine.ksfMirrored;
// Show rotation handle
variantLayers[turbine.variant].addLayer(turbine.layers.rotationHandle);
editPanel.style.display = 'block';
updateEditPanelPosition();
document.getElementById('statusInfo').innerText = `Bearbeite WEA ${turbine.nr}`;
}
function closeEditPanel() {
if (activeTurbine) {
variantLayers[activeTurbine.variant].removeLayer(activeTurbine.layers.rotationHandle);
}
activeTurbine = null;
editPanel.style.display = 'none';
document.getElementById('statusInfo').innerText = "Bereit.";
}
// Reposition floating panel on map movement
state.map.on('move zoom', updateEditPanelPosition);
// Legend Logic
function updateLegend() {
if (!legendContent) return;
// Items to always show if any turbine exists
let html = '';
if (state.turbines.length > 0) {
html += `
<div class="legend-item"><span class="color-box" style="background: #00c8ff;"></span> Rotorfläche</div>
<div class="legend-item"><span class="color-box" style="border: 2px dashed #ffcc00; background: transparent;"></span> Techn. Abstand (Ellipse)</div>
<div class="legend-item"><span class="color-box" style="border: 1.5px dotted #ffcc00; background: transparent;"></span> Techn. Abstand (Circle)</div>
<div class="legend-item"><span class="color-box" style="background: rgba(255, 68, 68, 0.2); border: 1px solid #ff4444;"></span> Auflastenradius</div>
<div class="legend-item"><span class="color-box" style="background: rgba(52, 152, 219, 0.3); border: 1px solid #3498db;"></span> Fundament</div>
<div class="legend-item"><span class="color-box" style="background: #e74c3c; opacity: 0.6;"></span> Kranstellfläche (KSF)</div>
`;
}
// Search for active external layers
Object.keys(overlays).forEach(name => {
const layer = overlays[name];
if (state.map.hasLayer(layer)) {
// Determine color (heuristic or from layer style)
let color = '#ccc';
if (name.includes('Eigentümer')) color = '#2ecc71';
if (name.includes('Hilfs')) color = '#ffcc00';
html += `<div class="legend-item"><span class="color-box" style="background: ${color}; opacity: 0.8;"></span> ${name}</div>`;
}
});
legendContent.innerHTML = html || '<div style="font-size: 0.75rem; color: var(--text-dim); text-align: center;">Keine aktiven Layer</div>';
}
// Toggle Legend collapse
legendHeader.addEventListener('click', () => {
floatingLegend.classList.toggle('collapsed');
btnToggleLegend.innerHTML = floatingLegend.classList.contains('collapsed') ? '▼' : '▲';
});
state.map.on('overlayadd overlayremove', updateLegend);
// Also update on variant change
variantTabs.forEach(tab => {
tab.addEventListener('click', () => {
// ... existing variant logic (already there, but we need to trigger updateLegend)
setTimeout(updateLegend, 100);
});
});
function createTurbine(latlng, loadedNr = null, overrideData = null) {
const rd = overrideData?.rd || parseFloat(inputRotor.value) || 160;
const hh = overrideData?.hh || parseFloat(inputHub.value) || 165;
const fr = overrideData?.fr || parseFloat(inputFoundation.value) || 15;
const type = overrideData?.type || inputType.value || "Standard Typ";
const weaNr = overrideData?.nr || loadedNr || (state.turbines.length + 1).toString();
const ksfAngle = overrideData?.ksfAngle || 0;
const ksfMirrored = overrideData?.ksfMirrored || false;
const geoms = calculateGeometries(latlng, rd, hh, fr, ksfAngle, ksfMirrored);
const turbineIcon = L.divIcon({
className: 'turbine-icon-container',
html: `
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 22L12.5 22L12.2 12L11.8 12L11.5 22Z" fill="#00c8ff" />
<circle cx="12" cy="12" r="1.5" fill="#00c8ff" stroke="white" stroke-width="0.5"/>
<path d="M12 12L12 3" stroke="white" stroke-width="1.8" stroke-linecap="round" />
<path d="M12 12L19.7942 16.5" stroke="white" stroke-width="1.8" stroke-linecap="round" />
<path d="M12 12L4.20577 16.5" stroke="white" stroke-width="1.8" stroke-linecap="round" />
</svg>
`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
const turbine = {
id: `WEA_${Date.now()}`,
nr: weaNr,
variant: overrideData?.variant || state.activeVariant,
type, rd, hh, fr, latlng,
ksfAngle, ksfMirrored,
totalHeight: geoms.totalHeight,
layers: {
marker: L.marker(latlng, { draggable: true, icon: turbineIcon }),
sweptArea: L.geoJSON(geoms.sweptArea, { style: { color: '#00c8ff', weight: 1, dashArray: '4, 4', fillOpacity: 0.1 } }),
techDist: L.geoJSON(geoms.techDist, { style: { color: '#ffcc00', weight: 2, dashArray: '5, 5', fillOpacity: 0 } }),
techDistSmall: L.geoJSON(geoms.techDistSmall, { style: { color: '#ffcc00', weight: 1.5, dashArray: '2, 4', fillOpacity: 0 } }),
loadRadius: L.geoJSON(geoms.loadRadius, { style: { color: '#ff4444', weight: 1, fillOpacity: 0.05 } }),
foundation: L.geoJSON(geoms.foundation, { style: { color: '#3498db', weight: 1, fillOpacity: 0.3 } }),
ksf: L.geoJSON(geoms.ksf, { style: { color: '#e74c3c', weight: 1.5, fillOpacity: 0.4 } }),
blf: L.geoJSON(geoms.blf, { style: { color: '#9b59b6', weight: 1, dashArray: '3, 3', fillOpacity: 0.2 } }),
mf: L.geoJSON(geoms.mf, { style: { color: '#95a5a6', weight: 1, dashArray: '2, 2', fillOpacity: 0.15 } }),
rotationHandle: L.marker(latlng, {
draggable: true,
icon: L.divIcon({
className: 'rotation-handle',
html: `
<div style="background: #e74c3c; width: 24px; height: 24px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 5px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="white">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
})
})
}
};
// Function to position the rotation handle based on current center and angle
const updateRotationHandlePos = (turbine) => {
const angleRad = (turbine.ksfAngle * Math.PI) / 180;
const dist = (turbine.rd * 0.4) / 1000; // Place it within or near the rotor radius for easy access (scaled for km)
// In UTM for better precision
const utmCenter = proj4(wgs84, utm32, [turbine.latlng.lng, turbine.latlng.lat]);
// Negative for CW rotation if ksfAngle follows that convention
const handleUtm = [
utmCenter[0] - (turbine.rd * 0.5 + 10) * Math.sin(angleRad),
utmCenter[1] - (turbine.rd * 0.5 + 10) * Math.cos(angleRad)
];
const handleWgs = proj4(utm32, wgs84, handleUtm);
turbine.layers.rotationHandle.setLatLng([handleWgs[1], handleWgs[0]]);
};
updateRotationHandlePos(turbine);
Object.entries(turbine.layers).forEach(([name, layer]) => {
if (name !== 'rotationHandle') {
if (name === 'marker' || state.showAuxiliary) {
variantLayers[turbine.variant].addLayer(layer);
}
}
});
updateLabel(turbine, geoms);
// Click to Edit
turbine.layers.marker.on('click', () => openEditPanel(turbine));
// Drag Update
turbine.layers.marker.on('drag', (e) => {
const newPos = e.target.getLatLng();
turbine.latlng = newPos;
const newGeoms = calculateGeometries(newPos, turbine.rd, turbine.hh, turbine.fr, turbine.ksfAngle, turbine.ksfMirrored);
turbine.layers.sweptArea.clearLayers().addData(newGeoms.sweptArea);
turbine.layers.techDist.clearLayers().addData(newGeoms.techDist);
turbine.layers.techDistSmall.clearLayers().addData(newGeoms.techDistSmall);
turbine.layers.loadRadius.clearLayers().addData(newGeoms.loadRadius);
turbine.layers.foundation.clearLayers().addData(newGeoms.foundation);
turbine.layers.ksf.clearLayers().addData(newGeoms.ksf);
turbine.layers.blf.clearLayers().addData(newGeoms.blf);
turbine.layers.mf.clearLayers().addData(newGeoms.mf);
updateRotationHandlePos(turbine);
updateLabel(turbine, newGeoms);
updateProximityLines();
updateLegend(); // Show symbols in legend
triggerAutoSave();
if (activeTurbine && activeTurbine.id === turbine.id) {
// Keep fields updated
editNr.value = turbine.nr;
updateEditPanelPosition(); // Sync floating panel
}
});
// Rotation Handle drag logic
turbine.layers.rotationHandle.on('drag', (e) => {
const handlePos = e.target.getLatLng();
const centerUtm = proj4(wgs84, utm32, [turbine.latlng.lng, turbine.latlng.lat]);
const handleUtm = proj4(wgs84, utm32, [handlePos.lng, handlePos.lat]);
// Calculate angle: Math.atan2(dx, dy). Note: y is North (up), x is East (right)
// We want 0 deg to be South (down), following the KSF pattern if needed.
// Moving handle CW (to West/-X) should increase angle.
const dx = handleUtm[0] - centerUtm[0];
const dy = centerUtm[1] - handleUtm[1]; // Handle is South of center -> positive dy
let angle = Math.atan2(-dx, dy) * (180 / Math.PI);
if (angle < 0) angle += 360;
turbine.ksfAngle = angle;
if (activeTurbine && activeTurbine.id === turbine.id) {
editKsfAngle.value = angle.toFixed(1);
}
const newGeoms = calculateGeometries(turbine.latlng, turbine.rd, turbine.hh, turbine.fr, turbine.ksfAngle, turbine.ksfMirrored);
turbine.layers.ksf.clearLayers().addData(newGeoms.ksf);
turbine.layers.blf.clearLayers().addData(newGeoms.blf);
turbine.layers.mf.clearLayers().addData(newGeoms.mf);
updateRotationHandlePos(turbine);
});
turbine.layers.rotationHandle.on('dragend', triggerAutoSave);
state.turbines.push(turbine);
updateProximityLines();
triggerAutoSave();
if (!loadedNr) openEditPanel(turbine);
}
// Panel Event Listeners
btnCloseEdit.onclick = closeEditPanel;
btnSaveEdit.onclick = () => {
if (!activeTurbine) return;
const newNr = editNr.value;
const newType = editType.value;
const newRd = parseFloat(editRd.value);
const newNh = parseFloat(editNh.value);
const newFr = parseFloat(editFr.value) || 15;
const newAngle = parseFloat(editKsfAngle.value) || 0;
const newMirrored = editKsfMirrored.checked;
if (isNaN(newRd) || isNaN(newNh) || isNaN(newFr)) {
alert("Bitte RD, NH und Fundament korrekt angeben."); return;
}
activeTurbine.nr = newNr;
activeTurbine.type = newType;
activeTurbine.rd = newRd;
activeTurbine.hh = newNh;
activeTurbine.fr = newFr;
activeTurbine.ksfAngle = newAngle;
activeTurbine.ksfMirrored = newMirrored;
const geoms = calculateGeometries(activeTurbine.layers.marker.getLatLng(), newRd, newNh, newFr, newAngle, newMirrored);
activeTurbine.totalHeight = geoms.totalHeight;
activeTurbine.layers.sweptArea.clearLayers().addData(geoms.sweptArea);
activeTurbine.layers.techDist.clearLayers().addData(geoms.techDist);
activeTurbine.layers.techDistSmall.clearLayers().addData(geoms.techDistSmall);
activeTurbine.layers.loadRadius.clearLayers().addData(geoms.loadRadius);
activeTurbine.layers.ksf.clearLayers().addData(geoms.ksf);
activeTurbine.layers.blf.clearLayers().addData(geoms.blf);
activeTurbine.layers.mf.clearLayers().addData(geoms.mf);
updateLabel(activeTurbine, geoms);
updateProximityLines();
triggerAutoSave();
document.getElementById('statusInfo').innerText = `WEA ${newNr} gespeichert.`;
};
btnDeleteWEA.onclick = () => {
if (!activeTurbine) return;
if (confirm(`WEA ${activeTurbine.nr} wirklich löschen?`)) {
Object.values(activeTurbine.layers).forEach(l => variantLayers[activeTurbine.variant].removeLayer(l));
state.turbines = state.turbines.filter(t => t.id !== activeTurbine.id);
updateProximityLines();
triggerAutoSave();
closeEditPanel();
}
};
// Toggle Placement Mode
btnPlaceTurbine.addEventListener('click', () => {
placementMode = !placementMode;
btnPlaceTurbine.classList.toggle('active', placementMode);
state.map.getContainer().style.cursor = placementMode ? 'crosshair' : '';
state.map.getContainer().classList.toggle('placement-active', placementMode);
});
state.map.on('click', (e) => {
if (placementMode) {
createTurbine(e.latlng);
placementMode = false;
btnPlaceTurbine.classList.remove('active');
state.map.getContainer().style.cursor = '';
state.map.getContainer().classList.remove('placement-active');
}
});
// Variant Switching Logic
variantTabs.forEach(tab => {
tab.addEventListener('click', () => {
if (state.activeVariant === tab.dataset.variant) return;
variantTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const newVariant = tab.dataset.variant;
state.map.removeLayer(variantLayers[state.activeVariant]);
state.activeVariant = newVariant;
variantLayers[state.activeVariant].addTo(state.map);
document.getElementById('statusInfo').innerText = `Variante ${newVariant} aktiv.`;
});
});
// Hilfsgeometrien Toggle
const checkShowAux = document.getElementById('checkShowAux');
if (checkShowAux) {
checkShowAux.onchange = () => {
state.showAuxiliary = checkShowAux.checked;
state.turbines.forEach(t => {
Object.entries(t.layers).forEach(([name, layer]) => {
if (name !== 'marker' && name !== 'rotationHandle') {
if (state.showAuxiliary) {
variantLayers[t.variant].addLayer(layer);
} else {
variantLayers[t.variant].removeLayer(layer);
}
}
});
});
};
}
// Simplified Measurement Tool
let measureLayer = null;
let measurePoints = [];
let mouseMarker = L.circleMarker([0, 0], { radius: 0, opacity: 0 }).addTo(state.map);
// UTM Creation Logic
const btnCreateAtUTM = document.getElementById('btnCreateAtUTM');
const inputUtmE = document.getElementById('utm-e');
const inputUtmN = document.getElementById('utm-n');
btnCreateAtUTM.addEventListener('click', () => {
const e = parseFloat(inputUtmE.value);
const n = parseFloat(inputUtmN.value);
if (isNaN(e) || isNaN(n)) {
alert("Bitte geben Sie gültige UTM-Koordinaten (Rechtswert und Hochwert) ein.");
return;
}
try {
// Convert UTM32 (EPSG:25832) to WGS84
const coords = proj4(utm32, wgs84, [e, n]);
const latlng = L.latLng(coords[1], coords[0]);
createTurbine(latlng);
state.map.setView(latlng, 15);
// Success visual feedback
btnCreateAtUTM.style.background = '#2ecc71';
setTimeout(() => btnCreateAtUTM.style.background = '', 1000);
} catch (err) {
console.error("UTM conversion error:", err);
alert("Fehler bei der Koordinatenumrechnung. Bitte prüfen Sie die Werte.");
}
});
function stopMeasurement() {
measureMode = null;
measurePoints = [];
if (measureLayer) state.map.removeLayer(measureLayer);
measureLayer = null;
mouseMarker.unbindTooltip();
btnMeasureDist.classList.remove('active');
btnMeasureArea.classList.remove('active');
state.map.getContainer().style.cursor = '';
}
function startMeasurement(mode) {
// Clear previous results from Hilfs-Geometrien as requested
overlays["Hilfs-Geometrien"].clearLayers();
stopMeasurement();
measureMode = mode;
if (mode === 'dist') {
btnMeasureDist.classList.add('active');
measureLayer = L.polyline([], { color: 'var(--primary-color)', weight: 3, dashArray: '5, 5' }).addTo(state.map);
} else {
btnMeasureArea.classList.add('active');
measureLayer = L.polygon([], { color: 'var(--primary-color)', weight: 3, fillOpacity: 0.2, dashArray: '5, 5' }).addTo(state.map);
}
state.map.getContainer().style.cursor = 'crosshair';
document.getElementById('statusInfo').innerText = "Klicke zum Starten. Doppelklick zum Beenden.";
}
btnMeasureDist.onclick = () => measureMode === 'dist' ? stopMeasurement() : startMeasurement('dist');
btnMeasureArea.onclick = () => measureMode === 'area' ? stopMeasurement() : startMeasurement('area');
state.map.on('mousemove', (e) => {
if (!measureMode || measurePoints.length === 0) return;
const tempPoints = [...measurePoints, e.latlng];
measureLayer.setLatLngs(tempPoints);
// Calculate intermediate result
const geojson = measureLayer.toGeoJSON();
let val = "";
if (measureMode === 'dist') {
const len = turf.length(geojson, { units: 'kilometers' });
val = len < 1 ? `${(len * 1000).toFixed(0)}m` : `${len.toFixed(2)}km`;
} else if (measurePoints.length > 2) {
const area = turf.area(geojson);
val = area < 10000 ? `${area.toFixed(0)}` : `${(area / 10000).toFixed(2)}ha`;
}
if (val) {
mouseMarker.setLatLng(e.latlng);
mouseMarker.bindTooltip(val, { permanent: true, direction: 'top', className: 'measure-tooltip' }).openTooltip();
}
});
state.map.on('click', (e) => {
if (placementMode) {
createTurbine(e.latlng);
placementMode = false;
btnPlaceTurbine.classList.remove('active');
state.map.getContainer().style.cursor = '';
return;
}
if (measureMode) {
measurePoints.push(e.latlng);
measureLayer.setLatLngs(measurePoints);
// After first click, change style to solid
measureLayer.setStyle({ dashArray: null });
return;
}
// Close panel if clicking empty map
if (e.originalEvent && (e.originalEvent.target.id === 'map' || e.originalEvent.target.classList.contains('leaflet-container'))) {
closeEditPanel();
}
});
state.map.on('dblclick', (e) => {
if (measureMode) {
if (measurePoints.length < 2) { stopMeasurement(); return; }
const geojson = measureLayer.toGeoJSON();
let result = "";
if (measureMode === 'dist') {
const len = turf.length(geojson, { units: 'kilometers' });
result = len < 1 ? `${(len * 1000).toFixed(1)} m` : `${len.toFixed(3)} km`;
} else {
const area = turf.area(geojson);
result = area < 10000 ? `${area.toFixed(1)}` : `${(area / 10000).toFixed(2)} ha`;
}
// Keep the layer
const finalLayer = measureLayer;
finalLayer.setStyle({ color: '#ffcc00', weight: 4 });
const popupDiv = document.createElement('div');
popupDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; gap: 12px;">
<div style="font-size: 0.85rem; line-height: 1.2;"><b>Messung:</b><br>${result}</div>
<button class="btn-del-measure" style="background: #ff4444; color: white; border: none; border-radius: 4px; padding: 3px 7px; cursor: pointer; font-weight: bold; font-size: 0.9rem;">✕</button>
</div>
`;
popupDiv.querySelector('.btn-del-measure').onclick = () => {
overlays["Hilfs-Geometrien"].removeLayer(finalLayer);
};
finalLayer.bindPopup(popupDiv).openPopup(e.latlng);
measureLayer = null;
overlays["Hilfs-Geometrien"].addLayer(finalLayer);
stopMeasurement();
}
});
// Variant Switching Logic
variantTabs.forEach(tab => {
tab.addEventListener('click', () => {
if (state.activeVariant === tab.dataset.variant) return;
variantTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.map.removeLayer(variantLayers[state.activeVariant]);
state.activeVariant = tab.dataset.variant;
variantLayers[state.activeVariant].addTo(state.map);
updateProximityLines();
closeEditPanel();
});
});
// Base Layers & Overlays
const baseLayers = {
"Straßenkarte": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OSM' }),
"Luftbild": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
}).addTo(state.map)
};
const overlays = {
"Variante A": variantLayers['A'],
"Variante B": variantLayers['B'],
"Variante C": variantLayers['C'],
"Hilfs-Geometrien": L.featureGroup().addTo(state.map),
"Abstände (Gleiches Layout)": L.featureGroup().addTo(state.map)
};
const proximityLinesLayer = overlays["Abstände (Gleiches Layout)"];
function updateProximityLines() {
proximityLinesLayer.clearLayers();
// Only show lines if the current variant layer is visible
if (!state.map.hasLayer(variantLayers[state.activeVariant])) return;
// Filter turbines for current variant
const currentVariantTurbines = state.turbines.filter(t => t.variant === state.activeVariant);
if (currentVariantTurbines.length === 0) return;
// 1. Turbine-to-Turbine Proximity (600m)
const groups = {};
currentVariantTurbines.forEach(t => {
const key = `${t.type}_${t.rd}_${t.hh}`;
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
for (const key in groups) {
const group = groups[key];
if (group.length < 2) continue;
for (let i = 0; i < group.length; i++) {
for (let j = i + 1; j < group.length; j++) {
const t1 = group[i];
const t2 = group[j];
const p1 = turf.point([t1.latlng.lng, t1.latlng.lat]);
const p2 = turf.point([t2.latlng.lng, t2.latlng.lat]);
const distKm = turf.distance(p1, p2, { units: 'kilometers' });
const distM = distKm * 1000;
if (distM < 600) {
const line = L.polyline([t1.latlng, t2.latlng], {
color: '#ff8800', weight: 2, dashArray: '10, 10', opacity: 0.7
}).addTo(proximityLinesLayer);
const mid = L.latLng((t1.latlng.lat + t2.latlng.lat) / 2, (t1.latlng.lng + t2.latlng.lng) / 2);
line.bindTooltip(`${distM.toFixed(1)} m`, { permanent: true, direction: 'center', className: 'proximity-tooltip' }).openTooltip(mid);
}
}
}
}
// 2. Feature Proximity (Houses 700m, Lines 300m)
Object.keys(state.bakedData).forEach(layerName => {
const layer = overlays[layerName];
const isVisible = layer && state.map.hasLayer(layer);
if (!isVisible) return;
const nameLow = layerName.toLowerCase();
let threshold = 0;
let type = '';
let color = '#9400d3'; // Default violet
if (nameLow.includes('wohnbebauung') || nameLow.includes('wohngebäude')) {
threshold = 700;
type = 'house';
color = '#ff4444';
} else if (nameLow.includes('freileitung')) {
threshold = 140;
type = 'overhead';
color = '#ccaa00';
} else if (nameLow.includes('leitung')) {
threshold = 300;
type = 'line';
color = '#9400d3';
}
if (threshold === 0) return;
const layerData = state.bakedData[layerName].data;
currentVariantTurbines.forEach(t => {
const GH = Number(t.hh) + (Number(t.rd) / 2);
const tPoint = turf.point([t.latlng.lng, t.latlng.lat]);
turf.featureEach(layerData, (feature) => {
let closestPoint;
try {
if (feature.geometry.type === 'Point') {
closestPoint = turf.point(feature.geometry.coordinates);
} else if (feature.geometry.type === 'LineString' || feature.geometry.type === 'MultiLineString') {
closestPoint = turf.nearestPointOnLine(feature, tPoint);
} else if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') {
// Find closest point on polygon boundary
const boundary = turf.polygonToLine(feature);
closestPoint = turf.nearestPointOnLine(boundary, tPoint);
} else {
return;
}
const distKm = turf.distance(tPoint, closestPoint, { units: 'kilometers' });
const distM = distKm * 1000;
if (distM < threshold) {
const ratio = (distM / GH).toFixed(1);
const targetLatLng = L.latLng(closestPoint.geometry.coordinates[1], closestPoint.geometry.coordinates[0]);
const line = L.polyline([t.latlng, targetLatLng], {
color: color, weight: 2, dashArray: '5, 8', opacity: 0.8
}).addTo(proximityLinesLayer);
const mid = L.latLng((t.latlng.lat + targetLatLng.lat) / 2, (t.latlng.lng + targetLatLng.lng) / 2);
const label = type === 'house' ? `${distM.toFixed(1)} m (${ratio} H)` : `${distM.toFixed(1)} m`;
line.bindTooltip(label, {
permanent: true,
direction: 'center',
className: type === 'house' ? 'proximity-tooltip-red' :
type === 'overhead' ? 'proximity-tooltip-yellow' :
'proximity-tooltip-violet'
}).openTooltip(mid);
}
} catch (err) { console.warn("Distance calc error", err); }
});
});
});
}
const layerControl = L.control.layers(baseLayers, overlays, { collapsed: false }).addTo(state.map);
// Update proximity lines when layers are toggled in legend
state.map.on('overlayadd overlayremove', () => {
updateProximityLines();
});
// Filtered loadLocalLayer logic...
async function loadLocalLayer(path, label, color) {
try {
const response = await fetch(path).catch(() => null);
if (!response || !response.ok) return;
const data = await response.json();
const layer = L.geoJSON(data, {
style: { color: color, weight: 1.5, fillOpacity: 0.2 },
onEachFeature: (feature, layer) => {
if (feature.properties) {
let popup = "<b>Attribute:</b><br>";
for (let key in feature.properties) popup += `${key}: ${feature.properties[key]}<br>`;
layer.bindPopup(popup);
}
}
});
overlays[label] = layer;
state.map.addLayer(layer);
layerControl.addOverlay(layer, label);
if (label.toLowerCase().includes('eigentümer') || label.toLowerCase().includes('flurstücke')) {
layer.bringToBack();
}
} catch (e) { }
}
function getDynamicStyle(layerName) {
const name = layerName.toLowerCase();
if (name.includes('wohnbebauung')) {
return { color: '#ff4444', weight: 1.5, fillOpacity: 0.6, isResidential: true };
}
if (name.includes('freileitung')) {
return { color: '#ccaa00', weight: 3, fillOpacity: 0 };
}
if (name.includes('leitung')) {
return { color: '#9400d3', weight: 2.5, fillOpacity: 0 };
}
if (name.includes('eigentümer')) {
return { color: '#000000', weight: 2, fillOpacity: 0, fillColor: 'transparent' };
}
if (name.includes('windeignungsgebiet') || name.includes('konzentrationszone') || name.includes('plangebiet')) {
return { color: '#ff0000', weight: 3, fillOpacity: 0, fillColor: 'transparent' };
}
if (name.includes('standort') || (name.includes('wea') && !name.includes('zone'))) {
return { isTurbine: true };
}
return null; // Return null if no smart match
}
async function processShapefileBuffers(shpBuffer, dbfBuffer, layerName, manualStyle = null) {
const statusEl = document.getElementById('statusInfo');
try {
statusEl.innerText = `Verarbeite ${layerName}...`;
const geojson = await shp.combine([shp.parseShp(shpBuffer), shp.parseDbf(dbfBuffer)]);
let count = 0;
geojson.features.forEach(f => {
if (!f.geometry) return;
const reproject = (coords) => {
if (typeof coords[0] === 'number') {
count++;
return proj4(utm32, wgs84, coords);
}
return coords.map(reproject);
};
f.geometry.coordinates = reproject(f.geometry.coordinates);
});
// Apply Smart Styling (Smart Match takes precedence over manual config)
const smartStyle = getDynamicStyle(layerName);
const style = smartStyle || manualStyle || { color: '#00c8ff', weight: 1.5, fillOpacity: 0.2 };
// Store in cache for bundling
state.bakedData[layerName] = { data: geojson, style: style };
// Automated Turbine Creation from Points
if (style.isTurbine) {
geojson.features.forEach(f => {
if (f.geometry && f.geometry.type === 'Point') {
const latlng = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const props = f.properties || {};
createTurbine(latlng, null, {
nr: props.WEA_Nr || props.Nr || props.ID,
type: props.Typ || props.Type,
rd: parseFloat(props.RD || props.Rotor),
hh: parseFloat(props.NH || props.HubHeight || props.HH)
});
}
});
statusEl.innerText = `${layerName}: WEAs automatisch erstellt.`;
return; // Don't add as a regular GeoJSON layer if we created real WEAs
}
const layer = L.geoJSON(geojson, {
style: (feature) => {
let fillColor = style.fillColor || style.color;
// Owner-Status-Coloring
if (layerName.toLowerCase().includes('eigentümer') && state.ownerMapping) {
const props = feature.properties;
const firstName = props[state.ownerMapping.firstName] || '';
const lastName = props[state.ownerMapping.lastName] || '';
const ownerName = `${firstName} ${lastName}`.trim();
const status = state.ownerStatuses[ownerName];
if (status === 'gbr') fillColor = '#2ecc71';
if (status === 'external') fillColor = '#e74c3c';
if (status === 'declined') fillColor = '#f1c40f';
if (status === 'positive') fillColor = '#5efd9c';
if (status === 'undecided') fillColor = '#95a5a6';
return { color: '#000', weight: 1, fillOpacity: 0.7, fillColor: fillColor };
}
return {
color: style.color,
weight: style.weight,
fillOpacity: style.fillOpacity,
fillColor: fillColor
};
},
pointToLayer: (feature, latlng) => {
if (style.isResidential) {
return L.marker(latlng, {
icon: L.divIcon({
className: 'residential-icon',
html: '🏠',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
});
}
return L.circleMarker(latlng, {
radius: 4,
fillColor: style.color,
color: "#fff",
weight: 1,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
if (feature.properties) {
let popup = `<b>${layerName}</b><br><hr style="margin: 5px 0; border: 0; border-top: 1px solid #444;">`;
for (let key in feature.properties) {
const val = feature.properties[key];
if (val !== null && val !== undefined) popup += `<b>${key}:</b> ${val}<br>`;
}
layer.bindPopup(popup);
}
}
});
overlays[layerName] = layer;
state.map.addLayer(layer);
layerControl.addOverlay(layer, layerName);
if (layerName.toLowerCase().includes('eigentümer') || layerName.toLowerCase().includes('flurstücke')) {
layer.bringToBack();
}
// Auto-Zoom to Planning Area
if (layerName.toLowerCase().includes('windeignungsgebiet')) {
state.map.fitBounds(layer.getBounds());
}
statusEl.innerText = `${layerName} erfolgreich geladen.`;
} catch (e) {
console.error(`Fehler beim Verarbeiten von ${layerName}:`, e);
statusEl.innerHTML = `<span style="color: #ff4444;">Fehler bei ${layerName}: ${e.message}</span>`;
}
}
async function loadShapefileLayer(layerDef) {
const statusEl = document.getElementById('statusInfo');
try {
statusEl.innerText = `Lade Shapefile: ${layerDef.name}...`;
const encodedFile = encodeURIComponent(layerDef.file);
const shpResp = await fetch(`Shapefile/${encodedFile}.shp`);
const dbfResp = await fetch(`Shapefile/${encodedFile}.dbf`);
if (!shpResp.ok || !dbfResp.ok) throw new Error("Fetch fehlgeschlagen");
const shpBuffer = await shpResp.arrayBuffer();
const dbfBuffer = await dbfResp.arrayBuffer();
await processShapefileBuffers(shpBuffer, dbfBuffer, layerDef.name, layerDef);
} catch (e) {
if (window.location.protocol !== 'file:') console.error(`Fehler bei ${layerDef.name}:`, e);
}
}
async function initDynamicLayers() {
const statusEl = document.getElementById('statusInfo');
const isLocalFile = window.location.protocol === 'file:';
try {
// Priority 1: Use window.BAKED_DATA if available (Standalone Mode)
if (window.BAKED_DATA) {
// Support both old format (direct layers) and new format (with mapping/statuses)
let bakedLayers = window.BAKED_DATA;
if (window.BAKED_DATA.layers) {
bakedLayers = window.BAKED_DATA.layers;
state.ownerMapping = window.BAKED_DATA.ownerMapping || null;
state.ownerStatuses = window.BAKED_DATA.ownerStatuses || {};
}
for (const name in bakedLayers) {
const entry = bakedLayers[name];
const smartStyle = getDynamicStyle(name);
const style = smartStyle || entry.style || { color: '#00c8ff', weight: 1.5, fillOpacity: 0.2 };
// Automated Turbine Creation from BAKED_DATA
if (style.isTurbine) {
entry.data.features.forEach(f => {
if (f.geometry && f.geometry.type === 'Point') {
const latlng = L.latLng(f.geometry.coordinates[1], f.geometry.coordinates[0]);
const props = f.properties || {};
createTurbine(latlng, null, {
nr: props.WEA_Nr || props.Nr || props.ID,
type: props.Typ || props.Type,
rd: parseFloat(props.RD || props.Rotor),
hh: parseFloat(props.NH || props.HubHeight || props.HH)
});
}
});
state.bakedData[name] = entry;
continue;
}
const layer = L.geoJSON(entry.data, {
style: (feature) => {
let fillColor = style.fillColor || style.color;
if (name.toLowerCase().includes('eigentümer') && state.ownerMapping) {
const props = feature.properties;
const firstName = props[state.ownerMapping.firstName] || '';
const lastName = props[state.ownerMapping.lastName] || '';
const ownerName = `${firstName} ${lastName}`.trim();
const status = state.ownerStatuses[ownerName];
if (status === 'gbr') fillColor = '#2ecc71';
if (status === 'external') fillColor = '#e74c3c';
if (status === 'declined') fillColor = '#f1c40f';
if (status === 'positive') fillColor = '#5efd9c';
if (status === 'undecided') fillColor = '#95a5a6';
return { color: '#000', weight: 1, fillOpacity: 0.7, fillColor: fillColor };
}
return {
color: style.color,
weight: style.weight,
fillOpacity: style.fillOpacity,
fillColor: fillColor
};
},
pointToLayer: (feature, latlng) => {
if (style.isResidential) {
return L.marker(latlng, {
icon: L.divIcon({
className: 'residential-icon',
html: '🏠',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
});
}
return L.circleMarker(latlng, {
radius: 4,
fillColor: style.color,
color: "#fff",
weight: 1,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
if (feature.properties) {
let popup = `<b>${name}</b><br><hr style="margin: 5px 0; border: 0; border-top: 1px solid #444;">`;
for (let key in feature.properties) {
const val = feature.properties[key];
if (val !== null && val !== undefined) popup += `<b>${key}:</b> ${val}<br>`;
}
layer.bindPopup(popup);
}
}
});
overlays[name] = layer;
state.map.addLayer(layer);
layerControl.addOverlay(layer, name);
state.bakedData[name] = entry;
if (name.toLowerCase().includes('eigentümer') || name.toLowerCase().includes('flurstücke')) {
layer.bringToBack();
}
// Auto-Zoom to Planning Area (Standalone Mode)
if (name.toLowerCase().includes('windeignungsgebiet')) {
state.map.fitBounds(layer.getBounds());
}
}
statusEl.innerText = "Stand-Alone Daten geladen.";
return;
}
// Priority 2: Use window.LAYER_CONFIG (Script-based config)
let layers = window.LAYER_CONFIG;
// Fallback: Fetch layers.json (if server is running)
if (!layers) {
const resp = await fetch('config/layers.json').catch(() => null);
if (resp && resp.ok) layers = await resp.json();
}
if (!layers) {
if (isLocalFile) {
statusEl.innerHTML = `<b style="color: #ff8800;">Manuelles Laden:</b> Ziehen Sie Shapefiles auf die Karte.`;
}
return;
}
for (const l of layers) {
if (l.file.toLowerCase().endsWith('.geojson')) {
await loadLocalLayer(`data/${l.file}`, l.name, l.color);
} else {
await loadShapefileLayer(l);
}
}
statusEl.innerText = "Alle konfigurierten Layer geladen.";
} catch (e) {
if (!isLocalFile) console.error("Layer-Init fehlgeschlagen:", e);
}
}
// Manual Import & Bundling
const btnManualImport = document.getElementById('btnManualImport');
const manualShpInput = document.getElementById('manualShpInput');
const btnExportBundle = document.getElementById('btnExportBundle');
if (btnExportBundle) {
btnExportBundle.onclick = () => {
if (Object.keys(state.bakedData).length === 0) {
alert("Keine Daten zum Exportieren vorhanden! Bitte laden Sie erst Shapefiles.");
return;
}
// Include Parcel statuses and mapping in the export
const exportData = {
layers: state.bakedData,
ownerMapping: state.ownerMapping,
ownerStatuses: state.ownerStatuses
};
const content = `window.BAKED_DATA = ${JSON.stringify(exportData)};`;
const blob = new Blob([content], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'baked_layers.js';
a.click();
URL.revokeObjectURL(url);
alert("Bundle erstellt! Bitte speichern Sie 'baked_layers.js' im Ordner /data des Projekts.");
};
}
// Owner Management UI & Logic
const btnManageOwners = document.getElementById('btnManageOwners');
const ownerModal = document.getElementById('ownerModal');
const btnCloseOwnerModal = document.getElementById('btnCloseOwnerModal');
const ownerMappingSection = document.getElementById('ownerMappingSection');
const ownerListSection = document.getElementById('ownerListSection');
const selectFirstName = document.getElementById('selectFirstName');
const selectLastName = document.getElementById('selectLastName');
const btnConfirmMapping = document.getElementById('btnConfirmMapping');
const ownerTableBody = document.querySelector('#ownerTable tbody');
const ownerSearch = document.getElementById('ownerSearch');
btnManageOwners.onclick = () => {
const ownerLayer = Object.keys(overlays).find(k => k.toLowerCase().includes('eigentümer'));
if (!ownerLayer) {
alert("Kein Eigentümer-Layer gefunden! Bitte laden Sie erst die Eigentümer-Daten.");
return;
}
ownerModal.style.display = 'flex';
if (!state.ownerMapping) {
showMappingStage(overlays[ownerLayer]);
} else {
showOwnerListStage(overlays[ownerLayer]);
}
};
btnCloseOwnerModal.onclick = () => ownerModal.style.display = 'none';
function showMappingStage(layer) {
ownerMappingSection.style.display = 'flex';
ownerListSection.style.display = 'none';
// Extract properties from all available features to ensure we get all keys
const allKeys = new Set();
layer.eachLayer(l => {
if (l.feature && l.feature.properties) {
Object.keys(l.feature.properties).forEach(k => allKeys.add(k));
}
});
const sortedKeys = Array.from(allKeys).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
if (sortedKeys.length > 0) {
const optionsHtml = sortedKeys.map(k => `<option value="${k}">${k}</option>`).join('');
selectFirstName.innerHTML = optionsHtml;
selectLastName.innerHTML = optionsHtml;
// Smart Defaults: Prefer VNA for First Name and NBA (or GNA/Name) for Last Name
const vnaMatch = sortedKeys.find(k => k.toLowerCase() === 'vna');
const nbaMatch = sortedKeys.find(k => k.toLowerCase() === 'nba');
const gnaMatch = sortedKeys.find(k => k.toLowerCase() === 'gna');
if (vnaMatch) selectFirstName.value = vnaMatch;
else {
const vMatch = sortedKeys.find(k => k.toLowerCase().startsWith('v'));
if (vMatch) selectFirstName.value = vMatch;
}
if (nbaMatch) selectLastName.value = nbaMatch;
else if (gnaMatch) selectLastName.value = gnaMatch;
else {
const nMatch = sortedKeys.find(k => k.toLowerCase().startsWith('n'));
if (nMatch) selectLastName.value = nMatch;
}
} else {
selectFirstName.innerHTML = '<option value="">Keine Spalten gefunden</option>';
selectLastName.innerHTML = '<option value="">Keine Spalten gefunden</option>';
}
}
btnConfirmMapping.onclick = () => {
state.ownerMapping = {
firstName: selectFirstName.value,
lastName: selectLastName.value
};
const ownerLayer = Object.keys(overlays).find(k => k.toLowerCase().includes('eigentümer'));
showOwnerListStage(overlays[ownerLayer]);
};
function showOwnerListStage(layer) {
ownerMappingSection.style.display = 'none';
ownerListSection.style.display = 'flex';
updateOwnerTable(layer);
}
function updateOwnerTable(layer) {
const owners = {};
layer.eachLayer(l => {
const p = l.feature.properties;
const first = p[state.ownerMapping.firstName] || '';
const last = p[state.ownerMapping.lastName] || '';
const fullName = `${first} ${last}`.trim() || "Unbekannt";
if (!owners[fullName]) {
owners[fullName] = { count: 0, first, last };
}
owners[fullName].count++;
});
renderOwnerRows(owners);
ownerSearch.oninput = () => {
const query = ownerSearch.value.toLowerCase();
const filtered = {};
for (let name in owners) {
if (name.toLowerCase().includes(query)) filtered[name] = owners[name];
}
renderOwnerRows(filtered);
};
}
function renderOwnerRows(owners) {
ownerTableBody.innerHTML = '';
Object.keys(owners).sort().forEach(name => {
const data = owners[name];
const status = state.ownerStatuses[name] || 'none';
const row = document.createElement('tr');
row.innerHTML = `
<td><b>${name}</b></td>
<td>${data.count} Flurstücke</td>
<td>
<select class="status-select" data-owner="${name}">
<option value="none" ${status === 'none' ? 'selected' : ''}>Kein Status</option>
<option value="gbr" ${status === 'gbr' ? 'selected' : ''}>Mitglied der GbR</option>
<option value="external" ${status === 'external' ? 'selected' : ''}>Fremdplanung</option>
<option value="declined" ${status === 'declined' ? 'selected' : ''}>Ablehnend</option>
<option value="positive" ${status === 'positive' ? 'selected' : ''}>Positiv</option>
<option value="undecided" ${status === 'undecided' ? 'selected' : ''}>Unentschlossen</option>
</select>
</td>
<td>
<button class="btn-secure" data-first="${data.first}" data-last="${data.last}" style="padding: 4px 8px; font-size: 0.75rem; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer;">Sichern</button>
</td>
`;
ownerTableBody.appendChild(row);
});
// Add event listeners to dropdowns
document.querySelectorAll('.status-select').forEach(sel => {
sel.onchange = async (e) => {
const name = e.target.dataset.owner;
const status = e.target.value;
state.ownerStatuses[name] = status;
// Sync with DB
const data = owners[name];
if (data) {
await secureOwner(data.first, data.last, e.target, status);
}
// Refresh map style
const ownerLayerName = Object.keys(overlays).find(k => k.toLowerCase().includes('eigentümer'));
if (ownerLayerName) {
overlays[ownerLayerName].setStyle(overlays[ownerLayerName].options.style);
}
};
});
// Add event listeners to secure buttons
document.querySelectorAll('.btn-secure').forEach(btn => {
btn.onclick = async (e) => {
const first = e.target.dataset.first;
const last = e.target.dataset.last;
await secureOwner(first, last, e.target);
};
});
}
async function secureOwner(vorname, nachname, element, status = 'Gesichert') {
const isButton = element.tagName === 'BUTTON';
const originalText = isButton ? element.innerText : "";
if (isButton) {
element.innerText = "Wait...";
element.disabled = true;
}
const projekt_id = "BWSamern-Ohne";
try {
const response = await fetch('/api/sicherung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vorname, nachname, projekt_id, status })
});
const result = await response.json();
if (response.ok) {
if (isButton) {
element.style.background = '#2ecc71';
element.innerText = "✓ Gesichert";
}
console.log(result.message);
const fullName = `${vorname} ${nachname}`.trim();
state.ownerStatuses[fullName] = status;
const ownerLayerName = Object.keys(overlays).find(k => k.toLowerCase().includes('eigentümer'));
if (ownerLayerName) {
overlays[ownerLayerName].setStyle(overlays[ownerLayerName].options.style);
}
const select = document.querySelector(`.status-select[data-owner="${fullName}"]`);
if (select) select.value = status;
} else {
throw new Error(result.error || "Fehler");
}
} catch (err) {
console.error(err);
if (isButton) {
element.style.background = '#e74c3c';
element.innerText = "Error";
setTimeout(() => {
element.style.background = '';
element.innerText = originalText;
element.disabled = false;
}, 2000);
}
}
}
async function handleFileSelection(fileList) {
const files = Array.from(fileList);
const shpFiles = files.filter(f => f.name.toLowerCase().endsWith('.shp'));
const dbfFiles = files.filter(f => f.name.toLowerCase().endsWith('.dbf'));
if (shpFiles.length === 0) {
document.getElementById('statusInfo').innerHTML = `<span style="color: #ff8800;">Keine .shp Dateien gefunden.</span>`;
return;
}
for (const shpFile of shpFiles) {
const baseName = shpFile.name.slice(0, -4);
const dbfFile = dbfFiles.find(f => f.name.slice(0, -4).toLowerCase() === baseName.toLowerCase());
if (dbfFile) {
const shpBuffer = await shpFile.arrayBuffer();
const dbfBuffer = await dbfFile.arrayBuffer();
await processShapefileBuffers(shpBuffer, dbfBuffer, baseName);
} else {
alert(`DBF-Datei für ${baseName} fehlt! Bitte wählen Sie .shp und .dbf gleichzeitig aus.`);
}
}
}
if (btnManualImport && manualShpInput) {
btnManualImport.onclick = () => manualShpInput.click();
manualShpInput.onchange = (e) => handleFileSelection(e.target.files);
}
// Drag & Drop Support
const mapContainer = state.map.getContainer();
mapContainer.addEventListener('dragover', (e) => {
e.preventDefault();
mapContainer.style.outline = '4px dashed var(--primary-color)';
mapContainer.style.outlineOffset = '-10px';
});
mapContainer.addEventListener('dragleave', () => {
mapContainer.style.outline = '';
});
mapContainer.addEventListener('drop', (e) => {
e.preventDefault();
mapContainer.style.outline = '';
if (e.dataTransfer.files.length > 0) {
handleFileSelection(e.dataTransfer.files);
}
});
async function loadOwnerStatusesFromDB() {
const projekt_id = "BWSamern-Ohne";
console.log("Lade Eigentümer-Status aus Datenbank...");
try {
const response = await fetch(`/api/sicherung/${projekt_id}`);
if (response.ok) {
const statuses = await response.json();
statuses.forEach(s => {
const fullName = `${s.vorname} ${s.nachname}`.trim();
state.ownerStatuses[fullName] = s.status;
});
console.log(`${statuses.length} Eigentümer-Status geladen.`);
// Refresh map style if owner layer exists
const ownerLayerName = Object.keys(overlays).find(k => k.toLowerCase().includes('eigentümer'));
if (ownerLayerName && overlays[ownerLayerName]) {
overlays[ownerLayerName].setStyle(overlays[ownerLayerName].options.style);
}
}
} catch (err) {
console.error("Fehler beim Laden der Status:", err);
}
}
initDynamicLayers().then(() => {
loadOwnerStatusesFromDB();
loadTurbinesFromDB();
});
// Project Persistence
const btnSave = document.getElementById('btnSaveProject');
const btnLoad = document.getElementById('btnLoadProject');
const projectInput = document.getElementById('projectInput');
btnSave.addEventListener('click', () => {
const projectData = {
version: "1.0",
timestamp: new Date().toISOString(),
config: state.config,
turbines: state.turbines.map(t => ({
id: t.id,
nr: t.nr,
variant: t.variant,
type: t.type,
rd: t.rd,
hh: t.hh,
latlng: t.layers.marker.getLatLng()
})),
ownerMapping: state.ownerMapping,
ownerStatuses: state.ownerStatuses
};
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `WindPlan_Projekt_${new Date().toLocaleDateString()}.json`;
a.click();
URL.revokeObjectURL(url);
document.getElementById('statusInfo').innerText = "Projekt lokal exportiert.";
});
// DB Persistence
let autoSaveTimeout = null;
function triggerAutoSave() {
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(() => {
saveTurbinesToDB();
}, 1500); // 1.5 Seconds debounce
}
async function saveTurbinesToDB() {
const statusEl = document.getElementById('statusInfo');
if (statusEl) statusEl.innerText = "Automatische Speicherung...";
const projekt_id = "BWSamern-Ohne";
const turbineData = state.turbines.map(t => ({
nr: t.nr,
variant: t.variant,
type: t.type,
rd: t.rd,
hh: t.hh,
ksfAngle: t.ksfAngle,
latlng: t.layers.marker.getLatLng()
}));
try {
const response = await fetch('/api/wea', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projekt_id, turbines: turbineData })
});
const result = await response.json();
if (response.ok) {
if (statusEl) statusEl.innerHTML = `<span style="color: #2ecc71;">${result.message}</span>`;
} else {
throw new Error(result.error || "Fehler beim Speichern in DB");
}
} catch (err) {
console.error(err);
if (statusEl) statusEl.innerHTML = `<span style="color: #ff4444;">Auto-Save Fehler: ${err.message}</span>`;
}
}
const btnSaveDB = document.getElementById('btnSaveDB');
if (btnSaveDB) {
btnSaveDB.addEventListener('click', saveTurbinesToDB);
}
async function loadTurbinesFromDB() {
const projekt_id = "BWSamern-Ohne";
const statusEl = document.getElementById('statusInfo');
console.log(`Lade WEAs aus Datenbank für Projekt: ${projekt_id}...`);
try {
const response = await fetch(`/api/wea/${projekt_id}`);
if (response.ok) {
const dbTurbines = await response.json();
console.log(`Datenbank-Response: ${dbTurbines.length} WEAs erhalten.`);
if (dbTurbines.length > 0) {
// Clear existing turbines
state.turbines.forEach(t => {
Object.values(t.layers).forEach(l => variantLayers[t.variant].removeLayer(l));
});
state.turbines = [];
dbTurbines.forEach(t => {
const latlng = L.latLng(t.lat, t.lng);
createTurbine(latlng, null, {
nr: t.nr,
type: t.type,
rd: t.rd,
hh: t.hh,
ksfAngle: t.ksfangle ?? t.ksfAngle ?? 0,
variant: t.variant
});
});
statusEl.innerText = `${dbTurbines.length} WEAs aus Datenbank geladen.`;
} else {
console.log("Datenbank ist leer für dieses Projekt.");
}
} else {
console.error("Fehler beim API-Aufruf:", response.status);
}
} catch (err) {
console.error("Netzwerkfehler beim Laden aus der DB:", err);
}
}
// Load from DB after layers are initialized
initDynamicLayers().then(() => {
loadTurbinesFromDB();
});
btnLoad.addEventListener('click', () => projectInput.click());
projectInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
// Clear existing turbines
state.turbines.forEach(t => {
Object.values(t.layers).forEach(l => {
variantLayers[t.variant].removeLayer(l);
});
});
state.turbines = [];
// Restore Owner Data
state.ownerMapping = data.ownerMapping || null;
state.ownerStatuses = data.ownerStatuses || {};
// Reconstruct from data
data.turbines.forEach(tData => {
// Set current UI state momentarily for creating turbine correctly
// (Easier than refactoring createTurbine now)
inputType.value = tData.type;
inputRotor.value = tData.rd;
inputHub.value = tData.hh;
const oldVariant = state.activeVariant;
state.activeVariant = tData.variant;
createTurbine(tData.latlng, tData.nr);
state.activeVariant = oldVariant;
});
updateProximityLines();
updateLegend();
triggerAutoSave();
document.getElementById('statusInfo').innerText = "Projekt geladen.";
} catch (err) {
console.error("Fehler beim Laden:", err);
alert("Ungültige Projektdatei.");
}
};
reader.readAsText(file);
});
updateLegend();
console.log("WindPlaner initialisiert.");
document.getElementById('statusInfo').innerText = "System bereit. Karte geladen.";
});