diff --git a/ROADMAP.md b/ROADMAP.md index 7e0bce1..562b32d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,6 +52,17 @@ Legende Aufwand: 🟱 klein · 🟡 mittel · 🔮 gross - Live-Sync fĂŒr Journal existierte bereits (Socket → journal-refresh). - _Offen (KĂŒr): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._ +## Zusatz – Leitungsrechner professionalisiert ✅ + +- [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar) +- [x] Ergebnis-Panel als React-Komponente (`MeasurePanel`) statt innerHTML/Inline-CSS +- [x] Schlauchtyp wĂ€hlbar (Standard ★ vorausgewĂ€hlt) statt alle gestapelt +- [x] Strahlrohrdruck einstellbar (4/5/6/8 bar) +- [x] Schlauchanzahl aus `lengthPerPieceM` + 10% Verlegereserve (Feld war vorher tot) +- [x] Ehrliche Höhendaten-Anzeige (Quelle bzw. Warnung „keine Höhendaten") +- Offen (spĂ€ter): Berechnungsparameter pro Mandant, mehrere Leitungen/Pumpenkette, + Elevation-Proxy mit Cache, Ergebnis als speicherbares Lageplan-Objekt. + ## Phase 5 – KĂŒr - [ ] **5.1 – Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal) diff --git a/package.json b/package.json index 466b0a8..821d746 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.8", + "version": "1.4.9", "description": "Feuerwehr Lageplan - Krokier-App fĂŒr Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/components/dialogs/hose-settings-dialog.tsx b/src/components/dialogs/hose-settings-dialog.tsx index 115f277..090facd 100644 --- a/src/components/dialogs/hose-settings-dialog.tsx +++ b/src/components/dialogs/hose-settings-dialog.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' import { Plus, Trash2, Save, Star, Loader2 } from 'lucide-react' +import { frictionPer100m as calcFrictionPer100m } from '@/lib/hose-calc' interface HoseType { id: string @@ -126,11 +127,12 @@ export function HoseSettingsDialog({ open, onOpenChange }: HoseSettingsDialogPro } } + // Nutzt die zentrale Formel aus lib/hose-calc — keine Duplikation mehr const frictionPer100m = (coeff: string, flow: string) => { const c = parseFloat(coeff) const q = parseFloat(flow) if (!c || !q) return '-' - return (c * Math.pow(q / 100, 2)).toFixed(2) + return calcFrictionPer100m({ frictionCoeff: c, flowRateLpm: q }).toFixed(2) } return ( diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 969ffe7..80385bc 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -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(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 = ` -
- 📏 Messergebnis - -
-
- Distanz: - ${formatDistance(totalDist)} - Höhe Start: - ${Math.round(elStart)} m ĂŒ.M. - Höhe Ende: - ${Math.round(elEnd)} m ĂŒ.M. - Min / Max: - ${Math.round(minSampledElev)} / ${Math.round(maxSampledElev)} m ĂŒ.M. - Aufstieg: - +${Math.round(totalClimb)} m ↑ - Abstieg: - -${Math.round(totalDescent)} m ↓ - Netto Diff.: - ${elDiff > 0 ? '+' : ''}${Math.round(elDiff)} m ${elDiff > 0 ? '↑' : elDiff < 0 ? '↓' : '→'} -
-
- ${sampledCoords.length} Messpunkte (alle ${SAMPLE_INTERVAL}m), Quelle: Open-Meteo DEM (~90m Raster) -
-
- 🚒 Schlauchleitung (3er Verteiler, ${AUSGANGSDRUCK_STRAHLROHR} bar Strahlrohr) -
-
- Höhendruck: - ${totalHoehenDruck > 0 ? '+' : ''}${totalHoehenDruck.toFixed(1)} bar -
- ${hoseCalcs.map(h => { - const w = h.gesamt > PUMPE_MAX_DRUCK - return ` -
-
⏀ ${h.name} (${h.diameter}mm, ${h.flow} l/min)
-
- Reibung: - ${h.reibung.toFixed(1)} bar (${(h.c * Math.pow(h.flow / 100, 2)).toFixed(2)} bar/100m) - Gesamt: - ${h.gesamt.toFixed(1)} bar -
-
- ${h.pumpen <= 1 - ? '✅ 1 Pumpe reicht' - : '⚠ ' + h.pumpen + ' Pumpen! VerstĂ€rker alle ~' + Math.round(totalDist / (h.pumpen - 1)) + 'm' - } -
-
` - }).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 )} + + {/* Messergebnis + Leitungsrechner (React-Panel, ersetzt das alte DOM-Overlay) */} + {measureResult && ( + setMeasureResult(null)} + /> + )} + {/* Inline edit overlay — replaces native prompt() to prevent fullscreen exit */} {inlineEdit && (
void +} + +function formatDistance(m: number): string { + return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` +} + +/** + * Ergebnis-Panel des Messwerkzeugs: Streckenprofil + Leitungsrechner. + * Ersetzt das frĂŒhere innerHTML-DOM-Overlay durch eine echte React-Komponente. + */ +export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelProps) { + const defaultHose = hoseTypes.find(h => h.isDefault) || hoseTypes[0] + const [selectedHoseName, setSelectedHoseName] = useState(defaultHose?.name || '') + const [nozzlePressure, setNozzlePressure] = useState(String(DEFAULT_CALC_PARAMS.nozzlePressure)) + const [detailsOpen, setDetailsOpen] = useState(false) + + const selectedHose = hoseTypes.find(h => h.name === selectedHoseName) || defaultHose + + const result = useMemo(() => { + if (!selectedHose) return null + return calcHoseLine( + measurement.totalDistanceM, + measurement.netDiffM, + selectedHose, + { ...DEFAULT_CALC_PARAMS, nozzlePressure: parseFloat(nozzlePressure) || DEFAULT_CALC_PARAMS.nozzlePressure } + ) + }, [selectedHose, measurement, nozzlePressure]) + + const noElevation = measurement.elevationSource === 'none' + + return ( +
+ {/* Header */} +
+ + Messergebnis + + +
+ +
+ {/* Strecke kompakt */} +
+ Distanz + {formatDistance(measurement.totalDistanceM)} +
+
+ + +{Math.round(measurement.climbM)} m + + + −{Math.round(measurement.descentM)} m + + 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}> + Netto {measurement.netDiffM > 0 ? '+' : ''}{Math.round(measurement.netDiffM)} m + +
+ + {/* Höhen-Details (einklappbar) */} + + {detailsOpen && ( +
+ Start{Math.round(measurement.elevationStartM)} m ĂŒ.M. + Ende{Math.round(measurement.elevationEndM)} m ĂŒ.M. + Min / Max{Math.round(measurement.minElevationM)} / {Math.round(measurement.maxElevationM)} m + Messpunkte{measurement.samplePointCount} +
+ )} + + {noElevation && ( +
+ + Höhendaten nicht verfĂŒgbar — Berechnung ohne Höhendifferenz (flach). +
+ )} + + {/* Leitungsrechner */} +
+
+ Leitungsrechner +
+ +
+ + +
+ + {result && ( + <> +
+ Reibungsverlust + {result.frictionBar.toFixed(1)} bar ({result.frictionPer100mBar.toFixed(2)}/100m) + Höhendruck + 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}> + {result.elevationBar > 0 ? '+' : ''}{result.elevationBar.toFixed(1)} bar + + Strahlrohr + {parseFloat(nozzlePressure).toFixed(1)} bar + SchlÀuche à {result.hose.lengthPerPieceM} m + {result.hoseCount} Stk. (inkl. 10% Reserve) +
+ + {/* Gesamtergebnis */} +
+
+ Pumpendruck benötigt + + {result.totalBar.toFixed(1)} bar + +
+
+ {result.needsBooster ? ( + <> {result.pumpCount} Pumpen nötig — VerstĂ€rker alle ~{Math.round(result.boosterSpacingM || 0)} m + ) : ( + <> 1 Pumpe reicht + )} +
+
+ + )} + +

+ LÀnge inkl. 10% Verlegereserve · Höhendaten: {measurement.elevationSource === 'open-meteo' ? 'Open-Meteo DEM (~90 m Raster)' : measurement.elevationSource === 'open-elevation' ? 'Open-Elevation' : 'keine'} · Richtwerte, ersetzt keine Lagebeurteilung. +

+
+
+
+ ) +} diff --git a/src/lib/hose-calc.ts b/src/lib/hose-calc.ts new file mode 100644 index 0000000..29d2fb8 --- /dev/null +++ b/src/lib/hose-calc.ts @@ -0,0 +1,120 @@ +/** + * Leitungsrechner — Druckverlust-Berechnung fĂŒr Schlauchleitungen (Schweizer Standard). + * + * EINZIGE Quelle fĂŒr die Formel. Wird vom Messwerkzeug (MeasurePanel) und vom + * HoseSettingsDialog verwendet — keine duplizierten Berechnungen mehr. + * + * Faustformel Reibungsverlust: R = c · (Q/100)ÂČ Â· (L/100) + * c = Reibungskoeffizient des Schlauchtyps [bar/100m bei 100 l/min] + * Q = Durchfluss [l/min], L = LeitungslĂ€nge [m] + */ + +export interface HoseTypeData { + id?: string + name: string + diameterMm: number + flowRateLpm: number + frictionCoeff: number + lengthPerPieceM: number + isDefault?: boolean +} + +export interface CalcParams { + /** Erforderlicher Druck am Strahlrohr/Verteiler [bar] */ + nozzlePressure: number + /** Maximaler Förderdruck einer Pumpe (TS/FP) [bar] */ + pumpMaxPressure: number + /** DruckĂ€nderung pro Meter Höhendifferenz [bar/m] */ + elevationFactor: number + /** Verlegefaktor: reale Schlauchstrecke vs. Luftlinie (z.B. 1.1 = +10% Reserve) */ + layoutFactor: number +} + +/** Schweizer Standardwerte */ +export const DEFAULT_CALC_PARAMS: CalcParams = { + nozzlePressure: 5.0, + pumpMaxPressure: 10.0, + elevationFactor: 0.1, + layoutFactor: 1.1, +} + +/** Fallback-Schlauchtypen, falls die API keine liefert (Schweizer Standard). */ +export const FALLBACK_HOSE_TYPES: HoseTypeData[] = [ + { name: '55mm Transportleitung', diameterMm: 55, flowRateLpm: 500, frictionCoeff: 0.034, lengthPerPieceM: 20, isDefault: true }, + { name: '75mm Zubringerleitung', diameterMm: 75, flowRateLpm: 1500, frictionCoeff: 0.012, lengthPerPieceM: 20 }, +] + +/** Reibungsverlust pro 100 m fĂŒr einen Schlauchtyp [bar/100m]. */ +export function frictionPer100m(hose: Pick): number { + return hose.frictionCoeff * Math.pow(hose.flowRateLpm / 100, 2) +} + +export interface HoseCalcResult { + hose: HoseTypeData + /** Effektive LeitungslĂ€nge inkl. Verlegefaktor [m] */ + effectiveLengthM: number + /** Reibungsverlust ĂŒber die ganze Leitung [bar] */ + frictionBar: number + /** Reibungsverlust pro 100 m [bar] */ + frictionPer100mBar: number + /** Druck aus Höhendifferenz [bar] (positiv = bergauf) */ + elevationBar: number + /** Benötigter Pumpen-Ausgangsdruck gesamt [bar] */ + totalBar: number + /** Anzahl benötigter Pumpen (inkl. erster) */ + pumpCount: number + /** Abstand zwischen VerstĂ€rkerpumpen [m], null wenn 1 Pumpe reicht */ + boosterSpacingM: number | null + /** Anzahl SchlĂ€uche (effektive LĂ€nge / LĂ€nge pro StĂŒck, aufgerundet) */ + hoseCount: number + /** true wenn der Gesamtdruck den max. Pumpendruck ĂŒbersteigt */ + needsBooster: boolean +} + +/** + * Berechnet Druckbedarf & Materialbedarf fĂŒr EINE Leitung. + * @param distanceM Gemessene Distanz (Luftlinie entlang der Messpunkte) [m] + * @param netElevationDiffM Netto-Höhendifferenz Ende − Start [m] (positiv = bergauf) + */ +export function calcHoseLine( + distanceM: number, + netElevationDiffM: number, + hose: HoseTypeData, + params: CalcParams = DEFAULT_CALC_PARAMS +): HoseCalcResult { + const effectiveLengthM = distanceM * params.layoutFactor + const per100 = frictionPer100m(hose) + const frictionBar = per100 * (effectiveLengthM / 100) + const elevationBar = netElevationDiffM * params.elevationFactor + const totalBar = frictionBar + elevationBar + params.nozzlePressure + const pumpCount = Math.max(1, Math.ceil(Math.max(totalBar, 0) / params.pumpMaxPressure)) + const hoseCount = hose.lengthPerPieceM > 0 ? Math.ceil(effectiveLengthM / hose.lengthPerPieceM) : 0 + + return { + hose, + effectiveLengthM, + frictionBar, + frictionPer100mBar: per100, + elevationBar, + totalBar, + pumpCount, + boosterSpacingM: pumpCount > 1 ? distanceM / (pumpCount - 1) : null, + hoseCount, + needsBooster: pumpCount > 1, + } +} + +/** Zusammenfassung einer Streckenmessung (Distanz + Höhenprofil). */ +export interface MeasurementSummary { + totalDistanceM: number + elevationStartM: number + elevationEndM: number + netDiffM: number + climbM: number + descentM: number + minElevationM: number + maxElevationM: number + samplePointCount: number + /** Woher die Höhendaten stammen — 'none' = nicht verfĂŒgbar (flach gerechnet) */ + elevationSource: 'open-meteo' | 'open-elevation' | 'none' +}