feat(wind): taktische Live-Windanzeige in der Kopfzeile (v1.6.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32m13s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32m13s
- Wind-Badge oben in der Topbar: Pfeil zeigt die Ausbreitungsrichtung (wohin es treibt, Nord oben) + Himmelsrichtung „aus NW", mit Live-Punkt - Aktualisiert sich in Echtzeit: solange im Live-Modus, alle 10 Min. frische Windrichtung von Open-Meteo für den Einsatzort - Manuelles Setzen (Windrose) schaltet Live-Modus ab; „Live vom Standort" schaltet ihn wieder an - Tooltip: „Wind aus NW (315°) — treibt nach SO · live" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -473,9 +473,16 @@ export default function AppPage() {
|
|||||||
onConflict: handleSaveConflict,
|
onConflict: handleSaveConflict,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Windrichtung fürs Lagebild setzen (lokal + am Projekt speichern)
|
// Wind-Modus: live (aus Open-Meteo, aktualisiert sich) vs. manuell gesetzt
|
||||||
const handleWindChange = useCallback(async (dir: number | null) => {
|
const [windLive, setWindLive] = useState(true)
|
||||||
|
const windLiveRef = useRef(true)
|
||||||
|
useEffect(() => { windLiveRef.current = windLive }, [windLive])
|
||||||
|
|
||||||
|
// Windrichtung fürs Lagebild setzen (lokal + am Projekt speichern).
|
||||||
|
// opts.live=true markiert einen Live-Wert (periodische Aktualisierung bleibt an).
|
||||||
|
const handleWindChange = useCallback(async (dir: number | null, opts?: { live?: boolean }) => {
|
||||||
if (!currentProject) return
|
if (!currentProject) return
|
||||||
|
setWindLive(opts?.live === true)
|
||||||
setCurrentProject((prev) => (prev ? { ...prev, windDirection: dir } : prev))
|
setCurrentProject((prev) => (prev ? { ...prev, windDirection: dir } : prev))
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/projects/${currentProject.id}`, {
|
await fetch(`/api/projects/${currentProject.id}`, {
|
||||||
@@ -496,7 +503,7 @@ export default function AppPage() {
|
|||||||
toast({ title: 'Wind nicht verfügbar', description: 'Keine Live-Daten für diesen Standort.', variant: 'destructive' })
|
toast({ title: 'Wind nicht verfügbar', description: 'Keine Live-Daten für diesen Standort.', variant: 'destructive' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await handleWindChange(wind.direction)
|
await handleWindChange(wind.direction, { live: true })
|
||||||
toast({
|
toast({
|
||||||
title: 'Live-Wind übernommen',
|
title: 'Live-Wind übernommen',
|
||||||
description: `aus ${degToCompass(wind.direction)} (${wind.direction}°) · ${Math.round(wind.speed)} km/h${wind.gusts ? `, Böen ${Math.round(wind.gusts)} km/h` : ''}`,
|
description: `aus ${degToCompass(wind.direction)} (${wind.direction}°) · ${Math.round(wind.speed)} km/h${wind.gusts ? `, Böen ${Math.round(wind.gusts)} km/h` : ''}`,
|
||||||
@@ -515,10 +522,24 @@ export default function AppPage() {
|
|||||||
const c = proj.mapCenter
|
const c = proj.mapCenter
|
||||||
if (!c?.lat || !c?.lng) return
|
if (!c?.lat || !c?.lng) return
|
||||||
fetchLiveWind(c.lat, c.lng).then((wind) => {
|
fetchLiveWind(c.lat, c.lng).then((wind) => {
|
||||||
if (wind) handleWindChange(wind.direction)
|
if (wind) handleWindChange(wind.direction, { live: true })
|
||||||
})
|
})
|
||||||
}, [currentProject?.id, currentProject?.windDirection, handleWindChange])
|
}, [currentProject?.id, currentProject?.windDirection, handleWindChange])
|
||||||
|
|
||||||
|
// Echtzeit: solange der Wind im Live-Modus ist, alle 10 Min. aktualisieren
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (!windLiveRef.current || typeof navigator === 'undefined' || !navigator.onLine) return
|
||||||
|
const c = mapRef.current?.getCenter?.() || currentProject.mapCenter
|
||||||
|
if (!c) return
|
||||||
|
fetchLiveWind(c.lat, c.lng).then((wind) => {
|
||||||
|
if (wind) handleWindChange(wind.direction, { live: true })
|
||||||
|
})
|
||||||
|
}, 10 * 60 * 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [currentProject?.id, handleWindChange])
|
||||||
|
|
||||||
// Fullscreen toggle
|
// Fullscreen toggle
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@@ -1007,6 +1028,7 @@ export default function AppPage() {
|
|||||||
handleStopEditing()
|
handleStopEditing()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
windLive={windLive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
|
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||||
|
import { degToCompass } from '@/components/map/map-compass'
|
||||||
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
||||||
import { formatDateTime } from '@/lib/utils'
|
import { formatDateTime } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/ui/logo'
|
import { Logo } from '@/components/ui/logo'
|
||||||
@@ -77,6 +78,8 @@ interface TopbarProps {
|
|||||||
onStartTour?: () => void
|
onStartTour?: () => void
|
||||||
presentationLocked?: boolean
|
presentationLocked?: boolean
|
||||||
onTogglePresentationLock?: () => void
|
onTogglePresentationLock?: () => void
|
||||||
|
/** Windrichtung wird live aktualisiert (für die Live-Anzeige oben) */
|
||||||
|
windLive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({
|
export function Topbar({
|
||||||
@@ -103,6 +106,7 @@ export function Topbar({
|
|||||||
onStartTour,
|
onStartTour,
|
||||||
presentationLocked,
|
presentationLocked,
|
||||||
onTogglePresentationLock,
|
onTogglePresentationLock,
|
||||||
|
windLive,
|
||||||
}: TopbarProps) {
|
}: TopbarProps) {
|
||||||
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
||||||
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
||||||
@@ -198,6 +202,22 @@ export function Topbar({
|
|||||||
|
|
||||||
{/* Zone 3 — Uhr + Aktionen */}
|
{/* Zone 3 — Uhr + Aktionen */}
|
||||||
<div className="flex items-center gap-1 md:gap-1.5 shrink-0">
|
<div className="flex items-center gap-1 md:gap-1.5 shrink-0">
|
||||||
|
{/* Wind — taktische Live-Anzeige (Pfeil zeigt Ausbreitungsrichtung, N oben) */}
|
||||||
|
{typeof project?.windDirection === 'number' && (
|
||||||
|
<div
|
||||||
|
className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 text-white pl-1.5 pr-2 py-1 shrink-0"
|
||||||
|
title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}${windLive ? ' · live' : ''}`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0" style={{ transform: `rotate(${project.windDirection + 180}deg)` }} aria-hidden>
|
||||||
|
<path d="M12 3 L18 16 L12 12.5 L6 16 Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-bold leading-none whitespace-nowrap">
|
||||||
|
<span className="hidden lg:inline opacity-80 font-medium">Wind </span>aus {degToCompass(project.windDirection)}
|
||||||
|
</span>
|
||||||
|
{windLive && <span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse shrink-0" title="Live" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Uhr — Ops-Rooms leben nach der Uhr */}
|
{/* Uhr — Ops-Rooms leben nach der Uhr */}
|
||||||
<span className="hidden sm:inline text-base md:text-lg font-bold tabular-nums mr-0.5 md:mr-1.5">
|
<span className="hidden sm:inline text-base md:text-lg font-bold tabular-nums mr-0.5 md:mr-1.5">
|
||||||
{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
|||||||
Reference in New Issue
Block a user