import { useCallback } from 'react' import { jsPDF } from 'jspdf' import type { DrawFeature, Project } from '@/types' interface UseMapExportOptions { mapRef: React.MutableRefObject featuresRef: React.MutableRefObject 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 => 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 } }