Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions src/__stories__/Funnel/Funnel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {Meta, StoryObj} from '@storybook/react-webpack5';

import {Chart} from '../../components';
import {ChartStory} from '../ChartStory';
import {funnelBasicData, funnelContinuousLegendData} from '../__data__';

const meta: Meta<typeof ChartStory> = {
title: 'Funnel',
render: ChartStory,
component: Chart,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: `A funnel chart is a data visualization that displays values as progressively decreasing proportions through stages, typically shown as a tapering cone or pyramid. It's primarily used to track conversion rates and identify drop-off points in sequential processes like sales pipelines or website user journeys. Unlike bar or pie charts that show static comparisons, funnel charts specifically emphasize the flow and attrition between consecutive stages of a process.`,
},
},
},
};

export default meta;

type Story = StoryObj<typeof ChartStory>;

export const FunnelBasic = {
name: 'Basic',
args: {
data: funnelBasicData,
},
} satisfies Story;

export const FunnelWithContinuousLegend = {
name: 'Continuous legend',
args: {
data: funnelContinuousLegendData,
},
} satisfies Story;
26 changes: 26 additions & 0 deletions src/__stories__/__data__/funnel/basic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {ChartData} from '../../../types';

function prepareData(): ChartData {
const chartData: ChartData = {
series: {
data: [
{
type: 'funnel',
name: 'Series 1',
data: [
{value: 100, name: 'Visit'},
{value: 87, name: 'Sign-up'},
{value: 63, name: 'Selection'},
{value: 27, name: 'Purchase'},
{value: 12, name: 'Review'},
],
},
],
},
legend: {enabled: true},
};

return chartData;
}

export const funnelBasicData = prepareData();
58 changes: 58 additions & 0 deletions src/__stories__/__data__/funnel/continuous-legend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type {ChartData, FunnelSeriesData} from '../../../types';
import {getContinuesColorFn, getFormattedValue} from '../../../utils';

function prepareData(): ChartData {
const colors = ['rgb(255, 61, 100)', 'rgb(255, 198, 54)', 'rgb(84, 165, 32)'];
const stops = [0, 0.5, 1];

const data: FunnelSeriesData[] = [
{value: 1200, name: 'Visit'},
{value: 900, name: 'Sign-up'},
{value: 505, name: 'Selection'},
{value: 240, name: 'Purchase'},
{value: 150, name: 'Review'},
];
const maxValue = Math.max(...data.map((d) => d.value));
const getColor = getContinuesColorFn({
colors,
stops,
values: data.map((d) => d.value ?? 0),
});
data.forEach((d) => {
d.color = getColor(d.value ?? 0);
const percentage = getFormattedValue({
value: d.value / maxValue,
format: {type: 'number', format: 'percent', precision: 0},
});
const absolute = getFormattedValue({value: d.value, format: {type: 'number'}});
d.label = `${d.name}: ${percentage} (${absolute})`;
});

const chartData: ChartData = {
series: {
data: [
{
type: 'funnel',
name: 'Series 1',
data,
dataLabels: {
align: 'left',
},
},
],
},
legend: {
enabled: true,
type: 'continuous',
title: {text: 'Funnel steps'},
colorScale: {
colors: colors,
stops,
},
},
};

return chartData;
}

export const funnelContinuousLegendData = prepareData();
2 changes: 2 additions & 0 deletions src/__stories__/__data__/funnel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './basic';
export * from './continuous-legend';
1 change: 1 addition & 0 deletions src/__stories__/__data__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './sankey';
export * from './scatter';
export * from './radar';
export * from './heatmap';
export * from './funnel';
19 changes: 19 additions & 0 deletions src/__tests__/funnel.visual.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';

import {expect, test} from '@playwright/experimental-ct-react';

import {funnelBasicData, funnelContinuousLegendData} from 'src/__stories__/__data__';

import {ChartTestStory} from '../../playwright/components/ChartTestStory';

test.describe('Funnel series', () => {
test('Basic', async ({mount}) => {
const component = await mount(<ChartTestStory data={funnelBasicData} />);
await expect(component.locator('svg')).toHaveScreenshot();
});

test('With continuous legend', async ({mount}) => {
const component = await mount(<ChartTestStory data={funnelContinuousLegendData} />);
await expect(component.locator('svg')).toHaveScreenshot();
});
});
10 changes: 7 additions & 3 deletions src/components/Tooltip/DefaultTooltipContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import get from 'lodash/get';
import isEqual from 'lodash/isEqual';

