|
| 1 | +// SPDX-License-Identifier: BSD-3-Clause |
| 2 | +// Copyright (c) 2026, The OpenROAD Authors |
| 3 | + |
| 4 | +// WYSIWYG layout capture. Composites the currently rendered Leaflet layers |
| 5 | +// (base tiles, the highlight/selection overlay, the active heatmap) plus the |
| 6 | +// SVG overlays (rulers, selection outline, labels) into a PNG and downloads |
| 7 | +// it — mirroring the Qt GUI's "Save image", which saves the displayed scene. |
| 8 | +// |
| 9 | +// All tiles are same-origin <img> (blob: URLs), so the canvas is not tainted |
| 10 | +// and toBlob() works. Tile positions are read from getBoundingClientRect so |
| 11 | +// the composite is correct under fractional zoom / CSS transforms. |
| 12 | +// |
| 13 | +// entire=true first fits the whole design (no animation), waits for every |
| 14 | +// layer's tiles to settle, then composites — capturing the full design exactly |
| 15 | +// as the window shows it (layers + heatmap + highlights + rulers), and restores |
| 16 | +// the previous view afterwards. |
| 17 | + |
| 18 | +function downloadBlob(blob, filename) { |
| 19 | + if (!blob) { |
| 20 | + return; |
| 21 | + } |
| 22 | + const url = URL.createObjectURL(blob); |
| 23 | + const a = document.createElement('a'); |
| 24 | + a.href = url; |
| 25 | + a.download = filename; |
| 26 | + a.style.display = 'none'; |
| 27 | + document.body.appendChild(a); |
| 28 | + a.click(); |
| 29 | + document.body.removeChild(a); |
| 30 | + setTimeout(() => URL.revokeObjectURL(url), 0); |
| 31 | +} |
| 32 | + |
| 33 | +// A layer's effective opacity (default 1 when unset). |
| 34 | +function layerOpacity(layer) { |
| 35 | + return (layer.options && layer.options.opacity != null) |
| 36 | + ? layer.options.opacity : 1; |
| 37 | +} |
| 38 | + |
| 39 | +// Draw one <img>/element at its on-screen position relative to the map. Skips |
| 40 | +// an <img> that hasn't decoded yet, and swallows a not-yet-drawable element. |
| 41 | +function drawElementAt(ctx, el, mapRect) { |
| 42 | + if (!el) { |
| 43 | + return; |
| 44 | + } |
| 45 | + if (el.tagName === 'IMG' && (!el.complete || !el.naturalWidth)) { |
| 46 | + return; // still loading |
| 47 | + } |
| 48 | + const r = el.getBoundingClientRect(); |
| 49 | + if (r.width <= 0 || r.height <= 0) { |
| 50 | + return; |
| 51 | + } |
| 52 | + try { |
| 53 | + ctx.drawImage(el, r.left - mapRect.left, r.top - mapRect.top, |
| 54 | + r.width, r.height); |
| 55 | + } catch (err) { |
| 56 | + // Skip an element that can't be drawn yet. |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +// Draw every loaded GridLayer <img> tile onto ctx, in z-index order, at its |
| 61 | +// on-screen position relative to the map container. |
| 62 | +function drawGridLayers(app, ctx, mapRect) { |
| 63 | + const layers = []; |
| 64 | + app.map.eachLayer((layer) => { |
| 65 | + if (layer instanceof L.GridLayer && layer._tiles) { |
| 66 | + layers.push(layer); |
| 67 | + } |
| 68 | + }); |
| 69 | + layers.sort((a, b) => |
| 70 | + ((a.options && a.options.zIndex) || 0) |
| 71 | + - ((b.options && b.options.zIndex) || 0)); |
| 72 | + |
| 73 | + for (const layer of layers) { |
| 74 | + const opacity = layerOpacity(layer); |
| 75 | + if (opacity <= 0) { |
| 76 | + continue; |
| 77 | + } |
| 78 | + ctx.globalAlpha = opacity; |
| 79 | + for (const key of Object.keys(layer._tiles)) { |
| 80 | + const t = layer._tiles[key]; |
| 81 | + if (!t || !t.current) { |
| 82 | + continue; |
| 83 | + } |
| 84 | + drawElementAt(ctx, t.el, mapRect); |
| 85 | + } |
| 86 | + } |
| 87 | + ctx.globalAlpha = 1; |
| 88 | +} |
| 89 | + |
| 90 | +// Rasterize the SVG overlay panes (rulers, selection outline, labels) on top. |
| 91 | +// Async: each SVG is drawn through an <img> data URL. |
| 92 | +function drawSvgOverlays(app, ctx, mapRect) { |
| 93 | + const svgs = app.map.getContainer().querySelectorAll('.leaflet-pane svg'); |
| 94 | + const jobs = []; |
| 95 | + svgs.forEach((svg) => { |
| 96 | + const r = svg.getBoundingClientRect(); |
| 97 | + if (r.width <= 0 || r.height <= 0) { |
| 98 | + return; |
| 99 | + } |
| 100 | + const clone = svg.cloneNode(true); |
| 101 | + clone.setAttribute('width', r.width); |
| 102 | + clone.setAttribute('height', r.height); |
| 103 | + const xml = new XMLSerializer().serializeToString(clone); |
| 104 | + const url = 'data:image/svg+xml;charset=utf-8,' |
| 105 | + + encodeURIComponent(xml); |
| 106 | + jobs.push(new Promise((resolve) => { |
| 107 | + const img = new Image(); |
| 108 | + img.onload = () => { |
| 109 | + try { |
| 110 | + ctx.drawImage(img, r.left - mapRect.left, |
| 111 | + r.top - mapRect.top, r.width, r.height); |
| 112 | + } catch (err) { /* ignore */ } |
| 113 | + resolve(); |
| 114 | + }; |
| 115 | + img.onerror = () => resolve(); |
| 116 | + img.src = url; |
| 117 | + })); |
| 118 | + }); |
| 119 | + return Promise.all(jobs); |
| 120 | +} |
| 121 | + |
| 122 | +// Count tiles that haven't finished loading across every GridLayer (base, |
| 123 | +// overlay/highlights, heatmap, ...): a layer still marked _loading, or a |
| 124 | +// current tile whose <img> hasn't decoded yet. Zero => the scene is painted. |
| 125 | +function tilesPending(app) { |
| 126 | + let pending = 0; |
| 127 | + app.map.eachLayer((layer) => { |
| 128 | + if (!(layer instanceof L.GridLayer) || !layer._tiles) { |
| 129 | + return; |
| 130 | + } |
| 131 | + if (layer._loading) { |
| 132 | + pending++; |
| 133 | + } |
| 134 | + for (const key of Object.keys(layer._tiles)) { |
| 135 | + const t = layer._tiles[key]; |
| 136 | + const el = t && t.el; |
| 137 | + if (t && t.current && el && el.tagName === 'IMG' |
| 138 | + && (!el.complete || !el.naturalWidth)) { |
| 139 | + pending++; |
| 140 | + } |
| 141 | + } |
| 142 | + }); |
| 143 | + return pending; |
| 144 | +} |
| 145 | + |
| 146 | +// Resolve once all layers' tiles have settled (stable across a couple of polls) |
| 147 | +// or a timeout elapses. Used after a fit so the capture waits for the newly |
| 148 | +// requested tiles (base + overlay + heatmap) to arrive. |
| 149 | +function whenTilesSettled(app, timeoutMs = 6000, intervalMs = 32) { |
| 150 | + return new Promise((resolve) => { |
| 151 | + const deadline = Date.now() + timeoutMs; |
| 152 | + let stable = 0; |
| 153 | + const check = () => { |
| 154 | + if (tilesPending(app) === 0) { |
| 155 | + if (++stable >= 2) { |
| 156 | + resolve(); |
| 157 | + return; |
| 158 | + } |
| 159 | + } else { |
| 160 | + stable = 0; |
| 161 | + } |
| 162 | + if (Date.now() >= deadline) { |
| 163 | + resolve(); |
| 164 | + return; |
| 165 | + } |
| 166 | + setTimeout(check, intervalMs); |
| 167 | + }; |
| 168 | + setTimeout(check, intervalMs); |
| 169 | + }); |
| 170 | +} |
| 171 | + |
| 172 | +// Draw single-image overlays (e.g. the timing-path L.ImageOverlay) at their |
| 173 | +// on-screen position, so they land in the capture like the tile layers. |
| 174 | +function drawImageOverlays(app, ctx, mapRect) { |
| 175 | + app.map.eachLayer((layer) => { |
| 176 | + if (!(layer instanceof L.ImageOverlay)) { |
| 177 | + return; |
| 178 | + } |
| 179 | + const opacity = layerOpacity(layer); |
| 180 | + if (opacity <= 0) { |
| 181 | + return; |
| 182 | + } |
| 183 | + ctx.globalAlpha = opacity; |
| 184 | + drawElementAt(ctx, layer._image, mapRect); |
| 185 | + ctx.globalAlpha = 1; |
| 186 | + }); |
| 187 | +} |
| 188 | + |
| 189 | +async function renderToBlob(app) { |
| 190 | + const container = app.map.getContainer(); |
| 191 | + const mapRect = container.getBoundingClientRect(); |
| 192 | + const w = Math.max(1, Math.round(mapRect.width)); |
| 193 | + const h = Math.max(1, Math.round(mapRect.height)); |
| 194 | + const canvas = document.createElement('canvas'); |
| 195 | + canvas.width = w; |
| 196 | + canvas.height = h; |
| 197 | + const ctx = canvas.getContext('2d'); |
| 198 | + // Background matches the viewer (--bg-map); fall back to #111 when the |
| 199 | + // computed color is empty or fully transparent (both would export a |
| 200 | + // transparent PNG, hiding light elements on white viewers). |
| 201 | + const bg = getComputedStyle(container).backgroundColor; |
| 202 | + ctx.fillStyle = (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') |
| 203 | + ? bg : '#111'; |
| 204 | + ctx.fillRect(0, 0, w, h); |
| 205 | + drawGridLayers(app, ctx, mapRect); |
| 206 | + drawImageOverlays(app, ctx, mapRect); |
| 207 | + await drawSvgOverlays(app, ctx, mapRect); |
| 208 | + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); |
| 209 | +} |
| 210 | + |
| 211 | +// Public entry point. entire=false composites the current viewport; |
| 212 | +// entire=true fits the whole design first, waits for the newly requested tiles |
| 213 | +// to settle, captures, then restores the previous view. Both are WYSIWYG: |
| 214 | +// base layers + heatmap + highlights/selection + rulers, exactly as shown. |
| 215 | +export async function captureLayout(app, { entire = false } = {}) { |
| 216 | + if (!app.map) { |
| 217 | + return; |
| 218 | + } |
| 219 | + // Entire needs whole-design bounds; without them the capture would just be |
| 220 | + // the current viewport, so save it (honestly) as the visible layout. |
| 221 | + if (!entire || !app.fitBounds) { |
| 222 | + downloadBlob(await renderToBlob(app), 'layout_visible.png'); |
| 223 | + return; |
| 224 | + } |
| 225 | + |
| 226 | + const prevCenter = app.map.getCenter(); |
| 227 | + const prevZoom = app.map.getZoom(); |
| 228 | + try { |
| 229 | + app.map.fitBounds(app.fitBounds, { animate: false }); |
| 230 | + await whenTilesSettled(app); |
| 231 | + downloadBlob(await renderToBlob(app), 'layout_entire.png'); |
| 232 | + } finally { |
| 233 | + app.map.setView(prevCenter, prevZoom, { animate: false }); |
| 234 | + } |
| 235 | +} |
0 commit comments