From b192603e577ec3274baaf5b241fc611cc93562db Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Thu, 26 Oct 2023 10:06:05 -0700 Subject: [PATCH] feat(ui-toolkit): sparkline shaded area (#5131) --- .../components/sparkline/sparkline.spec.tsx | 135 ++++++++---------- .../sparkline/sparkline.stories.tsx | 73 +++++++--- .../src/components/sparkline/sparkline.tsx | 119 +++++++-------- 3 files changed, 177 insertions(+), 150 deletions(-) diff --git a/libs/ui-toolkit/src/components/sparkline/sparkline.spec.tsx b/libs/ui-toolkit/src/components/sparkline/sparkline.spec.tsx index 7011f0a48..5a45c3a62 100644 --- a/libs/ui-toolkit/src/components/sparkline/sparkline.spec.tsx +++ b/libs/ui-toolkit/src/components/sparkline/sparkline.spec.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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(); + 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(); + 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' + ); + }); }); diff --git a/libs/ui-toolkit/src/components/sparkline/sparkline.stories.tsx b/libs/ui-toolkit/src/components/sparkline/sparkline.stories.tsx index 9cdd4462b..395ee8dee 100644 --- a/libs/ui-toolkit/src/components/sparkline/sparkline.stories.tsx +++ b/libs/ui-toolkit/src/components/sparkline/sparkline.stories.tsx @@ -8,39 +8,49 @@ export default { const Template: Story = (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]', }; diff --git a/libs/ui-toolkit/src/components/sparkline/sparkline.tsx b/libs/ui-toolkit/src/components/sparkline/sparkline.tsx index a988f1765..a929738a8 100644 --- a/libs/ui-toolkit/src/components/sparkline/sparkline.tsx +++ b/libs/ui-toolkit/src/components/sparkline/sparkline.tsx @@ -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 ( - {preMarketCreationPath && ( - + {linePath && ( + + )} + {areaPath && ( + )} - {mainPath && } ); };