Skip to content

Commit ce06026

Browse files
authored
Merge branch 'main' into table-filter-show-placeholder-when-selected
2 parents 11db1b0 + f46c9d8 commit ce06026

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
@@ -171,6 +171,47 @@ describe('Cartesian Chart component', () => {
171171
expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1);
172172
}));
173173

174+
test('should have correct active series', fakeAsync(() => {
175+
const chart = createHost(`<ht-cartesian-chart [series]="series" [legend]="legend"></ht-cartesian-chart>`, {
176+
hostProps: {
177+
series: [],
178+
legend: undefined
179+
}
180+
});
181+
chart.setHostInput({
182+
series: [
183+
{
184+
data: [[1, 2]],
185+
name: 'test series 1',
186+
color: 'blue',
187+
type: CartesianSeriesVisualizationType.Column,
188+
stacking: true
189+
},
190+
{
191+
data: [[1, 6]],
192+
name: 'test series 2',
193+
color: 'red',
194+
type: CartesianSeriesVisualizationType.Column,
195+
stacking: true
196+
}
197+
],
198+
legend: LegendPosition.Bottom
199+
});
200+
tick();
201+
expect(chart.queryAll(CartesianLegend.CSS_SELECTOR, { root: true }).length).toBe(1);
202+
expect(chart.queryAll('.legend-entry').length).toBe(2);
203+
expect(chart.query('.reset.hidden')).toExist();
204+
205+
const legendEntryTexts = chart.queryAll('.legend-text');
206+
chart.click(legendEntryTexts[0]);
207+
tick();
208+
expect(chart.query('.reset.hidden')).not.toExist();
209+
210+
chart.click(chart.query('.reset') as Element);
211+
tick();
212+
expect(chart.query('.reset.hidden')).toExist();
213+
}));
214+
174215
test('should render column chart', fakeAsync(() => {
175216
const chart = createHost(`<ht-cartesian-chart [series]="series"></ht-cartesian-chart>`, {
176217
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
@@ -1,6 +1,7 @@
11
import { Injector, Renderer2 } from '@angular/core';
22
import { TimeRange } from '@hypertrace/common';
33
import { ContainerElement, mouse, select } from 'd3-selection';
4+
import { Subscription } from 'rxjs';
45
import { LegendPosition } from '../../../legend/legend.component';
56
import { ChartTooltipRef } from '../../../utils/chart-tooltip/chart-tooltip-popover';
67
import { D3UtilService } from '../../../utils/d3/d3-util.service';
@@ -35,6 +36,7 @@ import { CartesianScaleBuilder } from '../scale/cartesian-scale-builder';
3536
// tslint:disable:max-file-line-count
3637
export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
3738
public static DATA_SERIES_CLASS: string = 'data-series';
39+
public static CHART_VISUALIZATION_CLASS: string = 'chart-visualization';
3840

3941
protected readonly margin: number = 16;
4042
protected readonly axisHeight: number = 16;
@@ -45,7 +47,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
4547
protected chartBackgroundSvgElement?: SVGSVGElement;
4648
protected dataElement?: ContainerElement;
4749
protected mouseEventContainer?: SVGSVGElement;
48-
protected legend?: CartesianLegend;
50+
protected legend?: CartesianLegend<TData>;
4951
protected tooltip?: ChartTooltipRef<TData>;
5052
protected allSeriesData: CartesianData<TData, Series<TData>>[] = [];
5153
protected allCartesianData: CartesianData<TData, Series<TData> | Band<TData>>[] = [];
@@ -65,6 +67,9 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
6567
onEvent: ChartEventListener<TData>;
6668
}[] = [];
6769

70+
private activeSeriesSubscription?: Subscription;
71+
private activeSeries: Series<TData>[] = [];
72+
6873
public constructor(
6974
protected readonly hostElement: Element,
7075
protected readonly injector: Injector,
@@ -80,6 +85,10 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
8085
this.tooltip && this.tooltip.destroy();
8186
this.legend && this.legend.destroy();
8287

88+
if (this.activeSeriesSubscription) {
89+
this.activeSeriesSubscription.unsubscribe();
90+
}
91+
8392
return this;
8493
}
8594

@@ -104,6 +113,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
104113
public withSeries(...series: Series<TData>[]): this {
105114
this.series.length = 0;
106115
this.series.push(...series);
116+
this.activeSeries = [...series];
107117

108118
this.seriesSummaries.length = 0;
109119
this.seriesSummaries.push(
@@ -273,6 +283,10 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
273283

274284
private updateData(): void {
275285
this.drawLegend();
286+
this.drawVisualizations();
287+
}
288+
289+
private drawVisualizations(): void {
276290
this.buildVisualizations();
277291
this.drawChartBackground();
278292
this.drawAxes();
@@ -283,6 +297,16 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
283297
this.setupEventListeners();
284298
}
285299

300+
private redrawVisualization(): void {
301+
const chartViz = select(this.chartContainerElement!).selectAll(
302+
`.${DefaultCartesianChart.CHART_VISUALIZATION_CLASS}`
303+
);
304+
if (chartViz.nodes().length > 0) {
305+
chartViz.remove();
306+
this.drawVisualizations();
307+
}
308+
}
309+
286310
private moveDataOnTopOfAxes(): void {
287311
if (!this.dataElement) {
288312
return;
@@ -338,19 +362,26 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
338362
return;
339363
}
340364

341-
new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.series).updateMessage();
365+
new CartesianNoDataMessage(this.chartBackgroundSvgElement, this.activeSeries).updateMessage();
342366
}
343367

344368
private drawLegend(): void {
345369
if (this.chartContainerElement) {
346370
if (this.legendPosition !== undefined && this.legendPosition !== LegendPosition.None) {
347-
this.legend = new CartesianLegend(this.series, this.injector, this.intervalData, this.seriesSummaries).draw(
348-
this.chartContainerElement,
349-
this.legendPosition
350-
);
371+
this.legend = new CartesianLegend<TData>(
372+
this.activeSeries,
373+
this.injector,
374+
this.intervalData,
375+
this.seriesSummaries
376+
).draw(this.chartContainerElement, this.legendPosition);
377+
this.activeSeriesSubscription?.unsubscribe();
378+
this.activeSeriesSubscription = this.legend.activeSeries$.subscribe(activeSeries => {
379+
this.activeSeries = activeSeries;
380+
this.redrawVisualization();
381+
});
351382
} else {
352383
// The legend also contains the interval selector, so even without a legend we need to create an element for that
353-
this.legend = new CartesianLegend([], this.injector, this.intervalData, this.seriesSummaries).draw(
384+
this.legend = new CartesianLegend<TData>([], this.injector, this.intervalData, this.seriesSummaries).draw(
354385
this.chartContainerElement,
355386
LegendPosition.None
356387
);
@@ -370,6 +401,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
370401

371402
this.chartBackgroundSvgElement = select(this.chartContainerElement)
372403
.append('svg')
404+
.classed(DefaultCartesianChart.CHART_VISUALIZATION_CLASS, true)
373405
.style('position', 'absolute')
374406
.attr('width', `${chartBox.width}px`)
375407
.attr('height', `${chartBox.height}px`)
@@ -430,7 +462,7 @@ export class DefaultCartesianChart<TData> implements CartesianChart<TData> {
430462

431463
private buildVisualizations(): void {
432464
this.allSeriesData = [
433-
...this.series.map(series => this.getChartSeriesVisualization(series)),
465+
...this.activeSeries.map(series => this.getChartSeriesVisualization(series)),
434466
...this.bands.flatMap(band => [
435467
// Need to add bands as series to get tooltips
436468
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)