diff --git a/packages/polaris-viz/CHANGELOG.md b/packages/polaris-viz/CHANGELOG.md index 6d9a53ba7..6d555e883 100644 --- a/packages/polaris-viz/CHANGELOG.md +++ b/packages/polaris-viz/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - +## Unreleased + +### Added + +- Added `` and ``. + ## [15.5.0] - 2024-12-17 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 000000000..214685fc8 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -0,0 +1,287 @@ +import type {ReactNode} from 'react'; +import {Fragment, useMemo, useState} from 'react'; +import {scaleBand, scaleLinear} from 'd3-scale'; +import type {DataSeries, LabelFormatter} from '@shopify/polaris-viz-core'; +import { + uniqueId, + LinearGradientWithStops, + useChartContext, +} from '@shopify/polaris-viz-core'; + +import {useFunnelBarScaling} from '../../hooks'; +import { + FunnelChartConnector, + FunnelChartConnectorGradient, +} from '../shared/FunnelChartConnector'; +import {FunnelChartSegment} from '../shared'; +import {SingleTextLine} from '../Labels'; +import {ChartElements} from '../ChartElements'; + +import { + FunnelChartLabels, + Tooltip, + FunnelTooltip, + TooltipWithPortal, +} from './components'; +import type {FunnelChartNextProps} from './FunnelChartNext'; +import { + TOOLTIP_WIDTH, + LABELS_HEIGHT, + PERCENTAGE_SUMMARY_HEIGHT, + LINE_GRADIENT, + PERCENTAGE_COLOR, + LINE_OFFSET, + LINE_WIDTH, + GAP, + SHORT_TOOLTIP_HEIGHT, + TOOLTIP_HEIGHT, + SEGMENT_WIDTH_RATIO, + TOOLTIP_HORIZONTAL_OFFSET, +} from './constants'; + +export interface ChartProps { + data: DataSeries[]; + tooltipLabels: FunnelChartNextProps['tooltipLabels']; + seriesNameFormatter: LabelFormatter; + labelFormatter: LabelFormatter; + percentageFormatter?: (value: number) => string; + renderScaleIconTooltipContent?: () => ReactNode; +} + +export function Chart({ + data, + tooltipLabels, + seriesNameFormatter, + labelFormatter, + percentageFormatter = (value: number) => { + return labelFormatter(value); + }, + renderScaleIconTooltipContent, +}: ChartProps) { + const [tooltipIndex, setTooltipIndex] = useState(null); + const {containerBounds} = useChartContext(); + + const dataSeries = data[0].data; + const xValues = dataSeries.map(({key}) => key) as string[]; + const yValues = dataSeries.map(({value}) => value) as [number, number]; + + const { + width: drawableWidth, + height: drawableHeight, + x: chartX, + y: chartY, + } = containerBounds ?? { + width: 0, + height: 0, + x: 0, + y: 0, + }; + + const highestYValue = Math.max(...yValues); + const yScale = scaleLinear() + .range([0, drawableHeight - LABELS_HEIGHT - PERCENTAGE_SUMMARY_HEIGHT]) + .domain([0, highestYValue]); + + const {getBarHeight, shouldApplyScaling} = useFunnelBarScaling({ + yScale, + values: yValues, + }); + + const labels = useMemo( + () => dataSeries.map(({key}) => seriesNameFormatter(key)), + [dataSeries, seriesNameFormatter], + ); + + const totalStepWidth = drawableWidth / xValues.length; + const connectorWidth = totalStepWidth * (1 - SEGMENT_WIDTH_RATIO); + const drawableWidthWithLastConnector = drawableWidth + connectorWidth; + + const xScale = scaleBand() + .domain(xValues) + .range([0, drawableWidthWithLastConnector]); + + const labelXScale = scaleBand() + .range([0, drawableWidthWithLastConnector]) + .domain(labels.map((_, index) => index.toString())); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * SEGMENT_WIDTH_RATIO; + const lineGradientId = useMemo(() => uniqueId('line-gradient'), []); + + const lastPoint = dataSeries.at(-1); + const firstPoint = dataSeries[0]; + + const calculatePercentage = (value: number, total: number) => { + return total === 0 ? 0 : (value / total) * 100; + }; + + const percentages = dataSeries.map((dataPoint) => { + const firstValue = firstPoint?.value ?? 0; + return percentageFormatter( + calculatePercentage(dataPoint.value ?? 0, firstValue), + ); + }); + + const formattedValues = dataSeries.map((dataPoint) => { + return labelFormatter(dataPoint.value); + }); + + const mainPercentage = percentageFormatter( + calculatePercentage(lastPoint?.value ?? 0, firstPoint?.value ?? 0), + ); + + const handleChartBlur = (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + const relatedTarget = event.relatedTarget as Node; + + if (!currentTarget.contains(relatedTarget)) { + setTooltipIndex(null); + } + }; + + return ( + + + + + + + + + + + + + {dataSeries.map((dataPoint, index: number) => { + const nextPoint = dataSeries[index + 1]; + const xPosition = xScale(dataPoint.key.toString()); + const x = xPosition == null ? 0 : xPosition; + const isLast = index === dataSeries.length - 1; + const barHeight = getBarHeight(dataPoint.value || 0); + const nextBarHeight = getBarHeight(nextPoint?.value || 0); + + return ( + + + setTooltipIndex(index)} + onMouseLeave={() => setTooltipIndex(null)} + shouldApplyScaling={shouldApplyScaling} + x={x} + > + {!isLast && ( + + )} + + {index > 0 && ( + + )} + + + ); + })} + + {getTooltipMarkup()} + + + ); + + function getTooltipMarkup() { + if (tooltipIndex == null) { + return null; + } + + const tooltipHeight = + tooltipIndex === dataSeries.length - 1 + ? SHORT_TOOLTIP_HEIGHT + : TOOLTIP_HEIGHT; + + const activeDataSeries = dataSeries[tooltipIndex]; + + if (activeDataSeries == null) { + return null; + } + + const xPosition = getXPosition(); + const yPosition = getYPosition(); + + return ( + + + + ); + + function getXPosition() { + if (tooltipIndex === 0) { + return chartX + barWidth + TOOLTIP_HORIZONTAL_OFFSET; + } + + const xOffset = (barWidth - TOOLTIP_WIDTH) / 2; + return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset; + } + + function getYPosition() { + const barHeight = getBarHeight(activeDataSeries.value ?? 0); + const yPosition = chartY + drawableHeight - barHeight; + + if (tooltipIndex === 0) { + return yPosition; + } + + return yPosition - tooltipHeight; + } + } +} 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 000000000..8d37ad657 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -0,0 +1,75 @@ +import type {ChartProps, LabelFormatter} from '@shopify/polaris-viz-core'; +import { + DEFAULT_CHART_PROPS, + ChartState, + useChartContext, +} from '@shopify/polaris-viz-core'; +import type {ReactNode} from 'react'; + +import {ChartContainer} from '../../components/ChartContainer'; +import {ChartSkeleton} from '../'; + +import {Chart} from './Chart'; + +export type FunnelChartNextProps = { + tooltipLabels: { + reached: string; + dropped: string; + }; + seriesNameFormatter?: LabelFormatter; + labelFormatter?: LabelFormatter; + renderScaleIconTooltipContent?: () => ReactNode; + percentageFormatter?: (value: number) => string; +} & ChartProps; + +const DEFAULT_LABEL_FORMATTER: LabelFormatter = (value) => `${value}`; + +export function FunnelChartNext(props: FunnelChartNextProps) { + const {theme: defaultTheme} = useChartContext(); + + const { + data, + theme = defaultTheme, + id, + isAnimated, + state, + errorText, + tooltipLabels, + seriesNameFormatter = DEFAULT_LABEL_FORMATTER, + labelFormatter = DEFAULT_LABEL_FORMATTER, + percentageFormatter, + onError, + renderScaleIconTooltipContent, + } = { + ...DEFAULT_CHART_PROPS, + ...props, + }; + + return ( + + {state !== ChartState.Success ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartLabels.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartLabels.tsx new file mode 100644 index 000000000..ed5caf0cd --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartLabels.tsx @@ -0,0 +1,142 @@ +import type {ReactNode} from 'react'; +import {Fragment, useMemo, useState} from 'react'; +import type {ScaleBand} from 'd3-scale'; +import {estimateStringWidth, useChartContext} from '@shopify/polaris-viz-core'; + +import {LINE_HEIGHT} from '../../../constants'; +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine} from '../../Labels'; + +import {ScaleIcon} from './ScaleIcon'; +import {ScaleIconTooltip} from './ScaleIconTooltip'; + +const LINE_GAP = 5; +const LINE_PADDING = 10; +const GROUP_OFFSET = 10; +const LABEL_FONT_SIZE = 12; +const PERCENT_FONT_SIZE = 14; +const PERCENT_FONT_WEIGHT = 650; +const VALUE_FONT_SIZE = 11; + +const TEXT_COLOR = 'rgba(31, 33, 36, 1)'; +const VALUE_COLOR = 'rgba(97, 97, 97, 1)'; + +const REDUCED_FONT_SIZE = 11; + +export interface FunnelChartLabelsProps { + formattedValues: string[]; + labels: string[]; + labelWidth: number; + barWidth: number; + percentages: string[]; + xScale: ScaleBand; + shouldApplyScaling: boolean; + renderScaleIconTooltipContent?: () => ReactNode; +} + +export function FunnelChartLabels({ + formattedValues, + labels, + labelWidth, + barWidth, + percentages, + xScale, + shouldApplyScaling, + renderScaleIconTooltipContent, +}: FunnelChartLabelsProps) { + const {characterWidths} = useChartContext(); + const [showTooltip, setShowTooltip] = useState(false); + + const labelFontSize = useMemo(() => { + const maxLabelWidth = Math.max( + ...labels.map((label) => estimateStringWidth(label, characterWidths)), + ); + + return maxLabelWidth > labelWidth ? REDUCED_FONT_SIZE : LABEL_FONT_SIZE; + }, [labels, characterWidths, labelWidth]); + + return ( + + {labels.map((label, index) => { + const x = xScale(index.toString()) ?? 0; + const showScaleIcon = index === 0 && shouldApplyScaling; + const isLast = index === labels.length - 1; + + const targetWidth = isLast + ? barWidth - GROUP_OFFSET * 3 + : labelWidth - GROUP_OFFSET * 3; + + const percentWidth = estimateStringWidthWithOffset( + percentages[index], + PERCENT_FONT_SIZE, + PERCENT_FONT_WEIGHT, + ); + + const formattedValueWidth = estimateStringWidthWithOffset( + formattedValues[index], + VALUE_FONT_SIZE, + ); + + const totalPercentAndValueWidth = percentWidth + formattedValueWidth; + const shouldShowFormattedValue = + totalPercentAndValueWidth < targetWidth; + + return ( + + {showScaleIcon && ( + setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + {showTooltip && renderScaleIconTooltipContent && ( + + )} + + )} + + + + + {shouldShowFormattedValue && ( + + )} + + + ); + })} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss new file mode 100644 index 000000000..a2ac6ea04 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss @@ -0,0 +1,10 @@ +.Tooltip { + top: 0; + left: 0; + position: absolute; + pointer-events: none; + // Matches --p-z-index-12 + // https://polaris.shopify.com/tokens/z-index + z-index: 520; + max-width: 300px; +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx new file mode 100644 index 000000000..645bbeefa --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx @@ -0,0 +1,33 @@ +import type {ReactNode} from 'react'; +import {animated, useSpring} from '@react-spring/web'; + +import {FUNNEL_CONNECTOR_Y_OFFSET} from '../../constants'; + +import styles from './FunnelTooltip.scss'; + +export function FunnelTooltip({ + children, + x, + y, +}: { + children: ReactNode; + x: number; + y: number; +}) { + const {transform, opacity} = useSpring({ + from: { + transform: `translate(${x}px, ${y + FUNNEL_CONNECTOR_Y_OFFSET}px)`, + opacity: 0, + }, + to: { + transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`, + opacity: 1, + }, + }); + + return ( + + {children} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/index.ts new file mode 100644 index 000000000..9eed7c40a --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/index.ts @@ -0,0 +1 @@ +export {FunnelTooltip} from './FunnelTooltip'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIcon.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIcon.tsx new file mode 100644 index 000000000..d39b05d52 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIcon.tsx @@ -0,0 +1,20 @@ +import {Fragment} from 'react'; + +const ICON_COLOR = '#050F2E'; +const ICON_BACKGROUND_COLOR = '#F3F3F3'; + +export function ScaleIcon() { + return ( + + + + + + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIconTooltip.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIconTooltip.tsx new file mode 100644 index 000000000..b3c739083 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIconTooltip.tsx @@ -0,0 +1,38 @@ +import {DEFAULT_THEME_NAME, useChartContext} from '@shopify/polaris-viz-core'; +import type {ReactNode} from 'react'; +import {Fragment} from 'react'; + +import {TooltipContentContainer} from '../../../components/TooltipContent'; + +import {FunnelTooltip} from './FunnelTooltip'; +import {TooltipWithPortal} from './TooltipWithPortal'; + +const TOOLTIP_VERTICAL_OFFSET = 65; + +interface ScaleIconTooltipProps { + renderScaleIconTooltipContent: () => ReactNode; +} + +export function ScaleIconTooltip({ + renderScaleIconTooltipContent, +}: ScaleIconTooltipProps) { + const {containerBounds} = useChartContext(); + const {x, y} = containerBounds ?? { + x: 0, + y: 0, + }; + + return ( + + + + {() => {renderScaleIconTooltipContent()}} + + + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss new file mode 100644 index 000000000..8f44240af --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss @@ -0,0 +1,33 @@ +.Rows { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.Row { + font-size: 12px; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + color: rgba(97, 97, 97, 1); + align-items: center; +} + +.Keys { + display: flex; + align-items: center; + gap: 4px; +} + +.Values { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-weight: 600; + + strong { + font-weight: 600; + color: rgba(31, 33, 36, 1); + } +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..b274217db --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx @@ -0,0 +1,96 @@ +import {Fragment} from 'react'; +import type {Color, DataPoint, LabelFormatter} from '@shopify/polaris-viz-core'; +import {DEFAULT_THEME_NAME} from '@shopify/polaris-viz-core'; + +import {TOOLTIP_WIDTH} from '../../constants'; +import {FUNNEL_CHART_CONNECTOR_GRADIENT} from '../../../shared/FunnelChartConnector'; +import {FUNNEL_CHART_SEGMENT_FILL} from '../../../shared/FunnelChartSegment'; +import type {FunnelChartNextProps} from '../../FunnelChartNext'; +import {SeriesIcon} from '../../../shared/SeriesIcon'; +import {calculateDropOff} from '../../utilities/calculate-dropoff'; +import {TooltipContentContainer, TooltipTitle} from '../../../TooltipContent'; + +import styles from './Tooltip.scss'; + +export interface TooltipContentProps { + activeIndex: number; + dataSeries: DataPoint[]; + isLast: boolean; + tooltipLabels: FunnelChartNextProps['tooltipLabels']; + labelFormatter: LabelFormatter; + percentageFormatter: (value: number) => string; +} + +interface Data { + key: string; + value: string; + color: Color; + percent: number; +} + +export function Tooltip({ + activeIndex, + dataSeries, + isLast, + tooltipLabels, + labelFormatter, + percentageFormatter, +}: TooltipContentProps) { + const point = dataSeries[activeIndex]; + const nextPoint = dataSeries[activeIndex + 1]; + + const dropOffPercentage = Math.abs( + calculateDropOff(point?.value ?? 0, nextPoint?.value ?? 0), + ); + + const data: Data[] = [ + { + key: tooltipLabels.reached, + value: labelFormatter(point.value), + color: FUNNEL_CHART_SEGMENT_FILL, + percent: 100 - dropOffPercentage, + }, + ]; + + if (!isLast) { + data.push({ + key: tooltipLabels.dropped, + value: labelFormatter(nextPoint?.value ?? 0 * dropOffPercentage), + percent: dropOffPercentage, + color: FUNNEL_CHART_CONNECTOR_GRADIENT, + }); + } + + return ( + + {() => ( + + {point.key} +
+ {data.map(({key, value, color, percent}, index) => { + return ( +
+
+ + {key} +
+
+ {value} + {!isLast && ( + + {percentageFormatter(percent)} + + )} +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts new file mode 100644 index 000000000..f53bae244 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts @@ -0,0 +1 @@ +export {Tooltip} from './Tooltip'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/TooltipWithPortal.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/TooltipWithPortal.tsx new file mode 100644 index 000000000..9ff0bba2d --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/TooltipWithPortal.tsx @@ -0,0 +1,11 @@ +import {createPortal} from 'react-dom'; +import type {ReactNode} from 'react'; + +import {useRootContainer} from '../../../hooks/useRootContainer'; +import {TOOLTIP_ID} from '../../../constants'; + +export function TooltipWithPortal({children}: {children: ReactNode}) { + const container = useRootContainer(TOOLTIP_ID); + + return createPortal(children, container); +} 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 000000000..2df703edb --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -0,0 +1,6 @@ +export {FunnelChartLabels} from './FunnelChartLabels'; +export {Tooltip} from './Tooltip'; +export {FunnelTooltip} from './FunnelTooltip'; +export {TooltipWithPortal} from './TooltipWithPortal'; +export {ScaleIcon} from './ScaleIcon'; +export {ScaleIconTooltip} from './ScaleIconTooltip'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/constants.ts b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts new file mode 100644 index 000000000..c4c11112f --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts @@ -0,0 +1,24 @@ +export const FUNNEL_CONNECTOR_Y_OFFSET = 30; +export const TOOLTIP_WIDTH = 270; +export const SEGMENT_WIDTH_RATIO = 0.75; +export const TOOLTIP_HORIZONTAL_OFFSET = 10; +export const LINE_OFFSET = 3; +export const LINE_WIDTH = 1; +export const TOOLTIP_HEIGHT = 90; +export const SHORT_TOOLTIP_HEIGHT = 65; +export const GAP = 1; + +export const PERCENTAGE_COLOR = 'rgba(48, 48, 48, 1)'; +export const LINE_GRADIENT = [ + { + color: 'rgba(227, 227, 227, 1)', + offset: 0, + }, + { + color: 'rgba(227, 227, 227, 0)', + offset: 100, + }, +]; + +export const LABELS_HEIGHT = 80; +export const PERCENTAGE_SUMMARY_HEIGHT = 30; 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 000000000..c6cbf51cd --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/index.ts @@ -0,0 +1,3 @@ +export {FunnelChartNext} from './FunnelChartNext'; +export type {FunnelChartNextProps} from './FunnelChartNext'; +export {getFunnelBarHeight} from './utilities/get-funnel-bar-height'; 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 000000000..9920bd39d --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -0,0 +1,37 @@ +import type {Story} from '@storybook/react'; + +export {META as default} from './meta'; + +import type {FunnelChartNextProps} from '../FunnelChartNext'; + +import {DEFAULT_DATA, Template} from './data'; +import {Fragment} from 'react'; + +export const Default: Story = Template.bind({}); + +const labelFormatter = (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); +}; + +const percentageFormatter = (value) => `${labelFormatter(value)}%`; + +Default.args = { + data: DEFAULT_DATA, + labelFormatter, + percentageFormatter, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, + renderScaleIconTooltipContent: () => ( + +
Truncated Sessions
{' '} +

+ Sessions were drawn to scale to better represent the funnel +

+
+ ), +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx new file mode 100644 index 000000000..cddd91139 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx @@ -0,0 +1,32 @@ +import type {Story} from '@storybook/react'; + +export default { + ...META, + title: 'polaris-viz/Chromatic/Charts/FunnelChartNext', + parameters: { + ...META.parameters, + chromatic: {disableSnapshot: false}, + }, +}; + +import {DEFAULT_DATA, Template} from './data'; +import {META} from './meta'; +import type {FunnelChartNextProps} from '../FunnelChartNext'; + +export const Default: Story = Template.bind({}); + +const labelFormatter = (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); +}; + +Default.args = { + data: DEFAULT_DATA, + labelFormatter, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx new file mode 100644 index 000000000..644743155 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx @@ -0,0 +1,51 @@ +import type {Story} from '@storybook/react'; + +import type {FunnelChartNextProps} from '../../FunnelChartNext'; + +export default { + ...META, + title: `${META.title}/Playground`, +}; + +import {Template} from './data'; +import {META} from './meta'; + +export const ZeroValues: Story = Template.bind({}); + +const labelFormatter = (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); +}; + +ZeroValues.args = { + data: [ + { + data: [ + { + value: 0, + key: 'Sessions', + }, + { + value: 0, + key: 'Sessions with cart addition', + }, + { + value: 0, + key: 'Sessions that reached checkout', + }, + { + value: 0, + key: 'Sessions that completed checkout', + }, + ], + name: 'Conversion rates', + }, + ], + labelFormatter, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, +}; 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 000000000..ceccb7517 --- /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: 54654, + key: 'Sessions with cart addition', + }, + { + value: 47887, + key: 'Sessions that reached 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 000000000..ca3801d88 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -0,0 +1,33 @@ +import type {Meta} from '@storybook/react'; + +import { + CHART_STATE_CONTROL_ARGS, + CONTROLS_ARGS, + LABEL_FORMATTER_ARGS, + PERCENTAGE_FORMATTER_ARGS, + SERIES_NAME_FORMATTER_ARGS, + THEME_CONTROL_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: { + seriesNameFormatter: SERIES_NAME_FORMATTER_ARGS, + labelFormatter: LABEL_FORMATTER_ARGS, + percentageFormatter: PERCENTAGE_FORMATTER_ARGS, + theme: THEME_CONTROL_ARGS, + state: CHART_STATE_CONTROL_ARGS, + }, +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx b/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx new file mode 100644 index 000000000..8c8fc3e84 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx @@ -0,0 +1,105 @@ +import {mount} from '@shopify/react-testing'; +import {ChartContext} from '@shopify/polaris-viz-core'; +import type {DataSeries} from '@shopify/polaris-viz-core'; +import React from 'react'; + +import {Chart} from '../Chart'; +import {FunnelChartConnector, FunnelChartSegment} from '../../shared'; +import {FunnelTooltip} from '../components'; +import {SingleTextLine} from '../../Labels'; + +const mockData: DataSeries[] = [ + { + name: 'Group 1', + data: [ + {key: 'Step 1', value: 100}, + {key: 'Step 2', value: 75}, + {key: 'Step 3', value: 50}, + ], + }, +]; + +const mockContext = { + containerBounds: { + width: 500, + height: 300, + x: 0, + y: 0, + }, +}; + +const defaultProps = { + data: mockData, + tooltipLabels: { + dropoff: 'Dropoff', + total: 'Total', + }, + seriesNameFormatter: (value: string) => `$${value}`, + labelFormatter: (value: string) => `$${value}`, +}; + +describe('', () => { + it('renders funnel segments for each data point', () => { + const chart = mount( + + + , + ); + + expect(chart).toContainReactComponentTimes(FunnelChartSegment, 3); + }); + + it('renders n-1 connectors for n funnel segments, excluding the last segment', () => { + const chart = mount( + + + , + ); + + expect(chart).toContainReactComponentTimes(FunnelChartConnector, 2); + }); + + it('formats labels using the provided formatters', () => { + const customFormatter = (value: string) => `Custom ${value}`; + const chart = mount( + + + , + ); + + expect(chart).toContainReactComponent(SingleTextLine, { + text: 'Custom Step 1', + }); + }); + + it('shows tooltip when hovering over a segment', () => { + const chart = mount( + + + , + ); + + const firstSegment = chart.find(FunnelChartSegment); + firstSegment?.trigger('onMouseEnter', 0); + + expect(chart).toContainReactComponent(FunnelTooltip); + }); + + it('hides tooltip when mouse leaves a segment', () => { + const chart = mount( + + + , + ); + + const firstSegment = chart.find(FunnelChartSegment); + firstSegment?.trigger('onMouseEnter', 0); + firstSegment?.trigger('onMouseLeave'); + + expect(chart).not.toContainReactComponent(FunnelTooltip); + }); +}); diff --git a/packages/polaris-viz/src/components/FunnelChartNext/tests/FunnelChartNext.test.tsx b/packages/polaris-viz/src/components/FunnelChartNext/tests/FunnelChartNext.test.tsx new file mode 100644 index 000000000..45cd8cc9d --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/tests/FunnelChartNext.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import {mount} from '@shopify/react-testing'; +import { + ChartState, + ChartContext, + DEFAULT_THEME_NAME, +} from '@shopify/polaris-viz-core'; +import {act} from 'react-dom/test-utils'; + +import {FunnelChartNext} from '../FunnelChartNext'; +import {Chart} from '../Chart'; +import {ChartSkeleton} from '../../ChartSkeleton'; +import {FunnelChartSegment} from '../../shared'; +import {FunnelConnector} from '../components'; + +const mockData = [ + { + name: 'Funnel', + data: [ + {key: 'Step 1', value: 1000}, + {key: 'Step 2', value: 750}, + {key: 'Step 3', value: 500}, + {key: 'Step 4', value: 250}, + ], + }, +]; + +const mockTooltipLabels = { + reached: 'Reached', + dropped: 'Dropped', +}; + +describe('', () => { + describe('rendering states', () => { + it('renders a Chart when state is Success', () => { + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(Chart); + }); + + it('renders a ChartSkeleton when state is Loading', () => { + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(ChartSkeleton, { + type: 'Funnel', + state: ChartState.Loading, + }); + }); + + it('renders a ChartSkeleton with error text when state is Error', () => { + const errorText = 'Something went wrong'; + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(ChartSkeleton, { + type: 'Funnel', + errorText, + state: ChartState.Error, + }); + }); + }); + + describe('chart configuration', () => { + it('passes theme to Chart component', () => { + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(Chart); + }); + + it('passes seriesNameFormatter to Chart', () => { + const seriesNameFormatter = (value) => `$${value}`; + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(Chart, { + seriesNameFormatter, + }); + }); + + it('passes yAxisOptions to Chart', () => { + const labelFormatter = (value: number) => `$${value}`; + const funnel = mount( + , + ); + + expect(funnel).toContainReactComponent(Chart, { + labelFormatter, + }); + }); + }); +}); diff --git a/packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts new file mode 100644 index 000000000..8eaed1819 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts @@ -0,0 +1,3 @@ +export function calculateDropOff(value: number, nextValue: number) { + return ((nextValue - value) / value) * 100; +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-funnel-bar-height.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-funnel-bar-height.ts new file mode 100644 index 000000000..a22f116d4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-funnel-bar-height.ts @@ -0,0 +1,13 @@ +import type {ScaleLinear} from 'd3-scale'; + +import {MIN_BAR_HEIGHT} from '../../../constants'; + +export function getFunnelBarHeight( + rawValue: number, + yScale: ScaleLinear, +) { + const rawHeight = Math.abs(yScale(rawValue) - yScale(0)); + const needsMinHeight = rawHeight < MIN_BAR_HEIGHT && rawHeight !== 0; + + return needsMinHeight ? MIN_BAR_HEIGHT : rawHeight; +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts new file mode 100644 index 000000000..b5f5c2a2f --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts @@ -0,0 +1,48 @@ +import type { + TooltipPosition, + TooltipPositionParams, +} from '../../TooltipWrapper'; +import { + TOOLTIP_POSITION_DEFAULT_RETURN, + eventPointNative, +} from '../../TooltipWrapper'; + +interface Props { + tooltipPosition: TooltipPositionParams; + step: number; + maxIndex: number; + yMax: number; + formatPositionForTooltip: (index: number) => TooltipPosition; +} + +export function getTooltipPosition({ + formatPositionForTooltip, + maxIndex, + step, + tooltipPosition, + yMax, +}: Props): TooltipPosition { + const {event, index, eventType} = tooltipPosition; + + if (eventType === 'mouse' && event) { + const point = eventPointNative(event); + + if (point == null) { + return TOOLTIP_POSITION_DEFAULT_RETURN; + } + + const {svgX, svgY} = point; + + const activeIndex = Math.floor(svgX / step); + + if (activeIndex < 0 || activeIndex > maxIndex || svgY <= 0 || svgY > yMax) { + return TOOLTIP_POSITION_DEFAULT_RETURN; + } + + return formatPositionForTooltip(activeIndex); + } else if (index != null) { + return formatPositionForTooltip(index); + } + + return TOOLTIP_POSITION_DEFAULT_RETURN; +} diff --git a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx index 70a008a31..357af1e67 100644 --- a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx +++ b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx @@ -10,25 +10,29 @@ import {endLineTruncate} from './utilities/endLineTruncate'; interface SingleTextLineProps { color: string; fontSize: number; + fontWeight?: number; targetWidth: number; text: string; - x: number; - y: number; + x?: number; + y?: number; ariaHidden?: boolean; dominantBaseline?: 'middle' | 'hanging'; textAnchor?: 'start' | 'middle' | 'end'; } +const DEFAULT_LABEL_FONT_WEIGHT = 400; + export function SingleTextLine({ ariaHidden = false, color, dominantBaseline = 'hanging', fontSize, + fontWeight = DEFAULT_LABEL_FONT_WEIGHT, targetWidth, text, textAnchor = 'middle', - y, - x, + y = 0, + x = 0, }: SingleTextLineProps) { const {characterWidths} = useChartContext(); @@ -49,6 +53,7 @@ export function SingleTextLine({ width={targetWidth} fill={color} 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 8b8f76ec4..6c3c1089b 100644 --- a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx +++ b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx @@ -20,10 +20,12 @@ interface Props { labels: string[]; targetWidth: number; onHeightChange?: Dispatch> | (() => void); + align?: 'center' | 'left'; } export function useLabels({ allowLineWrap, + align = 'center', labels, onHeightChange = () => {}, targetWidth, @@ -69,6 +71,8 @@ export function useLabels({ switch (true) { case shouldDrawHorizontal: { return getHorizontalLabels({ + align, + fontSize, labels: preparedLabels, targetWidth, targetHeight: HORIZONTAL_LABEL_TARGET_HEIGHT, @@ -100,7 +104,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 5f8c5e011..9ffe4af4d 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,12 +77,12 @@ 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, fontSize: label.fontSize, width: targetWidth, height: LINE_HEIGHT, - textAnchor: 'middle', + textAnchor: align === 'left' ? 'start' : 'middle', dominantBaseline: 'hanging', }); diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx new file mode 100644 index 000000000..c2cf883b1 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx @@ -0,0 +1,100 @@ +import {Fragment} from 'react'; +import {scaleBand, scaleLinear} from 'd3-scale'; +import {useChartContext} from '@shopify/polaris-viz-core'; + +import {useFunnelBarScaling} from '../../hooks'; +import {FunnelChartConnectorGradient} from '../shared/FunnelChartConnector'; +import {FunnelChartConnector, FunnelChartSegment} from '../shared'; +import {ChartElements} from '../ChartElements'; + +import type {SparkFunnelChartProps} from './SparkFunnelChart'; +import styles from './SparkFunnelChart.scss'; + +const LINE_OFFSET = 1; +const GAP = 1; +const SEGMENT_WIDTH_RATIO = 0.75; + +export function Chart({data, accessibilityLabel}: SparkFunnelChartProps) { + const {containerBounds} = useChartContext(); + + const dataSeries = data[0].data; + const xValues = dataSeries.map(({key}) => key) as string[]; + const yValues = dataSeries.map(({value}) => value) as [number, number]; + + const {width: drawableWidth, height: drawableHeight} = containerBounds ?? { + width: 0, + height: 0, + }; + + const totalStepWidth = drawableWidth / xValues.length; + const connectorWidth = totalStepWidth * (1 - SEGMENT_WIDTH_RATIO); + const drawableWidthWithLastConnector = drawableWidth + connectorWidth; + + const xScale = scaleBand() + .domain(xValues) + .range([0, drawableWidthWithLastConnector]); + + const yScale = scaleLinear() + .range([0, drawableHeight]) + .domain([0, Math.max(...yValues)]); + + const {getBarHeight, shouldApplyScaling} = useFunnelBarScaling({ + yScale, + values: yValues, + }); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * SEGMENT_WIDTH_RATIO; + + return ( + + {accessibilityLabel ? ( + {accessibilityLabel} + ) : null} + + + + + {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); + const isLast = index === dataSeries.length - 1; + + return ( + + + + {!isLast && ( + + )} + + + + ); + })} + + + ); +} diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.scss b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.scss new file mode 100644 index 000000000..e70bee3c2 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.scss @@ -0,0 +1,5 @@ +@import '../../styles/common'; + +.VisuallyHidden { + @include visually-hidden; +} diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx new file mode 100644 index 000000000..48d4311d8 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx @@ -0,0 +1,55 @@ +import type {ChartProps} from '@shopify/polaris-viz-core'; +import { + DEFAULT_CHART_PROPS, + ChartState, + useChartContext, +} from '@shopify/polaris-viz-core'; + +import {ChartContainer} from '../../components/ChartContainer'; +import {ChartSkeleton} from '../'; + +import {Chart} from './Chart'; + +export type SparkFunnelChartProps = { + accessibilityLabel?: string; +} & ChartProps; + +export function SparkFunnelChart(props: SparkFunnelChartProps) { + const {theme: defaultTheme} = useChartContext(); + + const { + data, + accessibilityLabel, + theme = defaultTheme, + id, + isAnimated, + state, + errorText, + onError, + } = { + ...DEFAULT_CHART_PROPS, + ...props, + }; + + return ( + + {state !== ChartState.Success ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/components/FunnelConnector.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/components/FunnelConnector.tsx new file mode 100644 index 000000000..1d8e50780 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/components/FunnelConnector.tsx @@ -0,0 +1,56 @@ +import {useSpring, animated, to} from '@react-spring/web'; + +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +const ANIMATION_DELAY = 150; + +interface ConnectorProps { + drawableHeight: number; + fill: string; + height: number; + index: number; + nextX: number; + nextY: number; + startX: number; + startY: number; +} + +export function FunnelConnector({ + drawableHeight, + fill, + height, + index, + nextX, + nextY, + startX, + startY, +}: ConnectorProps) { + const springConfig = useBarSpringConfig({ + animationDelay: index * ANIMATION_DELAY, + }); + + const {animatedStartY, animatedNextY} = useSpring({ + from: { + animatedStartY: drawableHeight, + animatedNextY: drawableHeight, + }, + to: { + animatedStartY: startY, + animatedNextY: nextY, + }, + ...springConfig, + }); + + return ( + + `M${startX} ${startY} + L ${nextX} ${nextY} + V ${height} H ${startX} Z`, + )} + fill={fill} + /> + ); +} diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/index.ts b/packages/polaris-viz/src/components/SparkFunnelChart/index.ts new file mode 100644 index 000000000..b42150b42 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/index.ts @@ -0,0 +1,2 @@ +export {SparkFunnelChart} from './SparkFunnelChart'; +export type {SparkFunnelChartProps} from './SparkFunnelChart'; diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/stories/Default.stories.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/stories/Default.stories.tsx new file mode 100644 index 000000000..75501b6db --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/stories/Default.stories.tsx @@ -0,0 +1,13 @@ +import type {Story} from '@storybook/react'; + +export {META as default} from './meta'; + +import type {SparkFunnelChartProps} from '../SparkFunnelChart'; + +import {DEFAULT_DATA, Template} from './data'; + +export const Default: Story = Template.bind({}); + +Default.args = { + data: DEFAULT_DATA, +}; diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/stories/SparkFunnelChart.chromatic.stories.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/stories/SparkFunnelChart.chromatic.stories.tsx new file mode 100644 index 000000000..26de3a7e6 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/stories/SparkFunnelChart.chromatic.stories.tsx @@ -0,0 +1,20 @@ +import type {Story} from '@storybook/react'; + +export default { + ...META, + title: 'polaris-viz/Chromatic/Charts/SparkFunnelChart', + parameters: { + ...META.parameters, + chromatic: {disableSnapshot: false}, + }, +}; + +import {DEFAULT_DATA, Template} from './data'; +import {META} from './meta'; +import type {SparkFunnelChartProps} from '../SparkFunnelChart'; + +export const Default: Story = Template.bind({}); + +Default.args = { + data: DEFAULT_DATA, +}; diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/stories/data.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/stories/data.tsx new file mode 100644 index 000000000..6caa76814 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/stories/data.tsx @@ -0,0 +1,39 @@ +import type {DataSeries} from '@shopify/polaris-viz-core'; +import type {Story} from '@storybook/react'; + +import type {SparkFunnelChartProps} from '../SparkFunnelChart'; +import {SparkFunnelChart} from '../SparkFunnelChart'; + +export const DEFAULT_DATA: DataSeries[] = [ + { + data: [ + { + value: 454662, + key: 'Sessions', + }, + { + value: 54654, + key: 'Sessions with cart addition', + }, + { + value: 47887, + key: 'Sessions that reached checkout', + }, + { + value: 22543, + key: 'Sessions that completed checkout', + }, + ], + name: 'Conversion rates', + }, +]; + +export const Template: Story = ( + args: SparkFunnelChartProps, +) => { + return ( +
+ +
+ ); +}; diff --git a/packages/polaris-viz/src/components/SparkFunnelChart/stories/meta.ts b/packages/polaris-viz/src/components/SparkFunnelChart/stories/meta.ts new file mode 100644 index 000000000..ee1195575 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/stories/meta.ts @@ -0,0 +1,25 @@ +import type {Meta} from '@storybook/react'; + +import { + CHART_STATE_CONTROL_ARGS, + CONTROLS_ARGS, +} from '../../../storybook/constants'; +import {PageWithSizingInfo} from '../../Docs/stories'; +import {SparkFunnelChart} from '../SparkFunnelChart'; + +export const META: Meta = { + title: 'polaris-viz/Charts/SparkFunnelChart', + component: SparkFunnelChart, + parameters: { + controls: CONTROLS_ARGS, + docs: { + page: PageWithSizingInfo, + description: { + component: 'Used to show conversion data.', + }, + }, + }, + argTypes: { + state: CHART_STATE_CONTROL_ARGS, + }, +}; diff --git a/packages/polaris-viz/src/components/TextLine/TextLine.tsx b/packages/polaris-viz/src/components/TextLine/TextLine.tsx index 9b3a5cd1c..70abb58ac 100644 --- a/packages/polaris-viz/src/components/TextLine/TextLine.tsx +++ b/packages/polaris-viz/src/components/TextLine/TextLine.tsx @@ -7,9 +7,10 @@ import type {FormattedLine} from '../../types'; interface TextLineProps { index: number; line: FormattedLine[]; + color?: string; } -export function TextLine({index, line}: TextLineProps) { +export function TextLine({color, index, line}: TextLineProps) { const selectedTheme = useTheme(); return ( @@ -41,7 +42,7 @@ export function TextLine({index, line}: TextLineProps) { width={width} x={x} y={y} - fill={selectedTheme.xAxis.labelColor} + fill={color ?? selectedTheme.xAxis.labelColor} fontSize={fontSize} fontFamily={FONT_FAMILY} transform={transform} diff --git a/packages/polaris-viz/src/components/TooltipContent/components/TooltipContentContainer/TooltipContentContainer.tsx b/packages/polaris-viz/src/components/TooltipContent/components/TooltipContentContainer/TooltipContentContainer.tsx index 03db0d8dd..75734c251 100644 --- a/packages/polaris-viz/src/components/TooltipContent/components/TooltipContentContainer/TooltipContentContainer.tsx +++ b/packages/polaris-viz/src/components/TooltipContent/components/TooltipContentContainer/TooltipContentContainer.tsx @@ -21,9 +21,15 @@ interface Props { }) => ReactNode; maxWidth: number; theme: string; + color?: string; } -export function TooltipContentContainer({children, maxWidth, theme}: Props) { +export function TooltipContentContainer({ + children, + maxWidth, + theme, + color, +}: Props) { const {isFirefox} = useBrowserCheck(); const selectedTheme = useTheme(theme); @@ -39,10 +45,12 @@ export function TooltipContentContainer({children, maxWidth, theme}: Props) {
+ `M${startX} ${startY} + L ${nextX} ${nextY} + V ${height} H ${startX} Z`, + )} + fill={`url(#${FUNNEL_CHART_CONNECTOR_GRADIENT_ID})`} + /> + ); +} diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx new file mode 100644 index 000000000..58e12068a --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx @@ -0,0 +1,19 @@ +import {LinearGradientWithStops} from '@shopify/polaris-viz-core'; + +import { + FUNNEL_CHART_CONNECTOR_GRADIENT_ID, + FUNNEL_CHART_CONNECTOR_GRADIENT, +} from './constants'; + +export function FunnelChartConnectorGradient() { + return ( + + ); +} diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts b/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts new file mode 100644 index 000000000..fe55f5e0a --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts @@ -0,0 +1,12 @@ +export const FUNNEL_CHART_CONNECTOR_GRADIENT_ID = + 'funnel-chart-connector-gradient'; +export const FUNNEL_CHART_CONNECTOR_GRADIENT = [ + { + color: '#ADC4FC', + offset: 0, + }, + { + color: '#8BAAF9', + offset: 100, + }, +]; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts b/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts new file mode 100644 index 000000000..bafcb78c0 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts @@ -0,0 +1,3 @@ +export {FunnelChartConnector} from './FunnelChartConnector'; +export {FunnelChartConnectorGradient} from './FunnelChartConnectorGradient'; +export {FUNNEL_CHART_CONNECTOR_GRADIENT} from './constants'; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx new file mode 100644 index 000000000..7ed544c88 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx @@ -0,0 +1,96 @@ +import type {ReactNode} from 'react'; +import {Fragment, useRef} from 'react'; +import {useSpring} from '@react-spring/web'; +import {useChartContext} from '@shopify/polaris-viz-core'; + +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +import {InteractiveOverlay} from './components/InteractiveOverlay'; +import {ScaledSegment} from './components/ScaledSegment'; +import {AnimatedSegment} from './components/AnimatedSegment'; + +interface Props { + ariaLabel: string; + barHeight: number; + barWidth: number; + children: ReactNode; + index: number; + isLast: boolean; + onMouseEnter?: (index: number) => void; + onMouseLeave?: () => void; + shouldApplyScaling: boolean; + x: number; +} + +export function FunnelChartSegment({ + ariaLabel, + barHeight, + barWidth, + children, + index = 0, + isLast, + onMouseEnter, + onMouseLeave, + shouldApplyScaling, + x, +}: Props) { + const mounted = useRef(false); + const {containerBounds} = useChartContext(); + const isFirst = index === 0; + const {height: drawableHeight} = containerBounds ?? { + height: 0, + }; + + const springConfig = useBarSpringConfig({ + animationDelay: index * 150, + }); + + const {animatedHeight} = useSpring({ + from: { + animatedHeight: mounted.current ? barHeight : 0, + }, + to: { + animatedHeight: barHeight, + }, + ...springConfig, + }); + + if (shouldApplyScaling && isFirst) { + return ( + + {children} + + ); + } + + return ( + + + + {children} + + ); +} diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx new file mode 100644 index 000000000..6176de5ed --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx @@ -0,0 +1,51 @@ +import {getRoundedRectPath, useChartContext} from '@shopify/polaris-viz-core'; +import type {SpringValue} from '@react-spring/web'; +import {animated} from '@react-spring/web'; + +import {FUNNEL_CHART_SEGMENT_FILL, BORDER_RADIUS} from '../constants'; + +interface AnimatedSegmentProps { + animatedHeight: SpringValue; + ariaLabel: string; + barWidth: number; + isFirst: boolean; + isLast: boolean; + x: number; +} + +export function AnimatedSegment({ + animatedHeight, + ariaLabel, + barWidth, + isFirst, + isLast, + x, +}: AnimatedSegmentProps) { + const {containerBounds} = useChartContext(); + const {height: drawableHeight} = containerBounds ?? { + height: 0, + }; + const borderRadius = `${isFirst ? BORDER_RADIUS : 0} ${ + isLast ? BORDER_RADIUS : 0 + } 0 0`; + + return ( + + getRoundedRectPath({ + height: value, + width: barWidth, + borderRadius, + }), + )} + fill={FUNNEL_CHART_SEGMENT_FILL} + style={{ + transform: animatedHeight.to( + (value: number) => `translate(${x}px, ${drawableHeight - value}px)`, + ), + }} + width={barWidth} + /> + ); +} diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx new file mode 100644 index 000000000..dfef4e0a5 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx @@ -0,0 +1,34 @@ +interface InteractiveOverlayProps { + width: number; + height: number; + index: number; + onMouseEnter?: (index: number) => void; + onMouseLeave?: () => void; + x?: number; + y?: number; +} + +export function InteractiveOverlay({ + width, + height, + index, + onMouseEnter, + onMouseLeave, + x = 0, + y = 0, +}: InteractiveOverlayProps) { + return ( + onMouseEnter?.(index)} + onMouseLeave={onMouseLeave} + onFocus={() => onMouseEnter?.(index)} + tabIndex={0} + /> + ); +} diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx new file mode 100644 index 000000000..65d4578a7 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx @@ -0,0 +1,241 @@ +import type {ReactNode} from 'react'; +import {Fragment, useState} from 'react'; +import {animated, useSpring} from '@react-spring/web'; +import {getRoundedRectPath, useChartContext} from '@shopify/polaris-viz-core'; + +import {InteractiveOverlay} from '../components/InteractiveOverlay'; +import { + FUNNEL_CHART_SEGMENT_FILL, + FUNNEL_CHART_SEGMENT_SCALE_LIGHT, + FUNNEL_CHART_SEGMENT_SCALE_SHADOW, +} from '../constants'; + +const FUNNEL_SEGMENT = { + minBorderRadius: 3, + maxBorderRadius: 6, + borderRadiusRatio: 0.03, + scaleGap: 4, + scaleStartRatio: 0.2, + heightScaleFactor: 0.015, + widthScaleFactor: 0.005, + borderRadiusHeightThreshold: 200, + colors: { + primary: FUNNEL_CHART_SEGMENT_FILL, + scaleLight: FUNNEL_CHART_SEGMENT_SCALE_LIGHT, + scaleShadow: FUNNEL_CHART_SEGMENT_SCALE_SHADOW, + ripple: 'white', + }, +}; + +interface InteractionHandlers { + onMouseEnter?: (index: number) => void; + onMouseLeave?: () => void; +} + +interface Props extends InteractionHandlers { + barHeight: number; + barWidth: number; + index: number; + isLast: boolean; + x: number; + children: ReactNode; +} + +export function ScaledSegment({ + barHeight, + barWidth, + index, + isLast, + x, + onMouseEnter, + onMouseLeave, + children, +}: Props) { + const {containerBounds} = useChartContext(); + const {width: drawableWidth, height: drawableHeight} = containerBounds ?? { + height: 0, + width: 0, + }; + const [hasAnimated, setHasAnimated] = useState(false); + + const scaleStripeHeight = calculateResponsiveScale( + drawableHeight, + drawableWidth, + ); + const totalScaleHeight = scaleStripeHeight * 4; + + const springs = useSpring({ + from: {height: 0}, + to: {height: barHeight}, + delay: index * 100, + }); + + const scaleSpring = useSpring({ + from: { + opacity: 0, + scaleStripeHeight: 0, + }, + to: { + opacity: 1, + scaleStripeHeight: totalScaleHeight, + }, + config: { + mass: 1, + tension: 400, + friction: 15, + }, + delay: index * 100 + 700, + onRest: () => setHasAnimated(true), + }); + + const isFirst = index === 0; + + const scaleStartHeight = calculateScaleStartHeight(barHeight); + + const dynamicBorderRadius = Math.min( + Math.max( + Math.round(drawableHeight * FUNNEL_SEGMENT.borderRadiusRatio), + FUNNEL_SEGMENT.minBorderRadius, + ), + FUNNEL_SEGMENT.maxBorderRadius, + ); + + const fullSegmentMarkup = ( + + getRoundedRectPath({ + width: barWidth, + height, + borderRadius: `${isFirst ? dynamicBorderRadius : 0} ${ + isLast ? dynamicBorderRadius : 0 + } 0 0`, + }), + )} + fill={FUNNEL_SEGMENT.colors.primary} + /> + ); + + const scalePattern = [ + FUNNEL_SEGMENT.colors.scaleLight, + FUNNEL_SEGMENT.colors.scaleShadow, + FUNNEL_SEGMENT.colors.scaleLight, + FUNNEL_SEGMENT.colors.scaleShadow, + ]; + + const scaleEffectMarkup = ( + + {scalePattern.map((fill, scaleIndex) => + hasAnimated ? ( + + ) : ( + (height / 4) * scaleIndex, + )} + width={barWidth} + height={scaleSpring.scaleStripeHeight.to((height) => height / 4)} + fill={fill} + /> + ), + )} + + ); + + const getRipplePath = ( + scaleStripeHeight: number, + verticalOffset: number, + height: number = scaleStripeHeight * 2, + ) => { + return `M ${scaleStripeHeight * 1.5},${scaleStripeHeight + verticalOffset} + L 0,${verticalOffset} + L 0,${verticalOffset + height} Z`; + }; + + const scaleRippleMarkup = ( + + {hasAnimated ? ( + + + + + ) : ( + + + getRipplePath(scaleStripeHeight, 0, height / 2), + )} + fill={FUNNEL_SEGMENT.colors.ripple} + /> + + getRipplePath( + scaleStripeHeight, + scaleStripeHeight * 2, + height / 2, + ), + )} + fill={FUNNEL_SEGMENT.colors.ripple} + /> + + )} + + ); + + return ( + + `translate(${x}px, ${drawableHeight - height}px)`, + ), + }} + > + {fullSegmentMarkup} + {scaleEffectMarkup} + {scaleRippleMarkup} + + + {children} + + ); +} + +const calculateScaleStartHeight = (height: number) => + Math.floor(height * FUNNEL_SEGMENT.scaleStartRatio); + +const calculateResponsiveScale = ( + drawableHeight: number, + drawableWidth: number, +) => { + const heightScale = drawableHeight * FUNNEL_SEGMENT.heightScaleFactor; + const widthScale = drawableWidth * FUNNEL_SEGMENT.widthScaleFactor; + const scale = Math.max((heightScale + widthScale) / 2, 1); + return scale; +}; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts new file mode 100644 index 000000000..9912ae22f --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts @@ -0,0 +1,4 @@ +export const FUNNEL_CHART_SEGMENT_FILL = 'rgba(48, 94, 232, 1)'; +export const FUNNEL_CHART_SEGMENT_SCALE_LIGHT = '#597EED'; +export const FUNNEL_CHART_SEGMENT_SCALE_SHADOW = '#133AAF'; +export const BORDER_RADIUS = 6; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts b/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts new file mode 100644 index 000000000..d8e00a9bf --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts @@ -0,0 +1,2 @@ +export {FunnelChartSegment} from './FunnelChartSegment'; +export {FUNNEL_CHART_SEGMENT_FILL} from './constants'; diff --git a/packages/polaris-viz/src/components/shared/index.ts b/packages/polaris-viz/src/components/shared/index.ts index a9168e01d..651f46445 100644 --- a/packages/polaris-viz/src/components/shared/index.ts +++ b/packages/polaris-viz/src/components/shared/index.ts @@ -4,3 +4,5 @@ export {HorizontalBars, Label} from './HorizontalBars'; export {HorizontalStackedBars} from './HorizontalStackedBars'; export {HorizontalGroup} from './HorizontalGroup'; export {Bar} from './Bar'; +export {FunnelChartConnector} from './FunnelChartConnector'; +export {FunnelChartSegment} from './FunnelChartSegment'; diff --git a/packages/polaris-viz/src/hooks/index.ts b/packages/polaris-viz/src/hooks/index.ts index 9dd63e8e3..5ece380a6 100644 --- a/packages/polaris-viz/src/hooks/index.ts +++ b/packages/polaris-viz/src/hooks/index.ts @@ -11,6 +11,7 @@ export {useHorizontalXScale} from './useHorizontalXScale'; export {useHorizontalTicksAndScale} from './useHorizontalTicksAndScale'; export {useHorizontalTransitions} from './useHorizontalTransitions'; export {useHorizontalSeriesColors} from './useHorizontalSeriesColors'; +export {useFunnelBarScaling} from './useFunnelBarScaling'; export type {HorizontalTransitionStyle} from './useHorizontalTransitions'; export {useBarChartTooltipContent} from './useBarChartTooltipContent'; export {useHorizontalStackedValues} from './useHorizontalStackedValues'; diff --git a/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx b/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx new file mode 100644 index 000000000..5d0e2f4d5 --- /dev/null +++ b/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx @@ -0,0 +1,117 @@ +import type {Root} from '@shopify/react-testing'; +import {mount} from '@shopify/react-testing'; +import {scaleLinear} from 'd3-scale'; +import React from 'react'; + +import { + useFunnelBarScaling, + MINIMUM_SEGMENT_HEIGHT_RATIO, +} from '../useFunnelBarScaling'; + +const mockYScale = scaleLinear().domain([0, 100]).range([0, 400]); + +function parseData(result: Root) { + return JSON.parse(result.domNode?.dataset.data ?? ''); +} + +describe('useFunnelBarScaling', () => { + it('returns shouldApplyScaling=false when ratio above threshold', () => { + function TestComponent() { + const data = useFunnelBarScaling({ + yScale: mockYScale, + values: [90, 100], + }); + + return ; + } + + const result = mount(); + const data = parseData(result); + + expect(data.shouldApplyScaling).toBe(false); + }); + + it('returns shouldApplyScaling=true when ratio below threshold', () => { + function TestComponent() { + const data = useFunnelBarScaling({ + yScale: mockYScale, + values: [5, 100], + }); + + return ; + } + + const result = mount(); + const data = parseData(result); + + expect(data.shouldApplyScaling).toBe(true); + }); + + describe('getBarHeight', () => { + it('returns original bar height when ratio is above scaling threshold', () => { + function TestComponent() { + const data = useFunnelBarScaling({ + yScale: mockYScale, + values: [90, 100], + }); + + const height = data.getBarHeight(90); + return ; + } + + const result = mount(); + const data = parseData(result); + + expect(data.height).toBe(mockYScale(90)); + }); + + it('returns scaled height when scaling needed', () => { + function TestComponent() { + const data = useFunnelBarScaling({ + yScale: mockYScale, + values: [5, 100], + }); + + const scaledHeight = data.getBarHeight(5); + const originalHeight = mockYScale(5); + const tallestHeight = mockYScale(100); + + return ( + + ); + } + + const result = mount(); + const data = parseData(result); + + expect(data.scaledHeight).toBeGreaterThan(data.originalHeight); + expect(data.scaledHeight).toBeLessThan(data.tallestHeight); + expect(data.scaledHeight / data.tallestHeight).toBeGreaterThanOrEqual( + MINIMUM_SEGMENT_HEIGHT_RATIO, + ); + }); + + it('returns original height for tallest bar even when scaling applied', () => { + function TestComponent() { + const data = useFunnelBarScaling({ + yScale: mockYScale, + values: [5, 100], + }); + + const height = data.getBarHeight(100); + return ; + } + + const result = mount(); + const data = parseData(result); + + expect(data.height).toBe(mockYScale(100)); + }); + }); +}); diff --git a/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts b/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts new file mode 100644 index 000000000..4bb0ec427 --- /dev/null +++ b/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts @@ -0,0 +1,59 @@ +import {useCallback, useMemo} from 'react'; +import type {ScaleLinear} from 'd3-scale'; + +// Threshold to determine if we should scale the segments, i.e if the smallest segment is less than 10% of the tallest segment +export const SCALING_RATIO_THRESHOLD = 0.1; + +// Minimum height ratio between smallest and tallest segments +export const MINIMUM_SEGMENT_HEIGHT_RATIO = 0.25; + +interface UseFunnelBarScalingProps { + yScale: ScaleLinear; + values: number[]; +} + +export function useFunnelBarScaling({ + yScale, + values, +}: UseFunnelBarScalingProps) { + const tallestBarHeight = useMemo( + () => yScale(Math.max(...values)), + [yScale, values], + ); + const smallestBarHeight = useMemo( + () => yScale(Math.min(...values)), + [yScale, values], + ); + + const smallestToTallestBarRatio = useMemo( + () => smallestBarHeight / tallestBarHeight, + [smallestBarHeight, tallestBarHeight], + ); + + const shouldApplyScaling = useMemo( + () => smallestToTallestBarRatio <= SCALING_RATIO_THRESHOLD, + [smallestToTallestBarRatio], + ); + + const getBarHeight = useCallback( + (rawValue: number) => { + const barHeight = yScale(rawValue); + + if (!shouldApplyScaling || barHeight === tallestBarHeight) { + return barHeight; + } + + const currentRatio = smallestBarHeight / tallestBarHeight; + const scaleFactor = MINIMUM_SEGMENT_HEIGHT_RATIO / currentRatio; + + // Ensure we don't scale larger than the first segment + return Math.min(barHeight * scaleFactor, tallestBarHeight * 0.9); + }, + [yScale, shouldApplyScaling, smallestBarHeight, tallestBarHeight], + ); + + return { + shouldApplyScaling, + getBarHeight, + }; +} diff --git a/packages/polaris-viz/src/index.ts b/packages/polaris-viz/src/index.ts index 59049f25b..bf3a013eb 100644 --- a/packages/polaris-viz/src/index.ts +++ b/packages/polaris-viz/src/index.ts @@ -20,6 +20,8 @@ export { LineChartPredictive, MissingDataArea, Grid, + FunnelChartNext, + SparkFunnelChart, } from './components'; export type { @@ -37,6 +39,8 @@ export type { ComparisonMetricProps, LineChartRelationalProps, GridProps, + FunnelChartNextProps, + SparkFunnelChartProps, } from './components'; export { diff --git a/packages/polaris-viz/src/storybook/constants.ts b/packages/polaris-viz/src/storybook/constants.ts index 4473211af..c4931e140 100644 --- a/packages/polaris-viz/src/storybook/constants.ts +++ b/packages/polaris-viz/src/storybook/constants.ts @@ -138,6 +138,19 @@ export const MAX_SERIES_ARGS = { }, }; +export const SERIES_NAME_FORMATTER_ARGS = { + description: 'A function that formats the series name in the chart.', +}; + +export const LABEL_FORMATTER_ARGS = { + description: 'A function that formats numeric values displayed in the chart.', +}; + +export const PERCENTAGE_FORMATTER_ARGS = { + description: + 'A function that formats percentage values displayed in the chart.', +}; + export const DEFAULT_CHART_CONTEXT: ChartContextValues = { shouldAnimate: false, characterWidths,