From a61bf5e7375b420a5d77df3c20b5d39079a0535b Mon Sep 17 00:00:00 2001 From: Cinthia Davalos Date: Sun, 14 Dec 2025 19:25:36 -0600 Subject: [PATCH] 7028: Reduce timeseries tooltip to max 5 items --- .../scalar_card_component.ng.html | 6 + .../card_renderer/scalar_card_component.scss | 10 +- .../card_renderer/scalar_card_component.ts | 30 +++- .../scalar_card_line_chart_test.ts | 151 ++++++++++++++++-- .../views/card_renderer/scalar_card_test.ts | 101 ++++++++++++ .../line_chart_interactive_view.ng.html | 8 +- .../sub_view/line_chart_interactive_view.ts | 16 ++ .../line_chart_interactive_view_test.ts | 100 ++++++++++++ 8 files changed, 406 insertions(+), 16 deletions(-) diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html index 79d4264b1e7..451daf40c3b 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ng.html @@ -178,6 +178,12 @@ + + + {{ additionalItemsCount }} additional {{ additionalItemsCount === + 1 ? 'item' : 'items' }} + + diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.scss b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.scss index d27a4f00b6b..b25ac4ab576 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.scss +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.scss @@ -115,13 +115,21 @@ $_data_table_initial_height: 100px; } .tooltip { - border-spacing: 4px; + border-spacing: 8px; font-size: 13px; th { text-align: left; } + td { + text-align: justify; + + .legend { + font-weight: 500; + } + } + $_circle-size: 12px; .tooltip-row { diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts index 0d1292fe639..2b93999d27d 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts @@ -149,10 +149,13 @@ export class ScalarCardComponent { @ViewChild('dataTableContainer') dataTableContainer?: ElementRef; + readonly MAX_TOOLTIP_ITEMS = 5; + constructor(private readonly ref: ElementRef, private dialog: MatDialog) {} yScaleType = ScaleType.LINEAR; isViewBoxOverridden: boolean = false; + tooltipTotalCount = 0; toggleYScaleType() { this.yScaleType = @@ -224,22 +227,31 @@ export class ScalarCardComponent { scalarTooltipData[minIndex].metadata.closest = true; } + let sortedData: ScalarTooltipDatum[]; switch (this.tooltipSort) { case TooltipSort.ASCENDING: - return scalarTooltipData.sort((a, b) => a.dataPoint.y - b.dataPoint.y); + sortedData = scalarTooltipData.sort( + (a, b) => a.dataPoint.y - b.dataPoint.y + ); + break; case TooltipSort.DESCENDING: - return scalarTooltipData.sort((a, b) => b.dataPoint.y - a.dataPoint.y); + sortedData = scalarTooltipData.sort( + (a, b) => b.dataPoint.y - a.dataPoint.y + ); + break; case TooltipSort.NEAREST: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorPixels - b.metadata.distToCursorPixels; }); + break; case TooltipSort.NEAREST_Y: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorY - b.metadata.distToCursorY; }); + break; case TooltipSort.DEFAULT: case TooltipSort.ALPHABETICAL: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { if (a.metadata.displayName < b.metadata.displayName) { return -1; } @@ -248,7 +260,15 @@ export class ScalarCardComponent { } return 0; }); + break; } + + this.tooltipTotalCount = sortedData.length; + return sortedData.slice(0, this.MAX_TOOLTIP_ITEMS); + } + + get additionalItemsCount(): number { + return Math.max(0, this.tooltipTotalCount - this.MAX_TOOLTIP_ITEMS); } openDataDownloadDialog(): void { diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_line_chart_test.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_line_chart_test.ts index 5ac09278968..78644acab96 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_line_chart_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_line_chart_test.ts @@ -199,9 +199,9 @@ class TestableLineChart { @@ -233,6 +233,12 @@ class TestableLineChart { + + + {{ additionalItemsCount }} additional + {{ additionalItemsCount === 1 ? 'item' : 'items' }} + + @@ -257,10 +263,13 @@ class TestableScalarCardLineChart { readonly valueFormatter = numberFormatter; readonly stepFormatter = intlNumberFormatter; + readonly MAX_TOOLTIP_ITEMS = 5; + tooltipTotalCount = 0; + constructor(public readonly changeDetectorRef: ChangeDetectorRef) {} getCursorAwareTooltipData( - tooltipData: TooltipDatum[], + tooltipData: TooltipDatum[], cursorLocationInDataCoord: {x: number; y: number}, cursorLocation: {x: number; y: number} ) { @@ -293,22 +302,31 @@ class TestableScalarCardLineChart { scalarTooltipData[minIndex].metadata.closest = true; } + let sortedData; switch (this.tooltipSort) { case TooltipSort.ASCENDING: - return scalarTooltipData.sort((a, b) => a.dataPoint.y - b.dataPoint.y); + sortedData = scalarTooltipData.sort( + (a, b) => a.dataPoint.y - b.dataPoint.y + ); + break; case TooltipSort.DESCENDING: - return scalarTooltipData.sort((a, b) => b.dataPoint.y - a.dataPoint.y); + sortedData = scalarTooltipData.sort( + (a, b) => b.dataPoint.y - a.dataPoint.y + ); + break; case TooltipSort.NEAREST: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorPixels - b.metadata.distToCursorPixels; }); + break; case TooltipSort.NEAREST_Y: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorY - b.metadata.distToCursorY; }); + break; case TooltipSort.DEFAULT: case TooltipSort.ALPHABETICAL: - return scalarTooltipData.sort((a, b) => { + sortedData = scalarTooltipData.sort((a, b) => { if (a.metadata.displayName < b.metadata.displayName) { return -1; } @@ -317,7 +335,15 @@ class TestableScalarCardLineChart { } return 0; }); + break; } + + this.tooltipTotalCount = sortedData.length; + return sortedData.slice(0, this.MAX_TOOLTIP_ITEMS); + } + + get additionalItemsCount(): number { + return Math.max(0, this.tooltipTotalCount - this.MAX_TOOLTIP_ITEMS); } } @@ -784,6 +810,11 @@ describe('scalar card line chart', () => { tooltipData: TooltipDatum[] ) { fixture.componentInstance.tooltipDataForTesting = tooltipData; + const lineChart = fixture.debugElement.query(Selector.LINE_CHART); + if (lineChart) { + lineChart.componentInstance.tooltipDataForTesting = tooltipData; + lineChart.componentInstance.changeDetectorRef.markForCheck(); + } fixture.componentInstance.changeDetectorRef.markForCheck(); } @@ -792,11 +823,23 @@ describe('scalar card line chart', () => { dataPoint?: {x: number; y: number}, domPoint?: Point ) { + const lineChart = fixture.debugElement.query(Selector.LINE_CHART); if (dataPoint) { fixture.componentInstance.dataPointForTesting = dataPoint; + if (lineChart) { + lineChart.componentInstance.dataPointForTesting = dataPoint; + lineChart.componentInstance.cursorLocationInDataCoordForTesting = + dataPoint; + } } if (domPoint) { fixture.componentInstance.cursorLocationForTesting = domPoint; + if (lineChart) { + lineChart.componentInstance.cursorLocationForTesting = domPoint; + } + } + if (lineChart) { + lineChart.componentInstance.changeDetectorRef.markForCheck(); } fixture.componentInstance.changeDetectorRef.markForCheck(); } @@ -1369,6 +1412,96 @@ describe('scalar card line chart', () => { ['', 'world', '-500', '1,000', anyString, anyString], ]); })); + + describe('tooltip item limiting and legend', () => { + const colors = [ + '#00f', + '#0f0', + '#f00', + '#ff0', + '#0ff', + '#f0f', + '#fff', + '#000', + ]; + + function buildTooltipData(count: number) { + return Array.from({length: count}, (_, i) => + buildTooltipDatum({ + id: `row${i + 1}`, + type: SeriesType.ORIGINAL, + displayName: `Row ${i + 1}`, + alias: null, + visible: true, + color: colors[i % colors.length], + }) + ); + } + + function getLegendRow( + fixture: ComponentFixture + ) { + return fixture.debugElement.query(By.css('table.tooltip tr.legend')); + } + + it('displays all items when there are 5 or fewer', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent(); + setTooltipData(fixture, buildTooltipData(5)); + fixture.detectChanges(); + + expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe( + 5 + ); + expect(getLegendRow(fixture)).toBeNull(); + })); + + it('limits tooltip to 5 items when there are more than 5', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent(); + setTooltipData(fixture, buildTooltipData(7)); + fixture.detectChanges(); + + expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe( + 5 + ); + })); + + it('shows legend with singular text for 1 additional item', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent(); + setTooltipData(fixture, buildTooltipData(6)); + fixture.detectChanges(); + + const legendRow = getLegendRow(fixture); + expect(legendRow).not.toBeNull(); + expect(legendRow.nativeElement.textContent.trim()).toBe( + '1 additional item' + ); + })); + + it('shows legend with plural text for multiple additional items', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent(); + setTooltipData(fixture, buildTooltipData(8)); + fixture.detectChanges(); + + const legendRow = getLegendRow(fixture); + expect(legendRow).not.toBeNull(); + expect(legendRow.nativeElement.textContent.trim()).toBe( + '3 additional items' + ); + })); + + it('does not show legend when there are exactly 5 items', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent(); + setTooltipData(fixture, buildTooltipData(5)); + fixture.detectChanges(); + + expect(getLegendRow(fixture)).toBeNull(); + })); + }); }); describe('linked time feature integration', () => { diff --git a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts index d01555d5465..ce9e3f7f07f 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts @@ -1885,6 +1885,107 @@ describe('scalar card', () => { ['', 'world', '-500', '1,000', anyString, anyString], ]); })); + + describe('tooltip item limiting and legend', () => { + const colors = [ + '#00f', + '#0f0', + '#f00', + '#ff0', + '#0ff', + '#f0f', + '#fff', + '#000', + ]; + + function buildTooltipData(count: number) { + return Array.from({length: count}, (_, i) => + buildTooltipDatum({ + id: `row${i + 1}`, + type: SeriesType.ORIGINAL, + displayName: `Row ${i + 1}`, + alias: null, + visible: true, + color: colors[i % colors.length], + }) + ); + } + + function getLegendRow(fixture: ComponentFixture) { + return fixture.debugElement.query(By.css('table.tooltip tr.legend')); + } + + it('displays all items when there are 5 or fewer', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(5)); + fixture.detectChanges(); + + expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe( + 5 + ); + expect(getLegendRow(fixture)).toBeNull(); + })); + + it('limits tooltip to 5 items when there are more than 5', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(7)); + fixture.detectChanges(); + + expect(fixture.debugElement.queryAll(Selector.TOOLTIP_ROW).length).toBe( + 5 + ); + })); + + it('shows legend with singular text for 1 additional item', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(6)); + fixture.detectChanges(); + + const legendRow = getLegendRow(fixture); + expect(legendRow).not.toBeNull(); + expect(legendRow.nativeElement.textContent.trim()).toBe( + '1 additional item' + ); + })); + + it('shows legend with plural text for multiple additional items', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(8)); + fixture.detectChanges(); + + const legendRow = getLegendRow(fixture); + expect(legendRow).not.toBeNull(); + expect(legendRow.nativeElement.textContent.trim()).toBe( + '3 additional items' + ); + })); + + it('does not show legend when there are exactly 5 items', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(5)); + fixture.detectChanges(); + + expect(getLegendRow(fixture)).toBeNull(); + })); + + it('shows legend with correct colspan when smoothing is enabled', fakeAsync(() => { + store.overrideSelector(selectors.getMetricsScalarSmoothing, 0.5); + const fixture = createComponent('card1'); + setTooltipData(fixture, buildTooltipData(6)); + fixture.detectChanges(); + + const legendRow = getLegendRow(fixture); + expect(legendRow).not.toBeNull(); + expect( + legendRow.query(By.css('td')).nativeElement.getAttribute('colspan') + ).toBe('100'); + })); + }); }); describe('non-monotonic increase in x-axis', () => { diff --git a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ng.html b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ng.html index 96ecc671428..1eeeae79c30 100644 --- a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ng.html +++ b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ng.html @@ -67,7 +67,7 @@ {{ datum.dataPoint.x }} + + + {{ additionalItemsCount }} additional {{ additionalItemsCount === 1 ? + 'item' : 'items' }} + + diff --git a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts index c2b4ffd20cd..f61538a94b1 100644 --- a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts +++ b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view.ts @@ -208,8 +208,12 @@ export class LineChartInteractiveViewComponent cursorLocationInDataCoord: {x: number; y: number} | null = null; cursorLocation: {x: number; y: number} | null = null; cursoredData: TooltipDatum[] = []; + limitedCursoredData: TooltipDatum[] = []; tooltipDisplayAttached: boolean = false; + readonly MAX_TOOLTIP_ITEMS = 5; + tooltipTotalCount = 0; + @HostBinding('class.show-zoom-instruction') showZoomInstruction: boolean = false; @@ -486,6 +490,10 @@ export class LineChartInteractiveViewComponent return datum.id; } + get additionalItemsCount(): number { + return Math.max(0, this.tooltipTotalCount - this.MAX_TOOLTIP_ITEMS); + } + getDomX(uiCoord: number): number { return this.xScale.forward( this.viewExtent.x, @@ -542,6 +550,8 @@ export class LineChartInteractiveViewComponent const cursorLoc = this.cursorLocationInDataCoord; if (cursorLoc === null) { this.cursoredData = []; + this.limitedCursoredData = []; + this.tooltipTotalCount = 0; this.tooltipDisplayAttached = false; return; } @@ -573,6 +583,12 @@ export class LineChartInteractiveViewComponent }) .filter((tooltipDatumOrNull) => tooltipDatumOrNull) as TooltipDatum[]) : []; + + this.tooltipTotalCount = this.cursoredData.length; + this.limitedCursoredData = this.cursoredData.slice( + 0, + this.MAX_TOOLTIP_ITEMS + ); this.tooltipDisplayAttached = Boolean(this.cursoredData.length); } } diff --git a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view_test.ts b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view_test.ts index 27fb2568aa5..ef674b27947 100644 --- a/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view_test.ts +++ b/tensorboard/webapp/widgets/line_chart_v2/sub_view/line_chart_interactive_view_test.ts @@ -279,6 +279,106 @@ describe('line_chart_v2/sub_view/interactive_view test', () => { expect(overlayContainer.getContainerElement().childElementCount).toBe(0); }); + describe('tooltip item limiting and legend', () => { + const seriesNames = [ + 'foo', + 'bar', + 'baz', + 'qux', + 'quux', + 'corge', + 'grault', + 'garply', + ]; + const colors = [ + '#f00', + '#0f0', + '#00f', + '#ff0', + '#f0f', + '#0ff', + '#fff', + '#000', + ]; + + function setupSeriesData( + fixture: ComponentFixture, + count: number + ) { + fixture.componentInstance.seriesData = seriesNames + .slice(0, count) + .map((name, i) => + createSeries(name, (index: number) => index * (i + 1)) + ); + fixture.componentInstance.seriesMetadataMap = seriesNames + .slice(0, count) + .reduce((map, name, i) => { + map[name] = buildMetadata({ + id: name, + displayName: name.charAt(0).toUpperCase() + name.slice(1), + color: colors[i % colors.length], + }); + return map; + }, {} as any); + fixture.componentInstance.domDim = {width: 500, height: 200}; + fixture.detectChanges(); + emitEvent(fixture, 'mouseenter', {clientX: 250, clientY: 10}); + fixture.detectChanges(); + } + + function getLegendRow() { + return overlayContainer + .getContainerElement() + .querySelector('tbody tr.legend'); + } + + it('displays all items when there are 5 or fewer', () => { + const fixture = createComponent(); + setupSeriesData(fixture, 5); + + const rows = overlayContainer + .getContainerElement() + .querySelectorAll('tbody tr'); + expect(rows.length).toBe(5); + expect(getLegendRow()).toBeNull(); + }); + + it('limits tooltip to 5 items when there are more than 5', () => { + const fixture = createComponent(); + setupSeriesData(fixture, 7); + + const rows = overlayContainer + .getContainerElement() + .querySelectorAll('tbody tr:not(.legend)'); + expect(rows.length).toBe(5); + }); + + it('shows legend with singular text for 1 additional item', () => { + const fixture = createComponent(); + setupSeriesData(fixture, 6); + + const legendRow = getLegendRow(); + expect(legendRow).not.toBeNull(); + expect(legendRow!.textContent!.trim()).toBe('1 additional item'); + }); + + it('shows legend with plural text for multiple additional items', () => { + const fixture = createComponent(); + setupSeriesData(fixture, 8); + + const legendRow = getLegendRow(); + expect(legendRow).not.toBeNull(); + expect(legendRow!.textContent!.trim()).toBe('3 additional items'); + }); + + it('does not show legend when there are exactly 5 items', () => { + const fixture = createComponent(); + setupSeriesData(fixture, 5); + + expect(getLegendRow()).toBeNull(); + }); + }); + it('does not render tooltip when disableTooltip is true', () => { const fixture = createComponent(); fixture.componentInstance.disableTooltip = true;