refactor(leitungsrechner): professionelles React-Panel + zentrale Berechnung
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33m10s

- Neue lib src/lib/hose-calc.ts: EINZIGE Quelle der Druckformel (typisiert),
  inkl. Schlauchanzahl aus lengthPerPieceM (Feld war vorher tot) + 10% Verlegereserve
- MeasurePanel als echte React-Komponente ersetzt das innerHTML/Inline-CSS-Overlay
  in map-view.tsx (~130 Zeilen DOM-Gebastel entfernt)
- Schlauchtyp im Messergebnis wählbar (Standard vorausgewählt) statt alle gestapelt
- Strahlrohrdruck einstellbar (4/5/6/8 bar), Ergebnis rechnet live neu
- Höhendaten-Quelle ehrlich ausgewiesen; sichtbare Warnung wenn keine Höhendaten
  (vorher stilles "alles flach")
- hose-settings-dialog nutzt die zentrale Formel (Duplikat entfernt)
- Version 1.4.9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-19 00:44:55 +02:00
parent bae1cfe9a0
commit ddb7c63600
6 changed files with 346 additions and 116 deletions

View File

@@ -7,6 +7,8 @@ import { useDrop } from 'react-dnd'
import Moveable from 'react-moveable'
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
import type { Project, DrawFeature, DrawMode } from '@/types'
import { MeasurePanel } from './measure-panel'
import { FALLBACK_HOSE_TYPES, type HoseTypeData, type MeasurementSummary } from '@/lib/hose-calc'
// Haversine distance between two [lng, lat] points in meters
function haversineDistance(a: number[], b: number[]): number {
@@ -110,6 +112,8 @@ export function MapView({
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
const [measurePointCount, setMeasurePointCount] = useState(0)
const [measureFinished, setMeasureFinished] = useState(false)
// Messergebnis für das React-Panel (ersetzt das frühere innerHTML-DOM-Overlay)
const [measureResult, setMeasureResult] = useState<{ measurement: MeasurementSummary; hoseTypes: HoseTypeData[] } | null>(null)
const [drawingPointCount, setDrawingPointCount] = useState(0)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const tooltipPosRef = useRef({ x: 0, y: 0 })
@@ -447,6 +451,7 @@ export function MapView({
const lngs = sampledCoords.map(c => c[0].toFixed(6)).join(',')
let sampledElevations: number[] = []
let elevationSource: 'open-meteo' | 'open-elevation' | 'none' = 'none'
// Try Open-Meteo first
try {
@@ -454,6 +459,7 @@ export function MapView({
if (res.ok) {
const data = await res.json()
sampledElevations = data.elevation || []
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-meteo'
}
} catch (e) {
console.warn('[Elevation] Open-Meteo nicht erreichbar')
@@ -471,15 +477,17 @@ export function MapView({
if (res.ok) {
const data = await res.json()
sampledElevations = (data.results || []).map((r: any) => r.elevation)
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-elevation'
}
} catch (e) {
console.warn('[Elevation] Open-Elevation auch nicht erreichbar')
}
}
// Final fallback: flat terrain
// Final fallback: flat terrain (ehrlich als 'none' ausgewiesen)
if (sampledElevations.length !== sampledCoords.length) {
sampledElevations = sampledCoords.map(() => 0)
elevationSource = 'none'
}
// Extract elevations for the original measurement points
@@ -507,138 +515,51 @@ export function MapView({
minSampledElev = Math.min(minSampledElev, sampledElevations[i])
}
// === Feuerwehr Druckverlust-Berechnung (Schweizer Standard) ===
const AUSGANGSDRUCK_STRAHLROHR = 5.0 // bar am Strahlrohr/Monitor
const PUMPE_MAX_DRUCK = 10.0 // bar Förderdruck TS/FP
const HOEHENDRUCK_FAKTOR = 0.1 // bar pro Meter Höhe
// Load hose types from API (fall back to hardcoded defaults)
let hoseTypes: { name: string; diameter: number; flow: number; c: number }[] = []
// === Leitungsrechner: Schlauchtypen laden (volle Stammdaten) ===
let hoseTypes: HoseTypeData[] = []
try {
const htRes = await fetch('/api/hose-types')
if (htRes.ok) {
const htData = await htRes.json()
if (htData.hoseTypes && htData.hoseTypes.length > 0) {
hoseTypes = htData.hoseTypes.map((ht: any) => ({
id: ht.id,
name: ht.name,
diameter: ht.diameterMm,
flow: ht.flowRateLpm,
c: ht.frictionCoeff,
diameterMm: ht.diameterMm,
flowRateLpm: ht.flowRateLpm,
frictionCoeff: ht.frictionCoeff,
lengthPerPieceM: ht.lengthPerPieceM ?? 20,
isDefault: ht.isDefault === true,
}))
}
}
} catch { /* ignore */ }
if (hoseTypes.length === 0) {
hoseTypes = [
{ name: '55mm Transportleitung', diameter: 55, flow: 500, c: 0.034 },
{ name: '75mm Zubringerleitung', diameter: 75, flow: 1500, c: 0.012 },
]
}
if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES
let totalDist = 0
let totalHoehenDruck = 0
const segments: { dist: number; elDiff: number }[] = []
for (let i = 1; i < coords.length; i++) {
const segDist = haversineDistance(coords[i - 1], coords[i])
const elDiff = elevations[i] - elevations[i - 1]
totalDist += segDist
totalHoehenDruck += elDiff * HOEHENDRUCK_FAKTOR
segments.push({ dist: segDist, elDiff })
totalDist += haversineDistance(coords[i - 1], coords[i])
}
// Calculate for each hose type
const hoseCalcs = hoseTypes.map(h => {
const reibung = h.c * Math.pow(h.flow / 100, 2) * (totalDist / 100)
const gesamt = reibung + totalHoehenDruck + AUSGANGSDRUCK_STRAHLROHR
const pumpen = Math.ceil(Math.max(gesamt, 0) / PUMPE_MAX_DRUCK)
return { ...h, reibung, gesamt, pumpen }
})
const elStart = elevations[0]
const elEnd = elevations[elevations.length - 1]
const elDiff = elEnd - elStart
const elMax = Math.max(...elevations)
const elMin = Math.min(...elevations)
// Remove existing info panel if any
const existingPanel = document.getElementById('measure-info-panel')
if (existingPanel) existingPanel.remove()
// Create info panel overlay
const panel = document.createElement('div')
panel.id = 'measure-info-panel'
const isDark = document.documentElement.classList.contains('dark')
panel.style.cssText = `
position: absolute; bottom: 48px; left: 16px; z-index: 1000;
background: ${isDark ? '#1a1a1a' : 'white'}; color: ${isDark ? '#c8a060' : '#1e293b'};
border: 2px solid ${isDark ? '#c89040' : '#fbbf24'}; border-radius: 12px;
padding: 14px 18px; font-size: 13px; line-height: 1.6;
box-shadow: 0 4px 20px rgba(0,0,0,0.3); max-width: 380px;
font-family: system-ui, sans-serif;
`
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid ${isDark ? '#333' : '#e5e7eb'};padding-bottom:6px;">
<span style="font-weight:700;font-size:15px;">📏 Messergebnis</span>
<button id="measure-panel-close" style="background:none;border:none;cursor:pointer;font-size:20px;color:${isDark ? '#886633' : '#94a3b8'};padding:0 4px;line-height:1;">✕</button>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Distanz:</span>
<span style="font-weight:600">${formatDistance(totalDist)}</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhe Start:</span>
<span style="font-weight:600">${Math.round(elStart)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhe Ende:</span>
<span style="font-weight:600">${Math.round(elEnd)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Min / Max:</span>
<span style="font-weight:600">${Math.round(minSampledElev)} / ${Math.round(maxSampledElev)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Aufstieg:</span>
<span style="font-weight:600;color:#ef4444">+${Math.round(totalClimb)} m ↑</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Abstieg:</span>
<span style="font-weight:600;color:#22c55e">-${Math.round(totalDescent)} m ↓</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Netto Diff.:</span>
<span style="font-weight:600;color:${elDiff > 0 ? '#ef4444' : '#22c55e'}">${elDiff > 0 ? '+' : ''}${Math.round(elDiff)} m ${elDiff > 0 ? '↑' : elDiff < 0 ? '↓' : '→'}</span>
</div>
<div style="font-size:11px;color:${isDark ? '#665533' : '#94a3b8'};margin-top:4px;">
${sampledCoords.length} Messpunkte (alle ${SAMPLE_INTERVAL}m), Quelle: Open-Meteo DEM (~90m Raster)
</div>
<div style="font-weight:700;font-size:15px;margin:10px 0 6px;border-bottom:1px solid ${isDark ? '#333' : '#e5e7eb'};padding-bottom:6px;">
🚒 Schlauchleitung (3er Verteiler, ${AUSGANGSDRUCK_STRAHLROHR} bar Strahlrohr)
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhendruck:</span>
<span style="font-weight:600;color:${totalHoehenDruck > 0 ? '#ef4444' : '#22c55e'}">${totalHoehenDruck > 0 ? '+' : ''}${totalHoehenDruck.toFixed(1)} bar</span>
</div>
${hoseCalcs.map(h => {
const w = h.gesamt > PUMPE_MAX_DRUCK
return `
<div style="margin-top:8px;padding:8px 10px;background:${isDark ? '#222' : '#f8fafc'};border-radius:8px;border:1px solid ${isDark ? '#333' : '#e5e7eb'};">
<div style="font-weight:700;font-size:13px;margin-bottom:4px;">⬤ ${h.name} (${h.diameter}mm, ${h.flow} l/min)</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:1px 10px;font-size:12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Reibung:</span>
<span style="font-weight:600">${h.reibung.toFixed(1)} bar (${(h.c * Math.pow(h.flow / 100, 2)).toFixed(2)} bar/100m)</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Gesamt:</span>
<span style="font-weight:700;color:${w ? '#ef4444' : '#22c55e'}">${h.gesamt.toFixed(1)} bar</span>
</div>
<div style="margin-top:4px;padding:4px 8px;background:${w ? (isDark ? '#3a1a1a' : '#fef2f2') : (isDark ? '#1a2a1a' : '#f0fdf4')};border-radius:6px;font-size:12px;font-weight:600;color:${w ? '#ef4444' : '#22c55e'};">
${h.pumpen <= 1
? '✅ 1 Pumpe reicht'
: '⚠️ ' + h.pumpen + ' Pumpen! Verstärker alle ~' + Math.round(totalDist / (h.pumpen - 1)) + 'm'
}
</div>
</div>`
}).join('')}
`
// Attach to map container
const container = map.current?.getContainer()
if (container) {
container.style.position = 'relative'
container.appendChild(panel)
// X button closes panel
const closeBtn = document.getElementById('measure-panel-close')
if (closeBtn) {
closeBtn.addEventListener('click', () => panel.remove())
}
}
// Ergebnis in React-State — das MeasurePanel übernimmt Darstellung + Interaktion
setMeasureResult({
measurement: {
totalDistanceM: totalDist,
elevationStartM: elStart,
elevationEndM: elEnd,
netDiffM: elEnd - elStart,
climbM: totalClimb,
descentM: totalDescent,
minElevationM: minSampledElev,
maxElevationM: maxSampledElev,
samplePointCount: sampledCoords.length,
elevationSource,
},
hoseTypes,
})
// Add elevation labels at each point
coords.forEach((pt, i) => {
@@ -2384,6 +2305,16 @@ export function MapView({
Neue Messung
</button>
)}
{/* Messergebnis + Leitungsrechner (React-Panel, ersetzt das alte DOM-Overlay) */}
{measureResult && (
<MeasurePanel
measurement={measureResult.measurement}
hoseTypes={measureResult.hoseTypes}
onClose={() => setMeasureResult(null)}
/>
)}
{/* Inline edit overlay — replaces native prompt() to prevent fullscreen exit */}
{inlineEdit && (
<div