diff --git a/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-Basic-1-chromium-linux.png b/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-Basic-1-chromium-linux.png new file mode 100644 index 000000000..df78b5614 Binary files /dev/null and b/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-Basic-1-chromium-linux.png differ diff --git a/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-With-continuous-legend-1-chromium-linux.png b/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-With-continuous-legend-1-chromium-linux.png new file mode 100644 index 000000000..7b37d902e Binary files /dev/null and b/src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-With-continuous-legend-1-chromium-linux.png differ diff --git a/src/__stories__/Funnel/Funnel.stories.tsx b/src/__stories__/Funnel/Funnel.stories.tsx new file mode 100644 index 000000000..6cc811acd --- /dev/null +++ b/src/__stories__/Funnel/Funnel.stories.tsx @@ -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 = { + 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; + +export const FunnelBasic = { + name: 'Basic', + args: { + data: funnelBasicData, + }, +} satisfies Story; + +export const FunnelWithContinuousLegend = { + name: 'Continuous legend', + args: { + data: funnelContinuousLegendData, + }, +} satisfies Story; diff --git a/src/__stories__/__data__/funnel/basic.ts b/src/__stories__/__data__/funnel/basic.ts new file mode 100644 index 000000000..47745283a --- /dev/null +++ b/src/__stories__/__data__/funnel/basic.ts @@ -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(); diff --git a/src/__stories__/__data__/funnel/continuous-legend.ts b/src/__stories__/__data__/funnel/continuous-legend.ts new file mode 100644 index 000000000..94568a670 --- /dev/null +++ b/src/__stories__/__data__/funnel/continuous-legend.ts @@ -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(); diff --git a/src/__stories__/__data__/funnel/index.ts b/src/__stories__/__data__/funnel/index.ts new file mode 100644 index 000000000..172169ffe --- /dev/null +++ b/src/__stories__/__data__/funnel/index.ts @@ -0,0 +1,2 @@ +export * from './basic'; +export * from './continuous-legend'; diff --git a/src/__stories__/__data__/index.ts b/src/__stories__/__data__/index.ts index ed6cae23d..5ad0b40c8 100644 --- a/src/__stories__/__data__/index.ts +++ b/src/__stories__/__data__/index.ts @@ -11,3 +11,4 @@ export * from './sankey'; export * from './scatter'; export * from './radar'; export * from './heatmap'; +export * from './funnel'; diff --git a/src/__tests__/funnel.visual.test.tsx b/src/__tests__/funnel.visual.test.tsx new file mode 100644 index 000000000..45e13c29a --- /dev/null +++ b/src/__tests__/funnel.visual.test.tsx @@ -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(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('With continuous legend', async ({mount}) => { + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); +}); diff --git a/src/components/Tooltip/DefaultTooltipContent/index.tsx b/src/components/Tooltip/DefaultTooltipContent/index.tsx index bfc8162d4..478a19443 100644 --- a/src/components/Tooltip/DefaultTooltipContent/index.tsx +++ b/src/components/Tooltip/DefaultTooltipContent/index.tsx @@ -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, @@ -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'}, diff --git a/src/components/Tooltip/DefaultTooltipContent/utils.ts b/src/components/Tooltip/DefaultTooltipContent/utils.ts index d7538db54..e91a13d2d 100644 --- a/src/components/Tooltip/DefaultTooltipContent/utils.ts +++ b/src/components/Tooltip/DefaultTooltipContent/utils.ts @@ -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; @@ -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; } diff --git a/src/constants/chart-types.ts b/src/constants/chart-types.ts index 5cb26ad22..201233ea3 100644 --- a/src/constants/chart-types.ts +++ b/src/constants/chart-types.ts @@ -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]; diff --git a/src/constants/defaults/series-options.ts b/src/constants/defaults/series-options.ts index 23893cc77..1859c9fb5 100644 --- a/src/constants/defaults/series-options.ts +++ b/src/constants/defaults/series-options.ts @@ -144,6 +144,14 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = { }, }, }, + funnel: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + }, + }, }; export const seriesRangeSliderOptionsDefaults: Required = { diff --git a/src/hooks/useChartOptions/tooltip.ts b/src/hooks/useChartOptions/tooltip.ts index 7ff8d2e4a..6511cba77 100644 --- a/src/hooks/useChartOptions/tooltip.ts +++ b/src/hooks/useChartOptions/tooltip.ts @@ -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; diff --git a/src/hooks/useSeries/prepare-funnel.ts b/src/hooks/useSeries/prepare-funnel.ts new file mode 100644 index 000000000..66f393d26 --- /dev/null +++ b/src/hooks/useSeries/prepare-funnel.ts @@ -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((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; +} diff --git a/src/hooks/useSeries/prepare-pie.ts b/src/hooks/useSeries/prepare-pie.ts index 37bec66a9..b9cc638af 100644 --- a/src/hooks/useSeries/prepare-pie.ts +++ b/src/hooks/useSeries/prepare-pie.ts @@ -36,7 +36,8 @@ export function preparePieSeries(args: PreparePieSeriesArgs) { const stackId = getUniqId(); const seriesHoverState = get(seriesOptions, 'pie.states.hover'); - const preparedSeries: PreparedSeries[] = preparedData.map((dataItem, i) => { + const preparedSeries: PreparedSeries[] = preparedData.map((dataItem) => { + const id = getUniqId(); const result: PreparedPieSeries = { type: 'pie', data: dataItem, @@ -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), diff --git a/src/hooks/useSeries/prepareSeries.ts b/src/hooks/useSeries/prepareSeries.ts index b3c55d532..3763c841c 100644 --- a/src/hooks/useSeries/prepareSeries.ts +++ b/src/hooks/useSeries/prepareSeries.ts @@ -7,6 +7,7 @@ import type { BarYSeries, ChartSeries, ChartSeriesOptions, + FunnelSeries, HeatmapSeries, LineSeries, PieSeries, @@ -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'; @@ -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`, diff --git a/src/hooks/useSeries/types.ts b/src/hooks/useSeries/types.ts index 333258ebb..dab4d81bd 100644 --- a/src/hooks/useSeries/types.ts +++ b/src/hooks/useSeries/types.ts @@ -18,6 +18,8 @@ import type { ChartSeriesRangeSliderOptions, ConnectorCurve, ConnectorShape, + FunnelSeries, + FunnelSeriesData, HeatmapSeries, HeatmapSeriesData, LineSeries, @@ -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['dataLabels']>['align']; + }; + connectors: Required; +} & BasePreparedSeries; + export type PreparedSeries = | PreparedScatterSeries | PreparedBarXSeries @@ -404,7 +419,8 @@ export type PreparedSeries = | PreparedWaterfallSeries | PreparedSankeySeries | PreparedRadarSeries - | PreparedHeatmapSeries; + | PreparedHeatmapSeries + | PreparedFunnelSeries; export type PreparedZoomableSeries = Extract< PreparedSeries, diff --git a/src/hooks/useShapes/funnel/index.tsx b/src/hooks/useShapes/funnel/index.tsx new file mode 100644 index 000000000..c6c2cadd3 --- /dev/null +++ b/src/hooks/useShapes/funnel/index.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import {color, select} from 'd3'; +import type {Dispatch} from 'd3'; + +import type {TooltipDataChunkFunnel} from '../../../types'; +import {block, getLineDashArray} from '../../../utils'; +import type {PreparedSeriesOptions} from '../../useSeries/types'; + +import type {PreparedFunnelData} from './types'; + +export {prepareFunnelData} from './prepare-data'; +export * from './types'; + +const b = block('funnel'); + +type Args = { + dispatcher?: Dispatch; + preparedData: PreparedFunnelData; + seriesOptions: PreparedSeriesOptions; + htmlLayout: HTMLElement | null; +}; + +export const FunnelSeriesShapes = (args: Args) => { + const {dispatcher, preparedData, seriesOptions} = args; + const hoveredDataRef = React.useRef(null); + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + const hoverOptions = seriesOptions.funnel?.states?.hover; + svgElement.selectAll('*').remove(); + + // funnel levels + const cellsSelection = svgElement + .selectAll('rect') + .data(preparedData.items) + .join('rect') + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .attr('height', (d) => d.height) + .attr('width', (d) => d.width) + .attr('fill', (d) => d.color) + .attr('stroke', (d) => d.borderColor) + .attr('stroke-width', (d) => d.borderWidth); + + // connectors + const connectorAreaClassName = b('connector-area'); + svgElement + .selectAll(`.${connectorAreaClassName}`) + .data(preparedData.connectors) + .join('path') + .attr('d', (d) => d.areaPath.toString()) + .attr('class', connectorAreaClassName) + .attr('fill', (d) => d.areaColor) + .attr('opacity', (d) => d.areaOpacity); + + const connectorLineClassName = b('connector-line'); + const connectorLines = svgElement + .selectAll(`.${connectorLineClassName}`) + .data(preparedData.connectors) + .join('g') + .attr('class', connectorLineClassName) + .attr('stroke', (d) => d.lineColor) + .attr('stroke-width', (d) => d.lineWidth) + .attr('stroke-dasharray', (d) => getLineDashArray(d.dashStyle, d.lineWidth)) + .attr('fill', 'none') + .attr('opacity', (d) => d.lineOpacity); + connectorLines.append('path').attr('d', (d) => d.linePath[0].toString()); + connectorLines.append('path').attr('d', (d) => d.linePath[1].toString()); + + // dataLabels + svgElement + .selectAll('text') + .data(preparedData.svgLabels) + .join('text') + .text((d) => d.text) + .attr('class', b('label')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .style('font-size', (d) => d.style.fontSize) + .style('font-weight', (d) => d.style.fontWeight || null) + .style('fill', (d) => d.style.fontColor || null); + + function handleShapeHover(data?: TooltipDataChunkFunnel[]) { + hoveredDataRef.current = data; + const hoverEnabled = hoverOptions?.enabled; + + if (hoverEnabled) { + const hovered = data?.reduce((acc, d) => { + acc.add(d.data); + return acc; + }, new Set()); + + cellsSelection.attr('fill', (d) => { + const fillColor = d.color; + if (hovered?.has(d.data)) { + return ( + color(fillColor)?.brighter(hoverOptions.brightness).toString() || + fillColor + ); + } + return fillColor; + }); + } + } + + if (hoveredDataRef.current !== null) { + handleShapeHover(hoveredDataRef.current); + } + + dispatcher?.on('hover-shape.funnel', handleShapeHover); + + return () => { + dispatcher?.on('hover-shape.funnel', null); + }; + }, [dispatcher, preparedData, seriesOptions]); + + return ( + + + + ); +}; diff --git a/src/hooks/useShapes/funnel/prepare-data.ts b/src/hooks/useShapes/funnel/prepare-data.ts new file mode 100644 index 000000000..614b49476 --- /dev/null +++ b/src/hooks/useShapes/funnel/prepare-data.ts @@ -0,0 +1,151 @@ +import {path} from 'd3'; + +import {calculateNumericProperty, getFormattedValue, getTextSizeFn} from '../../../utils'; +import type {PreparedFunnelSeries} from '../../useSeries/types'; + +import type {PreparedFunnelData} from './types'; + +type Args = { + series: PreparedFunnelSeries[]; + boundsWidth: number; + boundsHeight: number; +}; + +function getLineConnectorPaths(args: {points: [number, number][]}) { + const {points} = args; + + const leftPath = path(); + leftPath.moveTo(...points[0]); + leftPath.lineTo(...points[3]); + + const rightPath = path(); + rightPath.moveTo(...points[1]); + rightPath.lineTo(...points[2]); + + return [leftPath, rightPath]; +} + +function getAreaConnectorPath(args: {points: [number, number][]}) { + const {points} = args; + + const p = path(); + + p.moveTo(...points[points.length - 1]); + points.forEach((point) => p.lineTo(...point)); + p.closePath(); + + return p; +} + +export async function prepareFunnelData(args: Args): Promise { + const {series, boundsWidth, boundsHeight} = args; + + const items: PreparedFunnelData['items'] = []; + const svgLabels: PreparedFunnelData['svgLabels'] = []; + const connectors: PreparedFunnelData['connectors'] = []; + + const maxValue = Math.max(...series.map((s) => s.data.value)); + const itemBandSpace = boundsHeight / series.length; + const connectorHeight = + calculateNumericProperty({ + value: series[0].connectors?.height, + base: itemBandSpace, + }) ?? 0; + const itemHeight = (boundsHeight - connectorHeight * (series.length - 1)) / series.length; + const getTextSize = getTextSizeFn({style: series[0].dataLabels.style}); + + const getSegmentY = (index: number) => { + return index * (itemHeight + connectorHeight); + }; + + let segmentLeftOffset = 0; + let segmentRightOffset = 0; + for (let index = 0; index < series.length; index++) { + const s = series[index]; + + if (s.dataLabels.enabled) { + const d = s.data; + const labelContent = + d.label ?? getFormattedValue({value: d.value, format: s.dataLabels.format}); + const labelSize = await getTextSize(labelContent); + + let x; + switch (s.dataLabels.align) { + case 'left': { + x = 0; + segmentLeftOffset = Math.max(segmentLeftOffset, labelSize.width); + break; + } + case 'right': { + x = boundsWidth - labelSize.width; + segmentRightOffset = Math.max(segmentRightOffset, labelSize.width); + break; + } + case 'center': { + x = boundsWidth / 2 - labelSize.width / 2; + break; + } + } + + svgLabels.push({ + x, + y: getSegmentY(index) + itemHeight / 2 - labelSize.height / 2, + text: labelContent, + style: s.dataLabels.style, + size: labelSize, + textAnchor: 'start', + series: s, + }); + } + } + + const segmentMaxWidth = boundsWidth - segmentLeftOffset - segmentRightOffset; + for (let index = 0; index < series.length; index++) { + const s = series[index]; + const d = s.data; + const itemWidth = (segmentMaxWidth * d.value) / maxValue; + const funnelSegment = { + x: segmentLeftOffset + segmentMaxWidth / 2 - itemWidth / 2, + y: getSegmentY(index), + width: itemWidth, + height: itemHeight, + color: s.color, + series: s, + data: d, + borderColor: '', + borderWidth: 0, + cursor: s.cursor, + }; + items.push(funnelSegment); + + const prevSeries = series[index - 1]; + const prevItem = items[index - 1]; + if (prevSeries && prevItem && prevSeries.connectors?.enabled) { + const connectorPoints: [number, number][] = [ + [prevItem.x, prevItem.y + prevItem.height], + [prevItem.x + prevItem.width, prevItem.y + prevItem.height], + [funnelSegment.x + funnelSegment.width, funnelSegment.y], + [funnelSegment.x, funnelSegment.y], + ]; + connectors.push({ + linePath: getLineConnectorPaths({points: connectorPoints}), + areaPath: getAreaConnectorPath({points: connectorPoints}), + lineWidth: prevSeries.connectors.lineWidth, + lineColor: prevSeries.connectors.lineColor, + lineOpacity: prevSeries.connectors.lineOpacity, + areaColor: prevSeries.connectors.areaColor, + areaOpacity: prevSeries.connectors.areaOpacity, + dashStyle: prevSeries.connectors.lineDashStyle, + }); + } + } + + const data: PreparedFunnelData = { + type: 'funnel', + items, + svgLabels, + connectors, + }; + + return data; +} diff --git a/src/hooks/useShapes/funnel/types.ts b/src/hooks/useShapes/funnel/types.ts new file mode 100644 index 000000000..ccf911298 --- /dev/null +++ b/src/hooks/useShapes/funnel/types.ts @@ -0,0 +1,37 @@ +import type {Path} from 'd3'; + +import type {DashStyle} from 'src/constants'; + +import type {FunnelSeriesData, LabelData} from '../../../types'; +import type {PreparedFunnelSeries} from '../../useSeries/types'; + +export type FunnelItemData = { + x: number; + y: number; + width: number; + height: number; + color: string; + series: PreparedFunnelSeries; + data: FunnelSeriesData; + borderColor: string; + borderWidth: number; + cursor: string | null; +}; + +export type FunnelConnectorData = { + linePath: Path[]; + areaPath: Path; + lineWidth: number; + lineColor: string; + lineOpacity: number; + areaColor: string; + areaOpacity: number; + dashStyle: DashStyle; +}; + +export type PreparedFunnelData = { + type: 'funnel'; + items: FunnelItemData[]; + connectors: FunnelConnectorData[]; + svgLabels: LabelData[]; +}; diff --git a/src/hooks/useShapes/index.tsx b/src/hooks/useShapes/index.tsx index 04ec9cfe9..9e7fa7731 100644 --- a/src/hooks/useShapes/index.tsx +++ b/src/hooks/useShapes/index.tsx @@ -13,6 +13,7 @@ import type { PreparedAreaSeries, PreparedBarXSeries, PreparedBarYSeries, + PreparedFunnelSeries, PreparedHeatmapSeries, PreparedLineSeries, PreparedPieSeries, @@ -33,6 +34,8 @@ import {BarXSeriesShapes, prepareBarXData} from './bar-x'; import type {PreparedBarXData} from './bar-x'; import {BarYSeriesShapes, prepareBarYData} from './bar-y'; import type {PreparedBarYData} from './bar-y/types'; +import type {PreparedFunnelData} from './funnel'; +import {FunnelSeriesShapes, prepareFunnelData} from './funnel'; import type {PreparedHeatmapData} from './heatmap'; import {HeatmapSeriesShapes, prepareHeatmapData} from './heatmap'; import {LineSeriesShapes} from './line'; @@ -68,7 +71,8 @@ export type ShapeData = | PreparedWaterfallData | PreparedSankeyData | PreparedRadarData - | PreparedHeatmapData; + | PreparedHeatmapData + | PreparedFunnelData; export type ClipPathBySeriesType = Partial>; @@ -387,6 +391,24 @@ export const useShapes = (args: Args) => { } break; } + case 'funnel': { + const preparedData = await prepareFunnelData({ + series: chartSeries as PreparedFunnelSeries[], + boundsWidth, + boundsHeight, + }); + shapes.push( + , + ); + shapesData.push(preparedData); + break; + } default: { throw new ChartError({ message: `The display method is not defined for a series with type "${seriesType}"`, diff --git a/src/hooks/useShapes/styles.scss b/src/hooks/useShapes/styles.scss index e70ff0f8d..6ca91ebbe 100644 --- a/src/hooks/useShapes/styles.scss +++ b/src/hooks/useShapes/styles.scss @@ -62,3 +62,9 @@ dominant-baseline: text-before-edge; } } + +.gcharts-funnel { + &__label { + dominant-baseline: text-before-edge; + } +} diff --git a/src/types/chart/base.ts b/src/types/chart/base.ts index 7dc65de1f..4c52e93b3 100644 --- a/src/types/chart/base.ts +++ b/src/types/chart/base.ts @@ -14,37 +14,39 @@ export type CustomFormat = { }; export type ValueFormat = NumberFormat | DateFormat; +export interface BaseDataLabels { + /** + * Enable or disable the data labels + * @default true + */ + enabled?: boolean; + style?: Partial; + /** + * @default 5 + * */ + padding?: number; + /** + * @default false + * */ + allowOverlap?: boolean; + /** + * Allows to use any html-tags to display the content. + * The element will be displayed outside the box of the SVG element. + * + * @default false + * */ + html?: boolean; + /** Formatting settings for labels. */ + format?: ValueFormat; +} + export interface BaseSeries { /** Initial visibility of the series */ visible?: boolean; /** * Options for the series data labels, appearing next to each data point. * */ - dataLabels?: { - /** - * Enable or disable the data labels - * @default true - */ - enabled?: boolean; - style?: Partial; - /** - * @default 5 - * */ - padding?: number; - /** - * @default false - * */ - allowOverlap?: boolean; - /** - * Allows to use any html-tags to display the content. - * The element will be displayed outside the box of the SVG element. - * - * @default false - * */ - html?: boolean; - /** Formatting settings for labels. */ - format?: ValueFormat; - }; + dataLabels?: BaseDataLabels; /** You can set the cursor to "pointer" if you have click events attached to the series, to signal to the user that the points and lines can be clicked. */ cursor?: string; /** diff --git a/src/types/chart/funnel.ts b/src/types/chart/funnel.ts new file mode 100644 index 000000000..248d9ec47 --- /dev/null +++ b/src/types/chart/funnel.ts @@ -0,0 +1,49 @@ +import type {DashStyle, SERIES_TYPE} from '../../constants'; +import type {MeaningfulAny} from '../misc'; + +import type {BaseDataLabels, BaseSeries, BaseSeriesData} from './base'; +import type {ChartLegend, RectLegendSymbolOptions} from './legend'; + +export interface FunnelSeriesData extends BaseSeriesData { + /** The value of the funnel segment. */ + value: number; + /** The name of the funnel segment (used in legend, tooltip etc). */ + name: string; + /** Initial data label of the funnel segment. If not specified, the value is used. */ + label?: string; +} + +export interface FunnelSeries extends Omit { + type: typeof SERIES_TYPE.Funnel; + data: FunnelSeriesData[]; + /** The name of the funnel series. */ + name?: string; + /** The color of the funnel series. */ + color?: string; + /** Individual series legend options. Has higher priority than legend options in widget data */ + legend?: ChartLegend & { + symbol?: RectLegendSymbolOptions; + }; + /** Lines or areas connecting the funnel segments. */ + connectors?: { + enabled?: boolean; + /** The height of the connector area relative to the funnel segment. */ + height?: string | number; + /** Option for line stroke style */ + lineDashStyle?: DashStyle; + /** Opacity for the connector line. */ + lineOpacity?: number; + /** Connector line color. */ + lineColor?: string; + /** Connector line width in pixels. */ + lineWidth?: number; + /** Connector area color. */ + areaColor?: string; + /** Opacity for the connector area. */ + areaOpacity?: number; + }; + dataLabels?: Omit & { + /** Horizontal alignment of the data labels. */ + align?: 'left' | 'center' | 'right'; + }; +} diff --git a/src/types/chart/series.ts b/src/types/chart/series.ts index 49c878097..82fb0bb83 100644 --- a/src/types/chart/series.ts +++ b/src/types/chart/series.ts @@ -6,6 +6,7 @@ import type {MeaningfulAny} from '../misc'; import type {AreaSeries, AreaSeriesData} from './area'; import type {BarXSeries, BarXSeriesData} from './bar-x'; import type {BarYSeries, BarYSeriesData} from './bar-y'; +import type {FunnelSeries, FunnelSeriesData} from './funnel'; import type {Halo} from './halo'; import type {HeatmapSeries, HeatmapSeriesData} from './heatmap'; import type {LineSeries, LineSeriesData} from './line'; @@ -28,7 +29,8 @@ export type ChartSeries = | WaterfallSeries | SankeySeries | RadarSeries - | HeatmapSeries; + | HeatmapSeries + | FunnelSeries; export type ChartSeriesData = | ScatterSeriesData @@ -41,7 +43,8 @@ export type ChartSeriesData = | WaterfallSeriesData | SankeySeriesData | RadarSeriesData - | HeatmapSeriesData; + | HeatmapSeriesData + | FunnelSeriesData; export interface DataLabelRendererData { data: ChartSeriesData; @@ -317,6 +320,12 @@ export interface ChartSeriesOptions { */ borderColor?: string; }; + funnel?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + }; + }; } export type ChartSeriesRangeSliderOptions = { diff --git a/src/types/chart/tooltip.ts b/src/types/chart/tooltip.ts index 8e3dc7d57..09a5e8759 100644 --- a/src/types/chart/tooltip.ts +++ b/src/types/chart/tooltip.ts @@ -6,6 +6,7 @@ import type {ChartXAxis, ChartYAxis} from './axis'; import type {BarXSeries, BarXSeriesData} from './bar-x'; import type {BarYSeries, BarYSeriesData} from './bar-y'; import type {CustomFormat, ValueFormat} from './base'; +import type {FunnelSeries, FunnelSeriesData} from './funnel'; import type {HeatmapSeries, HeatmapSeriesData} from './heatmap'; import type {LineSeries, LineSeriesData} from './line'; import type {PieSeries, PieSeriesData} from './pie'; @@ -88,7 +89,15 @@ export interface TooltipDataChunkRadar { export interface TooltipDataChunkHeatmap { data: HeatmapSeriesData; series: HeatmapSeries; - closest: boolean; +} + +export interface TooltipDataChunkFunnel { + data: FunnelSeriesData; + series: { + type: FunnelSeries['type']; + id: string; + name: string; + }; } export type TooltipDataChunk = ( @@ -103,6 +112,7 @@ export type TooltipDataChunk = ( | TooltipDataChunkWaterfall | TooltipDataChunkRadar | TooltipDataChunkHeatmap + | TooltipDataChunkFunnel ) & {closest?: boolean}; export interface ChartTooltipRendererArgs { diff --git a/src/types/index.ts b/src/types/index.ts index 294095bf1..d27bdde83 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,7 @@ export * from './chart/waterfall'; export * from './chart/sankey'; export * from './chart/radar'; export * from './chart/heatmap'; +export * from './chart/funnel'; export * from './chart/brush'; export interface ChartData { diff --git a/src/utils/chart/color.ts b/src/utils/chart/color.ts index 2a1c27b9c..282e4cd7a 100644 --- a/src/utils/chart/color.ts +++ b/src/utils/chart/color.ts @@ -9,7 +9,8 @@ export function getDomainForContinuousColorScale(args: { const values = series.reduce((acc, s) => { switch (s.type) { case 'pie': - case 'heatmap': { + case 'heatmap': + case 'funnel': { acc.push(...s.data.map((d) => Number(d.value))); break; } diff --git a/src/utils/chart/get-closest-data.ts b/src/utils/chart/get-closest-data.ts index 94ebb01c2..601d58748 100644 --- a/src/utils/chart/get-closest-data.ts +++ b/src/utils/chart/get-closest-data.ts @@ -5,6 +5,7 @@ import groupBy from 'lodash/groupBy'; import type {PreparedBarXData, PreparedScatterData, ShapeData} from '../../hooks'; import type {PreparedAreaData} from '../../hooks/useShapes/area/types'; import type {PreparedBarYData} from '../../hooks/useShapes/bar-y/types'; +import type {PreparedFunnelData} from '../../hooks/useShapes/funnel/types'; import type {PreparedHeatmapData} from '../../hooks/useShapes/heatmap'; import type {PreparedLineData} from '../../hooks/useShapes/line/types'; import type {PreparedPieData} from '../../hooks/useShapes/pie/types'; @@ -356,6 +357,26 @@ export function getClosestPoints(args: GetClosestPointsArgs): TooltipDataChunk[] } } + break; + } + case 'funnel': { + const data = list as unknown as PreparedFunnelData[]; + const closestPoint = data[0]?.items.find((item) => { + return ( + pointerX >= item.x && + pointerX <= item.x + item.width && + pointerY >= item.y && + pointerY <= item.y + item.height + ); + }); + if (closestPoint) { + result.push({ + data: closestPoint.data, + series: closestPoint.series, + closest: true, + }); + } + break; } } diff --git a/src/utils/chart/index.ts b/src/utils/chart/index.ts index b2a71d101..eedeac1df 100644 --- a/src/utils/chart/index.ts +++ b/src/utils/chart/index.ts @@ -24,7 +24,7 @@ export * from './text'; export * from './time'; export * from './zoom'; -const CHARTS_WITHOUT_AXIS: ChartSeries['type'][] = ['pie', 'treemap', 'sankey', 'radar']; +const CHARTS_WITHOUT_AXIS: ChartSeries['type'][] = ['pie', 'treemap', 'sankey', 'radar', 'funnel']; export const CHART_SERIES_WITH_VOLUME_ON_Y_AXIS: ChartSeries['type'][] = [ 'bar-x', 'area',