Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ cc_binary(
copts = OPENROAD_COPTS,
data = [
"@tclreadline//:tclreadline_scripts",
"//src/web:web_assets",
],
features = ["-use_header_modules"],
malloc = select({
Expand Down
34 changes: 31 additions & 3 deletions src/web/BUILD
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -23,19 +23,19 @@ 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",
"src/search.h",
"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",
Expand Down Expand Up @@ -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"],
)
40 changes: 40 additions & 0 deletions src/web/src/demo_html_open.sh
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of find ... | head -1 to locate the JSON file is fragile and can lead to unpredictable behavior if multiple JSON files are present. For a more robust solution, consider accepting the JSON file path as a command-line argument.

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"
23 changes: 23 additions & 0 deletions src/web/src/export_json.tcl
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions src/web/src/histogram-svg.js
Original file line number Diff line number Diff line change
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
`<text x="${width / 2}" y="${height / 2}" text-anchor="middle" ` +
`fill="${kSvgTextColor}" font-family="monospace" font-size="14">No data</text></svg>`;
}

const { bars, yMax, yTicks, chartArea } = layout;
const parts = [];

parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
`font-family="monospace" font-size="11">`);

// Background
parts.push(`<rect width="${width}" height="${height}" fill="#1e1e1e"/>`);

// Title
const unit = histogramData.time_unit || 'ns';
const total = histogramData.total_endpoints || 0;
parts.push(`<text x="${width / 2}" y="18" text-anchor="middle" ` +
`fill="${kSvgTextColor}" font-size="13">Endpoint Slack (${escapeXml(unit)}) — ` +
`${total} endpoints</text>`);

// Y-axis grid lines and labels
for (const tick of yTicks) {
const y = chartArea.bottom - (tick / yMax) * (chartArea.bottom - chartArea.top);
parts.push(`<line x1="${chartArea.left}" y1="${y}" ` +
`x2="${chartArea.right}" y2="${y}" stroke="${kSvgGridColor}" stroke-width="1"/>`);
parts.push(`<text x="${chartArea.left - 8}" y="${y + 4}" ` +
`text-anchor="end" fill="${kSvgTextColor}">${tick}</text>`);
}

// 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(`<rect x="${bar.x.toFixed(1)}" y="${bar.y.toFixed(1)}" ` +
`width="${bar.width.toFixed(1)}" height="${bar.height.toFixed(1)}" ` +
`fill="${fill}" stroke="${stroke}" stroke-width="1">`);
parts.push(`<title>${bar.count} endpoints [${bar.lower.toFixed(4)}, ${bar.upper.toFixed(4)}] ${unit}</title>`);
parts.push(`</rect>`);
}

// 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(`<text x="${x.toFixed(1)}" y="${chartArea.bottom + 15}" ` +
`text-anchor="middle" fill="${kSvgTextColor}" font-size="10">` +
`${bar.lower.toFixed(2)}</text>`);
}
// Always label the last bar's upper bound
if (bars.length > 0) {
const last = bars[bars.length - 1];
parts.push(`<text x="${(last.x + last.width).toFixed(1)}" ` +
`y="${chartArea.bottom + 15}" text-anchor="middle" fill="${kSvgTextColor}" ` +
`font-size="10">${last.upper.toFixed(2)}</text>`);
}

// X-axis label
parts.push(`<text x="${(chartArea.left + chartArea.right) / 2}" ` +
`y="${height - 5}" text-anchor="middle" fill="${kSvgTextColor}" ` +
`font-size="12">Slack (${escapeXml(unit)})</text>`);

// Axes
parts.push(`<line x1="${chartArea.left}" y1="${chartArea.top}" ` +
`x2="${chartArea.left}" y2="${chartArea.bottom}" ` +
`stroke="${kSvgAxisColor}" stroke-width="1"/>`);
parts.push(`<line x1="${chartArea.left}" y1="${chartArea.bottom}" ` +
`x2="${chartArea.right}" y2="${chartArea.bottom}" ` +
`stroke="${kSvgAxisColor}" stroke-width="1"/>`);

parts.push('</svg>');
return parts.join('\n');
}
49 changes: 49 additions & 0 deletions src/web/src/html_open.sh
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of find ... | head -1 to locate the JSON file is fragile and can lead to unpredictable behavior if multiple JSON files are present. For a more robust solution, consider accepting the JSON file path as a command-line argument.


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"
51 changes: 40 additions & 11 deletions src/web/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This setTimeout to update the widget in static mode appears to be redundant. The widgets are already updated within the app.websocketManager.readyPromise.then block (lines 673-674), which is a more appropriate place for this logic. This call can be removed to avoid a duplicate update and simplify the code.

}

function createDRCWidget(container) {
Expand All @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This setTimeout to update the widget in static mode appears to be redundant. The widgets are already updated within the app.websocketManager.readyPromise.then block (lines 673-674), which is a more appropriate place for this logic. This call can be removed to avoid a duplicate update and simplify the code.

}

function createHelpWidget(container) {
Expand Down Expand Up @@ -443,6 +446,11 @@ const defaultLayoutConfig = {
componentType: 'SchematicWidget',
title: 'Schematic',
},
{
type: 'component',
componentType: 'ChartsWidget',
title: 'Charts',
},
],
},
{
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading