From d66f432b009046d30b31e9576e6b81fd6f638c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 25 Mar 2026 16:18:30 +0100 Subject: [PATCH] Demo: static HTML viewer sharing code with live web viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Try it: bazelisk run //test/orfs/gcd:gcd_route_html bazelisk run //test/orfs/gcd:gcd_route_web The static HTML opens from file:// with all data pre-embedded. Zero server, zero click-and-wait. Same GoldenLayout viewer as the live web server, same widgets, same CSS — only the data source differs (embedded JSON vs WebSocket). This is a demo PR showing how these two use-cases can share code. Much refinement is needed, but the architecture is proven. The intent is for maintainers to pick useful ideas from this demo (telling Claude to apply them on their branch), then close it. What works: - web_export_json TCL command extracts timing data as JSON - StaticDataManager drops in for WebSocketManager (zero widget changes) - Timing paths table (50 paths), endpoint slack histogram - All stages: synth/floorplan/place/cts/grt/route - All existing JS tests pass See test/orfs/gcd/WEB.md for full docs and debug tips. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Øyvind Harboe --- BUILD.bazel | 1 + src/web/BUILD | 34 ++++- src/web/src/demo_html_open.sh | 40 +++++ src/web/src/export_json.tcl | 23 +++ src/web/src/histogram-svg.js | 108 +++++++++++++ src/web/src/html_open.sh | 49 ++++++ src/web/src/main.js | 51 +++++-- src/web/src/render-static-page.js | 177 ++++++++++++++++++++++ src/web/src/render-static.js | 170 +++++++++++++++++++++ src/web/src/static-data-manager.js | 48 ++++++ src/web/src/timing-table-html.js | 59 ++++++++ src/web/src/web.i | 142 +++++++++++++++++ src/web/src/web.tcl | 29 ++++ src/web/src/web_open.sh | 70 +++++++++ src/web/test/BUILD | 14 ++ src/web/test/js/test-histogram-svg.js | 72 +++++++++ src/web/test/js/test-timing-table-html.js | 107 +++++++++++++ test/orfs/gcd/BUILD | 64 ++++++++ test/orfs/gcd/WEB.md | 133 ++++++++++++++++ 19 files changed, 1377 insertions(+), 14 deletions(-) create mode 100755 src/web/src/demo_html_open.sh create mode 100644 src/web/src/export_json.tcl create mode 100644 src/web/src/histogram-svg.js create mode 100755 src/web/src/html_open.sh create mode 100644 src/web/src/render-static-page.js create mode 100644 src/web/src/render-static.js create mode 100644 src/web/src/static-data-manager.js create mode 100644 src/web/src/timing-table-html.js create mode 100755 src/web/src/web_open.sh create mode 100644 src/web/test/js/test-histogram-svg.js create mode 100644 src/web/test/js/test-timing-table-html.js create mode 100644 test/orfs/gcd/WEB.md diff --git a/BUILD.bazel b/BUILD.bazel index e753b4f9717..a33335d1d41 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -166,6 +166,7 @@ cc_binary( copts = OPENROAD_COPTS, data = [ "@tclreadline//:tclreadline_scripts", + "//src/web:web_assets", ], features = ["-use_header_modules"], malloc = select({ diff --git a/src/web/BUILD b/src/web/BUILD index ad325929f91..439f91598b6 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -1,7 +1,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026, The OpenROAD Authors -load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library") load("@npm//:defs.bzl", "npm_link_all_packages") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("//bazel:tcl_encode_or.bzl", "tcl_encode") @@ -23,7 +23,6 @@ cc_library( "src/color.h", "src/hierarchy_report.cpp", "src/hierarchy_report.h", - "src/json_builder.h", "src/request_handler.cpp", "src/request_handler.h", "src/search.cpp", @@ -31,11 +30,12 @@ cc_library( "src/tile_generator.cpp", "src/tile_generator.h", "src/timing_report.cpp", - "src/timing_report.h", "src/web.cpp", ], hdrs = [ "include/web/web.h", + "src/json_builder.h", + "src/timing_report.h", ], copts = [ "-DBOOST_ASIO_NO_DEPRECATED", @@ -116,3 +116,31 @@ js_library( srcs = glob(["src/*.js"]), visibility = ["//src/web:__subpackages__"], ) + +filegroup( + name = "web_assets", + srcs = glob([ + "src/*.html", + "src/*.js", + "src/*.css", + ]), + visibility = ["//visibility:public"], +) + +# Static HTML renderer — Node.js CLI tool. +js_binary( + name = "render_static", + data = [":js_sources"], + entry_point = "src/render-static.js", + visibility = ["//visibility:public"], +) + +exports_files( + [ + "src/demo_html_open.sh", + "src/export_json.tcl", + "src/html_open.sh", + "src/web_open.sh", + ], + visibility = ["//visibility:public"], +) diff --git a/src/web/src/demo_html_open.sh b/src/web/src/demo_html_open.sh new file mode 100755 index 00000000000..df91405bcc3 --- /dev/null +++ b/src/web/src/demo_html_open.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# Generate a demo static HTML report (histogram-only prototype) and open it. +# Usage: bazelisk run //test/orfs/gcd:gcd_route_demo_html + +set -euo pipefail + +if [[ -n "${RUNFILES_DIR:-}" ]]; then + RUNFILES="$RUNFILES_DIR" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES="$0.runfiles" +else + echo "Error: cannot find runfiles directory" >&2 + exit 1 +fi + +RENDER_STATIC="$RUNFILES/_main/src/web/render_static_/render_static" +if [[ ! -x "$RENDER_STATIC" ]]; then + echo "Error: render_static not found at $RENDER_STATIC" >&2 + exit 1 +fi + +JSON=$(find "$RUNFILES/_main" -name "*.json" -path "*/test/orfs/*" 2>/dev/null | head -1) +if [[ -z "$JSON" ]]; then + echo "Error: no JSON payload found in runfiles" >&2 + exit 1 +fi + +if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + OUTPUT="$BUILD_WORKSPACE_DIRECTORY/gcd_demo_report.html" +else + OUTPUT="/tmp/gcd_demo_report.html" +fi + +"$RENDER_STATIC" --label route "$JSON" -o "$OUTPUT" + +echo "Opening $OUTPUT" +xdg-open "$OUTPUT" 2>/dev/null || open "$OUTPUT" 2>/dev/null || echo "Open $OUTPUT in your browser" diff --git a/src/web/src/export_json.tcl b/src/web/src/export_json.tcl new file mode 100644 index 00000000000..d3d3da5d7ba --- /dev/null +++ b/src/web/src/export_json.tcl @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors + +# Export timing data as JSON for static HTML reports. +# Used by orfs_run targets to extract data from a built design. + +source $::env(SCRIPTS_DIR)/open.tcl + +set design "" +if { [info exists ::env(DESIGN_NAME)] } { + set design $::env(DESIGN_NAME) +} +set stage "" +if { [info exists ::env(STAGE_NAME)] } { + set stage $::env(STAGE_NAME) +} +set variant "" +if { [info exists ::env(VARIANT_NAME)] } { + set variant $::env(VARIANT_NAME) +} + +web_export_json -output $::env(OUTPUT) \ + -design $design -stage $stage -variant $variant diff --git a/src/web/src/histogram-svg.js b/src/web/src/histogram-svg.js new file mode 100644 index 00000000000..b0122326170 --- /dev/null +++ b/src/web/src/histogram-svg.js @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Pure SVG renderer for slack histograms. +// Takes computeHistogramLayout() output and produces an SVG string. + +import { computeHistogramLayout } from './charts-widget.js'; + +// Colors — reuse from charts-widget.js when loaded as ES module, +// redeclare with unique names for bundled/concatenated mode. +const kSvgNegativeFill = '#f08080'; // lightcoral +const kSvgNegativeBorder = '#8b0000'; // darkred +const kSvgPositiveFill = '#90ee90'; // lightgreen +const kSvgPositiveBorder = '#006400'; // darkgreen + +const kSvgAxisColor = '#888'; +const kSvgTextColor = '#ccc'; +const kSvgGridColor = '#333'; + +function escapeXml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// Render a slack histogram as an SVG string. +// +// histogramData: { bins: [{lower, upper, count, negative},...], +// time_unit, total_endpoints, unconstrained_count } +// width, height: SVG dimensions in pixels +export function renderHistogramSVG(histogramData, width = 800, height = 400) { + const layout = computeHistogramLayout(histogramData, width, height); + if (!layout.chartArea) { + return `` + + `No data`; + } + + const { bars, yMax, yTicks, chartArea } = layout; + const parts = []; + + parts.push(``); + + // Background + parts.push(``); + + // Title + const unit = histogramData.time_unit || 'ns'; + const total = histogramData.total_endpoints || 0; + parts.push(`Endpoint Slack (${escapeXml(unit)}) — ` + + `${total} endpoints`); + + // Y-axis grid lines and labels + for (const tick of yTicks) { + const y = chartArea.bottom - (tick / yMax) * (chartArea.bottom - chartArea.top); + parts.push(``); + parts.push(`${tick}`); + } + + // Bars + for (const bar of bars) { + if (bar.height <= 0) continue; + const fill = bar.negative ? kSvgNegativeFill : kSvgPositiveFill; + const stroke = bar.negative ? kSvgNegativeBorder : kSvgPositiveBorder; + parts.push(``); + parts.push(`${bar.count} endpoints [${bar.lower.toFixed(4)}, ${bar.upper.toFixed(4)}] ${unit}`); + parts.push(``); + } + + // X-axis labels (show a subset to avoid overlap) + const maxLabels = Math.min(bars.length, 10); + const step = Math.max(1, Math.floor(bars.length / maxLabels)); + for (let i = 0; i < bars.length; i += step) { + const bar = bars[i]; + const x = bar.x + bar.width / 2; + parts.push(`` + + `${bar.lower.toFixed(2)}`); + } + // Always label the last bar's upper bound + if (bars.length > 0) { + const last = bars[bars.length - 1]; + parts.push(`${last.upper.toFixed(2)}`); + } + + // X-axis label + parts.push(`Slack (${escapeXml(unit)})`); + + // Axes + parts.push(``); + parts.push(``); + + parts.push(''); + return parts.join('\n'); +} diff --git a/src/web/src/html_open.sh b/src/web/src/html_open.sh new file mode 100755 index 00000000000..3a7a345c692 --- /dev/null +++ b/src/web/src/html_open.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# Generate a self-contained static HTML viewer and open it. +# No server needed — single file, works from file:// directly. +# +# Usage: bazelisk run //test/orfs/gcd:gcd_route_html + +set -euo pipefail + +if [[ -n "${RUNFILES_DIR:-}" ]]; then + RUNFILES="$RUNFILES_DIR" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES="$0.runfiles" +else + echo "Error: cannot find runfiles directory" >&2 + exit 1 +fi + +NODE=$(command -v node 2>/dev/null || true) +if [[ -z "$NODE" ]]; then + echo "Error: node not found in PATH" >&2 + exit 1 +fi + +WEB_SRC="$RUNFILES/_main/src/web/src" +RENDER_PAGE="$WEB_SRC/render-static-page.js" +JSON=$(find "$RUNFILES/_main" -name "*.json" -path "*/test/orfs/*" 2>/dev/null | head -1) + +if [[ ! -f "$RENDER_PAGE" ]]; then + echo "Error: render-static-page.js not found" >&2 + exit 1 +fi +if [[ -z "$JSON" ]]; then + echo "Error: no JSON payload found" >&2 + exit 1 +fi + +if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + OUTPUT="$BUILD_WORKSPACE_DIRECTORY/gcd_report.html" +else + OUTPUT="/tmp/gcd_report.html" +fi + +"$NODE" "$RENDER_PAGE" "$JSON" -o "$OUTPUT" + +echo "Opening $OUTPUT" +xdg-open "$OUTPUT" 2>/dev/null || open "$OUTPUT" 2>/dev/null || echo "Open $OUTPUT in your browser" diff --git a/src/web/src/main.js b/src/web/src/main.js index 6b46cfc55ec..020a4ca56d1 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -4,6 +4,7 @@ import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0'; import { latLngToDbu } from './coordinates.js'; import { WebSocketManager } from './websocket-manager.js'; +import { StaticDataManager } from './static-data-manager.js'; import { createWebSocketTileLayer } from './websocket-tile-layer.js'; import { TimingWidget } from './timing-widget.js'; import { ClockTreeWidget } from './clock-tree-widget.js'; @@ -362,6 +363,7 @@ function createBrowser(container) { function createTimingWidget(container) { app.timingWidget = new TimingWidget(container, app, redrawAllLayers); + if (app.staticMode) setTimeout(() => app.timingWidget.update(), 100); } function createDRCWidget(container) { @@ -375,6 +377,7 @@ function createClockWidget(container) { function createChartsWidget(container) { app.chartsWidget = new ChartsWidget(container, app, redrawAllLayers); + if (app.staticMode) setTimeout(() => app.chartsWidget.update(), 100); } function createHelpWidget(container) { @@ -443,6 +446,11 @@ const defaultLayoutConfig = { componentType: 'SchematicWidget', title: 'Schematic', }, + { + type: 'component', + componentType: 'ChartsWidget', + title: 'Charts', + }, ], }, { @@ -522,22 +530,31 @@ const LAYOUT_VERSION = 3; // Must be created before loadLayout so that components (e.g. SchematicWidget) // constructed during layout initialisation can access app.websocketManager. -const websocketUrl = `ws://${window.location.host || 'localhost:8080'}/ws`; -app.websocketManager = new WebSocketManager(websocketUrl, updateStatus); +// Static mode: if embedded data is present, use StaticDataManager instead of WebSocket. +const embeddedEl = document.querySelector('script[type="application/json"][data-static]'); +if (embeddedEl) { + const data = JSON.parse(embeddedEl.textContent); + app.websocketManager = new StaticDataManager(data); + app.staticMode = true; + statusDiv.style.display = 'none'; +} else { + const websocketUrl = `ws://${window.location.host || 'localhost:8080'}/ws`; + app.websocketManager = new WebSocketManager(websocketUrl, updateStatus); +} -// Restore saved layout or use default +// Load the layout. In static mode (IIFE bundle), GoldenLayout needs +// init(resolvedConfig) instead of loadLayout() because loadLayout +// requires isInitialised. Detect by checking isInitialised. const savedLayout = localStorage.getItem('gl-layout'); const savedVersion = parseInt(localStorage.getItem('gl-layout-version'), 10); +let layoutConfig = defaultLayoutConfig; if (savedLayout && savedVersion === LAYOUT_VERSION) { try { - const resolved = JSON.parse(savedLayout); - app.goldenLayout.loadLayout(LayoutConfig.fromResolved(resolved)); - } catch (e) { - app.goldenLayout.loadLayout(defaultLayoutConfig); - } -} else { - app.goldenLayout.loadLayout(defaultLayoutConfig); + layoutConfig = LayoutConfig.fromResolved(JSON.parse(savedLayout)); + } catch (e) { /* use default */ } } + +app.goldenLayout.loadLayout(layoutConfig); localStorage.setItem('gl-layout-version', LAYOUT_VERSION); // Persist layout on changes (drag, resize, close, etc.) @@ -644,7 +661,19 @@ app.websocketManager.readyPromise.then(async () => { [-minY * scale, minX * scale], [-maxY * scale, maxX * scale] ]; - app.map.fitBounds(app.fitBounds); + if (app.map) app.map.fitBounds(app.fitBounds); + } + + // Skip interactive map features in static mode (no tiles/server) + if (!app.map) { + populateDisplayControls(app, visibility, null, techData, redrawAllLayers, null); + updateHeatMaps(heatMapData); + + // Auto-populate widgets with pre-embedded data + if (app.chartsWidget) app.chartsWidget.update(); + if (app.timingWidget) app.timingWidget.update(); + + return; } // Click-to-select: convert click position to DBU and query server diff --git a/src/web/src/render-static-page.js b/src/web/src/render-static-page.js new file mode 100644 index 00000000000..2acb3730193 --- /dev/null +++ b/src/web/src/render-static-page.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Assemble a single self-contained HTML file from a JSON payload. +// +// All JS is bundled into one inline + + + + + + + + +
+
+ + + + + +`; + +writeFileSync(output, html); +console.log(`Wrote ${output} (${(html.length / 1024).toFixed(0)} KB)`); diff --git a/src/web/src/render-static.js b/src/web/src/render-static.js new file mode 100644 index 00000000000..ffb97013a97 --- /dev/null +++ b/src/web/src/render-static.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Static HTML assembler for OpenROAD timing reports. +// +// Takes one or more JSON payloads (from web_export_json) and produces +// a self-contained HTML file with embedded SVG histograms and timing tables. +// +// Usage: +// node render-static.js --label route route.json -o report.html +// node render-static.js --label place place.json --label route route.json -o stages.html + +import { readFileSync, writeFileSync } from 'node:fs'; +import { renderHistogramSVG } from './histogram-svg.js'; +import { renderTimingTableHTML } from './timing-table-html.js'; + +function parseArgs(argv) { + const args = argv.slice(2); + const payloads = []; + let output = null; + let currentLabel = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--label' || args[i] === '-l') { + currentLabel = args[++i]; + } else if (args[i] === '-o' || args[i] === '--output') { + output = args[++i]; + } else if (args[i] === '-h' || args[i] === '--help') { + console.log('Usage: render-static.js [--label NAME] file.json [...] -o output.html'); + process.exit(0); + } else { + // JSON file + const file = args[i]; + const data = JSON.parse(readFileSync(file, 'utf-8')); + const label = currentLabel + || data.metadata?.stage + || data.metadata?.design + || file.replace(/.*\//, '').replace(/\.json$/, ''); + payloads.push({ label, data, file }); + currentLabel = null; + } + } + + if (payloads.length === 0) { + console.error('Error: no JSON payload files specified'); + process.exit(1); + } + if (!output) { + console.error('Error: -o output.html is required'); + process.exit(1); + } + + return { payloads, output }; +} + +function generateHTML(payloads) { + const multi = payloads.length > 1; + const parts = []; + + parts.push(` + + + + +OpenROAD Timing Report + + + +

