diff --git a/tensorboard/webapp/metrics/views/card_renderer/card_lazy_loader_test.ts b/tensorboard/webapp/metrics/views/card_renderer/card_lazy_loader_test.ts index f1268d7b65..90b9f02090 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/card_lazy_loader_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/card_lazy_loader_test.ts @@ -109,7 +109,8 @@ describe('card view test', () => { dispatchedActions = []; store = TestBed.inject>(Store) as MockStore; - spyOn(store, 'dispatch').and.callFake((action: Action) => { + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => { dispatchedActions.push(action); }); diff --git a/tensorboard/webapp/metrics/views/card_renderer/card_view_test.ts b/tensorboard/webapp/metrics/views/card_renderer/card_view_test.ts index 4d83e97af2..323dfe5762 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/card_view_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/card_view_test.ts @@ -65,7 +65,8 @@ describe('card view test', () => { dispatchedActions = []; store = TestBed.inject>(Store) as MockStore; - spyOn(store, 'dispatch').and.callFake((action: Action) => { + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => { dispatchedActions.push(action); }); store.overrideSelector(selectors.getRunColorMap, {}); diff --git a/tensorboard/webapp/metrics/views/card_renderer/histogram_card_test.ts b/tensorboard/webapp/metrics/views/card_renderer/histogram_card_test.ts index 9326f0e55b..0141459630 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/histogram_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/histogram_card_test.ts @@ -267,9 +267,12 @@ describe('histogram card', () => { provideMockCardSeriesData(selectSpy, PluginType.HISTOGRAMS, 'card1'); dispatchedActions = []; - spyOn(store, 'dispatch').and.callFake((action: Action) => { - dispatchedActions.push(action); - }); + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake( + (action: Action) => { + dispatchedActions.push(action); + } + ); }); it('dispatches metricsCardFullSizeToggled on full size toggle', () => { @@ -301,9 +304,12 @@ describe('histogram card', () => { const fixture = createHistogramCardContainer(); fixture.detectChanges(); const dispatchedActions: Action[] = []; - spyOn(store, 'dispatch').and.callFake((action: Action) => { - dispatchedActions.push(action); - }); + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake( + (action: Action) => { + dispatchedActions.push(action); + } + ); const histogramWidget = fixture.debugElement.query( By.directive(TestableHistogramWidget) @@ -584,9 +590,12 @@ describe('histogram card', () => { const fixture = createHistogramCardContainer(); fixture.detectChanges(); const dispatchedActions: Action[] = []; - spyOn(store, 'dispatch').and.callFake((action: Action) => { - dispatchedActions.push(action); - }); + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake( + (action: Action) => { + dispatchedActions.push(action); + } + ); const histogramWidget = fixture.debugElement.query( By.directive(TestableHistogramWidget) diff --git a/tensorboard/webapp/metrics/views/card_renderer/image_card_test.ts b/tensorboard/webapp/metrics/views/card_renderer/image_card_test.ts index 33ef483177..515330e2df 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/image_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/image_card_test.ts @@ -107,7 +107,8 @@ describe('image card', () => { store = TestBed.inject>(Store) as MockStore; dataSource = TestBed.inject(MetricsDataSource); selectSpy = spyOn(store, 'select').and.callThrough(); - spyOn(store, 'dispatch').and.callFake((action: Action) => { + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => { dispatchedActions.push(action); }); 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 @@ + + + {{ 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..8a627599e4 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_component.ts @@ -75,6 +75,8 @@ type ScalarTooltipDatum = TooltipDatum< } >; +const MAX_TOOLTIP_ITEMS = 5; + @Component({ standalone: false, selector: 'scalar-card-component', @@ -153,6 +155,7 @@ export class ScalarCardComponent { yScaleType = ScaleType.LINEAR; isViewBoxOverridden: boolean = false; + additionalItemsCount = 0; toggleYScaleType() { this.yScaleType = @@ -226,20 +229,24 @@ export class ScalarCardComponent { switch (this.tooltipSort) { case TooltipSort.ASCENDING: - return scalarTooltipData.sort((a, b) => a.dataPoint.y - b.dataPoint.y); + 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); + scalarTooltipData.sort((a, b) => b.dataPoint.y - a.dataPoint.y); + break; case TooltipSort.NEAREST: - return scalarTooltipData.sort((a, b) => { + scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorPixels - b.metadata.distToCursorPixels; }); + break; case TooltipSort.NEAREST_Y: - return scalarTooltipData.sort((a, b) => { + scalarTooltipData.sort((a, b) => { return a.metadata.distToCursorY - b.metadata.distToCursorY; }); + break; case TooltipSort.DEFAULT: case TooltipSort.ALPHABETICAL: - return scalarTooltipData.sort((a, b) => { + scalarTooltipData.sort((a, b) => { if (a.metadata.displayName < b.metadata.displayName) { return -1; } @@ -248,7 +255,14 @@ export class ScalarCardComponent { } return 0; }); + break; } + + this.additionalItemsCount = Math.max( + 0, + scalarTooltipData.length - MAX_TOOLTIP_ITEMS + ); + return scalarTooltipData.slice(0, 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..21334debeb 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 @@ -461,7 +461,8 @@ describe('scalar card line chart', () => { store.overrideSelector(selectors.getMetricsCardUserViewBox, null); dispatchedActions = []; - spyOn(store, 'dispatch').and.callFake((action: Action) => { + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => { dispatchedActions.push(action); }); }); 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..b514bc64af 100644 --- a/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts +++ b/tensorboard/webapp/metrics/views/card_renderer/scalar_card_test.ts @@ -408,7 +408,8 @@ describe('scalar card', () => { store.overrideSelector(selectors.getMetricsCardUserViewBox, null); dispatchedActions = []; - spyOn(store, 'dispatch').and.callFake((action: Action) => { + // Cast to jasmine.Spy for compatibility between NgRx dispatch signature overloads. + (spyOn(store, 'dispatch') as jasmine.Spy).and.callFake((action: Action) => { dispatchedActions.push(action); }); }); @@ -1885,6 +1886,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', () => {