Skip to content
This repository was archived by the owner on Nov 14, 2023. It is now read-only.

Commit 205d485

Browse files
itssharmasandeepjaywalker21
authored andcommitted
feat: toggle able legend for cartesian chart (hypertrace#1270)
1 parent 13b4a6a commit 205d485

File tree

4 files changed

+208
-18
lines changed

4 files changed

+208
-18
lines changed

projects/observability/src/shared/components/cartesian/cartesian-chart.component.scss

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
width: 100%;
5656
position: absolute;
5757
min-height: 48px;
58+
padding-bottom: 20px;
5859

5960
&.position-none {
6061
display: none;
@@ -118,13 +119,44 @@
118119
}
119120

120121
.legend-text {
121-
fill: $gray-5;
122122
font-size: 14px;
123123
padding-left: 2px;
124+
125+
&.selectable {
126+
cursor: pointer;
127+
}
128+
129+
&.default {
130+
color: $gray-9;
131+
}
132+
133+
&.active {
134+
color: $blue-4;
135+
}
136+
137+
&.inactive {
138+
color: $gray-5;
139+
}
124140
}
125141
}
126142
}
127143

144+
.reset {
145+
@include font-title($blue-4);
146+
cursor: pointer;
147+
position: absolute;
148+
bottom: 0;
149+
right: 0;
150+
151+
&.hidden {
152+
display: none;
153+
}
154+
155+
&:hover {
156+
color: $blue-6;
157+
}
158+
}
159+
128160
.interval-control {
129161
padding: 0 8px;
130162
}

projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,47 @@ describe('Cartesian Chart component', () => {
173173
expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1);
174174
}));
175175

