Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding a new gauge widget #337

Merged
merged 14 commits into from
Nov 9, 2020
Merged

feat: adding a new gauge widget #337

merged 14 commits into from
Nov 9, 2020

Conversation

anandtiwary
Copy link
Contributor

@anandtiwary anandtiwary commented Nov 6, 2020

Description

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
  • Using httooltip inside svg

Testing

Deployed, tested locally and added unit test to verify certain dom changes

Checklist:

  • [X ] My changes generate no new warnings
  • [X ] I have added tests that prove my fix is effective or that my feature works
  • [X ] Any dependent changes have been merged and published in downstream modules

Documentation

Make sure that you have documented corresponding changes in this repository or hypertrace docs repo if required.
Sure

Screen Shot 2020-11-06 at 11 54 27 AM

- Using angular svg binding
- Using d3 shape to build arc path string
- Using resize observer to react on dom dimension changes
@anandtiwary anandtiwary requested a review from a team as a code owner November 6, 2020 19:42
@codecov
Copy link

codecov bot commented Nov 6, 2020

Codecov Report

Merging #337 (af0a771) into main (3828a01) will increase coverage by 0.03%.
The diff coverage is 96.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #337      +/-   ##
==========================================
+ Coverage   86.20%   86.24%   +0.03%     
==========================================
  Files         721      723       +2     
  Lines       14683    14733      +50     
  Branches     1827     1832       +5     
==========================================
+ Hits        12658    12706      +48     
- Misses       1993     1995       +2     
  Partials       32       32              
Impacted Files Coverage Δ
projects/components/src/gauge/gauge.component.ts 95.12% <95.12%> (ø)
projects/components/src/gauge/gauge.module.ts 100.00% <100.00%> (ø)
projects/components/src/public-api.ts 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3828a01...af0a771. Read the comment docs.

package.json Outdated
@@ -60,6 +60,7 @@
"graphql": "^15.3.0",
"graphql-tag": "^2.11.0",
"lodash-es": "^4.17.15",
"resize-observer-polyfill": "^1.5.1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have our own implementation of this which we use on all other visualizations. I can't remember the exact reason why we didn't use something like resize observer, but I do remember looking at it. Example usage from cartesian:

<div #chartContainer class="fill-container" (htLayoutChange)="this.redraw()"></div>

Copy link
Contributor Author

@anandtiwary anandtiwary Nov 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htLayoutChange is triggered for the entire window/dashboard. We can use it, but i strongly feel we should use the resizeObserver from inside this component. It would get triggered even if window/layout size doesn't change. Like if we have gauge and another component inside a flex container, and the other component is removed with NgIf, layout change wouldn't get triggered.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htLayoutChange uses the window as its root, but is hierarchical - each new layout change directive creates a new scope. Even if we eventually switch to a lib (don't think it's needed personally), that should be wrapped in this service for simplified use.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we have wrapped it up into an rxjs observable easily. Do we still need a service ? If yes, what would we gain?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So trying to page back to why we implemented it ourselves, I think other than the feature not having widespread support, the issue is that the way we often have implemented layouts (particularly with d3 stuff), a resize wouldn't trigger the way we'd want. For example, we may have done a measurement to set the svg's width, so when other parts of the dom are hidden, this component hasn't actually changed size - it's already explicitly sized. That also would applied to a parent div which got its size based on its child content.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to use LayoutChangeService and replaced it in the design I have above. The first time It tries to render (Default, without layout change), the root element gets a wrong width. This was not happening with the resize observer we used before.

Unclear what you mean - how would adding a (non-structural) directive change the width? Can you post a separate PR where you try the layout change directive? This is the method we use on every other svg, so if there are issues, we should raise them.

Is this the problem you were mentioning above?

The problem I'm referencing above is that a responsive svg scales evenly for all elements, text included. That's not behavior we want with our visualizations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#339 here you go . The problem is the dom first has a larger width. So it rendering origin is off. Then during the next trigger, it gets the correct width. Wondering why it is happening. With Resize observable, this doesn't happen.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My guess is that the same issue that appears in both - you're building the measuring observable in the constructor, which is too early to measure as the dom hasn't been laid out yet. In this form, you're debouncing the resize observable which means the first draw doesn't actually happen at that moment, and is likely delayed until after drawing. Try assigning it in afterViewInit instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. ViewInit was somehow adding a lot of delay I have tried that already. I am using debounce to fix this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this PR. Disregard the other one

