Skip to content

Commit bc9ed82

Browse files
feat: grouped cartesian legend (#1288)
1 parent a4c2803 commit bc9ed82

File tree

7 files changed

+175
-29
lines changed

7 files changed

+175
-29
lines changed

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,56 @@
157157
}
158158
}
159159

160+
&.grouped {
161+
flex-direction: column;
162+
gap: 12px;
163+
164+
.legend-entries {
165+
flex-direction: row;
166+
display: flex;
167+
gap: 20px;
168+
border: 1px solid $gray-2;
169+
border-radius: 8px;
170+
padding: 8px 20px;
171+
172+
&.active {
173+
border-color: $blue-5;
174+
}
175+
176+
.legend-entries-title {
177+
@include body-1-regular($gray-5);
178+
width: 200px;
179+
cursor: pointer;
180+
181+
&.active {
182+
color: $blue-5;
183+
}
184+
}
185+
186+
.legend-entry-values {
187+
flex: 1;
188+
display: flex;
189+
flex-wrap: wrap;
190+
}
191+
}
192+
}
193+
194+
.reset {
195+
@include font-title($blue-4);
196+
cursor: pointer;
197+
position: absolute;
198+
bottom: 0;
199+
right: 0;
200+
201+
&.hidden {
202+
display: none;
203+
}
204+
205+
&:hover {
206+
color: $blue-6;
207+
}
208+
}
209+
160210
.interval-control {
161211
padding: 0 8px;
162212
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,16 +182,18 @@ describe('Cartesian Chart component', () => {
182182
series: [
183183
{
184184
data: [[1, 2]],
185-
name: 'test series 1',
185+
name: 'first',
186186
color: 'blue',
187187
type: CartesianSeriesVisualizationType.Column,
188+
groupName: 'test series',
188189
stacking: true
189190
},
190191
{
191192
data: [[1, 6]],
192-
name: 'test series 2',
193+
name: 'second',
193194
color: 'red',
194195
type: CartesianSeriesVisualizationType.Column,
196+
groupName: 'test series',
195197
stacking: true
196198
}
197199
],
@@ -202,14 +204,28 @@ describe('Cartesian Chart component', () => {
202204
expect(chart.queryAll('.legend-entry').length).toBe(2);
203205
expect(chart.query('.reset.hidden')).toExist();
204206

207+
const legendEntriesTitleElement = chart.query('.legend-entries-title') as Element;
208+
chart.click(legendEntriesTitleElement);
209+
tick();
210+
expect(chart.queryAll('.legend-text.active').length).toBe(2);
211+
212+
chart.click(legendEntriesTitleElement);
213+
tick();
214+
expect(chart.queryAll('.legend-text.active').length).toBe(0);
215+
205216
const legendEntryTexts = chart.queryAll('.legend-text');
206217
chart.click(legendEntryTexts[0]);
207218
tick();
219+
expect(chart.queryAll('.legend-text.active').length).toBe(1);
208220
expect(chart.query('.reset.hidden')).not.toExist();
209221

210222
chart.click(chart.query('.reset') as Element);
211223
tick();
212224
expect(chart.query('.reset.hidden')).toExist();
225+
226+
chart.click(legendEntryTexts[0]);
227+
tick();
228+
expect(chart.queryAll('.legend-text.active').length).toBe(1);
213229
}));
214230

215231
test('should render column chart', fakeAsync(() => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface Series<TInterval> {
2626
// Override the default color string using a method that takes data point as input
2727
getColor?(datum?: TInterval): string;
2828
name: string;
29+
groupName?: string;
2930
symbol?: SeriesSymbol;
3031
type: CartesianSeriesVisualizationType;
3132
stacking?: boolean;

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

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ComponentRef, Injector } from '@angular/core';
2-
import { Color, DynamicComponentService } from '@hypertrace/common';
2+
import { Color, Dictionary, DynamicComponentService } from '@hypertrace/common';
33
import { ContainerElement, EnterElement, select, Selection } from 'd3-selection';
4+
import { groupBy } from 'lodash-es';
45
import { Observable, Subject } from 'rxjs';
56
import { startWith } from 'rxjs/operators';
67
import { LegendPosition } from '../../../legend/legend.component';
@@ -24,7 +25,9 @@ export class CartesianLegend<TData> {
2425
public readonly activeSeries$: Observable<Series<TData>[]>;
2526
private readonly activeSeriesSubject: Subject<Series<TData>[]> = new Subject();
2627
private readonly initialSeries: Series<TData>[];
28+
private readonly groupedSeries: Dictionary<Series<TData>[]>;
2729

30+
private readonly isGrouped: boolean = true;
2831
private isSelectionModeOn: boolean = false;
2932
private legendElement?: HTMLDivElement;
3033
private activeSeries: Series<TData>[];
@@ -37,6 +40,10 @@ export class CartesianLegend<TData> {
3740
private readonly intervalData?: CartesianIntervalData,
3841
private readonly summaries: Summary[] = []
3942
) {
43+
this.isGrouped =
44+
this.series.length > 0 && this.series.every(seriesEntry => seriesEntry.groupName !== seriesEntry.name);
45+
this.groupedSeries = this.isGrouped ? groupBy(this.series, seriesEntry => seriesEntry.groupName) : {};
46+
4047
this.activeSeries = [...this.series];
4148
this.initialSeries = [...this.series];
4249
this.activeSeries$ = this.activeSeriesSubject.asObservable().pipe(startWith(this.series));
@@ -83,11 +90,42 @@ export class CartesianLegend<TData> {
8390
}
8491

8592
private drawLegendEntries(container: ContainerElement): void {
86-
select(container)
93+
const containerSelection = select(container);
94+
if (!this.isGrouped) {
95+
containerSelection
96+
.append('div')
97+
.classed('legend-entries', true)
98+
.selectAll('.legend-entry')
99+
.data(this.series.filter(series => !series.hide))
100+
.enter()
101+
.each((_, index, elements) => this.drawLegendEntry(elements[index]));
102+
} else {
103+
containerSelection
104+
.selectAll('.legend-entries')
105+
.data(Object.values(this.groupedSeries))
106+
.enter()
107+
.append('div')
108+
.classed('legend-entries', true)
109+
.each((seriesGroup, index, elements) => this.drawLegendEntriesTitleAndValues(seriesGroup, elements[index]));
110+
}
111+
}
112+
113+
private drawLegendEntriesTitleAndValues(seriesGroup: Series<TData>[], element: HTMLDivElement): void {
114+
const legendEntriesSelection = select(element);
115+
legendEntriesSelection
116+
.selectAll('.legend-entries-title')
117+
.data([seriesGroup])
118+
.enter()
119+
.append('div')
120+
.classed('legend-entries-title', true)
121+
.text(group => `${group[0].groupName}:`)
122+
.on('click', () => this.updateActiveSeriesGroup(seriesGroup));
123+
124+
legendEntriesSelection
87125
.append('div')
88-
.classed('legend-entries', true)
126+
.classed('legend-entry-values', true)
89127
.selectAll('.legend-entry')
90-
.data(this.series.filter(series => !series.hide))
128+
.data(seriesGroup)
91129
.enter()
92130
.each((_, index, elements) => this.drawLegendEntry(elements[index]));
93131
}
@@ -107,7 +145,8 @@ export class CartesianLegend<TData> {
107145
return select(hostElement)
108146
.append('div')
109147
.classed(CartesianLegend.CSS_CLASS, true)
110-
.classed(`position-${legendPosition}`, true);
148+
.classed(`position-${legendPosition}`, true)
149+
.classed('grouped', this.isGrouped);
111150
}
112151

113152
private drawLegendEntry(element: EnterElement): Selection<HTMLDivElement, Series<TData>, null, undefined> {
@@ -128,6 +167,21 @@ export class CartesianLegend<TData> {
128167

129168
private updateLegendClassesAndStyle(): void {
130169
const legendElementSelection = select(this.legendElement!);
170+
if (this.isGrouped) {
171+
// Legend entries
172+
select(this.legendElement!)
173+
.selectAll('.legend-entries')
174+
.classed(CartesianLegend.ACTIVE_CSS_CLASS, seriesGroup =>
175+
this.isThisLegendSeriesGroupActive(seriesGroup as Series<TData>[])
176+
);
177+
178+
// Legend entry title
179+
select(this.legendElement!)
180+
.selectAll('.legend-entries-title')
181+
.classed(CartesianLegend.ACTIVE_CSS_CLASS, seriesGroup =>
182+
this.isThisLegendSeriesGroupActive(seriesGroup as Series<TData>[])
183+
);
184+
}
131185

132186
// Legend entry symbol
133187
legendElementSelection
@@ -201,14 +255,29 @@ export class CartesianLegend<TData> {
201255
this.activeSeriesSubject.next(this.activeSeries);
202256
}
203257

204-
private updateActiveSeries(seriesEntry: Series<TData>): void {
258+
private updateActiveSeriesGroup(seriesGroup: Series<TData>[]): void {
205259
if (!this.isSelectionModeOn) {
206-
this.activeSeries = [seriesEntry];
260+
this.activeSeries = [...seriesGroup];
207261
this.isSelectionModeOn = true;
208-
} else if (this.isThisLegendEntryActive(seriesEntry)) {
209-
this.activeSeries = this.activeSeries.filter(series => series !== seriesEntry);
262+
} else if (!this.isThisLegendSeriesGroupActive(seriesGroup)) {
263+
this.activeSeries = this.activeSeries.filter(series => !seriesGroup.includes(series));
264+
this.activeSeries.push(...seriesGroup);
210265
} else {
211-
this.activeSeries.push(seriesEntry);
266+
this.activeSeries = this.activeSeries.filter(series => !seriesGroup.includes(series));
267+
}
268+
this.updateLegendClassesAndStyle();
269+
this.updateResetElementVisibility(!this.isSelectionModeOn);
270+
this.activeSeriesSubject.next(this.activeSeries);
271+
}
272+
273+
private updateActiveSeries(series: Series<TData>): void {
274+
if (!this.isSelectionModeOn) {
275+
this.activeSeries = [series];
276+
this.isSelectionModeOn = true;
277+
} else if (this.isThisLegendEntryActive(series)) {
278+
this.activeSeries = this.activeSeries.filter(seriesEntry => series !== seriesEntry);
279+
} else {
280+
this.activeSeries.push(series);
212281
}
213282
this.updateLegendClassesAndStyle();
214283
this.updateResetElementVisibility(!this.isSelectionModeOn);
@@ -218,4 +287,8 @@ export class CartesianLegend<TData> {
218287
private isThisLegendEntryActive(seriesEntry: Series<TData>): boolean {
219288
return this.activeSeries.includes(seriesEntry);
220289
}
290+
291+
private isThisLegendSeriesGroupActive(seriesGroup: Series<TData>[]): boolean {
292+
return !this.isSelectionModeOn ? false : seriesGroup.every(series => this.activeSeries.includes(series));
293+
}
221294
}

projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ describe('Explore cartesian data source model', () => {
134134
timestamp: secondIntervalTime,
135135
value: 15
136136
}
137-
]
137+
],
138+
groupName: 'sum(foo)'
138139
}
139140
],
140141
bands: []
@@ -198,7 +199,8 @@ describe('Explore cartesian data source model', () => {
198199
data: [
199200
['first', 10],
200201
['second', 15]
201-
]
202+
],
203+
groupName: 'sum(foo)'
202204
}
203205
],
204206
bands: []
@@ -280,7 +282,7 @@ describe('Explore cartesian data source model', () => {
280282
series: [
281283
{
282284
color: 'first color',
283-
name: 'sum(foo): first',
285+
name: 'first',
284286
type: CartesianSeriesVisualizationType.Area,
285287
data: [
286288
{
@@ -295,11 +297,12 @@ describe('Explore cartesian data source model', () => {
295297
timestamp: secondIntervalTime,
296298
value: 15
297299
}
298-
]
300+
],
301+
groupName: 'sum(foo)'
299302
},
300303
{
301304
color: 'second color',
302-
name: 'sum(foo): second',
305+
name: 'second',
303306
type: CartesianSeriesVisualizationType.Area,
304307
data: [
305308
{
@@ -314,7 +317,8 @@ describe('Explore cartesian data source model', () => {
314317
timestamp: secondIntervalTime,
315318
value: 25
316319
}
317-
]
320+
],
321+
groupName: 'sum(foo)'
318322
}
319323
],
320324
bands: []

projects/observability/src/shared/dashboard/data/graphql/explore/explore-cartesian-data-source.model.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,9 @@ export abstract class ExploreCartesianDataSourceModel extends GraphQlDataSourceM
134134
data: result.data,
135135
units: obj.attribute.units !== '' ? obj.attribute.units : undefined,
136136
type: request.series.find(series => series.specification === result.spec)!.visualizationOptions.type,
137-
name: isEmpty(result.groupName)
138-
? obj.specDisplayName
139-
: request.useGroupName
140-
? result.groupName!
141-
: `${obj.specDisplayName}: ${result.groupName}`,
137+
name: !isEmpty(result.groupName) ? result.groupName! : obj.specDisplayName,
138+
groupName:
139+
!isEmpty(result.groupName) && (request.useGroupName ?? false) ? result.groupName! : obj.specDisplayName,
142140
color: color
143141
}))
144142
);

projects/observability/src/shared/dashboard/data/graphql/explorer-visualization/explorer-visualization-cartesian-data-source.model.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ describe('Explorer Visualization cartesian data source model', () => {
153153
timestamp: secondIntervalTime,
154154
value: 15
155155
}
156-
]
156+
],
157+
groupName: 'sum(foo)'
157158
}
158159
],
159160
bands: []
@@ -218,7 +219,8 @@ describe('Explorer Visualization cartesian data source model', () => {
218219
data: [
219220
['first', 10],
220221
['second', 15]
221-
]
222+
],
223+
groupName: 'sum(foo)'
222224
}
223225
],
224226
bands: []
@@ -302,7 +304,7 @@ describe('Explorer Visualization cartesian data source model', () => {
302304
series: [
303305
{
304306
color: 'first color',
305-
name: 'sum(foo): first',
307+
name: 'first',
306308
type: CartesianSeriesVisualizationType.Area,
307309
data: [
308310
{
@@ -317,11 +319,12 @@ describe('Explorer Visualization cartesian data source model', () => {
317319
timestamp: secondIntervalTime,
318320
value: 15
319321
}
320-
]
322+
],
323+
groupName: 'sum(foo)'
321324
},
322325
{
323326
color: 'second color',
324-
name: 'sum(foo): second',
327+
name: 'second',
325328
type: CartesianSeriesVisualizationType.Area,
326329
data: [
327330
{
@@ -336,7 +339,8 @@ describe('Explorer Visualization cartesian data source model', () => {
336339
timestamp: secondIntervalTime,
337340
value: 25
338341
}
339-
]
342+
],
343+
groupName: 'sum(foo)'
340344
}
341345
],
342346
bands: []

0 commit comments

Comments
 (0)