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 ``; + } + + const { bars, yMax, yTicks, chartArea } = layout; + const parts = []; + + 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 + + + + + + +
+ + + + + + + + +