diff --git a/package.json b/package.json index 61b501e2f..e248e42a9 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,11 @@ "@angular/platform-browser": "^10.0.4", "@angular/platform-browser-dynamic": "^10.0.4", "@angular/router": "^10.0.4", + "@apollo/client": "^3.2.5", "@hypertrace/hyperdash": "^1.1.2", "@hypertrace/hyperdash-angular": "^2.1.0", "@types/d3-hierarchy": "^2.0.0", "@types/d3-transition": "1.1.5", - "@apollo/client": "^3.2.5", "apollo-angular": "^2.0.4", "core-js": "^3.5.0", "d3-array": "^2.8.0", diff --git a/projects/components/package.json b/projects/components/package.json index 0d00ed00f..31e559599 100644 --- a/projects/components/package.json +++ b/projects/components/package.json @@ -26,7 +26,8 @@ "d3-array": "^2.2.0", "d3-axis": "^1.0.12", "d3-scale": "^3.0.0", - "d3-selection": "^1.4.0" + "d3-selection": "^1.4.0", + "d3-shape": "^1.3.5" }, "devDependencies": { "@hypertrace/test-utils": "^0.0.0" diff --git a/projects/components/src/gauge/gauge.component.scss b/projects/components/src/gauge/gauge.component.scss new file mode 100644 index 000000000..2f6981232 --- /dev/null +++ b/projects/components/src/gauge/gauge.component.scss @@ -0,0 +1,32 @@ +@import 'font'; +@import 'color-palette'; + +.gauge { + width: 100%; + height: 100%; + + .gauge-ring { + fill: $gray-2; + } + + .input-data { + cursor: default; + } + + .value-ring { + transition: transform 0.2s ease-out; + } + + .value-display { + font-style: normal; + font-weight: bold; + font-size: 56px; + text-anchor: middle; + } + + .label-display { + @include body-1-semibold($gray-7); + font-family: $font-family; + text-anchor: middle; + } +} diff --git a/projects/components/src/gauge/gauge.component.test.ts b/projects/components/src/gauge/gauge.component.test.ts new file mode 100644 index 000000000..b1fb1db7f --- /dev/null +++ b/projects/components/src/gauge/gauge.component.test.ts @@ -0,0 +1,65 @@ +import { Color } from '@hypertrace/common'; +import { runFakeRxjs } from '@hypertrace/test-utils'; +import { createHostFactory, Spectator } from '@ngneat/spectator/jest'; +import { MockDirective } from 'ng-mocks'; +import { LayoutChangeDirective } from '../layout/layout-change.directive'; +import { GaugeComponent } from './gauge.component'; +import { GaugeModule } from './gauge.module'; + +describe('Gauge component', () => { + let spectator: Spectator; + + const createHost = createHostFactory({ + component: GaugeComponent, + declareComponent: false, + declarations:[MockDirective(LayoutChangeDirective)], + imports: [GaugeModule] + }); + + test('render all data', () => { + spectator = createHost(``, { + hostProps: { + value: 80, + maxValue: 100, + thresholds: [ + { + label: 'Medium', + start: 60, + end: 90, + color: Color.Brown1 + }, + { + label: 'High', + start: 90, + end: 100, + color: Color.Red5 + } + ] + } + }); + spectator.component.onLayoutChange(); + + runFakeRxjs(({ expectObservable }) => { + expectObservable(spectator.component.gaugeRendererData$).toBe('200ms (x)', { + x: { + backgroundArc: 'M0,0Z', + origin: { + x: 0, + y: 0 + }, + data: { + value: 80, + maxValue: 100, + valueArc: 'M0,0Z', + threshold: { + color: '#9e4c41', + end: 90, + label: 'Medium', + start: 60 + } + } + } + }); + }); + }); +}); diff --git a/projects/components/src/gauge/gauge.component.ts b/projects/components/src/gauge/gauge.component.ts new file mode 100644 index 000000000..a7992d24b --- /dev/null +++ b/projects/components/src/gauge/gauge.component.ts @@ -0,0 +1,176 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges } from '@angular/core'; +import { Color, LayoutChangeService, Point } from '@hypertrace/common'; +import { Arc, arc, DefaultArcObject } from 'd3-shape'; +import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs'; +import { map, debounceTime } from 'rxjs/operators'; + +@Component({ + selector: 'ht-gauge', + template: ` + + + + + + + {{ rendererData.data.value }} + + {{ rendererData.data.threshold.label }} + + + + `, + styleUrls: ['./gauge.component.scss'], + providers: [LayoutChangeService], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class GaugeComponent implements OnChanges { + private static readonly GAUGE_RING_WIDTH: number = 20; + private static readonly GAUGE_ARC_CORNER_RADIUS: number = 10; + private static readonly GAUGE_AXIS_PADDING: number = 30; + + @Input() + public value?: number; + + @Input() + public maxValue?: number; + + @Input() + public thresholds: GaugeThreshold[] = []; + + public readonly gaugeRendererData$: Observable; + + private readonly inputDataSubject: Subject = new BehaviorSubject< + GaugeInputData | undefined + >(undefined); + private readonly inputData$: Observable = this.inputDataSubject.asObservable(); + + private readonly redrawSubject: Subject = new BehaviorSubject(true); + private readonly redraw$: Observable = this.redrawSubject.pipe(debounceTime(100)); + + public constructor(public readonly elementRef: ElementRef) { + this.gaugeRendererData$ = this.buildGaugeRendererDataObservable(); + } + + public ngOnChanges(): void { + this.emitInputData(); + } + + public onLayoutChange(): void { + this.redrawSubject.next(true); + } + + private buildGaugeRendererDataObservable(): Observable { + return combineLatest([this.inputData$, this.redraw$ ]).pipe( + map(([inputData]) => { + const boundingBox = this.elementRef.nativeElement.getBoundingClientRect(); + const radius = this.buildRadius(boundingBox); + + return { + origin: this.buildOrigin(boundingBox, radius), + backgroundArc: this.buildBackgroundArc(radius), + data: this.buildGaugeData(radius, inputData) + }; + }) + ); + } + + private buildBackgroundArc(radius: number): string { + return this.buildArcGenerator()({ + innerRadius: radius - GaugeComponent.GAUGE_RING_WIDTH, + outerRadius: radius, + startAngle: -Math.PI / 2, + endAngle: Math.PI / 2 + })!; + } + + private buildGaugeData(radius: number, inputData?: GaugeInputData): GaugeData | undefined { + if (inputData === undefined) { + return undefined; + } + + return { + valueArc: this.buildValueArc(radius, inputData), + ...inputData + }; + } + + private buildValueArc(radius: number, inputData: GaugeInputData): string { + return this.buildArcGenerator()({ + innerRadius: radius - GaugeComponent.GAUGE_RING_WIDTH, + outerRadius: radius, + startAngle: -Math.PI / 2, + endAngle: -Math.PI / 2 + (inputData.value / inputData.maxValue) * Math.PI + })!; + } + + private buildArcGenerator(): Arc { + return arc().cornerRadius(GaugeComponent.GAUGE_ARC_CORNER_RADIUS); + } + + private buildRadius(boundingBox: ClientRect): number { + return Math.min( + boundingBox.height - GaugeComponent.GAUGE_AXIS_PADDING, + boundingBox.height / 2 + Math.min(boundingBox.height, boundingBox.width) / 2 + ); + } + + private buildOrigin(boundingBox: ClientRect, radius: number): Point { + return { + x: boundingBox.width / 2, + y: radius + }; + } + + private emitInputData(): void { + let inputData; + if (this.value !== undefined && this.maxValue !== undefined && this.maxValue > 0 && this.thresholds.length > 0) { + const currentThreshold = this.thresholds.find( + threshold => this.value! >= threshold.start && this.value! < threshold.end + ); + + if (currentThreshold) { + inputData = { + value: this.value, + maxValue: this.maxValue, + threshold: currentThreshold + }; + } + } + this.inputDataSubject.next(inputData); + } +} + +export interface GaugeThreshold { + label: string; + start: number; + end: number; + color: Color; +} + +interface GaugeSvgRendererData { + origin: Point; + backgroundArc: string; + data?: GaugeData; +} + +interface GaugeData { + valueArc: string; + value: number; + maxValue: number; + threshold: GaugeThreshold; +} + +interface GaugeInputData { + value: number; + maxValue: number; + threshold: GaugeThreshold; +} diff --git a/projects/components/src/gauge/gauge.module.ts b/projects/components/src/gauge/gauge.module.ts new file mode 100644 index 000000000..e06f8079d --- /dev/null +++ b/projects/components/src/gauge/gauge.module.ts @@ -0,0 +1,13 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormattingModule } from '@hypertrace/common'; +import { LayoutChangeModule } from '../layout/layout-change.module'; +import { TooltipModule } from './../tooltip/tooltip.module'; +import { GaugeComponent } from './gauge.component'; + +@NgModule({ + declarations: [GaugeComponent], + exports: [GaugeComponent], + imports: [CommonModule, FormattingModule, TooltipModule, LayoutChangeModule] +}) +export class GaugeModule {} diff --git a/projects/components/src/public-api.ts b/projects/components/src/public-api.ts index 9e1c174bd..06f22f95b 100644 --- a/projects/components/src/public-api.ts +++ b/projects/components/src/public-api.ts @@ -81,6 +81,10 @@ export * from './filtering/filter-modal/in-filter-modal.component'; // Filter Parser export * from './filtering/filter/parser/filter-parser-lookup.service'; +// Gauge +export * from './gauge/gauge.component'; +export * from './gauge/gauge.module'; + // Header export * from './header/application/application-header.component'; export * from './header/application/application-header.module';