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:
@@ -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>
|
||||
|
||||
103
src/components/map/map-compass.tsx
Normal file
103
src/components/map/map-compass.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user