github.com/hernad/nomad@v1.6.112/ui/app/components/das/recommendation-chart.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  import Component from '@glimmer/component';
     7  import { action } from '@ember/object';
     8  import { tracked } from '@glimmer/tracking';
     9  import { next } from '@ember/runloop';
    10  import { htmlSafe } from '@ember/string';
    11  import { get } from '@ember/object';
    12  
    13  import { scaleLinear } from 'd3-scale';
    14  import d3Format from 'd3-format';
    15  
    16  const statsKeyToLabel = {
    17    min: 'Min',
    18    median: 'Median',
    19    mean: 'Mean',
    20    p99: '99th',
    21    max: 'Max',
    22    current: 'Current',
    23    recommended: 'New',
    24  };
    25  
    26  const formatPercent = d3Format.format('+.0%');
    27  export default class RecommendationChartComponent extends Component {
    28    @tracked width;
    29    @tracked height;
    30  
    31    @tracked shown = false;
    32  
    33    @tracked showLegend = false;
    34    @tracked mouseX;
    35    @tracked activeLegendRow;
    36  
    37    get isIncrease() {
    38      return this.args.currentValue < this.args.recommendedValue;
    39    }
    40  
    41    get directionClass() {
    42      if (this.args.disabled) {
    43        return 'disabled';
    44      } else if (this.isIncrease) {
    45        return 'increase';
    46      } else {
    47        return 'decrease';
    48      }
    49    }
    50  
    51    get icon() {
    52      return {
    53        x: 0,
    54        y: this.resourceLabel.y - this.iconHeight / 2,
    55        width: 20,
    56        height: this.iconHeight,
    57        name: this.isIncrease ? 'arrow-up' : 'arrow-down',
    58      };
    59    }
    60  
    61    gutterWidthLeft = 62;
    62    gutterWidthRight = 50;
    63    gutterWidth = this.gutterWidthLeft + this.gutterWidthRight;
    64  
    65    iconHeight = 21;
    66  
    67    tickTextHeight = 15;
    68  
    69    edgeTickHeight = 23;
    70    centerTickOffset = 6;
    71  
    72    centerY =
    73      this.tickTextHeight + this.centerTickOffset + this.edgeTickHeight / 2;
    74  
    75    edgeTickY1 = this.tickTextHeight + this.centerTickOffset;
    76    edgeTickY2 =
    77      this.tickTextHeight + this.edgeTickHeight + this.centerTickOffset;
    78  
    79    deltaTextY = this.edgeTickY2;
    80  
    81    meanHeight = this.edgeTickHeight * 0.6;
    82    p99Height = this.edgeTickHeight * 0.48;
    83    maxHeight = this.edgeTickHeight * 0.4;
    84  
    85    deltaTriangleHeight = this.edgeTickHeight / 2.5;
    86  
    87    get statsShapes() {
    88      if (this.width) {
    89        const maxShapes = this.shapesFor('max');
    90        const p99Shapes = this.shapesFor('p99');
    91        const meanShapes = this.shapesFor('mean');
    92  
    93        const labelProximityThreshold = 25;
    94  
    95        if (p99Shapes.text.x + labelProximityThreshold > maxShapes.text.x) {
    96          maxShapes.text.class = 'right';
    97        }
    98  
    99        if (meanShapes.text.x + labelProximityThreshold > p99Shapes.text.x) {
   100          p99Shapes.text.class = 'right';
   101        }
   102  
   103        if (meanShapes.text.x + labelProximityThreshold * 2 > maxShapes.text.x) {
   104          p99Shapes.text.class = 'hidden';
   105        }
   106  
   107        return [maxShapes, p99Shapes, meanShapes];
   108      } else {
   109        return [];
   110      }
   111    }
   112  
   113    shapesFor(key) {
   114      const stat = this.args.stats[key];
   115  
   116      const rectWidth = this.xScale(stat);
   117      const rectHeight = this[`${key}Height`];
   118  
   119      const tickX = rectWidth + this.gutterWidthLeft;
   120  
   121      const label = statsKeyToLabel[key];
   122  
   123      return {
   124        class: key,
   125        text: {
   126          label,
   127          x: tickX,
   128          y: this.tickTextHeight - 5,
   129          class: '', // overridden in statsShapes to align/hide based on proximity
   130        },
   131        line: {
   132          x1: tickX,
   133          y1: this.tickTextHeight,
   134          x2: tickX,
   135          y2: this.centerY - 2,
   136        },
   137        rect: {
   138          x: this.gutterWidthLeft,
   139          y:
   140            (this.edgeTickHeight - rectHeight) / 2 +
   141            this.centerTickOffset +
   142            this.tickTextHeight,
   143          width: rectWidth,
   144          height: rectHeight,
   145        },
   146      };
   147    }
   148  
   149    get barWidth() {
   150      return this.width - this.gutterWidth;
   151    }
   152  
   153    get higherValue() {
   154      return Math.max(this.args.currentValue, this.args.recommendedValue);
   155    }
   156  
   157    get maximumX() {
   158      return Math.max(
   159        this.higherValue,
   160        get(this.args.stats, 'max') || Number.MIN_SAFE_INTEGER
   161      );
   162    }
   163  
   164    get lowerValue() {
   165      return Math.min(this.args.currentValue, this.args.recommendedValue);
   166    }
   167  
   168    get xScale() {
   169      return scaleLinear()
   170        .domain([0, this.maximumX])
   171        .rangeRound([0, this.barWidth]);
   172    }
   173  
   174    get lowerValueWidth() {
   175      return this.gutterWidthLeft + this.xScale(this.lowerValue);
   176    }
   177  
   178    get higherValueWidth() {
   179      return this.gutterWidthLeft + this.xScale(this.higherValue);
   180    }
   181  
   182    get center() {
   183      if (this.width) {
   184        return {
   185          x1: this.gutterWidthLeft,
   186          y1: this.centerY,
   187          x2: this.width - this.gutterWidthRight,
   188          y2: this.centerY,
   189        };
   190      } else {
   191        return null;
   192      }
   193    }
   194  
   195    get resourceLabel() {
   196      const text = this.args.resource === 'CPU' ? 'CPU' : 'Mem';
   197  
   198      return {
   199        text,
   200        x: this.gutterWidthLeft - 10,
   201        y: this.centerY,
   202      };
   203    }
   204  
   205    get deltaRect() {
   206      if (this.isIncrease) {
   207        return {
   208          x: this.lowerValueWidth,
   209          y: this.edgeTickY1,
   210          width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
   211          height: this.edgeTickHeight,
   212        };
   213      } else {
   214        return {
   215          x: this.shown ? this.lowerValueWidth : this.higherValueWidth,
   216          y: this.edgeTickY1,
   217          width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
   218          height: this.edgeTickHeight,
   219        };
   220      }
   221    }
   222  
   223    get deltaTriangle() {
   224      const directionXMultiplier = this.isIncrease ? 1 : -1;
   225      let translateX;
   226  
   227      if (this.shown) {
   228        translateX = this.isIncrease
   229          ? this.higherValueWidth
   230          : this.lowerValueWidth;
   231      } else {
   232        translateX = this.isIncrease
   233          ? this.lowerValueWidth
   234          : this.higherValueWidth;
   235      }
   236  
   237      return {
   238        style: htmlSafe(`transform: translateX(${translateX}px)`),
   239        points: `
   240          0,${this.center.y1}
   241          0,${this.center.y1 - this.deltaTriangleHeight / 2}
   242          ${(directionXMultiplier * this.deltaTriangleHeight) / 2},${
   243          this.center.y1
   244        }
   245          0,${this.center.y1 + this.deltaTriangleHeight / 2}
   246        `,
   247      };
   248    }
   249  
   250    get deltaLines() {
   251      if (this.isIncrease) {
   252        return {
   253          original: {
   254            x: this.lowerValueWidth,
   255          },
   256          delta: {
   257            style: htmlSafe(
   258              `transform: translateX(${
   259                this.shown ? this.higherValueWidth : this.lowerValueWidth
   260              }px)`
   261            ),
   262          },
   263        };
   264      } else {
   265        return {
   266          original: {
   267            x: this.higherValueWidth,
   268          },
   269          delta: {
   270            style: htmlSafe(
   271              `transform: translateX(${
   272                this.shown ? this.lowerValueWidth : this.higherValueWidth
   273              }px)`
   274            ),
   275          },
   276        };
   277      }
   278    }
   279  
   280    get deltaText() {
   281      const yOffset = 17;
   282      const y = this.deltaTextY + yOffset;
   283  
   284      const lowerValueText = {
   285        anchor: 'end',
   286        x: this.lowerValueWidth,
   287        y,
   288      };
   289  
   290      const higherValueText = {
   291        anchor: 'start',
   292        x: this.higherValueWidth,
   293        y,
   294      };
   295  
   296      const percentText = formatPercent(
   297        (this.args.recommendedValue - this.args.currentValue) /
   298          this.args.currentValue
   299      );
   300  
   301      const percent = {
   302        x: (lowerValueText.x + higherValueText.x) / 2,
   303        y,
   304        text: percentText,
   305      };
   306  
   307      if (this.isIncrease) {
   308        return {
   309          original: lowerValueText,
   310          delta: higherValueText,
   311          percent,
   312        };
   313      } else {
   314        return {
   315          original: higherValueText,
   316          delta: lowerValueText,
   317          percent,
   318        };
   319      }
   320    }
   321  
   322    get chartHeight() {
   323      return this.deltaText.original.y + 1;
   324    }
   325  
   326    get tooltipStyle() {
   327      if (this.showLegend) {
   328        return htmlSafe(`left: ${this.mouseX}px`);
   329      }
   330  
   331      return undefined;
   332    }
   333  
   334    get sortedStats() {
   335      if (this.args.stats) {
   336        const statsWithCurrentAndRecommended = {
   337          ...this.args.stats,
   338          current: this.args.currentValue,
   339          recommended: this.args.recommendedValue,
   340        };
   341  
   342        return Object.keys(statsWithCurrentAndRecommended)
   343          .map((key) => ({
   344            label: statsKeyToLabel[key],
   345            value: statsWithCurrentAndRecommended[key],
   346          }))
   347          .sortBy('value');
   348      } else {
   349        return [];
   350      }
   351    }
   352  
   353    @action
   354    isShown() {
   355      next(() => {
   356        this.shown = true;
   357      });
   358    }
   359  
   360    @action
   361    onResize() {
   362      this.width = this.svgElement.clientWidth;
   363      this.height = this.svgElement.clientHeight;
   364    }
   365  
   366    @action
   367    storeSvgElement(element) {
   368      this.svgElement = element;
   369    }
   370  
   371    @action
   372    setLegendPosition(mouseMoveEvent) {
   373      this.showLegend = true;
   374      this.mouseX = mouseMoveEvent.layerX;
   375    }
   376  
   377    @action
   378    setActiveLegendRow(row) {
   379      this.activeLegendRow = row;
   380    }
   381  
   382    @action
   383    unsetActiveLegendRow() {
   384      this.activeLegendRow = undefined;
   385    }
   386  }