Skip to content

Commit a63f34d

Browse files
committed
web: add self-contained static HTML timing report (web_save_report)
Add a `web_save_report` Tcl command that generates a standalone HTML timing report reusing the exact same JS/CSS as the live web viewer. Architecture: The static report substitutes the WebSocket server with a cache layer. WebSocketManager gains a `fromCache(cache)` factory that serves pre-computed responses from `window.__STATIC_CACHE__` embedded in the HTML. Cached request types include tech, bounds, timing_report, slack_histogram, chart_filters, and tile PNGs. Uncached requests (select, tcl_eval, etc.) reject gracefully. All 18 JS source files plus a vendored GoldenLayout bundle are concatenated by embed_report_assets.py into a single <script> block. Each file is wrapped in an IIFE to isolate const/let declarations; exported symbols are forwarded to outer-scope vars. Layout view: Tiles are pre-rendered at a fixed Leaflet zoom level using the existing generateTile() infrastructure. The tile layer returns data URIs from cache instead of creating blob URLs, ensuring compatibility with file:// origins. Leaflet zoom is locked to the cached level; pan is allowed. Timing path overlay: Per-path overlay PNGs are pre-rendered via renderOverlayPng() (colored rects + flight lines on a transparent background). When a timing path is selected, the cache drives a Leaflet ImageOverlay to display the highlight. Display controls, timing tables, slack histograms, and clock tree all work identically to the live viewer since they run the same widget code against cached data. Files: - websocket-manager.js: fromCache(), _cacheRequest(), isStaticMode - main.js: __STATIC_CACHE__ detection, zoom lock, overlay setup - embed_report_assets.py: IIFE wrapping, export-block handling - web.cpp: saveReport() with tile/overlay/JSON cache generation - web.tcl/web.i/web.h: web_save_report command plumbing - tile_generator.cpp/h: renderOverlayPng() for path highlights - websocket-tile-layer.js: data URI support alongside blob URLs - golden-layout.min.js: vendored ESM bundle from esm.sh - BUILD/CMakeLists.txt: embed pipeline with all JS files Signed-off-by: Matt Liberty <mliberty@precisioninno.com>
1 parent 2849539 commit a63f34d

24 files changed

Lines changed: 1622 additions & 132 deletions

src/web/BUILD

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# SPDX-License-Identifier: BSD-3-Clause
22
# Copyright (c) 2026, The OpenROAD Authors
33

4+
load("@npm//:defs.bzl", "npm_link_all_packages")
45
load("@rules_cc//cc:cc_library.bzl", "cc_library")
6+
load("@rules_python//python:defs.bzl", "py_binary")
57
load("//bazel:tcl_encode_or.bzl", "tcl_encode")
68
load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc")
79

@@ -10,6 +12,61 @@ package(
1012
features = ["layering_check"],
1113
)
1214

