From 8316d3e6241031877e3d849fc2cba4e5789710ea Mon Sep 17 00:00:00 2001 From: Matt Vickers Date: Fri, 4 Oct 2024 11:15:37 -0500 Subject: [PATCH] Adding FunnelChartNext --- packages/polaris-viz-core/src/constants.ts | 1 + .../src/components/FunnelChartNext/Chart.tsx | 210 ++++++++++++++++++ .../FunnelChartNext/FunnelChartNext.tsx | 74 ++++++ .../components/FunnelChartXAxisLabels.tsx | 91 ++++++++ .../components/FunnelSegment.tsx | 74 ++++++ .../FunnelChartNext/components/index.ts | 2 + .../src/components/FunnelChartNext/index.ts | 2 + .../stories/Default.stories.tsx | 21 ++ .../FunnelChartNext/stories/data.tsx | 39 ++++ .../FunnelChartNext/stories/meta.ts | 31 +++ .../src/components/Labels/SingleTextLine.tsx | 15 +- .../src/components/Labels/hooks/useLabels.tsx | 15 +- .../Labels/utilities/getHorizontalLabels.ts | 14 +- .../src/components/TextLine/TextLine.tsx | 13 +- .../estimateStringWidthWithOffset.ts | 10 +- 15 files changed, 593 insertions(+), 19 deletions(-) create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts diff --git a/packages/polaris-viz-core/src/constants.ts b/packages/polaris-viz-core/src/constants.ts index de6f5f8b06..b6cd616ab6 100644 --- a/packages/polaris-viz-core/src/constants.ts +++ b/packages/polaris-viz-core/src/constants.ts @@ -8,6 +8,7 @@ import {InternalChartType, ChartState, Hue} from './types'; export const LINE_HEIGHT = 14; export const FONT_SIZE = 11; +export const FONT_WEIGHT = 300; export const FONT_FAMILY = 'Inter, -apple-system, "system-ui", "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx new file mode 100644 index 0000000000..47042b77e5 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -0,0 +1,210 @@ +import {Fragment, useMemo, useCallback} from 'react'; +import {scaleBand, scaleLinear} from 'd3-scale'; +import type { + DataSeries, + Dimensions, + XAxisOptions, + YAxisOptions, +} from '@shopify/polaris-viz-core'; +import {uniqueId, LinearGradientWithStops} from '@shopify/polaris-viz-core'; + +import {SingleTextLine} from '../Labels'; +import {ChartElements} from '../ChartElements'; +import {MIN_BAR_HEIGHT} from '../../constants'; + +import {FunnelChartXAxisLabels, FunnelSegment} from './components/'; + +export interface ChartProps { + data: DataSeries[]; + xAxisOptions: Required; + yAxisOptions: Required; + dimensions?: Dimensions; +} + +const LINE_OFFSET = 3; +const LINE_WIDTH = 1; + +const GAP = 1; +const BLUE_09 = 'rgba(48, 94, 232, 1)'; +const CONNECTOR_GRADIENT = [ + { + color: '#ADC4FC', + offset: 0, + }, + { + color: '#8BAAF9', + offset: 100, + }, +]; + +const LINE_GRADIENT = [ + { + color: 'rgba(227, 227, 227, 1)', + offset: 0, + }, + { + color: 'rgba(227, 227, 227, 0)', + offset: 100, + }, +]; + +const LABELS_HEIGHT = 80; +const OVERALL_PERCENTAGE_HEIGHT = 30; + +export function Chart({ + data, + dimensions, + xAxisOptions, + yAxisOptions, +}: ChartProps) { + const dataSeries = data[0].data; + + const xValues = dataSeries.map(({key}) => key) as string[]; + const yValues = dataSeries.map(({value}) => value) as [number, number]; + + const {width, height: drawableHeight} = dimensions ?? { + width: 0, + height: 0, + }; + + const labels = useMemo( + () => dataSeries.map(({key}) => xAxisOptions.labelFormatter(key)), + [dataSeries, xAxisOptions], + ); + + const xScale = scaleBand().domain(xValues).range([0, width]); + + const labelXScale = scaleBand() + .range([0, width]) + .domain(labels.map((_, index) => index.toString())); + + const yScale = scaleLinear() + .range([0, drawableHeight - LABELS_HEIGHT - OVERALL_PERCENTAGE_HEIGHT]) + .domain([0, Math.max(...yValues)]); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * 0.75; + + const getBarHeight = useCallback( + (rawValue: number) => { + const rawHeight = Math.abs(yScale(rawValue) - yScale(0)); + const needsMinHeight = rawHeight < MIN_BAR_HEIGHT && rawHeight !== 0; + + return needsMinHeight ? MIN_BAR_HEIGHT : rawHeight; + }, + [yScale], + ); + + const connectorGradientId = useMemo(() => uniqueId('connector-gradient'), []); + const lineGradientId = useMemo(() => uniqueId('line-gradient'), []); + + const lastPoint = dataSeries.at(-1); + const firstPoint = dataSeries[0]; + + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(value)}%`; + } + + const percentages = dataSeries.map((dataPoint) => { + const yAxisValue = dataPoint.value; + + const percentCalculation = + firstPoint?.value && yAxisValue + ? (yAxisValue / firstPoint.value) * 100 + : 0; + + return formatPercentage(percentCalculation); + }); + + const formattedValues = dataSeries.map((dataPoint) => { + return yAxisOptions.labelFormatter(dataPoint.value); + }); + + return ( + + + + + + + + + + + {dataSeries.map((dataPoint, index: number) => { + const nextPoint = dataSeries[index + 1]; + const xPosition = xScale(dataPoint.key as string); + const x = xPosition == null ? 0 : xPosition; + const nextBarHeight = getBarHeight(nextPoint?.value || 0); + + const barHeight = getBarHeight(dataPoint.value || 0); + + return ( + + + + {index > 0 && ( + + )} + + + ); + })} + + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx new file mode 100644 index 0000000000..cafde322bb --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -0,0 +1,74 @@ +import type { + XAxisOptions, + YAxisOptions, + ChartProps, +} from '@shopify/polaris-viz-core'; +import { + DEFAULT_CHART_PROPS, + ChartState, + usePolarisVizContext, +} from '@shopify/polaris-viz-core'; + +import {ChartContainer} from '../../components/ChartContainer'; +import { + getYAxisOptionsWithDefaults, + getXAxisOptionsWithDefaults, +} from '../../utilities'; +import {ChartSkeleton} from '../'; + +import {Chart} from './Chart'; + +export type FunnelChartNextProps = { + xAxisOptions?: Omit; + yAxisOptions?: Omit; +} & ChartProps; + +export function FunnelChartNext(props: FunnelChartNextProps) { + const {defaultTheme} = usePolarisVizContext(); + + const { + data, + theme = defaultTheme, + xAxisOptions, + yAxisOptions, + id, + isAnimated, + state, + errorText, + onError, + } = { + ...DEFAULT_CHART_PROPS, + ...props, + }; + + const xAxisOptionsForChart: Required = + getXAxisOptionsWithDefaults(xAxisOptions); + + const yAxisOptionsForChart: Required = + getYAxisOptionsWithDefaults(yAxisOptions); + + return ( + + {state !== ChartState.Success ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx new file mode 100644 index 0000000000..8431749a9e --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx @@ -0,0 +1,91 @@ +import {Fragment} from 'react'; +import type {ScaleBand} from 'd3-scale'; + +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine, useLabels} from '../../Labels'; +import {TextLine} from '../../TextLine'; + +const LINE_GAP = 5; +const LINE_PADDING = 10; +const GROUP_OFFSET = 10; +const LABEL_FONT_SIZE = 12; + +export interface FunnelChartXAxisLabelsProps { + formattedValues: string[]; + labels: string[]; + labelWidth: number; + percentages: string[]; + xScale: ScaleBand; +} + +export function FunnelChartXAxisLabels({ + formattedValues, + labels, + labelWidth, + percentages, + xScale, +}: FunnelChartXAxisLabelsProps) { + const {lines} = useLabels({ + allowLineWrap: true, + align: 'left', + fontSize: LABEL_FONT_SIZE, + labels, + targetWidth: labelWidth - GROUP_OFFSET * 2, + }); + + return ( + + {lines.map((line, index) => { + const x = xScale(index.toString()) ?? 0; + + const firstLabelHeight = line.reduce( + (acc, {height}) => acc + height, + 0, + ); + + const percentWidth = estimateStringWidthWithOffset( + percentages[index], + 14, + 650, + ); + + return ( + + + + + + + + + ); + })} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx new file mode 100644 index 0000000000..5281a95826 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx @@ -0,0 +1,74 @@ +import {Fragment, useRef} from 'react'; +import {useSpring, animated, to} from '@react-spring/web'; +import {getRoundedRectPath} from '@shopify/polaris-viz-core'; + +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +const BORDER_RADIUS = 6; + +export function FunnelSegment({ + ariaLabel, + barHeight, + barWidth, + color, + connector, + drawableHeight, + index = 0, + isLast, + x, +}) { + const mounted = useRef(false); + + const springConfig = useBarSpringConfig({animationDelay: index * 150}); + const isFirst = index === 0; + + const {animatedHeight, animatedStartY, animatedNextY} = useSpring({ + from: { + animatedHeight: mounted.current ? barHeight : 0, + animatedStartY: drawableHeight, + animatedNextY: drawableHeight, + }, + to: { + animatedHeight: barHeight, + animatedStartY: connector.startY, + animatedNextY: connector.nextY, + }, + ...springConfig, + }); + + return ( + + + getRoundedRectPath({ + height: value, + width: barWidth, + borderRadius: `${isFirst ? BORDER_RADIUS : 0} ${ + isLast ? BORDER_RADIUS : 0 + } 0 0`, + }), + )} + style={{ + transform: animatedHeight.to( + (value: number) => `translate(${x}px, ${drawableHeight - value}px)`, + ), + }} + /> + {!isLast && ( + + `M${connector.startX} ${startY} + L ${connector.nextX} ${nextY} + V ${connector.height} H ${connector.startX} Z`, + )} + fill={connector.fill} + /> + )} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts new file mode 100644 index 0000000000..605bf4748c --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -0,0 +1,2 @@ +export {FunnelChartXAxisLabels} from './FunnelChartXAxisLabels'; +export {FunnelSegment} from './FunnelSegment'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/index.ts new file mode 100644 index 0000000000..5388503aa4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/index.ts @@ -0,0 +1,2 @@ +export {FunnelChartNext} from './FunnelChartNext'; +export type {FunnelChartNextProps} from './FunnelChartNext'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx new file mode 100644 index 0000000000..7c95d7dd79 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -0,0 +1,21 @@ +import type {Story} from '@storybook/react'; + +export {META as default} from './meta'; + +import type {FunnelChartProps} from '../../../components'; + +import {DEFAULT_DATA, Template} from './data'; + +export const Default: Story = Template.bind({}); + +Default.args = { + data: DEFAULT_DATA, + yAxisOptions: { + labelFormatter: (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); + }, + }, +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx new file mode 100644 index 0000000000..6b3a66df4d --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx @@ -0,0 +1,39 @@ +import type {DataSeries} from '@shopify/polaris-viz-core'; +import type {Story} from '@storybook/react'; + +import type {FunnelChartNextProps} from '../FunnelChartNext'; +import {FunnelChartNext} from '../FunnelChartNext'; + +export const DEFAULT_DATA: DataSeries[] = [ + { + data: [ + { + value: 454662, + key: 'Sessions', + }, + { + value: 47887, + key: 'Sessions with cart addition', + }, + { + value: 54654, + key: 'Sessions that reacted checkout', + }, + { + value: 22543, + key: 'Sessions that completed checkout', + }, + ], + name: 'Conversion rates', + }, +]; + +export const Template: Story = ( + args: FunnelChartNextProps, +) => { + return ( +
+ +
+ ); +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts new file mode 100644 index 0000000000..b08bc538a4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -0,0 +1,31 @@ +import type {Meta} from '@storybook/react'; + +import { + CHART_STATE_CONTROL_ARGS, + CONTROLS_ARGS, + THEME_CONTROL_ARGS, + X_AXIS_OPTIONS_ARGS, + Y_AXIS_OPTIONS_ARGS, +} from '../../../storybook/constants'; +import {PageWithSizingInfo} from '../../Docs/stories'; +import {FunnelChartNext} from '../FunnelChartNext'; + +export const META: Meta = { + title: 'polaris-viz/Charts/FunnelChartNext', + component: FunnelChartNext, + parameters: { + controls: CONTROLS_ARGS, + docs: { + page: PageWithSizingInfo, + description: { + component: 'Used to show conversion data.', + }, + }, + }, + argTypes: { + xAxisOptions: X_AXIS_OPTIONS_ARGS, + yAxisOptions: Y_AXIS_OPTIONS_ARGS, + theme: THEME_CONTROL_ARGS, + state: CHART_STATE_CONTROL_ARGS, + }, +}; diff --git a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx index e80ea80f40..b91b5eed73 100644 --- a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx +++ b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx @@ -13,22 +13,26 @@ interface SingleTextLineProps { color: string; targetWidth: number; text: string; - x: number; - y: number; ariaHidden?: boolean; dominantBaseline?: 'middle' | 'hanging'; + fontSize?: number; + fontWeight?: number; textAnchor?: 'left' | 'center' | 'right'; + x?: number; + y?: number; } export function SingleTextLine({ ariaHidden = false, color, dominantBaseline = 'hanging', + fontSize = FONT_SIZE, + fontWeight = 300, targetWidth, text, textAnchor = 'center', - y, - x, + y = 0, + x = 0, }: SingleTextLineProps) { const {characterWidths} = useChartContext(); @@ -48,7 +52,8 @@ export function SingleTextLine({ height={LINE_HEIGHT} width={targetWidth} fill={color} - fontSize={FONT_SIZE} + fontSize={fontSize} + fontWeight={fontWeight} fontFamily={FONT_FAMILY} y={y} x={x} diff --git a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx index b4720e63c6..7114a7d6cf 100644 --- a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx +++ b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx @@ -1,7 +1,8 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useMemo} from 'react'; -import {estimateStringWidth, useChartContext} from '@shopify/polaris-viz-core'; +import {FONT_SIZE, useChartContext} from '@shopify/polaris-viz-core'; +import {estimateStringWidthWithOffset} from '../../../utilities'; import { LINE_HEIGHT, DIAGONAL_LABEL_MIN_WIDTH, @@ -18,10 +19,14 @@ interface Props { labels: string[]; targetWidth: number; onHeightChange?: Dispatch> | (() => void); + align?: 'center' | 'left'; + fontSize?: number; } export function useLabels({ allowLineWrap, + align = 'center', + fontSize = FONT_SIZE, labels, onHeightChange = () => {}, targetWidth, @@ -42,7 +47,7 @@ export function useLabels({ const longestLabelWidth = useMemo(() => { return labels.reduce((prev, string) => { - const newWidth = estimateStringWidth(string, characterWidths); + const newWidth = estimateStringWidthWithOffset(string, fontSize); if (newWidth > prev) { return newWidth; @@ -50,7 +55,7 @@ export function useLabels({ return prev; }, 0); - }, [labels, characterWidths]); + }, [labels, fontSize]); const {lines, containerHeight} = useMemo(() => { const shouldDrawHorizontal = checkIfShouldDrawHorizontal({ @@ -64,6 +69,8 @@ export function useLabels({ switch (true) { case shouldDrawHorizontal: { return getHorizontalLabels({ + align, + fontSize, labels: preparedLabels, targetWidth, targetHeight: HORIZONTAL_LABEL_TARGET_HEIGHT, @@ -95,7 +102,9 @@ export function useLabels({ } } }, [ + align, allowLineWrap, + fontSize, targetWidth, characterWidths, preparedLabels, diff --git a/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts b/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts index bd6e4ccc52..1a9a733fbd 100644 --- a/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts +++ b/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts @@ -1,6 +1,6 @@ import type {CharacterWidths} from '@shopify/polaris-viz-core'; -import {estimateStringWidth} from '@shopify/polaris-viz-core'; +import {estimateStringWidthWithOffset} from '../../../utilities'; import {LINE_HEIGHT} from '../../../constants'; import type {FormattedLine, PreparedLabels} from '../../../types'; @@ -10,6 +10,8 @@ import {truncateLabels} from './truncateLabels'; const NEXT_INDEX = 1; interface Props { + align: 'center' | 'left'; + fontSize: number; labels: PreparedLabels[]; targetHeight: number; targetWidth: number; @@ -17,6 +19,8 @@ interface Props { } export function getHorizontalLabels({ + align, + fontSize, labels, targetHeight, targetWidth, @@ -61,9 +65,9 @@ export function getHorizontalLabels({ while ( words[wordIndex + 1] != null && - estimateStringWidth( + estimateStringWidthWithOffset( `${line} ${words[wordIndex + NEXT_INDEX]}`, - characterWidths, + fontSize, ) < targetWidth ) { line += ` ${words[wordIndex + NEXT_INDEX]}`; @@ -73,11 +77,11 @@ export function getHorizontalLabels({ lines[index].push({ truncatedText: line, fullText: truncatedLabels[index].text, - x: targetWidth / 2, + x: align === 'left' ? 0 : targetWidth / 2, y: lineNumber * LINE_HEIGHT, width: targetWidth, height: LINE_HEIGHT, - textAnchor: 'middle', + textAnchor: align === 'left' ? 'start' : 'middle', dominantBaseline: 'hanging', }); diff --git a/packages/polaris-viz/src/components/TextLine/TextLine.tsx b/packages/polaris-viz/src/components/TextLine/TextLine.tsx index c25c3741cf..37eca6f39a 100644 --- a/packages/polaris-viz/src/components/TextLine/TextLine.tsx +++ b/packages/polaris-viz/src/components/TextLine/TextLine.tsx @@ -8,9 +8,16 @@ import type {FormattedLine} from '../../types'; interface TextLineProps { index: number; line: FormattedLine[]; + color?: string; + fontSize?: number; } -export function TextLine({index, line}: TextLineProps) { +export function TextLine({ + color, + index, + line, + fontSize = FONT_SIZE, +}: TextLineProps) { const selectedTheme = useTheme(); return ( @@ -41,8 +48,8 @@ export function TextLine({index, line}: TextLineProps) { width={width} x={x} y={y} - fill={selectedTheme.xAxis.labelColor} - fontSize={FONT_SIZE} + fill={color ?? selectedTheme.xAxis.labelColor} + fontSize={fontSize} fontFamily={FONT_FAMILY} transform={transform} > diff --git a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts index 2d5547c0d6..9ad72704e3 100644 --- a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts +++ b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts @@ -1,12 +1,16 @@ -import {estimateStringWidth} from '@shopify/polaris-viz-core'; +import { + estimateStringWidth, + FONT_SIZE, + FONT_WEIGHT, +} from '@shopify/polaris-viz-core'; import characterWidths from '../data/character-widths.json'; import characterWidthOffsets from '../data/character-width-offsets.json'; export function estimateStringWidthWithOffset( string: string, - fontSize: number, - fontWeight: number, + fontSize: number = FONT_SIZE, + fontWeight: number = FONT_WEIGHT, ) { const width = estimateStringWidth(string, characterWidths);