feat(ui-toolkit): sparkline shaded area (#5131)
This commit is contained in:
parent
8c2c8d987c
commit
b192603e57
@ -1,77 +1,68 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { Sparkline } from './sparkline';
|
||||
import type { SparklineProps } from './sparkline';
|
||||
|
||||
const props = {
|
||||
data: [
|
||||
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
|
||||
10, 11, 12,
|
||||
],
|
||||
muted: true,
|
||||
};
|
||||
describe('Sparkline', () => {
|
||||
let props: SparklineProps = {
|
||||
data: [],
|
||||
};
|
||||
|
||||
it('Renders an svg with a single path', () => {
|
||||
render(<Sparkline {...props} />);
|
||||
expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument();
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toBeInTheDocument();
|
||||
expect(path).toHaveAttribute('d', expect.any(String));
|
||||
expect(path).toHaveAttribute('stroke', expect.any(String));
|
||||
expect(path).toHaveAttribute('stroke-width', '1');
|
||||
expect(path).toHaveAttribute('fill', 'transparent');
|
||||
});
|
||||
|
||||
it('Requires a data prop but width and height are optional', () => {
|
||||
render(<Sparkline {...props} />);
|
||||
const svg = screen.getByTestId('sparkline-svg');
|
||||
expect(svg).toHaveAttribute('width', '60');
|
||||
expect(svg).toHaveAttribute('height', '15');
|
||||
});
|
||||
|
||||
it('Renders a red line if the last value is less than the first', () => {
|
||||
props.data[0] = 10;
|
||||
props.data[props.data.length - 1] = 5;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[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', () => {
|
||||
props.data[0] = 5;
|
||||
props.data[props.data.length - 1] = 10;
|
||||
props.muted = true;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[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', () => {
|
||||
props.data[0] = 5;
|
||||
props.data[props.data.length - 1] = 5;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[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'
|
||||
);
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
data: [
|
||||
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8,
|
||||
9, 10, 11, 12,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('Renders an svg with a single path', () => {
|
||||
render(<Sparkline {...props} />);
|
||||
expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument();
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toBeInTheDocument();
|
||||
expect(path).toHaveAttribute('d', expect.any(String));
|
||||
expect(path).toHaveAttribute('stroke-width', '1');
|
||||
});
|
||||
|
||||
it('Requires a data prop but width and height are optional', () => {
|
||||
render(<Sparkline {...props} />);
|
||||
const svg = screen.getByTestId('sparkline-svg');
|
||||
expect(svg).toHaveAttribute('width', '60');
|
||||
expect(svg).toHaveAttribute('height', '15');
|
||||
});
|
||||
|
||||
it('Renders a red line if the last value is less than the first', () => {
|
||||
props.data[0] = 10;
|
||||
props.data[props.data.length - 1] = 5;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[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', () => {
|
||||
props.data[0] = 5;
|
||||
props.data[props.data.length - 1] = 10;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[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', () => {
|
||||
props.data[0] = 5;
|
||||
props.data[props.data.length - 1] = 5;
|
||||
render(<Sparkline {...props} />);
|
||||
const paths = screen.getAllByTestId('sparkline-path');
|
||||
const path = paths[0];
|
||||
expect(path).toHaveClass(
|
||||
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -8,39 +8,49 @@ export default {
|
||||
|
||||
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({});
|
||||
Equal.args = {
|
||||
data: [
|
||||
12, 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,
|
||||
points: 25,
|
||||
className: 'w-[113px]',
|
||||
};
|
||||
|
||||
export const Increase = Template.bind({});
|
||||
Increase.args = {
|
||||
data: [
|
||||
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
|
||||
10, 11, 12,
|
||||
22,
|
||||
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,
|
||||
points: 25,
|
||||
className: 'w-[113px]',
|
||||
};
|
||||
|
||||
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,
|
||||
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,
|
||||
points: 25,
|
||||
className: 'w-[113px]',
|
||||
};
|
||||
|
@ -1,15 +1,25 @@
|
||||
import { extent } from 'd3-array';
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
import { line } from 'd3-shape';
|
||||
import { area, line } from 'd3-shape';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import React from 'react';
|
||||
|
||||
function colorByChange(a: number, b: number) {
|
||||
return a === b
|
||||
? 'stroke-black/40 dark:stroke-white/40'
|
||||
: a < b
|
||||
? 'stroke-market-green-600 dark:stroke-market-green'
|
||||
: 'stroke-market-red dark:stroke-market-red';
|
||||
if (a < b) {
|
||||
return 'stroke-market-green-600 dark:stroke-market-green';
|
||||
} else if (a > b) {
|
||||
return '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 {
|
||||
@ -18,75 +28,60 @@ export interface SparklineProps {
|
||||
height?: number;
|
||||
points?: number;
|
||||
className?: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export const SparklineView = ({
|
||||
data,
|
||||
width = 60,
|
||||
height = 15,
|
||||
points = 25,
|
||||
muted = false,
|
||||
points = 24,
|
||||
className,
|
||||
}: 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
|
||||
const [min, max] = extent(marketData, (d) => d[1]);
|
||||
const [min, max] = extent(data, (d) => d);
|
||||
|
||||
if (typeof min !== 'number' || typeof max !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a second set of data to render a gray line for any
|
||||
// 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]);
|
||||
const midValue = (min + max) / 2;
|
||||
|
||||
// Add the first point of or market data so that the two
|
||||
// lines join up
|
||||
preMarketData.push(marketData[0] as [number, number]);
|
||||
}
|
||||
// Market may be less than 24hr old so padd the data array
|
||||
// with values that is the mid value (avg of min and max).
|
||||
// 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);
|
||||
|
||||
const xScale = scaleLinear().domain([0, points]).range([0, 100]);
|
||||
const yScale = scaleLinear().domain([min, max]).range([100, 0]);
|
||||
// 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, width]);
|
||||
const yScale = scaleLinear().domain([min, max]).range([height, 0]);
|
||||
|
||||
const lineSeries = line()
|
||||
.x((d) => xScale(d[0]))
|
||||
.y((d) => yScale(d[1]));
|
||||
|
||||
// Get the color of the marketData line
|
||||
const [firstVal, lastVal] = [data[0], data[data.length - 1]];
|
||||
const strokeClassName = muted
|
||||
? data.length >= 24
|
||||
? colorByChange(firstVal, lastVal)
|
||||
: 'stroke-black/40 dark:stroke-white/40'
|
||||
: colorByChange(firstVal, lastVal);
|
||||
const areaSeries = area()
|
||||
.x((d) => xScale(d[0]))
|
||||
.y0(height)
|
||||
.y1((d) => yScale(d[1]));
|
||||
|
||||
const firstVal = trimmedData[0];
|
||||
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
|
||||
const preMarketCreationPath = lineSeries(preMarketData);
|
||||
const mainPath = lineSeries(marketData);
|
||||
const pathProps = {
|
||||
'data-testid': 'sparkline-path',
|
||||
className: `[vector-effect:non-scaling-stroke] ${strokeClassName}`,
|
||||
stroke: 'strokeCurrent',
|
||||
strokeWidth: 1,
|
||||
fill: 'transparent',
|
||||
};
|
||||
const linePath = lineSeries(lineData);
|
||||
const areaPath = areaSeries(lineData);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@ -94,13 +89,25 @@ export const SparklineView = ({
|
||||
className={className}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 100 100"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{preMarketCreationPath && (
|
||||
<path {...pathProps} d={preMarketCreationPath} />
|
||||
{linePath && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user