Skip to content

Commit

Permalink
Merge pull request #1733 from Shopify/envex/funnel-chart-next
Browse files Browse the repository at this point in the history
UA: Adding FunnelChartNext
  • Loading branch information
michaelnesen authored Dec 18, 2024
2 parents 4898b34 + fe55d53 commit e4e66fe
Show file tree
Hide file tree
Showing 58 changed files with 2,350 additions and 19 deletions.
7 changes: 6 additions & 1 deletion packages/polaris-viz/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

<!-- ## Unreleased -->
## Unreleased

### Added

- Added `<FunnelChartNext />` and `<SparkFunnelChart />`.


## [15.5.0] - 2024-12-17

Expand Down
287 changes: 287 additions & 0 deletions packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import type {ReactNode} from 'react';
import {Fragment, useMemo, useState} from 'react';
import {scaleBand, scaleLinear} from 'd3-scale';
import type {DataSeries, LabelFormatter} from '@shopify/polaris-viz-core';
import {
uniqueId,
LinearGradientWithStops,
useChartContext,
} from '@shopify/polaris-viz-core';

import {useFunnelBarScaling} from '../../hooks';
import {
FunnelChartConnector,
FunnelChartConnectorGradient,
} from '../shared/FunnelChartConnector';
import {FunnelChartSegment} from '../shared';
import {SingleTextLine} from '../Labels';
import {ChartElements} from '../ChartElements';

import {
FunnelChartLabels,
Tooltip,
FunnelTooltip,
TooltipWithPortal,
} from './components';
import type {FunnelChartNextProps} from './FunnelChartNext';
import {
TOOLTIP_WIDTH,
LABELS_HEIGHT,
PERCENTAGE_SUMMARY_HEIGHT,
LINE_GRADIENT,
PERCENTAGE_COLOR,
LINE_OFFSET,
LINE_WIDTH,
GAP,
SHORT_TOOLTIP_HEIGHT,
TOOLTIP_HEIGHT,
SEGMENT_WIDTH_RATIO,
TOOLTIP_HORIZONTAL_OFFSET,
} from './constants';

export interface ChartProps {
data: DataSeries[];
tooltipLabels: FunnelChartNextProps['tooltipLabels'];
seriesNameFormatter: LabelFormatter;
labelFormatter: LabelFormatter;
percentageFormatter?: (value: number) => string;
renderScaleIconTooltipContent?: () => ReactNode;
}

