feat(ui-toolkit): sparkline shaded area (#5131)

This commit is contained in:
Matthew Russell 2023-10-26 10:06:05 -07:00 committed by GitHub
parent 8c2c8d987c
commit b192603e57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 177 additions and 150 deletions

View File

@ -1,35 +1,39 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { Sparkline } from './sparkline'; import { Sparkline } from './sparkline';
import type { SparklineProps } from './sparkline';
const props = { describe('Sparkline', () => {
let props: SparklineProps = {
data: [],
};
beforeEach(() => {
props = {
data: [ data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9, 1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8,
10, 11, 12, 9, 10, 11, 12,
], ],
muted: true, };
}; });
it('Renders an svg with a single path', () => { it('Renders an svg with a single path', () => {
render(<Sparkline {...props} />); render(<Sparkline {...props} />);
expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument(); expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument();
const paths = screen.getAllByTestId('sparkline-path'); const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0]; const path = paths[0];
expect(path).toBeInTheDocument(); expect(path).toBeInTheDocument();
expect(path).toHaveAttribute('d', expect.any(String)); expect(path).toHaveAttribute('d', expect.any(String));
expect(path).toHaveAttribute('stroke', expect.any(String));
expect(path).toHaveAttribute('stroke-width', '1'); expect(path).toHaveAttribute('stroke-width', '1');
expect(path).toHaveAttribute('fill', 'transparent'); });
});
it('Requires a data prop but width and height are optional', () => { it('Requires a data prop but width and height are optional', () => {
render(<Sparkline {...props} />); render(<Sparkline {...props} />);
const svg = screen.getByTestId('sparkline-svg'); const svg = screen.getByTestId('sparkline-svg');
expect(svg).toHaveAttribute('width', '60'); expect(svg).toHaveAttribute('width', '60');
expect(svg).toHaveAttribute('height', '15'); expect(svg).toHaveAttribute('height', '15');
}); });
it('Renders a red line if the last value is less than the first', () => { it('Renders a red line if the last value is less than the first', () => {
props.data[0] = 10; props.data[0] = 10;
props.data[props.data.length - 1] = 5; props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />); render(<Sparkline {...props} />);
@ -38,21 +42,20 @@ it('Renders a red line if the last value is less than the first', () => {
expect(path).toHaveClass( expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-red dark:stroke-market-red' '[vector-effect:non-scaling-stroke] stroke-market-red dark:stroke-market-red'
); );
}); });
it('Renders a green line if the last value is greater than the first', () => { it('Renders a green line if the last value is greater than the first', () => {
props.data[0] = 5; props.data[0] = 5;
props.data[props.data.length - 1] = 10; props.data[props.data.length - 1] = 10;
props.muted = true;
render(<Sparkline {...props} />); render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path'); const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0]; const path = paths[0];
expect(path).toHaveClass( expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-green-600 dark:stroke-market-green' '[vector-effect:non-scaling-stroke] stroke-market-green-600 dark:stroke-market-green'
); );
}); });
it('Renders a white line if the first and last values are equal', () => { it('Renders a white line if the first and last values are equal', () => {
props.data[0] = 5; props.data[0] = 5;
props.data[props.data.length - 1] = 5; props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />); render(<Sparkline {...props} />);
@ -61,17 +64,5 @@ it('Renders a white line if the first and last values are equal', () => {
expect(path).toHaveClass( expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40' '[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
); );
}); });
it('Renders a gray line if there are not 24 values', () => {
props.data = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
22, 23,
];
render(<Sparkline {...props} />);
const paths = screen.queryAllByTestId('sparkline-path');
expect(paths).toHaveLength(2);
expect(paths[0]).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
);
}); });

View File

@ -8,39 +8,49 @@ export default {
const Template: Story = (args) => <Sparkline data={args['data']} {...args} />; const Template: Story = (args) => <Sparkline data={args['data']} {...args} />;
export const Grey = Template.bind({});
Grey.args = {
data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8,
],
width: 60,
height: 30,
points: 25,
className: 'w-[113px]',
};
export const Equal = Template.bind({}); export const Equal = Template.bind({});
Equal.args = { Equal.args = {
data: [ data: [
12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9, 12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 12, 10, 11, 12,
], ],
width: 60, width: 110,
height: 30, height: 30,
points: 25,
className: 'w-[113px]',
}; };
export const Increase = Template.bind({}); export const Increase = Template.bind({});
Increase.args = { Increase.args = {
data: [ data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9, 22,
10, 11, 12, 22,
22, // extra values should be ignored, this should still render an increase
0,
2,
3,
4,
5,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
6,
7,
8,
9,
10,
11,
12,
], ],
width: 60, width: 110,
height: 30, height: 30,
points: 25,
className: 'w-[113px]',
}; };
export const Decrease = Template.bind({}); export const Decrease = Template.bind({});
@ -49,8 +59,27 @@ Decrease.args = {
12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9, 12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 1, 10, 11, 1,
], ],
width: 60, width: 110,
height: 30,
};
export const LessThan24HoursIncrease = Template.bind({});
LessThan24HoursIncrease.args = {
data: [20, 21, 22, 25, 24, 24, 22, 19, 20, 22, 23, 27],
width: 110,
height: 30,
};
export const LessThan24HoursDecrease = Template.bind({});
LessThan24HoursDecrease.args = {
data: [20, 21, 22, 23, 24, 6, 7, 9, 11, 13, 11, 9],
width: 110,
height: 30,
};
export const NoData = Template.bind({});
NoData.args = {
data: [],
width: 110,
height: 30, height: 30,
points: 25,
className: 'w-[113px]',
}; };