15+
npm_link_all_packages(name = "node_modules")
16+
17+
py_binary(
18+
name = "embed_report_assets",
19+
srcs = ["src/embed_report_assets.py"],
20+
main = "src/embed_report_assets.py",
21+
)
22+
23+
genrule(
24+
name = "report_assets",
25+
srcs = [
26+
"src/style.css",
27+
"src/theme.js",
28+
"src/coordinates.js",
29+
"src/ui-utils.js",
30+
"src/checkbox-tree-model.js",
31+
"src/vis-tree.js",
32+
"src/websocket-manager.js",
33+
"src/websocket-tile-layer.js",
34+
"src/display-controls.js",
35+
"src/inspector.js",
36+
"src/ruler.js",
37+
"src/tcl-completer.js",
38+
"src/hierarchy-browser.js",
39+
"src/menu-bar.js",
40+
"src/clock-tree-widget.js",
41+
"src/schematic-widget.js",
42+
"src/charts-widget.js",
43+
"src/timing-widget.js",
44+
"src/main.js",
45+
],
46+
outs = ["src/report_assets.cpp"],
47+
cmd = "$(execpath :embed_report_assets)" +
48+
" --output $@" +
49+
" --css $(location src/style.css)" +
50+
" --js $(location src/theme.js)" +
51+
" $(location src/coordinates.js)" +
52+
" $(location src/ui-utils.js)" +
53+
" $(location src/checkbox-tree-model.js)" +
54+
" $(location src/vis-tree.js)" +
55+
" $(location src/websocket-manager.js)" +
56+
" $(location src/websocket-tile-layer.js)" +
57+
" $(location src/display-controls.js)" +
58+
" $(location src/inspector.js)" +
59+
" $(location src/ruler.js)" +
60+
" $(location src/tcl-completer.js)" +
61+
" $(location src/hierarchy-browser.js)" +
62+
" $(location src/menu-bar.js)" +
63+
" $(location src/clock-tree-widget.js)" +
64+
" $(location src/schematic-widget.js)" +
65+
" $(location src/charts-widget.js)" +
66+
" $(location src/timing-widget.js)" +
67+
" $(location src/main.js)",
68+
tools = [":embed_report_assets"],
69+
)
1370
cc_library(
1471
name = "web",
1572
srcs = [
@@ -29,6 +86,7 @@ cc_library(
2986
"src/timing_report.cpp",
3087
"src/timing_report.h",
3188
"src/web.cpp",
89+
":report_assets",
3290
],
3391
hdrs = [
3492
"include/web/web.h",

src/web/CMakeLists.txt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,54 @@ swig_lib(NAME web
1212
${ODB_HOME}/include
1313
)
1414

15+
set(REPORT_ASSETS_CPP ${CMAKE_CURRENT_BINARY_DIR}/report_assets.cpp)
16+
add_custom_command(
17+
OUTPUT ${REPORT_ASSETS_CPP}
18+
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/src/embed_report_assets.py
19+
--output ${REPORT_ASSETS_CPP}
20+
--css ${CMAKE_CURRENT_SOURCE_DIR}/src/style.css
21+
--js ${CMAKE_CURRENT_SOURCE_DIR}/src/theme.js
22+
${CMAKE_CURRENT_SOURCE_DIR}/src/coordinates.js
23+
${CMAKE_CURRENT_SOURCE_DIR}/src/ui-utils.js
24+
${CMAKE_CURRENT_SOURCE_DIR}/src/checkbox-tree-model.js
25+
${CMAKE_CURRENT_SOURCE_DIR}/src/vis-tree.js
26+
${CMAKE_CURRENT_SOURCE_DIR}/src/websocket-manager.js
27+
${CMAKE_CURRENT_SOURCE_DIR}/src/websocket-tile-layer.js
28+
${CMAKE_CURRENT_SOURCE_DIR}/src/display-controls.js
29+
${CMAKE_CURRENT_SOURCE_DIR}/src/inspector.js
30+
${CMAKE_CURRENT_SOURCE_DIR}/src/ruler.js
31+
${CMAKE_CURRENT_SOURCE_DIR}/src/tcl-completer.js
32+
${CMAKE_CURRENT_SOURCE_DIR}/src/hierarchy-browser.js
33+
${CMAKE_CURRENT_SOURCE_DIR}/src/menu-bar.js
34+
${CMAKE_CURRENT_SOURCE_DIR}/src/clock-tree-widget.js
35+
${CMAKE_CURRENT_SOURCE_DIR}/src/schematic-widget.js
36+
${CMAKE_CURRENT_SOURCE_DIR}/src/charts-widget.js
37+
${CMAKE_CURRENT_SOURCE_DIR}/src/timing-widget.js
38+
${CMAKE_CURRENT_SOURCE_DIR}/src/main.js
39+
DEPENDS
40+
src/embed_report_assets.py
41+
src/style.css
42+
src/theme.js
43+
src/coordinates.js
44+
src/ui-utils.js
45+
src/checkbox-tree-model.js
46+
src/vis-tree.js
47+
src/websocket-manager.js
48+
src/websocket-tile-layer.js
49+
src/display-controls.js
50+
src/inspector.js
51+
src/ruler.js
52+
src/tcl-completer.js
53+
src/hierarchy-browser.js
54+
src/menu-bar.js
55+
src/clock-tree-widget.js
56+
src/schematic-widget.js
57+
src/charts-widget.js
58+
src/timing-widget.js
59+
src/main.js
60+
COMMENT "Generating report_assets.cpp"
61+
)
62+
1563
target_sources(web
1664
PRIVATE
1765
src/clock_tree_report.cpp
@@ -23,6 +71,7 @@ target_sources(web
2371
src/timing_report.cpp
2472
src/web.cpp
2573
src/MakeWeb.cpp
74+
${REPORT_ASSETS_CPP}
2675
)
2776

2877
target_link_libraries(web

src/web/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,51 @@ save_image -web -display_option {routing false} \
106106
layout.png
107107
```
108108

109+
### Save Report
110+
111+
Generate a self-contained HTML timing report. The report uses the same
112+
JavaScript frontend as the live web viewer but serves all data from a cache
113+
embedded in the HTML file. No running server is required to view the report.
114+
115+
```tcl
116+
web_save_report
117+
[-setup_paths count]
118+
[-hold_paths count]
119+
path
120+
```
121+
122+
#### Options
123+
124+
| Switch Name | Description |
125+
| ----- | ----- |
126+
| `-setup_paths` | Maximum number of setup timing paths to include. Default: `100`. |
127+
| `-hold_paths` | Maximum number of hold timing paths to include. Default: `100`. |
128+
| `path` | Output HTML file path. |
129+
130+
The report includes:
131+
132+
- **Layout view** with pre-rendered tiles at a fixed zoom level. Layer
133+
visibility can be toggled using the same display controls as the live viewer.
134+
Zoom is disabled; pan is allowed.
135+
- **Timing table** with setup and hold paths. Clicking a path highlights it
136+
on the layout via a pre-rendered overlay image.
137+
- **Slack histogram** with setup/hold tabs.
138+
- **Display controls**, hierarchy browser, clock tree, and other panels from
139+
the live viewer (features that require server interaction show empty states).
140+
141+
The report requires an internet connection to load Leaflet and GoldenLayout
142+
CSS/JS from CDN.
143+
144+
#### Examples
145+
146+
```tcl
147+
# Generate a report with default settings
148+
web_save_report timing.html
149+
150+
# Include more paths
151+
web_save_report -setup_paths 200 -hold_paths 200 timing.html
152+
```
153+
109154
## Features
110155

111156
- **Tile-based rendering** — The server renders 256x256 PNG tiles on demand,

src/web/include/web/web.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ class WebServer
3333

3434
void serve(int port, const std::string& doc_root);
3535

36+
void saveReport(const std::string& filename,
37+
int max_setup_paths,
38+
int max_hold_paths);
39+
3640
void saveImage(const std::string& filename,
3741
int x0,
3842
int y0,

src/web/src/charts-widget.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ function computeYAxis(maxCount) {
9696
}
9797

9898
export class ChartsWidget {
99-
constructor(container, app, redrawAllLayers) {
99+
constructor(app, redrawAllLayers) {
100100
this._app = app;
101101
this._redrawAllLayers = redrawAllLayers;
102102
this._currentTab = 'setup';
@@ -107,12 +107,12 @@ export class ChartsWidget {
107107
this._chartArea = null;
108108
this._hoveredBar = null;
109109

110-
this._build(container);
110+
this._build();
111111
}
112112

113113
// ---- DOM construction ----
114114

115-
_build(container) {
115+
_build() {
116116
const el = document.createElement('div');
117117
el.className = 'charts-widget';
118118

@@ -184,8 +184,7 @@ export class ChartsWidget {
184184
this._tooltip.style.display = 'none';
185185
el.appendChild(this._tooltip);
186186

187-
container.element.appendChild(el);
188-
this._el = el;
187+
this.element = el;
189188

190189
this._ctx = this._canvas.getContext('2d');
191190
this._bindEvents();
@@ -482,7 +481,7 @@ export class ChartsWidget {
482481
`Slack: [${bar.lower.toFixed(precision)}, ${bar.upper.toFixed(precision)}) ${unit}`;
483482
this._tooltip.style.display = 'block';
484483

485-
const rect = this._el.getBoundingClientRect();
484+
const rect = this.element.getBoundingClientRect();
486485
const tx = e.clientX - rect.left + 12;
487486
const ty = e.clientY - rect.top - 10;
488487
this._tooltip.style.left = tx + 'px';

src/web/src/embed_report_assets.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
# Copyright (c) 2026, The OpenROAD Authors
4+
#
5+
# Embed JS/CSS files as C++ raw string literals for the standalone
6+
# timing report HTML. Produces a .cpp file with const char* constants.
7+
#
8+
# Each JS file is wrapped in an IIFE to isolate file-private const/let
9+
# declarations. Exported symbols are forwarded to outer-scope vars.
10+
11+
import argparse
12+
import re
13+
14+
15+
def process_js_file(content):
16+
"""Process a single JS file for concatenation into a shared scope."""
17+
# Remove import lines.
18+
content = re.sub(r"^import\s+.*;\s*$", "", content, flags=re.MULTILINE)
19+
20+
# Find exported names and strip the export keyword.
21+
# Two patterns:
22+
# 1. export function/class/const Name ...
23+
# 2. export { InternalA as ExportedA, InternalB, ... }
24+
exported_names = []
25+
26+
# Pattern 1: export function/class/const Name
27+
def capture_export_decl(m):
28+
keyword = m.group(1) # function, class, or const
29+
name = m.group(2)
30+
exported_names.append(name)
31+
return keyword + " " + name
32+
33+
content = re.sub(
34+
r"^export\s+(function|class|const)\s+(\w+)",
35+
capture_export_decl,
36+
content,
37+
flags=re.MULTILINE,
38+
)
39+
40+
# Pattern 2: export { InternalA as ExportedA, InternalB, ... }
41+
# Collects (internal_name, exported_name) pairs; removes the line;
42+
# will add alias assignments after the IIFE.
43+
renamed_exports = [] # (internal, exported)
44+
45+
def capture_export_block(m):
46+
for item in m.group(1).split(","):
47+
item = item.strip()
48+
if not item:
49+
continue
50+
if " as " in item:
51+
internal, exported = item.split(" as ", 1)
52+
renamed_exports.append((internal.strip(), exported.strip()))
53+
else:
54+
renamed_exports.append((item, item))
55+
return "" # remove the export block
56+
57+
content = re.sub(r"export\s*\{([^}]+)\}", capture_export_block, content)
58+
59+
content = content.strip()
60+
if not content:
61+
return ""
62+
63+
# Merge both export lists into a unified set of outer-scope names.
64+
all_exported = list(exported_names)
65+
for _, exported in renamed_exports:
66+
if exported not in all_exported:
67+
all_exported.append(exported)
68+
69+
if not all_exported:
70+
# No exports — wrap in a bare block for const/let isolation.
71+
return "{\n" + content + "\n}"
72+
73+
# Wrap in an IIFE. Exported names get outer-scope `var` declarations.
74+
lines = []
75+
lines.append("var " + ", ".join(all_exported) + ";")
76+
# IIFE returns an object with the exported names.
77+
# For pattern-1 exports, the name is the same inside and out.
78+
# For pattern-2 exports, we map internal -> exported.
79+
return_pairs = []
80+
for name in exported_names:
81+
return_pairs.append(name + ": " + name)
82+
for internal, exported in renamed_exports:
83+
return_pairs.append(exported + ": " + internal)
84+
exports_obj = "{ " + ", ".join(return_pairs) + " }"
85+
lines.append("var __e = (function() {")
86+
lines.append(content)
87+
lines.append("return " + exports_obj + ";")
88+
lines.append("})();")
89+
# Assign from returned object to outer vars.
90+
for name in all_exported:
91+
lines.append(name + " = __e." + name + ";")
92+
return "\n".join(lines)
93+
94+
95+
def main():
96+
parser = argparse.ArgumentParser()
97+
parser.add_argument("--output", "-o", required=True)
98+
parser.add_argument("--css", required=True, help="style.css path")
99+
parser.add_argument(
100+
"--js", nargs="+", required=True, help="JS files in dependency order"
101+
)
102+
args = parser.parse_args()
103+
104+
# Read and process JS files.
105+
js_parts = []
106+
for path in args.js:
107+
with open(path) as f:
108+
content = f.read()
109+
js_parts.append(f'// ── {path.split("/")[-1]} ──')
110+
js_parts.append(process_js_file(content))
111+
combined_js = "\n".join(js_parts)
112+
113+
# Read CSS.
114+
with open(args.css) as f:
115+
css_content = f.read()
116+
117+
with open(args.output, "w") as out:
118+
out.write("// Auto-generated — do not edit.\n")
119+
out.write("#include <string_view>\n")
120+
out.write("namespace web {\n")
121+
out.write('extern const std::string_view kReportCSS = R"__CSS__(\n')
122+
out.write(css_content)
123+
out.write(')__CSS__";\n\n')
124+
out.write('extern const std::string_view kReportJS = R"__JS__(\n')
125+
out.write(combined_js)
126+
out.write(')__JS__";\n')
127+
out.write("} // namespace web\n")
128+
129+
130+
if __name__ == "__main__":
131+
main()

0 commit comments

Comments
 (0)