import { useCallback } from 'react'; import { createMercatorProjection } from './map-projection'; import { geoJsonRings } from './map-geojson'; import { resolveScale } from './map-scale'; import { withAlpha } from './use-map-token-color'; import { niceScaleDistance } from '../map-scale-bar/scale-distance'; import type { MapMarkerData } from './map-marker'; import type { GeoJsonInput } from './map-geojson'; import type { LatLng } from './map.types'; export interface MapSnapshotLegendItem { color: string; label: string; } export interface MapSnapshotOptions { /** @default true */ withLegend?: boolean; /** @default true */ withScaleBar?: boolean; /** @default true */ withAttribution?: boolean; /** Device pixel multiplier. @default 2 */ scale?: number; /** @default 960 */ width?: number; /** @default 540 */ height?: number; background?: string; foreground?: string; mutedForeground?: string; legendItems?: MapSnapshotLegendItem[]; legendTitle?: string; attribution?: string; } export interface MapSnapshotSource { markers: MapMarkerData[]; baseGeojson?: GeoJsonInput | null; /** Resolved literal colors keyed by token name. */ colors: Record; defaultColor: string; outlineColor: string; } /** * Renders the map's data layer to a PNG, with no new dependency. * * @description * **What this can and cannot capture, stated plainly.** The Google basemap * tiles are served cross-origin and painted into a canvas the page does not * own; drawing them into a canvas of ours taints it, and `toDataURL` then * throws. There is no client-side way around that without either a DOM- * rasterizing dependency (`html2canvas` and friends) or a separate billed call * to the Static Maps API. Both were out of scope, so this composes the layer * the library itself draws: the reference outline, the markers at their true * projected positions, and the legend, scale bar and attribution that a report * needs in the image rather than beside it. * * The result is a clean cartographic figure without tile imagery — which is * frequently what a dossier wants anyway. When the basemap must be in the * picture, capture it server-side or through the Static Maps API. */ export function useMapSnapshot(source: MapSnapshotSource) { const draw = useCallback( (options: MapSnapshotOptions = {}) => { const { withLegend = true, withScaleBar = true, withAttribution = true, scale = 2, width = 960, height = 540, background = '#ffffff', foreground = '#111111', mutedForeground = '#666666', legendItems = [], legendTitle, attribution, } = options; if (typeof document === 'undefined') return null; const canvas = document.createElement('canvas'); canvas.width = width * scale; canvas.height = height * scale; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.scale(scale, scale); ctx.fillStyle = background; ctx.fillRect(0, 0, width, height); const outlineRings = geoJsonRings(source.baseGeojson); const points: LatLng[] = [ ...source.markers.map(marker => marker.position), ...outlineRings.flatMap(ring => ring.points), ]; const projection = createMercatorProjection(points, width, height, 40); if (!projection) return canvas; /* --- Reference outline ------------------------------------------- */ outlineRings.forEach(ring => { ctx.beginPath(); ring.points.forEach((point, index) => { const { x, y } = projection.project(point); if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); if (ring.closed) ctx.closePath(); ctx.fillStyle = withAlpha(source.outlineColor, 0.12); if (ring.closed) ctx.fill(); ctx.strokeStyle = withAlpha(source.outlineColor, 0.45); ctx.lineWidth = 1; ctx.stroke(); }); /* --- Markers ------------------------------------------------------ */ source.markers.forEach(marker => { const { x, y } = projection.project(marker.position); const token = marker.colorToken; const color = token ? source.colors[token] || source.defaultColor : marker.customColor || source.defaultColor; const radius = marker.scale ? resolveScale(marker.scale) : (marker.radiusPx ?? (marker.shape === 'pin' ? 8 : 8)); ctx.beginPath(); if (marker.shape === 'square') ctx.rect(x - radius, y - radius, radius * 2, radius * 2); else ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = withAlpha(color, marker.fillOpacity ?? 0.85); ctx.fill(); ctx.strokeStyle = background; ctx.lineWidth = 2; ctx.stroke(); if (marker.caption) { ctx.fillStyle = foreground; ctx.font = '11px system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(marker.caption, x + radius + 4, y); } }); /* --- Chrome ------------------------------------------------------- */ if (withLegend && legendItems.length > 0) { const padding = 10; const rowHeight = 16; const boxHeight = legendItems.length * rowHeight + padding * 2 + (legendTitle ? 16 : 0); const boxWidth = 190; const boxY = height - boxHeight - 12; ctx.fillStyle = withAlpha(background, 0.92); ctx.fillRect(12, boxY, boxWidth, boxHeight); ctx.strokeStyle = withAlpha(mutedForeground, 0.35); ctx.lineWidth = 1; ctx.strokeRect(12, boxY, boxWidth, boxHeight); let cursor = boxY + padding + 6; if (legendTitle) { ctx.fillStyle = foreground; ctx.font = '600 11px system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(legendTitle, 12 + padding, cursor); cursor += 16; } legendItems.forEach(item => { ctx.beginPath(); ctx.arc(12 + padding + 5, cursor, 5, 0, Math.PI * 2); ctx.fillStyle = item.color; ctx.fill(); ctx.fillStyle = mutedForeground; ctx.font = '11px system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(item.label, 12 + padding + 16, cursor); cursor += rowHeight; }); } if (withScaleBar) { // Metres per pixel straight from the projection: two points one degree // of longitude apart, measured on screen. const a = projection.project({ lat: 0, lng: 0 }); const b = projection.project({ lat: 0, lng: 1 }); const pixelsPerDegree = Math.abs(b.x - a.x); const metersPerPixel = pixelsPerDegree > 0 ? 111320 / pixelsPerDegree : 0; const bar = niceScaleDistance(metersPerPixel, 120, 'metric'); if (bar) { const barY = height - 22; const barX = width - bar.widthPx - 16; ctx.strokeStyle = foreground; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(barX, barY); ctx.lineTo(barX, barY + 5); ctx.lineTo(barX + bar.widthPx, barY + 5); ctx.lineTo(barX + bar.widthPx, barY); ctx.stroke(); ctx.fillStyle = foreground; ctx.font = '10px system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.fillText(bar.label, barX, barY - 2); } } if (withAttribution && attribution) { ctx.fillStyle = mutedForeground; ctx.font = '10px system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.fillText(attribution, 12, height - 6); } return canvas; }, [source] ); const toDataUrl = useCallback( (options?: MapSnapshotOptions) => draw(options)?.toDataURL('image/png') ?? null, [draw] ); const toPng = useCallback( (options?: MapSnapshotOptions) => new Promise(resolve => { const canvas = draw(options); if (!canvas) { resolve(null); return; } canvas.toBlob(blob => resolve(blob), 'image/png'); }), [draw] ); return { toPng, toDataUrl, toCanvas: draw }; }