Skip to content

Commit 2c25352

Browse files
authored
Merge pull request #10138 from The-OpenROAD-Project-staging/web-static-chart
web: filter timing paths by slack range on histogram click in static mode
2 parents 45a537e + d2fee8e commit 2c25352

5 files changed

Lines changed: 247 additions & 2 deletions

File tree

src/web/src/timing-widget.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,13 @@ export class TimingWidget {
212212
rows[idx].scrollIntoView({ block: 'nearest' });
213213
this._pathTableContainer.focus();
214214
this._renderDetailTable();
215+
// Use _originalIndex when paths were filtered (e.g. by histogram
216+
// column click in static mode) so the overlay lookup matches.
217+
const paths = this._currentTab === 'setup' ? this._setupPaths : this._holdPaths;
218+
const highlightIdx = paths[idx]?._originalIndex ?? idx;
215219
this._app.websocketManager.request({
216220
type: 'timing_highlight',
217-
path_index: idx,
221+
path_index: highlightIdx,
218222
is_setup: this._currentTab === 'setup' ? 1 : 0,
219223
}).then(() => this._redrawAllLayers())
220224
.catch(err => console.error('timing_highlight error:', err));
@@ -294,9 +298,12 @@ export class TimingWidget {
294298
const paths = this._currentTab === 'setup' ? this._setupPaths : this._holdPaths;
295299
const path = paths[this._selectedPathIndex];
296300
const nodes = this._detailTab === 'data' ? path.data_nodes : path.capture_nodes;
301+
// Use _originalIndex when paths were filtered (e.g. by histogram
302+
// column click in static mode) so the overlay lookup matches.
303+
const highlightIdx = path._originalIndex ?? this._selectedPathIndex;
297304
this._app.websocketManager.request({
298305
type: 'timing_highlight',
299-
path_index: this._selectedPathIndex,
306+
path_index: highlightIdx,
300307
is_setup: this._currentTab === 'setup' ? 1 : 0,
301308
pin_name: nodes[idx].pin,
302309
}).then(() => this._redrawAllLayers());

src/web/src/websocket-manager.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,20 @@ export class WebSocketManager {
174174
}
175175
const json = this._cache.json && this._cache.json[key];
176176
if (json !== undefined) {
177+
// Filter timing paths by slack range when histogram column clicked.
178+
// Tag each path with its original index so overlay lookup still
179+
// works after the array is narrowed down.
180+
if (type === 'timing_report'
181+
&& msg.slack_min != null && msg.slack_max != null
182+
&& json.paths) {
183+
const filtered = [];
184+
json.paths.forEach((p, i) => {
185+
if (p.slack >= msg.slack_min && p.slack < msg.slack_max) {
186+
filtered.push({ ...p, _originalIndex: i });
187+
}
188+
});
189+
return Promise.resolve({ ...json, paths: filtered });
190+
}
177191
return Promise.resolve(json);
178192
}
179193

src/web/test/BUILD

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ js_test(
9292
no_copy_to_bin = JS_FILES,
9393
)
9494

95+
js_test(
96+
name = "timing_widget_test",
97+
data = DOM_TEST_DATA,
98+
entry_point = "js/test-timing-widget.js",
99+
no_copy_to_bin = JS_FILES,
100+
)
101+
95102
js_test(
96103
name = "inspector_test",
97104
data = DOM_TEST_DATA,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: BSD-3-Clause
2+
// Copyright (c) 2026, The OpenROAD Authors
3+
4+
import './setup-dom.js';
5+
import { describe, it } from 'node:test';
6+
import assert from 'node:assert/strict';
7+
import { TimingWidget } from '../../src/timing-widget.js';
8+
9+
// jsdom does not implement scrollIntoView; stub it so _selectPathRow runs.
10+
if (!window.HTMLElement.prototype.scrollIntoView) {
11+
window.HTMLElement.prototype.scrollIntoView = function () {};
12+
}
13+
14+
// Build an app stub that records every websocketManager.request call and
15+
// lets each test control the response per message type.
16+
function createMockApp(responses = {}) {
17+
const requests = [];
18+
return {
19+
requests,
20+
websocketManager: {
21+
request(msg) {
22+
requests.push(msg);
23+
const handler = responses[msg.type];
24+
const value = handler ? handler(msg) : {};
25+
return Promise.resolve(value);
26+
},
27+
},
28+
};
29+
}
30+
31+
function makePath(slack, endPin, extra = {}) {
32+
return {
33+
start_clk: 'clk', end_clk: 'clk',
34+
slack, arrival: 0, required: 0, skew: 0,
35+
path_delay: 0, logic_depth: 0, fanout: 1,
36+
start_pin: 'start', end_pin: endPin,
37+
data_nodes: [], capture_nodes: [],
38+
...extra,
39+
};
40+
}
41+
42+
describe('TimingWidget._selectPathRow', () => {
43+
it('sends timing_highlight with the row index when paths are unfiltered', () => {
44+
const app = createMockApp();
45+
const widget = new TimingWidget(app, () => {});
46+
widget.showPaths('setup', [
47+
makePath(-0.1, 'a'),
48+
makePath(0.0, 'b'),
49+
makePath(0.1, 'c'),
50+
]);
51+
52+
// Clear requests from showPaths' _clearTimingHighlight.
53+
app.requests.length = 0;
54+
55+
widget._selectPathRow(1);
56+
57+
const highlight = app.requests.find(r => r.type === 'timing_highlight');
58+
assert.ok(highlight, 'timing_highlight was requested');
59+
assert.equal(highlight.path_index, 1);
60+
assert.equal(highlight.is_setup, 1);
61+
});
62+
63+
it('sends timing_highlight with _originalIndex when paths were filtered', () => {
64+
const app = createMockApp();
65+
const widget = new TimingWidget(app, () => {});
66+
// Filtered result — table row 0 maps to original overlay index 2.
67+
widget.showPaths('setup', [
68+
{ ...makePath(0.0, 'b'), _originalIndex: 2 },
69+
{ ...makePath(0.05, 'c'), _originalIndex: 4 },
70+
]);
71+
72+
app.requests.length = 0;
73+
74+
widget._selectPathRow(0);
75+
let highlight = app.requests.find(r => r.type === 'timing_highlight');
76+
assert.equal(highlight.path_index, 2);
77+
78+
app.requests.length = 0;
79+
widget._selectPathRow(1);
80+
highlight = app.requests.find(r => r.type === 'timing_highlight');
81+
assert.equal(highlight.path_index, 4);
82+
});
83+
84+
it('uses hold side flag when current tab is hold', () => {
85+
const app = createMockApp();
86+
const widget = new TimingWidget(app, () => {});
87+
widget.showPaths('hold', [
88+
{ ...makePath(0.1, 'h0'), _originalIndex: 3 },
89+
]);
90+
91+
app.requests.length = 0;
92+
widget._selectPathRow(0);
93+
94+
const highlight = app.requests.find(r => r.type === 'timing_highlight');
95+
assert.equal(highlight.path_index, 3);
96+
assert.equal(highlight.is_setup, 0);
97+
});
98+
99+
it('ignores out-of-range indices without sending a request', () => {
100+
const app = createMockApp();
101+
const widget = new TimingWidget(app, () => {});
102+
widget.showPaths('setup', [makePath(0.0, 'a')]);
103+
104+
app.requests.length = 0;
105+
widget._selectPathRow(-1);
106+
widget._selectPathRow(5);
107+
108+
assert.equal(
109+
app.requests.filter(r => r.type === 'timing_highlight').length, 0);
110+
});
111+
});

src/web/test/js/test-websocket-manager.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,110 @@ describe('WebSocketManager.fromCache', () => {
228228
await mgr.request({ type: 'timing_highlight', path_index: -1 });
229229
assert.equal(called, null);
230230
});
231+
232+
describe('timing_report slack filtering', () => {
233+
function makeCacheWithPaths() {
234+
return makeCache({
235+
json: {
236+
tech: { layers: [], sites: [], has_liberty: false, dbu_per_micron: 1000 },
237+
bounds: { bounds: [[0, 0], [100, 100]], shapes_ready: true },
238+
heatmaps: { active: '', heatmaps: [] },
239+
'timing_report:setup': {
240+
paths: [
241+
{ slack: -0.3, end_pin: 'a' },
242+
{ slack: -0.1, end_pin: 'b' },
243+
{ slack: 0.0, end_pin: 'c' },
244+
{ slack: 0.1, end_pin: 'd' },
245+
{ slack: 0.25, end_pin: 'e' },
246+
],
247+
},
248+
'timing_report:hold': {
249+
paths: [
250+
{ slack: 0.05, end_pin: 'h0' },
251+
{ slack: 0.15, end_pin: 'h1' },
252+
],
253+
},
254+
'slack_histogram:setup': { bins: [], total_endpoints: 0 },
255+
chart_filters: { path_groups: [], clocks: [] },
256+
},
257+
});
258+
}
259+
260+
it('returns all paths when slack range is not provided', async () => {
261+
const mgr = WebSocketManager.fromCache(makeCacheWithPaths());
262+
const result = await mgr.request({ type: 'timing_report', is_setup: 1 });
263+
assert.equal(result.paths.length, 5);
264+
// No _originalIndex annotation when not filtering.
265+
assert.equal(result.paths[0]._originalIndex, undefined);
266+
});
267+
268+
it('filters paths to the [slack_min, slack_max) range', async () => {
269+
const mgr = WebSocketManager.fromCache(makeCacheWithPaths());
270+
const result = await mgr.request({
271+
type: 'timing_report',
272+
is_setup: 1,
273+
slack_min: -0.1,
274+
slack_max: 0.1,
275+
});
276+
// -0.1 and 0.0 are in range; 0.1 is exclusive upper bound.
277+
assert.deepEqual(
278+
result.paths.map(p => p.end_pin),
279+
['b', 'c']);
280+
});
281+
282+
it('tags filtered paths with original index for overlay lookup', async () => {
283+
const mgr = WebSocketManager.fromCache(makeCacheWithPaths());
284+
const result = await mgr.request({
285+
type: 'timing_report',
286+
is_setup: 1,
287+
slack_min: -0.1,
288+
slack_max: 0.1,
289+
});
290+
assert.equal(result.paths[0].end_pin, 'b');
291+
assert.equal(result.paths[0]._originalIndex, 1);
292+
assert.equal(result.paths[1].end_pin, 'c');
293+
assert.equal(result.paths[1]._originalIndex, 2);
294+
});
295+
296+
it('returns empty paths when no slack falls in range', async () => {
297+
const mgr = WebSocketManager.fromCache(makeCacheWithPaths());
298+
const result = await mgr.request({
299+
type: 'timing_report',
300+
is_setup: 1,
301+
slack_min: 10.0,
302+
slack_max: 20.0,
303+
});
304+
assert.equal(result.paths.length, 0);
305+
});
306+
307+
it('filters the hold side independently of setup', async () => {
308+
const mgr = WebSocketManager.fromCache(makeCacheWithPaths());
309+
const result = await mgr.request({
310+
type: 'timing_report',
311+
is_setup: 0,
312+
slack_min: 0.1,
313+
slack_max: 0.2,
314+
});
315+
assert.equal(result.paths.length, 1);
316+
assert.equal(result.paths[0].end_pin, 'h1');
317+
assert.equal(result.paths[0]._originalIndex, 1);
318+
});
319+
320+
it('does not mutate the cached paths array', async () => {
321+
const cache = makeCacheWithPaths();
322+
const mgr = WebSocketManager.fromCache(cache);
323+
await mgr.request({
324+
type: 'timing_report',
325+
is_setup: 1,
326+
slack_min: -0.1,
327+
slack_max: 0.1,
328+
});
329+
const cached = cache.json['timing_report:setup'].paths;
330+
assert.equal(cached.length, 5);
331+
// Original cached objects must not be tagged.
332+
for (const p of cached) {
333+
assert.equal(p._originalIndex, undefined);
334+
}
335+
});
336+
});
231337
});

0 commit comments

Comments
 (0)