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);
+ }
}