176+
test('should have correct active series', fakeAsync(() => {
177+
const chart = createHost(`<ht-cartesian-chart [series]="series" [legend]="legend"></ht-cartesian-chart>`, {
178+
hostProps: {
179+
series: [],
180+
legend: undefined
181+
}
182+
});
183+
chart.setHostInput({
184+
series: [
185+
{
186+
data: [[1, 2]],
187+
name: 'test series 1',
188+
color: 'blue',
189+
type: CartesianSeriesVisualizationType.Column,
190+
stacking: true
191+
},
192+
{
193+
data: [[1, 6]],
194+
name: 'test series 2',
195+
color: 'red',
196+
type: CartesianSeriesVisualizationType.Column,
197+
stacking: true
198+
}
199+
],
200+
legend: LegendPosition.Bottom
201+
});
202+
tick();
203+
expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1);
204+
expect(chart.queryAll('.legend-entry').length).toBe(2);
205+
expect(chart.query('.reset.hidden')).toExist();
206+
207+
const legendEntryTexts = chart.queryAll('.legend-text');
208+
chart.click(legendEntryTexts[0]);
209+
tick();
210+
expect(chart.query('.reset.hidden')).not.toExist();
211+
212+
chart.click(chart.query('.reset') as Element);
213+
tick();
214+
expect(chart.query('.reset.hidden')).toExist();
215+
}));
216+
176217
test('should render column chart', fakeAsync(() => {
177218
const chart = createHost(`<ht-cartesian-chart [series]="series"></ht-cartesian-chart>`, {
178219
hostProps: {

projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TimeRange, TimeRangeService } from '@hypertrace/common';
33
import { BrushBehavior, brushX, D3BrushEvent } from 'd3-brush';
44
// tslint:disable
55
import { ContainerElement, event as d3CurrentEvent, mouse, select } from 'd3-selection';
6+
import { Subscription } from 'rxjs';
67
import { LegendPosition } from '../../../legend/legend.component';
78
import { ChartTooltipRef } from '../../../utils/chart-tooltip/chart-tooltip-popover';
89
import { D3UtilService } from '../../../utils/d3/d3-util.service';
@@ -37,6 +38,7 @@ import { CartesianScaleBuilder } from '../scale/cartesian-scale-builder';
3738
// tslint:disable:max-file-line-count
3839
export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
3940
public static DATA_SERIES_CLASS: string = 'data-series';
41+
public static CHART_VISUALIZATION_CLASS: string = 'chart-visualization';
4042

4143
protected readonly margin: number = 16;
4244
protected readonly axisHeight: number = 16;
@@ -47,7 +49,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
4749
protected chartBackgroundSvgElement?: SVGSVGElement;
4850
protected dataElement?: ContainerElement;
4951
protected mouseEventContainer?: SVGSVGElement;
50-
protected legend?: CartesianLegend;
52+
protected legend?: CartesianLegend<TData>;
5153
protected tooltip?: ChartTooltipRef<TData>;
5254
protected allSeriesData: CartesianData<TData, Series<TData>>[] = [];
5355
protected allCartesianData: CartesianData<TData, Series<TData> | Band<TData>>[] = [];
@@ -67,6 +69,9 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
6769
onEvent: ChartEventListener<TData>;
6870
}[] = [];
6971

72+
private activeSeriesSubscription?: Subscription;
73+
private activeSeries: Series<TData>[] = [];
74+
7075
public constructor(
7176
protected readonly hostElement: Element,
7277
protected readonly injector: Injector,
@@ -114,6 +119,10 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
114119
this.tooltip && this.tooltip.destroy();
115120
this.legend && this.legend.destroy();
116121

122+
if (this.activeSeriesSubscription) {
123+
this.activeSeriesSubscription.unsubscribe();
124+
}
125+
117126
return this;
118127
}
119128

@@ -138,6 +147,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
138147
public withSeries(...series: Series<TData>[]): this {
139148
this.series.length = 0;
140149
this.series.push(...series);
150+
this.activeSeries = [...series];
141151

142152
this.seriesSummaries.length = 0;
143153
this.seriesSummaries.push(
@@ -311,6 +321,10 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
311321

312322
private updateData(): void {
313323
this.drawLegend();
324+
this.drawVisualizations();
325+
}
326+
327+
private drawVisualizations(): void {
314328
this.buildVisualizations();
315329
this.drawChartBackground();
316330
this.drawAxes();
@@ -335,6 +349,16 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
335349
select(this.mouseEventContainer!).append('g').attr('class', 'brush').call(brushBehaviour);
336350
}
337351

352+
private redrawVisualization(): void {
353+
const chartViz = select(this.chartContainerElement!).selectAll(
354+
`.${DefaultCartesianChart.CHART_VISUALIZATION_CLASS}`
355+
);
356+
if (chartViz.nodes().length > 0) {
357+
chartViz.remove();
358+
this.drawVisualizations();
359+
}
360+
}
361+
338362
private moveDataOnTopOfAxes(): void {
339363
if (!this.dataElement) {
340364
return;
@@ -390,19 +414,26 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
390414
return;
391415
}
392416

393-
new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.series).updateMessage();
417+
new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.activeSeries).updateMessage();
394418
}
395419

396420
private drawLegend(): void {
397421
if (this.chartContainerElement) {
398422
if (this.legendPosition !== undefined && this.legendPosition !== LegendPosition.None) {
399-
this.legend = new CartesianLegend(this.series, this.injector, this.intervalData, this.seriesSummaries).draw(
400-
this.chartContainerElement,
401-
this.legendPosition
402-
);
423+
this.legend = new CartesianLegend<TData>(
424+
this.activeSeries,
425+
this.injector,
426+
this.intervalData,
427+
this.seriesSummaries
428+
).draw(this.chartContainerElement, this.legendPosition);
429+
this.activeSeriesSubscription?.unsubscribe();
430+
this.activeSeriesSubscription = this.legend.activeSeries$.subscribe(activeSeries => {
431+
this.activeSeries = activeSeries;
432+
this.redrawVisualization();
433+
});
403434
} else {
404435
// The legend also contains the interval selector, so even without a legend we need to create an element for that
405-
this.legend = new CartesianLegend([], this.injector, this.intervalData, this.seriesSummaries).draw(
436+
this.legend = new CartesianLegend<TData>([], this.injector, this.intervalData, this.seriesSummaries).draw(
406437
this.chartContainerElement,
407438
LegendPosition.None
408439
);
@@ -422,6 +453,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
422453

423454
this.chartBackgroundSvgElement = select(this.chartContainerElement)
424455
.append('svg')
456+
.classed(DefaultCartesianChart.CHART_VISUALIZATION_CLASS, true)
425457
.style('position', 'absolute')
426458
.attr('width', `${chartBox.width}px`)
427459
.attr('height', `${chartBox.height}px`)
@@ -485,7 +517,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
485517

486518
private buildVisualizations(): void {
487519
this.allSeriesData = [
488-
...this.series.map(series => this.getChartSeriesVisualization(series)),
520+
...this.activeSeries.map(series => this.getChartSeriesVisualization(series)),
489521
...this.bands.flatMap(band => [
490522
// Need to add bands as series to get tooltips
491523
this.getChartSeriesVisualization(band.upper),

projects/observability/src/shared/components/cartesian/d3/legend/cartesian-legend.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ComponentRef, Injector } from '@angular/core';
2-
import { DynamicComponentService } from '@hypertrace/common';
2+
import { Color, DynamicComponentService } from '@hypertrace/common';
33
import { ContainerElement, EnterElement, select, Selection } from 'd3-selection';
4+
import { Observable, Subject } from 'rxjs';
5+
import { startWith } from 'rxjs/operators';
46
import { LegendPosition } from '../../../legend/legend.component';
57
import { Series, Summary } from '../../chart';
68
import {
@@ -10,20 +12,35 @@ import {
1012
} from './cartesian-interval-control.component';
1113
import { CartesianSummaryComponent, SUMMARIES_DATA } from './cartesian-summary.component';
1214

13-
export class CartesianLegend {
15+
export class CartesianLegend<TData> {
1416
private static readonly CSS_CLASS: string = 'legend';
17+
private static readonly RESET_CSS_CLASS: string = 'reset';
18+
private static readonly SELECTABLE_CSS_CLASS: string = 'selectable';
19+
private static readonly DEFAULT_CSS_CLASS: string = 'default';
20+
private static readonly ACTIVE_CSS_CLASS: string = 'active';
21+
private static readonly INACTIVE_CSS_CLASS: string = 'inactive';
1522
public static readonly CSS_SELECTOR: string = `.${CartesianLegend.CSS_CLASS}`;
1623

24+
public readonly activeSeries$: Observable<Series<TData>[]>;
25+
private readonly activeSeriesSubject: Subject<Series<TData>[]> = new Subject();
26+
private readonly initialSeries: Series<TData>[];
27+
28+
private isSelectionModeOn: boolean = false;
1729
private legendElement?: HTMLDivElement;
30+
private activeSeries: Series<TData>[];
1831
private intervalControl?: ComponentRef<unknown>;
1932
private summaryControl?: ComponentRef<unknown>;
2033

2134
public constructor(
22-
private readonly series: Series<{}>[],
35+
private readonly series: Series<TData>[],
2336
private readonly injector: Injector,
2437
private readonly intervalData?: CartesianIntervalData,
2538
private readonly summaries: Summary[] = []
26-
) {}
39+
) {
40+
this.activeSeries = [...this.series];
41+
this.initialSeries = [...this.series];
42+
this.activeSeries$ = this.activeSeriesSubject.asObservable().pipe(startWith(this.series));
43+
}
2744

2845
public draw(hostElement: Element, position: LegendPosition): this {
2946
this.legendElement = this.drawLegendContainer(hostElement, position, this.intervalData !== undefined).node()!;
@@ -33,6 +50,7 @@ export class CartesianLegend {
3350
}
3451

3552
this.drawLegendEntries(this.legendElement);
53+
this.drawReset(this.legendElement);
3654

3755
if (this.intervalData) {
3856
this.intervalControl = this.drawIntervalControl(this.legendElement, this.intervalData);
@@ -50,6 +68,20 @@ export class CartesianLegend {
5068
this.summaryControl && this.summaryControl.destroy();
5169
}
5270

71+
private drawReset(container: ContainerElement): void {
72+
select(container)
73+
.append('span')
74+
.classed(CartesianLegend.RESET_CSS_CLASS, true)
75+
.text('Reset')
76+
.on('click', () => this.disableSelectionMode());
77+
78+
this.updateResetElementVisibility(!this.isSelectionModeOn);
79+
}
80+
81+
private updateResetElementVisibility(isHidden: boolean): void {
82+
select(this.legendElement!).select(`span.${CartesianLegend.RESET_CSS_CLASS}`).classed('hidden', isHidden);
83+
}
84+
5385
private drawLegendEntries(container: ContainerElement): void {
5486
select(container)
5587
.append('div')
@@ -78,20 +110,47 @@ export class CartesianLegend {
78110
.classed(`position-${legendPosition}`, true);
79111
}
80112

81-
private drawLegendEntry(element: EnterElement): Selection<HTMLDivElement, Series<{}>, null, undefined> {
82-
const legendEntry = select<EnterElement, Series<{}>>(element).append('div').classed('legend-entry', true);
113+
private drawLegendEntry(element: EnterElement): Selection<HTMLDivElement, Series<TData>, null, undefined> {
114+
const legendEntry = select<EnterElement, Series<TData>>(element).append('div').classed('legend-entry', true);
83115

84116
this.appendLegendSymbol(legendEntry);
85-
86117
legendEntry
87118
.append('span')
88119
.classed('legend-text', true)
89-
.text(series => series.name);
120+
.classed(CartesianLegend.SELECTABLE_CSS_CLASS, this.series.length > 1)
121+
.text(series => series.name)
122+
.on('click', series => (this.series.length > 1 ? this.updateActiveSeries(series) : undefined));
123+
124+
this.updateLegendClassesAndStyle();
90125

91126
return legendEntry;
92127
}
93128

94-
private appendLegendSymbol(selection: Selection<HTMLDivElement, Series<{}>, null, undefined>): void {
129+
private updateLegendClassesAndStyle(): void {
130+
const legendElementSelection = select(this.legendElement!);
131+
132+
// Legend entry symbol
133+
legendElementSelection
134+
.selectAll('.legend-symbol circle')
135+
.style('fill', series =>
136+
!this.isThisLegendEntryActive(series as Series<TData>) ? Color.Gray3 : (series as Series<TData>).color
137+
);
138+
139+
// Legend entry value text
140+
legendElementSelection
141+
.selectAll('span.legend-text')
142+
.classed(CartesianLegend.DEFAULT_CSS_CLASS, !this.isSelectionModeOn)
143+
.classed(
144+
CartesianLegend.ACTIVE_CSS_CLASS,
145+
series => this.isSelectionModeOn && this.isThisLegendEntryActive(series as Series<TData>)
146+
)
147+
.classed(
148+
CartesianLegend.INACTIVE_CSS_CLASS,
149+
series => this.isSelectionModeOn && !this.isThisLegendEntryActive(series as Series<TData>)
150+
);
151+
}
152+
153+
private appendLegendSymbol(selection: Selection<HTMLDivElement, Series<TData>, null, undefined>): void {
95154
selection
96155
.append('svg')
97156
.classed('legend-symbol', true)
@@ -133,4 +192,30 @@ export class CartesianLegend {
133192
})
134193
);
135194
}
195+
196+
private disableSelectionMode(): void {
197+
this.activeSeries = [...this.initialSeries];
198+
this.isSelectionModeOn = false;
199+
this.updateLegendClassesAndStyle();
200+
this.updateResetElementVisibility(!this.isSelectionModeOn);
201+
this.activeSeriesSubject.next(this.activeSeries);
202+
}
203+
204+
private updateActiveSeries(seriesEntry: Series<TData>): void {
205+
if (!this.isSelectionModeOn) {
206+
this.activeSeries = [seriesEntry];
207+
this.isSelectionModeOn = true;
208+
} else if (this.isThisLegendEntryActive(seriesEntry)) {
209+
this.activeSeries = this.activeSeries.filter(series => series !== seriesEntry);
210+
} else {
211+
this.activeSeries.push(seriesEntry);
212+
}
213+
this.updateLegendClassesAndStyle();
214+
this.updateResetElementVisibility(!this.isSelectionModeOn);
215+
this.activeSeriesSubject.next(this.activeSeries);
216+
}
217+
218+
private isThisLegendEntryActive(seriesEntry: Series<TData>): boolean {
219+
return this.activeSeries.includes(seriesEntry);
220+
}
136221
}

0 commit comments

Comments
 (0)