Skip to content

Commit c3691c3

Browse files
feat(web): add right-click context menu and search/navigation to Web GUI
Brings Qt-GUI parity (issue #10619 tables 2.7 and 2.8) to the web viewer. Table 2.7 - right-click context menu: - Select -> Connected insts / output / input / all nets / buffer trees - Highlight with the 8-color highlight groups - Save layout (visible viewport and entire design) - Clear (selections / focus nets / route guides / rulers / all) - Menu items enable/disable based on the type under the cursor; the View submenu is replaced by an on-canvas Fit control next to zoom. Table 2.8 - search & navigation: - Find by name/glob (fnmatch) with auto-zoom to the result - Go to position (x, y, size) - Ctrl/Cmd+F and Shift+G shortcuts Save layout is a client-side WYSIWYG composite of the displayed scene (base layers + heatmap + highlights/selection + rulers), for both the current viewport and the whole design (fit -> wait for tiles -> capture -> restore view). Backend: WebSocket handlers (handleSelect / handleFind / handleContextAction) in request_handler, whole-die image render in tile_generator, and a /download/image route with exception-free query parsing in web.cpp. Tests: FindHandler gtests plus JS unit tests for the context menu, search & navigation, and layout capture. Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
1 parent 90fa146 commit c3691c3

21 files changed

Lines changed: 2336 additions & 109 deletions

src/web/BUILD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ genrule(
4141
"src/tcl-completer.js",
4242
"src/hierarchy-browser.js",
4343
"src/menu-bar.js",
44+
"src/context-menu.js",
45+
"src/search-nav.js",
46+
"src/capture.js",
4447
"src/title.js",
4548
"src/clock-tree-widget.js",
4649
"src/schematic-widget.js",
@@ -67,6 +70,9 @@ genrule(
6770
" $(location src/tcl-completer.js)" +
6871
" $(location src/hierarchy-browser.js)" +
6972
" $(location src/menu-bar.js)" +
73+
" $(location src/context-menu.js)" +
74+
" $(location src/search-nav.js)" +
75+
" $(location src/capture.js)" +
7076
" $(location src/title.js)" +
7177
" $(location src/clock-tree-widget.js)" +
7278
" $(location src/schematic-widget.js)" +
@@ -94,6 +100,9 @@ _WEB_ASSET_FILES = [
94100
"src/tcl-completer.js",
95101
"src/hierarchy-browser.js",
96102
"src/menu-bar.js",
103+
"src/context-menu.js",
104+
"src/search-nav.js",
105+
"src/capture.js",
97106
"src/title.js",
98107
"src/clock-tree-widget.js",
99108
"src/schematic-widget.js",

src/web/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ set(WEB_ASSET_FILES
2828
src/tcl-completer.js
2929
src/hierarchy-browser.js
3030
src/menu-bar.js
31+
src/context-menu.js
32+
src/search-nav.js
33+
src/capture.js
3134
src/title.js
3235
src/clock-tree-widget.js
3336
src/schematic-widget.js
@@ -64,6 +67,9 @@ add_custom_command(
6467
${CMAKE_CURRENT_SOURCE_DIR}/src/tcl-completer.js
6568
${CMAKE_CURRENT_SOURCE_DIR}/src/hierarchy-browser.js
6669
${CMAKE_CURRENT_SOURCE_DIR}/src/menu-bar.js
70+
${CMAKE_CURRENT_SOURCE_DIR}/src/context-menu.js
71+
${CMAKE_CURRENT_SOURCE_DIR}/src/search-nav.js
72+
${CMAKE_CURRENT_SOURCE_DIR}/src/capture.js
6773
${CMAKE_CURRENT_SOURCE_DIR}/src/title.js
6874
${CMAKE_CURRENT_SOURCE_DIR}/src/clock-tree-widget.js
6975
${CMAKE_CURRENT_SOURCE_DIR}/src/schematic-widget.js

src/web/src/capture.js

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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).
199+
ctx.fillStyle = getComputedStyle(container).backgroundColor || '#111';
200+
ctx.fillRect(0, 0, w, h);
201+
drawGridLayers(app, ctx, mapRect);
202+
drawImageOverlays(app, ctx, mapRect);
203+
await drawSvgOverlays(app, ctx, mapRect);
204+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
205+
}
206+
207+
// Public entry point. entire=false composites the current viewport;
208+
// entire=true fits the whole design first, waits for the newly requested tiles
209+
// to settle, captures, then restores the previous view. Both are WYSIWYG:
210+
// base layers + heatmap + highlights/selection + rulers, exactly as shown.
211+
export async function captureLayout(app, { entire = false } = {}) {
212+
if (!app.map) {
213+
return;
214+
}
215+
// Entire needs whole-design bounds; without them the capture would just be
216+
// the current viewport, so save it (honestly) as the visible layout.
217+
if (!entire || !app.fitBounds) {
218+
downloadBlob(await renderToBlob(app), 'layout_visible.png');
219+
return;
220+
}
221+
222+
const prevCenter = app.map.getCenter();
223+
const prevZoom = app.map.getZoom();
224+
try {
225+
app.map.fitBounds(app.fitBounds, { animate: false });
226+
await whenTilesSettled(app);
227+
downloadBlob(await renderToBlob(app), 'layout_entire.png');
228+
} finally {
229+
app.map.setView(prevCenter, prevZoom, { animate: false });
230+
}
231+
}

0 commit comments

Comments
 (0)