diff --git a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss index 202eb55c4..fedaed806 100644 --- a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss +++ b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss @@ -157,6 +157,56 @@ } } + &.grouped { + flex-direction: column; + gap: 12px; + + .legend-entries { + flex-direction: row; + display: flex; + gap: 20px; + border: 1px solid $gray-2; + border-radius: 8px; + padding: 8px 20px; + + &.active { + border-color: $blue-5; + } + + .legend-entries-title { + @include body-1-regular($gray-5); + width: 200px; + cursor: pointer; + + &.active { + color: $blue-5; + } + } + + .legend-entry-values { + flex: 1; + display: flex; + flex-wrap: wrap; + } + } + } + + .reset { + @include font-title($blue-4); + cursor: pointer; + position: absolute; + bottom: 0; + right: 0; + + &.hidden { + display: none; + } + + &:hover { + color: $blue-6; + } + } + .interval-control { padding: 0 8px; } diff --git a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts index 3e9b49904..2b67efac3 100644 --- a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts +++ b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts @@ -182,16 +182,18 @@ describe('Cartesian Chart component', () => { series: [ { data: [[1, 2]], - name: 'test series 1', + name: 'first', color: 'blue', type: CartesianSeriesVisualizationType.Column, + groupName: 'test series', stacking: true }, { data: [[1, 6]], - name: 'test series 2', + name: 'second', color: 'red', type: CartesianSeriesVisualizationType.Column, + groupName: 'test series', stacking: true } ], @@ -202,14 +204,28 @@ describe('Cartesian Chart component', () => { expect(chart.queryAll('.legend-entry').length).toBe(2); expect(chart.query('.reset.hidden')).toExist(); + const legendEntriesTitleElement = chart.query('.legend-entries-title') as Element; + chart.click(legendEntriesTitleElement); + tick(); + expect(chart.queryAll('.legend-text.active').length).toBe(2); + + chart.click(legendEntriesTitleElement); + tick(); + expect(chart.queryAll('.legend-text.active').length).toBe(0); + const legendEntryTexts = chart.queryAll('.legend-text'); chart.click(legendEntryTexts[0]); tick(); + expect(chart.queryAll('.legend-text.active').length).toBe(1); expect(chart.query('.reset.hidden')).not.toExist(); chart.click(chart.query('.reset') as Element); tick(); expect(chart.query('.reset.hidden')).toExist(); + + chart.click(legendEntryTexts[0]); + tick(); + expect(chart.queryAll('.legend-text.active').length).toBe(1); })); test('should render column chart', fakeAsync(() => { diff --git a/projects/observability/src/shared/components/cartesian/chart.ts b/projects/observability/src/shared/components/cartesian/chart.ts index 6331dcc27..4fbc2933e 100644 --- a/projects/observability/src/shared/components/cartesian/chart.ts +++ b/projects/observability/src/shared/components/cartesian/chart.ts @@ -26,6 +26,7 @@ export interface Series { // Override the default color string using a method that takes data point as input getColor?(datum?: TInterval): string; name: string; + groupName?: string; symbol?: SeriesSymbol; type: CartesianSeriesVisualizationType; stacking?: boolean; diff --git a/projects/observability/src/shared/components/cartesian/d3/legend/cartesian-legend.ts b/projects/observability/src/shared/components/cartesian/d3/legend/cartesian-legend.ts index 2321c7ad7..5c0552862 100644 --- a/projects/observability/src/shared/components/cartesian/d3/legend/cartesian-legend.ts +++ b/projects/observability/src/shared/components/cartesian/d3/legend/cartesian-legend.ts @@ -1,6 +1,7 @@ import { ComponentRef, Injector } from '@angular/core'; -import { Color, DynamicComponentService } from '@hypertrace/common'; +import { Color, Dictionary, DynamicComponentService } from '@hypertrace/common'; import { ContainerElement, EnterElement, select, Selection } from 'd3-selection'; +import { groupBy } from 'lodash-es'; import { Observable, Subject } from 'rxjs'; import { startWith } from 'rxjs/operators'; import { LegendPosition } from '../../../legend/legend.component'; @@ -24,7 +25,9 @@ export class CartesianLegend { public readonly activeSeries$: Observable[]>; private readonly activeSeriesSubject: Subject[]> = new Subject(); private readonly initialSeries: Series[]; + private readonly groupedSeries: Dictionary[]>; + private readonly isGrouped: boolean = true; private isSelectionModeOn: boolean = false; private legendElement?: HTMLDivElement; private activeSeries: Series[]; @@ -37,6 +40,10 @@ export class CartesianLegend { private readonly intervalData?: CartesianIntervalData, private readonly summaries: Summary[] = [] ) { + this.isGrouped = + this.series.length > 0 && this.series.every(seriesEntry => seriesEntry.groupName !== seriesEntry.name); + this.groupedSeries = this.isGrouped ? groupBy(this.series, seriesEntry => seriesEntry.groupName) : {}; + this.activeSeries = [...this.series]; this.initialSeries = [...this.series]; this.activeSeries$ = this.activeSeriesSubject.asObservable().pipe(startWith(this.series)); @@ -83,11 +90,42 @@ export class CartesianLegend { } private drawLegendEntries(container: ContainerElement): void { - select(container) + const containerSelection = select(container); + if (!this.isGrouped) { + containerSelection + .append('div') + .classed('legend-entries', true) + .selectAll('.legend-entry') + .data(this.series.filter(series => !series.hide)) + .enter() + .each((_, index, elements) => this.drawLegendEntry(elements[index])); + } else { + containerSelection + .selectAll('.legend-entries') + .data(Object.values(this.groupedSeries)) + .enter() + .append('div') + .classed('legend-entries', true) + .each((seriesGroup, index, elements) => this.drawLegendEntriesTitleAndValues(seriesGroup, elements[index])); + } + } + + private drawLegendEntriesTitleAndValues(seriesGroup: Series[], element: HTMLDivElement): void { + const legendEntriesSelection = select(element); + legendEntriesSelection + .selectAll('.legend-entries-title') + .data([seriesGroup]) + .enter() + .append('div') + .classed('legend-entries-title', true) + .text(group => `${group[0].groupName}:`) + .on('click', () => this.updateActiveSeriesGroup(seriesGroup)); + + legendEntriesSelection .append('div') - .classed('legend-entries', true) + .classed('legend-entry-values', true) .selectAll('.legend-entry') - .data(this.series.filter(series => !series.hide)) + .data(seriesGroup) .enter() .each((_, index, elements) => this.drawLegendEntry(elements[index])); } @@ -107,7 +145,8 @@ export class CartesianLegend { return select(hostElement) .append('div') .classed(CartesianLegend.CSS_CLASS, true) - .classed(`position-${legendPosition}`, true); + .classed(`position-${legendPosition}`, true) + .classed('grouped', this.isGrouped); } private drawLegendEntry(element: EnterElement): Selection, null, undefined> { @@ -128,6 +167,21 @@ export class CartesianLegend { private updateLegendClassesAndStyle(): void { const legendElementSelection = select(this.legendElement!); + if (this.isGrouped) { + // Legend entries + select(this.legendElement!) + .selectAll('.legend-entries') + .classed(CartesianLegend.ACTIVE_CSS_CLASS, seriesGroup => + this.isThisLegendSeriesGroupActive(seriesGroup as Series[]) + ); + + // Legend entry title + select(this.legendElement!) + .selectAll('.legend-entries-title') + .classed(CartesianLegend.ACTIVE_CSS_CLASS, seriesGroup => + this.isThisLegendSeriesGroupActive(seriesGroup as Series[]) + ); + } // Legend entry symbol legendElementSelection @@ -201,14 +255,29 @@ export class CartesianLegend { this.activeSeriesSubject.next(this.activeSeries); } - private updateActiveSeries(seriesEntry: Series): void { + private updateActiveSeriesGroup(seriesGroup: Series[]): void { if (!this.isSelectionModeOn) { - this.activeSeries = [seriesEntry]; + this.activeSeries = [...seriesGroup]; this.isSelectionModeOn = true; - } else if (this.isThisLegendEntryActive(seriesEntry)) { - this.activeSeries = this.activeSeries.filter(series => series !== seriesEntry); + } else if (!this.isThisLegendSeriesGroupActive(seriesGroup)) { + this.activeSeries = this.activeSeries.filter(series => !seriesGroup.includes(series)); + this.activeSeries.push(...seriesGroup); } else { - this.activeSeries.push(seriesEntry); + this.activeSeries = this.activeSeries.filter(series => !seriesGroup.includes(series)); + } + this.updateLegendClassesAndStyle(); + this.updateResetElementVisibility(!this.isSelectionModeOn); + this.activeSeriesSubject.next(this.activeSeries); + } + + private updateActiveSeries(series: Series): void { + if (!this.isSelectionModeOn) { + this.activeSeries = [series]; + this.isSelectionModeOn = true; + } else if (this.isThisLegendEntryActive(series)) { + this.activeSeries = this.activeSeries.filter(seriesEntry => series !== seriesEntry); + } else { + this.activeSeries.push(series); } this.updateLegendClassesAndStyle(); this.updateResetElementVisibility(!this.isSelectionModeOn); @@ -218,4 +287,8 @@ export class CartesianLegend { private isThisLegendEntryActive(seriesEntry: Series): boolean { return this.activeSeries.includes(seriesEntry); } + + private isThisLegendSeriesGroupActive(seriesGroup: Series[]): boolean { + return !this.isSelectionModeOn ? false : seriesGroup.every(series => this.activeSeries.includes(series)); + } } diff --git a/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.test.ts b/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.test.ts index dc492c64f..b901b1ba7 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.test.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.test.ts @@ -134,7 +134,8 @@ describe('Explore cartesian data source model', () => { timestamp: secondIntervalTime, value: 15 } - ] + ], + groupName: 'sum(foo)' } ], bands: [] @@ -198,7 +199,8 @@ describe('Explore cartesian data source model', () => { data: [ ['first', 10], ['second', 15] - ] + ], + groupName: 'sum(foo)' } ], bands: [] @@ -280,7 +282,7 @@ describe('Explore cartesian data source model', () => { series: [ { color: 'first color', - name: 'sum(foo): first', + name: 'first', type: CartesianSeriesVisualizationType.Area, data: [ { @@ -295,11 +297,12 @@ describe('Explore cartesian data source model', () => { timestamp: secondIntervalTime, value: 15 } - ] + ], + groupName: 'sum(foo)' }, { color: 'second color', - name: 'sum(foo): second', + name: 'second', type: CartesianSeriesVisualizationType.Area, data: [ { @@ -314,7 +317,8 @@ describe('Explore cartesian data source model', () => { timestamp: secondIntervalTime, value: 25 } - ] + ], + groupName: 'sum(foo)' } ], bands: [] diff --git a/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.ts b/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.ts index 2cafb5bea..27fa2e81e 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.ts @@ -134,11 +134,9 @@ export abstract class ExploreCartesianDataSourceModel extends GraphQlDataSourceM data: result.data, units: obj.attribute.units !== '' ? obj.attribute.units : undefined, type: request.series.find(series => series.specification === result.spec)!.visualizationOptions.type, - name: isEmpty(result.groupName) - ? obj.specDisplayName - : request.useGroupName - ? result.groupName! - : `${obj.specDisplayName}: ${result.groupName}`, + name: !isEmpty(result.groupName) ? result.groupName! : obj.specDisplayName, + groupName: + !isEmpty(result.groupName) && (request.useGroupName ?? false) ? result.groupName! : obj.specDisplayName, color: color })) ); diff --git a/projects/observability/src/shared/dashboard/data/graphql/explorer-visualization/explorer-visualization-cartesian-data-source.model.test.ts b/projects/observability/src/shared/dashboard/data/graphql/explorer-visualization/explorer-visualization-cartesian-data-source.model.test.ts index 5a251f1db..836deda98 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/explorer-visualization/explorer-visualization-cartesian-data-source.model.test.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/explorer-visualization/explorer-visualization-cartesian-data-source.model.test.ts @@ -153,7 +153,8 @@ describe('Explorer Visualization cartesian data source model', () => { timestamp: secondIntervalTime, value: 15 } - ] + ], + groupName: 'sum(foo)' } ], bands: [] @@ -218,7 +219,8 @@ describe('Explorer Visualization cartesian data source model', () => { data: [ ['first', 10], ['second', 15] - ] + ], + groupName: 'sum(foo)' } ], bands: [] @@ -302,7 +304,7 @@ describe('Explorer Visualization cartesian data source model', () => { series: [ { color: 'first color', - name: 'sum(foo): first', + name: 'first', type: CartesianSeriesVisualizationType.Area, data: [ { @@ -317,11 +319,12 @@ describe('Explorer Visualization cartesian data source model', () => { timestamp: secondIntervalTime, value: 15 } - ] + ], + groupName: 'sum(foo)' }, { color: 'second color', - name: 'sum(foo): second', + name: 'second', type: CartesianSeriesVisualizationType.Area, data: [ { @@ -336,7 +339,8 @@ describe('Explorer Visualization cartesian data source model', () => { timestamp: secondIntervalTime, value: 25 } - ] + ], + groupName: 'sum(foo)' } ], bands: []