Skip to content

Commit 38fd259

Browse files
authored
Merge pull request #10087 from The-OpenROAD-Project-staging/web-static
web: add self-contained static HTML timing report (web_save_report)
2 parents 913ada2 + 4aa5e2f commit 38fd259

27 files changed

Lines changed: 1657 additions & 134 deletions

src/web/BUILD

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright (c) 2026, The OpenROAD Authors
33

44
load("@rules_cc//cc:cc_library.bzl", "cc_library")
5+
load("@rules_python//python:defs.bzl", "py_binary")
56
load("//bazel:tcl_encode_or.bzl", "tcl_encode")
67
load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc")
78

@@ -10,6 +11,60 @@ package(
1011
features = ["layering_check"],
1112
)
1213

14+
py_binary(
15+
name = "embed_report_assets",
16+
srcs = ["src/embed_report_assets.py"],
17+
main = "src/embed_report_assets.py",
18+
)
19+
20+
genrule(
21+
name = "report_assets",
22+
srcs = [
23+
"src/style.css",
24+
"src/theme.js",
25+
"src/coordinates.js",
26+
"src/ui-utils.js",
27+
"src/checkbox-tree-model.js",
28+
"src/vis-tree.js",
29+
"src/websocket-manager.js",
30+
"src/websocket-tile-layer.js",
31+
"src/display-controls.js",
32+
"src/inspector.js",
33+
"src/ruler.js",
34+
"src/tcl-completer.js",
35+
"src/hierarchy-browser.js",
36+
"src/menu-bar.js",
37+
"src/clock-tree-widget.js",
38+
"src/schematic-widget.js",
39+
"src/charts-widget.js",
40+
"src/timing-widget.js",
41+
"src/main.js",
42+
],
43+
outs = ["src/report_assets.cpp"],
44+
cmd = "$(execpath :embed_report_assets)" +
45+
" --output $@" +
46+
" --css $(location src/style.css)" +
47+
" --js $(location src/theme.js)" +
48+
" $(location src/coordinates.js)" +
49+
" $(location src/ui-utils.js)" +
50+
" $(location src/checkbox-tree-model.js)" +
51+
" $(location src/vis-tree.js)" +
52+
" $(location src/websocket-manager.js)" +
53+
" $(location src/websocket-tile-layer.js)" +
54+
" $(location src/display-controls.js)" +
55+
" $(location src/inspector.js)" +
56+
" $(location src/ruler.js)" +
57+
" $(location src/tcl-completer.js)" +
58+
" $(location src/hierarchy-browser.js)" +
59+
" $(location src/menu-bar.js)" +
60+
" $(location src/clock-tree-widget.js)" +
61+
" $(location src/schematic-widget.js)" +
62+
" $(location src/charts-widget.js)" +
63+
" $(location src/timing-widget.js)" +
64+
" $(location src/main.js)",
65+
tools = [":embed_report_assets"],
66+
)
67+
1368
cc_library(
1469
name = "web",
1570
srcs = [
@@ -29,6 +84,7 @@ cc_library(
2984
"src/timing_report.cpp",
3085
"src/timing_report.h",
3186
"src/web.cpp",
87+
":report_assets",
3288
],
3389
hdrs = [
3490
"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, encoding="utf-8") 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, encoding="utf-8") as f:
115+
css_content = f.read()
116+
117+
with open(args.output, "w", encoding="utf-8") 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()

src/web/src/json_builder.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ class JsonBuilder
155155
buf_ += val ? "true" : "false";
156156
}
157157

158+
void field(const std::string& key, const char* val)
159+
{
160+
field(key.c_str(), val);
161+
}
158162
void field(const std::string& key, int val) { field(key.c_str(), val); }
159163
void field(const std::string& key, float val) { field(key.c_str(), val); }
160164
void field(const std::string& key, double val) { field(key.c_str(), val); }

0 commit comments

Comments
 (0)