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>
This commit is contained in:
Pepe Ziberi
2026-07-22 20:56:11 +02:00
parent 226a15e882
commit 6b6bae9ce1
10 changed files with 280 additions and 2 deletions

View File

@@ -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}
/>
</main>
</div>

View File

@@ -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 (
<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>
<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 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<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(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({
</button>
)}
{/* Nordpfeil + Windrose (Lagebild) */}
<MapCompass
bearing={mapBearing}
windDirection={windDirection}
canEdit={canEdit}
onWindChange={(dir) => onWindChange?.(dir)}
/>
{/* Moveable controls for selected symbol */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
<>

View File

@@ -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<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') {
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<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 ──
const footerY = pageH - m - 4
pdf.setFontSize(7)

View File

@@ -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(),

View File

@@ -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