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 79d4264b1e..451daf40c3 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 @@
+
0">
+ |
+ {{ 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 d27a4f00b6..b25ac4ab57 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 0d1292fe63..2b93999d27 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 5ac0927896..78644acab9 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 {
+ 0">
+ |
+ {{ 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 d01555d546..ce9e3f7f07 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 96ecc67142..1eeeae79c3 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 }}
+ 0">
+ |
+ {{ 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 c2b4ffd20c..f61538a94b 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 27fb2568aa..ef674b2794 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;