From 6009f28eb192a47e4d67c7456def6a573cabe577 Mon Sep 17 00:00:00 2001 From: Matt Vickers Date: Fri, 4 Oct 2024 11:15:37 -0500 Subject: [PATCH 1/4] Adding FunnelChartNext v1.0.0-funnel-chart-next-beta.1 v1.0.0-funnel-chart-next-beta.2 v1.0.0-funnel-chart-next-beta.3 Export SparkFunnelChart v1.0.0-funnel-chart-next-beta.4 Tweak tooltips --- .eslintrc | 3 +- packages/polaris-viz-core/src/constants.ts | 1 + packages/polaris-viz-core/src/index.ts | 1 + packages/polaris-viz/CHANGELOG.md | 7 +- .../src/components/FunnelChartNext/Chart.tsx | 308 ++++++++++++++++++ .../FunnelChartNext/FunnelChartNext.tsx | 83 +++++ .../components/FunnelChartXAxisLabels.tsx | 87 +++++ .../components/FunnelConnector.tsx | 84 +++++ .../FunnelTooltip/FunnelTooltip.scss | 10 + .../FunnelTooltip/FunnelTooltip.tsx | 33 ++ .../components/Tooltip/Tooltip.scss | 33 ++ .../components/Tooltip/Tooltip.tsx | 100 ++++++ .../components/Tooltip/index.ts | 1 + .../FunnelChartNext/components/index.ts | 3 + .../components/FunnelChartNext/constants.ts | 2 + .../src/components/FunnelChartNext/index.ts | 3 + .../stories/Default.stories.tsx | 27 ++ .../FunnelChartNext.chromatic.stories.tsx | 34 ++ .../stories/Playground.stories.tsx | 53 +++ .../FunnelChartNext/stories/data.tsx | 39 +++ .../FunnelChartNext/stories/meta.ts | 38 +++ .../utilities/calculate-dropoff.ts | 3 + .../utilities/get-funnel-bar-height.ts | 13 + .../utilities/get-tooltip-position.ts | 51 +++ .../src/components/Labels/hooks/useLabels.tsx | 10 +- .../Labels/utilities/getHorizontalLabels.ts | 14 +- .../src/components/SparkFunnelChart/Chart.tsx | 98 ++++++ .../SparkFunnelChart/SparkFunnelChart.tsx | 81 +++++ .../components/FunnelConnector.tsx | 56 ++++ .../src/components/SparkFunnelChart/index.ts | 2 + .../stories/Default.stories.tsx | 13 + .../SparkFunnelChart.chromatic.stories.tsx | 20 ++ .../SparkFunnelChart/stories/data.tsx | 39 +++ .../SparkFunnelChart/stories/meta.ts | 25 ++ .../src/components/TextLine/TextLine.tsx | 11 +- .../TooltipWrapper/TooltipWrapper.tsx | 10 +- .../components/TooltipWrapper/constants.ts | 1 + packages/polaris-viz/src/components/index.ts | 4 + .../FunnelChartConnector.tsx | 56 ++++ .../FunnelChartConnectorGradient.tsx | 19 ++ .../shared/FunnelChartConnector/constants.ts | 12 + .../shared/FunnelChartConnector/index.ts | 3 + .../FunnelChartSegment/FunnelChartSegment.tsx | 89 +++++ .../shared/FunnelChartSegment/constants.ts | 1 + .../shared/FunnelChartSegment/index.ts | 2 + .../src/components/shared/index.ts | 2 + packages/polaris-viz/src/index.ts | 4 + .../estimateStringWidthWithOffset.ts | 10 +- 48 files changed, 1583 insertions(+), 16 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/FunnelConnector.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/constants.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/FunnelChartNext.chromatic.stories.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.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 create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/utilities/get-funnel-bar-height.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/components/FunnelConnector.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/index.ts create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/stories/Default.stories.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/stories/SparkFunnelChart.chromatic.stories.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/stories/data.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/stories/meta.ts create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnector.tsx create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts diff --git a/.eslintrc b/.eslintrc index 44a5a01c7..b56308cf1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -81,7 +81,8 @@ "UNSTABLE_telemetry" ] } - ] + ], + "@shopify/strict-component-boundaries": "warn" }, "overrides": [ { diff --git a/packages/polaris-viz-core/src/constants.ts b/packages/polaris-viz-core/src/constants.ts index bfdb1c842..f7df2568c 100644 --- a/packages/polaris-viz-core/src/constants.ts +++ b/packages/polaris-viz-core/src/constants.ts @@ -11,6 +11,7 @@ export const SMALL_CHART_HEIGHT = 125; export const FONT_SIZE = 11; export const TOUCH_FONT_SIZE = 12; +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-core/src/index.ts b/packages/polaris-viz-core/src/index.ts index 4011c90d5..ef4b38048 100644 --- a/packages/polaris-viz-core/src/index.ts +++ b/packages/polaris-viz-core/src/index.ts @@ -12,6 +12,7 @@ export { EMPTY_STATE_CHART_MAX, EMPTY_STATE_CHART_MIN, FONT_SIZE, + FONT_WEIGHT, HORIZONTAL_BAR_LABEL_HEIGHT, HORIZONTAL_BAR_LABEL_OFFSET, HORIZONTAL_GROUP_LABEL_HEIGHT, 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..a6cb38c2b --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -0,0 +1,308 @@ +import type {ReactNode} from 'react'; +import {Fragment, useMemo, useCallback, useState} from 'react'; +import {scaleBand, scaleLinear} from 'd3-scale'; +import type { + BoundingRect, + DataSeries, + XAxisOptions, + YAxisOptions, +} from '@shopify/polaris-viz-core'; +import {uniqueId, LinearGradientWithStops} from '@shopify/polaris-viz-core'; +import {createPortal} from 'react-dom'; + +import {TOOLTIP_ROOT_ID} from '../TooltipWrapper/constants'; +import {useRootContainer} from '../../hooks/useRootContainer'; +import {FunnelChartConnectorGradient} from '../shared/FunnelChartConnector'; +import {FunnelChartSegment} from '../shared'; +import {SingleTextLine} from '../Labels'; +import {ChartElements} from '../ChartElements'; + +import {FunnelChartXAxisLabels, Tooltip, FunnelConnector} from './components/'; +import {calculateDropOff} from './utilities/calculate-dropoff'; +import type {FunnelChartNextProps} from './FunnelChartNext'; +import {getFunnelBarHeight} from './utilities/get-funnel-bar-height'; +import {FunnelTooltip} from './components/FunnelTooltip/FunnelTooltip'; +import {FUNNEL_CONNECTOR_Y_OFFSET, TOOLTIP_WIDTH} from './constants'; + +export interface ChartProps { + data: DataSeries[]; + showConnectionPercentage: boolean; + tooltipLabels: FunnelChartNextProps['tooltipLabels']; + xAxisOptions: Required; + yAxisOptions: Required; + dimensions?: BoundingRect; +} + +const LINE_OFFSET = 3; +const LINE_WIDTH = 1; +const TOOLTIP_HEIGHT = 90; +const SHORT_TOOLTIP_HEIGHT = 65; +const GAP = 1; + +const PERCENTAGE_COLOR = 'rgba(48, 48, 48, 1)'; +const LINE_GRADIENT = [ + { + color: 'rgba(227, 227, 227, 1)', + offset: 0, + }, + { + color: 'rgba(227, 227, 227, 0)', + offset: 100, + }, +]; + +const LABELS_HEIGHT = 80; +const PERCENTAGE_SUMMARY_HEIGHT = 30; + +export function Chart({ + data, + dimensions, + showConnectionPercentage, + tooltipLabels, + xAxisOptions, + yAxisOptions, +}: ChartProps) { + const [tooltipIndex, setTooltipIndex] = useState(null); + + 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, + } = dimensions ?? { + width: 0, + height: 0, + x: 0, + y: 0, + }; + + const labels = useMemo( + () => dataSeries.map(({key}) => xAxisOptions.labelFormatter(key)), + [dataSeries, xAxisOptions], + ); + + const xScale = scaleBand().domain(xValues).range([0, drawableWidth]); + + const labelXScale = scaleBand() + .range([0, drawableWidth]) + .domain(labels.map((_, index) => index.toString())); + + const highestYValue = Math.max(...yValues); + const connectionPercentageHeight = showConnectionPercentage + ? FUNNEL_CONNECTOR_Y_OFFSET / 2 + : 0; + + const yScale = scaleLinear() + .range([ + 0, + drawableHeight - + LABELS_HEIGHT - + PERCENTAGE_SUMMARY_HEIGHT - + connectionPercentageHeight, + ]) + .domain([0, highestYValue]); + + const tallestBarHeight = yScale(highestYValue); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * 0.75; + + const getBarHeight = useCallback( + (rawValue: number) => getFunnelBarHeight(rawValue, yScale), + [yScale], + ); + + const lineGradientId = useMemo(() => uniqueId('line-gradient'), []); + + const lastPoint = dataSeries.at(-1); + const firstPoint = dataSeries[0]; + + 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); + }); + + const mainPercentage = formatPercentage( + ((lastPoint?.value ?? 0) / (firstPoint?.value ?? 0)) * 100, + ); + + return ( + + + + + + + + {xAxisOptions.hide === false && ( + + + + )} + + {dataSeries.map((dataPoint, index: number) => { + const nextPoint = dataSeries[index + 1]; + const xPosition = xScale(dataPoint.key.toString()); + const x = xPosition == null ? 0 : xPosition; + const nextBarHeight = getBarHeight(nextPoint?.value || 0); + + const percentCalculation = calculateDropOff( + dataPoint?.value ?? 0, + nextPoint?.value ?? 0, + ); + + const barHeight = getBarHeight(dataPoint.value || 0); + const formattedPercent = formatPercentage(percentCalculation); + const isLast = index === dataSeries.length - 1; + + return ( + + + setTooltipIndex(index)} + onMouseLeave={() => setTooltipIndex(null)} + tallestBarHeight={tallestBarHeight} + 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) { + // Push the tooltip beside the bar + return chartX + barWidth + 10; + } + + // Center the tooltip over the bar + const xOffset = (barWidth - TOOLTIP_WIDTH) / 2; + return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset; + } + + function getYPosition() { + const yPosition = + chartY + drawableHeight - yScale(activeDataSeries.value ?? 0); + + if (tooltipIndex === 0) { + return yPosition; + } + + return yPosition - tooltipHeight; + } + } + + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; + } +} + +function TooltipWithPortal({children}: {children: ReactNode}) { + const container = useRootContainer(TOOLTIP_ROOT_ID); + + return createPortal(children, container); +} 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..0f7448262 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -0,0 +1,83 @@ +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 = { + showConnectionPercentage?: boolean; + tooltipLabels: { + reached: string; + dropped: string; + }; + xAxisOptions?: Pick; + yAxisOptions?: Pick; +} & ChartProps; + +export function FunnelChartNext(props: FunnelChartNextProps) { + const {defaultTheme} = usePolarisVizContext(); + + const { + data, + theme = defaultTheme, + xAxisOptions, + yAxisOptions, + id, + isAnimated, + state, + errorText, + onError, + showConnectionPercentage = false, + tooltipLabels, + } = { + ...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 000000000..08b376ff4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx @@ -0,0 +1,87 @@ +import {Fragment} from 'react'; +import type {ScaleBand} from 'd3-scale'; + +import {LINE_HEIGHT} from '../../../constants'; +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine} from '../../Labels'; + +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)'; + +export interface FunnelChartXAxisLabelsProps { + formattedValues: string[]; + labels: string[]; + labelWidth: number; + percentages: string[]; + xScale: ScaleBand; +} + +export function FunnelChartXAxisLabels({ + formattedValues, + labels, + labelWidth, + percentages, + xScale, +}: FunnelChartXAxisLabelsProps) { + const targetWidth = labelWidth - GROUP_OFFSET * 3; + + return ( + + {labels.map((label, index) => { + const x = xScale(index.toString()) ?? 0; + + const percentWidth = estimateStringWidthWithOffset( + percentages[index], + PERCENT_FONT_SIZE, + PERCENT_FONT_WEIGHT, + ); + + return ( + + + + + + + + + ); + })} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx new file mode 100644 index 000000000..2822836a4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx @@ -0,0 +1,84 @@ +import {Fragment} from 'react'; +import {FONT_SIZE} from '@shopify/polaris-viz-core'; + +import {FunnelChartConnector} from '../../shared'; +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine} from '../../Labels'; +import {FUNNEL_CONNECTOR_Y_OFFSET} from '../constants'; + +const TEXT_HEIGHT = 10; +const TEXT_PADDING = 4; + +interface ConnectorProps { + drawableHeight: number; + height: number; + index: number; + nextX: number; + nextY: number; + percentCalculation: string; + showConnectionPercentage: boolean; + startX: number; + startY: number; + width: number; +} + +export function FunnelConnector({ + drawableHeight, + height, + index, + nextX, + nextY, + percentCalculation, + showConnectionPercentage, + startX, + startY, + width, +}: ConnectorProps) { + const textWidth = estimateStringWidthWithOffset( + percentCalculation, + FONT_SIZE, + 300, + ); + + const pillX = startX + width / 2 - textWidth / 2 - TEXT_PADDING; + + const doubleTextPadding = TEXT_PADDING * 2; + + return ( + + {showConnectionPercentage && ( + + + + + )} + + + + ); +} 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..ddfebf4a3 --- /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: 70%; +} 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/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..2e5a05ab8 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx @@ -0,0 +1,100 @@ +import {Fragment} from 'react'; +import type {Color, DataPoint, YAxisOptions} 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']; + yAxisOptions: Required; +} + +interface Data { + key: string; + value: string; + color: Color; + percent: number; +} + +export function Tooltip({ + activeIndex, + dataSeries, + isLast, + yAxisOptions, + tooltipLabels, +}: 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: yAxisOptions.labelFormatter(point.value), + color: FUNNEL_CHART_SEGMENT_FILL, + percent: 100 - dropOffPercentage, + }, + ]; + + if (!isLast) { + data.push({ + key: tooltipLabels.dropped, + value: yAxisOptions.labelFormatter( + nextPoint?.value ?? 0 * dropOffPercentage, + ), + percent: dropOffPercentage, + color: FUNNEL_CHART_CONNECTOR_GRADIENT, + }); + } + + return ( + + {() => ( + + {point.key} +
+ {data.map(({key, value, color, percent}) => { + return ( +
+
+ + {key} +
+
+ {value} + {!isLast && ( + + {formatPercentage(percent)} + + )} +
+
+ ); + })} +
+
+ )} +
+ ); + + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; + } +} 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/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts new file mode 100644 index 000000000..31caea5ed --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -0,0 +1,3 @@ +export {FunnelChartXAxisLabels} from './FunnelChartXAxisLabels'; +export {Tooltip} from './Tooltip'; +export {FunnelConnector} from './FunnelConnector'; 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..9abea2fad --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts @@ -0,0 +1,2 @@ +export const FUNNEL_CONNECTOR_Y_OFFSET = 30; +export const TOOLTIP_WIDTH = 250; 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..97a187c5b --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -0,0 +1,27 @@ +import type {Story} from '@storybook/react'; + +export {META as default} from './meta'; + +import type {FunnelChartNextProps} from '../FunnelChartNext'; + +import {DEFAULT_DATA, Template} from './data'; + +export const Default: Story = Template.bind({}); + +const yAxisOptions = { + labelFormatter: (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); + }, +}; + +Default.args = { + data: DEFAULT_DATA, + yAxisOptions: yAxisOptions, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, +}; 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..2d2c27864 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx @@ -0,0 +1,34 @@ +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 yAxisOptions = { + labelFormatter: (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); + }, +}; + +Default.args = { + data: DEFAULT_DATA, + yAxisOptions: yAxisOptions, + 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..cfd5d5096 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx @@ -0,0 +1,53 @@ +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 yAxisOptions = { + 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', + }, + ], + yAxisOptions: yAxisOptions, + 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..2c48c70d3 --- /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..a5a16cc4d --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -0,0 +1,38 @@ +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, + showConnectionPercentage: { + description: + 'Show the percentage change between each segment in the funnel.', + control: { + type: 'boolean', + }, + }, + }, +}; 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..f2d1533a3 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts @@ -0,0 +1,51 @@ +import type { + TooltipPosition, + TooltipPositionParams, +} from 'components/TooltipWrapper'; + +import {TOOLTIP_POSITION_DEFAULT_RETURN} from '../../TooltipWrapper'; +import {eventPointNative} from '../../../utilities'; + +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; + + console.log({svgX}); + + console.log({step}); + + 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/hooks/useLabels.tsx b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx index 8b8f76ec4..555481c15 100644 --- a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx +++ b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx @@ -1,6 +1,6 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useMemo} from 'react'; -import {useChartContext} from '@shopify/polaris-viz-core'; +import {useChartContext, FONT_SIZE} from '@shopify/polaris-viz-core'; import {getFontSize} from '../../../utilities/getFontSize'; import {estimateStringWidthWithOffset} from '../../../utilities'; @@ -20,10 +20,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, @@ -69,6 +73,8 @@ export function useLabels({ switch (true) { case shouldDrawHorizontal: { return getHorizontalLabels({ + align, + fontSize, labels: preparedLabels, targetWidth, targetHeight: HORIZONTAL_LABEL_TARGET_HEIGHT, @@ -100,7 +106,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..e5d103be2 --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx @@ -0,0 +1,98 @@ +import {Fragment, useCallback} from 'react'; +import {scaleBand, scaleLinear} from 'd3-scale'; +import type { + BoundingRect, + DataSeries, + XAxisOptions, + YAxisOptions, +} from '@shopify/polaris-viz-core'; + +import {getFunnelBarHeight} from '../FunnelChartNext'; +import {FunnelChartConnectorGradient} from '../shared/FunnelChartConnector'; +import {FunnelChartConnector, FunnelChartSegment} from '../shared'; +import {ChartElements} from '../ChartElements'; + +import type {SparkFunnelChartProps} from './SparkFunnelChart'; + +export interface ChartProps { + data: DataSeries[]; + tooltipLabels: SparkFunnelChartProps['tooltipLabels']; + xAxisOptions: Required; + yAxisOptions: Required; + dimensions?: BoundingRect; +} + +const LINE_OFFSET = 1; +const GAP = 1; + +export function Chart({data, dimensions}: 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: drawableWidth, height: drawableHeight} = dimensions ?? { + width: 0, + height: 0, + }; + + const xScale = scaleBand().domain(xValues).range([0, drawableWidth]); + + const yScale = scaleLinear() + .range([0, drawableHeight]) + .domain([0, Math.max(...yValues)]); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * 0.75; + + const getBarHeight = useCallback( + (rawValue: number) => getFunnelBarHeight(rawValue, yScale), + [yScale], + ); + + 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); + const isLast = index === dataSeries.length - 1; + + return ( + + + + {!isLast && ( + + )} + + + + ); + })} + + ); +} 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..3af78245b --- /dev/null +++ b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx @@ -0,0 +1,81 @@ +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 SparkFunnelChartProps = { + tooltipLabels: { + reached: string; + dropped: string; + }; + xAxisOptions?: Pick; + yAxisOptions?: Pick; +} & ChartProps; + +export function SparkFunnelChart(props: SparkFunnelChartProps) { + const {defaultTheme} = usePolarisVizContext(); + + const { + data, + theme = defaultTheme, + xAxisOptions, + yAxisOptions, + id, + isAnimated, + state, + errorText, + onError, + tooltipLabels, + } = { + ...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/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..4b8f6581d 100644 --- a/packages/polaris-viz/src/components/TextLine/TextLine.tsx +++ b/packages/polaris-viz/src/components/TextLine/TextLine.tsx @@ -7,9 +7,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,7 +48,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/TooltipWrapper/TooltipWrapper.tsx b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx index 8d31f8193..1e4d64fe4 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx +++ b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx @@ -19,8 +19,12 @@ import {getHorizontalBarChartTooltipPosition} from './utilities/getHorizontalBar import {getLineChartTooltipPosition} from './utilities/getLineChartTooltipPosition'; import {getVerticalBarChartTooltipPosition} from './utilities/getVerticalBarChartTooltipPosition'; import {shouldBlockTooltipEvents} from './utilities/shouldBlockTooltipEvents'; -import type {TooltipPosition} from './types'; -import {DEFAULT_TOOLTIP_POSITION} from './constants'; +import type {TooltipPosition, TooltipPositionParams} from './types'; +import { + DEFAULT_TOOLTIP_POSITION, + DEFAULT_TOOLTIP_POSITION, + TOOLTIP_ROOT_ID, +} from './constants'; import {TooltipAnimatedContainer} from './components/TooltipAnimatedContainer'; const TOUCH_START_DELAY = 300; @@ -349,7 +353,7 @@ function TooltipWithErrors(props: BaseProps) { } function TooltipWithPortal(props: BaseProps) { - const container = useRootContainer(TOOLTIP_ID); + const container = useRootContainer(TOOLTIP_ROOT_ID); return createPortal(, container); } diff --git a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts index ba0c9cf64..4169ed692 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts +++ b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts @@ -16,3 +16,4 @@ export const TOOLTIP_POSITION_DEFAULT_RETURN: TooltipPosition = { // The space between the cursor and the tooltip export const TOOLTIP_MARGIN = 20; export const SCROLLBAR_WIDTH = 20; +export const TOOLTIP_ROOT_ID = 'polaris_viz_tooltip_root'; diff --git a/packages/polaris-viz/src/components/index.ts b/packages/polaris-viz/src/components/index.ts index ed49e27f0..84295f149 100644 --- a/packages/polaris-viz/src/components/index.ts +++ b/packages/polaris-viz/src/components/index.ts @@ -54,3 +54,7 @@ export type {LineChartPredictiveProps} from './LineChartPredictive'; export type {ComparisonMetricProps} from './ComparisonMetric'; export {Grid} from './Grid'; export type {GridProps} from './Grid'; +export {FunnelChartNext} from './FunnelChartNext'; +export type {FunnelChartNextProps} from './FunnelChartNext'; +export {SparkFunnelChart} from './SparkFunnelChart'; +export type {SparkFunnelChartProps} from './SparkFunnelChart'; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnector.tsx b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnector.tsx new file mode 100644 index 000000000..633deefd5 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnector.tsx @@ -0,0 +1,56 @@ +import {useSpring, animated, to} from '@react-spring/web'; + +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +import {FUNNEL_CHART_CONNECTOR_GRADIENT_ID} from './constants'; + +const ANIMATION_DELAY = 150; + +interface ConnectorProps { + drawableHeight: number; + height: number; + index: number; + nextX: number; + nextY: number; + startX: number; + startY: number; +} + +export function FunnelChartConnector({ + drawableHeight, + 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={`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..415e590bc --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx @@ -0,0 +1,89 @@ +import type {ReactNode} from 'react'; +import {Fragment, useRef} from 'react'; +import {useSpring, animated} from '@react-spring/web'; +import {getRoundedRectPath} from '@shopify/polaris-viz-core'; + +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +import {FUNNEL_CHART_SEGMENT_FILL} from './constants'; + +const BORDER_RADIUS = 6; + +export interface Props { + ariaLabel: string; + barHeight: number; + barWidth: number; + children: ReactNode; + drawableHeight: number; + index: number; + isLast: boolean; + onMouseEnter: (index: number) => void; + onMouseLeave: () => void; + tallestBarHeight: number; + x: number; +} + +export function FunnelChartSegment({ + ariaLabel, + barHeight, + barWidth, + children, + drawableHeight, + index = 0, + isLast, + onMouseEnter, + onMouseLeave, + tallestBarHeight, + x, +}: Props) { + const mounted = useRef(false); + + const springConfig = useBarSpringConfig({animationDelay: index * 150}); + const isFirst = index === 0; + + const {animatedHeight} = useSpring({ + from: { + animatedHeight: mounted.current ? barHeight : 0, + }, + to: { + animatedHeight: barHeight, + }, + ...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)`, + ), + }} + /> + + onMouseEnter(index)} + onMouseLeave={onMouseLeave} + /> + + {children} + + ); +} 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..7dd696ab2 --- /dev/null +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts @@ -0,0 +1 @@ +export const FUNNEL_CHART_SEGMENT_FILL = 'rgba(48, 94, 232, 1)'; 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/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/utilities/estimateStringWidthWithOffset.ts b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts index d8be0ea3e..9ad72704e 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 = 400, + fontSize: number = FONT_SIZE, + fontWeight: number = FONT_WEIGHT, ) { const width = estimateStringWidth(string, characterWidths); From f3c1e241cc18d3402deff7bbe8106c5d6d6ff16b Mon Sep 17 00:00:00 2001 From: Michael Nesen Date: Thu, 5 Dec 2024 14:28:16 +0000 Subject: [PATCH 2/4] rebase --- .../src/components/FunnelChartNext/Chart.tsx | 12 +++++++----- .../components/FunnelChartNext/FunnelChartNext.tsx | 5 ++--- .../src/components/Labels/hooks/useLabels.tsx | 4 +--- .../polaris-viz/src/components/TextLine/TextLine.tsx | 8 +------- .../src/components/TooltipWrapper/TooltipWrapper.tsx | 8 ++------ .../src/components/TooltipWrapper/constants.ts | 1 - 6 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx index a6cb38c2b..c17895153 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -2,12 +2,15 @@ import type {ReactNode} from 'react'; import {Fragment, useMemo, useCallback, useState} from 'react'; import {scaleBand, scaleLinear} from 'd3-scale'; import type { - BoundingRect, DataSeries, XAxisOptions, YAxisOptions, } from '@shopify/polaris-viz-core'; -import {uniqueId, LinearGradientWithStops} from '@shopify/polaris-viz-core'; +import { + uniqueId, + LinearGradientWithStops, + useChartContext, +} from '@shopify/polaris-viz-core'; import {createPortal} from 'react-dom'; import {TOOLTIP_ROOT_ID} from '../TooltipWrapper/constants'; @@ -30,7 +33,6 @@ export interface ChartProps { tooltipLabels: FunnelChartNextProps['tooltipLabels']; xAxisOptions: Required; yAxisOptions: Required; - dimensions?: BoundingRect; } const LINE_OFFSET = 3; @@ -56,13 +58,13 @@ const PERCENTAGE_SUMMARY_HEIGHT = 30; export function Chart({ data, - dimensions, showConnectionPercentage, tooltipLabels, xAxisOptions, yAxisOptions, }: ChartProps) { const [tooltipIndex, setTooltipIndex] = useState(null); + const {containerBounds} = useChartContext(); const dataSeries = data[0].data; @@ -74,7 +76,7 @@ export function Chart({ height: drawableHeight, x: chartX, y: chartY, - } = dimensions ?? { + } = containerBounds ?? { width: 0, height: 0, x: 0, diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx index 0f7448262..28ca4ae56 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -6,7 +6,7 @@ import type { import { DEFAULT_CHART_PROPS, ChartState, - usePolarisVizContext, + useChartContext, } from '@shopify/polaris-viz-core'; import {ChartContainer} from '../../components/ChartContainer'; @@ -19,7 +19,6 @@ import {ChartSkeleton} from '../'; import {Chart} from './Chart'; export type FunnelChartNextProps = { - showConnectionPercentage?: boolean; tooltipLabels: { reached: string; dropped: string; @@ -29,7 +28,7 @@ export type FunnelChartNextProps = { } & ChartProps; export function FunnelChartNext(props: FunnelChartNextProps) { - const {defaultTheme} = usePolarisVizContext(); + const {theme: defaultTheme} = useChartContext(); const { data, diff --git a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx index 555481c15..6c3c1089b 100644 --- a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx +++ b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx @@ -1,6 +1,6 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useMemo} from 'react'; -import {useChartContext, FONT_SIZE} from '@shopify/polaris-viz-core'; +import {useChartContext} from '@shopify/polaris-viz-core'; import {getFontSize} from '../../../utilities/getFontSize'; import {estimateStringWidthWithOffset} from '../../../utilities'; @@ -21,13 +21,11 @@ interface Props { targetWidth: number; onHeightChange?: Dispatch> | (() => void); align?: 'center' | 'left'; - fontSize?: number; } export function useLabels({ allowLineWrap, align = 'center', - fontSize = FONT_SIZE, labels, onHeightChange = () => {}, targetWidth, diff --git a/packages/polaris-viz/src/components/TextLine/TextLine.tsx b/packages/polaris-viz/src/components/TextLine/TextLine.tsx index 4b8f6581d..70abb58ac 100644 --- a/packages/polaris-viz/src/components/TextLine/TextLine.tsx +++ b/packages/polaris-viz/src/components/TextLine/TextLine.tsx @@ -8,15 +8,9 @@ interface TextLineProps { index: number; line: FormattedLine[]; color?: string; - fontSize?: number; } -export function TextLine({ - color, - index, - line, - fontSize = FONT_SIZE, -}: TextLineProps) { +export function TextLine({color, index, line}: TextLineProps) { const selectedTheme = useTheme(); return ( diff --git a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx index 1e4d64fe4..562b72a18 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx +++ b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx @@ -20,11 +20,7 @@ import {getLineChartTooltipPosition} from './utilities/getLineChartTooltipPositi import {getVerticalBarChartTooltipPosition} from './utilities/getVerticalBarChartTooltipPosition'; import {shouldBlockTooltipEvents} from './utilities/shouldBlockTooltipEvents'; import type {TooltipPosition, TooltipPositionParams} from './types'; -import { - DEFAULT_TOOLTIP_POSITION, - DEFAULT_TOOLTIP_POSITION, - TOOLTIP_ROOT_ID, -} from './constants'; +import {DEFAULT_TOOLTIP_POSITION} from './constants'; import {TooltipAnimatedContainer} from './components/TooltipAnimatedContainer'; const TOUCH_START_DELAY = 300; @@ -353,7 +349,7 @@ function TooltipWithErrors(props: BaseProps) { } function TooltipWithPortal(props: BaseProps) { - const container = useRootContainer(TOOLTIP_ROOT_ID); + const container = useRootContainer(TOOLTIP_ID); return createPortal(, container); } diff --git a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts index 4169ed692..ba0c9cf64 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts +++ b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts @@ -16,4 +16,3 @@ export const TOOLTIP_POSITION_DEFAULT_RETURN: TooltipPosition = { // The space between the cursor and the tooltip export const TOOLTIP_MARGIN = 20; export const SCROLLBAR_WIDTH = 20; -export const TOOLTIP_ROOT_ID = 'polaris_viz_tooltip_root'; From 0df034d7053e90c62af2880c3fc4064b5523eb30 Mon Sep 17 00:00:00 2001 From: Michael Nesen Date: Thu, 5 Dec 2024 15:37:33 +0000 Subject: [PATCH 3/4] Add tabbing through chart accessibility --- .../src/components/FunnelChartNext/Chart.tsx | 199 +++++++++--------- .../FunnelChartSegment/FunnelChartSegment.tsx | 9 +- 2 files changed, 112 insertions(+), 96 deletions(-) diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx index c17895153..5177dbbcb 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -143,104 +143,115 @@ export function Chart({ ((lastPoint?.value ?? 0) / (firstPoint?.value ?? 0)) * 100, ); + const handleChartBlur = (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + const relatedTarget = event.relatedTarget as Node; + + if (!currentTarget.contains(relatedTarget)) { + setTooltipIndex(null); + } + }; + return ( - - - - - - - {xAxisOptions.hide === false && ( - - - - )} - - {dataSeries.map((dataPoint, index: number) => { - const nextPoint = dataSeries[index + 1]; - const xPosition = xScale(dataPoint.key.toString()); - const x = xPosition == null ? 0 : xPosition; - const nextBarHeight = getBarHeight(nextPoint?.value || 0); - - const percentCalculation = calculateDropOff( - dataPoint?.value ?? 0, - nextPoint?.value ?? 0, - ); - - const barHeight = getBarHeight(dataPoint.value || 0); - const formattedPercent = formatPercentage(percentCalculation); - const isLast = index === dataSeries.length - 1; - - return ( - - - setTooltipIndex(index)} - onMouseLeave={() => setTooltipIndex(null)} - tallestBarHeight={tallestBarHeight} - x={x} - > - {!isLast && ( - + + + + + + + {xAxisOptions.hide === false && ( + + + + )} + + {dataSeries.map((dataPoint, index: number) => { + const nextPoint = dataSeries[index + 1]; + const xPosition = xScale(dataPoint.key.toString()); + const x = xPosition == null ? 0 : xPosition; + const nextBarHeight = getBarHeight(nextPoint?.value || 0); + + const percentCalculation = calculateDropOff( + dataPoint?.value ?? 0, + nextPoint?.value ?? 0, + ); + + const barHeight = getBarHeight(dataPoint.value || 0); + const formattedPercent = formatPercentage(percentCalculation); + const isLast = index === dataSeries.length - 1; + + return ( + + + setTooltipIndex(index)} + onMouseLeave={() => setTooltipIndex(null)} + tallestBarHeight={tallestBarHeight} + x={x} + > + {!isLast && ( + + )} + + {index > 0 && ( + )} - - {index > 0 && ( - - )} - - - ); - })} - - {getTooltipMarkup()} + + + ); + })} + + {getTooltipMarkup()} + ); diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx index 415e590bc..8efe86db3 100644 --- a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx @@ -75,12 +75,17 @@ export function FunnelChartSegment({ onMouseEnter(index)} onMouseLeave={onMouseLeave} + onFocus={() => onMouseEnter(index)} + tabIndex={0} /> {children} From fe55d53aba38030a8d9fda1f571db7c92aab75cc Mon Sep 17 00:00:00 2001 From: Michael Nesen Date: Fri, 6 Dec 2024 23:10:16 +0000 Subject: [PATCH 4/4] Add scaling feature to funnel chart --- .eslintrc | 3 +- packages/polaris-viz-core/src/constants.ts | 1 - packages/polaris-viz-core/src/index.ts | 1 - .../src/components/FunnelChartNext/Chart.tsx | 224 +++++++--------- .../FunnelChartNext/FunnelChartNext.tsx | 41 ++- .../components/FunnelChartLabels.tsx | 142 +++++++++++ .../components/FunnelChartXAxisLabels.tsx | 87 ------- .../components/FunnelConnector.tsx | 84 ------ .../FunnelTooltip/FunnelTooltip.scss | 2 +- .../components/FunnelTooltip/index.ts | 1 + .../FunnelChartNext/components/ScaleIcon.tsx | 20 ++ .../components/ScaleIconTooltip.tsx | 38 +++ .../components/Tooltip/Tooltip.tsx | 24 +- .../components/TooltipWithPortal.tsx | 11 + .../FunnelChartNext/components/index.ts | 7 +- .../components/FunnelChartNext/constants.ts | 24 +- .../stories/Default.stories.tsx | 26 +- .../FunnelChartNext.chromatic.stories.tsx | 14 +- .../stories/Playground.stories.tsx | 14 +- .../FunnelChartNext/stories/data.tsx | 2 +- .../FunnelChartNext/stories/meta.ts | 17 +- .../FunnelChartNext/tests/Chart.test.tsx | 105 ++++++++ .../tests/FunnelChartNext.test.tsx | 127 +++++++++ .../utilities/get-tooltip-position.ts | 13 +- .../src/components/Labels/SingleTextLine.tsx | 13 +- .../src/components/SparkFunnelChart/Chart.tsx | 142 ++++++----- .../SparkFunnelChart/SparkFunnelChart.scss | 5 + .../SparkFunnelChart/SparkFunnelChart.tsx | 38 +-- .../TooltipContentContainer.tsx | 18 +- .../TooltipWrapper/TooltipWrapper.tsx | 2 +- .../src/components/TooltipWrapper/index.ts | 5 +- .../FunnelChartConnector.tsx | 2 +- .../FunnelChartSegment/FunnelChartSegment.tsx | 90 +++---- .../components/AnimatedSegment.tsx | 51 ++++ .../components/InteractiveOverlay.tsx | 34 +++ .../components/ScaledSegment.tsx | 241 ++++++++++++++++++ .../shared/FunnelChartSegment/constants.ts | 3 + packages/polaris-viz/src/hooks/index.ts | 1 + .../hooks/tests/useFunnelBarScaling.test.tsx | 117 +++++++++ .../src/hooks/useFunnelBarScaling.ts | 59 +++++ .../polaris-viz/src/storybook/constants.ts | 13 + .../estimateStringWidthWithOffset.ts | 10 +- 42 files changed, 1316 insertions(+), 556 deletions(-) create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartLabels.tsx delete mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx delete mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIcon.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/ScaleIconTooltip.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/TooltipWithPortal.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/tests/FunnelChartNext.test.tsx create mode 100644 packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.scss create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx create mode 100644 packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx create mode 100644 packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx create mode 100644 packages/polaris-viz/src/hooks/useFunnelBarScaling.ts diff --git a/.eslintrc b/.eslintrc index b56308cf1..44a5a01c7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -81,8 +81,7 @@ "UNSTABLE_telemetry" ] } - ], - "@shopify/strict-component-boundaries": "warn" + ] }, "overrides": [ { diff --git a/packages/polaris-viz-core/src/constants.ts b/packages/polaris-viz-core/src/constants.ts index f7df2568c..bfdb1c842 100644 --- a/packages/polaris-viz-core/src/constants.ts +++ b/packages/polaris-viz-core/src/constants.ts @@ -11,7 +11,6 @@ export const SMALL_CHART_HEIGHT = 125; export const FONT_SIZE = 11; export const TOUCH_FONT_SIZE = 12; -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-core/src/index.ts b/packages/polaris-viz-core/src/index.ts index ef4b38048..4011c90d5 100644 --- a/packages/polaris-viz-core/src/index.ts +++ b/packages/polaris-viz-core/src/index.ts @@ -12,7 +12,6 @@ export { EMPTY_STATE_CHART_MAX, EMPTY_STATE_CHART_MIN, FONT_SIZE, - FONT_WEIGHT, HORIZONTAL_BAR_LABEL_HEIGHT, HORIZONTAL_BAR_LABEL_OFFSET, HORIZONTAL_GROUP_LABEL_HEIGHT, diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx index 5177dbbcb..214685fc8 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -1,73 +1,67 @@ import type {ReactNode} from 'react'; -import {Fragment, useMemo, useCallback, useState} from 'react'; +import {Fragment, useMemo, useState} from 'react'; import {scaleBand, scaleLinear} from 'd3-scale'; -import type { - DataSeries, - XAxisOptions, - YAxisOptions, -} from '@shopify/polaris-viz-core'; +import type {DataSeries, LabelFormatter} from '@shopify/polaris-viz-core'; import { uniqueId, LinearGradientWithStops, useChartContext, } from '@shopify/polaris-viz-core'; -import {createPortal} from 'react-dom'; -import {TOOLTIP_ROOT_ID} from '../TooltipWrapper/constants'; -import {useRootContainer} from '../../hooks/useRootContainer'; -import {FunnelChartConnectorGradient} from '../shared/FunnelChartConnector'; +import {useFunnelBarScaling} from '../../hooks'; +import { + FunnelChartConnector, + FunnelChartConnectorGradient, +} from '../shared/FunnelChartConnector'; import {FunnelChartSegment} from '../shared'; import {SingleTextLine} from '../Labels'; import {ChartElements} from '../ChartElements'; -import {FunnelChartXAxisLabels, Tooltip, FunnelConnector} from './components/'; -import {calculateDropOff} from './utilities/calculate-dropoff'; +import { + FunnelChartLabels, + Tooltip, + FunnelTooltip, + TooltipWithPortal, +} from './components'; import type {FunnelChartNextProps} from './FunnelChartNext'; -import {getFunnelBarHeight} from './utilities/get-funnel-bar-height'; -import {FunnelTooltip} from './components/FunnelTooltip/FunnelTooltip'; -import {FUNNEL_CONNECTOR_Y_OFFSET, TOOLTIP_WIDTH} from './constants'; +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[]; - showConnectionPercentage: boolean; tooltipLabels: FunnelChartNextProps['tooltipLabels']; - xAxisOptions: Required; - yAxisOptions: Required; + seriesNameFormatter: LabelFormatter; + labelFormatter: LabelFormatter; + percentageFormatter?: (value: number) => string; + renderScaleIconTooltipContent?: () => ReactNode; } -const LINE_OFFSET = 3; -const LINE_WIDTH = 1; -const TOOLTIP_HEIGHT = 90; -const SHORT_TOOLTIP_HEIGHT = 65; -const GAP = 1; - -const PERCENTAGE_COLOR = 'rgba(48, 48, 48, 1)'; -const LINE_GRADIENT = [ - { - color: 'rgba(227, 227, 227, 1)', - offset: 0, - }, - { - color: 'rgba(227, 227, 227, 0)', - offset: 100, - }, -]; - -const LABELS_HEIGHT = 80; -const PERCENTAGE_SUMMARY_HEIGHT = 30; - export function Chart({ data, - showConnectionPercentage, tooltipLabels, - xAxisOptions, - yAxisOptions, + 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]; @@ -83,64 +77,57 @@ export function Chart({ 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}) => xAxisOptions.labelFormatter(key)), - [dataSeries, xAxisOptions], + () => dataSeries.map(({key}) => seriesNameFormatter(key)), + [dataSeries, seriesNameFormatter], ); - const xScale = scaleBand().domain(xValues).range([0, drawableWidth]); + 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, drawableWidth]) + .range([0, drawableWidthWithLastConnector]) .domain(labels.map((_, index) => index.toString())); - const highestYValue = Math.max(...yValues); - const connectionPercentageHeight = showConnectionPercentage - ? FUNNEL_CONNECTOR_Y_OFFSET / 2 - : 0; - - const yScale = scaleLinear() - .range([ - 0, - drawableHeight - - LABELS_HEIGHT - - PERCENTAGE_SUMMARY_HEIGHT - - connectionPercentageHeight, - ]) - .domain([0, highestYValue]); - - const tallestBarHeight = yScale(highestYValue); - const sectionWidth = xScale.bandwidth(); - const barWidth = sectionWidth * 0.75; - - const getBarHeight = useCallback( - (rawValue: number) => getFunnelBarHeight(rawValue, yScale), - [yScale], - ); - + const barWidth = sectionWidth * SEGMENT_WIDTH_RATIO; const lineGradientId = useMemo(() => uniqueId('line-gradient'), []); const lastPoint = dataSeries.at(-1); const firstPoint = dataSeries[0]; - const percentages = dataSeries.map((dataPoint) => { - const yAxisValue = dataPoint.value; - - const percentCalculation = - firstPoint?.value && yAxisValue - ? (yAxisValue / firstPoint.value) * 100 - : 0; + const calculatePercentage = (value: number, total: number) => { + return total === 0 ? 0 : (value / total) * 100; + }; - return formatPercentage(percentCalculation); + const percentages = dataSeries.map((dataPoint) => { + const firstValue = firstPoint?.value ?? 0; + return percentageFormatter( + calculatePercentage(dataPoint.value ?? 0, firstValue), + ); }); const formattedValues = dataSeries.map((dataPoint) => { - return yAxisOptions.labelFormatter(dataPoint.value); + return labelFormatter(dataPoint.value); }); - const mainPercentage = formatPercentage( - ((lastPoint?.value ?? 0) / (firstPoint?.value ?? 0)) * 100, + const mainPercentage = percentageFormatter( + calculatePercentage(lastPoint?.value ?? 0, firstPoint?.value ?? 0), ); const handleChartBlur = (event: React.FocusEvent) => { @@ -170,69 +157,59 @@ export function Chart({ color={PERCENTAGE_COLOR} fontWeight={600} targetWidth={drawableWidth} - fontSize={24} + fontSize={20} text={mainPercentage} - willTruncate={false} + textAnchor="start" /> - {xAxisOptions.hide === false && ( - - - - )} + + + {dataSeries.map((dataPoint, index: number) => { const nextPoint = dataSeries[index + 1]; const xPosition = xScale(dataPoint.key.toString()); const x = xPosition == null ? 0 : xPosition; - const nextBarHeight = getBarHeight(nextPoint?.value || 0); - - const percentCalculation = calculateDropOff( - dataPoint?.value ?? 0, - nextPoint?.value ?? 0, - ); - - const barHeight = getBarHeight(dataPoint.value || 0); - const formattedPercent = formatPercentage(percentCalculation); const isLast = index === dataSeries.length - 1; + const barHeight = getBarHeight(dataPoint.value || 0); + const nextBarHeight = getBarHeight(nextPoint?.value || 0); return ( setTooltipIndex(index)} onMouseLeave={() => setTooltipIndex(null)} - tallestBarHeight={tallestBarHeight} + shouldApplyScaling={shouldApplyScaling} x={x} > {!isLast && ( - )} @@ -281,25 +258,24 @@ export function Chart({ dataSeries={dataSeries} isLast={tooltipIndex === dataSeries.length - 1} tooltipLabels={tooltipLabels} - yAxisOptions={yAxisOptions} + labelFormatter={labelFormatter} + percentageFormatter={percentageFormatter} /> ); function getXPosition() { if (tooltipIndex === 0) { - // Push the tooltip beside the bar - return chartX + barWidth + 10; + return chartX + barWidth + TOOLTIP_HORIZONTAL_OFFSET; } - // Center the tooltip over the bar const xOffset = (barWidth - TOOLTIP_WIDTH) / 2; return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset; } function getYPosition() { - const yPosition = - chartY + drawableHeight - yScale(activeDataSeries.value ?? 0); + const barHeight = getBarHeight(activeDataSeries.value ?? 0); + const yPosition = chartY + drawableHeight - barHeight; if (tooltipIndex === 0) { return yPosition; @@ -308,14 +284,4 @@ export function Chart({ return yPosition - tooltipHeight; } } - - function formatPercentage(value: number) { - return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; - } -} - -function TooltipWithPortal({children}: {children: ReactNode}) { - const container = useRootContainer(TOOLTIP_ROOT_ID); - - return createPortal(children, container); } diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx index 28ca4ae56..8d37ad657 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -1,19 +1,12 @@ -import type { - XAxisOptions, - YAxisOptions, - ChartProps, -} from '@shopify/polaris-viz-core'; +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 { - getYAxisOptionsWithDefaults, - getXAxisOptionsWithDefaults, -} from '../../utilities'; import {ChartSkeleton} from '../'; import {Chart} from './Chart'; @@ -23,36 +16,35 @@ export type FunnelChartNextProps = { reached: string; dropped: string; }; - xAxisOptions?: Pick; - yAxisOptions?: Pick; + 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, - xAxisOptions, - yAxisOptions, id, isAnimated, state, errorText, - onError, - showConnectionPercentage = false, tooltipLabels, + seriesNameFormatter = DEFAULT_LABEL_FORMATTER, + labelFormatter = DEFAULT_LABEL_FORMATTER, + percentageFormatter, + onError, + renderScaleIconTooltipContent, } = { ...DEFAULT_CHART_PROPS, ...props, }; - const xAxisOptionsForChart: Required = - getXAxisOptionsWithDefaults(xAxisOptions); - - const yAxisOptionsForChart: Required = - getYAxisOptionsWithDefaults(yAxisOptions); - return ( )} 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/FunnelChartXAxisLabels.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx deleted file mode 100644 index 08b376ff4..000000000 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import {Fragment} from 'react'; -import type {ScaleBand} from 'd3-scale'; - -import {LINE_HEIGHT} from '../../../constants'; -import {estimateStringWidthWithOffset} from '../../../utilities'; -import {SingleTextLine} from '../../Labels'; - -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)'; - -export interface FunnelChartXAxisLabelsProps { - formattedValues: string[]; - labels: string[]; - labelWidth: number; - percentages: string[]; - xScale: ScaleBand; -} - -export function FunnelChartXAxisLabels({ - formattedValues, - labels, - labelWidth, - percentages, - xScale, -}: FunnelChartXAxisLabelsProps) { - const targetWidth = labelWidth - GROUP_OFFSET * 3; - - return ( - - {labels.map((label, index) => { - const x = xScale(index.toString()) ?? 0; - - const percentWidth = estimateStringWidthWithOffset( - percentages[index], - PERCENT_FONT_SIZE, - PERCENT_FONT_WEIGHT, - ); - - return ( - - - - - - - - - ); - })} - - ); -} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx deleted file mode 100644 index 2822836a4..000000000 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import {Fragment} from 'react'; -import {FONT_SIZE} from '@shopify/polaris-viz-core'; - -import {FunnelChartConnector} from '../../shared'; -import {estimateStringWidthWithOffset} from '../../../utilities'; -import {SingleTextLine} from '../../Labels'; -import {FUNNEL_CONNECTOR_Y_OFFSET} from '../constants'; - -const TEXT_HEIGHT = 10; -const TEXT_PADDING = 4; - -interface ConnectorProps { - drawableHeight: number; - height: number; - index: number; - nextX: number; - nextY: number; - percentCalculation: string; - showConnectionPercentage: boolean; - startX: number; - startY: number; - width: number; -} - -export function FunnelConnector({ - drawableHeight, - height, - index, - nextX, - nextY, - percentCalculation, - showConnectionPercentage, - startX, - startY, - width, -}: ConnectorProps) { - const textWidth = estimateStringWidthWithOffset( - percentCalculation, - FONT_SIZE, - 300, - ); - - const pillX = startX + width / 2 - textWidth / 2 - TEXT_PADDING; - - const doubleTextPadding = TEXT_PADDING * 2; - - return ( - - {showConnectionPercentage && ( - - - - - )} - - - - ); -} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss index ddfebf4a3..a2ac6ea04 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss @@ -6,5 +6,5 @@ // Matches --p-z-index-12 // https://polaris.shopify.com/tokens/z-index z-index: 520; - max-width: 70%; + max-width: 300px; } 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.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx index 2e5a05ab8..b274217db 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx @@ -1,5 +1,5 @@ import {Fragment} from 'react'; -import type {Color, DataPoint, YAxisOptions} from '@shopify/polaris-viz-core'; +import type {Color, DataPoint, LabelFormatter} from '@shopify/polaris-viz-core'; import {DEFAULT_THEME_NAME} from '@shopify/polaris-viz-core'; import {TOOLTIP_WIDTH} from '../../constants'; @@ -17,7 +17,8 @@ export interface TooltipContentProps { dataSeries: DataPoint[]; isLast: boolean; tooltipLabels: FunnelChartNextProps['tooltipLabels']; - yAxisOptions: Required; + labelFormatter: LabelFormatter; + percentageFormatter: (value: number) => string; } interface Data { @@ -31,8 +32,9 @@ export function Tooltip({ activeIndex, dataSeries, isLast, - yAxisOptions, tooltipLabels, + labelFormatter, + percentageFormatter, }: TooltipContentProps) { const point = dataSeries[activeIndex]; const nextPoint = dataSeries[activeIndex + 1]; @@ -44,7 +46,7 @@ export function Tooltip({ const data: Data[] = [ { key: tooltipLabels.reached, - value: yAxisOptions.labelFormatter(point.value), + value: labelFormatter(point.value), color: FUNNEL_CHART_SEGMENT_FILL, percent: 100 - dropOffPercentage, }, @@ -53,9 +55,7 @@ export function Tooltip({ if (!isLast) { data.push({ key: tooltipLabels.dropped, - value: yAxisOptions.labelFormatter( - nextPoint?.value ?? 0 * dropOffPercentage, - ), + value: labelFormatter(nextPoint?.value ?? 0 * dropOffPercentage), percent: dropOffPercentage, color: FUNNEL_CHART_CONNECTOR_GRADIENT, }); @@ -70,9 +70,9 @@ export function Tooltip({ {point.key}
- {data.map(({key, value, color, percent}) => { + {data.map(({key, value, color, percent}, index) => { return ( -
+
{key} @@ -81,7 +81,7 @@ export function Tooltip({ {value} {!isLast && ( - {formatPercentage(percent)} + {percentageFormatter(percent)} )}
@@ -93,8 +93,4 @@ export function Tooltip({ )} ); - - function formatPercentage(value: number) { - return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; - } } 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 index 31caea5ed..2df703edb 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -1,3 +1,6 @@ -export {FunnelChartXAxisLabels} from './FunnelChartXAxisLabels'; +export {FunnelChartLabels} from './FunnelChartLabels'; export {Tooltip} from './Tooltip'; -export {FunnelConnector} from './FunnelConnector'; +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 index 9abea2fad..c4c11112f 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/constants.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts @@ -1,2 +1,24 @@ export const FUNNEL_CONNECTOR_Y_OFFSET = 30; -export const TOOLTIP_WIDTH = 250; +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/stories/Default.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx index 97a187c5b..9920bd39d 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -5,23 +5,33 @@ 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 yAxisOptions = { - labelFormatter: (value) => { - return new Intl.NumberFormat('en', { - style: 'decimal', - maximumFractionDigits: 2, - }).format(Number(value)); - }, +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, - yAxisOptions: yAxisOptions, + 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 index 2d2c27864..cddd91139 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx @@ -15,18 +15,16 @@ import type {FunnelChartNextProps} from '../FunnelChartNext'; export const Default: Story = Template.bind({}); -const yAxisOptions = { - labelFormatter: (value) => { - return new Intl.NumberFormat('en', { - style: 'decimal', - maximumFractionDigits: 2, - }).format(Number(value)); - }, +const labelFormatter = (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); }; Default.args = { data: DEFAULT_DATA, - yAxisOptions: yAxisOptions, + 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 index cfd5d5096..644743155 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx @@ -12,13 +12,11 @@ import {META} from './meta'; export const ZeroValues: Story = Template.bind({}); -const yAxisOptions = { - labelFormatter: (value) => { - return new Intl.NumberFormat('en', { - style: 'decimal', - maximumFractionDigits: 2, - }).format(Number(value)); - }, +const labelFormatter = (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); }; ZeroValues.args = { @@ -45,7 +43,7 @@ ZeroValues.args = { name: 'Conversion rates', }, ], - yAxisOptions: yAxisOptions, + 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 index 2c48c70d3..ceccb7517 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx @@ -32,7 +32,7 @@ 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 index a5a16cc4d..ca3801d88 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -3,9 +3,10 @@ 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, - X_AXIS_OPTIONS_ARGS, - Y_AXIS_OPTIONS_ARGS, } from '../../../storybook/constants'; import {PageWithSizingInfo} from '../../Docs/stories'; import {FunnelChartNext} from '../FunnelChartNext'; @@ -23,16 +24,10 @@ export const META: Meta = { }, }, argTypes: { - xAxisOptions: X_AXIS_OPTIONS_ARGS, - yAxisOptions: Y_AXIS_OPTIONS_ARGS, + seriesNameFormatter: SERIES_NAME_FORMATTER_ARGS, + labelFormatter: LABEL_FORMATTER_ARGS, + percentageFormatter: PERCENTAGE_FORMATTER_ARGS, theme: THEME_CONTROL_ARGS, state: CHART_STATE_CONTROL_ARGS, - showConnectionPercentage: { - description: - 'Show the percentage change between each segment in the funnel.', - control: { - type: 'boolean', - }, - }, }, }; 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/get-tooltip-position.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts index f2d1533a3..b5f5c2a2f 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts @@ -1,10 +1,11 @@ import type { TooltipPosition, TooltipPositionParams, -} from 'components/TooltipWrapper'; - -import {TOOLTIP_POSITION_DEFAULT_RETURN} from '../../TooltipWrapper'; -import {eventPointNative} from '../../../utilities'; +} from '../../TooltipWrapper'; +import { + TOOLTIP_POSITION_DEFAULT_RETURN, + eventPointNative, +} from '../../TooltipWrapper'; interface Props { tooltipPosition: TooltipPositionParams; @@ -32,10 +33,6 @@ export function getTooltipPosition({ const {svgX, svgY} = point; - console.log({svgX}); - - console.log({step}); - const activeIndex = Math.floor(svgX / step); if (activeIndex < 0 || activeIndex > maxIndex || svgY <= 0 || svgY > yMax) { 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/SparkFunnelChart/Chart.tsx b/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx index e5d103be2..c2cf883b1 100644 --- a/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx +++ b/packages/polaris-viz/src/components/SparkFunnelChart/Chart.tsx @@ -1,98 +1,100 @@ -import {Fragment, useCallback} from 'react'; +import {Fragment} from 'react'; import {scaleBand, scaleLinear} from 'd3-scale'; -import type { - BoundingRect, - DataSeries, - XAxisOptions, - YAxisOptions, -} from '@shopify/polaris-viz-core'; - -import {getFunnelBarHeight} from '../FunnelChartNext'; +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'; - -export interface ChartProps { - data: DataSeries[]; - tooltipLabels: SparkFunnelChartProps['tooltipLabels']; - xAxisOptions: Required; - yAxisOptions: Required; - dimensions?: BoundingRect; -} +import styles from './SparkFunnelChart.scss'; const LINE_OFFSET = 1; const GAP = 1; +const SEGMENT_WIDTH_RATIO = 0.75; -export function Chart({data, dimensions}: ChartProps) { - const dataSeries = data[0].data; +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} = dimensions ?? { + const {width: drawableWidth, height: drawableHeight} = containerBounds ?? { width: 0, height: 0, }; - const xScale = scaleBand().domain(xValues).range([0, drawableWidth]); + 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 sectionWidth = xScale.bandwidth(); - const barWidth = sectionWidth * 0.75; + const {getBarHeight, shouldApplyScaling} = useFunnelBarScaling({ + yScale, + values: yValues, + }); - const getBarHeight = useCallback( - (rawValue: number) => getFunnelBarHeight(rawValue, yScale), - [yScale], - ); + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * SEGMENT_WIDTH_RATIO; 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); - const isLast = index === dataSeries.length - 1; - - return ( - - - - {!isLast && ( - - )} - - - - ); - })} - + + {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 index 3af78245b..48d4311d8 100644 --- a/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx +++ b/packages/polaris-viz/src/components/SparkFunnelChart/SparkFunnelChart.tsx @@ -1,57 +1,36 @@ -import type { - XAxisOptions, - YAxisOptions, - ChartProps, -} from '@shopify/polaris-viz-core'; +import type {ChartProps} from '@shopify/polaris-viz-core'; import { DEFAULT_CHART_PROPS, ChartState, - usePolarisVizContext, + useChartContext, } from '@shopify/polaris-viz-core'; import {ChartContainer} from '../../components/ChartContainer'; -import { - getYAxisOptionsWithDefaults, - getXAxisOptionsWithDefaults, -} from '../../utilities'; import {ChartSkeleton} from '../'; import {Chart} from './Chart'; export type SparkFunnelChartProps = { - tooltipLabels: { - reached: string; - dropped: string; - }; - xAxisOptions?: Pick; - yAxisOptions?: Pick; + accessibilityLabel?: string; } & ChartProps; export function SparkFunnelChart(props: SparkFunnelChartProps) { - const {defaultTheme} = usePolarisVizContext(); + const {theme: defaultTheme} = useChartContext(); const { data, + accessibilityLabel, theme = defaultTheme, - xAxisOptions, - yAxisOptions, id, isAnimated, state, errorText, onError, - tooltipLabels, } = { ...DEFAULT_CHART_PROPS, ...props, }; - const xAxisOptionsForChart: Required = - getXAxisOptionsWithDefaults(xAxisOptions); - - const yAxisOptionsForChart: Required = - getYAxisOptionsWithDefaults(yAxisOptions); - return ( ) : ( - + )} ); 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) {
void; - onMouseLeave: () => void; - tallestBarHeight: number; + onMouseEnter?: (index: number) => void; + onMouseLeave?: () => void; + shouldApplyScaling: boolean; x: number; } @@ -28,18 +27,23 @@ export function FunnelChartSegment({ barHeight, barWidth, children, - drawableHeight, index = 0, isLast, onMouseEnter, onMouseLeave, - tallestBarHeight, + shouldApplyScaling, x, }: Props) { const mounted = useRef(false); - - const springConfig = useBarSpringConfig({animationDelay: index * 150}); + const {containerBounds} = useChartContext(); const isFirst = index === 0; + const {height: drawableHeight} = containerBounds ?? { + height: 0, + }; + + const springConfig = useBarSpringConfig({ + animationDelay: index * 150, + }); const {animatedHeight} = useSpring({ from: { @@ -51,43 +55,41 @@ export function FunnelChartSegment({ ...springConfig, }); + if (shouldApplyScaling && isFirst) { + return ( + + {children} + + ); + } + 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)`, - ), - }} - /> - - + onMouseEnter(index)} + index={index} + onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - onFocus={() => onMouseEnter(index)} - tabIndex={0} + x={x} + y={drawableHeight - barHeight} /> - {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 index 7dd696ab2..9912ae22f 100644 --- a/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts @@ -1 +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/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/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, diff --git a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts index 9ad72704e..d8be0ea3e 100644 --- a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts +++ b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts @@ -1,16 +1,12 @@ -import { - estimateStringWidth, - FONT_SIZE, - FONT_WEIGHT, -} from '@shopify/polaris-viz-core'; +import {estimateStringWidth} 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 = FONT_SIZE, - fontWeight: number = FONT_WEIGHT, + fontSize: number, + fontWeight = 400, ) { const width = estimateStringWidth(string, characterWidths);