Enabled MultiLine segments for route variants
Deploy TrassenPlaner / deploy (push) Waiting to run Details

This commit is contained in:
Johannes Baumeister 2026-04-15 22:02:23 +02:00
parent 0cf7110f67
commit 60eadc4d38
1 changed files with 182 additions and 201 deletions

View File

@ -1888,84 +1888,80 @@
}
});
// Helper function to flatten coordinates
function getFlattenedCoords(rawRoutes) {
let latLngs = [];
const flattenLL = (raw) => {
if (!raw) return;
if (typeof raw === 'object' && raw.lat !== undefined && !isNaN(raw.lat)) {
latLngs.push(L.latLng(raw));
} else if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === 'number') {
latLngs.push(L.latLng(raw));
} else if (Array.isArray(raw)) {
raw.forEach(item => flattenLL(item));
function getFlattenedCoords(raw) {
if (!raw) return [];
// If it's a flat array of LatLngs
if (raw.length > 0 && (raw[0].lat !== undefined || (Array.isArray(raw[0]) && typeof raw[0][0] === 'number'))) {
return raw.map(ll => L.latLng(ll));
}
// If it's nested
const flattened = [];
raw.forEach(section => {
if (Array.isArray(section)) {
section.forEach(ll => flattened.push(L.latLng(ll)));
}
};
flattenLL(rawRoutes);
return latLngs.map(ll => {
try { return L.latLng(ll); } catch (e) { return null; }
}).filter(ll => ll && !isNaN(ll.lat) && !isNaN(ll.lng));
});
return flattened;
}
function getNestedCoords(raw) {
if (!raw) return [];
// Check if it's already nested
if (raw.length > 0 && Array.isArray(raw[0]) && raw[0].length > 0 && (raw[0][0].lat !== undefined || Array.isArray(raw[0][0]))) {
return raw.map(section => section.map(ll => L.latLng(ll)));
}
// If it's flat, wrap it
if (raw.length > 0) return [raw.map(ll => L.latLng(ll))];
return [];
}
function renderSegmentLabels(variant, coordsInput = null, tempPt = null) {
const vLabel = labelLayers[variant.id];
if (!vLabel) return;
if (!variant.visible) { vLabel.clearLayers(); return; }
// Use passed coordinates or fall back to state
let latLngs = coordsInput ? [...coordsInput] : getFlattenedCoords(variant.routes);
if (tempPt) latLngs.push(L.latLng(tempPt));
if (!variant.visible || latLngs.length < 2) {
vLabel.clearLayers();
return;
}
const nested = coordsInput ? [coordsInput] : getNestedCoords(variant.routes);
const currentMarkers = vLabel.getLayers();
let markerIdx = 0;
for (let i = 0; i < latLngs.length - 1; i++) {
const p1 = latLngs[i];
const p2 = latLngs[i + 1];
const dist = map.distance(p1, p2);
const mid = L.latLng((p1.lat + p2.lat) / 2, (p1.lng + p2.lng) / 2);
const labelText = `${dist.toFixed(0)}m`;
nested.forEach((latLngs, sectionIdx) => {
const drawLatLngs = [...latLngs];
// Only add temp point to the very LAST section if we are actively drawing
if (tempPt && sectionIdx === nested.length - 1) drawLatLngs.push(L.latLng(tempPt));
if (markerIdx < currentMarkers.length) {
const m = currentMarkers[markerIdx];
m.setLatLng(mid);
const el = m.getElement();
let contentSpan = null;
if (el) {
contentSpan = el.querySelector('.segment-label-content');
}
if (drawLatLngs.length < 2) return;
if (contentSpan) {
contentSpan.textContent = labelText;
for (let i = 0; i < drawLatLngs.length - 1; i++) {
const p1 = drawLatLngs[i];
const p2 = drawLatLngs[i + 1];
const dist = map.distance(p1, p2);
const mid = L.latLng((p1.lat + p2.lat) / 2, (p1.lng + p2.lng) / 2);
const labelText = `${dist.toFixed(0)}m`;
if (markerIdx < currentMarkers.length) {
const m = currentMarkers[markerIdx];
m.setLatLng(mid);
const el = m.getElement();
if (el) {
const span = el.querySelector('.segment-label-content');
if (span) span.textContent = labelText;
}
} else {
// Backup if element not yet rendered or span missing
m.setIcon(L.divIcon({
className: 'segment-label',
html: `<span class="segment-label-content">${labelText}</span>`,
iconSize: [46, 20],
iconAnchor: [23, 10]
}));
L.marker(mid, {
interactive: false,
pane: 'labelPane',
icon: L.divIcon({
className: 'segment-label',
html: `<span class="segment-label-content">${labelText}</span>`,
iconSize: [46, 20],
iconAnchor: [23, 10]
})
}).addTo(vLabel);
}
} else {
L.marker(mid, {
interactive: false,
pane: 'labelPane', // Explicitly set pane
icon: L.divIcon({
className: 'segment-label',
html: `<span class="segment-label-content">${labelText}</span>`,
iconSize: [46, 20],
iconAnchor: [23, 10]
})
}).addTo(vLabel);
markerIdx++;
}
markerIdx++;
}
});
// Remove excess markers
while (markerIdx < currentMarkers.length) {
vLabel.removeLayer(currentMarkers[markerIdx++]);
}
@ -1979,26 +1975,18 @@
const vDrill = drillingLayers[variant.id];
if (vDrill) vDrill.clearLayers();
const latLngs = getFlattenedCoords(variant.routes);
if (latLngs.length < 2) {
variant.stats.total = 0;
variant.stats.drilling = 0;
variant.stats.muffen = 0;
return;
}
const nested = getNestedCoords(variant.routes);
variant.stats.total = 0;
variant.stats.drilling = 0;
variant.stats.muffen = 0;
variant.stats.hasTooLongDrilling = false;
variant.drillingSegments = [];
if (nested.length === 0) return;
// Sync labels for final state
renderSegmentLabels(variant);
const line = turf.lineString(latLngs.map(ll => [ll.lng, ll.lat]));
variant.stats.total = turf.length(line, { units: 'meters' });
const startPoint = turf.point([latLngs[0].lng, latLngs[0].lat]);
// --- Bohrungs-Logik (Optimized Version) ---
const drillingRanges = [];
// Only re-filter if cache is empty or data changed
// Pre-filter obstacles once
if (!cachedObstacles && state.usage.features) {
const keywords = ['bahn', 'gewässer', 'wasser', 'straße', 'verkehr', 'gehölz', 'baufläche', 'wald', 'forst', 'hecke', 'weg', 'pfad', 'graben', 'bach', 'fluss'];
cachedObstacles = state.usage.features.filter(f => {
@ -2006,132 +1994,114 @@
return keywords.some(k => type.includes(k));
});
}
const currentObstacles = cachedObstacles || [];
if (currentObstacles.length > 0) {
const lineBbox = turf.bbox(line);
nested.forEach(latLngs => {
if (latLngs.length < 2) return;
currentObstacles.forEach(obs => {
try {
const obsBbox = turf.bbox(obs);
if (lineBbox[0] > obsBbox[2] || lineBbox[2] < obsBbox[0] ||
lineBbox[1] > obsBbox[3] || lineBbox[3] < obsBbox[1]) return;
const line = turf.lineString(latLngs.map(ll => [ll.lng, ll.lat]));
const sectionLength = turf.length(line, { units: 'meters' });
variant.stats.total += sectionLength;
if (turf.booleanIntersects(line, obs)) {
// Robust Intersection Strategy:
// 1. Find all intersection points
const intersect = turf.lineIntersect(line, obs);
let distances = [0, variant.stats.total];
const startPoint = turf.point([latLngs[0].lng, latLngs[0].lat]);
const drillingRanges = [];
intersect.features.forEach(f => {
const d = turf.length(turf.lineSlice(startPoint, f, line), { units: 'meters' });
distances.push(d);
});
// 2. Sort unique distances to create segments
distances = [...new Set(distances)].sort((a, b) => a - b);
// 3. Test the midpoint of each segment
for (let i = 0; i < distances.length - 1; i++) {
const dStart = distances[i];
const dEnd = distances[i + 1];
const midDist = (dStart + dEnd) / 2;
const midPt = turf.along(line, midDist / 1000, { units: 'kilometers' });
if (turf.booleanPointInPolygon(midPt, obs)) {
drillingRanges.push([
Math.max(0, dStart - 20),
Math.min(variant.stats.total, dEnd + 20)
]);
if (currentObstacles.length > 0) {
const lineBbox = turf.bbox(line);
currentObstacles.forEach(obs => {
try {
const obsBbox = turf.bbox(obs);
if (lineBbox[0] > obsBbox[2] || lineBbox[2] < obsBbox[0] || lineBbox[1] > obsBbox[3] || lineBbox[3] < obsBbox[1]) return;
if (turf.booleanIntersects(line, obs)) {
const intersect = turf.lineIntersect(line, obs);
let distances = [0, sectionLength];
intersect.features.forEach(f => {
distances.push(turf.length(turf.lineSlice(startPoint, f, line), { units: 'meters' }));
});
distances = [...new Set(distances)].sort((a, b) => a - b);
for (let i = 0; i < distances.length - 1; i++) {
const dStart = distances[i];
const dEnd = distances[i + 1];
const midPt = turf.along(line, (dStart + dEnd) / 2 / 1000, { units: 'kilometers' });
if (turf.booleanPointInPolygon(midPt, obs)) {
drillingRanges.push([Math.max(0, dStart - 20), Math.min(sectionLength, dEnd + 20)]);
}
}
}
}
} catch (e) { /* ignore error */ }
});
}
// Merge
let mergedRanges = [];
if (drillingRanges.length > 0) {
drillingRanges.sort((a, b) => a[0] - b[0]);
let cur = drillingRanges[0];
for (let i = 1; i < drillingRanges.length; i++) {
if (drillingRanges[i][0] <= cur[1]) cur[1] = Math.max(cur[1], drillingRanges[i][1]);
else { mergedRanges.push(cur); cur = drillingRanges[i]; }
}
mergedRanges.push(cur);
}
variant.stats.drilling = mergedRanges.reduce((sum, r) => sum + (r[1] - r[0]), 0);
variant.stats.open = Math.max(0, variant.stats.total - variant.stats.drilling);
variant.stats.muffen = mergedRanges.length * 2;
variant.stats.hasTooLongDrilling = false;
// Reconstruct geometry
variant.drillingSegments = mergedRanges.map(range => {
try {
const lengthM = range[1] - range[0];
if (lengthM > 180) variant.stats.hasTooLongDrilling = true;
const s = turf.along(line, range[0] / 1000, { units: 'kilometers' });
const e = turf.along(line, range[1] / 1000, { units: 'kilometers' });
const sCoord = [s.geometry.coordinates[1], s.geometry.coordinates[0]];
const eCoord = [e.geometry.coordinates[1], e.geometry.coordinates[0]];
return {
path: [sCoord, eCoord],
muffen: [sCoord, eCoord],
length: lengthM
};
} catch (e) { return null; }
}).filter(s => s !== null);
if (variant.visible) {
// Drilling & Muffen
if (vDrill) {
const drillingOpacity = variant.active ? 1 : 0.3;
variant.drillingSegments.forEach(seg => {
L.polyline(seg.path, {
color: '#000000',
weight: 8,
opacity: drillingOpacity,
dashArray: '5, 10',
interactive: false,
pane: 'drillingPane'
}).addTo(vDrill);
// Individual drilling label (number only)
const midLat = (seg.path[0][0] + seg.path[1][0]) / 2;
const midLng = (seg.path[0][1] + seg.path[1][1]) / 2;
L.marker([midLat, midLng], {
interactive: false,
pane: 'labelPane',
icon: L.divIcon({
className: 'drilling-segment-label',
html: `<div style="background: #000000; color: white; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); opacity: ${drillingOpacity};">${seg.length.toFixed(0)}m</div>`,
iconSize: [50, 20],
iconAnchor: [25, 25]
})
}).addTo(vDrill);
// Add Muffen markers
seg.muffen.forEach(mpos => {
L.circleMarker(mpos, {
radius: 5,
color: '#000000',
weight: 2,
fillOpacity: drillingOpacity,
opacity: drillingOpacity,
fillColor: '#ffffff',
pane: 'drillingPane',
interactive: false
}).addTo(vDrill);
});
} catch (e) {}
});
}
let mergedRanges = [];
if (drillingRanges.length > 0) {
drillingRanges.sort((a, b) => a[0] - b[0]);
let cur = drillingRanges[0];
for (let i = 1; i < drillingRanges.length; i++) {
if (drillingRanges[i][0] <= cur[1]) cur[1] = Math.max(cur[1], drillingRanges[i][1]);
else { mergedRanges.push(cur); cur = drillingRanges[i]; }
}
mergedRanges.push(cur);
}
variant.stats.drilling += mergedRanges.reduce((sum, r) => sum + (r[1] - r[0]), 0);
variant.stats.muffen += mergedRanges.length * 2;
mergedRanges.forEach(range => {
const lengthM = range[1] - range[0];
if (lengthM > 180) variant.stats.hasTooLongDrilling = true;
const s = turf.along(line, range[0] / 1000, { units: 'kilometers' });
const e = turf.along(line, range[1] / 1000, { units: 'kilometers' });
variant.drillingSegments.push({
path: [[s.geometry.coordinates[1], s.geometry.coordinates[0]], [e.geometry.coordinates[1], e.geometry.coordinates[0]]],
length: lengthM,
muffen: [[s.geometry.coordinates[1], s.geometry.coordinates[0]], [e.geometry.coordinates[1], e.geometry.coordinates[0]]]
});
});
});
variant.stats.open = Math.max(0, variant.stats.total - variant.stats.drilling);
if (vDrill) {
const drillingOpacity = variant.active ? 1 : 0.3;
variant.drillingSegments.forEach(seg => {
L.polyline(seg.path, {
color: '#000000',
weight: 8,
opacity: drillingOpacity,
interactive: false,
pane: 'drillingPane'
}).addTo(vDrill);
// Individual drilling label (number only)
const midLat = (seg.path[0][0] + seg.path[1][0]) / 2;
const midLng = (seg.path[0][1] + seg.path[1][1]) / 2;
L.marker([midLat, midLng], {
interactive: false,
pane: 'labelPane',
icon: L.divIcon({
className: 'drilling-segment-label',
html: `<div style="background: #000000; color: white; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); opacity: ${drillingOpacity};">${seg.length.toFixed(0)}m</div>`,
iconSize: [50, 20],
iconAnchor: [25, 25]
})
}).addTo(vDrill);
// Add Muffen markers
seg.muffen.forEach(mpos => {
L.circleMarker(mpos, {
radius: 5,
color: '#000000',
weight: 2,
fillOpacity: drillingOpacity,
opacity: drillingOpacity,
fillColor: '#ffffff',
pane: 'drillingPane',
interactive: false
}).addTo(vDrill);
});
});
// ONLY update the sidebar UI/list if this is the active variant
if (variant.active) {
updateRequiredPlots(variant);
@ -2151,11 +2121,14 @@
}
try {
const latLngs = getFlattenedCoords(variant.routes);
if (latLngs.length < 2) return;
const nested = getNestedCoords(variant.routes);
if (nested.length === 0) return;
const line = turf.lineString(latLngs.map(ll => [ll.lng, ll.lat]));
const lineBbox = turf.bbox(line);
const lines = nested.filter(s => s.length >= 2).map(s => turf.lineString(s.map(ll => [ll.lng, ll.lat])));
if (lines.length === 0) return;
const multiLine = lines.length === 1 ? lines[0] : turf.multiLineString(lines.map(l => l.geometry.coordinates));
const lineBbox = turf.bbox(multiLine);
const intersectingPlots = state.owners.features.filter(f => {
try {
@ -2163,7 +2136,7 @@
if (lineBbox[0] > obsBbox[2] || lineBbox[2] < obsBbox[0] ||
lineBbox[1] > obsBbox[3] || lineBbox[3] < obsBbox[1]) return false;
return turf.booleanIntersects(line, f);
return turf.booleanIntersects(multiLine, f);
} catch (e) { return false; }
});
@ -2216,6 +2189,13 @@
}
// --- Variant Management UI ---
window.startNewPath = (id) => {
const layer = routeLayers[id];
if (layer && layer.editor) {
layer.editor.newPath();
}
};
function renderVariants() {
const container = document.getElementById('variant-controls');
if (!container) return;
@ -2284,6 +2264,7 @@
<input type="checkbox" ${v.visible ? 'checked' : ''} onclick="toggleVariantVisibility(${v.id}, this.checked)">
<i data-lucide="eye" style="width: 14px;"></i>
</label>
<i data-lucide="plus-circle" class="action-icon" title="Weiteren Abschnitt hinzufügen" style="cursor: pointer; width: 15px; color: #10b981;" onclick="startNewPath(${v.id})"></i>
<i data-lucide="table" class="action-icon" title="Tabelle herunterladen" style="cursor: pointer; width: 15px; color: var(--corporate-teal);" onclick="downloadVariantTable(${v.id})"></i>
<i data-lucide="copy" class="action-icon" title="Duplizieren" style="cursor: pointer; width: 15px; color: #64748b;" onclick="duplicateVariant(${v.id})"></i>
<i data-lucide="trash-2" class="action-icon" title="Inhalt löschen" style="cursor: pointer; width: 15px; color: var(--danger);" onclick="clearVariant(${v.id})"></i>