/** * useMapPoster — render a Mapbox GL map to a poster-grade PNG or PDF blob. * * Two render paths: * 1. Single-canvas (≤4096² output, fast): * - Spin up a hidden map at the requested poster pixel size. * - Override window.devicePixelRatio for the hidden map only. * - Wait for `idle`, capture via getCanvas().toDataURL(). * - Composite overlay (title, legend, scale, north, footer) on a 2D canvas. * * 2. Tile-stitch (>4096², size-aware fallback): * - Iterate an N×M grid of viewports on the hidden map at TILE_PX each. * - For each tile: jumpTo, await idle, capture, paint into the master canvas. * - Memory-bound — caller should be aware. * * If the requested pixel size > 4096 in either dimension AND the user requested * the single-canvas path, we auto-switch to tile-stitch with a console warning. * * Mapbox attribution is ALWAYS rendered into the footer slot — non-negotiable * (Mapbox ToS). * * @example * const poster = useMapPoster(getMap); * const blob = await poster.render(spec, 'png'); * download(blob, 'research-map.png'); * * // TODO package.json deps when you wire this: * // "jspdf": "^4.2.1", * // "svg2pdf.js": "^2.5.0", * // "pdf-lib": "^1.17.1", * // "@pdf-lib/fontkit": "^1.1.1" */ import { ref, readonly } from 'vue'; import { computeLayout } from './usePosterLayout.js'; import { engineFor } from '../useMapProvider.js'; const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware const TILE_PX = 2048; // tile size for stitch path const IDLE_TIMEOUT_MS = 15000; /** * @typedef {Object} PosterMargins * @property {number} top inches * @property {number} right inches * @property {number} bottom inches * @property {number} left inches */ /** * @typedef {Object} PosterSlots * @property {Object} [title] { text, subtitle?, align? } * @property {Object} [legend] { rows: Array, type?: string } * @property {boolean} [scaleBar] * @property {boolean} [north] * @property {Object} [footer] { left?, right?, attribution? } */ /** * @typedef {Object} PosterSpec * @property {number} widthIn * @property {number} heightIn * @property {150|200|300} dpi * @property {'portrait'|'landscape'} orientation * @property {PosterMargins} margins * @property {number} bleed inches * @property {PosterSlots} slots * @property {'auto'|'single'|'stitch'} [renderPath='auto'] * @property {string} [filename] * @property {AbortSignal} [signal] */ /** * @param {() => any} getSourceMap function returning the live Mapbox map * @returns {{ * render: (spec: PosterSpec, format: 'png'|'pdf') => Promise, * progress: import('vue').Ref<{ phase: string, completed: number, total: number, message: string }>, * isRendering: import('vue').Ref, * cancel: () => void * }} */ export function useMapPoster(getSourceMap) { const progress = ref({ phase: 'idle', completed: 0, total: 0, message: '', fraction: 0, percent: 0 }); const isRendering = ref(false); let abortCtrl = null; /** * Render the poster. * @param {PosterSpec} spec * @param {'png'|'pdf'} [format='png'] * @returns {Promise} */ async function render(spec, format = 'png') { if (isRendering.value) throw new Error('useMapPoster: already rendering'); const sourceMap = getSourceMap(); if (!sourceMap) throw new Error('useMapPoster: no source map'); abortCtrl = new AbortController(); const signal = mergeSignals(spec.signal, abortCtrl.signal); isRendering.value = true; _maxFraction = 0; _setProgress('init', 1, 1, 'preparing render'); try { const px = posterPixels(spec); const renderPath = decidePath(spec.renderPath || 'auto', px); if (renderPath === 'single' && (px.width > MAX_SINGLE_CANVAS || px.height > MAX_SINGLE_CANVAS)) { // user explicitly requested single but exceeded WebGL ceiling — auto-switch // eslint-disable-next-line no-console console.warn( `[useMapPoster] requested single-canvas ${px.width}×${px.height}px > ${MAX_SINGLE_CANVAS}; ` + `auto-switching to tile-stitch.` ); } const finalPath = (renderPath === 'single' && (px.width > MAX_SINGLE_CANVAS || px.height > MAX_SINGLE_CANVAS)) ? 'stitch' : renderPath; const baseRaster = (finalPath === 'single') ? await _renderSingleCanvas(sourceMap, px, signal) : await _renderTileStitch(sourceMap, px, signal); _setProgress('compose', 0, 1, 'compositing overlay'); const composed = await _composeOverlay(baseRaster, spec, px); _setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`); const blob = (format === 'pdf') ? await _toPdfBlob(composed, spec) : await _canvasToBlob(composed, 'image/png'); _setProgress('done', 1, 1, 'render complete'); return blob; } finally { isRendering.value = false; abortCtrl = null; } } function cancel() { if (abortCtrl) abortCtrl.abort(); } // ── PHASE WEIGHTS: a bar that moves through the WHOLE render ──────────── // Every phase used to report (0 of 1), so the bar sat at 0% for the entire // slow part — spawning a hidden map and waiting for tiles is most of the wall // clock — and then jumped to 100% in the last instant. A user reads a bar that // does not move as FROZEN. // // These weights are the approximate share of wall-clock each phase takes, so // `fraction` advances continuously and is MONOTONIC (it can never go // backwards, which also reads as a glitch). Within a phase, completed/total // interpolates — the tile-stitch path reports real per-tile progress, so its // segment fills smoothly rather than in one jump. const PHASE_WEIGHT = { init: 0.02, 'hidden-map': 0.30, // spawning the map + waiting for tiles: the long pole stitch: 0.42, // tiled path only; real per-tile granularity capture: 0.14, compose: 0.06, encode: 0.05, done: 0.01, }; const PHASE_ORDER = ['init', 'hidden-map', 'stitch', 'capture', 'compose', 'encode', 'done']; let _maxFraction = 0; function _setProgress(phase, completed, total, message) { // Sum the weight of every phase already finished, then add this phase's own // share scaled by how far through it we are. const idx = PHASE_ORDER.indexOf(phase); let base = 0; for (let i = 0; i < idx; i++) base += (PHASE_WEIGHT[PHASE_ORDER[i]] || 0); const within = total > 0 ? Math.min(1, completed / total) : 0; const raw = base + (PHASE_WEIGHT[phase] || 0) * within; _maxFraction = phase === 'init' ? raw : Math.max(_maxFraction, raw); progress.value = { phase, completed, total, message, fraction: Math.min(1, _maxFraction), percent: Math.round(Math.min(1, _maxFraction) * 100), }; } // ─── Single-canvas path ──────────────────────────────────────────────── async function _renderSingleCanvas(sourceMap, px, signal) { // Sub-steps inside the long pole. Waiting for map tiles is the slowest part // of a render and used to be one silent await, so the bar froze there. _setProgress('hidden-map', 0, 3, 'creating hidden map'); const { hiddenMap, restoreDpr, container } = await _spawnHiddenMap(sourceMap, px.width, px.height); try { _throwIfAborted(signal); _setProgress('hidden-map', 1, 3, 'loading map tiles'); await _waitForIdle(hiddenMap); _setProgress('hidden-map', 3, 3, 'tiles loaded'); _throwIfAborted(signal); _setProgress('capture', 0, 1, 'capturing canvas'); const dataUrl = hiddenMap.getCanvas().toDataURL('image/png'); const img = await _loadImage(dataUrl); const canvas = document.createElement('canvas'); canvas.width = px.width; canvas.height = px.height; canvas.getContext('2d').drawImage(img, 0, 0, px.width, px.height); return canvas; } finally { try { hiddenMap.remove(); } catch (_) { /* noop */ } if (container?.parentNode) container.parentNode.removeChild(container); restoreDpr(); } } // ─── Tile-stitch path ────────────────────────────────────────────────── async function _renderTileStitch(sourceMap, px, signal) { const cols = Math.ceil(px.width / TILE_PX); const rows = Math.ceil(px.height / TILE_PX); const total = cols * rows; _setProgress('hidden-map', 0, total, `creating hidden map (${cols}×${rows} tiles)`); // Build a hidden map sized exactly to ONE tile; we'll re-aim it per tile. const sourceBounds = sourceMap.getBounds(); const sourceCenter = sourceMap.getCenter(); const sourceZoom = sourceMap.getZoom(); const sourceBearing = sourceMap.getBearing(); const sourcePitch = sourceMap.getPitch(); const { hiddenMap, restoreDpr, container } = await _spawnHiddenMap(sourceMap, TILE_PX, TILE_PX, { preserveBounds: true, bounds: sourceBounds, }); try { const master = document.createElement('canvas'); master.width = px.width; master.height = px.height; const ctx = master.getContext('2d'); // Compute the geographic span per tile by projecting the source's bounds // into a rectangle and slicing it. We keep the source's bearing/pitch. const ne = sourceBounds.getNorthEast(); const sw = sourceBounds.getSouthWest(); const lngSpan = ne.lng - sw.lng; const latSpan = ne.lat - sw.lat; const lngStep = lngSpan / cols; const latStep = latSpan / rows; let completed = 0; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { _throwIfAborted(signal); const tileSw = [sw.lng + (c * lngStep), ne.lat - ((r + 1) * latStep)]; const tileNe = [sw.lng + ((c + 1) * lngStep), ne.lat - (r * latStep)]; hiddenMap.fitBounds([tileSw, tileNe], { padding: 0, animate: false, duration: 0 }); await _waitForIdle(hiddenMap); const tileUrl = hiddenMap.getCanvas().toDataURL('image/png'); const img = await _loadImage(tileUrl); // tile fills 0..TILE_PX, but the last col/row may have a partial tile. const dx = c * TILE_PX; const dy = r * TILE_PX; const dw = Math.min(TILE_PX, px.width - dx); const dh = Math.min(TILE_PX, px.height - dy); ctx.drawImage(img, 0, 0, TILE_PX, TILE_PX, dx, dy, dw, dh); completed++; _setProgress('stitch', completed, total, `tile ${completed}/${total}`); } } // restore source map view (in case we share a token bucket etc.) sourceMap.jumpTo({ center: sourceCenter, zoom: sourceZoom, bearing: sourceBearing, pitch: sourcePitch }); return master; } finally { try { hiddenMap.remove(); } catch (_) { /* noop */ } if (container?.parentNode) container.parentNode.removeChild(container); restoreDpr(); } } // ─── Overlay compositing ────────────────────────────────────────────── async function _composeOverlay(baseCanvas, spec, px) { const layout = computeLayout(spec, px); const ctx = baseCanvas.getContext('2d'); // Render slots as SVG strings → rasterize → drawImage onto base canvas. const slotSvgs = await _collectSlotSvgs(spec, layout); for (const slot of slotSvgs) { const img = await _svgStringToImage(slot.svg, slot.box.w, slot.box.h); ctx.drawImage(img, slot.box.x, slot.box.y, slot.box.w, slot.box.h); } return baseCanvas; } // Collects SVG markup for each enabled slot. Components render their own // SVG; here we just stub the strings — actual SVG building is mostly done // inside the slot components (PosterPreview path) or inline (server path). async function _collectSlotSvgs(spec, layout) { const out = []; if (spec.slots?.title) out.push({ box: layout.title, svg: _stubTitleSvg(spec, layout.title) }); // A legend with no rows is an empty white panel over the map: leave it out. if (spec.slots?.legend?.rows?.length) out.push({ box: layout.legend, svg: _stubLegendSvg(spec, layout.legend) }); if (spec.slots?.scaleBar) out.push({ box: layout.scaleBar, svg: _stubScaleSvg(spec, layout.scaleBar) }); if (spec.slots?.north) out.push({ box: layout.north, svg: _stubNorthSvg(spec, layout.north) }); // Footer is ALWAYS present (attribution is mandatory). out.push({ box: layout.footer, svg: _stubFooterSvg(spec, layout.footer) }); return out; } return { render, progress: readonly(progress), isRendering: readonly(isRendering), cancel, }; } // ────────────────────────────────────────────────────────────────────────── // Helpers (module-private) // ────────────────────────────────────────────────────────────────────────── /** * Convert a PosterSpec to its raster pixel dimensions, honoring orientation. * @param {PosterSpec} spec * @returns {{ width: number, height: number, dpi: number }} */ export function posterPixels(spec) { let w = spec.widthIn; let h = spec.heightIn; if (spec.orientation === 'landscape' && h > w) [w, h] = [h, w]; if (spec.orientation === 'portrait' && w > h) [w, h] = [h, w]; const dpi = spec.dpi || 300; return { width: Math.round(w * dpi), height: Math.round(h * dpi), dpi, }; } function decidePath(requested, px) { if (requested === 'single' || requested === 'stitch') return requested; if (px.width > MAX_SINGLE_CANVAS || px.height > MAX_SINGLE_CANVAS) return 'stitch'; return 'single'; } /** * Spawn a hidden map sized to the requested pixel dimensions. * Restores window.devicePixelRatio after construction (the hidden map * captures the value at init; subsequent restoration is safe). */ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) { const container = document.createElement('div'); container.style.cssText = ` position: fixed; top: -100000px; left: -100000px; width: ${w}px; height: ${h}px; pointer-events: none; visibility: hidden; `; document.body.appendChild(container); const originalDpr = window.devicePixelRatio; const restoreDpr = () => { try { Object.defineProperty(window, 'devicePixelRatio', { value: originalDpr, configurable: true }); } catch (_) { /* noop in strict envs */ } }; try { Object.defineProperty(window, 'devicePixelRatio', { value: 1, configurable: true }); } catch (_) { // Some environments lock devicePixelRatio; fall back silently. } // THE SAME ENGINE AS THE SOURCE MAP. This used to grab window.mapboxgl unconditionally, so on a // keyless page (provider=maplibre) every poster threw "mapboxgl global not found" — the source map // is a MapLibre map and no Mapbox engine is loaded at all. Measured 2026-09-25 while making the // Simple Research export one click. Mapbox behaviour is unchanged. // eslint-disable-next-line no-undef const W = (typeof window !== 'undefined' ? window : {}); const isMaplibre = !!W.maplibregl && sourceMap instanceof W.maplibregl.Map; const gl = engineFor(sourceMap); // the one per-instance helper (useMapProvider.js) if (!gl) throw new Error('useMapPoster: ' + (isMaplibre ? 'maplibregl' : 'mapboxgl') + ' global not found'); const styleObj = sourceMap.getStyle(); // MapLibre's style spec wants projection as an OBJECT ({ type: 'globe' }); a live MapLibre map can // hand back Mapbox's string spelling ('globe'), which MapLibre then refuses to load — measured // 2026-09-25: every keyless poster failed with "projection: object expected, string found". if (isMaplibre && styleObj && typeof styleObj.projection === 'string') { styleObj.projection = { type: styleObj.projection }; } const hiddenMap = new gl.Map({ container, style: styleObj, center: sourceMap.getCenter(), zoom: sourceMap.getZoom(), bearing: sourceMap.getBearing(), pitch: sourceMap.getPitch(), interactive: false, attributionControl: false, preserveDrawingBuffer: true, canvasContextAttributes: { preserveDrawingBuffer: true }, // MapLibre v5 spelling of the same fadeDuration: 0, }); // Style ready: 'style.load', or 'load', or already loaded by the time we look (an engine may finish // a style OBJECT before a listener is attached). A style that cannot load says why ('error'), so a // failed poster names its cause instead of a bare timeout. let lastError = ''; await new Promise((resolve, reject) => { let done = false; const finish = (err) => { if (done) return; done = true; clearTimeout(t); clearInterval(poll); err ? reject(err) : resolve(); }; const t = setTimeout(() => finish(new Error('useMapPoster: hidden map style.load timeout' + (lastError ? ' (' + lastError + ')' : ''))), IDLE_TIMEOUT_MS); const poll = setInterval(() => { try { if (hiddenMap.isStyleLoaded()) finish(); } catch (_) { /* not yet */ } }, 100); hiddenMap.once('style.load', () => finish()); hiddenMap.once('load', () => finish()); hiddenMap.on('error', (e) => { lastError = String(e?.error?.message || e?.message || e).slice(0, 160); }); }); // Print the place names in the language the reader sees. Mapbox localizes INSIDE the engine // (setLanguage), so a new instance starts in the style default (English) — measured on an Arabic // poster; carry the source map's language across. MapLibre's localized labels are expressions in // the style itself, which the hidden map already copied. try { const srcLang = typeof sourceMap.getLanguage === 'function' ? sourceMap.getLanguage() : null; if (srcLang && typeof hiddenMap.setLanguage === 'function') hiddenMap.setLanguage(srcLang); } catch (_) { /* a label left in the default language is a nicety lost, never a failed export */ } if (opts.preserveBounds && opts.bounds) { hiddenMap.fitBounds(opts.bounds, { padding: 0, animate: false, duration: 0 }); } return { hiddenMap, restoreDpr, container }; } // The hidden map's 'idle' can arrive late or never on a heavy render (measured 2026-09-25: an // Arabic Letter poster, RTL shaping + a 2x canvas, missed a 15s idle and the export gave NO file). // So: at IDLE_TIMEOUT_MS, capture if every tile is in (something else is only repainting); keep // waiting to IDLE_HARD_MS otherwise, then capture what is drawn with a warning. A poster whose last // tiles are missing is still a file; a rejected promise is a dead Export button. const IDLE_HARD_MS = 60000; function _waitForIdle(map) { return new Promise((resolve) => { let settled = false; const timers = []; const finish = (why) => { if (settled) return; settled = true; timers.forEach(clearTimeout); if (why) console.warn('[useMapPoster] ' + why); requestAnimationFrame(() => resolve()); }; const tilesIn = () => { try { return typeof map.areTilesLoaded === 'function' && map.areTilesLoaded(); } catch (_) { return false; } }; timers.push(setTimeout(() => { if (tilesIn()) finish(); }, IDLE_TIMEOUT_MS)); timers.push(setTimeout(() => finish('hidden map not idle after ' + IDLE_HARD_MS / 1000 + 's; capturing what is drawn'), IDLE_HARD_MS)); if (map.loaded() && !map.isMoving()) { finish(); } else { map.once('idle', () => finish()); } }); } function _loadImage(srcOrBlob) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = (e) => reject(new Error('image load failed: ' + (e?.message || 'unknown'))); img.src = (typeof srcOrBlob === 'string') ? srcOrBlob : URL.createObjectURL(srcOrBlob); }); } function _svgStringToImage(svgStr, w, h) { const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = (e) => { URL.revokeObjectURL(url); reject(new Error('svg load failed: ' + (e?.message || 'unknown'))); }; img.width = w; img.height = h; img.src = url; }); } function _canvasToBlob(canvas, type) { return new Promise((resolve, reject) => { canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob failed')), type); }); } async function _toPdfBlob(canvas, spec) { // Lazy ESM import — keeps jspdf out of the main bundle until poster is used. // jsPDF / svg2pdf must be added to package.json (see header). let jsPDFCtor; try { const mod = await import('jspdf'); jsPDFCtor = mod.jsPDF || mod.default; } catch (_) { throw new Error('useMapPoster.toPdf: jspdf not installed (npm i jspdf svg2pdf.js)'); } const dataUrl = canvas.toDataURL('image/png'); const orient = (canvas.width > canvas.height) ? 'l' : 'p'; const pdf = new jsPDFCtor({ orientation: orient, unit: 'in', format: [spec.widthIn, spec.heightIn], }); pdf.addImage(dataUrl, 'PNG', 0, 0, spec.widthIn, spec.heightIn); return pdf.output('blob'); } function _throwIfAborted(signal) { if (signal?.aborted) { throw new DOMException('useMapPoster: aborted', 'AbortError'); } } function mergeSignals(...signals) { const ctrl = new AbortController(); for (const s of signals) { if (!s) continue; if (s.aborted) { ctrl.abort(); break; } s.addEventListener('abort', () => ctrl.abort(), { once: true }); } return ctrl.signal; } // ─── Stub SVG builders for the headless render path ─────────────────────── // These produce minimal SVG so the headless render still has *something* in // each slot. Live preview uses the real Vue slot components instead. // spec.dir === 'rtl' (an Arabic poster): text starts at the RIGHT edge and runs right-to-left. // ⚠ Under direction="rtl" SVG's text-anchor is relative to the text's direction, so the right edge // is "start", not "end" — "end" pushed every Arabic title off the poster (measured 2026-09-25). function _rtl(spec) { return spec?.dir === 'rtl'; } function _textAt(spec, box, inset) { return _rtl(spec) ? `x="${box.w - inset}" text-anchor="start" direction="rtl"` : `x="${inset}"`; } function _stubTitleSvg(spec, box) { const t = (spec.slots?.title?.text || '').replace(/[<>&]/g, _esc); const s = (spec.slots?.title?.subtitle || '').replace(/[<>&]/g, _esc); return ` ${t} ${s ? `${s}` : ''} `; } function _stubLegendSvg(spec, box) { const rows = (spec.slots?.legend?.rows || []).slice(0, 24); const title = (spec.slots?.legend?.title || 'Legend').replace(/[<>&]/g, _esc); const rtl = _rtl(spec); // Type scales with the panel (it is ~600px wide at 200 DPI); the panel is only as TALL as its rows, // so the rest of the column stays map. const fs = Math.max(14, Math.round(box.w / 26)), line = Math.round(fs * 1.9), sw = Math.round(fs * 1.2), pad = Math.round(fs * 1.1); const panelH = Math.min(box.h, pad * 2 + line * (rows.length + 1)); const swX = rtl ? box.w - pad - sw : pad; const textX = rtl ? `x="${box.w - pad - sw - Math.round(fs * 0.7)}" text-anchor="start" direction="rtl"` : `x="${pad + sw + Math.round(fs * 0.7)}"`; const items = rows.map((r, i) => { const y = pad + line * (i + 1.5); const c = (r.color || '#999').replace(/[<>&]/g, _esc); const l = (r.label || '').replace(/[<>&]/g, _esc); return `${l}`; }).join(''); return ` ${title} ${items} `; } function _stubScaleSvg(_spec, box) { return ` scale `; } function _stubNorthSvg(_spec, box) { const s = Math.min(box.w, box.h); return ` N `; } function _stubFooterSvg(spec, box) { const left = (spec.slots?.footer?.left || '').replace(/[<>&]/g, _esc); const right = (spec.slots?.footer?.right || '').replace(/[<>&]/g, _esc); // Mapbox attribution is mandatory. const attribution = (spec.slots?.footer?.attribution || '© Mapbox © OpenStreetMap'); return ` ${left} ${attribution} ${right} `; } function _esc(ch) { return ch === '<' ? '<' : ch === '>' ? '>' : '&'; }