v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking - Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt - Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek) - Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt - Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt - Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag) - Schema: TenantSymbol customName, sortOrder, unique constraint entfernt - Open Source Referenz entfernt (kostenloses Projekt)
This commit is contained in:
329
src/hooks/use-map-export.ts
Normal file
329
src/hooks/use-map-export.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useCallback } from 'react'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { DrawFeature, Project } from '@/types'
|
||||
|
||||
interface UseMapExportOptions {
|
||||
mapRef: React.MutableRefObject<any>
|
||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||
currentProject: Project | null
|
||||
tenant: { id: string; name: string } | null
|
||||
addAudit: (action: string) => void
|
||||
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||
}
|
||||
|
||||
export function useMapExport({
|
||||
mapRef,
|
||||
featuresRef,
|
||||
currentProject,
|
||||
tenant,
|
||||
addAudit,
|
||||
toast,
|
||||
}: UseMapExportOptions) {
|
||||
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
|
||||
const mapInstance = mapRef.current
|
||||
if (!mapInstance) {
|
||||
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get the MapLibre canvas (tiles + vector drawings)
|
||||
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
|
||||
const w = mapCanvas.width
|
||||
const h = mapCanvas.height
|
||||
|
||||
// 2. Create composite canvas
|
||||
const exportCanvas = document.createElement('canvas')
|
||||
exportCanvas.width = w
|
||||
exportCanvas.height = h
|
||||
const ctx = exportCanvas.getContext('2d')!
|
||||
ctx.drawImage(mapCanvas, 0, 0)
|
||||
|
||||
// 3. Draw symbols manually at correct size/rotation
|
||||
const currentFeatures = featuresRef.current
|
||||
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
|
||||
const container = mapInstance.getContainer()
|
||||
const dpr = mapCanvas.width / container.offsetWidth
|
||||
const zoom = mapInstance.getZoom()
|
||||
// Symbol sizing: match the map rendering logic exactly
|
||||
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
|
||||
const currentZoom = zoom
|
||||
|
||||
// Helper: load image as promise
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = src
|
||||
})
|
||||
|
||||
// Draw symbol features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
|
||||
if (f.geometry.type !== 'Point') continue
|
||||
const coords = f.geometry.coordinates as [number, number]
|
||||
const pixel = mapInstance.project(coords)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
|
||||
const scale = (f.properties.scale as number) || 1
|
||||
const rotation = (f.properties.rotation as number) || 0
|
||||
const baseSize = 32
|
||||
const placementZoom = (f.properties.placementZoom as number) || 17
|
||||
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
|
||||
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
|
||||
|
||||
// Determine image source
|
||||
const iconId = f.properties.iconId as string
|
||||
const imageUrl = f.properties.imageUrl as string
|
||||
let imgSrc = imageUrl || ''
|
||||
if (!imgSrc && iconId) {
|
||||
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
|
||||
const sym = getSymbolById(iconId)
|
||||
if (sym) imgSrc = getSymbolDataUri(sym)
|
||||
}
|
||||
|
||||
if (imgSrc) {
|
||||
try {
|
||||
const img = await loadImage(imgSrc)
|
||||
// Replicate CSS background-size: contain (preserve aspect ratio)
|
||||
const imgAspect = img.naturalWidth / img.naturalHeight
|
||||
let drawW = size
|
||||
let drawH = size
|
||||
if (imgAspect > 1) {
|
||||
drawH = size / imgAspect
|
||||
} else {
|
||||
drawW = size * imgAspect
|
||||
}
|
||||
ctx.save()
|
||||
ctx.translate(px, py)
|
||||
ctx.rotate((rotation * Math.PI) / 180)
|
||||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
|
||||
ctx.restore()
|
||||
} catch (e) {
|
||||
console.warn('[Export] Failed to load symbol image:', iconId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw arrowheads for arrow features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
|
||||
if (f.geometry.type !== 'LineString') continue
|
||||
const lineCoords = f.geometry.coordinates as number[][]
|
||||
if (lineCoords.length < 2) continue
|
||||
const p1 = lineCoords[lineCoords.length - 2]
|
||||
const p2 = lineCoords[lineCoords.length - 1]
|
||||
const px1 = mapInstance.project(p1 as [number, number])
|
||||
const px2 = mapInstance.project(p2 as [number, number])
|
||||
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
const arrowSize = 14 * dpr
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(px2.x * dpr, px2.y * dpr)
|
||||
ctx.rotate(angle + Math.PI / 2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -arrowSize)
|
||||
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
|
||||
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Draw line/polygon label markers at midpoints
|
||||
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
|
||||
const label = f.properties.label as string
|
||||
let midpoint: [number, number]
|
||||
|
||||
if (f.geometry.type === 'LineString') {
|
||||
const coords = f.geometry.coordinates as number[][]
|
||||
const midIdx = Math.floor(coords.length / 2)
|
||||
if (coords.length === 2) {
|
||||
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
||||
} else {
|
||||
midpoint = coords[midIdx] as [number, number]
|
||||
}
|
||||
} else {
|
||||
// Polygon: centroid of first ring
|
||||
const ring = (f.geometry.coordinates as number[][][])[0]
|
||||
const len = ring.length - 1
|
||||
let cx = 0, cy = 0
|
||||
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
||||
midpoint = [cx / len, cy / len]
|
||||
}
|
||||
|
||||
const pixel = mapInstance.project(midpoint)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
const fontSize = 13 * dpr
|
||||
const isDanger = f.type === 'dangerzone'
|
||||
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
|
||||
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
|
||||
|
||||
ctx.save()
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const metrics = ctx.measureText(label)
|
||||
const padX = 7 * dpr
|
||||
const padY = 3 * dpr
|
||||
const boxW = metrics.width + padX * 2
|
||||
const boxH = fontSize + padY * 2
|
||||
const radius = 4 * dpr
|
||||
|
||||
// Background pill
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||
ctx.fill()
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = borderColor
|
||||
ctx.lineWidth = 1.5 * dpr
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||
ctx.stroke()
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillText(label, px, py)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Draw text features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'text')) {
|
||||
if (f.geometry.type !== 'Point') continue
|
||||
const coords = f.geometry.coordinates as [number, number]
|
||||
const pixel = mapInstance.project(coords)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
|
||||
const text = (f.properties.text as string) || ''
|
||||
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
|
||||
ctx.save()
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
// White outline
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 3 * dpr
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.strokeText(text, px, py)
|
||||
// Fill
|
||||
ctx.fillStyle = color
|
||||
ctx.fillText(text, px, py)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const title = currentProject?.title || 'Lageplan'
|
||||
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
|
||||
|
||||
if (format === 'png') {
|
||||
const link = document.createElement('a')
|
||||
link.download = `${safeName}.png`
|
||||
link.href = exportCanvas.toDataURL('image/png')
|
||||
link.click()
|
||||
addAudit(`Export: ${safeName}.png`)
|
||||
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
|
||||
} else {
|
||||
// PDF Export — rapport-style clean layout
|
||||
const imgData = exportCanvas.toDataURL('image/png')
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
const locationStr = currentProject?.location || ''
|
||||
const einsatzNr = (currentProject as any)?.einsatzNr || ''
|
||||
const tenantLabel = tenant?.name || ''
|
||||
|
||||
// A4 landscape (mm)
|
||||
const pdf = new jsPDF('l', 'mm', 'a4')
|
||||
const pageW = pdf.internal.pageSize.getWidth() // 297
|
||||
const pageH = pdf.internal.pageSize.getHeight() // 210
|
||||
const m = 10 // margin
|
||||
|
||||
// ── Header section ──
|
||||
const headerY = m
|
||||
pdf.setFontSize(18)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setTextColor(26, 26, 26)
|
||||
pdf.text('Einsatz-Lageplan', m, headerY + 6)
|
||||
|
||||
pdf.setFontSize(9)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(107, 114, 128) // gray-500
|
||||
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
|
||||
|
||||
// Right side: Einsatz-Nr + date
|
||||
pdf.setFontSize(14)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setTextColor(185, 28, 28) // red-700
|
||||
if (einsatzNr) {
|
||||
const nrW = pdf.getTextWidth(einsatzNr)
|
||||
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
|
||||
}
|
||||
pdf.setFontSize(9)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(107, 114, 128)
|
||||
const dateLabel = `${dateStr} · ${timeStr}`
|
||||
const dlW = pdf.getTextWidth(dateLabel)
|
||||
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
|
||||
|
||||
// Divider line + red accent
|
||||
const divY = headerY + 15
|
||||
pdf.setDrawColor(26, 26, 26)
|
||||
pdf.setLineWidth(0.8)
|
||||
pdf.line(m, divY, pageW - m, divY)
|
||||
pdf.setFillColor(185, 28, 28)
|
||||
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
|
||||
|
||||
// ── Map image ──
|
||||
const mapTop = divY + 3
|
||||
const mapBottom = pageH - m - 12 // leave space for footer
|
||||
const mapAreaW = pageW - 2 * m
|
||||
const mapAreaH = mapBottom - mapTop
|
||||
|
||||
// Fit map image into area while preserving aspect ratio
|
||||
const imgAspect = w / h
|
||||
const areaAspect = mapAreaW / mapAreaH
|
||||
let drawW = mapAreaW
|
||||
let drawH = mapAreaH
|
||||
if (imgAspect > areaAspect) {
|
||||
drawH = mapAreaW / imgAspect
|
||||
} else {
|
||||
drawW = mapAreaH * imgAspect
|
||||
}
|
||||
const mapX = m + (mapAreaW - drawW) / 2
|
||||
const mapY = mapTop + (mapAreaH - drawH) / 2
|
||||
|
||||
// Light border around map
|
||||
pdf.setDrawColor(229, 231, 235)
|
||||
pdf.setLineWidth(0.3)
|
||||
pdf.rect(mapX, mapY, drawW, drawH)
|
||||
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
|
||||
|
||||
// ── Footer ──
|
||||
const footerY = pageH - m - 4
|
||||
pdf.setFontSize(7)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(156, 163, 175) // gray-400
|
||||
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
|
||||
const footerR = 'app.lageplan.ch'
|
||||
const frW = pdf.getTextWidth(footerR)
|
||||
pdf.text(footerR, pageW - m - frW, footerY)
|
||||
|
||||
pdf.save(`${safeName}.pdf`)
|
||||
addAudit(`Export: ${safeName}.pdf`)
|
||||
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
|
||||
}
|
||||
}, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
|
||||
|
||||
return { handleExport }
|
||||
}
|
||||
Reference in New Issue
Block a user