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 * * ``` * @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: ['id', '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 { id, previousVersion } = upgradeInfo; this.tooltipUpgradeText = `Vault was upgraded ${previousVersion ? 'from ' + previousVersion : ''} to ${id}`; } const node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node(); this.tooltipTarget = node; }); } }