Skip to content

Commit a82193a

Browse files
authored
Improve UX reducing timeseries tooltip to max 5 items (#7046)
## Motivation for features / changes Limits tooltip display to 5 items and adds a legend indicating additional items when there are more than 5 series. This prevents tooltips from becoming too large and improves readability and usability when many series are present. ## Technical description of changes - Tooltip data is limited to a maximum of 5 items to avoid oversized tooltips. - When more than 5 items exist, a legend row is added showing the count of additional items. - Example: “3 additional items” when 8 total items exist. - Correctly handles singular and plural cases (“1 item” vs “2 items”). - Updated tooltip templates in the following components: - **Scalar Card Component**: Custom tooltip template updated. - **Line Chart Interactive View**: Default tooltip template updated. - Tooltip data is pre-computed during data updates instead of calling functions from templates. - This reduces change detection overhead and improves performance. - Handles edge cases such as empty data, exactly 5 items, and different numbers of columns in the tooltip table. - Removes the tooltip scroll, which was not providing useful interaction. ## Screenshots of UI changes <img width="859" height="659" alt="Screenshot 2025-12-12 at 1 33 32 p m" src="https://github.com/user-attachments/assets/1d663f89-b6b4-4331-a156-b1cc5aca99aa" /> ## Detailed steps to verify changes work correctly 1. Load a timeseries chart with 5 or fewer series. - Verify the tooltip displays all items without a legend. 2. Load a timeseries chart with more than 5 series. - Verify the tooltip displays only the first 5 items. - Verify a legend row appears indicating the number of additional items. 3. Test scenarios with exactly 6 items and more than 10 items. - Confirm correct singular/plural text is displayed. 4. Verify behavior in both: - Scalar cards - Default line chart tooltips 5. Confirm the tooltip no longer shows a scroll and remains readable. 6. Run tests: ```bash bazel test //tensorboard/webapp:karma_test_chromium-local
1 parent 491288d commit a82193a

File tree

9 files changed

+163
-20
lines changed

9 files changed

+163
-20
lines changed

tensorboard/webapp/metrics/views/card_renderer/card_lazy_loader_test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ describe('card view test', () => {
109109

110110
dispatchedActions = [];
111111
store = TestBed.inject<Store<State>>(Store) as MockStore<State>;
112-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
112+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
113+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => {
113114
dispatchedActions.push(action);
114115
});
115116

tensorboard/webapp/metrics/views/card_renderer/card_view_test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ describe('card view test', () => {
6565

6666
dispatchedActions = [];
6767
store = TestBed.inject<Store<State>>(Store) as MockStore<State>;
68-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
68+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
69+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => {
6970
dispatchedActions.push(action);
7071
});
7172
store.overrideSelector(selectors.getRunColorMap, {});

tensorboard/webapp/metrics/views/card_renderer/histogram_card_test.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,12 @@ describe('histogram card', () => {
267267
provideMockCardSeriesData(selectSpy, PluginType.HISTOGRAMS, 'card1');
268268

269269
dispatchedActions = [];
270-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
271-
dispatchedActions.push(action);
272-
});
270+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
271+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake(
272+
(action: Action) => {
273+
dispatchedActions.push(action);
274+
}
275+
);
273276
});
274277

275278
it('dispatches metricsCardFullSizeToggled on full size toggle', () => {
@@ -301,9 +304,12 @@ describe('histogram card', () => {
301304
const fixture = createHistogramCardContainer();
302305
fixture.detectChanges();
303306
const dispatchedActions: Action[] = [];
304-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
305-
dispatchedActions.push(action);
306-
});
307+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
308+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake(
309+
(action: Action) => {
310+
dispatchedActions.push(action);
311+
}
312+
);
307313

308314
const histogramWidget = fixture.debugElement.query(
309315
By.directive(TestableHistogramWidget)
@@ -584,9 +590,12 @@ describe('histogram card', () => {
584590
const fixture = createHistogramCardContainer();
585591
fixture.detectChanges();
586592
const dispatchedActions: Action[] = [];
587-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
588-
dispatchedActions.push(action);
589-
});
593+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
594+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake(
595+
(action: Action) => {
596+
dispatchedActions.push(action);
597+
}
598+
);
590599

591600
const histogramWidget = fixture.debugElement.query(
592601
By.directive(TestableHistogramWidget)

tensorboard/webapp/metrics/views/card_renderer/image_card_test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ describe('image card', () => {
107107
store = TestBed.inject<Store<State>>(Store) as MockStore<State>;
108108
dataSource = TestBed.inject<MetricsDataSource>(MetricsDataSource);
109109
selectSpy = spyOn(store, 'select').and.callThrough();
110-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
110+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
111+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => {
111112
dispatchedActions.push(action);
112113
});
113114

tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@
178178
</td>
179179
</tr>
180180
</ng-container>
181+
<tr class="legend" *ngIf="additionalItemsCount > 0">
182+
<td colspan="100">
183+
{{ additionalItemsCount }} additional {{ additionalItemsCount ===
184+
1 ? 'item' : 'items' }}
185+
</td>
186+
</tr>
181187
</tbody>
182188
</table>
183189
</ng-template>

tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.scss

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,21 @@ $_data_table_initial_height: 100px;
115115
}
116116

117117
.tooltip {
118-
border-spacing: 4px;
118+
border-spacing: 8px;
119119
font-size: 13px;
120120

121121
th {
122122
text-align: left;
123123
}
124124

125+
td {
126+
text-align: justify;
127+
128+
.legend {
129+
font-weight: 500;
130+
}
131+
}
132+
125133
$_circle-size: 12px;
126134

127135
.tooltip-row {

tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type ScalarTooltipDatum = TooltipDatum<
7575
}
7676
>;
7777

78+
const MAX_TOOLTIP_ITEMS = 5;
79+
7880
@Component({
7981
standalone: false,
8082
selector: 'scalar-card-component',
@@ -153,6 +155,7 @@ export class ScalarCardComponent<Downloader> {
153155

154156
yScaleType = ScaleType.LINEAR;
155157
isViewBoxOverridden: boolean = false;
158+
additionalItemsCount = 0;
156159

157160
toggleYScaleType() {
158161
this.yScaleType =
@@ -226,20 +229,24 @@ export class ScalarCardComponent<Downloader> {
226229

227230
switch (this.tooltipSort) {
228231
case TooltipSort.ASCENDING:
229-
return scalarTooltipData.sort((a, b) => a.dataPoint.y - b.dataPoint.y);
232+
scalarTooltipData.sort((a, b) => a.dataPoint.y - b.dataPoint.y);
233+
break;
230234
case TooltipSort.DESCENDING:
231-
return scalarTooltipData.sort((a, b) => b.dataPoint.y - a.dataPoint.y);
235+
scalarTooltipData.sort((a, b) => b.dataPoint.y - a.dataPoint.y);
236+
break;
232237
case TooltipSort.NEAREST:
233-
return scalarTooltipData.sort((a, b) => {
238+
scalarTooltipData.sort((a, b) => {
234239
return a.metadata.distToCursorPixels - b.metadata.distToCursorPixels;
235240
});
241+
break;
236242
case TooltipSort.NEAREST_Y:
237-
return scalarTooltipData.sort((a, b) => {
243+
scalarTooltipData.sort((a, b) => {
238244
return a.metadata.distToCursorY - b.metadata.distToCursorY;
239245
});
246+
break;
240247
case TooltipSort.DEFAULT:
241248
case TooltipSort.ALPHABETICAL:
242-
return scalarTooltipData.sort((a, b) => {
249+
scalarTooltipData.sort((a, b) => {
243250
if (a.metadata.displayName < b.metadata.displayName) {
244251
return -1;
245252
}
@@ -248,7 +255,14 @@ export class ScalarCardComponent<Downloader> {
248255
}
249256
return 0;
250257
});
258+
break;
251259
}
260+
261+
this.additionalItemsCount = Math.max(
262+
0,
263+
scalarTooltipData.length - MAX_TOOLTIP_ITEMS
264+
);
265+
return scalarTooltipData.slice(0, MAX_TOOLTIP_ITEMS);
252266
}
253267

254268
openDataDownloadDialog(): void {

tensorboard/webapp/metrics/views/card_renderer/scalar_card_line_chart_test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,8 @@ describe('scalar card line chart', () => {
461461
store.overrideSelector(selectors.getMetricsCardUserViewBox, null);
462462

463463
dispatchedActions = [];
464-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
464+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
465+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => {
465466
dispatchedActions.push(action);
466467
});
467468
});

tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ describe('scalar card', () => {
408408
store.overrideSelector(selectors.getMetricsCardUserViewBox, null);
409409

410410
dispatchedActions = [];
411-
spyOn(store, 'dispatch').and.callFake((action: Action) => {
411+
// Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads.
412+
(spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => {
412413
dispatchedActions.push(action);
413414
});
414415
});
@@ -1885,6 +1886,107 @@ describe('scalar card', () => {
18851886
['', 'world', '-500', '1,000', anyString, anyString],
18861887
]);
18871888
}));
1889+
1890+
describe('tooltip item limiting and legend', () => {
1891+
const colors = [
1892+
'#00f',
1893+
'#0f0',
1894+
'#f00',
1895+
'#ff0',
1896+
'#0ff',
1897+
'#f0f',
1898+
'#fff',
1899+
'#000',
1900+
];
1901+
1902+
function buildTooltipData(count: number) {
1903+
return Array.from({length: count}, (_, i) =>
1904+
buildTooltipDatum({
1905+
id: `row${i + 1}`,
1906+
type: SeriesType.ORIGINAL,
1907+
displayName: `Row ${i + 1}`,
1908+
alias: null,
1909+
visible: true,
1910+
color: colors[i % colors.length],
1911+
})
1912+
);
1913+
}
1914+
1915+
function getLegendRow(fixture: ComponentFixture<ScalarCardContainer>) {
1916+
return fixture.debugElement.query(By.css('table.tooltip tr.legend'));
1917+
}
1918+
1919+
it('displays all items when there are 5 or fewer', fakeAsync(() => {
1920+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0);
1921+
const fixture = createComponent('card1');
1922+
setTooltipData(fixture, buildTooltipData(5));
1923+
fixture.detectChanges();
1924+
1925+
expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe(
1926+
5
1927+
);
1928+
expect(getLegendRow(fixture)).toBeNull();
1929+
}));
1930+
1931+
it('limits tooltip to 5 items when there are more than 5', fakeAsync(() => {
1932+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0);
1933+
const fixture = createComponent('card1');
1934+
setTooltipData(fixture, buildTooltipData(7));
1935+
fixture.detectChanges();
1936+
1937+
expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe(
1938+
5
1939+
);
1940+
}));
1941+
1942+
it('shows legend with singular text for 1 additional item', fakeAsync(() => {
1943+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0);
1944+
const fixture = createComponent('card1');
1945+
setTooltipData(fixture, buildTooltipData(6));
1946+
fixture.detectChanges();
1947+
1948+
const legendRow = getLegendRow(fixture);
1949+
expect(legendRow).not.toBeNull();
1950+
expect(legendRow.nativeElement.textContent.trim()).toBe(
1951+
'1 additional item'
1952+
);
1953+
}));
1954+
1955+
it('shows legend with plural text for multiple additional items', fakeAsync(() => {
1956+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0);
1957+
const fixture = createComponent('card1');
1958+
setTooltipData(fixture, buildTooltipData(8));
1959+
fixture.detectChanges();
1960+
1961+
const legendRow = getLegendRow(fixture);
1962+
expect(legendRow).not.toBeNull();
1963+
expect(legendRow.nativeElement.textContent.trim()).toBe(
1964+
'3 additional items'
1965+
);
1966+
}));
1967+
1968+
it('does not show legend when there are exactly 5 items', fakeAsync(() => {
1969+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0);
1970+
const fixture = createComponent('card1');
1971+
setTooltipData(fixture, buildTooltipData(5));
1972+
fixture.detectChanges();
1973+
1974+
expect(getLegendRow(fixture)).toBeNull();
1975+
}));
1976+
1977+
it('shows legend with correct colspan when smoothing is enabled', fakeAsync(() => {
1978+
store.overrideSelector(selectors.getMetricsScalarSmoothing, 0.5);
1979+
const fixture = createComponent('card1');
1980+
setTooltipData(fixture, buildTooltipData(6));
1981+
fixture.detectChanges();
1982+
1983+
const legendRow = getLegendRow(fixture);
1984+
expect(legendRow).not.toBeNull();
1985+
expect(
1986+
legendRow.query(By.css('td')).nativeElement.getAttribute('colspan')
1987+
).toBe('100');
1988+
}));
1989+
});
18881990
});
18891991

18901992
describe('non-monotonic increase in x-axis', () => {

0 commit comments

Comments
 (0)