vault/ui/app/components/clients/line-chart.js
hashicorp-copywrite[bot] 0b12cdcfd1
[COMPLIANCE] License changes (#22290)
* Adding explicit MPL license for sub-package.

This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository.

* Adding explicit MPL license for sub-package.

This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository.

* Updating the license from MPL to Business Source License.

Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl.

* add missing license headers

* Update copyright file headers to BUS-1.1

* Fix test that expected exact offset on hcl file

---------

Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
Co-authored-by: Sarah Thompson <sthompson@hashicorp.com>
Co-authored-by: Brian Kassouf <bkassouf@hashicorp.com>
2023-08-10 18:14:03 -07:00

202 lines
6.7 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { max } from 'd3-array';
// eslint-disable-next-line no-unused-vars
import { select, selectAll, node } from 'd3-selection';
import { axisLeft, axisBottom } from 'd3-axis';
import { scaleLinear, scalePoint } from 'd3-scale';
import { line } from 'd3-shape';
import {
LIGHT_AND_DARK_BLUE,
UPGRADE_WARNING,
SVG_DIMENSIONS,
formatNumbers,
} from 'vault/utils/chart-helpers';
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
import { formatNumber } from 'core/helpers/format-number';
/**
* @module LineChart
* LineChart components are used to display data in a line plot with accompanying tooltip
*
* @example
* ```js
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
* ```
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
* @param {array} upgradeData - array of objects containing version history from the /version-history endpoint
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/
export default class LineChart extends Component {
@tracked tooltipTarget = '';
@tracked tooltipMonth = '';
@tracked tooltipTotal = '';
@tracked tooltipNew = '';
@tracked tooltipUpgradeText = '';
get yKey() {
return this.args.yKey || 'clients';
}
get xKey() {
return this.args.xKey || 'month';
}
get upgradeData() {
const upgradeData = this.args.upgradeData;
if (!upgradeData) return null;
if (!Array.isArray(upgradeData)) {
console.debug('upgradeData must be an array of objects containing upgrade history'); // eslint-disable-line
return null;
} else if (!Object.keys(upgradeData[0]).includes('timestampInstalled')) {
// eslint-disable-next-line
console.debug(
`upgrade must be an object with the following key names: ['version', 'previousVersion', 'timestampInstalled']`
);
return null;
} else {
return upgradeData?.map((versionData) => {
return {
[this.xKey]: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'),
...versionData,
};
});
}
}
@action removeTooltip() {
this.tooltipTarget = null;
}
@action
renderChart(element, [chartData]) {
const dataset = chartData;
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
const domainMax = max(filteredData.map((d) => d[this.yKey]));
const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// clear out DOM before appending anything
chartSvg.selectAll('g').remove().exit().data(filteredData).enter();
// DEFINE AXES SCALES
const yScale = scaleLinear().domain([0, domainMax]).range([0, 100]).nice();
const yAxisScale = scaleLinear().domain([0, domainMax]).range([SVG_DIMENSIONS.height, 0]).nice();
// use full dataset (instead of filteredData) so x-axis spans months with and without data
const xScale = scalePoint()
.domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width])
.padding(0.2);
// CUSTOMIZE AND APPEND AXES
const yAxis = axisLeft(yAxisScale)
.ticks(4)
.tickPadding(10)
.tickSizeInner(-SVG_DIMENSIONS.width) // makes grid lines length of svg
.tickFormat(formatNumbers);
const xAxis = axisBottom(xScale).tickSize(0);
yAxis(chartSvg.append('g').attr('data-test-line-chart', 'y-axis-labels'));
xAxis(
chartSvg
.append('g')
.attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)
.attr('data-test-line-chart', 'x-axis-labels')
);
chartSvg.selectAll('.domain').remove();
const findUpgradeData = (datum) => {
return this.upgradeData
? this.upgradeData.find((upgrade) => upgrade[this.xKey] === datum[this.xKey])
: null;
};
// VERSION UPGRADE INDICATOR
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('class', 'upgrade-circle')
.attr('data-test-line-chart', (d) => `upgrade-${d[this.xKey]}`)
.attr('fill', UPGRADE_WARNING)
.style('opacity', (d) => (findUpgradeData(d) ? '1' : '0'))
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
// PATH BETWEEN PLOT POINTS
const lineGenerator = line()
.x((d) => xScale(d[this.xKey]))
.y((d) => yAxisScale(d[this.yKey]));
chartSvg
.append('g')
.append('path')
.attr('fill', 'none')
.attr('stroke', LIGHT_AND_DARK_BLUE[1])
.attr('stroke-width', 0.5)
.attr('d', lineGenerator(filteredData));
// LINE PLOTS (CIRCLES)
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('data-test-line-chart', 'plot-point')
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 3.5)
.attr('fill', LIGHT_AND_DARK_BLUE[0])
.attr('stroke', LIGHT_AND_DARK_BLUE[1])
.attr('stroke-width', 1.5);
// LARGER HOVER CIRCLES
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('class', 'hover-circle')
.style('cursor', 'pointer')
.style('opacity', '0')
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
const hoverCircles = chartSvg.selectAll('.hover-circle');
// MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => {
// TODO: how to generalize this?
this.tooltipMonth = formatChartDate(data[this.xKey]);
this.tooltipTotal = formatNumber([data[this.yKey]]) + ' total clients';
this.tooltipNew = (formatNumber([data?.new_clients[this.yKey]]) || '0') + ' new clients';
this.tooltipUpgradeText = '';
const upgradeInfo = findUpgradeData(data);
if (upgradeInfo) {
const { version, previousVersion } = upgradeInfo;
this.tooltipUpgradeText = `Vault was upgraded
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
}
const node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();
this.tooltipTarget = node;
});
}
}