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.