Skip to content

Commit 09f2234

Browse files
feat: adding a new gauge widget (#337)
* feat: adding a new gauge widget - Using angular svg binding - Using d3 shape to build arc path string - Using resize observer to react on dom dimension changes * refactor: updating package json * refactor: remove subs life cycle service * refactor: fixing radius and origin logic * refactor: updating type * refactor: removing resize polyfill * refactor: adding layout service * refactor: adding delay * refactor: remove layout change service provider * refactor: tweak layout rendering * refactor: fixing width and first render * refactor: update rendering Co-authored-by: Aaron Steinfeld <[email protected]>
1 parent 3828a01 commit 09f2234

File tree

7 files changed

+275
-2
lines changed

7 files changed

+275
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@
3535
"@angular/platform-browser": "^10.0.4",
3636
"@angular/platform-browser-dynamic": "^10.0.4",
3737
"@angular/router": "^10.0.4",
38+
"@apollo/client": "^3.2.5",
3839
"@hypertrace/hyperdash": "^1.1.2",
3940
"@hypertrace/hyperdash-angular": "^2.1.0",
4041
"@types/d3-hierarchy": "^2.0.0",
4142
"@types/d3-transition": "1.1.5",
42-
"@apollo/client": "^3.2.5",
4343
"apollo-angular": "^2.0.4",
4444
"core-js": "^3.5.0",
4545
"d3-array": "^2.8.0",

projects/components/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"d3-array": "^2.2.0",
2727
"d3-axis": "^1.0.12",
2828
"d3-scale": "^3.0.0",
29-
"d3-selection": "^1.4.0"
29+
"d3-selection": "^1.4.0",
30+
"d3-shape": "^1.3.5"
3031
},
3132
"devDependencies": {
3233
"@hypertrace/test-utils": "^0.0.0"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
@import 'font';
2+
@import 'color-palette';
3+
4+
.gauge-container {
5+
width: 100%;
6+
height: 100%;
7+
}
8+
9+
.gauge {
10+
width: 100%;
11+
height: 100%;
12+
13+
.gauge-ring {
14+
fill: $gray-2;
15+
}
16+
17+
.input-data {
18+
cursor: default;
19+
}
20+
21+
.value-ring {
22+
transition: transform 0.2s ease-out;
23+
}
24+
25+
.value-display {
26+
font-style: normal;
27+
font-weight: bold;
28+
font-size: 56px;
29+
text-anchor: middle;
30+
}
31+
32+
.label-display {
33+
@include body-1-semibold($gray-7);
34+
font-family: $font-family;
35+
text-anchor: middle;
36+
}
37+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Color } from '@hypertrace/common';
2+
import { createHostFactory, Spectator } from '@ngneat/spectator/jest';
3+
import { GaugeComponent } from './gauge.component';
4+
5+
describe('Gauge component', () => {
6+
let spectator: Spectator<GaugeComponent>;
7+
8+
const createHost = createHostFactory({
9+
component: GaugeComponent,
10+
shallow: true
11+
});
12+
13+
test('render all data', () => {
14+
spectator = createHost(`<ht-gauge [value]="value" [maxValue]="maxValue" [thresholds]="thresholds"></ht-gauge>`, {
15+
hostProps: {
16+
value: 80,
17+
maxValue: 100,
18+
thresholds: [
19+
{
20+
label: 'Medium',
21+
start: 60,
22+
end: 90,
23+
color: Color.Brown1
24+
},
25+
{
26+
label: 'High',
27+
start: 90,
28+
end: 100,
29+
color: Color.Red5
30+
}
31+
]
32+
}
33+
});
34+
spectator.component.onLayoutChange();
35+
expect(spectator.component.rendererData).toEqual({
36+
backgroundArc: 'M0,0Z',
37+
origin: {
38+
x: 0,
39+
y: -30
40+
},
41+
data: {
42+
value: 80,
43+
maxValue: 100,
44+
valueArc: 'M0,0Z',
45+
threshold: {
46+
color: '#9e4c41',
47+
end: 90,
48+
label: 'Medium',
49+
start: 60
50+
}
51+
}
52+
});
53+
});
54+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges } from '@angular/core';
2+
import { Color, Point } from '@hypertrace/common';
3+
import { Arc, arc, DefaultArcObject } from 'd3-shape';
4+
5+
@Component({
6+
selector: 'ht-gauge',
7+
template: `
8+
<div class="gauge-container" (htLayoutChange)="this.onLayoutChange()">
9+
<svg class="gauge" *ngIf="this.rendererData">
10+
<g attr.transform="translate({{ rendererData.origin.x }}, {{ rendererData.origin.y }})">
11+
<path class="gauge-ring" [attr.d]="rendererData.backgroundArc" />
12+
<g
13+
class="input-data"
14+
*ngIf="rendererData.data"
15+
htTooltip="{{ rendererData.data.value }} of {{ rendererData.data.maxValue }}"
16+
>
17+
<path
18+
class="value-ring"
19+
[attr.d]="rendererData.data.valueArc"
20+
[attr.fill]="rendererData.data.threshold.color"
21+
/>
22+
<text x="0" y="0" class="value-display" [attr.fill]="rendererData.data.threshold.color">
23+
{{ rendererData.data.value }}
24+
</text>
25+
<text x="0" y="24" class="label-display">{{ rendererData.data.threshold.label }}</text>
26+
</g>
27+
</g>
28+
</svg>
29+
</div>
30+
`,
31+
styleUrls: ['./gauge.component.scss'],
32+
changeDetection: ChangeDetectionStrategy.OnPush
33+
})
34+
export class GaugeComponent implements OnChanges {
35+
private static readonly GAUGE_RING_WIDTH: number = 20;
36+
private static readonly GAUGE_ARC_CORNER_RADIUS: number = 10;
37+
private static readonly GAUGE_AXIS_PADDING: number = 30;
38+
39+
@Input()
40+
public value?: number;
41+
42+
@Input()
43+
public maxValue?: number;
44+
45+
@Input()
46+
public thresholds: GaugeThreshold[] = [];
47+
48+
public rendererData?: GaugeSvgRendererData;
49+
50+
public constructor(public readonly elementRef: ElementRef) {}
51+
52+
public ngOnChanges(): void {
53+
this.rendererData = this.buildRendererData();
54+
}
55+
56+
public onLayoutChange(): void {
57+
this.rendererData = this.buildRendererData();
58+
}
59+
60+
private buildRendererData(): GaugeSvgRendererData | undefined {
61+
const inputData = this.calculateInputData();
62+
if (!inputData) {
63+
return undefined;
64+
}
65+
66+
const boundingBox = this.elementRef.nativeElement.getBoundingClientRect();
67+
const radius = this.buildRadius(boundingBox);
68+
69+
return {
70+
origin: this.buildOrigin(boundingBox, radius),
71+
backgroundArc: this.buildBackgroundArc(radius),
72+
data: this.buildGaugeData(radius, inputData)
73+
};
74+
}
75+
76+
private buildBackgroundArc(radius: number): string {
77+
return this.buildArcGenerator()({
78+
innerRadius: radius - GaugeComponent.GAUGE_RING_WIDTH,
79+
outerRadius: radius,
80+
startAngle: -Math.PI / 2,
81+
endAngle: Math.PI / 2
82+
})!;
83+
}
84+
85+
private buildGaugeData(radius: number, inputData?: GaugeInputData): GaugeData | undefined {
86+
if (inputData === undefined) {
87+
return undefined;
88+
}
89+
90+
return {
91+
valueArc: this.buildValueArc(radius, inputData),
92+
...inputData
93+
};
94+
}
95+
96+
private buildValueArc(radius: number, inputData: GaugeInputData): string {
97+
return this.buildArcGenerator()({
98+
innerRadius: radius - GaugeComponent.GAUGE_RING_WIDTH,
99+
outerRadius: radius,
100+
startAngle: -Math.PI / 2,
101+
endAngle: -Math.PI / 2 + (inputData.value / inputData.maxValue) * Math.PI
102+
})!;
103+
}
104+
105+
private buildArcGenerator(): Arc<unknown, DefaultArcObject> {
106+
return arc().cornerRadius(GaugeComponent.GAUGE_ARC_CORNER_RADIUS);
107+
}
108+
109+
private buildRadius(boundingBox: ClientRect): number {
110+
return Math.min(
111+
boundingBox.height - GaugeComponent.GAUGE_AXIS_PADDING,
112+
boundingBox.height / 2 + Math.min(boundingBox.height, boundingBox.width) / 2
113+
);
114+
}
115+
116+
private buildOrigin(boundingBox: ClientRect, radius: number): Point {
117+
return {
118+
x: boundingBox.width / 2,
119+
y: radius
120+
};
121+
}
122+
123+
private calculateInputData(): GaugeInputData | undefined {
124+
if (this.value !== undefined && this.maxValue !== undefined && this.maxValue > 0 && this.thresholds.length > 0) {
125+
const currentThreshold = this.thresholds.find(
126+
threshold => this.value! >= threshold.start && this.value! < threshold.end
127+
);
128+
129+
if (currentThreshold) {
130+
return {
131+
value: this.value,
132+
maxValue: this.maxValue,
133+
threshold: currentThreshold
134+
};
135+
}
136+
}
137+
}
138+
}
139+
140+
export interface GaugeThreshold {
141+
label: string;
142+
start: number;
143+
end: number;
144+
color: Color;
145+
}
146+
147+
interface GaugeSvgRendererData {
148+
origin: Point;
149+
backgroundArc: string;
150+
data?: GaugeData;
151+
}
152+
153+
interface GaugeData {
154+
valueArc: string;
155+
value: number;
156+
maxValue: number;
157+
threshold: GaugeThreshold;
158+
}
159+
160+
interface GaugeInputData {
161+
value: number;
162+
maxValue: number;
163+
threshold: GaugeThreshold;
164+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { CommonModule } from '@angular/common';
2+
import { NgModule } from '@angular/core';
3+
import { FormattingModule } from '@hypertrace/common';
4+
import { LayoutChangeModule } from '../layout/layout-change.module';
5+
import { TooltipModule } from './../tooltip/tooltip.module';
6+
import { GaugeComponent } from './gauge.component';
7+
8+
@NgModule({
9+
declarations: [GaugeComponent],
10+
exports: [GaugeComponent],
11+
imports: [CommonModule, FormattingModule, TooltipModule, LayoutChangeModule]
12+
})
13+
export class GaugeModule {}

projects/components/src/public-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export * from './filtering/filter-modal/in-filter-modal.component';
8181
// Filter Parser
8282
export * from './filtering/filter/parser/filter-parser-lookup.service';
8383

84+
// Gauge
85+
export * from './gauge/gauge.component';
86+
export * from './gauge/gauge.module';
87+
8488
// Header
8589
export * from './header/application/application-header.component';
8690
export * from './header/application/application-header.module';

0 commit comments

Comments
 (0)