4 Commits

Author SHA1 Message Date
Pepe Ziberi
d758c8c744 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
- 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>
2026-07-22 21:11:42 +02:00
Pepe Ziberi
997e200d68 feat(wind): Live-Windrichtung vom Standort (Open-Meteo) (v1.6.1)
- Neuer „Live vom Standort"-Knopf in der Windrose holt die aktuelle
  Windrichtung + Geschwindigkeit von Open-Meteo (kostenlos, ohne Key)
- Beim Öffnen eines Einsatzes ohne gesetzten Wind wird die Windrichtung
  automatisch vom Einsatzort vorgeschlagen (einmal pro Projekt, online)
- Toast zeigt Richtung, Grad, km/h und Böen
- Neue lib/wind.ts (fetchLiveWind)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:01:24 +02:00
Pepe Ziberi
6b6bae9ce1 feat(krokier): Nordpfeil + Windrichtung + Export-Dokument (v1.6.0)
Macht aus dem digitalen Kroki ein echtes taktisches Lagebild:

Karte:
- Nordpfeil-Overlay (dreht mit dem Karten-Bearing mit)
- Windrichtung in 8 Richtungen setzbar (Windrose mit Drift-Pfeil + Label
  "Wind aus NW"), pro Einsatz gespeichert (Project.windDirection)

Export (PNG & PDF):
- Nordpfeil, Massstabsbalken und Windanzeige werden in die Karte eingebrannt
- PDF zusätzlich: Symbol-Legende (Namen aus /api/icons) + bestehende Kopfzeile
  (Einsatz/Ort/Zeit/Nr) → fertiges, weitergebbares Krokier-Dokument

Technik:
- windDirection: Schema + idempotente Migration + Zod-Validierung + Typ
- Neue Komponente MapCompass (Nordpfeil/Windrose/Wind-Setzer)
- MapView verfolgt Bearing über 'rotate'-Event

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:56:11 +02:00
Pepe Ziberi
226a15e882 fix(measure): Messlinie verschwand nach dem Abschliessen (v1.5.9)
Ursache: finishMeasure schaltete auf 'select' um → der Reset-Effekt
(drawMode !== 'measure') löschte die eben gezeichnete Linie sofort; die
Höhen-Labels kamen asynchron danach und blieben, die Linie nicht.

- Nach dem Abschliessen bleibt der Mess-Modus aktiv → Linie + Labels + Panel
  bleiben stehen (Tippen fügt keinen Punkt mehr hinzu, da bereits fertig)
- Gemeinsamer clearMeasurement-Helfer: Panel-X und „Neue Messung" räumen jetzt
  Linie, Marker, Höhen-Labels und Panel vollständig auf
- Beenden weiterhin über „Neue Messung" oder Wahl eines anderen Werkzeugs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:42:23 +02:00
12 changed files with 434 additions and 16 deletions

View File

@@ -97,6 +97,17 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- [x] SW cachte Kacheln schon Cache-First — jetzt auch nie besuchte Bereiche da - [x] SW cachte Kacheln schon Cache-First — jetzt auch nie besuchte Bereiche da
- Offen (später): Satellit/Swisstopo vorladen, grösserer Radius wählbar - Offen (später): Satellit/Swisstopo vorladen, grösserer Radius wählbar
## Zusatz Krokier-Lagebild ✅ v1.6.0
- [x] **Nordpfeil** als Karten-Overlay, dreht mit dem Bearing mit
- [x] **Windrichtung** (8 Richtungen) setzbar, am Projekt gespeichert
(`Project.windDirection`), als Windrose mit Drift-Pfeil angezeigt
- [x] **Export als Dokument**: Nordpfeil + Massstabsbalken + Windanzeige werden
in PNG & PDF eingebrannt; PDF zusätzlich mit **Symbol-Legende** (Namen aus
/api/icons) und bestehender Kopfzeile (Einsatz/Ort/Zeit/Nr)
- Offen (später): Absperr-/Gefahrenkreis mit Meter-Eingabe, Einheiten
beschriften + Favoriten-Leiste, normgerechte taktische Zeichen
## Phase 5 Kür ## Phase 5 Kür
- [x] **5.1 Linien-Typ-Abfrage** 🟡 ✅ - [x] **5.1 Linien-Typ-Abfrage** 🟡 ✅

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.5.8", "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": {

View File

@@ -84,6 +84,8 @@ async function migrate() {
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`, `ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
// Modul-Baukasten (Cockpit-Konfiguration pro Mandant) // Modul-Baukasten (Cockpit-Konfiguration pro Mandant)
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`,
// Windrichtung fürs Lagebild
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "windDirection" INTEGER`,
] ]
let added = 0 let added = 0
for (const sql of columnMigrations) { for (const sql of columnMigrations) {

View File

@@ -167,6 +167,8 @@ model Project {
journalfuehrer String? journalfuehrer String?
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}") mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
mapZoom Float @default(15) mapZoom Float @default(15)
// Windrichtung in Grad (meteorologisch „Wind aus"), fürs Lagebild. NULL = nicht gesetzt.
windDirection Int?
isLocked Boolean @default(false) isLocked Boolean @default(false)
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt. // Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand // Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand

View File

@@ -27,6 +27,8 @@ import { useMapExport } from '@/hooks/use-map-export'
import { useAutoSave } from '@/hooks/use-auto-save' import { useAutoSave } from '@/hooks/use-auto-save'
import { useOfflineSync } from '@/hooks/use-offline-sync' import { useOfflineSync } from '@/hooks/use-offline-sync'
import { preloadTilesForArea, shouldPreloadForProject, markPreloadedForProject } from '@/lib/offline-tiles' import { preloadTilesForArea, shouldPreloadForProject, markPreloadedForProject } from '@/lib/offline-tiles'
import { fetchLiveWind } from '@/lib/wind'
import { degToCompass } from '@/components/map/map-compass'
import { useRealtimeSync } from '@/hooks/use-realtime-sync' import { useRealtimeSync } from '@/hooks/use-realtime-sync'
import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types' import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
import { useToolStore } from '@/stores/tool-store' import { useToolStore } from '@/stores/tool-store'
@@ -471,6 +473,73 @@ export default function AppPage() {
onConflict: handleSaveConflict, onConflict: handleSaveConflict,
}) })
// Wind-Modus: live (aus Open-Meteo, aktualisiert sich) vs. manuell gesetzt
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
setWindLive(opts?.live === true)
setCurrentProject((prev) => (prev ? { ...prev, windDirection: dir } : prev))
try {
await fetch(`/api/projects/${currentProject.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ windDirection: dir }),
})
} catch { /* Komfort — Fehler still ignorieren */ }
}, [currentProject])
// Live-Wind von Open-Meteo für den aktuellen Kartenausschnitt (Einsatzort) holen
const handleFetchLiveWind = useCallback(async () => {
if (!currentProject) return
const c = mapRef.current?.getCenter?.() || currentProject.mapCenter
if (!c) return
const wind = await fetchLiveWind(c.lat, c.lng)
if (!wind) {
toast({ title: 'Wind nicht verfügbar', description: 'Keine Live-Daten für diesen Standort.', variant: 'destructive' })
return
}
await handleWindChange(wind.direction, { live: true })
toast({
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` : ''}`,
})
}, [currentProject, handleWindChange, toast])
// Beim Öffnen eines Einsatzes ohne gesetzten Wind automatisch die aktuelle
// Windrichtung vorschlagen (nur einmal pro Projekt, online).
const autoWindRef = useRef<string | null>(null)
useEffect(() => {
const proj = currentProject
if (!proj?.id || typeof navigator === 'undefined' || !navigator.onLine) return
if (typeof proj.windDirection === 'number') return
if (autoWindRef.current === proj.id) return
autoWindRef.current = proj.id
const c = proj.mapCenter
if (!c?.lat || !c?.lng) return
fetchLiveWind(c.lat, c.lng).then((wind) => {
if (wind) handleWindChange(wind.direction, { live: true })
})
}, [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) {
@@ -959,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.
@@ -1048,6 +1118,9 @@ export default function AppPage() {
onSymbolPlaced={() => setPlacedCount((n) => n + 1)} onSymbolPlaced={() => setPlacedCount((n) => n + 1)}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
windDirection={currentProject?.windDirection}
onWindChange={handleWindChange}
onFetchLiveWind={handleFetchLiveWind}
/> />
</main> </main>
</div> </div>

View File

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

View File

@@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { Wind, X, Loader2, Satellite } from 'lucide-react'
interface MapCompassProps {
/** Karten-Bearing in Grad (0 = Norden oben) */
bearing: number
/** Windrichtung meteorologisch „Wind aus" in Grad, oder null */
windDirection: number | null | undefined
canEdit: boolean
onWindChange: (dir: number | null) => void
/** Live-Wind (Open-Meteo) für den aktuellen Standort holen */
onFetchLiveWind?: () => Promise<void> | void
}
const DIRS = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
export function degToCompass(deg: number): string {
return DIRS[Math.round(((deg % 360) + 360) % 360 / 45) % 8]
}
// 8-Richtungs-Auswahl (Grad, meteorologisch „aus")
const WIND_OPTIONS: { label: string; deg: number }[] = [
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 },
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 },
]
export function MapCompass({ bearing, windDirection, canEdit, onWindChange, onFetchLiveWind }: MapCompassProps) {
const [open, setOpen] = useState(false)
const [loadingLive, setLoadingLive] = useState(false)
const hasWind = typeof windDirection === 'number'
const handleLive = async () => {
if (!onFetchLiveWind) return
setLoadingLive(true)
try { await onFetchLiveWind() } finally { setLoadingLive(false) }
}
return (
<div className="absolute top-3 left-[60px] md:left-3 z-10 flex flex-col items-center gap-1.5">
{/* Kompass mit Nordpfeil (dreht mit dem Karten-Bearing) */}
<button
type="button"
onClick={() => canEdit && setOpen((o) => !o)}
className="w-12 h-12 rounded-full bg-white/95 dark:bg-gray-900/95 shadow-md border border-black/10 dark:border-white/10 flex items-center justify-center relative"
title={canEdit ? 'Wind einstellen' : 'Nordpfeil'}
style={{ cursor: canEdit ? 'pointer' : 'default' }}
>
<svg viewBox="0 0 40 40" className="w-10 h-10" style={{ transform: `rotate(${-bearing}deg)` }}>
{/* Nordnadel (rot) */}
<polygon points="20,5 24,21 20,18 16,21" fill="#dc2626" />
{/* Südnadel (grau) */}
<polygon points="20,35 24,19 20,22 16,19" fill="#9ca3af" />
<text x="20" y="12" textAnchor="middle" fontSize="7" fontWeight="700" fill="#dc2626" transform="rotate(0 20 20)">N</text>
</svg>
</button>
{/* Wind-Anzeige */}
{hasWind && (
<div className="flex items-center gap-1 rounded-full bg-blue-600 text-white text-[11px] font-semibold pl-1.5 pr-2 py-0.5 shadow">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ transform: `rotate(${(windDirection as number) + 180 - bearing}deg)` }}>
{/* Pfeil zeigt in die Drift-/Ausbreitungsrichtung (wohin es weht) */}
<path d="M12 3 L18 15 L12 12 L6 15 Z" fill="currentColor" />
</svg>
<span>aus {degToCompass(windDirection as number)}</span>
</div>
)}
{!hasWind && canEdit && (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 rounded-full bg-white/95 dark:bg-gray-900/95 text-[11px] font-medium text-muted-foreground pl-1.5 pr-2 py-0.5 shadow border border-black/10 dark:border-white/10"
title="Windrichtung einstellen"
>
<Wind className="w-3.5 h-3.5" /> Wind
</button>
)}
{/* Wind-Auswahl */}
{open && canEdit && (
<div className="absolute top-0 left-14 w-40 rounded-xl bg-white dark:bg-gray-900 shadow-xl border border-border p-2 z-20">
<div className="flex items-center justify-between mb-1.5 px-1">
<span className="text-xs font-semibold flex items-center gap-1"><Wind className="w-3.5 h-3.5" /> Wind aus</span>
<button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground"><X className="w-3.5 h-3.5" /></button>
</div>
{onFetchLiveWind && (
<button
onClick={handleLive}
disabled={loadingLive}
className="w-full mb-1.5 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
title="Aktuelle Windrichtung vom Standort (Open-Meteo)"
>
{loadingLive ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
Live vom Standort
</button>
)}
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
<div className="grid grid-cols-4 gap-1">
{WIND_OPTIONS.map((w) => (
<button
key={w.deg}
onClick={() => { onWindChange(w.deg); setOpen(false) }}
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
}`}
>
{w.label}
</button>
))}
</div>
{hasWind && (
<button
onClick={() => { onWindChange(null); setOpen(false) }}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-destructive py-1"
>
Wind entfernen
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ 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 { MeasurePanel } from './measure-panel'
import { MapCompass } from './map-compass'
import { FALLBACK_HOSE_TYPES, type HoseTypeData, type MeasurementSummary } from '@/lib/hose-calc' 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
@@ -91,6 +92,11 @@ interface MapViewProps {
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */ /** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
isFullscreen?: boolean isFullscreen?: boolean
onToggleFullscreen?: () => void onToggleFullscreen?: () => void
/** Windrichtung fürs Lagebild (Grad, „Wind aus") + Setter */
windDirection?: number | null
onWindChange?: (dir: number | null) => void
/** Live-Wind (Open-Meteo) für den aktuellen Standort holen */
onFetchLiveWind?: () => Promise<void> | void
} }
export function MapView({ export function MapView({
@@ -110,6 +116,9 @@ export function MapView({
onSymbolPlaced, onSymbolPlaced,
isFullscreen, isFullscreen,
onToggleFullscreen, onToggleFullscreen,
windDirection,
onWindChange,
onFetchLiveWind,
}: MapViewProps) { }: MapViewProps) {
const mapContainer = useRef<HTMLDivElement | null>(null) const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(null) const map = useRef<maplibregl.Map | null>(null)
@@ -120,6 +129,7 @@ export function MapView({
const [isMapLoaded, setIsMapLoaded] = useState(false) const [isMapLoaded, setIsMapLoaded] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm') const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false) const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
const [mapBearing, setMapBearing] = useState(0)
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) // Messergebnis für das React-Panel (ersetzt das frühere innerHTML-DOM-Overlay)
@@ -706,6 +716,9 @@ export function MapView({
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right') map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left') map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
// Bearing verfolgen, damit Nordpfeil/Windrose mit der Kartendrehung mitlaufen
map.current.on('rotate', () => setMapBearing(map.current?.getBearing() || 0))
// Geolocation: eigene Position dauerhaft anzeigen (Punkt + Genauigkeit + Blickrichtung) // Geolocation: eigene Position dauerhaft anzeigen (Punkt + Genauigkeit + Blickrichtung)
const geolocate = new maplibregl.GeolocateControl({ const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true }, positionOptions: { enableHighAccuracy: true },
@@ -2141,6 +2154,20 @@ export function MapView({
onDrawModeChangeRef.current?.('select') onDrawModeChangeRef.current?.('select')
}, []) }, [])
// Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand)
const clearMeasurement = useCallback(() => {
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
measureCoordsRef.current = []
setMeasurePointCount(0)
setMeasureFinished(false)
setMeasureResult(null)
if (map.current) {
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
}, [])
// Finish measurement handler // Finish measurement handler
const finishMeasure = useCallback(() => { const finishMeasure = useCallback(() => {
drawingRef.current.isDrawing = false drawingRef.current.isDrawing = false
@@ -2152,8 +2179,9 @@ export function MapView({
if (src) src.setData({ type: 'FeatureCollection', features: [] }) if (src) src.setData({ type: 'FeatureCollection', features: [] })
} }
fetchElevationsAndUpdateRef.current() fetchElevationsAndUpdateRef.current()
// Apple-style: auto-return to select mode after finishing measurement // WICHTIG: im Mess-Modus bleiben, sonst löscht der Reset-Effekt (drawMode !== 'measure')
onDrawModeChangeRef.current?.('select') // die eben gezeichnete Linie sofort wieder. Der Nutzer beendet über „Neue Messung"
// oder durch Wahl eines anderen Werkzeugs.
}, []) }, [])
return ( return (
@@ -2178,6 +2206,15 @@ export function MapView({
</button> </button>
)} )}
{/* Nordpfeil + Windrose (Lagebild) */}
<MapCompass
bearing={mapBearing}
windDirection={windDirection}
canEdit={canEdit}
onWindChange={(dir) => onWindChange?.(dir)}
onFetchLiveWind={onFetchLiveWind}
/>
{/* Moveable controls for selected symbol */} {/* Moveable controls for selected symbol */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && ( {isSymbolSelected && selectedSymbolRef.current && canEdit && (
<> <>
@@ -2358,17 +2395,7 @@ export function MapView({
{/* Neue Messung starten */} {/* Neue Messung starten */}
{drawMode === 'measure' && measureFinished && ( {drawMode === 'measure' && measureFinished && (
<button <button
onClick={() => { onClick={clearMeasurement}
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
measureCoordsRef.current = []
setMeasurePointCount(0)
setMeasureFinished(false)
if (map.current) {
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
}}
style={{ style={{
position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)', position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
zIndex: 1000, padding: '12px 28px', fontSize: '15px', fontWeight: 700, zIndex: 1000, padding: '12px 28px', fontSize: '15px', fontWeight: 700,
@@ -2385,7 +2412,7 @@ export function MapView({
<MeasurePanel <MeasurePanel
measurement={measureResult.measurement} measurement={measureResult.measurement}
hoseTypes={measureResult.hoseTypes} hoseTypes={measureResult.hoseTypes}
onClose={() => setMeasureResult(null)} onClose={clearMeasurement}
/> />
)} )}

View File

@@ -219,9 +219,95 @@ export function useMapExport({
ctx.restore() ctx.restore()
} }
// ── Nordpfeil + Massstab + Wind in die Karte einbrennen (PNG & PDF) ──
const bearing = mapInstance.getBearing() || 0
// Massstabsbalken (unten links): meter/CSS-Pixel aus zwei entprojizierten Punkten
const hav = (a: [number, number], b: [number, number]) => {
const R = 6371000, toR = Math.PI / 180
const dLat = (b[1] - a[1]) * toR, dLng = (b[0] - a[0]) * toR
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[1] * toR) * Math.cos(b[1] * toR) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
}
try {
const midY = container.offsetHeight / 2
const pA = mapInstance.unproject([0, midY]); const pB = mapInstance.unproject([100, midY])
const mPerCss = hav([pA.lng, pA.lat], [pB.lng, pB.lat]) / 100
const nice = [5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]
const raw = mPerCss * 120
const niceM = nice.reduce((p, c) => Math.abs(c - raw) < Math.abs(p - raw) ? c : p, nice[0])
const barPx = (niceM / mPerCss) * dpr
const bx = 18 * dpr, by = h - 26 * dpr
ctx.save()
ctx.strokeStyle = '#111'; ctx.fillStyle = '#111'; ctx.lineWidth = 3 * dpr
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + barPx, by)
ctx.moveTo(bx, by - 5 * dpr); ctx.lineTo(bx, by + 5 * dpr)
ctx.moveTo(bx + barPx, by - 5 * dpr); ctx.lineTo(bx + barPx, by + 5 * dpr); ctx.stroke()
ctx.font = `bold ${11 * dpr}px system-ui, sans-serif`; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'
const label = niceM >= 1000 ? `${niceM / 1000} km` : `${niceM} m`
ctx.lineWidth = 3 * dpr; ctx.strokeStyle = '#fff'; ctx.strokeText(label, bx, by - 6 * dpr)
ctx.fillText(label, bx, by - 6 * dpr)
ctx.restore()
} catch { /* Massstab optional */ }
// Nordpfeil (oben rechts), dreht mit dem Bearing
{
const cx = w - 34 * dpr, cy = 34 * dpr, r = 22 * dpr
ctx.save()
ctx.translate(cx, cy)
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.92)'; ctx.fill()
ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.lineWidth = 1 * dpr; ctx.stroke()
ctx.rotate((-bearing * Math.PI) / 180)
ctx.beginPath(); ctx.moveTo(0, -r * 0.7); ctx.lineTo(r * 0.28, r * 0.15); ctx.lineTo(0, 0); ctx.lineTo(-r * 0.28, r * 0.15); ctx.closePath()
ctx.fillStyle = '#dc2626'; ctx.fill()
ctx.beginPath(); ctx.moveTo(0, r * 0.7); ctx.lineTo(r * 0.28, -r * 0.15); ctx.lineTo(0, 0); ctx.lineTo(-r * 0.28, -r * 0.15); ctx.closePath()
ctx.fillStyle = '#9ca3af'; ctx.fill()
ctx.rotate((bearing * Math.PI) / 180) // Label aufrecht
ctx.font = `bold ${11 * dpr}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
ctx.fillStyle = '#dc2626'; ctx.fillText('N', 0, -r - 7 * dpr)
ctx.restore()
}
// Windrichtung (unter dem Nordpfeil), falls gesetzt
const windDir = currentProject?.windDirection
if (typeof windDir === 'number') {
const dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
const label = `Wind aus ${dirs[Math.round(((windDir % 360) + 360) % 360 / 45) % 8]}`
const cx = w - 34 * dpr, cy = 70 * dpr
ctx.save()
ctx.font = `bold ${10 * dpr}px system-ui, sans-serif`; ctx.textAlign = 'center'
const tw = ctx.measureText(label).width
const padX = 7 * dpr, boxW = tw + 20 * dpr + padX * 2, boxH = 18 * dpr
ctx.fillStyle = '#2563eb'
ctx.beginPath(); ctx.roundRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH, 9 * dpr); ctx.fill()
// Drift-Pfeil (wohin es weht)
ctx.save()
ctx.translate(cx - boxW / 2 + 11 * dpr, cy)
ctx.rotate(((windDir + 180 - bearing) * Math.PI) / 180)
ctx.beginPath(); ctx.moveTo(0, -6 * dpr); ctx.lineTo(4 * dpr, 5 * dpr); ctx.lineTo(0, 2.5 * dpr); ctx.lineTo(-4 * dpr, 5 * dpr); ctx.closePath()
ctx.fillStyle = '#fff'; ctx.fill()
ctx.restore()
ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle'
ctx.fillText(label, cx + 8 * dpr, cy)
ctx.restore()
}
const title = currentProject?.title || 'Lageplan' const title = currentProject?.title || 'Lageplan'
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_') const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
// Distinkte Symbole für die PDF-Legende (nur solche mit Bild)
const distinctSymbols: { iconId: string; imageUrl: string }[] = []
const seenIcon = new Set<string>()
for (const f of currentFeatures) {
if (f.type !== 'symbol') continue
const imageUrl = (f.properties.imageUrl as string) || ''
const iconId = (f.properties.iconId as string) || imageUrl
if (!imageUrl || seenIcon.has(iconId)) continue
seenIcon.add(iconId)
distinctSymbols.push({ iconId: (f.properties.iconId as string) || '', imageUrl })
}
if (format === 'png') { if (format === 'png') {
const link = document.createElement('a') const link = document.createElement('a')
link.download = `${safeName}.png` link.download = `${safeName}.png`
@@ -281,8 +367,9 @@ export function useMapExport({
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F') pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
// ── Map image ── // ── Map image ──
const legendH = distinctSymbols.length > 0 ? 16 : 0
const mapTop = divY + 3 const mapTop = divY + 3
const mapBottom = pageH - m - 12 // leave space for footer const mapBottom = pageH - m - 12 - legendH // Platz für Fusszeile (+ Legende)
const mapAreaW = pageW - 2 * m const mapAreaW = pageW - 2 * m
const mapAreaH = mapBottom - mapTop const mapAreaH = mapBottom - mapTop
@@ -305,6 +392,43 @@ export function useMapExport({
pdf.rect(mapX, mapY, drawW, drawH) pdf.rect(mapX, mapY, drawW, drawH)
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH) pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
// ── Legende (verwendete Symbole) ──
if (legendH > 0) {
// Namen aus /api/icons beziehen (id → name)
const nameMap: Record<string, string> = {}
try {
const r = await fetch('/api/icons')
if (r.ok) {
const d = await r.json()
;(d.categories || []).forEach((c: any) => (c.icons || []).forEach((ic: any) => { nameMap[ic.id] = ic.name }))
;(d.tenantSymbolGroups || []).forEach((g: any) => (g.symbols || []).forEach((s: any) => { nameMap[s.id] = s.name }))
}
} catch { /* Namen optional */ }
const legY = mapBottom + 4
pdf.setFontSize(7); pdf.setFont('helvetica', 'bold'); pdf.setTextColor(107, 114, 128)
pdf.text('LEGENDE', m, legY - 1)
pdf.setFont('helvetica', 'normal'); pdf.setTextColor(40, 40, 40)
let lx = m, ly = legY + 1
for (const s of distinctSymbols.slice(0, 16)) {
try {
const img = await loadImage(s.imageUrl)
const c = document.createElement('canvas'); c.width = 48; c.height = 48
const cctx = c.getContext('2d')!; cctx.drawImage(img, 0, 0, 48, 48)
pdf.addImage(c.toDataURL('image/png'), 'PNG', lx, ly, 5, 5)
} catch { /* Symbolbild optional */ }
const name = (nameMap[s.iconId] || '').slice(0, 22)
pdf.text(name, lx + 6, ly + 3.5)
const nameW = Math.min(34, pdf.getTextWidth(name) + 3)
lx += 6 + nameW + 5
if (lx > pageW - m - 40) { lx = m; ly += 6.5 }
}
if (distinctSymbols.length > 16) {
pdf.setTextColor(150, 150, 150)
pdf.text(`+${distinctSymbols.length - 16} weitere`, lx, ly + 3.5)
}
}
// ── Footer ── // ── Footer ──
const footerY = pageH - m - 4 const footerY = pageH - m - 4
pdf.setFontSize(7) pdf.setFontSize(7)

View File

@@ -16,6 +16,8 @@ export const projectSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
einsatzleiter: z.string().optional(), einsatzleiter: z.string().optional(),
journalfuehrer: z.string().optional(), journalfuehrer: z.string().optional(),
// Windrichtung in Grad (0=N, 90=O …), meteorologisch „Wind aus"; null = nicht gesetzt
windDirection: z.number().int().min(0).max(359).nullable().optional(),
mapCenter: z.object({ mapCenter: z.object({
lng: z.number(), lng: z.number(),
lat: z.number(), lat: z.number(),

32
src/lib/wind.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* Live-Winddaten von Open-Meteo (kostenlos, ohne API-Key).
* Liefert die aktuelle Windrichtung (meteorologisch „Wind aus", Grad) und
* Geschwindigkeit (km/h) am gegebenen Standort.
*/
export interface LiveWind {
direction: number // 0..359, meteorologisch „Wind aus"
speed: number // km/h
gusts?: number // km/h, Böen
}
export async function fetchLiveWind(lat: number, lng: number): Promise<LiveWind | null> {
try {
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}` +
`&current=wind_speed_10m,wind_direction_10m,wind_gusts_10m`
const res = await fetch(url)
if (!res.ok) return null
const data = await res.json()
const dir = data?.current?.wind_direction_10m
const spd = data?.current?.wind_speed_10m
const gust = data?.current?.wind_gusts_10m
if (typeof dir !== 'number') return null
return {
direction: ((Math.round(dir) % 360) + 360) % 360,
speed: typeof spd === 'number' ? spd : 0,
gusts: typeof gust === 'number' ? gust : undefined,
}
} catch {
return null
}
}

View File

@@ -11,6 +11,7 @@ export interface Project {
journalfuehrer?: string journalfuehrer?: string
mapCenter: { lng: number; lat: number } mapCenter: { lng: number; lat: number }
mapZoom: number mapZoom: number
windDirection?: number | null
isLocked: boolean isLocked: boolean
featuresVersion?: number featuresVersion?: number
editingById?: string | null editingById?: string | null