refactor(leitungsrechner): professionelles React-Panel + zentrale Berechnung
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33m10s
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:
11
ROADMAP.md
11
ROADMAP.md
@@ -52,6 +52,17 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
|||||||
- Live-Sync für Journal existierte bereits (Socket → journal-refresh).
|
- Live-Sync für Journal existierte bereits (Socket → journal-refresh).
|
||||||
- _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._
|
- _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
|
## Phase 5 – Kür
|
||||||
|
|
||||||
- [ ] **5.1 – Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal)
|
- [ ] **5.1 – Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.8",
|
"version": "1.4.9",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Plus, Trash2, Save, Star, Loader2 } from 'lucide-react'
|
import { Plus, Trash2, Save, Star, Loader2 } from 'lucide-react'
|
||||||
|
import { frictionPer100m as calcFrictionPer100m } from '@/lib/hose-calc'
|
||||||
|
|
||||||
interface HoseType {
|
interface HoseType {
|
||||||
id: string
|
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 frictionPer100m = (coeff: string, flow: string) => {
|
||||||
const c = parseFloat(coeff)
|
const c = parseFloat(coeff)
|
||||||
const q = parseFloat(flow)
|
const q = parseFloat(flow)
|
||||||
if (!c || !q) return '-'
|
if (!c || !q) return '-'
|
||||||
return (c * Math.pow(q / 100, 2)).toFixed(2)
|
return calcFrictionPer100m({ frictionCoeff: c, flowRateLpm: q }).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useDrop } from 'react-dnd'
|
|||||||
import Moveable from 'react-moveable'
|
import Moveable from 'react-moveable'
|
||||||
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
|
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
|
||||||
import type { Project, DrawFeature, DrawMode } from '@/types'
|
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
|
// Haversine distance between two [lng, lat] points in meters
|
||||||
function haversineDistance(a: number[], b: number[]): number {
|
function haversineDistance(a: number[], b: number[]): number {
|
||||||
@@ -110,6 +112,8 @@ export function MapView({
|
|||||||
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
||||||
const [measurePointCount, setMeasurePointCount] = useState(0)
|
const [measurePointCount, setMeasurePointCount] = useState(0)
|
||||||
const [measureFinished, setMeasureFinished] = useState(false)
|
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 [drawingPointCount, setDrawingPointCount] = useState(0)
|
||||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||||
const tooltipPosRef = useRef({ x: 0, y: 0 })
|
const tooltipPosRef = useRef({ x: 0, y: 0 })
|
||||||
@@ -447,6 +451,7 @@ export function MapView({
|
|||||||
const lngs = sampledCoords.map(c => c[0].toFixed(6)).join(',')
|
const lngs = sampledCoords.map(c => c[0].toFixed(6)).join(',')
|
||||||
|
|
||||||
let sampledElevations: number[] = []
|
let sampledElevations: number[] = []
|
||||||
|
let elevationSource: 'open-meteo' | 'open-elevation' | 'none' = 'none'
|
||||||
|
|
||||||
// Try Open-Meteo first
|
// Try Open-Meteo first
|
||||||
try {
|
try {
|
||||||
@@ -454,6 +459,7 @@ export function MapView({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
sampledElevations = data.elevation || []
|
sampledElevations = data.elevation || []
|
||||||
|
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-meteo'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Elevation] Open-Meteo nicht erreichbar')
|
console.warn('[Elevation] Open-Meteo nicht erreichbar')
|
||||||
@@ -471,15 +477,17 @@ export function MapView({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
sampledElevations = (data.results || []).map((r: any) => r.elevation)
|
sampledElevations = (data.results || []).map((r: any) => r.elevation)
|
||||||
|
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-elevation'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Elevation] Open-Elevation auch nicht erreichbar')
|
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) {
|
if (sampledElevations.length !== sampledCoords.length) {
|
||||||
sampledElevations = sampledCoords.map(() => 0)
|
sampledElevations = sampledCoords.map(() => 0)
|
||||||
|
elevationSource = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract elevations for the original measurement points
|
// Extract elevations for the original measurement points
|
||||||
@@ -507,138 +515,51 @@ export function MapView({
|
|||||||
minSampledElev = Math.min(minSampledElev, sampledElevations[i])
|
minSampledElev = Math.min(minSampledElev, sampledElevations[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Feuerwehr Druckverlust-Berechnung (Schweizer Standard) ===
|
// === Leitungsrechner: Schlauchtypen laden (volle Stammdaten) ===
|
||||||
const AUSGANGSDRUCK_STRAHLROHR = 5.0 // bar am Strahlrohr/Monitor
|
let hoseTypes: HoseTypeData[] = []
|
||||||
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 }[] = []
|
|
||||||
try {
|
try {
|
||||||
const htRes = await fetch('/api/hose-types')
|
const htRes = await fetch('/api/hose-types')
|
||||||
if (htRes.ok) {
|
if (htRes.ok) {
|
||||||
const htData = await htRes.json()
|
const htData = await htRes.json()
|
||||||
if (htData.hoseTypes && htData.hoseTypes.length > 0) {
|
if (htData.hoseTypes && htData.hoseTypes.length > 0) {
|
||||||
hoseTypes = htData.hoseTypes.map((ht: any) => ({
|
hoseTypes = htData.hoseTypes.map((ht: any) => ({
|
||||||
|
id: ht.id,
|
||||||
name: ht.name,
|
name: ht.name,
|
||||||
diameter: ht.diameterMm,
|
diameterMm: ht.diameterMm,
|
||||||
flow: ht.flowRateLpm,
|
flowRateLpm: ht.flowRateLpm,
|
||||||
c: ht.frictionCoeff,
|
frictionCoeff: ht.frictionCoeff,
|
||||||
|
lengthPerPieceM: ht.lengthPerPieceM ?? 20,
|
||||||
|
isDefault: ht.isDefault === true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
if (hoseTypes.length === 0) {
|
if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES
|
||||||
hoseTypes = [
|
|
||||||
{ name: '55mm Transportleitung', diameter: 55, flow: 500, c: 0.034 },
|
|
||||||
{ name: '75mm Zubringerleitung', diameter: 75, flow: 1500, c: 0.012 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDist = 0
|
let totalDist = 0
|
||||||
let totalHoehenDruck = 0
|
|
||||||
const segments: { dist: number; elDiff: number }[] = []
|
|
||||||
|
|
||||||
for (let i = 1; i < coords.length; i++) {
|
for (let i = 1; i < coords.length; i++) {
|
||||||
const segDist = haversineDistance(coords[i - 1], coords[i])
|
totalDist += haversineDistance(coords[i - 1], coords[i])
|
||||||
const elDiff = elevations[i] - elevations[i - 1]
|
|
||||||
totalDist += segDist
|
|
||||||
totalHoehenDruck += elDiff * HOEHENDRUCK_FAKTOR
|
|
||||||
segments.push({ dist: segDist, elDiff })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 elStart = elevations[0]
|
||||||
const elEnd = elevations[elevations.length - 1]
|
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
|
// Ergebnis in React-State — das MeasurePanel übernimmt Darstellung + Interaktion
|
||||||
const existingPanel = document.getElementById('measure-info-panel')
|
setMeasureResult({
|
||||||
if (existingPanel) existingPanel.remove()
|
measurement: {
|
||||||
|
totalDistanceM: totalDist,
|
||||||
// Create info panel overlay
|
elevationStartM: elStart,
|
||||||
const panel = document.createElement('div')
|
elevationEndM: elEnd,
|
||||||
panel.id = 'measure-info-panel'
|
netDiffM: elEnd - elStart,
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
climbM: totalClimb,
|
||||||
panel.style.cssText = `
|
descentM: totalDescent,
|
||||||
position: absolute; bottom: 48px; left: 16px; z-index: 1000;
|
minElevationM: minSampledElev,
|
||||||
background: ${isDark ? '#1a1a1a' : 'white'}; color: ${isDark ? '#c8a060' : '#1e293b'};
|
maxElevationM: maxSampledElev,
|
||||||
border: 2px solid ${isDark ? '#c89040' : '#fbbf24'}; border-radius: 12px;
|
samplePointCount: sampledCoords.length,
|
||||||
padding: 14px 18px; font-size: 13px; line-height: 1.6;
|
elevationSource,
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3); max-width: 380px;
|
},
|
||||||
font-family: system-ui, sans-serif;
|
hoseTypes,
|
||||||
`
|
})
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add elevation labels at each point
|
// Add elevation labels at each point
|
||||||
coords.forEach((pt, i) => {
|
coords.forEach((pt, i) => {
|
||||||
@@ -2384,6 +2305,16 @@ export function MapView({
|
|||||||
↻ Neue Messung
|
↻ Neue Messung
|
||||||
</button>
|
</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 */}
|
{/* Inline edit overlay — replaces native prompt() to prevent fullscreen exit */}
|
||||||
{inlineEdit && (
|
{inlineEdit && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
166
src/components/map/measure-panel.tsx
Normal file
166
src/components/map/measure-panel.tsx
Normal 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
120
src/lib/hose-calc.ts
Normal 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'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user