feat(ui-toolkit): sparkline shaded area (#5131)
This commit is contained in:
parent
8c2c8d987c
commit
b192603e57
@ -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'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -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]',
|
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user