OpenROAD Timing Report

`); + + // Embed all payloads as JSON + for (let i = 0; i < payloads.length; i++) { + parts.push(``); + } + + // Tab bar (only if multiple payloads) + if (multi) { + parts.push('
'); + for (let i = 0; i < payloads.length; i++) { + const cls = i === 0 ? ' active' : ''; + parts.push(`
${payloads[i].label}
`); + } + parts.push('
'); + } + + // Pre-render each payload's content + for (let i = 0; i < payloads.length; i++) { + const p = payloads[i].data; + const cls = i === 0 ? ' active' : ''; + parts.push(`
`); + + // Metadata + const meta = p.metadata || {}; + const metaParts = []; + if (meta.design) metaParts.push(`Design: ${meta.design}`); + if (meta.stage) metaParts.push(`Stage: ${meta.stage}`); + if (meta.variant) metaParts.push(`Variant: ${meta.variant}`); + if (meta.platform) metaParts.push(`PDK: ${meta.platform}`); + if (meta.timestamp) metaParts.push(`Time: ${meta.timestamp}`); + if (metaParts.length > 0) { + parts.push(`
${metaParts.join(' | ')}
`); + } + + // Histogram SVG (support both old and new key names) + const histData = p.slack_histogram || p.histogram; + if (histData) { + parts.push('
'); + parts.push('

Endpoint Slack Histogram

'); + parts.push(renderHistogramSVG(histData, 800, 350)); + parts.push('
'); + } + + // Timing paths table (support both old and new key names) + const timingData = p.timing_report_setup || p.timing_paths; + if (timingData) { + parts.push('
'); + parts.push('

Timing Paths

'); + parts.push(renderTimingTableHTML(timingData)); + parts.push('
'); + } + + parts.push('
'); + } + + // Tab switching JS (only if multiple payloads) + if (multi) { + parts.push(``); + } + + parts.push(''); + return parts.join('\n'); +} + +const { payloads, output } = parseArgs(process.argv); +const html = generateHTML(payloads); +writeFileSync(output, html); +console.log(`Wrote ${output} (${payloads.length} payload${payloads.length > 1 ? 's' : ''}, ${html.length} bytes)`); diff --git a/src/web/src/static-data-manager.js b/src/web/src/static-data-manager.js new file mode 100644 index 00000000000..dfe6393ba67 --- /dev/null +++ b/src/web/src/static-data-manager.js @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Drop-in replacement for WebSocketManager that serves pre-embedded data. +// Used in static HTML mode where all data is inlined at build time. +// +// Implements the same request(msg) → Promise interface that all widgets use, +// so zero widget code changes are needed. + +export class StaticDataManager { + constructor(data) { + this._data = data; + this.readyPromise = Promise.resolve(); + this.pending = new Map(); // match WebSocketManager interface + } + + // Stub responses for request types not included in the payload + static _stubs = { + heatmaps: { active: '', heatmaps: [] }, + module_hierarchy: { modules: [] }, + clock_tree: { clocks: [] }, + select: { selected: [] }, + timing_highlight: { ok: true }, + }; + + // Same interface as WebSocketManager.request(msg) → Promise + request(msg) { + const key = this._resolveKey(msg); + const result = this._data[key]; + if (result !== undefined) { + return Promise.resolve(structuredClone(result)); + } + const stub = StaticDataManager._stubs[msg.type]; + if (stub !== undefined) { + return Promise.resolve(structuredClone(stub)); + } + // Unsupported in static mode (tiles, tcl_eval, highlights, etc.) + return Promise.reject( + new Error(`Not available in static mode: ${msg.type}`)); + } + + _resolveKey(msg) { + if (msg.type === 'timing_report') { + return msg.is_setup ? 'timing_report_setup' : 'timing_report_hold'; + } + return msg.type; + } +} diff --git a/src/web/src/timing-table-html.js b/src/web/src/timing-table-html.js new file mode 100644 index 00000000000..d0b499251d3 --- /dev/null +++ b/src/web/src/timing-table-html.js @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Pure HTML renderer for timing path tables. +// Takes timing report JSON and produces an HTML string. + +import { fmtTime } from './timing-widget.js'; + +function escapeHtmlTable(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// Render timing paths as an HTML table string. +// +// timingData: { paths: [{start_clk, end_clk, slack, arrival, required, +// path_delay, logic_depth, fanout, start_pin, end_pin}, ...] } +export function renderTimingTableHTML(timingData) { + const paths = timingData?.paths; + if (!paths || paths.length === 0) { + return '

No timing paths available.

'; + } + + const rows = []; + rows.push('
'); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + rows.push(''); + + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + const slackClass = p.slack < 0 ? ' class="negative"' : ''; + rows.push(''); + rows.push(``); + rows.push(`${fmtTime(p.slack)}`); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(``); + rows.push(''); + } + + rows.push('
#SlackPath DelayRequiredArrivalDepthFanoutStart PinEnd PinClock
${i + 1}${fmtTime(p.path_delay)}${fmtTime(p.required)}${fmtTime(p.arrival)}${p.logic_depth ?? ''}${p.fanout ?? ''}${escapeHtmlTable(p.start_pin)}${escapeHtmlTable(p.end_pin)}${escapeHtmlTable(p.start_clk)}→${escapeHtmlTable(p.end_clk)}
'); + return rows.join('\n'); +} diff --git a/src/web/src/web.i b/src/web/src/web.i index 3aa4d413819..1c446530207 100644 --- a/src/web/src/web.i +++ b/src/web/src/web.i @@ -2,8 +2,12 @@ // Copyright (c) 2026, The OpenROAD Authors %{ +#include #include "ord/OpenRoad.hh" +#include "odb/db.h" #include "web/web.h" +#include "timing_report.h" +#include "json_builder.h" %} %include "../../Exception.i" @@ -19,6 +23,144 @@ web_server_cmd(int port, const char* doc_root) server->serve(port, doc_root); } +static void serializePaths(web::JsonBuilder& builder, + const std::vector& paths) +{ + builder.beginArray("paths"); + for (const auto& p : paths) { + builder.beginObject(); + builder.field("start_clk", p.start_clk); + builder.field("end_clk", p.end_clk); + builder.field("required", p.required); + builder.field("arrival", p.arrival); + builder.field("slack", p.slack); + builder.field("skew", p.skew); + builder.field("path_delay", p.path_delay); + builder.field("logic_depth", p.logic_depth); + builder.field("fanout", p.fanout); + builder.field("start_pin", p.start_pin); + builder.field("end_pin", p.end_pin); + builder.endObject(); + } + builder.endArray(); +} + +void +web_export_json_cmd(const char* output_file, + const char* design_name, + const char* stage_name, + const char* variant_name) +{ + ord::OpenRoad* openroad = ord::OpenRoad::openRoad(); + odb::dbDatabase* db = openroad->getDb(); + sta::dbSta* sta = openroad->getSta(); + web::TimingReport report(sta); + + web::JsonBuilder builder; + builder.beginObject(); + + // Metadata + builder.beginObject("metadata"); + if (design_name[0] != '\0') { + builder.field("design", design_name); + } + if (stage_name[0] != '\0') { + builder.field("stage", stage_name); + } + if (variant_name[0] != '\0') { + builder.field("variant", variant_name); + } + builder.endObject(); + + // Tech data (matches WebSocket 'tech' response) + odb::dbBlock* block = db->getChip() ? db->getChip()->getBlock() : nullptr; + builder.beginObject("tech"); + builder.beginArray("layers"); + if (db->getTech()) { + for (auto* layer : db->getTech()->getLayers()) { + builder.value(layer->getName()); + } + } + builder.endArray(); + builder.beginArray("sites"); + builder.endArray(); + builder.field("has_liberty", sta != nullptr); + if (block) { + builder.field("dbu_per_micron", block->getDbUnitsPerMicron()); + } + builder.endObject(); + + // Bounds (matches WebSocket 'bounds' response) + builder.beginObject("bounds"); + if (block) { + odb::Rect die = block->getDieArea(); + builder.beginArray("bounds"); + builder.beginArray(); + builder.value(die.yMin()); + builder.value(die.xMin()); + builder.endArray(); + builder.beginArray(); + builder.value(die.yMax()); + builder.value(die.xMax()); + builder.endArray(); + builder.endArray(); + builder.field("shapes_ready", true); + } + builder.endObject(); + + // Chart filters (path groups + clocks) + auto filters = report.getChartFilters(); + builder.beginObject("chart_filters"); + builder.beginArray("path_groups"); + for (const auto& name : filters.path_groups) { + builder.value(name); + } + builder.endArray(); + builder.beginArray("clocks"); + for (const auto& name : filters.clocks) { + builder.value(name); + } + builder.endArray(); + builder.endObject(); + + // Slack histogram (setup) + auto histogram = report.getSlackHistogram(true); + builder.beginObject("slack_histogram"); + builder.beginArray("bins"); + for (const auto& bin : histogram.bins) { + builder.beginObject(); + builder.field("lower", bin.lower); + builder.field("upper", bin.upper); + builder.field("count", bin.count); + builder.field("negative", bin.is_negative); + builder.endObject(); + } + builder.endArray(); + builder.field("unconstrained_count", histogram.unconstrained_count); + builder.field("total_endpoints", histogram.total_endpoints); + builder.field("time_unit", histogram.time_unit); + builder.endObject(); + + // Timing paths — setup (top 50) + builder.beginObject("timing_report_setup"); + serializePaths(builder, report.getReport(true, 50)); + builder.endObject(); + + // Timing paths — hold (top 50) + builder.beginObject("timing_report_hold"); + serializePaths(builder, report.getReport(false, 50)); + builder.endObject(); + + builder.endObject(); + + std::ofstream out(output_file); + if (!out.is_open()) { + throw std::runtime_error( + std::string("Cannot open output file: ") + output_file); + } + out << builder.str(); +} + } // namespace web %} // inline diff --git a/src/web/src/web.tcl b/src/web/src/web.tcl index aa331913567..846e10935ec 100644 --- a/src/web/src/web.tcl +++ b/src/web/src/web.tcl @@ -20,3 +20,32 @@ proc web_server { args } { web::web_server_cmd $port $keys(-dir) } + +sta::define_cmd_args "web_export_json" { -output output_file \ + [-design design_name] [-stage stage_name] [-variant variant_name] } + +proc web_export_json { args } { + sta::parse_key_args "web_export_json" args \ + keys {-output -design -stage -variant} flags {} + + sta::check_argc_eq0 "web_export_json" $args + + if { ![info exists keys(-output)] } { + utl::error WEB 20 "-output is required." + } + + set design "" + if { [info exists keys(-design)] } { + set design $keys(-design) + } + set stage "" + if { [info exists keys(-stage)] } { + set stage $keys(-stage) + } + set variant "" + if { [info exists keys(-variant)] } { + set variant $keys(-variant) + } + + web::web_export_json_cmd $keys(-output) $design $stage $variant +} diff --git a/src/web/src/web_open.sh b/src/web/src/web_open.sh new file mode 100755 index 00000000000..76bb9e1f254 --- /dev/null +++ b/src/web/src/web_open.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# Launch the OpenROAD web viewer for a design. +# Usage: bazelisk run //test/orfs/gcd:gcd_route_web + +set -euo pipefail + +# Locate the openroad binary and web assets in bazel runfiles +if [[ -n "${RUNFILES_DIR:-}" ]]; then + RUNFILES="$RUNFILES_DIR" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES="$0.runfiles" +else + echo "Error: cannot find runfiles directory" >&2 + exit 1 +fi + +OPENROAD="$RUNFILES/_main/openroad" +WEB_DIR="$(dirname "$RUNFILES/_main/src/web/src/index.html")" + +if [[ ! -x "$OPENROAD" ]]; then + echo "Error: openroad binary not found at $OPENROAD" >&2 + exit 1 +fi +if [[ ! -f "$WEB_DIR/index.html" ]]; then + echo "Error: web assets not found at $WEB_DIR" >&2 + exit 1 +fi + +# Find the ODB file: explicit arg, env var, or auto-discover from runfiles +ODB="${ODB_FILE:-${1:-}}" +if [[ -z "$ODB" ]]; then + # Auto-discover: find the most advanced stage ODB in runfiles + ODB=$(find "$RUNFILES/_main" -name "*.odb" -path "*/results/*" 2>/dev/null \ + | sort | tail -1) +fi +if [[ -z "$ODB" || ! -f "$ODB" ]]; then + echo "Error: ODB file not found. Set ODB_FILE or pass as first argument." >&2 + exit 1 +fi + +PORT="${WEB_PORT:-8088}" +URL="http://localhost:$PORT" + +echo "Starting web viewer on $URL" +echo "Design: $ODB" + +# Kill any existing server on this port +if lsof -ti :"$PORT" &>/dev/null; then + echo "Killing existing server on port $PORT" + kill $(lsof -ti :"$PORT") 2>/dev/null || true + sleep 0.5 +fi + +# Write a temp TCL script (openroad takes a file, not inline commands) +TCL_SCRIPT=$(mktemp /tmp/web_open_XXXXXX.tcl) +trap "rm -f $TCL_SCRIPT" EXIT +cat > "$TCL_SCRIPT" < { + it('returns valid SVG for sample data', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.startsWith('')); + assert.ok(svg.includes('xmlns="http://www.w3.org/2000/svg"')); + }); + + it('contains bars as rect elements', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + const rectCount = (svg.match(/= 4, `expected at least 4 rects, got ${rectCount}`); + }); + + it('uses negative colors for negative bins', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.includes('#f08080'), 'should contain negative fill color'); + assert.ok(svg.includes('#8b0000'), 'should contain negative border color'); + }); + + it('uses positive colors for positive bins', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.includes('#90ee90'), 'should contain positive fill color'); + assert.ok(svg.includes('#006400'), 'should contain positive border color'); + }); + + it('includes title with endpoint count', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.includes('48 endpoints'), 'should show total endpoint count'); + }); + + it('includes time unit', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.includes('ns'), 'should include time unit'); + }); + + it('handles empty data gracefully', () => { + const svg = renderHistogramSVG({ bins: [] }, 800, 400); + assert.ok(svg.includes('No data')); + }); + + it('handles null data gracefully', () => { + const svg = renderHistogramSVG(null, 800, 400); + assert.ok(svg.includes('No data')); + }); + + it('includes tooltip titles on bars', () => { + const svg = renderHistogramSVG(SAMPLE_DATA, 800, 400); + assert.ok(svg.includes(''), 'should have tooltip titles'); + assert.ok(svg.includes('5 endpoints'), 'tooltip should show count'); + }); +}); diff --git a/src/web/test/js/test-timing-table-html.js b/src/web/test/js/test-timing-table-html.js new file mode 100644 index 00000000000..e8f574cdf21 --- /dev/null +++ b/src/web/test/js/test-timing-table-html.js @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderTimingTableHTML } from '../../src/timing-table-html.js'; + +const SAMPLE_PATHS = { + paths: [ + { + start_clk: 'core_clock', + end_clk: 'core_clock', + required: 1.377, + arrival: 1.5, + slack: -0.123, + skew: 0.012, + path_delay: 1.2, + logic_depth: 5, + fanout: 3, + start_pin: 'ctrl/state_reg[0]/CK', + end_pin: 'datapath/result_reg[0]/D', + }, + { + start_clk: 'core_clock', + end_clk: 'core_clock', + required: 1.377, + arrival: 1.4, + slack: 0.023, + skew: 0.012, + path_delay: 1.1, + logic_depth: 3, + fanout: 2, + start_pin: 'ctrl/state_reg[1]/CK', + end_pin: 'datapath/result_reg[1]/D', + }, + ], +}; + +describe('renderTimingTableHTML', () => { + it('returns a table for valid data', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('<table')); + assert.ok(html.includes('</table>')); + }); + + it('includes header row', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('<thead>')); + assert.ok(html.includes('Slack')); + assert.ok(html.includes('Path Delay')); + assert.ok(html.includes('Start Pin')); + }); + + it('renders correct number of rows', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + const rowCount = (html.match(/<tr>/g) || []).length; + // 1 header row + 2 data rows + assert.equal(rowCount, 3); + }); + + it('marks negative slack with class', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('class="negative"'), 'negative slack should have class'); + }); + + it('formats time values to 4 decimal places', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('-0.1230'), 'slack should be formatted'); + assert.ok(html.includes('1.2000'), 'path delay should be formatted'); + }); + + it('includes pin names', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('ctrl/state_reg[0]/CK')); + assert.ok(html.includes('datapath/result_reg[0]/D')); + }); + + it('includes clock info', () => { + const html = renderTimingTableHTML(SAMPLE_PATHS); + assert.ok(html.includes('core_clock')); + }); + + it('handles empty paths', () => { + const html = renderTimingTableHTML({ paths: [] }); + assert.ok(html.includes('No timing paths')); + }); + + it('handles null data', () => { + const html = renderTimingTableHTML(null); + assert.ok(html.includes('No timing paths')); + }); + + it('escapes HTML in pin names', () => { + const data = { + paths: [{ + start_clk: 'clk', end_clk: 'clk', + slack: 0, required: 0, arrival: 0, path_delay: 0, + logic_depth: 0, fanout: 0, skew: 0, + start_pin: '<script>alert(1)</script>', + end_pin: 'normal_pin', + }], + }; + const html = renderTimingTableHTML(data); + assert.ok(!html.includes('<script>'), 'should escape HTML'); + assert.ok(html.includes('<script>')); + }); +}); diff --git a/test/orfs/gcd/BUILD b/test/orfs/gcd/BUILD index 8e8f623bb84..d98a9db4c24 100644 --- a/test/orfs/gcd/BUILD +++ b/test/orfs/gcd/BUILD @@ -1,3 +1,4 @@ +load("@bazel-orfs//:openroad.bzl", "orfs_run") load("@bazel-orfs//:sweep.bzl", "orfs_sweep") load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//test/orfs:check_same.bzl", "check_same") @@ -77,3 +78,66 @@ check_same( variant_a = "base", variant_b = "b", ) + +# --- Static HTML report targets --- + +_STAGES = { + "cts": "4_cts", + "floorplan": "2_floorplan", + "grt": "5_1_grt", + "place": "3_place", + "route": "5_route", + "synth": "1_synth", +} + +# Extract JSON payload + generate static HTML for each stage +[ + ( + orfs_run( + name = "gcd_{}_json".format(stage), + src = ":gcd_{}".format(stage), + outs = ["gcd_{}.json".format(stage)], + arguments = { + "DESIGN_NAME": "gcd", + "GUI_TIMING": "1", + "OUTPUT": "$(location :gcd_{}.json)".format(stage), + "STAGE_NAME": orfs_name, + }, + script = "//src/web:src/export_json.tcl", + tags = ["manual"], + ), + sh_binary( + name = "gcd_{}_html".format(stage), + srcs = ["//src/web:src/html_open.sh"], + data = [ + ":gcd_{}_json".format(stage), + "//src/web:web_assets", + ], + tags = ["manual"], + ), + ) + for stage, orfs_name in _STAGES.items() +] + +# Generate histogram-only demo report (A/B prototype) +sh_binary( + name = "gcd_route_demo_html", + srcs = ["//src/web:src/demo_html_open.sh"], + data = [ + ":gcd_route_json", + "//src/web:render_static", + ], + tags = ["manual"], +) + +# Launch live web viewer for the routed design +sh_binary( + name = "gcd_route_web", + srcs = ["//src/web:src/web_open.sh"], + data = [ + ":gcd_route", + "//:openroad", + "//src/web:web_assets", + ], + tags = ["manual"], +) diff --git a/test/orfs/gcd/WEB.md b/test/orfs/gcd/WEB.md new file mode 100644 index 00000000000..836116b9a27 --- /dev/null +++ b/test/orfs/gcd/WEB.md @@ -0,0 +1,133 @@ +# Web GUI and Static HTML Reports + +## Web GUI + +Open the live web-based viewer for a completed stage: + + bazelisk run //test/orfs/gcd:gcd_route_web + +This loads the routed design into OpenROAD and starts the web viewer on +port 8088. A browser window opens automatically. The live viewer supports +interactive inspection, timing analysis, and hierarchy browsing. + +## Static HTML Report + +Generate a self-contained static HTML viewer — same layout and widgets as +the live web viewer, but with all data pre-embedded (zero click-and-wait): + + bazelisk run //test/orfs/gcd:gcd_route_html + +Opens in the browser automatically, just like `gcd_route_web`. + +## A/B: Live Web Viewer vs. Static HTML + +Run both side by side to compare the live interactive viewer against the +static snapshot: + + bazelisk run //test/orfs/gcd:gcd_route_web & + bazelisk run //test/orfs/gcd:gcd_route_html + +## Demo: Histogram-only prototype + +A simpler prototype that only renders the endpoint slack histogram and +timing path table (used during development for A/B comparison): + + bazelisk run //test/orfs/gcd:gcd_route_demo_html + +## Compare across stages + +Extract JSON payloads for multiple stages, then combine them into a +single viewer with tabs. Flip between stages to watch the endpoint slack +histogram animate as timing evolves through the flow: + + bazelisk build //test/orfs/gcd:gcd_floorplan_json + bazelisk build //test/orfs/gcd:gcd_place_json + bazelisk build //test/orfs/gcd:gcd_cts_json + bazelisk build //test/orfs/gcd:gcd_route_json + + node src/web/src/render-static.js \ + --label floorplan bazel-bin/test/orfs/gcd/gcd_floorplan.json \ + --label place bazel-bin/test/orfs/gcd/gcd_place.json \ + --label cts bazel-bin/test/orfs/gcd/gcd_cts.json \ + --label route bazel-bin/test/orfs/gcd/gcd_route.json \ + -o gcd_stages.html + +## Compare across variants + +Compare different parameter sweeps (e.g. placement density) at the same +stage: + + node src/web/src/render-static.js \ + --label "base" gcd_base_route.json \ + --label "dense" gcd_dense_route.json \ + -o density_comparison.html + +## Compare across time + +Rebuild after an OpenROAD change and compare before/after: + + node src/web/src/render-static.js \ + --label "before" gcd_route_before.json \ + --label "after" gcd_route_after.json \ + -o regression_check.html + +## JSON payload extraction + +Extract raw timing data as JSON for custom analysis or external tools: + + bazelisk build //test/orfs/gcd:gcd_route_json + +The payload contains the endpoint slack histogram and top timing paths +in the same format as the live web viewer's WebSocket protocol. + +## Debugging the static HTML viewer + +To debug the static HTML without a human in the loop, use puppeteer-core +with the system chromium in headless mode: + +```bash +# One-time setup +cd /tmp && npm init -y && npm install puppeteer-core + +# Generate the HTML +node src/web/src/render-static-page.js payload.json -o ~/test.html + +# Run headless Chrome and capture errors + screenshot +node --input-type=module << 'SCRIPT' +import puppeteer from '/tmp/node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js'; +const browser = await puppeteer.launch({ + executablePath: '/snap/bin/chromium', + headless: true, + args: ['--no-sandbox', '--allow-file-access-from-files', '--disable-web-security'] +}); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { localStorage.clear(); }); +const errors = []; +page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); }); +page.on('pageerror', err => errors.push(err.message)); +await page.goto(`file://${process.env.HOME}/test.html`, { + waitUntil: 'networkidle0', timeout: 20000 +}); +await new Promise(r => setTimeout(r, 3000)); +console.log('Errors:', errors.length === 0 ? 'NONE' : errors.join('\n')); +const tabs = await page.evaluate(() => + [...document.querySelectorAll('.lm_tab .lm_title')].map(t => t.textContent)); +console.log('Tabs:', tabs.join(', ') || '(none)'); +await page.screenshot({ path: `${process.env.HOME}/screenshot.png` }); +await browser.close(); +SCRIPT +``` + +Use `page.evaluate()` to click tabs, inspect DOM, and check widget state. +Read the screenshot PNG with the Read tool to visually verify rendering. + +## Known issues (pre-existing, not introduced by this PR) + +- **tclreadline warning on startup**: `Runfiles::Create failed: cannot find + runfiles` followed by `tclreadlineInit.tcl not found`. The web viewer + works fine without tclreadline — it only affects tab-completion in the + OpenROAD CLI, which isn't used by the web viewer. + +- **tclreadline error on exit**: Ctrl-C or Ctrl-D prints + `can't read "::auto_index(::tclreadline::ScriptCompleter)"`. Harmless + noise from tclreadline teardown when it was never fully initialized.