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

@@ -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)

View File

@@ -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": {

View File

@@ -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 (

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

View File

@@ -0,0 +1,166 @@
'use client'
import { useState, useMemo } from 'react'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { X, Ruler, Droplets, TrendingUp, TrendingDown, AlertTriangle, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
import {
calcHoseLine, DEFAULT_CALC_PARAMS,
type HoseTypeData, type MeasurementSummary,
} from '@/lib/hose-calc'
interface MeasurePanelProps {
measurement: MeasurementSummary
hoseTypes: HoseTypeData[]
onClose: () => 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<string>(defaultHose?.name || '')
const [nozzlePressure, setNozzlePressure] = useState<string>(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 (
<div className="absolute bottom-12 left-4 z-[1000] w-[360px] max-w-[calc(100%-2rem)] rounded-xl border-2 border-amber-400 dark:border-amber-600 bg-white dark:bg-zinc-900 text-sm shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200 dark:border-amber-800">
<span className="font-bold flex items-center gap-2 text-amber-900 dark:text-amber-200">
<Ruler className="w-4 h-4" /> Messergebnis
</span>
<button onClick={onClose} className="text-amber-700/60 hover:text-amber-900 dark:text-amber-400/60 dark:hover:text-amber-200 transition-colors" title="Schliessen">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-3">
{/* Strecke kompakt */}
<div className="flex items-baseline justify-between">
<span className="text-muted-foreground text-xs">Distanz</span>
<span className="font-bold text-lg tabular-nums">{formatDistance(measurement.totalDistanceM)}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
<TrendingUp className="w-3.5 h-3.5" /> +{Math.round(measurement.climbM)} m
</span>
<span className="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
<TrendingDown className="w-3.5 h-3.5" /> {Math.round(measurement.descentM)} m
</span>
<span className={`font-semibold ${measurement.netDiffM > 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
Netto {measurement.netDiffM > 0 ? '+' : ''}{Math.round(measurement.netDiffM)} m
</span>
</div>
{/* Höhen-Details (einklappbar) */}
<button onClick={() => setDetailsOpen(!detailsOpen)} className="w-full flex items-center justify-between text-xs text-muted-foreground hover:text-foreground transition-colors">
<span>Höhenprofil-Details</span>
{detailsOpen ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
{detailsOpen && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs bg-muted/50 rounded-lg p-2.5">
<span className="text-muted-foreground">Start</span><span className="font-medium text-right tabular-nums">{Math.round(measurement.elevationStartM)} m ü.M.</span>
<span className="text-muted-foreground">Ende</span><span className="font-medium text-right tabular-nums">{Math.round(measurement.elevationEndM)} m ü.M.</span>
<span className="text-muted-foreground">Min / Max</span><span className="font-medium text-right tabular-nums">{Math.round(measurement.minElevationM)} / {Math.round(measurement.maxElevationM)} m</span>
<span className="text-muted-foreground">Messpunkte</span><span className="font-medium text-right tabular-nums">{measurement.samplePointCount}</span>
</div>
)}
{noElevation && (
<div className="flex items-start gap-2 text-xs rounded-lg bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-700 p-2.5 text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>Höhendaten nicht verfügbar Berechnung ohne Höhendifferenz (flach).</span>
</div>
)}
{/* Leitungsrechner */}
<div className="border-t pt-3 space-y-2.5">
<div className="font-bold flex items-center gap-2 text-red-700 dark:text-red-400">
<Droplets className="w-4 h-4" /> Leitungsrechner
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Select value={selectedHoseName} onValueChange={setSelectedHoseName}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
<SelectContent>
{hoseTypes.map(h => (
<SelectItem key={h.name} value={h.name}>
{h.name} · {h.diameterMm}mm · {h.flowRateLpm} l/min{h.isDefault ? ' ★' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={nozzlePressure} onValueChange={setNozzlePressure}>
<SelectTrigger className="h-8 text-xs w-[92px]" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
<SelectContent>
{['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)}
</SelectContent>
</Select>
</div>
{result && (
<>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<span className="text-muted-foreground">Reibungsverlust</span>
<span className="font-medium text-right tabular-nums">{result.frictionBar.toFixed(1)} bar <span className="text-muted-foreground">({result.frictionPer100mBar.toFixed(2)}/100m)</span></span>
<span className="text-muted-foreground">Höhendruck</span>
<span className={`font-medium text-right tabular-nums ${result.elevationBar > 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
{result.elevationBar > 0 ? '+' : ''}{result.elevationBar.toFixed(1)} bar
</span>
<span className="text-muted-foreground">Strahlrohr</span>
<span className="font-medium text-right tabular-nums">{parseFloat(nozzlePressure).toFixed(1)} bar</span>
<span className="text-muted-foreground">Schläuche à {result.hose.lengthPerPieceM} m</span>
<span className="font-medium text-right tabular-nums">{result.hoseCount} Stk. <span className="text-muted-foreground">(inkl. 10% Reserve)</span></span>
</div>
{/* Gesamtergebnis */}
<div className={`rounded-lg p-3 border ${result.needsBooster
? 'bg-red-50 dark:bg-red-950/40 border-red-300 dark:border-red-700'
: 'bg-green-50 dark:bg-green-950/40 border-green-300 dark:border-green-700'}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Pumpendruck benötigt</span>
<span className={`font-bold text-lg tabular-nums ${result.needsBooster ? 'text-red-700 dark:text-red-400' : 'text-green-700 dark:text-green-400'}`}>
{result.totalBar.toFixed(1)} bar
</span>
</div>
<div className={`flex items-center gap-1.5 mt-1 text-xs font-semibold ${result.needsBooster ? 'text-red-700 dark:text-red-400' : 'text-green-700 dark:text-green-400'}`}>
{result.needsBooster ? (
<><AlertTriangle className="w-3.5 h-3.5" /> {result.pumpCount} Pumpen nötig Verstärker alle ~{Math.round(result.boosterSpacingM || 0)} m</>
) : (
<><CheckCircle2 className="w-3.5 h-3.5" /> 1 Pumpe reicht</>
)}
</div>
</div>
</>
)}
<p className="text-[10px] text-muted-foreground leading-snug">
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.
</p>
</div>
</div>
</div>
)
}

120
src/lib/hose-calc.ts Normal file
View File

@@ -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<HoseTypeData, 'frictionCoeff' | 'flowRateLpm'>): 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'
}