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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user