Skip to content

Commit

Permalink
Adding FunnelChartNext
Browse files Browse the repository at this point in the history
  • Loading branch information
envex committed Oct 9, 2024
1 parent d03ef90 commit c72c654
Show file tree
Hide file tree
Showing 26 changed files with 1,055 additions and 20 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
301 changes: 301 additions & 0 deletions packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx
Original file line number Diff line number Diff line change
@@ -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<XAxisOptions>;
yAxisOptions: Required<YAxisOptions>;
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<SVGSVGElement | null>(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 (
<ChartElements.Svg
height={drawableHeight}
width={drawableWidth}
setRef={setSvgRef}
>
<LinearGradientWithStops
gradient={CONNECTOR_GRADIENT}
id={connectorGradientId}
x1="100%"
x2="0%"
y1="0%"
y2="0%"
/>

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

<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}
/>
</g>

{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 (
<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,
width: sectionWidth - barWidth,
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}
percentCalculation={formatPercentage(percentCalculation)}
x={x}
/>
{index > 0 && (
<rect
x={x - (LINE_OFFSET - LINE_WIDTH)}
width={LINE_WIDTH}
height={drawableHeight}
fill={`url(#${lineGradientId})`}
/>
)}
</g>
</Fragment>
);
})}
<TooltipWrapper
bandwidth={xScale.bandwidth()}
chartBounds={chartBounds}
focusElementDataType={DataType.BarGroup}
getMarkup={getTooltipMarkup}
getPosition={getPosition}
margin={{Top: 0, Left: 0, Bottom: 0, Right: 0}}
parentRef={svgRef}
chartDimensions={dimensions}
usePortal
/>
</ChartElements.Svg>
);

function getTooltipMarkup(index: number) {
return (
<Tooltip
activeIndex={index}
dataSeries={dataSeries}
isLast={index === dataSeries.length - 1}
tooltipLabels={tooltipLabels}
yAxisOptions={yAxisOptions}
/>
);
}

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,
});
}
}
Original file line number Diff line number Diff line change
@@ -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<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,
tooltipLabels,
} = {
...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}
tooltipLabels={tooltipLabels}
/>
)}
</ChartContainer>
);
}
Loading

0 comments on commit c72c654

Please sign in to comment.