import {usePrevious} from '../../../hooks';
import type {PreparedPieSeries, PreparedRadarSeries} from '../../../hooks';
import type {PreparedFunnelSeries, PreparedPieSeries, PreparedRadarSeries} from '../../../hooks';
import {i18n} from '../../../i18n';
import type {
ChartTooltip,
Expand Down Expand Up @@ -245,8 +245,12 @@ export const DefaultTooltipContent = ({
}
case 'pie':
case 'heatmap':
case 'treemap': {
const seriesData = data as PreparedPieSeries | TreemapSeriesData;
case 'treemap':
case 'funnel': {
const seriesData = data as
| PreparedPieSeries
| TreemapSeriesData
| PreparedFunnelSeries;
const formattedValue = getFormattedValue({
value: hoveredValues[i],
format: rowValueFormat || {type: 'number'},
Expand Down
7 changes: 5 additions & 2 deletions src/components/Tooltip/DefaultTooltipContent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export const getMeasureValue = ({
}) => {
if (
data.every((item) =>
['pie', 'treemap', 'waterfall', 'sankey', 'heatmap'].includes(item.series.type),
['pie', 'treemap', 'waterfall', 'sankey', 'heatmap', 'funnel'].includes(
item.series.type,
),
)
) {
return null;
Expand Down Expand Up @@ -135,7 +137,8 @@ export function getHoveredValues(args: {
case 'pie':
case 'radar':
case 'heatmap':
case 'treemap': {
case 'treemap':
case 'funnel': {
const seriesData = data as PreparedPieSeries | TreemapSeriesData | RadarSeriesData;
return seriesData.value;
}
Expand Down
1 change: 1 addition & 0 deletions src/constants/chart-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const SERIES_TYPE = {
Sankey: 'sankey',
Radar: 'radar',
Heatmap: 'heatmap',
Funnel: 'funnel',
} as const;

export type SeriesType = (typeof SERIES_TYPE)[keyof typeof SERIES_TYPE];
8 changes: 8 additions & 0 deletions src/constants/defaults/series-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = {
},
},
},
funnel: {
states: {
hover: {
enabled: true,
brightness: 0.3,
},
},
},
};

export const seriesRangeSliderOptionsDefaults: Required<ChartSeriesRangeSliderOptions> = {
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useChartOptions/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ function getDefaultHeaderFormat({
}) {
if (
seriesData.every((item) =>
['pie', 'treemap', 'waterfall', 'sankey', 'radar', 'heatmap'].includes(item.type),
['pie', 'treemap', 'waterfall', 'sankey', 'radar', 'heatmap', 'funnel'].includes(
item.type,
),
)
) {
return undefined;
Expand Down
64 changes: 64 additions & 0 deletions src/hooks/useSeries/prepare-funnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {scaleOrdinal} from 'd3';
import get from 'lodash/get';

import {DEFAULT_DATALABELS_STYLE} from '../../constants';
import type {ChartSeriesOptions, FunnelSeries} from '../../types';
import {getUniqId} from '../../utils';

import type {PreparedFunnelSeries, PreparedLegend, PreparedSeries} from './types';
import {prepareLegendSymbol} from './utils';

type PrepareFunnelSeriesArgs = {
series: FunnelSeries;
seriesOptions?: ChartSeriesOptions;
legend: PreparedLegend;
colors: string[];
};

export function prepareFunnelSeries(args: PrepareFunnelSeriesArgs) {
const {series, legend, colors} = args;
const dataNames = series.data.map((d) => d.name);
const colorScale = scaleOrdinal(dataNames, colors);

const isConnectorsEnabled = series.connectors?.enabled ?? true;

const preparedSeries: PreparedSeries[] = series.data.map<PreparedFunnelSeries>((dataItem) => {
const id = getUniqId();
const color = dataItem.color || colorScale(dataItem.name);
const result: PreparedFunnelSeries = {
type: 'funnel',
data: dataItem,
dataLabels: {
enabled: get(series, 'dataLabels.enabled', true),
style: Object.assign({}, DEFAULT_DATALABELS_STYLE, series.dataLabels?.style),
html: get(series, 'dataLabels.html', false),
format: series.dataLabels?.format,
align: series.dataLabels?.align ?? 'center',
},
visible: true,
name: dataItem.name,
id,
color,
legend: {
enabled: get(series, 'legend.enabled', legend.enabled),
symbol: prepareLegendSymbol(series),
},
cursor: get(series, 'cursor', null),
tooltip: series.tooltip,
connectors: {
enabled: isConnectorsEnabled,
height: isConnectorsEnabled ? (series.connectors?.height ?? '25%') : 0,
lineDashStyle: series.connectors?.lineDashStyle ?? 'Dash',
lineOpacity: series.connectors?.lineOpacity ?? 1,
lineColor: series.connectors?.lineColor ?? 'var(--g-color-line-generic-active)',
areaColor: series.connectors?.areaColor ?? color,
areaOpacity: series.connectors?.areaOpacity ?? 0.25,
lineWidth: series.connectors?.lineWidth ?? 1,
},
};

return result;
});

return preparedSeries;
}
5 changes: 3 additions & 2 deletions src/hooks/useSeries/prepare-pie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export function preparePieSeries(args: PreparePieSeriesArgs) {
const stackId = getUniqId();
const seriesHoverState = get(seriesOptions, 'pie.states.hover');

const preparedSeries: PreparedSeries[] = preparedData.map<PreparedPieSeries>((dataItem, i) => {
const preparedSeries: PreparedSeries[] = preparedData.map<PreparedPieSeries>((dataItem) => {
const id = getUniqId();
const result: PreparedPieSeries = {
type: 'pie',
data: dataItem,
Expand All @@ -56,7 +57,7 @@ export function preparePieSeries(args: PreparePieSeriesArgs) {
value: dataItem.value,
visible: typeof dataItem.visible === 'boolean' ? dataItem.visible : true,
name: dataItem.name,
id: `Series ${i}`,
id,
color: dataItem.color || colorScale(dataItem.name),
legend: {
enabled: get(series, 'legend.enabled', legend.enabled),
Expand Down
10 changes: 10 additions & 0 deletions src/hooks/useSeries/prepareSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
BarYSeries,
ChartSeries,
ChartSeriesOptions,
FunnelSeries,
HeatmapSeries,
LineSeries,
PieSeries,
Expand All @@ -20,6 +21,7 @@ import type {
import {prepareArea} from './prepare-area';
import {prepareBarXSeries} from './prepare-bar-x';
import {prepareBarYSeries} from './prepare-bar-y';
import {prepareFunnelSeries} from './prepare-funnel';
import {prepareHeatmapSeries} from './prepare-heatmap';
import {prepareLineSeries} from './prepare-line';
import {preparePieSeries} from './prepare-pie';
Expand Down Expand Up @@ -129,6 +131,14 @@ export async function prepareSeries(args: {
seriesOptions,
});
}
case 'funnel': {
return prepareFunnelSeries({
series: series[0] as FunnelSeries,
seriesOptions,
legend,
colors,
});
}
default: {
throw new ChartError({
message: `Series type "${type}" does not support data preparation for series that do not support the presence of axes`,
Expand Down
18 changes: 17 additions & 1 deletion src/hooks/useSeries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
ChartSeriesRangeSliderOptions,
ConnectorCurve,
ConnectorShape,
FunnelSeries,
FunnelSeriesData,
HeatmapSeries,
HeatmapSeriesData,
LineSeries,
Expand Down Expand Up @@ -393,6 +395,19 @@ export type PreparedRadarSeries = {
};
} & BasePreparedSeries;

export type PreparedFunnelSeries = {
type: FunnelSeries['type'];
data: FunnelSeriesData;
dataLabels: {
enabled: boolean;
style: BaseTextStyle;
html: boolean;
format?: ValueFormat;
align: Required<Required<FunnelSeries>['dataLabels']>['align'];
};
connectors: Required<FunnelSeries['connectors']>;
} & BasePreparedSeries;

export type PreparedSeries =
| PreparedScatterSeries
| PreparedBarXSeries
Expand All @@ -404,7 +419,8 @@ export type PreparedSeries =
| PreparedWaterfallSeries
| PreparedSankeySeries
| PreparedRadarSeries
| PreparedHeatmapSeries;
| PreparedHeatmapSeries
| PreparedFunnelSeries;

export type PreparedZoomableSeries = Extract<
PreparedSeries,
Expand Down
Loading
Loading