diff --git a/packages/polaris-viz-core/src/constants.ts b/packages/polaris-viz-core/src/constants.ts index de6f5f8b06..b6cd616ab6 100644 --- a/packages/polaris-viz-core/src/constants.ts +++ b/packages/polaris-viz-core/src/constants.ts @@ -8,6 +8,7 @@ import {InternalChartType, ChartState, Hue} from './types'; export const LINE_HEIGHT = 14; export const FONT_SIZE = 11; +export const FONT_WEIGHT = 300; export const FONT_FAMILY = 'Inter, -apple-system, "system-ui", "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif'; diff --git a/packages/polaris-viz-core/src/index.ts b/packages/polaris-viz-core/src/index.ts index 71f092aae4..ddf5821c6f 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/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx new file mode 100644 index 0000000000..3f9b708d31 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -0,0 +1,301 @@ +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, + DataType, +} from '@shopify/polaris-viz-core'; + +import type {TooltipPosition, TooltipPositionParams} from '../TooltipWrapper'; +import { + TOOLTIP_POSITION_DEFAULT_RETURN, + TooltipHorizontalOffset, + TooltipVerticalOffset, + TooltipWrapper, +} from '../TooltipWrapper'; +import {SingleTextLine} from '../Labels'; +import {ChartElements} from '../ChartElements'; +import {MIN_BAR_HEIGHT} from '../../constants'; + +import {FunnelChartXAxisLabels, FunnelSegment, Tooltip} from './components/'; +import {getTooltipPosition} from './utilities/get-tooltip-position'; +import {calculateDropOff} from './utilities/calculate-dropoff'; +import {BLUE_09, CONNECTOR_GRADIENT} from './constants'; +import type {FunnelChartNextProps} from './FunnelChartNext'; + +export interface ChartProps { + data: DataSeries[]; + tooltipLabels: FunnelChartNextProps['tooltipLabels']; + xAxisOptions: Required; + yAxisOptions: Required; + dimensions?: BoundingRect; +} + +const LINE_OFFSET = 3; +const LINE_WIDTH = 1; + +const GAP = 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 OVERALL_PERCENTAGE_HEIGHT = 30; + +export function Chart({ + data, + dimensions, + tooltipLabels, + xAxisOptions, + yAxisOptions, +}: ChartProps) { + const [svgRef, setSvgRef] = 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} = dimensions ?? { + width: 0, + height: 0, + }; + + const chartBounds: BoundingRect = { + width: drawableWidth, + height: drawableHeight, + 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 yScale = scaleLinear() + .range([ + 0, + drawableHeight - + LABELS_HEIGHT - + OVERALL_PERCENTAGE_HEIGHT - + OVERALL_PERCENTAGE_HEIGHT, + ]) + .domain([0, Math.max(...yValues)]); + + const sectionWidth = xScale.bandwidth(); + const barWidth = sectionWidth * 0.75; + + const getBarHeight = useCallback( + (rawValue: number) => { + const rawHeight = Math.abs(yScale(rawValue) - yScale(0)); + const needsMinHeight = rawHeight < MIN_BAR_HEIGHT && rawHeight !== 0; + + return needsMinHeight ? MIN_BAR_HEIGHT : rawHeight; + }, + [yScale], + ); + + const connectorGradientId = useMemo(() => uniqueId('connector-gradient'), []); + const lineGradientId = useMemo(() => uniqueId('line-gradient'), []); + + const lastPoint = dataSeries.at(-1); + const firstPoint = dataSeries[0]; + + const percentages = dataSeries.map((dataPoint) => { + const yAxisValue = dataPoint.value; + + const percentCalculation = + firstPoint?.value && yAxisValue + ? (yAxisValue / firstPoint.value) * 100 + : 0; + + return formatPercentage(percentCalculation); + }); + + const formattedValues = dataSeries.map((dataPoint) => { + return yAxisOptions.labelFormatter(dataPoint.value); + }); + + return ( + + + + + + + + + + + + {dataSeries.map((dataPoint, index: number) => { + const nextPoint = dataSeries[index + 1]; + const xPosition = xScale(dataPoint.key as string); + const x = xPosition == null ? 0 : xPosition; + const nextBarHeight = getBarHeight(nextPoint?.value || 0); + + const percentCalculation = calculateDropOff( + dataPoint?.value ?? 0, + nextPoint?.value ?? 0, + ); + + const barHeight = getBarHeight(dataPoint.value || 0); + + return ( + + + + {index > 0 && ( + + )} + + + ); + })} + + + ); + + function getTooltipMarkup(index: number) { + return ( + + ); + } + + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(value)}%`; + } + + function formatPositionForTooltip(index: number | null): TooltipPosition { + // Don't render the tooltip for the first bar + if (index === 0 || index == null) { + return TOOLTIP_POSITION_DEFAULT_RETURN; + } + + const xOffset = (sectionWidth - barWidth) / 2; + const x = labelXScale(`${index}`) ?? 0; + + const y = drawableHeight - yScale(dataSeries[index].value ?? 0); + + return { + x: x - xOffset + (dimensions?.x ?? 0), + y: Math.abs(y) + (dimensions?.y ?? 0), + position: { + horizontal: TooltipHorizontalOffset.Center, + vertical: TooltipVerticalOffset.Above, + }, + activeIndex: index, + }; + } + + function getPosition({ + event, + index, + eventType, + }: TooltipPositionParams): TooltipPosition { + return getTooltipPosition({ + tooltipPosition: {event, index, eventType}, + formatPositionForTooltip, + maxIndex: dataSeries.length - 1, + step: xScale.step(), + yMax: drawableHeight, + }); + } +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx new file mode 100644 index 0000000000..0e04d6806c --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -0,0 +1,80 @@ +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 = { + tooltipLabels: { + reached: string; + dropped: string; + }; + xAxisOptions?: Omit; + yAxisOptions?: Omit; +} & ChartProps; + +export function FunnelChartNext(props: FunnelChartNextProps) { + 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/FunnelChartNext/components/FunnelChartXAxisLabels.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx new file mode 100644 index 0000000000..8431749a9e --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx @@ -0,0 +1,91 @@ +import {Fragment} from 'react'; +import type {ScaleBand} from 'd3-scale'; + +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine, useLabels} from '../../Labels'; +import {TextLine} from '../../TextLine'; + +const LINE_GAP = 5; +const LINE_PADDING = 10; +const GROUP_OFFSET = 10; +const LABEL_FONT_SIZE = 12; + +export interface FunnelChartXAxisLabelsProps { + formattedValues: string[]; + labels: string[]; + labelWidth: number; + percentages: string[]; + xScale: ScaleBand; +} + +export function FunnelChartXAxisLabels({ + formattedValues, + labels, + labelWidth, + percentages, + xScale, +}: FunnelChartXAxisLabelsProps) { + const {lines} = useLabels({ + allowLineWrap: true, + align: 'left', + fontSize: LABEL_FONT_SIZE, + labels, + targetWidth: labelWidth - GROUP_OFFSET * 2, + }); + + return ( + + {lines.map((line, index) => { + const x = xScale(index.toString()) ?? 0; + + const firstLabelHeight = line.reduce( + (acc, {height}) => acc + height, + 0, + ); + + const percentWidth = estimateStringWidthWithOffset( + percentages[index], + 14, + 650, + ); + + return ( + + + + + + + + + ); + })} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx new file mode 100644 index 0000000000..04fc042af0 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx @@ -0,0 +1,123 @@ +import {Fragment, useState} from 'react'; +import {useSpring, animated, to} from '@react-spring/web'; +import type {DataPoint} from '@shopify/polaris-viz-core'; +import {FONT_SIZE} from '@shopify/polaris-viz-core'; + +import {estimateStringWidthWithOffset} from '../../../utilities'; +import {SingleTextLine} from '../../Labels'; +import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; + +const ANIMATION_DELAY = 150; +const TEXT_HEIGHT = 10; +const TEXT_PADDING = 4; +const Y_OFFSET = 30; + +export interface Connector { + fill: string; + height: number; + nextPoint: DataPoint; + nextX: number; + nextY: number; + startX: number; + startY: number; + width: number; +} + +interface ConnectorProps { + connector: Connector; + drawableHeight: number; + index: number; + percentCalculation: string; +} + +export function FunnelConnector({ + connector, + drawableHeight, + index, + percentCalculation, +}: ConnectorProps) { + const [isHovering, setIsHovering] = useState(false); + + const springConfig = useBarSpringConfig({ + animationDelay: index * ANIMATION_DELAY, + }); + + const {animatedStartY, animatedNextY} = useSpring({ + from: { + animatedStartY: drawableHeight, + animatedNextY: drawableHeight, + }, + to: { + animatedStartY: connector.startY, + animatedNextY: connector.nextY, + }, + ...springConfig, + }); + + const textWidth = estimateStringWidthWithOffset( + percentCalculation, + FONT_SIZE, + 300, + ); + + const pillX = + connector.startX + connector.width / 2 - textWidth / 2 - TEXT_PADDING; + + const yOffset = isHovering ? Y_OFFSET : 0; + + const {pillTransform, pillOpacity} = useSpring({ + pillTransform: `translate(${pillX}px, ${connector.startY - yOffset}px)`, + pillOpacity: isHovering ? 1 : 0, + ...springConfig, + }); + + const doubleTextPadding = TEXT_PADDING * 2; + + return ( + + + + + + + + `M${connector.startX} ${startY} + L ${connector.nextX} ${nextY} + V ${connector.height} H ${connector.startX} Z`, + )} + fill={connector.fill} + /> + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsHovering(true)} + onBlur={() => setIsHovering(false)} + tabIndex={0} + /> + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx new file mode 100644 index 0000000000..a99ffbfcc5 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelSegment.tsx @@ -0,0 +1,83 @@ +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 type {Connector} from './FunnelConnector'; +import {FunnelConnector} from './FunnelConnector'; + +const BORDER_RADIUS = 6; + +export interface Props { + ariaLabel: string; + barHeight: number; + barWidth: number; + color: string; + connector: Connector; + drawableHeight: number; + index: number; + isLast: boolean; + percentCalculation: string; + x: number; +} + +export function FunnelSegment({ + ariaLabel, + barHeight, + barWidth, + color, + connector, + drawableHeight, + index = 0, + isLast, + percentCalculation, + 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)`, + ), + }} + /> + {!isLast && ( + + )} + + ); +} 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 0000000000..1fe8a59f5c --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss @@ -0,0 +1,26 @@ +.Rows { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.Row { + font-size: 12px; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.Keys { + display: flex; + align-items: center; + gap: 4px; +} + +.Values { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-weight: semibold; +} 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 0000000000..2ac91288c0 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx @@ -0,0 +1,103 @@ +import {Fragment} from 'react'; +import type {Color, DataPoint, YAxisOptions} from '@shopify/polaris-viz-core'; +import {DEFAULT_THEME_NAME, useTheme} from '@shopify/polaris-viz-core'; +import type {FunnelChartNextProps} from 'components/FunnelChartNext/FunnelChartNext'; + +import {SeriesIcon} from '../../../shared/SeriesIcon'; +import {BLUE_09, CONNECTOR_GRADIENT} from '../../constants'; +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; +} + +const MAX_WIDTH = 300; + +interface Data { + key: string; + value: string; + color: Color; + percent: number; +} + +export function Tooltip({ + activeIndex, + dataSeries, + isLast, + yAxisOptions, + tooltipLabels, +}: TooltipContentProps) { + const selectedTheme = useTheme(); + + 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: BLUE_09, + percent: 100 - dropOffPercentage, + }, + ]; + + if (!isLast) { + data.push({ + key: tooltipLabels.dropped, + value: yAxisOptions.labelFormatter( + nextPoint?.value ?? 0 * dropOffPercentage, + ), + percent: dropOffPercentage, + color: CONNECTOR_GRADIENT, + }); + } + + return ( + + {() => ( + + {point.key} +
+ {data.map(({key, value, color, percent}) => { + return ( +
+
+ + {key} +
+
+ {value} + {!isLast && ( + + {formatPercentage(percent)} + + )} +
+
+ ); + })} +
+
+ )} +
+ ); + + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(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 0000000000..f53bae2446 --- /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 0000000000..3bfae2fb40 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -0,0 +1,3 @@ +export {FunnelChartXAxisLabels} from './FunnelChartXAxisLabels'; +export {FunnelSegment} from './FunnelSegment'; +export {Tooltip} from './Tooltip'; 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 0000000000..f6a40b66d4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts @@ -0,0 +1,11 @@ +export const BLUE_09 = 'rgba(48, 94, 232, 1)'; +export const CONNECTOR_GRADIENT = [ + { + color: '#ADC4FC', + offset: 0, + }, + { + color: '#8BAAF9', + offset: 100, + }, +]; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/index.ts new file mode 100644 index 0000000000..5388503aa4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/index.ts @@ -0,0 +1,2 @@ +export {FunnelChartNext} from './FunnelChartNext'; +export type {FunnelChartNextProps} from './FunnelChartNext'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx new file mode 100644 index 0000000000..5d9f6b3288 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -0,0 +1,25 @@ +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({}); + +Default.args = { + data: DEFAULT_DATA, + yAxisOptions: { + labelFormatter: (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); + }, + }, + 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 0000000000..2b5dcaafc7 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx @@ -0,0 +1,29 @@ +import type {Story} from '@storybook/react'; + +export default { + ...META, + title: 'polaris-viz/Chromatic/Charts/FunnelChartNext', + parameters: { + ...META.parameters, + chromatic: {disableSnapshot: false}, + }, +}; + +import type {FunnelChartProps} from '../../../components'; + +import {DEFAULT_DATA, Template} from './data'; +import {META} from './meta'; + +export const Default: Story = Template.bind({}); + +Default.args = { + data: DEFAULT_DATA, + yAxisOptions: { + labelFormatter: (value) => { + return new Intl.NumberFormat('en', { + style: 'decimal', + maximumFractionDigits: 2, + }).format(Number(value)); + }, + }, +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/data.tsx new file mode 100644 index 0000000000..2c48c70d33 --- /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 0000000000..b08bc538a4 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -0,0 +1,31 @@ +import type {Meta} from '@storybook/react'; + +import { + CHART_STATE_CONTROL_ARGS, + CONTROLS_ARGS, + THEME_CONTROL_ARGS, + X_AXIS_OPTIONS_ARGS, + Y_AXIS_OPTIONS_ARGS, +} from '../../../storybook/constants'; +import {PageWithSizingInfo} from '../../Docs/stories'; +import {FunnelChartNext} from '../FunnelChartNext'; + +export const META: Meta = { + title: 'polaris-viz/Charts/FunnelChartNext', + component: FunnelChartNext, + parameters: { + controls: CONTROLS_ARGS, + docs: { + page: PageWithSizingInfo, + description: { + component: 'Used to show conversion data.', + }, + }, + }, + argTypes: { + xAxisOptions: X_AXIS_OPTIONS_ARGS, + yAxisOptions: Y_AXIS_OPTIONS_ARGS, + theme: THEME_CONTROL_ARGS, + state: CHART_STATE_CONTROL_ARGS, + }, +}; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/calculate-dropoff.ts new file mode 100644 index 0000000000..8eaed1819a --- /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-tooltip-position.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts new file mode 100644 index 0000000000..e7539d60ef --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts @@ -0,0 +1,47 @@ +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; + + const activeIndex = Math.floor(svgX / step); + + if (activeIndex < 0 || activeIndex > maxIndex || svgY <= 0 || svgY > yMax) { + return TOOLTIP_POSITION_DEFAULT_RETURN; + } + + return formatPositionForTooltip(activeIndex); + } else if (index != null) { + return formatPositionForTooltip(index); + } + + return TOOLTIP_POSITION_DEFAULT_RETURN; +} diff --git a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx index e80ea80f40..8a1837c4fc 100644 --- a/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx +++ b/packages/polaris-viz/src/components/Labels/SingleTextLine.tsx @@ -13,22 +13,28 @@ interface SingleTextLineProps { color: string; targetWidth: number; text: string; - x: number; - y: number; ariaHidden?: boolean; dominantBaseline?: 'middle' | 'hanging'; + fontSize?: number; + fontWeight?: number; textAnchor?: 'left' | 'center' | 'right'; + willTruncate?: boolean; + x?: number; + y?: number; } export function SingleTextLine({ ariaHidden = false, color, dominantBaseline = 'hanging', + fontSize = FONT_SIZE, + fontWeight = 300, targetWidth, text, textAnchor = 'center', - y, - x, + y = 0, + x = 0, + willTruncate = true, }: SingleTextLineProps) { const {characterWidths} = useChartContext(); @@ -48,14 +54,15 @@ export function SingleTextLine({ height={LINE_HEIGHT} width={targetWidth} fill={color} - fontSize={FONT_SIZE} + fontSize={fontSize} + fontWeight={fontWeight} fontFamily={FONT_FAMILY} y={y} x={x} > {truncated} - {text} + {willTruncate && {text}} ); } diff --git a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx index b4720e63c6..7114a7d6cf 100644 --- a/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx +++ b/packages/polaris-viz/src/components/Labels/hooks/useLabels.tsx @@ -1,7 +1,8 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useMemo} from 'react'; -import {estimateStringWidth, useChartContext} from '@shopify/polaris-viz-core'; +import {FONT_SIZE, useChartContext} from '@shopify/polaris-viz-core'; +import {estimateStringWidthWithOffset} from '../../../utilities'; import { LINE_HEIGHT, DIAGONAL_LABEL_MIN_WIDTH, @@ -18,10 +19,14 @@ interface Props { labels: string[]; targetWidth: number; onHeightChange?: Dispatch> | (() => void); + align?: 'center' | 'left'; + fontSize?: number; } export function useLabels({ allowLineWrap, + align = 'center', + fontSize = FONT_SIZE, labels, onHeightChange = () => {}, targetWidth, @@ -42,7 +47,7 @@ export function useLabels({ const longestLabelWidth = useMemo(() => { return labels.reduce((prev, string) => { - const newWidth = estimateStringWidth(string, characterWidths); + const newWidth = estimateStringWidthWithOffset(string, fontSize); if (newWidth > prev) { return newWidth; @@ -50,7 +55,7 @@ export function useLabels({ return prev; }, 0); - }, [labels, characterWidths]); + }, [labels, fontSize]); const {lines, containerHeight} = useMemo(() => { const shouldDrawHorizontal = checkIfShouldDrawHorizontal({ @@ -64,6 +69,8 @@ export function useLabels({ switch (true) { case shouldDrawHorizontal: { return getHorizontalLabels({ + align, + fontSize, labels: preparedLabels, targetWidth, targetHeight: HORIZONTAL_LABEL_TARGET_HEIGHT, @@ -95,7 +102,9 @@ export function useLabels({ } } }, [ + align, allowLineWrap, + fontSize, targetWidth, characterWidths, preparedLabels, diff --git a/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts b/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts index bd6e4ccc52..1a9a733fbd 100644 --- a/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts +++ b/packages/polaris-viz/src/components/Labels/utilities/getHorizontalLabels.ts @@ -1,6 +1,6 @@ import type {CharacterWidths} from '@shopify/polaris-viz-core'; -import {estimateStringWidth} from '@shopify/polaris-viz-core'; +import {estimateStringWidthWithOffset} from '../../../utilities'; import {LINE_HEIGHT} from '../../../constants'; import type {FormattedLine, PreparedLabels} from '../../../types'; @@ -10,6 +10,8 @@ import {truncateLabels} from './truncateLabels'; const NEXT_INDEX = 1; interface Props { + align: 'center' | 'left'; + fontSize: number; labels: PreparedLabels[]; targetHeight: number; targetWidth: number; @@ -17,6 +19,8 @@ interface Props { } export function getHorizontalLabels({ + align, + fontSize, labels, targetHeight, targetWidth, @@ -61,9 +65,9 @@ export function getHorizontalLabels({ while ( words[wordIndex + 1] != null && - estimateStringWidth( + estimateStringWidthWithOffset( `${line} ${words[wordIndex + NEXT_INDEX]}`, - characterWidths, + fontSize, ) < targetWidth ) { line += ` ${words[wordIndex + NEXT_INDEX]}`; @@ -73,11 +77,11 @@ export function getHorizontalLabels({ lines[index].push({ truncatedText: line, fullText: truncatedLabels[index].text, - x: targetWidth / 2, + x: align === 'left' ? 0 : targetWidth / 2, y: lineNumber * LINE_HEIGHT, width: targetWidth, height: LINE_HEIGHT, - textAnchor: 'middle', + textAnchor: align === 'left' ? 'start' : 'middle', dominantBaseline: 'hanging', }); diff --git a/packages/polaris-viz/src/components/TextLine/TextLine.tsx b/packages/polaris-viz/src/components/TextLine/TextLine.tsx index c25c3741cf..37eca6f39a 100644 --- a/packages/polaris-viz/src/components/TextLine/TextLine.tsx +++ b/packages/polaris-viz/src/components/TextLine/TextLine.tsx @@ -8,9 +8,16 @@ import type {FormattedLine} from '../../types'; interface TextLineProps { index: number; line: FormattedLine[]; + color?: string; + fontSize?: number; } -export function TextLine({index, line}: TextLineProps) { +export function TextLine({ + color, + index, + line, + fontSize = FONT_SIZE, +}: TextLineProps) { const selectedTheme = useTheme(); return ( @@ -41,8 +48,8 @@ export function TextLine({index, line}: TextLineProps) { width={width} x={x} y={y} - fill={selectedTheme.xAxis.labelColor} - fontSize={FONT_SIZE} + fill={color ?? selectedTheme.xAxis.labelColor} + fontSize={fontSize} fontFamily={FONT_FAMILY} transform={transform} > diff --git a/packages/polaris-viz/src/components/index.ts b/packages/polaris-viz/src/components/index.ts index 489dcc8ac8..6a39809fe1 100644 --- a/packages/polaris-viz/src/components/index.ts +++ b/packages/polaris-viz/src/components/index.ts @@ -55,3 +55,5 @@ export type {LineChartRelationalProps} from './LineChartRelational'; export {LineChartPredictive} from './LineChartPredictive'; export type {LineChartPredictiveProps} from './LineChartPredictive'; export type {ComparisonMetricProps} from './ComparisonMetric'; +export {FunnelChartNext} from './FunnelChartNext'; +export type {FunnelChartNextProps} from './FunnelChartNext'; diff --git a/packages/polaris-viz/src/index.ts b/packages/polaris-viz/src/index.ts index be67ec240f..8ab3232db2 100644 --- a/packages/polaris-viz/src/index.ts +++ b/packages/polaris-viz/src/index.ts @@ -19,6 +19,7 @@ export { LineChartRelational, LineChartPredictive, MissingDataArea, + FunnelChartNext, } from './components'; export type { @@ -35,6 +36,7 @@ export type { DonutChartProps, ComparisonMetricProps, LineChartRelationalProps, + FunnelChartNextProps, } from './components'; export { diff --git a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts index 2d5547c0d6..9ad72704e3 100644 --- a/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts +++ b/packages/polaris-viz/src/utilities/estimateStringWidthWithOffset.ts @@ -1,12 +1,16 @@ -import {estimateStringWidth} from '@shopify/polaris-viz-core'; +import { + estimateStringWidth, + FONT_SIZE, + FONT_WEIGHT, +} from '@shopify/polaris-viz-core'; import characterWidths from '../data/character-widths.json'; import characterWidthOffsets from '../data/character-width-offsets.json'; export function estimateStringWidthWithOffset( string: string, - fontSize: number, - fontWeight: number, + fontSize: number = FONT_SIZE, + fontWeight: number = FONT_WEIGHT, ) { const width = estimateStringWidth(string, characterWidths);