From cab111b53e213726849c5f72b6c2abf86cae9ff3 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Wed, 5 Nov 2025 12:08:44 +0300 Subject: [PATCH 1/4] feat: basic funnel chart --- .../Funnel-series-Basic-1-chromium-linux.png | Bin 0 -> 8078 bytes ...ith-continuous-legend-1-chromium-linux.png | Bin 0 -> 12241 bytes src/__stories__/Funnel/Funnel.stories.tsx | 37 +++++ src/__stories__/__data__/funnel/basic.ts | 26 +++ .../__data__/funnel/continuous-legend.ts | 58 +++++++ src/__stories__/__data__/funnel/index.ts | 2 + src/__stories__/__data__/index.ts | 1 + src/__tests__/funnel.visual.test.tsx | 19 +++ src/components/Legend/index.tsx | 2 +- .../Tooltip/DefaultTooltipContent/index.tsx | 10 +- .../Tooltip/DefaultTooltipContent/utils.ts | 7 +- src/constants/chart-types.ts | 1 + src/constants/defaults/series-options.ts | 8 + src/hooks/useChartOptions/tooltip.ts | 4 +- src/hooks/useSeries/prepare-funnel.ts | 64 ++++++++ src/hooks/useSeries/prepareSeries.ts | 10 ++ src/hooks/useSeries/types.ts | 18 ++- src/hooks/useShapes/funnel/index.tsx | 130 +++++++++++++++ src/hooks/useShapes/funnel/prepare-data.ts | 152 ++++++++++++++++++ src/hooks/useShapes/funnel/types.ts | 38 +++++ src/hooks/useShapes/index.tsx | 24 ++- src/hooks/useShapes/styles.scss | 6 + src/types/chart/base.ts | 52 +++--- src/types/chart/funnel.ts | 49 ++++++ src/types/chart/series.ts | 13 +- src/types/chart/tooltip.ts | 12 ++ src/types/index.ts | 1 + src/utils/chart/color.ts | 3 +- src/utils/chart/get-closest-data.ts | 21 +++ src/utils/chart/index.ts | 2 +- 30 files changed, 732 insertions(+), 38 deletions(-) create mode 100644 src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-Basic-1-chromium-linux.png create mode 100644 src/__snapshots__/funnel.visual.test.tsx-snapshots/Funnel-series-With-continuous-legend-1-chromium-linux.png create mode 100644 src/__stories__/Funnel/Funnel.stories.tsx create mode 100644 src/__stories__/__data__/funnel/basic.ts create mode 100644 src/__stories__/__data__/funnel/continuous-legend.ts create mode 100644 src/__stories__/__data__/funnel/index.ts create mode 100644 src/__tests__/funnel.visual.test.tsx create mode 100644 src/hooks/useSeries/prepare-funnel.ts create mode 100644 src/hooks/useShapes/funnel/index.tsx create mode 100644 src/hooks/useShapes/funnel/prepare-data.ts create mode 100644 src/hooks/useShapes/funnel/types.ts create mode 100644 src/types/chart/funnel.ts 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 0000000000000000000000000000000000000000..df78b56142ed6982e66970ea119b0fca7719d4c8 GIT binary patch literal 8078 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 = { + 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..8cf528445 --- /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.only('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/Legend/index.tsx b/src/components/Legend/index.tsx index 24be3d91b..4899ab5ff 100644 --- a/src/components/Legend/index.tsx +++ b/src/components/Legend/index.tsx @@ -342,7 +342,7 @@ export const Legend = (props: Props) => { align: legend.align, width: config.maxWidth, contentWidth, - offsetLeft: config.offset.left, + offsetLeft: 0, }); left = legendLinePostion.left; legendWidth = config.maxWidth; 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..33da26415 --- /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 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, i) => { + 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: `Series ${i}`, + 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/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..34f503954 --- /dev/null +++ b/src/hooks/useShapes/funnel/index.tsx @@ -0,0 +1,130 @@ +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 {HtmlLayer} from '../HtmlLayer'; + +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, htmlLayout} = 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..3404d44a1 --- /dev/null +++ b/src/hooks/useShapes/funnel/prepare-data.ts @@ -0,0 +1,152 @@ +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, + htmlElements: [], + 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..2164322fc --- /dev/null +++ b/src/hooks/useShapes/funnel/types.ts @@ -0,0 +1,38 @@ +import type {Path} from 'd3'; + +import type {DashStyle} from 'src/constants'; + +import type {FunnelSeriesData, HtmlItem, 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[]; + htmlElements: HtmlItem[]; +}; 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..519606750 --- /dev/null +++ b/src/types/chart/funnel.ts @@ -0,0 +1,49 @@ +import type {DashStyle, SeriesType} 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 SeriesType.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..ee578819c 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'; @@ -91,6 +92,16 @@ export interface TooltipDataChunkHeatmap { closest: boolean; } +export interface TooltipDataChunkFunnel { + data: FunnelSeriesData; + series: { + type: FunnelSeries['type']; + id: string; + name: string; + }; + closest: boolean; +} + export type TooltipDataChunk = ( | TooltipDataChunkBarX | TooltipDataChunkBarY @@ -103,6 +114,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', From c1d0b999efaa42c36df3585548541cbaa9a20e69 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 21 Nov 2025 14:30:39 +0300 Subject: [PATCH 2/4] fix: type --- src/types/chart/funnel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/chart/funnel.ts b/src/types/chart/funnel.ts index 519606750..248d9ec47 100644 --- a/src/types/chart/funnel.ts +++ b/src/types/chart/funnel.ts @@ -1,4 +1,4 @@ -import type {DashStyle, SeriesType} from '../../constants'; +import type {DashStyle, SERIES_TYPE} from '../../constants'; import type {MeaningfulAny} from '../misc'; import type {BaseDataLabels, BaseSeries, BaseSeriesData} from './base'; @@ -14,7 +14,7 @@ export interface FunnelSeriesData extends BaseSeriesData { } export interface FunnelSeries extends Omit { - type: typeof SeriesType.Funnel; + type: typeof SERIES_TYPE.Funnel; data: FunnelSeriesData[]; /** The name of the funnel series. */ name?: string; From ca40bf0cf61e5f0932f48f26523d352b27581356 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 21 Nov 2025 14:37:44 +0300 Subject: [PATCH 3/4] fix: remove only --- src/__tests__/funnel.visual.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/funnel.visual.test.tsx b/src/__tests__/funnel.visual.test.tsx index 8cf528445..45e13c29a 100644 --- a/src/__tests__/funnel.visual.test.tsx +++ b/src/__tests__/funnel.visual.test.tsx @@ -6,7 +6,7 @@ import {funnelBasicData, funnelContinuousLegendData} from 'src/__stories__/__dat import {ChartTestStory} from '../../playwright/components/ChartTestStory'; -test.describe.only('Funnel series', () => { +test.describe('Funnel series', () => { test('Basic', async ({mount}) => { const component = await mount(); await expect(component.locator('svg')).toHaveScreenshot(); From 5fffce0ebfcd8ed30bd5498369de5b6e8fbdcc54 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 21 Nov 2025 15:37:30 +0300 Subject: [PATCH 4/4] fix: review fixes --- src/components/Legend/index.tsx | 2 +- src/hooks/useSeries/prepare-funnel.ts | 74 +++++++++++----------- src/hooks/useSeries/prepare-pie.ts | 5 +- src/hooks/useShapes/funnel/index.tsx | 4 +- src/hooks/useShapes/funnel/prepare-data.ts | 1 - src/hooks/useShapes/funnel/types.ts | 3 +- src/types/chart/tooltip.ts | 2 - 7 files changed, 43 insertions(+), 48 deletions(-) diff --git a/src/components/Legend/index.tsx b/src/components/Legend/index.tsx index 4899ab5ff..24be3d91b 100644 --- a/src/components/Legend/index.tsx +++ b/src/components/Legend/index.tsx @@ -342,7 +342,7 @@ export const Legend = (props: Props) => { align: legend.align, width: config.maxWidth, contentWidth, - offsetLeft: 0, + offsetLeft: config.offset.left, }); left = legendLinePostion.left; legendWidth = config.maxWidth; diff --git a/src/hooks/useSeries/prepare-funnel.ts b/src/hooks/useSeries/prepare-funnel.ts index 33da26415..66f393d26 100644 --- a/src/hooks/useSeries/prepare-funnel.ts +++ b/src/hooks/useSeries/prepare-funnel.ts @@ -3,6 +3,7 @@ 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'; @@ -21,44 +22,43 @@ export function prepareFunnelSeries(args: PrepareFunnelSeriesArgs) { const isConnectorsEnabled = series.connectors?.enabled ?? true; - const preparedSeries: PreparedSeries[] = series.data.map( - (dataItem, i) => { - 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: `Series ${i}`, - 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, - }, - }; + 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 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/useShapes/funnel/index.tsx b/src/hooks/useShapes/funnel/index.tsx index 34f503954..c6c2cadd3 100644 --- a/src/hooks/useShapes/funnel/index.tsx +++ b/src/hooks/useShapes/funnel/index.tsx @@ -6,7 +6,6 @@ import type {Dispatch} from 'd3'; import type {TooltipDataChunkFunnel} from '../../../types'; import {block, getLineDashArray} from '../../../utils'; import type {PreparedSeriesOptions} from '../../useSeries/types'; -import {HtmlLayer} from '../HtmlLayer'; import type {PreparedFunnelData} from './types'; @@ -23,7 +22,7 @@ type Args = { }; export const FunnelSeriesShapes = (args: Args) => { - const {dispatcher, preparedData, seriesOptions, htmlLayout} = args; + const {dispatcher, preparedData, seriesOptions} = args; const hoveredDataRef = React.useRef(null); const ref = React.useRef(null); @@ -124,7 +123,6 @@ export const FunnelSeriesShapes = (args: Args) => { return ( - ); }; diff --git a/src/hooks/useShapes/funnel/prepare-data.ts b/src/hooks/useShapes/funnel/prepare-data.ts index 3404d44a1..614b49476 100644 --- a/src/hooks/useShapes/funnel/prepare-data.ts +++ b/src/hooks/useShapes/funnel/prepare-data.ts @@ -144,7 +144,6 @@ export async function prepareFunnelData(args: Args): Promise type: 'funnel', items, svgLabels, - htmlElements: [], connectors, }; diff --git a/src/hooks/useShapes/funnel/types.ts b/src/hooks/useShapes/funnel/types.ts index 2164322fc..ccf911298 100644 --- a/src/hooks/useShapes/funnel/types.ts +++ b/src/hooks/useShapes/funnel/types.ts @@ -2,7 +2,7 @@ import type {Path} from 'd3'; import type {DashStyle} from 'src/constants'; -import type {FunnelSeriesData, HtmlItem, LabelData} from '../../../types'; +import type {FunnelSeriesData, LabelData} from '../../../types'; import type {PreparedFunnelSeries} from '../../useSeries/types'; export type FunnelItemData = { @@ -34,5 +34,4 @@ export type PreparedFunnelData = { items: FunnelItemData[]; connectors: FunnelConnectorData[]; svgLabels: LabelData[]; - htmlElements: HtmlItem[]; }; diff --git a/src/types/chart/tooltip.ts b/src/types/chart/tooltip.ts index ee578819c..09a5e8759 100644 --- a/src/types/chart/tooltip.ts +++ b/src/types/chart/tooltip.ts @@ -89,7 +89,6 @@ export interface TooltipDataChunkRadar { export interface TooltipDataChunkHeatmap { data: HeatmapSeriesData; series: HeatmapSeries; - closest: boolean; } export interface TooltipDataChunkFunnel { @@ -99,7 +98,6 @@ export interface TooltipDataChunkFunnel { id: string; name: string; }; - closest: boolean; } export type TooltipDataChunk = (