Skip to content

Commit 89d6f86

Browse files
committed
web: filter timing paths by slack range on histogram click in static mode
When a slack histogram column is clicked, the timing view should show only paths whose slack falls in that bin's range. In live WebSocket mode the server performs this filtering, but in static page mode the cache handler returned all paths regardless of slack_min/slack_max. Filter cached paths client-side in WebSocketManager._cacheRequest using the half-open [slack_min, slack_max) range matching the histogram's bin assignment. Tag each filtered path with _originalIndex so subsequent timing_highlight overlay lookups resolve to the correct pre-rendered image. Use _originalIndex in TimingWidget._selectPathRow when present. Fixes #10020 Signed-off-by: Matt Liberty <mliberty@precisioninno.com>
1 parent 45a537e commit 89d6f86

5 files changed

Lines changed: 241 additions & 1 deletion

File tree

src/web/src/timing-widget.js

Lines changed: 5 additions & 1 deletion
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));

src/web/src/websocket-manager.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,18 @@ 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 = json.paths
184+
.map((p, i) => ({ ...p, _originalIndex: i }))
185+
.filter(
186+
p => p.slack >= msg.slack_min && p.slack < msg.slack_max);
187+
return Promise.resolve({ ...json, paths: filtered });
188+
}
177189
return Promise.resolve(json);
178190
}
179191

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)