export function Chart({
data,
tooltipLabels,
seriesNameFormatter,
labelFormatter,
percentageFormatter = (value: number) => {
return labelFormatter(value);
},
renderScaleIconTooltipContent,
}: ChartProps) {
const [tooltipIndex, setTooltipIndex] = useState<number | null>(null);
const {containerBounds} = useChartContext();

const dataSeries = data[0].data;
const xValues = dataSeries.map(({key}) => key) as string[];
const yValues = dataSeries.map(({value}) => value) as [number, number];

const {
width: drawableWidth,
height: drawableHeight,
x: chartX,
y: chartY,
} = containerBounds ?? {
width: 0,
height: 0,
x: 0,
y: 0,
};

const highestYValue = Math.max(...yValues);
const yScale = scaleLinear()
.range([0, drawableHeight - LABELS_HEIGHT - PERCENTAGE_SUMMARY_HEIGHT])
.domain([0, highestYValue]);

const {getBarHeight, shouldApplyScaling} = useFunnelBarScaling({
yScale,
values: yValues,
});

const labels = useMemo(
() => dataSeries.map(({key}) => seriesNameFormatter(key)),
[dataSeries, seriesNameFormatter],
);

const totalStepWidth = drawableWidth / xValues.length;
const connectorWidth = totalStepWidth * (1 - SEGMENT_WIDTH_RATIO);
const drawableWidthWithLastConnector = drawableWidth + connectorWidth;

const xScale = scaleBand()
.domain(xValues)
.range([0, drawableWidthWithLastConnector]);

const labelXScale = scaleBand()
.range([0, drawableWidthWithLastConnector])
.domain(labels.map((_, index) => index.toString()));

const sectionWidth = xScale.bandwidth();
const barWidth = sectionWidth * SEGMENT_WIDTH_RATIO;
const lineGradientId = useMemo(() => uniqueId('line-gradient'), []);

const lastPoint = dataSeries.at(-1);
const firstPoint = dataSeries[0];

const calculatePercentage = (value: number, total: number) => {
return total === 0 ? 0 : (value / total) * 100;
};

const percentages = dataSeries.map((dataPoint) => {
const firstValue = firstPoint?.value ?? 0;
return percentageFormatter(
calculatePercentage(dataPoint.value ?? 0, firstValue),
);
});

const formattedValues = dataSeries.map((dataPoint) => {
return labelFormatter(dataPoint.value);
});

const mainPercentage = percentageFormatter(
calculatePercentage(lastPoint?.value ?? 0, firstPoint?.value ?? 0),
);

const handleChartBlur = (event: React.FocusEvent) => {
const currentTarget = event.currentTarget;
const relatedTarget = event.relatedTarget as Node;

if (!currentTarget.contains(relatedTarget)) {
setTooltipIndex(null);
}
};

return (
<ChartElements.Svg height={drawableHeight} width={drawableWidth}>
<g onBlur={handleChartBlur}>
<FunnelChartConnectorGradient />

<LinearGradientWithStops
gradient={LINE_GRADIENT}
id={lineGradientId}
x1="0%"
x2="0%"
y1="0%"
y2="100%"
/>

<SingleTextLine
color={PERCENTAGE_COLOR}
fontWeight={600}
targetWidth={drawableWidth}
fontSize={20}
text={mainPercentage}
textAnchor="start"
/>

<g transform={`translate(0,${PERCENTAGE_SUMMARY_HEIGHT})`}>
<FunnelChartLabels
formattedValues={formattedValues}
labels={labels}
labelWidth={sectionWidth}
barWidth={barWidth}
percentages={percentages}
xScale={labelXScale}
shouldApplyScaling={shouldApplyScaling}
renderScaleIconTooltipContent={renderScaleIconTooltipContent}
/>
</g>

{dataSeries.map((dataPoint, index: number) => {
const nextPoint = dataSeries[index + 1];
const xPosition = xScale(dataPoint.key.toString());
const x = xPosition == null ? 0 : xPosition;
const isLast = index === dataSeries.length - 1;
const barHeight = getBarHeight(dataPoint.value || 0);
const nextBarHeight = getBarHeight(nextPoint?.value || 0);

return (
<Fragment key={dataPoint.key}>
<g key={dataPoint.key} role="listitem">
<FunnelChartSegment
ariaLabel={`${seriesNameFormatter(
dataPoint.key,
)}: ${labelFormatter(dataPoint.value)}`}
barHeight={barHeight}
barWidth={barWidth}
index={index}
isLast={isLast}
onMouseEnter={(index) => setTooltipIndex(index)}
onMouseLeave={() => setTooltipIndex(null)}
shouldApplyScaling={shouldApplyScaling}
x={x}
>
{!isLast && (
<FunnelChartConnector
drawableHeight={drawableHeight}
height={drawableHeight}
index={index}
nextX={
(xScale(nextPoint?.key.toString()) ?? 0) - LINE_OFFSET
}
nextY={drawableHeight - nextBarHeight}
startX={x + barWidth + GAP}
startY={drawableHeight - barHeight}
/>
)}
</FunnelChartSegment>
{index > 0 && (
<rect
y={PERCENTAGE_SUMMARY_HEIGHT}
x={x - (LINE_OFFSET - LINE_WIDTH)}
width={LINE_WIDTH}
height={drawableHeight - PERCENTAGE_SUMMARY_HEIGHT}
fill={`url(#${lineGradientId})`}
/>
)}
</g>
</Fragment>
);
})}

<TooltipWithPortal>{getTooltipMarkup()}</TooltipWithPortal>
</g>
</ChartElements.Svg>
);

function getTooltipMarkup() {
if (tooltipIndex == null) {
return null;
}

const tooltipHeight =
tooltipIndex === dataSeries.length - 1
? SHORT_TOOLTIP_HEIGHT
: TOOLTIP_HEIGHT;

const activeDataSeries = dataSeries[tooltipIndex];

if (activeDataSeries == null) {
return null;
}

const xPosition = getXPosition();
const yPosition = getYPosition();

return (
<FunnelTooltip x={xPosition} y={yPosition}>
<Tooltip
activeIndex={tooltipIndex}
dataSeries={dataSeries}
isLast={tooltipIndex === dataSeries.length - 1}
tooltipLabels={tooltipLabels}
labelFormatter={labelFormatter}
percentageFormatter={percentageFormatter}
/>
</FunnelTooltip>
);

function getXPosition() {
if (tooltipIndex === 0) {
return chartX + barWidth + TOOLTIP_HORIZONTAL_OFFSET;
}

const xOffset = (barWidth - TOOLTIP_WIDTH) / 2;
return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset;
}

function getYPosition() {
const barHeight = getBarHeight(activeDataSeries.value ?? 0);
const yPosition = chartY + drawableHeight - barHeight;

if (tooltipIndex === 0) {
return yPosition;
}

return yPosition - tooltipHeight;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type {ChartProps, LabelFormatter} from '@shopify/polaris-viz-core';
import {
DEFAULT_CHART_PROPS,
ChartState,
useChartContext,
} from '@shopify/polaris-viz-core';
import type {ReactNode} from 'react';

import {ChartContainer} from '../../components/ChartContainer';
import {ChartSkeleton} from '../';

import {Chart} from './Chart';

export type FunnelChartNextProps = {
tooltipLabels: {
reached: string;
dropped: string;
};
seriesNameFormatter?: LabelFormatter;
labelFormatter?: LabelFormatter;
renderScaleIconTooltipContent?: () => ReactNode;
percentageFormatter?: (value: number) => string;
} & ChartProps;

const DEFAULT_LABEL_FORMATTER: LabelFormatter = (value) => `${value}`;

export function FunnelChartNext(props: FunnelChartNextProps) {
const {theme: defaultTheme} = useChartContext();

const {
data,
theme = defaultTheme,
id,
isAnimated,
state,
errorText,
tooltipLabels,
seriesNameFormatter = DEFAULT_LABEL_FORMATTER,
labelFormatter = DEFAULT_LABEL_FORMATTER,
percentageFormatter,
onError,
renderScaleIconTooltipContent,
} = {
...DEFAULT_CHART_PROPS,
...props,
};

return (
<ChartContainer
data={data}
id={id}
isAnimated={isAnimated}
onError={onError}
theme={theme}
>
{state !== ChartState.Success ? (
<ChartSkeleton
type="Funnel"
state={state}
errorText={errorText}
theme={theme}
/>
) : (
<Chart
data={data}
tooltipLabels={tooltipLabels}
seriesNameFormatter={seriesNameFormatter}
labelFormatter={labelFormatter}
percentageFormatter={percentageFormatter}
renderScaleIconTooltipContent={renderScaleIconTooltipContent}
/>
)}
</ChartContainer>
);
}
Loading

0 comments on commit e4e66fe

Please sign in to comment.