projects/components/src/gauge/gauge.component.ts Outdated Show resolved Hide resolved
private buildOrigin(boundingBox: ClientRect, radius: number): Point {
return {
x: boundingBox.width / 2,
y: boundingBox.height / 2 + radius / 2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems strange, assuming a square bounding box, this makes the origin 3/4 of the way down?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Because we want the center of the gauge to be at the center of the container. Hence the offset by radius/2.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the radius is basically the height of the gauge right? Which means a gauge takes up half the height of its container (why not the full height with some padding) ?

And am I reading this logic right - it's putting the origin y at the bottom of the gauge (not the middle of the gauge), and the origin x in the middle of the gauge (which is always the center of the bounding box).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@anandtiwary
Copy link
Contributor Author

@aaron-steinfeld Btw how did you find the angular, svg and d3 mix-up? I think we could use this approach for some other basic svg charts.

@aaron-steinfeld
Copy link
Contributor

@aaron-steinfeld Btw how did you find the angular, svg and d3 mix-up? I think we could use this approach for some other basic svg charts.

In general I like it - as long as we resolve that concern about text sizing and relative vs explicit dimensions.

return {
x: boundingBox.width / 2,
y: boundingBox.height / 2 + radius / 2
y: boundingBox.height - GaugeComponent.GAUGE_AXIS_PADDING
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case where our height is greater than our width, I believe this is is incorrect -
Take, for example a width of 300 and height of 600 - we calculate a radius of 300 - 30 = 270. Our origin here would be at (150, 570) - instead, we'd likely want it centered which would be more like (150, 450) - the padding not being needed because it's already accounted for in the centering. I think something like min(height - padding, height / 2 + min(height, width) / 2) => min(570, 450)

@anandtiwary
Copy link
Contributor Author

@aaron-steinfeld I think I addressed all the comments. Let me know if I miss anything

@aaron-steinfeld
Copy link
Contributor

So I started playing with this locally to investigate whether the debounce was needed and noticed issues with the radius. Some of these things are just style, but there's a measurement issue in here too and I was able to remove the debounce/observables:

diff --git a/projects/components/src/gauge/gauge.component.ts b/projects/components/src/gauge/gauge.component.ts
index 1f2cd49b..093268f2 100644
--- a/projects/components/src/gauge/gauge.component.ts
+++ b/projects/components/src/gauge/gauge.component.ts
@@ -1,18 +1,11 @@
 import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges } from '@angular/core';
 import { Color, Point } from '@hypertrace/common';
 import { Arc, arc, DefaultArcObject } from 'd3-shape';
-import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
-import { debounceTime, map } from 'rxjs/operators';
 
 @Component({
   selector: 'ht-gauge',
   template: `
-    <svg
-      #chartContainer
-      class="gauge"
-      (htLayoutChange)="this.onLayoutChange()"
-      *ngIf="this.gaugeRendererData$ | async as rendererData"
-    >
+    <svg #chartContainer class="gauge" (htLayoutChange)="this.onLayoutChange()" *ngIf="this.rendererData">
       <g attr.transform="translate({{ rendererData.origin.x }}, {{ rendererData.origin.y }})">
         <path class="gauge-ring" [attr.d]="rendererData.backgroundArc" />
         <g
@@ -50,41 +43,32 @@ export class GaugeComponent implements OnChanges {
   @Input()
   public thresholds: GaugeThreshold[] = [];
 
-  public readonly gaugeRendererData$: Observable<GaugeSvgRendererData>;
+  public rendererData?: GaugeSvgRendererData;
 
-  private readonly inputDataSubject: Subject<GaugeInputData | undefined> = new BehaviorSubject<
-    GaugeInputData | undefined
-  >(undefined);
-  private readonly inputData$: Observable<GaugeInputData | undefined> = this.inputDataSubject.asObservable();
-
-  private readonly redrawSubject: Subject<true> = new BehaviorSubject(true);
-  private readonly redraw$: Observable<true> = this.redrawSubject.pipe(debounceTime(100));
-
-  public constructor(public readonly elementRef: ElementRef) {
-    this.gaugeRendererData$ = this.buildGaugeRendererDataObservable();
-  }
+  public constructor(public readonly elementRef: ElementRef) {}
 
   public ngOnChanges(): void {
-    this.emitInputData();
+    this.rendererData = this.buildRendererData();
   }
 
   public onLayoutChange(): void {
-    this.redrawSubject.next(true);
+    this.rendererData = this.buildRendererData();
   }
 
-  private buildGaugeRendererDataObservable(): Observable<GaugeSvgRendererData> {
-    return combineLatest([this.inputData$, this.redraw$]).pipe(
-      map(([inputData]) => {
-        const boundingBox = this.elementRef.nativeElement.getBoundingClientRect();
-        const radius = this.buildRadius(boundingBox);
+  private buildRendererData(): GaugeSvgRendererData | undefined {
+    const inputData = this.calculateInputData();
+    if (!inputData) {
+      return undefined;
+    }
 
-        return {
-          origin: this.buildOrigin(boundingBox, radius),
-          backgroundArc: this.buildBackgroundArc(radius),
-          data: this.buildGaugeData(radius, 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 {
@@ -123,7 +107,7 @@ export class GaugeComponent implements OnChanges {
   private buildRadius(boundingBox: ClientRect): number {
     return Math.min(
       boundingBox.height - GaugeComponent.GAUGE_AXIS_PADDING,
-      boundingBox.height / 2 + Math.min(boundingBox.height, boundingBox.width) / 2
+      boundingBox.width / 2 - GaugeComponent.GAUGE_AXIS_PADDING / 2
     );
   }
 
@@ -134,22 +118,20 @@ export class GaugeComponent implements OnChanges {
     };
   }
 
-  private emitInputData(): void {
-    let inputData;
+  private calculateInputData(): GaugeInputData | undefined {
     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 = {
+        return {
           value: this.value,
           maxValue: this.maxValue,
           threshold: currentThreshold
         };
       }
     }
-    this.inputDataSubject.next(inputData);
   }
 }

@aaron-steinfeld
Copy link
Contributor

aaron-steinfeld commented Nov 9, 2020

This was also a good exercise because the reason for the debounce was a wider-spread issue with an easy fix. In angular, components are rendered from the bottom of the tree to the top - so a gauge would render before the application frame, which will take up some of the available space. In most places, we put our components in dashboards which are async and control the available space anyway, so it's not an issue, but we should account for the sync case too which I believe is what you were running into. I did that while testing by hiding the frame's content until after view init. Another approach could be emitting a layout change (or via resize observer too), but for both of those, we'd just be doing a second render - preventing seems better.

@anandtiwary
Copy link
Contributor Author

Axis Padding is a vertical padding, I don't think we should use the same for width as well.

-      boundingBox.height / 2 + Math.min(boundingBox.height, boundingBox.width) / 2
+      boundingBox.width / 2 - GaugeComponent.GAUGE_AXIS_PADDING / 2

Just noticed that Debounce is applied already in layout service.

@anandtiwary
Copy link
Contributor Author

This was also a good exercise because the reason for the debounce was a wider-spread issue with an easy fix. In angular, components are rendered from the bottom of the tree to the top - so a gauge would render before the application frame, which will take up some of the available space. In most places, we put our components in dashboards which are async and control the available space anyway, so it's not an issue, but we should account for the sync case too which I believe is what you were running into. I did that while testing by hiding the frame's content until after view init. Another approach could be emitting a layout change (or via resize observer too), but for both of those, we'd just be doing a second render - preventing seems better.

Yeah, I had tried this with observable approach too. Basically emitting on redrawSubject on viewInit rather than using a behavior subject. But the rendering got delayed by few seconds somehow. With your approach, was it still instantaneous?

@aaron-steinfeld
Copy link
Contributor

Axis Padding is a vertical padding, I don't think we should use the same for width as well.

-      boundingBox.height / 2 + Math.min(boundingBox.height, boundingBox.width) / 2
+      boundingBox.width / 2 - GaugeComponent.GAUGE_AXIS_PADDING / 2

Just noticed that Debounce is applied already in layout service.

Can remove the padding on the width side if not applicable there, assumed it was uniform.

@aaron-steinfeld
Copy link
Contributor

This was also a good exercise because the reason for the debounce was a wider-spread issue with an easy fix. In angular, components are rendered from the bottom of the tree to the top - so a gauge would render before the application frame, which will take up some of the available space. In most places, we put our components in dashboards which are async and control the available space anyway, so it's not an issue, but we should account for the sync case too which I believe is what you were running into. I did that while testing by hiding the frame's content until after view init. Another approach could be emitting a layout change (or via resize observer too), but for both of those, we'd just be doing a second render - preventing seems better.

Yeah, I had tried this with observable approach too. Basically emitting on redrawSubject on viewInit rather than using a behavior subject. But the rendering got delayed by few seconds somehow. With your approach, was it still instantaneous?

So the view init in gauge alone doesn't help, because that runs before the parent's view init which can change the available space - the approach I took, it felt instantaneous because the frame waits to render it's content until the top/side are set up.

jake-bassett
jake-bassett previously approved these changes Nov 9, 2020
@anandtiwary anandtiwary merged commit 09f2234 into main Nov 9, 2020
@anandtiwary anandtiwary deleted the gauge-component branch November 9, 2020 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants