diff --git a/ROADMAP.md b/ROADMAP.md
index 1916643..c045232 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -97,6 +97,17 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- [x] SW cachte Kacheln schon Cache-First — jetzt auch nie besuchte Bereiche da
- 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
- [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅
diff --git a/package.json b/package.json
index e57d568..928061f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lageplan",
- "version": "1.5.9",
+ "version": "1.6.0",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
diff --git a/prisma/migrate.js b/prisma/migrate.js
index 86cb8d4..7bb82da 100644
--- a/prisma/migrate.js
+++ b/prisma/migrate.js
@@ -84,6 +84,8 @@ async function migrate() {
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
// Modul-Baukasten (Cockpit-Konfiguration pro Mandant)
`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
for (const sql of columnMigrations) {
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 37ee653..bafe32c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -167,6 +167,8 @@ model Project {
journalfuehrer String?
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
mapZoom Float @default(15)
+ // Windrichtung in Grad (meteorologisch „Wind aus"), fürs Lagebild. NULL = nicht gesetzt.
+ windDirection Int?
isLocked Boolean @default(false)
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx
index a2bbd46..11e5927 100644
--- a/src/app/app/page.tsx
+++ b/src/app/app/page.tsx
@@ -471,6 +471,19 @@ export default function AppPage() {
onConflict: handleSaveConflict,
})
+ // Windrichtung fürs Lagebild setzen (lokal + am Projekt speichern)
+ const handleWindChange = useCallback(async (dir: number | null) => {
+ if (!currentProject) return
+ 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])
+
// Fullscreen toggle
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
@@ -1048,6 +1061,8 @@ export default function AppPage() {
onSymbolPlaced={() => setPlacedCount((n) => n + 1)}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
+ windDirection={currentProject?.windDirection}
+ onWindChange={handleWindChange}
/>
diff --git a/src/components/map/map-compass.tsx b/src/components/map/map-compass.tsx
new file mode 100644
index 0000000..f819b1b
--- /dev/null
+++ b/src/components/map/map-compass.tsx
@@ -0,0 +1,103 @@
+'use client'
+
+import { useState } from 'react'
+import { Wind, X } 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
+}
+
+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 }: MapCompassProps) {
+ const [open, setOpen] = useState(false)
+ const hasWind = typeof windDirection === 'number'
+
+ return (
+
+ {/* Kompass mit Nordpfeil (dreht mit dem Karten-Bearing) */}
+
+
+ {/* Wind-Anzeige */}
+ {hasWind && (
+
+
+
aus {degToCompass(windDirection as number)}
+
+ )}
+ {!hasWind && canEdit && (
+
+ )}
+
+ {/* Wind-Auswahl */}
+ {open && canEdit && (
+
+
+ Wind aus
+
+
+
+ {WIND_OPTIONS.map((w) => (
+
+ ))}
+
+ {hasWind && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx
index 91f8318..77b44c5 100644
--- a/src/components/map/map-view.tsx
+++ b/src/components/map/map-view.tsx
@@ -8,6 +8,7 @@ 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 { MapCompass } from './map-compass'
import { FALLBACK_HOSE_TYPES, type HoseTypeData, type MeasurementSummary } from '@/lib/hose-calc'
// Haversine distance between two [lng, lat] points in meters
@@ -91,6 +92,9 @@ interface MapViewProps {
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
isFullscreen?: boolean
onToggleFullscreen?: () => void
+ /** Windrichtung fürs Lagebild (Grad, „Wind aus") + Setter */
+ windDirection?: number | null
+ onWindChange?: (dir: number | null) => void
}
export function MapView({
@@ -110,6 +114,8 @@ export function MapView({
onSymbolPlaced,
isFullscreen,
onToggleFullscreen,
+ windDirection,
+ onWindChange,
}: MapViewProps) {
const mapContainer = useRef(null)
const map = useRef(null)
@@ -120,6 +126,7 @@ export function MapView({
const [isMapLoaded, setIsMapLoaded] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
+ const [mapBearing, setMapBearing] = useState(0)
const [measurePointCount, setMeasurePointCount] = useState(0)
const [measureFinished, setMeasureFinished] = useState(false)
// Messergebnis für das React-Panel (ersetzt das frühere innerHTML-DOM-Overlay)
@@ -706,6 +713,9 @@ export function MapView({
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
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)
const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
@@ -2193,6 +2203,14 @@ export function MapView({
)}
+ {/* Nordpfeil + Windrose (Lagebild) */}
+ onWindChange?.(dir)}
+ />
+
{/* Moveable controls for selected symbol */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
<>
diff --git a/src/hooks/use-map-export.ts b/src/hooks/use-map-export.ts
index 8c39d10..245f1a1 100644
--- a/src/hooks/use-map-export.ts
+++ b/src/hooks/use-map-export.ts
@@ -219,9 +219,95 @@ export function useMapExport({
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 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()
+ 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') {
const link = document.createElement('a')
link.download = `${safeName}.png`
@@ -281,8 +367,9 @@ export function useMapExport({
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
// ── Map image ──
+ const legendH = distinctSymbols.length > 0 ? 16 : 0
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 mapAreaH = mapBottom - mapTop
@@ -305,6 +392,43 @@ export function useMapExport({
pdf.rect(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 = {}
+ 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 ──
const footerY = pageH - m - 4
pdf.setFontSize(7)
diff --git a/src/lib/validations.ts b/src/lib/validations.ts
index 09b3aaf..ced0a00 100644
--- a/src/lib/validations.ts
+++ b/src/lib/validations.ts
@@ -16,6 +16,8 @@ export const projectSchema = z.object({
description: z.string().optional(),
einsatzleiter: 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({
lng: z.number(),
lat: z.number(),
diff --git a/src/types/index.ts b/src/types/index.ts
index c492a1c..1450cbb 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -11,6 +11,7 @@ export interface Project {
journalfuehrer?: string
mapCenter: { lng: number; lat: number }
mapZoom: number
+ windDirection?: number | null
isLocked: boolean
featuresVersion?: number
editingById?: string | null