From 6b6bae9ce18888847221093f0552ad23efca27db Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 22 Jul 2026 20:56:11 +0200 Subject: [PATCH] feat(krokier): Nordpfeil + Windrichtung + Export-Dokument (v1.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ROADMAP.md | 11 +++ package.json | 2 +- prisma/migrate.js | 2 + prisma/schema.prisma | 2 + src/app/app/page.tsx | 15 ++++ src/components/map/map-compass.tsx | 103 +++++++++++++++++++++++ src/components/map/map-view.tsx | 18 +++++ src/hooks/use-map-export.ts | 126 ++++++++++++++++++++++++++++- src/lib/validations.ts | 2 + src/types/index.ts | 1 + 10 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/components/map/map-compass.tsx 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 && ( +
+ + {/* Pfeil zeigt in die Drift-/Ausbreitungsrichtung (wohin es weht) */} + + + 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