Skip to content

Commit

Permalink
Adding FunnelChartNext
Browse files Browse the repository at this point in the history
  • Loading branch information
envex committed Oct 4, 2024
1 parent d03ef90 commit 2dc5f46
Show file tree
Hide file tree
Showing 19 changed files with 627 additions and 19 deletions.
1 change: 1 addition & 0 deletions packages/polaris-viz-core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions packages/polaris-viz-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
210 changes: 210 additions & 0 deletions packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {Fragment, useMemo, useCallback} from 'react';
import {scaleBand, scaleLinear} from 'd3-scale';
import type {
DataSeries,
Dimensions,
XAxisOptions,
YAxisOptions,
} from '@shopify/polaris-viz-core';
import {uniqueId, LinearGradientWithStops} from '@shopify/polaris-viz-core';

import {SingleTextLine} from '../Labels';
import {ChartElements} from '../ChartElements';
import {MIN_BAR_HEIGHT} from '../../constants';

import {FunnelChartXAxisLabels, FunnelSegment} from './components/';

export interface ChartProps {
data: DataSeries[];
xAxisOptions: Required<XAxisOptions>;
yAxisOptions: Required<YAxisOptions>;
dimensions?: Dimensions;
}

const LINE_OFFSET = 3;
const LINE_WIDTH = 1;

const GAP = 1;
const BLUE_09 = 'rgba(48, 94, 232, 1)';
const CONNECTOR_GRADIENT = [
{
color: '#ADC4FC',
offset: 0,
},
{
color: '#8BAAF9',
offset: 100,
},
];

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,
xAxisOptions,
yAxisOptions,
}: ChartProps) {
const dataSeries = data[0].data;

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

const {width, height: drawableHeight} = dimensions ?? {
width: 0,
height: 0,
};

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

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

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

const yScale = scaleLinear()
.range([0, drawableHeight - LABELS_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];

function formatPercentage(value: number) {
return `${yAxisOptions.labelFormatter(value)}%`;
}

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 (
<ChartElements.Svg height={drawableHeight} width={width}>
<LinearGradientWithStops
gradient={CONNECTOR_GRADIENT}
id={connectorGradientId}
x1="100%"
x2="0%"
y1="0%"
y2="0%"
/>

<SingleTextLine
color="rgba(48, 48, 48, 1)"
fontWeight={600}
targetWidth={width}
fontSize={24}
text={formatPercentage(
((lastPoint?.value ?? 0) / (firstPoint?.value ?? 0)) * 100,
)}
/>

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

<g transform={`translate(0,${OVERALL_PERCENTAGE_HEIGHT})`}>
<FunnelChartXAxisLabels
formattedValues={formattedValues}
labels={labels}
labelWidth={sectionWidth}
percentages={percentages}
xScale={labelXScale}
/>

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

return (
<Fragment key={dataPoint.key}>
<g key={dataPoint.key} role="listitem">
<FunnelSegment
ariaLabel={`${xAxisOptions.labelFormatter(
dataPoint.key,
)}: ${yAxisOptions.labelFormatter(dataPoint.value)}`}
barHeight={barHeight}
barWidth={barWidth}
connector={{
height: drawableHeight,
startX: x + barWidth + GAP,
startY: drawableHeight - barHeight,
nextX:
(xScale(nextPoint?.key as string) ?? 0) - LINE_OFFSET,
nextY: drawableHeight - nextBarHeight,
nextPoint,
fill: `url(#${connectorGradientId})`,
}}
color={BLUE_09}
drawableHeight={drawableHeight}
index={index}
isLast={index === dataSeries.length - 1}
x={x}
/>
{index > 0 && (
<rect
x={x - (LINE_OFFSET - LINE_WIDTH)}
width={LINE_WIDTH}
height={drawableHeight}
fill={`url(#${lineGradientId})`}
/>
)}
</g>
</Fragment>
);
})}
</g>
</ChartElements.Svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 = {
xAxisOptions?: Omit<XAxisOptions, 'hide'>;
yAxisOptions?: Omit<XAxisOptions, 'integersOnly'>;
} & ChartProps;

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

const {
data,
theme = defaultTheme,
xAxisOptions,
yAxisOptions,
id,
isAnimated,
state,
errorText,
onError,
} = {
...DEFAULT_CHART_PROPS,
...props,
};

const xAxisOptionsForChart: Required<XAxisOptions> =
getXAxisOptionsWithDefaults(xAxisOptions);

const yAxisOptionsForChart: Required<YAxisOptions> =
getYAxisOptionsWithDefaults(yAxisOptions);

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}
xAxisOptions={xAxisOptionsForChart}
yAxisOptions={yAxisOptionsForChart}
/>
)}
</ChartContainer>
);
}
Original file line number Diff line number Diff line change
@@ -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<string>;
}

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 (
<Fragment>
{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 (
<g
transform={`translate(${
index === 0 ? x : x + GROUP_OFFSET
},${GROUP_OFFSET})`}
key={index}
>
<TextLine
color="rgba(31, 33, 36, 1)"
line={line}
index={index}
fontSize={LABEL_FONT_SIZE}
/>

<g transform={`translate(0,${firstLabelHeight + LINE_GAP})`}>
<SingleTextLine
color="rgba(31, 33, 36, 1)"
text={percentages[index]}
targetWidth={labelWidth}
textAnchor="left"
fontSize={14}
fontWeight={650}
/>
<SingleTextLine
color="rgba(97, 97, 97, 1)"
text={formattedValues[index]}
targetWidth={labelWidth}
x={percentWidth + LINE_PADDING}
// Fix visual centering
y={1}
textAnchor="left"
fontSize={11}
/>
</g>
</g>
);
})}
</Fragment>
);
}
Loading

0 comments on commit 2dc5f46

Please sign in to comment.