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 709b50dc6..202eb55c4 100644 --- a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss +++ b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss @@ -55,6 +55,7 @@ width: 100%; position: absolute; min-height: 48px; + padding-bottom: 20px; &.position-none { display: none; @@ -118,13 +119,44 @@ } .legend-text { - fill: $gray-5; font-size: 14px; padding-left: 2px; + + &.selectable { + cursor: pointer; + } + + &.default { + color: $gray-9; + } + + &.active { + color: $blue-4; + } + + &.inactive { + color: $gray-5; + } } } } + .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 92c0c91ab..3e9b49904 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 @@ -171,6 +171,47 @@ describe('Cartesian Chart component', () => { expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1); })); + test('should have correct active series', fakeAsync(() => { + const chart = createHost(``, { + hostProps: { + series: [], + legend: undefined + } + }); + chart.setHostInput({ + series: [ + { + data: [[1, 2]], + name: 'test series 1', + color: 'blue', + type: CartesianSeriesVisualizationType.Column, + stacking: true + }, + { + data: [[1, 6]], + name: 'test series 2', + color: 'red', + type: CartesianSeriesVisualizationType.Column, + stacking: true + } + ], + legend: LegendPosition.Bottom + }); + tick(); + expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1); + expect(chart.queryAll('.legend-entry').length).toBe(2); + expect(chart.query('.reset.hidden')).toExist(); + + const legendEntryTexts = chart.queryAll('.legend-text'); + chart.click(legendEntryTexts[0]); + tick(); + expect(chart.query('.reset.hidden')).not.toExist(); + + chart.click(chart.query('.reset') as Element); + tick(); + expect(chart.query('.reset.hidden')).toExist(); + })); + test('should render column chart', fakeAsync(() => { const chart = createHost(``, { hostProps: { diff --git a/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts b/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts index 79b787686..2ba8c615b 100644 --- a/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts +++ b/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts @@ -1,6 +1,7 @@ import { Injector, Renderer2 } from '@angular/core'; import { TimeRange } from '@hypertrace/common'; import { ContainerElement, mouse, select } from 'd3-selection'; +import { Subscription } from 'rxjs'; import { LegendPosition } from '../../../legend/legend.component'; import { ChartTooltipRef } from '../../../utils/chart-tooltip/chart-tooltip-popover'; import { D3UtilService } from '../../../utils/d3/d3-util.service'; @@ -35,6 +36,7 @@ import { CartesianScaleBuilder } from '../scale/cartesian-scale-builder'; // tslint:disable:max-file-line-count export class DefaultCartesianChart implements CartesianChart { public static DATA_SERIES_CLASS: string = 'data-series'; + public static CHART_VISUALIZATION_CLASS: string = 'chart-visualization'; protected readonly margin: number = 16; protected readonly axisHeight: number = 16; @@ -45,7 +47,7 @@ export class DefaultCartesianChart implements CartesianChart { protected chartBackgroundSvgElement?: SVGSVGElement; protected dataElement?: ContainerElement; protected mouseEventContainer?: SVGSVGElement; - protected legend?: CartesianLegend; + protected legend?: CartesianLegend; protected tooltip?: ChartTooltipRef; protected allSeriesData: CartesianData>[] = []; protected allCartesianData: CartesianData | Band>[] = []; @@ -65,6 +67,9 @@ export class DefaultCartesianChart implements CartesianChart { onEvent: ChartEventListener; }[] = []; + private activeSeriesSubscription?: Subscription; + private activeSeries: Series[] = []; + public constructor( protected readonly hostElement: Element, protected readonly injector: Injector, @@ -80,6 +85,10 @@ export class DefaultCartesianChart implements CartesianChart { this.tooltip && this.tooltip.destroy(); this.legend && this.legend.destroy(); + if (this.activeSeriesSubscription) { + this.activeSeriesSubscription.unsubscribe(); + } + return this; } @@ -104,6 +113,7 @@ export class DefaultCartesianChart implements CartesianChart { public withSeries(...series: Series[]): this { this.series.length = 0; this.series.push(...series); + this.activeSeries = [...series]; this.seriesSummaries.length = 0; this.seriesSummaries.push( @@ -273,6 +283,10 @@ export class DefaultCartesianChart implements CartesianChart { private updateData(): void { this.drawLegend(); + this.drawVisualizations(); + } + + private drawVisualizations(): void { this.buildVisualizations(); this.drawChartBackground(); this.drawAxes(); @@ -283,6 +297,16 @@ export class DefaultCartesianChart implements CartesianChart { this.setupEventListeners(); } + private redrawVisualization(): void { + const chartViz = select(this.chartContainerElement!).selectAll( + `.${DefaultCartesianChart.CHART_VISUALIZATION_CLASS}` + ); + if (chartViz.nodes().length > 0) { + chartViz.remove(); + this.drawVisualizations(); + } + } + private moveDataOnTopOfAxes(): void { if (!this.dataElement) { return; @@ -338,19 +362,26 @@ export class DefaultCartesianChart implements CartesianChart { return; } - new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.series).updateMessage(); + new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.activeSeries).updateMessage(); } private drawLegend(): void { if (this.chartContainerElement) { if (this.legendPosition !== undefined && this.legendPosition !== LegendPosition.None) { - this.legend = new CartesianLegend(this.series, this.injector, this.intervalData, this.seriesSummaries).draw( - this.chartContainerElement, - this.legendPosition - ); + this.legend = new CartesianLegend( + this.activeSeries, + this.injector, + this.intervalData, + this.seriesSummaries + ).draw(this.chartContainerElement, this.legendPosition); + this.activeSeriesSubscription?.unsubscribe(); + this.activeSeriesSubscription = this.legend.activeSeries$.subscribe(activeSeries => { + this.activeSeries = activeSeries; + this.redrawVisualization(); + }); } else { // The legend also contains the interval selector, so even without a legend we need to create an element for that - this.legend = new CartesianLegend([], this.injector, this.intervalData, this.seriesSummaries).draw( + this.legend = new CartesianLegend([], this.injector, this.intervalData, this.seriesSummaries).draw( this.chartContainerElement, LegendPosition.None ); @@ -370,6 +401,7 @@ export class DefaultCartesianChart implements CartesianChart { this.chartBackgroundSvgElement = select(this.chartContainerElement) .append('svg') + .classed(DefaultCartesianChart.CHART_VISUALIZATION_CLASS, true) .style('position', 'absolute') .attr('width', `${chartBox.width}px`) .attr('height', `${chartBox.height}px`) @@ -430,7 +462,7 @@ export class DefaultCartesianChart implements CartesianChart { private buildVisualizations(): void { this.allSeriesData = [ - ...this.series.map(series => this.getChartSeriesVisualization(series)), + ...this.activeSeries.map(series => this.getChartSeriesVisualization(series)), ...this.bands.flatMap(band => [ // Need to add bands as series to get tooltips this.getChartSeriesVisualization(band.upper), 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 f93945369..2321c7ad7 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,8 @@ import { ComponentRef, Injector } from '@angular/core'; -import { DynamicComponentService } from '@hypertrace/common'; +import { Color, DynamicComponentService } from '@hypertrace/common'; import { ContainerElement, EnterElement, select, Selection } from 'd3-selection'; +import { Observable, Subject } from 'rxjs'; +import { startWith } from 'rxjs/operators'; import { LegendPosition } from '../../../legend/legend.component'; import { Series, Summary } from '../../chart'; import { @@ -10,20 +12,35 @@ import { } from './cartesian-interval-control.component'; import { CartesianSummaryComponent, SUMMARIES_DATA } from './cartesian-summary.component'; -export class CartesianLegend { +export class CartesianLegend { private static readonly CSS_CLASS: string = 'legend'; + private static readonly RESET_CSS_CLASS: string = 'reset'; + private static readonly SELECTABLE_CSS_CLASS: string = 'selectable'; + private static readonly DEFAULT_CSS_CLASS: string = 'default'; + private static readonly ACTIVE_CSS_CLASS: string = 'active'; + private static readonly INACTIVE_CSS_CLASS: string = 'inactive'; public static readonly CSS_SELECTOR: string = `.${CartesianLegend.CSS_CLASS}`; + public readonly activeSeries$: Observable[]>; + private readonly activeSeriesSubject: Subject[]> = new Subject(); + private readonly initialSeries: Series[]; + + private isSelectionModeOn: boolean = false; private legendElement?: HTMLDivElement; + private activeSeries: Series[]; private intervalControl?: ComponentRef; private summaryControl?: ComponentRef; public constructor( - private readonly series: Series<{}>[], + private readonly series: Series[], private readonly injector: Injector, private readonly intervalData?: CartesianIntervalData, private readonly summaries: Summary[] = [] - ) {} + ) { + this.activeSeries = [...this.series]; + this.initialSeries = [...this.series]; + this.activeSeries$ = this.activeSeriesSubject.asObservable().pipe(startWith(this.series)); + } public draw(hostElement: Element, position: LegendPosition): this { this.legendElement = this.drawLegendContainer(hostElement, position, this.intervalData !== undefined).node()!; @@ -33,6 +50,7 @@ export class CartesianLegend { } this.drawLegendEntries(this.legendElement); + this.drawReset(this.legendElement); if (this.intervalData) { this.intervalControl = this.drawIntervalControl(this.legendElement, this.intervalData); @@ -50,6 +68,20 @@ export class CartesianLegend { this.summaryControl && this.summaryControl.destroy(); } + private drawReset(container: ContainerElement): void { + select(container) + .append('span') + .classed(CartesianLegend.RESET_CSS_CLASS, true) + .text('Reset') + .on('click', () => this.disableSelectionMode()); + + this.updateResetElementVisibility(!this.isSelectionModeOn); + } + + private updateResetElementVisibility(isHidden: boolean): void { + select(this.legendElement!).select(`span.${CartesianLegend.RESET_CSS_CLASS}`).classed('hidden', isHidden); + } + private drawLegendEntries(container: ContainerElement): void { select(container) .append('div') @@ -78,20 +110,47 @@ export class CartesianLegend { .classed(`position-${legendPosition}`, true); } - private drawLegendEntry(element: EnterElement): Selection, null, undefined> { - const legendEntry = select>(element).append('div').classed('legend-entry', true); + private drawLegendEntry(element: EnterElement): Selection, null, undefined> { + const legendEntry = select>(element).append('div').classed('legend-entry', true); this.appendLegendSymbol(legendEntry); - legendEntry .append('span') .classed('legend-text', true) - .text(series => series.name); + .classed(CartesianLegend.SELECTABLE_CSS_CLASS, this.series.length > 1) + .text(series => series.name) + .on('click', series => (this.series.length > 1 ? this.updateActiveSeries(series) : undefined)); + + this.updateLegendClassesAndStyle(); return legendEntry; } - private appendLegendSymbol(selection: Selection, null, undefined>): void { + private updateLegendClassesAndStyle(): void { + const legendElementSelection = select(this.legendElement!); + + // Legend entry symbol + legendElementSelection + .selectAll('.legend-symbol circle') + .style('fill', series => + !this.isThisLegendEntryActive(series as Series) ? Color.Gray3 : (series as Series).color + ); + + // Legend entry value text + legendElementSelection + .selectAll('span.legend-text') + .classed(CartesianLegend.DEFAULT_CSS_CLASS, !this.isSelectionModeOn) + .classed( + CartesianLegend.ACTIVE_CSS_CLASS, + series => this.isSelectionModeOn && this.isThisLegendEntryActive(series as Series) + ) + .classed( + CartesianLegend.INACTIVE_CSS_CLASS, + series => this.isSelectionModeOn && !this.isThisLegendEntryActive(series as Series) + ); + } + + private appendLegendSymbol(selection: Selection, null, undefined>): void { selection .append('svg') .classed('legend-symbol', true) @@ -133,4 +192,30 @@ export class CartesianLegend { }) ); } + + private disableSelectionMode(): void { + this.activeSeries = [...this.initialSeries]; + this.isSelectionModeOn = false; + this.updateLegendClassesAndStyle(); + this.updateResetElementVisibility(!this.isSelectionModeOn); + this.activeSeriesSubject.next(this.activeSeries); + } + + private updateActiveSeries(seriesEntry: Series): void { + if (!this.isSelectionModeOn) { + this.activeSeries = [seriesEntry]; + this.isSelectionModeOn = true; + } else if (this.isThisLegendEntryActive(seriesEntry)) { + this.activeSeries = this.activeSeries.filter(series => series !== seriesEntry); + } else { + this.activeSeries.push(seriesEntry); + } + this.updateLegendClassesAndStyle(); + this.updateResetElementVisibility(!this.isSelectionModeOn); + this.activeSeriesSubject.next(this.activeSeries); + } + + private isThisLegendEntryActive(seriesEntry: Series): boolean { + return this.activeSeries.includes(seriesEntry); + } }