View File

@ -1,15 +1,25 @@
import { extent } from 'd3-array'; import { extent } from 'd3-array';
import { scaleLinear } from 'd3-scale'; import { scaleLinear } from 'd3-scale';
import { line } from 'd3-shape'; import { area, line } from 'd3-shape';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import React from 'react'; import React from 'react';
function colorByChange(a: number, b: number) { function colorByChange(a: number, b: number) {
return a === b if (a < b) {
? 'stroke-black/40 dark:stroke-white/40' return 'stroke-market-green-600 dark:stroke-market-green';
: a < b } else if (a > b) {
? 'stroke-market-green-600 dark:stroke-market-green' return 'stroke-market-red dark:stroke-market-red';
: 'stroke-market-red dark:stroke-market-red'; }
return 'stroke-black/40 dark:stroke-white/40';
}
function shadedColor(a: number, b: number) {
if (a < b) {
return 'fill-market-green-600 dark:fill-market-green';
} else if (a > b) {
return 'fill-market-red-600 dark:fill-market-red';
}
return 'fill-black dark:fill-white';
} }
export interface SparklineProps { export interface SparklineProps {
@ -18,75 +28,60 @@ export interface SparklineProps {
height?: number; height?: number;
points?: number; points?: number;
className?: string; className?: string;
muted?: boolean;
} }
export const SparklineView = ({ export const SparklineView = ({
data, data,
width = 60, width = 60,
height = 15, height = 15,
points = 25, points = 24,
muted = false,
className, className,
}: SparklineProps) => { }: SparklineProps) => {
// How many points are missing. If market is 12 hours old the 25 - 12
const preMarketLength = points - data.length;
// Create two dimensional array for sparkline points [x, y]
const marketData: [number, number][] = data.map((d, i) => [
preMarketLength + i,
d,
]);
// Empty two dimensional array for gray, 'no data' line
let preMarketData: [number, number][] = [];
// Get the extent for our y value // Get the extent for our y value
const [min, max] = extent(marketData, (d) => d[1]); const [min, max] = extent(data, (d) => d);
if (typeof min !== 'number' || typeof max !== 'number') { if (typeof min !== 'number' || typeof max !== 'number') {
return null; return null;
} }
// Create a second set of data to render a gray line for any const midValue = (min + max) / 2;
// missing points if the market is less than 24 hours old
if (marketData.length < points) {
// Populate preMarketData with the average of our extents
// so that the line renders centered vertically
const fillValue = (min + max) / 2;
preMarketData = new Array(points - marketData.length)
.fill(fillValue)
.map((d: number, i) => [i, d] as [number, number]);
// Add the first point of or market data so that the two // Market may be less than 24hr old so padd the data array
// lines join up // with values that is the mid value (avg of min and max).
preMarketData.push(marketData[0] as [number, number]); // This will rendera horizontal line until the real data shifts the line
const padCount = data.length < points ? points - data.length : 0;
const padArr = new Array(padCount).fill(midValue);
const trimmedData = data.slice(-points);
// Get the last 24 values if data has more than needed
const lineData: [number, number][] = [...padArr, ...trimmedData].map(
(d, i) => {
return [i, d];
} }
);
const xScale = scaleLinear().domain([0, points]).range([0, 100]); const xScale = scaleLinear().domain([0, points]).range([0, width]);
const yScale = scaleLinear().domain([min, max]).range([100, 0]); const yScale = scaleLinear().domain([min, max]).range([height, 0]);
const lineSeries = line() const lineSeries = line()
.x((d) => xScale(d[0])) .x((d) => xScale(d[0]))
.y((d) => yScale(d[1])); .y((d) => yScale(d[1]));
// Get the color of the marketData line const areaSeries = area()
const [firstVal, lastVal] = [data[0], data[data.length - 1]]; .x((d) => xScale(d[0]))
const strokeClassName = muted .y0(height)
? data.length >= 24 .y1((d) => yScale(d[1]));
? colorByChange(firstVal, lastVal)
: 'stroke-black/40 dark:stroke-white/40' const firstVal = trimmedData[0];
: colorByChange(firstVal, lastVal); const lastVal = trimmedData[trimmedData.length - 1];
// Get the color of the marketData line depending on market movement
const strokeClassName = colorByChange(firstVal, lastVal);
const areaClassName = shadedColor(firstVal, lastVal);
// Create paths // Create paths
const preMarketCreationPath = lineSeries(preMarketData); const linePath = lineSeries(lineData);
const mainPath = lineSeries(marketData); const areaPath = areaSeries(lineData);
const pathProps = {
'data-testid': 'sparkline-path',
className: `[vector-effect:non-scaling-stroke] ${strokeClassName}`,
stroke: 'strokeCurrent',
strokeWidth: 1,
fill: 'transparent',
};
return ( return (
<svg <svg
@ -94,13 +89,25 @@ export const SparklineView = ({
className={className} className={className}
width={width} width={width}
height={height} height={height}
viewBox="0 0 100 100" viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none" preserveAspectRatio="none"
> >
{preMarketCreationPath && ( {linePath && (
<path {...pathProps} d={preMarketCreationPath} /> <path
d={linePath}
data-testid="sparkline-path"
className={`[vector-effect:non-scaling-stroke] fill-transparent ${strokeClassName}`}
strokeWidth={1}
/>
)}
{areaPath && (
<path
className={areaClassName}
fillOpacity={0.2}
stroke="none"
d={areaPath}
/>
)} )}
{mainPath && <path {...pathProps} d={mainPath} />}
</svg> </svg>
); );
}; };