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);