From 060908399c3b9c672a2a7ac2b64b89dfb392ffdc Mon Sep 17 00:00:00 2001 From: bdknox Date: Sun, 7 May 2023 00:57:19 -0400 Subject: [PATCH 1/7] Feature - fixed shaped funnel charts --- packages/funnel/src/Funnel.tsx | 144 ++++++++- packages/funnel/src/hooks.ts | 322 ++++++++++++++++++++ packages/funnel/src/props.tsx | 1 + packages/funnel/src/types.ts | 1 + packages/funnel/tests/Funnel.test.tsx | 33 ++ storybook/stories/funnel/Funnel.stories.tsx | 4 + 6 files changed, 503 insertions(+), 2 deletions(-) diff --git a/packages/funnel/src/Funnel.tsx b/packages/funnel/src/Funnel.tsx index 6d6d8f8f42..87447120dd 100644 --- a/packages/funnel/src/Funnel.tsx +++ b/packages/funnel/src/Funnel.tsx @@ -1,7 +1,7 @@ import { createElement, Fragment, ReactNode } from 'react' import { SvgWrapper, Container, useDimensions } from '@nivo/core' import { svgDefaultProps } from './props' -import { useFunnel } from './hooks' +import { useFunnel, useShapedFunnel } from './hooks' import { Parts } from './Parts' import { PartLabels } from './PartLabels' import { Separators } from './Separators' @@ -154,12 +154,148 @@ const InnerFunnel = ({ ) } +const InnerShapedFunnel = ({ + data, + width, + height, + margin: partialMargin, + interpolation = svgDefaultProps.interpolation, + spacing = svgDefaultProps.spacing, + valueFormat, + colors = svgDefaultProps.colors, + fillOpacity = svgDefaultProps.fillOpacity, + borderColor = svgDefaultProps.borderColor, + borderOpacity = svgDefaultProps.borderOpacity, + enableLabel = svgDefaultProps.enableLabel, + labelColor = svgDefaultProps.labelColor, + enableBeforeSeparators = svgDefaultProps.enableBeforeSeparators, + beforeSeparatorLength = svgDefaultProps.beforeSeparatorLength, + beforeSeparatorOffset = svgDefaultProps.beforeSeparatorOffset, + enableAfterSeparators = svgDefaultProps.enableAfterSeparators, + afterSeparatorLength = svgDefaultProps.afterSeparatorLength, + afterSeparatorOffset = svgDefaultProps.afterSeparatorOffset, + layers = svgDefaultProps.layers, + annotations = svgDefaultProps.annotations, + isInteractive = svgDefaultProps.isInteractive, + currentPartSizeExtension = svgDefaultProps.currentPartSizeExtension, + currentBorderWidth, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + role = svgDefaultProps.role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: InnerFunnelProps) => { + const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( + width, + height, + partialMargin + ) + + const { + areaGenerator, + borderGenerator, + parts, + beforeSeparators, + afterSeparators, + customLayerProps, + } = useShapedFunnel({ + data, + width: innerWidth, + height: innerHeight, + interpolation, + spacing, + valueFormat, + colors, + fillOpacity, + borderColor, + borderOpacity, + labelColor, + enableBeforeSeparators, + beforeSeparatorLength, + beforeSeparatorOffset, + enableAfterSeparators, + afterSeparatorLength, + afterSeparatorOffset, + isInteractive, + currentPartSizeExtension, + currentBorderWidth, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + }) + + const layerById: Record = { + separators: null, + parts: null, + annotations: null, + labels: null, + } + + if (layers.includes('separators')) { + layerById.separators = ( + + ) + } + + if (layers.includes('parts')) { + layerById.parts = ( + + key="parts" + parts={parts} + areaGenerator={areaGenerator} + borderGenerator={borderGenerator} + /> + ) + } + + if (layers?.includes('annotations')) { + layerById.annotations = ( + key="annotations" parts={parts} annotations={annotations} /> + ) + } + + if (layers.includes('labels') && enableLabel) { + layerById.labels = key="labels" parts={parts} /> + } + + return ( + + {layers.map((layer, i) => { + if (typeof layer === 'function') { + return {createElement(layer, customLayerProps)} + } + + return layerById?.[layer] ?? null + })} + + ) +} + export const Funnel = ({ isInteractive = svgDefaultProps.isInteractive, animate = svgDefaultProps.animate, motionConfig = svgDefaultProps.motionConfig, theme, renderWrapper, + fixedShape = svgDefaultProps.fixedShape, ...otherProps }: FunnelSvgProps) => ( ({ theme, }} > - isInteractive={isInteractive} {...otherProps} /> + {fixedShape ? ( + isInteractive={isInteractive} {...otherProps} /> + ) : ( + isInteractive={isInteractive} {...otherProps} /> + )} ) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index ca026177ab..04b5c98880 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -654,6 +654,328 @@ export const useFunnel = ({ } } +export const useShapedFunnel = ({ + data, + width, + height, + interpolation = defaults.interpolation, + spacing = defaults.spacing, + valueFormat, + colors = defaults.colors, + fillOpacity = defaults.fillOpacity, + borderColor = defaults.borderColor, + borderOpacity = defaults.borderOpacity, + labelColor = defaults.labelColor, + enableBeforeSeparators = defaults.enableBeforeSeparators, + beforeSeparatorLength = defaults.beforeSeparatorLength, + beforeSeparatorOffset = defaults.beforeSeparatorOffset, + enableAfterSeparators = defaults.enableAfterSeparators, + afterSeparatorLength = defaults.afterSeparatorLength, + afterSeparatorOffset = defaults.afterSeparatorOffset, + isInteractive = defaults.isInteractive, + currentPartSizeExtension = defaults.currentPartSizeExtension, + currentBorderWidth, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, +}: { + data: FunnelDataProps['data'] + width: number + height: number + interpolation?: FunnelCommonProps['interpolation'] + fixedShape?: FunnelCommonProps['fixedShape'] + spacing?: FunnelCommonProps['spacing'] + valueFormat?: FunnelCommonProps['valueFormat'] + colors?: FunnelCommonProps['colors'] + fillOpacity?: FunnelCommonProps['fillOpacity'] + borderColor?: FunnelCommonProps['borderColor'] + borderOpacity?: FunnelCommonProps['borderOpacity'] + labelColor?: FunnelCommonProps['labelColor'] + enableBeforeSeparators?: FunnelCommonProps['enableBeforeSeparators'] + beforeSeparatorLength?: FunnelCommonProps['beforeSeparatorLength'] + beforeSeparatorOffset?: FunnelCommonProps['beforeSeparatorOffset'] + enableAfterSeparators?: FunnelCommonProps['enableAfterSeparators'] + afterSeparatorLength?: FunnelCommonProps['afterSeparatorLength'] + afterSeparatorOffset?: FunnelCommonProps['afterSeparatorOffset'] + isInteractive?: FunnelCommonProps['isInteractive'] + currentPartSizeExtension?: FunnelCommonProps['currentPartSizeExtension'] + currentBorderWidth?: FunnelCommonProps['currentBorderWidth'] + onMouseEnter?: FunnelCommonProps['onMouseEnter'] + onMouseMove?: FunnelCommonProps['onMouseMove'] + onMouseLeave?: FunnelCommonProps['onMouseLeave'] + onClick?: FunnelCommonProps['onClick'] + tooltip?: (props: PartTooltipProps) => JSX.Element +}) => { + const theme = useTheme() + const getColor = useOrdinalColorScale(colors, 'id') + const getBorderColor = useInheritedColor(borderColor, theme) + const getLabelColor = useInheritedColor(labelColor, theme) + const direction = 'vertical' + + const formatValue = useValueFormatter(valueFormat) + + const [areaGenerator, borderGenerator] = useMemo( + () => computeShapeGenerators(interpolation, direction), + [interpolation] + ) + + const paddingBefore = enableBeforeSeparators ? beforeSeparatorLength + beforeSeparatorOffset : 0 + const paddingAfter = enableAfterSeparators ? afterSeparatorLength + afterSeparatorOffset : 0 + const innerWidth = width - paddingBefore - paddingAfter + const innerHeight = height + + const [currentPartId, setCurrentPartId] = useState(null) + + const parts: FunnelPart[] = useMemo(() => { + let currentHeight = 0 + const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) + const slope = -(innerHeight * 0.67) / (innerWidth * 0.33) + + const enhancedParts = data.map(datum => { + const isCurrent = datum.id === currentPartId + + const partHeight = (datum.value / totalValue) * innerHeight + const y0 = currentHeight + const y1 = y0 + partHeight + const inBottomThird = y0 > innerHeight * 0.67 + const x0 = inBottomThird ? innerWidth * 0.33 : -currentHeight / slope + const x1 = inBottomThird ? innerWidth * 0.67 : innerWidth - x0 + const partWidth = x1 - x0 + + const x = innerWidth * 0.5 + const y = y0 + partHeight * 0.5 + currentHeight = y1 + + const part: FunnelPart = { + data: datum, + width: partWidth, + height: partHeight, + color: getColor(datum), + fillOpacity, + borderWidth: isCurrent && currentBorderWidth !== undefined ? currentBorderWidth : 0, + borderOpacity, + formattedValue: formatValue(datum.value), + isCurrent, + x, + x0, + x1, + y, + y0, + y1, + borderColor: '', + labelColor: '', + points: [], + areaPoints: [], + borderPoints: [], + } + + part.borderColor = getBorderColor(part) + part.labelColor = getLabelColor(part) + + return part + }) + + enhancedParts.forEach((part, index) => { + const nextPart = enhancedParts[index + 1] + + part.points.push({ x: part.x0, y: part.y0 }) + part.points.push({ x: part.x1, y: part.y0 }) + if (nextPart) { + part.points.push({ x: nextPart.x1, y: part.y1 }) + part.points.push({ x: nextPart.x0, y: part.y1 }) + } else { + part.points.push({ x: innerWidth * 0.67, y: part.y1 }) + part.points.push({ x: innerWidth * 0.33, y: part.y1 }) + } + if (part.y0 < innerHeight * 0.67 && part.y1 > innerHeight * 0.67) { + part.points = [ + part.points[0], + part.points[1], + { x: innerWidth * 0.67, y: innerHeight * 0.67 }, + part.points[2], + part.points[3], + { x: innerWidth * 0.33, y: innerHeight * 0.67 }, + ] + } + if (part.isCurrent && part.points.length === 6) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x += currentPartSizeExtension + part.points[4].x -= currentPartSizeExtension + part.points[5].x -= currentPartSizeExtension + } else if (part.isCurrent && part.points.length === 4) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x -= currentPartSizeExtension + } + + part.areaPoints = [ + { + x: 0, + x0: part.points[0].x, + x1: part.points[1].x, + y: part.y0, + y0: 0, + y1: 0, + }, + ] + if (part.y0 < innerHeight * 0.67 && part.y1 > innerHeight * 0.67) { + part.areaPoints.push({ + x: 0, + x0: part.points[5].x, + x1: part.points[2].x, + y: innerHeight * 0.67, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[4].x, + x1: part.points[3].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } else { + part.areaPoints.push({ + x: 0, + x0: (part.points[0].x + part.points[3].x) / 2, + x1: (part.points[1].x + part.points[2].x) / 2, + y: (part.y0 + part.y1) / 2, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[3].x, + x1: part.points[2].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } + ;[0, 1, 2].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x0, + y: part.areaPoints[index].y, + }) + }) + part.borderPoints.push(null) + ;[2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x1, + y: part.areaPoints[index].y, + }) + }) + }) + + return enhancedParts + }, [ + data, + innerWidth, + innerHeight, + paddingBefore, + paddingAfter, + getColor, + formatValue, + getBorderColor, + getLabelColor, + currentPartId, + ]) + + const { showTooltipFromEvent, hideTooltip } = useTooltip() + const partsWithHandlers = useMemo( + () => + computePartsHandlers({ + parts, + setCurrentPartId, + isInteractive, + onMouseEnter, + onMouseLeave, + onMouseMove, + onClick, + showTooltipFromEvent, + hideTooltip, + tooltip, + }), + [ + parts, + setCurrentPartId, + isInteractive, + onMouseEnter, + onMouseLeave, + onMouseMove, + onClick, + showTooltipFromEvent, + hideTooltip, + tooltip, + ] + ) + + const [beforeSeparators, afterSeparators] = useMemo( + () => + computeSeparators({ + parts, + direction, + width, + height, + spacing, + enableBeforeSeparators, + beforeSeparatorOffset, + enableAfterSeparators, + afterSeparatorOffset, + }), + [ + parts, + width, + height, + spacing, + enableBeforeSeparators, + beforeSeparatorOffset, + enableAfterSeparators, + afterSeparatorOffset, + ] + ) + + const customLayerProps: FunnelCustomLayerProps = useMemo( + () => ({ + width, + height, + parts: partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + }), + [ + width, + height, + partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + ] + ) + + return { + parts: partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + currentPartId, + customLayerProps, + } +} + export const useFunnelAnnotations = ( parts: FunnelPart[], annotations: FunnelCommonProps['annotations'] diff --git a/packages/funnel/src/props.tsx b/packages/funnel/src/props.tsx index ed8b056365..d2fe63778b 100644 --- a/packages/funnel/src/props.tsx +++ b/packages/funnel/src/props.tsx @@ -7,6 +7,7 @@ export const svgDefaultProps = { direction: 'vertical' as const, interpolation: 'smooth' as const, + fixedShape: false, spacing: 0, shapeBlending: 0.66, diff --git a/packages/funnel/src/types.ts b/packages/funnel/src/types.ts index 1c4b35caba..e8fde556c6 100644 --- a/packages/funnel/src/types.ts +++ b/packages/funnel/src/types.ts @@ -90,6 +90,7 @@ export interface FunnelCommonProps { direction: FunnelDirection interpolation: 'smooth' | 'linear' + fixedShape: boolean spacing: number shapeBlending: number diff --git a/packages/funnel/tests/Funnel.test.tsx b/packages/funnel/tests/Funnel.test.tsx index 060a70c99e..dade85c399 100644 --- a/packages/funnel/tests/Funnel.test.tsx +++ b/packages/funnel/tests/Funnel.test.tsx @@ -116,6 +116,39 @@ describe('layout', () => { expect(part2.prop('part').y1).toBe(600) expect(part2.prop('part').height).toBe(198) }) + + it('shaped layout', () => { + const wrapper = mount() + + const parts = wrapper.find('Part') + const slope = (baseProps.height * 0.67) / (baseProps.width * 0.33) + + const part0 = parts.at(0) + expect(part0.prop('part').x0).toBe(0) + expect(part0.prop('part').x1).toBe(300) + expect(part0.prop('part').width).toBe(300) + expect(part0.prop('part').y0).toBe(0) + expect(part0.prop('part').y1).toBe(300) + expect(part0.prop('part').height).toBe(300) + + const part1 = parts.at(1) + expect(part1.prop('part').x0).toBe(300 / slope) + expect(part1.prop('part').x1).toBe(baseProps.width - 300 / slope) + expect(part1.prop('part').width).toBe(baseProps.width - 2 * (300 / slope)) + expect(part1.prop('part').y0).toBe(300) + expect(part1.prop('part').y1).toBe(500) + expect(part1.prop('part').height).toBe(200) + + const part2 = parts.at(2) + expect(part2.prop('part').x0).toBe(baseProps.width * 0.33) + expect(part2.prop('part').x1).toBe(baseProps.width * 0.67) + expect(part2.prop('part').width).toBe( + baseProps.width * 0.67 - baseProps.width * 0.33 + ) + expect(part2.prop('part').y0).toBe(500) + expect(part2.prop('part').y1).toBe(600) + expect(part2.prop('part').height).toBe(100) + }) }) describe('data', () => { diff --git a/storybook/stories/funnel/Funnel.stories.tsx b/storybook/stories/funnel/Funnel.stories.tsx index ef79d96638..49511c329b 100644 --- a/storybook/stories/funnel/Funnel.stories.tsx +++ b/storybook/stories/funnel/Funnel.stories.tsx @@ -73,3 +73,7 @@ export const CustomTooltip: Story = { export const CombiningWithOtherCharts: Story = { render: () => , } + +export const Shaped: Story = { + render: () => , +} From 8d9a12f451c950e9649de760f85937bfec51f676 Mon Sep 17 00:00:00 2001 From: bdknox Date: Sun, 7 May 2023 12:32:10 -0400 Subject: [PATCH 2/7] Make the neck width and height ratio customizable --- packages/funnel/src/Funnel.tsx | 150 +---- packages/funnel/src/hooks.ts | 826 ++++++++++++-------------- packages/funnel/src/props.tsx | 2 + packages/funnel/src/types.ts | 2 + packages/funnel/tests/Funnel.test.tsx | 20 +- 5 files changed, 399 insertions(+), 601 deletions(-) diff --git a/packages/funnel/src/Funnel.tsx b/packages/funnel/src/Funnel.tsx index 87447120dd..55e7db68b2 100644 --- a/packages/funnel/src/Funnel.tsx +++ b/packages/funnel/src/Funnel.tsx @@ -1,7 +1,7 @@ import { createElement, Fragment, ReactNode } from 'react' import { SvgWrapper, Container, useDimensions } from '@nivo/core' import { svgDefaultProps } from './props' -import { useFunnel, useShapedFunnel } from './hooks' +import { useFunnel } from './hooks' import { Parts } from './Parts' import { PartLabels } from './PartLabels' import { Separators } from './Separators' @@ -19,6 +19,9 @@ const InnerFunnel = ({ height, margin: partialMargin, direction = svgDefaultProps.direction, + fixedShape = svgDefaultProps.fixedShape, + neckHeightRatio = svgDefaultProps.neckHeightRatio, + neckWidthRatio = svgDefaultProps.neckWidthRatio, interpolation = svgDefaultProps.interpolation, spacing = svgDefaultProps.spacing, shapeBlending = svgDefaultProps.shapeBlending, @@ -69,6 +72,9 @@ const InnerFunnel = ({ width: innerWidth, height: innerHeight, direction, + fixedShape, + neckHeightRatio, + neckWidthRatio, interpolation, spacing, shapeBlending, @@ -154,148 +160,12 @@ const InnerFunnel = ({ ) } -const InnerShapedFunnel = ({ - data, - width, - height, - margin: partialMargin, - interpolation = svgDefaultProps.interpolation, - spacing = svgDefaultProps.spacing, - valueFormat, - colors = svgDefaultProps.colors, - fillOpacity = svgDefaultProps.fillOpacity, - borderColor = svgDefaultProps.borderColor, - borderOpacity = svgDefaultProps.borderOpacity, - enableLabel = svgDefaultProps.enableLabel, - labelColor = svgDefaultProps.labelColor, - enableBeforeSeparators = svgDefaultProps.enableBeforeSeparators, - beforeSeparatorLength = svgDefaultProps.beforeSeparatorLength, - beforeSeparatorOffset = svgDefaultProps.beforeSeparatorOffset, - enableAfterSeparators = svgDefaultProps.enableAfterSeparators, - afterSeparatorLength = svgDefaultProps.afterSeparatorLength, - afterSeparatorOffset = svgDefaultProps.afterSeparatorOffset, - layers = svgDefaultProps.layers, - annotations = svgDefaultProps.annotations, - isInteractive = svgDefaultProps.isInteractive, - currentPartSizeExtension = svgDefaultProps.currentPartSizeExtension, - currentBorderWidth, - onMouseEnter, - onMouseMove, - onMouseLeave, - onClick, - tooltip, - role = svgDefaultProps.role, - ariaLabel, - ariaLabelledBy, - ariaDescribedBy, -}: InnerFunnelProps) => { - const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( - width, - height, - partialMargin - ) - - const { - areaGenerator, - borderGenerator, - parts, - beforeSeparators, - afterSeparators, - customLayerProps, - } = useShapedFunnel({ - data, - width: innerWidth, - height: innerHeight, - interpolation, - spacing, - valueFormat, - colors, - fillOpacity, - borderColor, - borderOpacity, - labelColor, - enableBeforeSeparators, - beforeSeparatorLength, - beforeSeparatorOffset, - enableAfterSeparators, - afterSeparatorLength, - afterSeparatorOffset, - isInteractive, - currentPartSizeExtension, - currentBorderWidth, - onMouseEnter, - onMouseMove, - onMouseLeave, - onClick, - tooltip, - }) - - const layerById: Record = { - separators: null, - parts: null, - annotations: null, - labels: null, - } - - if (layers.includes('separators')) { - layerById.separators = ( - - ) - } - - if (layers.includes('parts')) { - layerById.parts = ( - - key="parts" - parts={parts} - areaGenerator={areaGenerator} - borderGenerator={borderGenerator} - /> - ) - } - - if (layers?.includes('annotations')) { - layerById.annotations = ( - key="annotations" parts={parts} annotations={annotations} /> - ) - } - - if (layers.includes('labels') && enableLabel) { - layerById.labels = key="labels" parts={parts} /> - } - - return ( - - {layers.map((layer, i) => { - if (typeof layer === 'function') { - return {createElement(layer, customLayerProps)} - } - - return layerById?.[layer] ?? null - })} - - ) -} - export const Funnel = ({ isInteractive = svgDefaultProps.isInteractive, animate = svgDefaultProps.animate, motionConfig = svgDefaultProps.motionConfig, theme, renderWrapper, - fixedShape = svgDefaultProps.fixedShape, ...otherProps }: FunnelSvgProps) => ( ({ theme, }} > - {fixedShape ? ( - isInteractive={isInteractive} {...otherProps} /> - ) : ( - isInteractive={isInteractive} {...otherProps} /> - )} + isInteractive={isInteractive} {...otherProps} /> ) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index 04b5c98880..d2e88d5e36 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -1,7 +1,12 @@ import { createElement, useMemo, useState, MouseEvent } from 'react' import { line, area, curveBasis, curveLinear } from 'd3-shape' import { ScaleLinear, scaleLinear } from 'd3-scale' -import { useInheritedColor, useOrdinalColorScale } from '@nivo/colors' +import { + InheritedColorConfigCustomFunction, + OrdinalColorScale, + useInheritedColor, + useOrdinalColorScale, +} from '@nivo/colors' import { useTheme, useValueFormatter } from '@nivo/core' import { useAnnotations } from '@nivo/annotations' import { useTooltip, TooltipActionsContextData } from '@nivo/tooltip' @@ -18,6 +23,7 @@ import { FunnelAreaPoint, FunnelBorderGenerator, Position, + FunnelDirection, } from './types' export const computeShapeGenerators = ( @@ -275,6 +281,9 @@ export const useFunnel = ({ width, height, direction = defaults.direction, + fixedShape = defaults.fixedShape, + neckWidthRatio = defaults.neckWidthRatio, + neckHeightRatio = defaults.neckHeightRatio, interpolation = defaults.interpolation, spacing = defaults.spacing, shapeBlending: rawShapeBlending = defaults.shapeBlending, @@ -304,6 +313,9 @@ export const useFunnel = ({ width: number height: number direction?: FunnelCommonProps['direction'] + fixedShape?: FunnelCommonProps['fixedShape'] + neckWidthRatio?: FunnelCommonProps['neckWidthRatio'] + neckHeightRatio?: FunnelCommonProps['neckHeightRatio'] interpolation?: FunnelCommonProps['interpolation'] spacing?: FunnelCommonProps['spacing'] shapeBlending?: FunnelCommonProps['shapeBlending'] @@ -368,185 +380,45 @@ export const useFunnel = ({ const [currentPartId, setCurrentPartId] = useState(null) const parts: FunnelPart[] = useMemo(() => { - const enhancedParts = data.map((datum, index) => { - const isCurrent = datum.id === currentPartId - - let partWidth - let partHeight - let y0, x0 - - if (direction === 'vertical') { - partWidth = linearScale(datum.value) - partHeight = bandScale.bandwidth - x0 = paddingBefore + (innerWidth - partWidth) * 0.5 - y0 = bandScale(index) - } else { - partWidth = bandScale.bandwidth - partHeight = linearScale(datum.value) - x0 = bandScale(index) - y0 = paddingBefore + (innerHeight - partHeight) * 0.5 - } - - const x1 = x0 + partWidth - const x = x0 + partWidth * 0.5 - const y1 = y0 + partHeight - const y = y0 + partHeight * 0.5 - - const part: FunnelPart = { - data: datum, - width: partWidth, - height: partHeight, - color: getColor(datum), + if (fixedShape) { + return computeShapedParts( + data, + innerWidth, + innerHeight, + neckHeightRatio, + neckWidthRatio, fillOpacity, - borderWidth: - isCurrent && currentBorderWidth !== undefined - ? currentBorderWidth - : borderWidth, borderOpacity, - formattedValue: formatValue(datum.value), - isCurrent, - x, - x0, - x1, - y, - y0, - y1, - borderColor: '', - labelColor: '', - points: [], - areaPoints: [], - borderPoints: [], - } - - part.borderColor = getBorderColor(part) - part.labelColor = getLabelColor(part) - - return part - }) - - const shapeBlending = rawShapeBlending / 2 - - enhancedParts.forEach((part, index) => { - const nextPart = enhancedParts[index + 1] - - if (direction === 'vertical') { - part.points.push({ x: part.x0, y: part.y0 }) - part.points.push({ x: part.x1, y: part.y0 }) - if (nextPart) { - part.points.push({ x: nextPart.x1, y: part.y1 }) - part.points.push({ x: nextPart.x0, y: part.y1 }) - } else { - part.points.push({ x: part.points[1].x, y: part.y1 }) - part.points.push({ x: part.points[0].x, y: part.y1 }) - } - if (part.isCurrent) { - part.points[0].x -= currentPartSizeExtension - part.points[1].x += currentPartSizeExtension - part.points[2].x += currentPartSizeExtension - part.points[3].x -= currentPartSizeExtension - } - - part.areaPoints = [ - { - x: 0, - x0: part.points[0].x, - x1: part.points[1].x, - y: part.y0, - y0: 0, - y1: 0, - }, - ] - part.areaPoints.push({ - ...part.areaPoints[0], - y: part.y0 + part.height * shapeBlending, - }) - const lastAreaPoint = { - x: 0, - x0: part.points[3].x, - x1: part.points[2].x, - y: part.y1, - y0: 0, - y1: 0, - } - part.areaPoints.push({ - ...lastAreaPoint, - y: part.y1 - part.height * shapeBlending, - }) - part.areaPoints.push(lastAreaPoint) - ;[0, 1, 2, 3].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x0, - y: part.areaPoints[index].y, - }) - }) - part.borderPoints.push(null) - ;[3, 2, 1, 0].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x1, - y: part.areaPoints[index].y, - }) - }) - } else { - part.points.push({ x: part.x0, y: part.y0 }) - if (nextPart) { - part.points.push({ x: part.x1, y: nextPart.y0 }) - part.points.push({ x: part.x1, y: nextPart.y1 }) - } else { - part.points.push({ x: part.x1, y: part.y0 }) - part.points.push({ x: part.x1, y: part.y1 }) - } - part.points.push({ x: part.x0, y: part.y1 }) - if (part.isCurrent) { - part.points[0].y -= currentPartSizeExtension - part.points[1].y -= currentPartSizeExtension - part.points[2].y += currentPartSizeExtension - part.points[3].y += currentPartSizeExtension - } - - part.areaPoints = [ - { - x: part.x0, - x0: 0, - x1: 0, - y: 0, - y0: part.points[0].y, - y1: part.points[3].y, - }, - ] - part.areaPoints.push({ - ...part.areaPoints[0], - x: part.x0 + part.width * shapeBlending, - }) - const lastAreaPoint = { - x: part.x1, - x0: 0, - x1: 0, - y: 0, - y0: part.points[1].y, - y1: part.points[2].y, - } - part.areaPoints.push({ - ...lastAreaPoint, - x: part.x1 - part.width * shapeBlending, - }) - part.areaPoints.push(lastAreaPoint) - ;[0, 1, 2, 3].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x, - y: part.areaPoints[index].y0, - }) - }) - part.borderPoints.push(null) - ;[3, 2, 1, 0].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x, - y: part.areaPoints[index].y1, - }) - }) - } - }) - - return enhancedParts + currentBorderWidth, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } else { + return computeParts( + data, + direction, + innerWidth, + innerHeight, + paddingBefore, + linearScale, + bandScale, + fillOpacity, + borderOpacity, + borderWidth, + currentBorderWidth, + rawShapeBlending, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } }, [ data, direction, @@ -654,159 +526,100 @@ export const useFunnel = ({ } } -export const useShapedFunnel = ({ - data, - width, - height, - interpolation = defaults.interpolation, - spacing = defaults.spacing, - valueFormat, - colors = defaults.colors, - fillOpacity = defaults.fillOpacity, - borderColor = defaults.borderColor, - borderOpacity = defaults.borderOpacity, - labelColor = defaults.labelColor, - enableBeforeSeparators = defaults.enableBeforeSeparators, - beforeSeparatorLength = defaults.beforeSeparatorLength, - beforeSeparatorOffset = defaults.beforeSeparatorOffset, - enableAfterSeparators = defaults.enableAfterSeparators, - afterSeparatorLength = defaults.afterSeparatorLength, - afterSeparatorOffset = defaults.afterSeparatorOffset, - isInteractive = defaults.isInteractive, - currentPartSizeExtension = defaults.currentPartSizeExtension, - currentBorderWidth, - onMouseEnter, - onMouseMove, - onMouseLeave, - onClick, - tooltip, -}: { - data: FunnelDataProps['data'] - width: number - height: number - interpolation?: FunnelCommonProps['interpolation'] - fixedShape?: FunnelCommonProps['fixedShape'] - spacing?: FunnelCommonProps['spacing'] - valueFormat?: FunnelCommonProps['valueFormat'] - colors?: FunnelCommonProps['colors'] - fillOpacity?: FunnelCommonProps['fillOpacity'] - borderColor?: FunnelCommonProps['borderColor'] - borderOpacity?: FunnelCommonProps['borderOpacity'] - labelColor?: FunnelCommonProps['labelColor'] - enableBeforeSeparators?: FunnelCommonProps['enableBeforeSeparators'] - beforeSeparatorLength?: FunnelCommonProps['beforeSeparatorLength'] - beforeSeparatorOffset?: FunnelCommonProps['beforeSeparatorOffset'] - enableAfterSeparators?: FunnelCommonProps['enableAfterSeparators'] - afterSeparatorLength?: FunnelCommonProps['afterSeparatorLength'] - afterSeparatorOffset?: FunnelCommonProps['afterSeparatorOffset'] - isInteractive?: FunnelCommonProps['isInteractive'] - currentPartSizeExtension?: FunnelCommonProps['currentPartSizeExtension'] - currentBorderWidth?: FunnelCommonProps['currentBorderWidth'] - onMouseEnter?: FunnelCommonProps['onMouseEnter'] - onMouseMove?: FunnelCommonProps['onMouseMove'] - onMouseLeave?: FunnelCommonProps['onMouseLeave'] - onClick?: FunnelCommonProps['onClick'] - tooltip?: (props: PartTooltipProps) => JSX.Element -}) => { - const theme = useTheme() - const getColor = useOrdinalColorScale(colors, 'id') - const getBorderColor = useInheritedColor(borderColor, theme) - const getLabelColor = useInheritedColor(labelColor, theme) - const direction = 'vertical' - - const formatValue = useValueFormatter(valueFormat) - - const [areaGenerator, borderGenerator] = useMemo( - () => computeShapeGenerators(interpolation, direction), - [interpolation] - ) +function computeParts( + data: FunnelDataProps['data'], + direction: FunnelDirection, + innerWidth: number, + innerHeight: number, + paddingBefore: number, + linearScale: ScaleLinear, + bandScale: CustomBandScale, + fillOpacity: FunnelCommonProps['fillOpacity'], + borderOpacity: FunnelCommonProps['borderOpacity'], + borderWidth: FunnelCommonProps['borderWidth'], + currentBorderWidth: FunnelCommonProps['currentBorderWidth'] | undefined, + rawShapeBlending: FunnelCommonProps['shapeBlending'], + currentPartId: string | number | null, + currentPartSizeExtension: number, + getColor: OrdinalColorScale, + getBorderColor: + | InheritedColorConfigCustomFunction> + | ((d: FunnelPart) => string), + getLabelColor: + | InheritedColorConfigCustomFunction> + | ((d: FunnelPart) => string), + formatValue: (value: number) => string +) { + const enhancedParts = data.map((datum, index) => { + const isCurrent = datum.id === currentPartId + + let partWidth + let partHeight + let y0, x0 + + if (direction === 'vertical') { + partWidth = linearScale(datum.value) + partHeight = bandScale.bandwidth + x0 = paddingBefore + (innerWidth - partWidth) * 0.5 + y0 = bandScale(index) + } else { + partWidth = bandScale.bandwidth + partHeight = linearScale(datum.value) + x0 = bandScale(index) + y0 = paddingBefore + (innerHeight - partHeight) * 0.5 + } - const paddingBefore = enableBeforeSeparators ? beforeSeparatorLength + beforeSeparatorOffset : 0 - const paddingAfter = enableAfterSeparators ? afterSeparatorLength + afterSeparatorOffset : 0 - const innerWidth = width - paddingBefore - paddingAfter - const innerHeight = height + const x1 = x0 + partWidth + const x = x0 + partWidth * 0.5 + const y1 = y0 + partHeight + const y = y0 + partHeight * 0.5 + + const part: FunnelPart = { + data: datum, + width: partWidth, + height: partHeight, + color: getColor(datum), + fillOpacity, + borderWidth: + isCurrent && currentBorderWidth !== undefined ? currentBorderWidth : borderWidth, + borderOpacity, + formattedValue: formatValue(datum.value), + isCurrent, + x, + x0, + x1, + y, + y0, + y1, + borderColor: '', + labelColor: '', + points: [], + areaPoints: [], + borderPoints: [], + } - const [currentPartId, setCurrentPartId] = useState(null) + part.borderColor = getBorderColor(part) + part.labelColor = getLabelColor(part) - const parts: FunnelPart[] = useMemo(() => { - let currentHeight = 0 - const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) - const slope = -(innerHeight * 0.67) / (innerWidth * 0.33) - - const enhancedParts = data.map(datum => { - const isCurrent = datum.id === currentPartId - - const partHeight = (datum.value / totalValue) * innerHeight - const y0 = currentHeight - const y1 = y0 + partHeight - const inBottomThird = y0 > innerHeight * 0.67 - const x0 = inBottomThird ? innerWidth * 0.33 : -currentHeight / slope - const x1 = inBottomThird ? innerWidth * 0.67 : innerWidth - x0 - const partWidth = x1 - x0 - - const x = innerWidth * 0.5 - const y = y0 + partHeight * 0.5 - currentHeight = y1 - - const part: FunnelPart = { - data: datum, - width: partWidth, - height: partHeight, - color: getColor(datum), - fillOpacity, - borderWidth: isCurrent && currentBorderWidth !== undefined ? currentBorderWidth : 0, - borderOpacity, - formattedValue: formatValue(datum.value), - isCurrent, - x, - x0, - x1, - y, - y0, - y1, - borderColor: '', - labelColor: '', - points: [], - areaPoints: [], - borderPoints: [], - } + return part + }) - part.borderColor = getBorderColor(part) - part.labelColor = getLabelColor(part) + const shapeBlending = rawShapeBlending / 2 - return part - }) - - enhancedParts.forEach((part, index) => { - const nextPart = enhancedParts[index + 1] + enhancedParts.forEach((part, index) => { + const nextPart = enhancedParts[index + 1] + if (direction === 'vertical') { part.points.push({ x: part.x0, y: part.y0 }) part.points.push({ x: part.x1, y: part.y0 }) if (nextPart) { part.points.push({ x: nextPart.x1, y: part.y1 }) part.points.push({ x: nextPart.x0, y: part.y1 }) } else { - part.points.push({ x: innerWidth * 0.67, y: part.y1 }) - part.points.push({ x: innerWidth * 0.33, y: part.y1 }) - } - if (part.y0 < innerHeight * 0.67 && part.y1 > innerHeight * 0.67) { - part.points = [ - part.points[0], - part.points[1], - { x: innerWidth * 0.67, y: innerHeight * 0.67 }, - part.points[2], - part.points[3], - { x: innerWidth * 0.33, y: innerHeight * 0.67 }, - ] + part.points.push({ x: part.points[1].x, y: part.y1 }) + part.points.push({ x: part.points[0].x, y: part.y1 }) } - if (part.isCurrent && part.points.length === 6) { - part.points[0].x -= currentPartSizeExtension - part.points[1].x += currentPartSizeExtension - part.points[2].x += currentPartSizeExtension - part.points[3].x += currentPartSizeExtension - part.points[4].x -= currentPartSizeExtension - part.points[5].x -= currentPartSizeExtension - } else if (part.isCurrent && part.points.length === 4) { + if (part.isCurrent) { part.points[0].x -= currentPartSizeExtension part.points[1].x += currentPartSizeExtension part.points[2].x += currentPartSizeExtension @@ -823,157 +636,268 @@ export const useShapedFunnel = ({ y1: 0, }, ] - if (part.y0 < innerHeight * 0.67 && part.y1 > innerHeight * 0.67) { - part.areaPoints.push({ - x: 0, - x0: part.points[5].x, - x1: part.points[2].x, - y: innerHeight * 0.67, - y0: 0, - y1: 0, - }) - part.areaPoints.push({ - x: 0, - x0: part.points[4].x, - x1: part.points[3].x, - y: part.y1, - y0: 0, - y1: 0, - }) - } else { - part.areaPoints.push({ - x: 0, - x0: (part.points[0].x + part.points[3].x) / 2, - x1: (part.points[1].x + part.points[2].x) / 2, - y: (part.y0 + part.y1) / 2, - y0: 0, - y1: 0, - }) - part.areaPoints.push({ - x: 0, - x0: part.points[3].x, - x1: part.points[2].x, - y: part.y1, - y0: 0, - y1: 0, - }) + part.areaPoints.push({ + ...part.areaPoints[0], + y: part.y0 + part.height * shapeBlending, + }) + const lastAreaPoint = { + x: 0, + x0: part.points[3].x, + x1: part.points[2].x, + y: part.y1, + y0: 0, + y1: 0, } - ;[0, 1, 2].map(index => { + part.areaPoints.push({ + ...lastAreaPoint, + y: part.y1 - part.height * shapeBlending, + }) + part.areaPoints.push(lastAreaPoint) + ;[0, 1, 2, 3].map(index => { part.borderPoints.push({ x: part.areaPoints[index].x0, y: part.areaPoints[index].y, }) }) part.borderPoints.push(null) - ;[2, 1, 0].map(index => { + ;[3, 2, 1, 0].map(index => { part.borderPoints.push({ x: part.areaPoints[index].x1, y: part.areaPoints[index].y, }) }) - }) + } else { + part.points.push({ x: part.x0, y: part.y0 }) + if (nextPart) { + part.points.push({ x: part.x1, y: nextPart.y0 }) + part.points.push({ x: part.x1, y: nextPart.y1 }) + } else { + part.points.push({ x: part.x1, y: part.y0 }) + part.points.push({ x: part.x1, y: part.y1 }) + } + part.points.push({ x: part.x0, y: part.y1 }) + if (part.isCurrent) { + part.points[0].y -= currentPartSizeExtension + part.points[1].y -= currentPartSizeExtension + part.points[2].y += currentPartSizeExtension + part.points[3].y += currentPartSizeExtension + } - return enhancedParts - }, [ - data, - innerWidth, - innerHeight, - paddingBefore, - paddingAfter, - getColor, - formatValue, - getBorderColor, - getLabelColor, - currentPartId, - ]) + part.areaPoints = [ + { + x: part.x0, + x0: 0, + x1: 0, + y: 0, + y0: part.points[0].y, + y1: part.points[3].y, + }, + ] + part.areaPoints.push({ + ...part.areaPoints[0], + x: part.x0 + part.width * shapeBlending, + }) + const lastAreaPoint = { + x: part.x1, + x0: 0, + x1: 0, + y: 0, + y0: part.points[1].y, + y1: part.points[2].y, + } + part.areaPoints.push({ + ...lastAreaPoint, + x: part.x1 - part.width * shapeBlending, + }) + part.areaPoints.push(lastAreaPoint) + ;[0, 1, 2, 3].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x, + y: part.areaPoints[index].y0, + }) + }) + part.borderPoints.push(null) + ;[3, 2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x, + y: part.areaPoints[index].y1, + }) + }) + } + }) - const { showTooltipFromEvent, hideTooltip } = useTooltip() - const partsWithHandlers = useMemo( - () => - computePartsHandlers({ - parts, - setCurrentPartId, - isInteractive, - onMouseEnter, - onMouseLeave, - onMouseMove, - onClick, - showTooltipFromEvent, - hideTooltip, - tooltip, - }), - [ - parts, - setCurrentPartId, - isInteractive, - onMouseEnter, - onMouseLeave, - onMouseMove, - onClick, - showTooltipFromEvent, - hideTooltip, - tooltip, - ] - ) + return enhancedParts +} - const [beforeSeparators, afterSeparators] = useMemo( - () => - computeSeparators({ - parts, - direction, - width, - height, - spacing, - enableBeforeSeparators, - beforeSeparatorOffset, - enableAfterSeparators, - afterSeparatorOffset, - }), - [ - parts, - width, - height, - spacing, - enableBeforeSeparators, - beforeSeparatorOffset, - enableAfterSeparators, - afterSeparatorOffset, - ] - ) +function computeShapedParts( + data: FunnelDataProps['data'], + innerWidth: number, + innerHeight: number, + neckHeightRatio: number, + neckWidthRatio: number, + fillOpacity: FunnelCommonProps['fillOpacity'], + borderOpacity: FunnelCommonProps['borderOpacity'], + currentBorderWidth: FunnelCommonProps['currentBorderWidth'] | undefined, + currentPartId: string | number | null, + currentPartSizeExtension: number, + getColor: OrdinalColorScale, + getBorderColor: + | InheritedColorConfigCustomFunction> + | ((d: FunnelPart) => string), + getLabelColor: + | InheritedColorConfigCustomFunction> + | ((d: FunnelPart) => string), + formatValue: (value: number) => string +) { + let currentHeight = 0 + const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) + const neckHeight = innerHeight * (1 - neckHeightRatio) + const neckSideRatio = (1 - neckWidthRatio) / 2 + const neckWidthLeft = Math.round(innerWidth * neckSideRatio) + const neckWidthRight = Math.round(innerWidth * (1 - neckSideRatio)) + const slope = neckHeight / neckWidthLeft + + const enhancedParts = data.map(datum => { + const isCurrent = datum.id === currentPartId + + const partHeight = (datum.value / totalValue) * innerHeight + const y0 = currentHeight + const y1 = y0 + partHeight + const inBottomThird = y0 > neckHeight + const x0 = inBottomThird ? neckWidthLeft : Math.round(currentHeight / slope) + const x1 = inBottomThird ? neckWidthRight : innerWidth - x0 + const partWidth = x1 - x0 + + const x = innerWidth * 0.5 + const y = y0 + partHeight * 0.5 + currentHeight = y1 + + const part: FunnelPart = { + data: datum, + width: partWidth, + height: partHeight, + color: getColor(datum), + fillOpacity, + borderWidth: isCurrent && currentBorderWidth !== undefined ? currentBorderWidth : 0, + borderOpacity, + formattedValue: formatValue(datum.value), + isCurrent, + x, + x0, + x1, + y, + y0, + y1, + borderColor: '', + labelColor: '', + points: [], + areaPoints: [], + borderPoints: [], + } - const customLayerProps: FunnelCustomLayerProps = useMemo( - () => ({ - width, - height, - parts: partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, - }), - [ - width, - height, - partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, + part.borderColor = getBorderColor(part) + part.labelColor = getLabelColor(part) + + return part + }) + + enhancedParts.forEach((part, index) => { + const nextPart = enhancedParts[index + 1] + + part.points.push({ x: part.x0, y: part.y0 }) + part.points.push({ x: part.x1, y: part.y0 }) + if (nextPart) { + part.points.push({ x: nextPart.x1, y: part.y1 }) + part.points.push({ x: nextPart.x0, y: part.y1 }) + } else { + part.points.push({ x: neckWidthRight, y: part.y1 }) + part.points.push({ x: neckWidthLeft, y: part.y1 }) + } + if (part.y0 < neckHeight && part.y1 > neckHeight) { + part.points = [ + part.points[0], + part.points[1], + { x: neckWidthRight, y: neckHeight }, + part.points[2], + part.points[3], + { x: neckWidthLeft, y: neckHeight }, + ] + } + if (part.isCurrent && part.points.length === 6) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x += currentPartSizeExtension + part.points[4].x -= currentPartSizeExtension + part.points[5].x -= currentPartSizeExtension + } else if (part.isCurrent && part.points.length === 4) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x -= currentPartSizeExtension + } + + part.areaPoints = [ + { + x: 0, + x0: part.points[0].x, + x1: part.points[1].x, + y: part.y0, + y0: 0, + y1: 0, + }, ] - ) + if (part.y0 < neckHeight && part.y1 > neckHeight) { + part.areaPoints.push({ + x: 0, + x0: part.points[5].x, + x1: part.points[2].x, + y: neckHeight, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[4].x, + x1: part.points[3].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } else { + part.areaPoints.push({ + x: 0, + x0: (part.points[0].x + part.points[3].x) / 2, + x1: (part.points[1].x + part.points[2].x) / 2, + y: (part.y0 + part.y1) / 2, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[3].x, + x1: part.points[2].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } + ;[0, 1, 2].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x0, + y: part.areaPoints[index].y, + }) + }) + part.borderPoints.push(null) + ;[2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x1, + y: part.areaPoints[index].y, + }) + }) + }) - return { - parts: partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, - currentPartId, - customLayerProps, - } + return enhancedParts } export const useFunnelAnnotations = ( diff --git a/packages/funnel/src/props.tsx b/packages/funnel/src/props.tsx index d2fe63778b..c4d30e0e0f 100644 --- a/packages/funnel/src/props.tsx +++ b/packages/funnel/src/props.tsx @@ -8,6 +8,8 @@ export const svgDefaultProps = { direction: 'vertical' as const, interpolation: 'smooth' as const, fixedShape: false, + neckHeightRatio: 0.33, + neckWidthRatio: 0.33, spacing: 0, shapeBlending: 0.66, diff --git a/packages/funnel/src/types.ts b/packages/funnel/src/types.ts index e8fde556c6..69dbd467a2 100644 --- a/packages/funnel/src/types.ts +++ b/packages/funnel/src/types.ts @@ -91,6 +91,8 @@ export interface FunnelCommonProps { direction: FunnelDirection interpolation: 'smooth' | 'linear' fixedShape: boolean + neckHeightRatio: number + neckWidthRatio: number spacing: number shapeBlending: number diff --git a/packages/funnel/tests/Funnel.test.tsx b/packages/funnel/tests/Funnel.test.tsx index dade85c399..8abff1ce7f 100644 --- a/packages/funnel/tests/Funnel.test.tsx +++ b/packages/funnel/tests/Funnel.test.tsx @@ -121,7 +121,9 @@ describe('layout', () => { const wrapper = mount() const parts = wrapper.find('Part') - const slope = (baseProps.height * 0.67) / (baseProps.width * 0.33) + const neckSideRatio = (1 - 0.33) / 2 + const neckSideWidth = Math.round(baseProps.width * neckSideRatio) + const slope = (baseProps.height * 0.67) / neckSideWidth const part0 = parts.at(0) expect(part0.prop('part').x0).toBe(0) @@ -132,19 +134,21 @@ describe('layout', () => { expect(part0.prop('part').height).toBe(300) const part1 = parts.at(1) - expect(part1.prop('part').x0).toBe(300 / slope) - expect(part1.prop('part').x1).toBe(baseProps.width - 300 / slope) - expect(part1.prop('part').width).toBe(baseProps.width - 2 * (300 / slope)) + expect(part1.prop('part').x0).toBe(Math.round(300 / slope)) + expect(part1.prop('part').x1).toBe(baseProps.width - Math.round(300 / slope)) + expect(part1.prop('part').width).toBe( + baseProps.width - 2 * Math.round(300 / slope) + ) expect(part1.prop('part').y0).toBe(300) expect(part1.prop('part').y1).toBe(500) expect(part1.prop('part').height).toBe(200) const part2 = parts.at(2) - expect(part2.prop('part').x0).toBe(baseProps.width * 0.33) - expect(part2.prop('part').x1).toBe(baseProps.width * 0.67) - expect(part2.prop('part').width).toBe( - baseProps.width * 0.67 - baseProps.width * 0.33 + expect(part2.prop('part').x0).toBe(Math.round(neckSideWidth)) + expect(part2.prop('part').x1).toBe( + Math.round(baseProps.width * (1 - neckSideRatio)) ) + expect(part2.prop('part').width).toBe(baseProps.width - 2 * neckSideWidth) expect(part2.prop('part').y0).toBe(500) expect(part2.prop('part').y1).toBe(600) expect(part2.prop('part').height).toBe(100) From b26a228e5ec8df4a44e64c3369cd67803f5aea13 Mon Sep 17 00:00:00 2001 From: bdknox Date: Sun, 7 May 2023 17:34:12 -0400 Subject: [PATCH 3/7] Support horizontal direction for fixedShape funnel --- packages/funnel/src/hooks.ts | 330 ++++++++++++++++++++++++----------- 1 file changed, 225 insertions(+), 105 deletions(-) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index d2e88d5e36..4b7dcb8bda 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -383,13 +383,12 @@ export const useFunnel = ({ if (fixedShape) { return computeShapedParts( data, + direction, innerWidth, innerHeight, neckHeightRatio, neckWidthRatio, fillOpacity, - borderOpacity, - currentBorderWidth, currentPartId, currentPartSizeExtension, getColor, @@ -731,13 +730,12 @@ function computeParts( function computeShapedParts( data: FunnelDataProps['data'], + direction: FunnelDirection, innerWidth: number, innerHeight: number, neckHeightRatio: number, neckWidthRatio: number, fillOpacity: FunnelCommonProps['fillOpacity'], - borderOpacity: FunnelCommonProps['borderOpacity'], - currentBorderWidth: FunnelCommonProps['currentBorderWidth'] | undefined, currentPartId: string | number | null, currentPartSizeExtension: number, getColor: OrdinalColorScale, @@ -749,28 +747,50 @@ function computeShapedParts( | ((d: FunnelPart) => string), formatValue: (value: number) => string ) { - let currentHeight = 0 + let currentHeight = 0, + currentWidth = 0 + let slope: number, neckHeight: number, beforeNeckDistance: number, afterNeckDistance: number const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) - const neckHeight = innerHeight * (1 - neckHeightRatio) - const neckSideRatio = (1 - neckWidthRatio) / 2 - const neckWidthLeft = Math.round(innerWidth * neckSideRatio) - const neckWidthRight = Math.round(innerWidth * (1 - neckSideRatio)) - const slope = neckHeight / neckWidthLeft + const neckPaddingRatio = (1 - neckWidthRatio) / 2 + if (direction === 'vertical') { + beforeNeckDistance = Math.round(innerWidth * neckPaddingRatio) + afterNeckDistance = Math.round(innerWidth * (1 - neckPaddingRatio)) + neckHeight = innerHeight * (1 - neckHeightRatio) + slope = neckHeight / beforeNeckDistance + } else { + beforeNeckDistance = Math.round(innerHeight * neckPaddingRatio) + afterNeckDistance = Math.round(innerHeight * (1 - neckPaddingRatio)) + neckHeight = innerWidth * (1 - neckHeightRatio) + slope = neckHeight / beforeNeckDistance + } const enhancedParts = data.map(datum => { const isCurrent = datum.id === currentPartId - const partHeight = (datum.value / totalValue) * innerHeight - const y0 = currentHeight - const y1 = y0 + partHeight - const inBottomThird = y0 > neckHeight - const x0 = inBottomThird ? neckWidthLeft : Math.round(currentHeight / slope) - const x1 = inBottomThird ? neckWidthRight : innerWidth - x0 - const partWidth = x1 - x0 + let partWidth: number, partHeight: number, x0: number, x1: number, y0: number, y1: number - const x = innerWidth * 0.5 + if (direction === 'vertical') { + partHeight = (datum.value / totalValue) * innerHeight + y0 = currentHeight + y1 = y0 + partHeight + const inBottomThird = y0 > neckHeight + x0 = inBottomThird ? beforeNeckDistance : Math.round(currentHeight / slope) + x1 = inBottomThird ? afterNeckDistance : innerWidth - x0 + partWidth = x1 - x0 + currentHeight = y1 + } else { + partWidth = (datum.value / totalValue) * innerWidth + x0 = currentWidth + x1 = x0 + partWidth + const inLastThird = x0 > neckHeight + y0 = inLastThird ? beforeNeckDistance : Math.round(currentWidth / slope) + y1 = inLastThird ? afterNeckDistance : innerHeight - y0 + partHeight = y1 - y0 + currentWidth = x1 + } + + const x = x0 + partWidth * 0.5 const y = y0 + partHeight * 0.5 - currentHeight = y1 const part: FunnelPart = { data: datum, @@ -778,8 +798,8 @@ function computeShapedParts( height: partHeight, color: getColor(datum), fillOpacity, - borderWidth: isCurrent && currentBorderWidth !== undefined ? currentBorderWidth : 0, - borderOpacity, + borderWidth: 0, + borderOpacity: 0, formattedValue: formatValue(datum.value), isCurrent, x, @@ -804,97 +824,197 @@ function computeShapedParts( enhancedParts.forEach((part, index) => { const nextPart = enhancedParts[index + 1] - part.points.push({ x: part.x0, y: part.y0 }) - part.points.push({ x: part.x1, y: part.y0 }) - if (nextPart) { - part.points.push({ x: nextPart.x1, y: part.y1 }) - part.points.push({ x: nextPart.x0, y: part.y1 }) - } else { - part.points.push({ x: neckWidthRight, y: part.y1 }) - part.points.push({ x: neckWidthLeft, y: part.y1 }) - } - if (part.y0 < neckHeight && part.y1 > neckHeight) { - part.points = [ - part.points[0], - part.points[1], - { x: neckWidthRight, y: neckHeight }, - part.points[2], - part.points[3], - { x: neckWidthLeft, y: neckHeight }, - ] - } - if (part.isCurrent && part.points.length === 6) { - part.points[0].x -= currentPartSizeExtension - part.points[1].x += currentPartSizeExtension - part.points[2].x += currentPartSizeExtension - part.points[3].x += currentPartSizeExtension - part.points[4].x -= currentPartSizeExtension - part.points[5].x -= currentPartSizeExtension - } else if (part.isCurrent && part.points.length === 4) { - part.points[0].x -= currentPartSizeExtension - part.points[1].x += currentPartSizeExtension - part.points[2].x += currentPartSizeExtension - part.points[3].x -= currentPartSizeExtension - } + if (direction === 'vertical') { + part.points.push({ x: part.x0, y: part.y0 }) + part.points.push({ x: part.x1, y: part.y0 }) + if (nextPart) { + part.points.push({ x: nextPart.x1, y: part.y1 }) + part.points.push({ x: nextPart.x0, y: part.y1 }) + } else { + part.points.push({ x: afterNeckDistance, y: part.y1 }) + part.points.push({ x: beforeNeckDistance, y: part.y1 }) + } + if (part.y0 < neckHeight && part.y1 > neckHeight) { + part.points = [ + part.points[0], + part.points[1], + { x: afterNeckDistance, y: neckHeight }, + part.points[2], + part.points[3], + { x: beforeNeckDistance, y: neckHeight }, + ] + } + if (part.isCurrent && part.points.length === 6) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x += currentPartSizeExtension + part.points[4].x -= currentPartSizeExtension + part.points[5].x -= currentPartSizeExtension + } else if (part.isCurrent && part.points.length === 4) { + part.points[0].x -= currentPartSizeExtension + part.points[1].x += currentPartSizeExtension + part.points[2].x += currentPartSizeExtension + part.points[3].x -= currentPartSizeExtension + } - part.areaPoints = [ - { - x: 0, - x0: part.points[0].x, - x1: part.points[1].x, - y: part.y0, - y0: 0, - y1: 0, - }, - ] - if (part.y0 < neckHeight && part.y1 > neckHeight) { - part.areaPoints.push({ - x: 0, - x0: part.points[5].x, - x1: part.points[2].x, - y: neckHeight, - y0: 0, - y1: 0, + part.areaPoints = [ + { + x: 0, + x0: part.points[0].x, + x1: part.points[1].x, + y: part.y0, + y0: 0, + y1: 0, + }, + ] + if (part.y0 < neckHeight && part.y1 > neckHeight) { + part.areaPoints.push({ + x: 0, + x0: part.points[5].x, + x1: part.points[2].x, + y: neckHeight, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[4].x, + x1: part.points[3].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } else { + part.areaPoints.push({ + x: 0, + x0: (part.points[0].x + part.points[3].x) / 2, + x1: (part.points[1].x + part.points[2].x) / 2, + y: (part.y0 + part.y1) / 2, + y0: 0, + y1: 0, + }) + part.areaPoints.push({ + x: 0, + x0: part.points[3].x, + x1: part.points[2].x, + y: part.y1, + y0: 0, + y1: 0, + }) + } + ;[0, 1, 2].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x0, + y: part.areaPoints[index].y, + }) }) - part.areaPoints.push({ - x: 0, - x0: part.points[4].x, - x1: part.points[3].x, - y: part.y1, - y0: 0, - y1: 0, + part.borderPoints.push(null) + ;[2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x1, + y: part.areaPoints[index].y, + }) }) } else { - part.areaPoints.push({ - x: 0, - x0: (part.points[0].x + part.points[3].x) / 2, - x1: (part.points[1].x + part.points[2].x) / 2, - y: (part.y0 + part.y1) / 2, - y0: 0, - y1: 0, + part.points.push({ x: part.x0, y: part.y0 }) + if (nextPart) { + part.points.push({ x: part.x1, y: nextPart.y0 }) + part.points.push({ x: part.x1, y: nextPart.y1 }) + } else { + part.points.push({ x: part.x1, y: beforeNeckDistance }) + part.points.push({ x: part.x1, y: afterNeckDistance }) + } + part.points.push({ x: part.x0, y: part.y1 }) + if (part.x0 < neckHeight && part.x1 > neckHeight) { + part.points = [ + part.points[0], + { x: neckHeight, y: beforeNeckDistance }, + part.points[1], + part.points[2], + { x: neckHeight, y: afterNeckDistance }, + part.points[3], + ] + } + if (part.isCurrent && part.points.length === 6) { + part.points[0].y -= currentPartSizeExtension + part.points[1].y -= currentPartSizeExtension + part.points[2].y -= currentPartSizeExtension + part.points[3].y += currentPartSizeExtension + part.points[4].y += currentPartSizeExtension + part.points[5].y += currentPartSizeExtension + } else if (part.isCurrent && part.points.length === 4) { + part.points[0].y -= currentPartSizeExtension + part.points[1].y -= currentPartSizeExtension + part.points[2].y += currentPartSizeExtension + part.points[3].y += currentPartSizeExtension + } + + if (part.x0 < neckHeight && part.x1 > neckHeight) { + part.areaPoints.push({ + x: part.x0, + x0: 0, + x1: 0, + y: 0, + y0: part.points[0].y, + y1: part.points[5].y, + }) + part.areaPoints.push({ + x: neckHeight, + x0: 0, + x1: 0, + y: 0, + y0: part.points[1].y, + y1: part.points[4].y, + }) + part.areaPoints.push({ + x: part.x1, + x0: 0, + x1: 0, + y: 0, + y0: part.points[2].y, + y1: part.points[3].y, + }) + } else { + part.areaPoints.push({ + x: part.x0, + x0: 0, + x1: 0, + y: 0, + y0: part.points[0].y, + y1: part.points[3].y, + }) + part.areaPoints.push({ + x: (part.x0 + part.x1) / 2, + x0: 0, + x1: 0, + y: 0, + y0: (part.points[0].y + part.points[1].y) / 2, + y1: (part.points[2].y + part.points[3].y) / 2, + }) + part.areaPoints.push({ + x: part.x1, + x0: 0, + x1: 0, + y: 0, + y0: part.points[1].y, + y1: part.points[2].y, + }) + } + ;[0, 1, 2].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x, + y: part.areaPoints[index].y0, + }) }) - part.areaPoints.push({ - x: 0, - x0: part.points[3].x, - x1: part.points[2].x, - y: part.y1, - y0: 0, - y1: 0, + part.borderPoints.push(null) + ;[2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x, + y: part.areaPoints[index].y1, + }) }) } - ;[0, 1, 2].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x0, - y: part.areaPoints[index].y, - }) - }) - part.borderPoints.push(null) - ;[2, 1, 0].map(index => { - part.borderPoints.push({ - x: part.areaPoints[index].x1, - y: part.areaPoints[index].y, - }) - }) }) return enhancedParts From 888b66ce7774f2eb3346aa2ff4b781776efc802d Mon Sep 17 00:00:00 2001 From: bdknox Date: Mon, 8 May 2023 23:33:02 -0400 Subject: [PATCH 4/7] Separators work with the fixedShape funnels --- packages/funnel/src/hooks.ts | 41 ++++++++++++++++++--------- packages/funnel/tests/Funnel.test.tsx | 6 ++-- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index 4b7dcb8bda..ad4a92cf06 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -386,6 +386,7 @@ export const useFunnel = ({ direction, innerWidth, innerHeight, + paddingBefore, neckHeightRatio, neckWidthRatio, fillOpacity, @@ -733,6 +734,7 @@ function computeShapedParts( direction: FunnelDirection, innerWidth: number, innerHeight: number, + paddingBefore: number, neckHeightRatio: number, neckWidthRatio: number, fillOpacity: FunnelCommonProps['fillOpacity'], @@ -749,19 +751,24 @@ function computeShapedParts( ) { let currentHeight = 0, currentWidth = 0 - let slope: number, neckHeight: number, beforeNeckDistance: number, afterNeckDistance: number + let slope: number, + neckHeight: number, + beforeNeckDistance: number, + afterNeckDistance: number, + neckWidth: number const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) - const neckPaddingRatio = (1 - neckWidthRatio) / 2 if (direction === 'vertical') { - beforeNeckDistance = Math.round(innerWidth * neckPaddingRatio) - afterNeckDistance = Math.round(innerWidth * (1 - neckPaddingRatio)) + neckWidth = innerWidth * neckWidthRatio + beforeNeckDistance = paddingBefore + (innerWidth - neckWidth) * 0.5 + afterNeckDistance = beforeNeckDistance + neckWidth neckHeight = innerHeight * (1 - neckHeightRatio) - slope = neckHeight / beforeNeckDistance + slope = neckHeight / (beforeNeckDistance - paddingBefore) } else { - beforeNeckDistance = Math.round(innerHeight * neckPaddingRatio) - afterNeckDistance = Math.round(innerHeight * (1 - neckPaddingRatio)) + neckWidth = innerHeight * neckWidthRatio + beforeNeckDistance = paddingBefore + (innerHeight - neckWidth) * 0.5 + afterNeckDistance = beforeNeckDistance + neckWidth neckHeight = innerWidth * (1 - neckHeightRatio) - slope = neckHeight / beforeNeckDistance + slope = neckHeight / (beforeNeckDistance - paddingBefore) } const enhancedParts = data.map(datum => { @@ -774,18 +781,24 @@ function computeShapedParts( y0 = currentHeight y1 = y0 + partHeight const inBottomThird = y0 > neckHeight - x0 = inBottomThird ? beforeNeckDistance : Math.round(currentHeight / slope) - x1 = inBottomThird ? afterNeckDistance : innerWidth - x0 - partWidth = x1 - x0 + + partWidth = inBottomThird + ? neckWidth + : innerWidth - 2 * (Math.round((currentHeight / slope) * 10) / 10) + x0 = paddingBefore + (innerWidth - partWidth) * 0.5 + x1 = x0 + partWidth currentHeight = y1 } else { partWidth = (datum.value / totalValue) * innerWidth x0 = currentWidth x1 = x0 + partWidth const inLastThird = x0 > neckHeight - y0 = inLastThird ? beforeNeckDistance : Math.round(currentWidth / slope) - y1 = inLastThird ? afterNeckDistance : innerHeight - y0 - partHeight = y1 - y0 + + partHeight = inLastThird + ? neckWidth + : innerHeight - 2 * (Math.round((currentWidth / slope) * 10) / 10) + y0 = paddingBefore + (innerHeight - partHeight) * 0.5 + y1 = y0 + partHeight currentWidth = x1 } diff --git a/packages/funnel/tests/Funnel.test.tsx b/packages/funnel/tests/Funnel.test.tsx index 8abff1ce7f..7fc3134b48 100644 --- a/packages/funnel/tests/Funnel.test.tsx +++ b/packages/funnel/tests/Funnel.test.tsx @@ -122,7 +122,7 @@ describe('layout', () => { const parts = wrapper.find('Part') const neckSideRatio = (1 - 0.33) / 2 - const neckSideWidth = Math.round(baseProps.width * neckSideRatio) + const neckSideWidth = Math.round(baseProps.width * neckSideRatio * 10) / 10 const slope = (baseProps.height * 0.67) / neckSideWidth const part0 = parts.at(0) @@ -144,9 +144,9 @@ describe('layout', () => { expect(part1.prop('part').height).toBe(200) const part2 = parts.at(2) - expect(part2.prop('part').x0).toBe(Math.round(neckSideWidth)) + expect(part2.prop('part').x0).toBe(neckSideWidth) expect(part2.prop('part').x1).toBe( - Math.round(baseProps.width * (1 - neckSideRatio)) + Math.round(baseProps.width * (1 - neckSideRatio) * 10) / 10 ) expect(part2.prop('part').width).toBe(baseProps.width - 2 * neckSideWidth) expect(part2.prop('part').y0).toBe(500) From 3595dc196a7bc6b51bb46985afdcb257564c8e7b Mon Sep 17 00:00:00 2001 From: bdknox Date: Tue, 9 May 2023 00:16:06 -0400 Subject: [PATCH 5/7] Set spacing to 0 for separators when fixedShape --- packages/funnel/src/hooks.ts | 515 ++++++++++++++++++----------------- 1 file changed, 258 insertions(+), 257 deletions(-) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index ad4a92cf06..1af301d451 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -269,263 +269,6 @@ export const computePartsHandlers = ({ }) } -/** - * Creates required layout to generate a funnel chart, - * it uses almost the same parameters as the Funnel component. - * - * For purpose/constrains on the parameters, please have a look - * at the component's props. - */ -export const useFunnel = ({ - data, - width, - height, - direction = defaults.direction, - fixedShape = defaults.fixedShape, - neckWidthRatio = defaults.neckWidthRatio, - neckHeightRatio = defaults.neckHeightRatio, - interpolation = defaults.interpolation, - spacing = defaults.spacing, - shapeBlending: rawShapeBlending = defaults.shapeBlending, - valueFormat, - colors = defaults.colors, - fillOpacity = defaults.fillOpacity, - borderWidth = defaults.borderWidth, - borderColor = defaults.borderColor, - borderOpacity = defaults.borderOpacity, - labelColor = defaults.labelColor, - enableBeforeSeparators = defaults.enableBeforeSeparators, - beforeSeparatorLength = defaults.beforeSeparatorLength, - beforeSeparatorOffset = defaults.beforeSeparatorOffset, - enableAfterSeparators = defaults.enableAfterSeparators, - afterSeparatorLength = defaults.afterSeparatorLength, - afterSeparatorOffset = defaults.afterSeparatorOffset, - isInteractive = defaults.isInteractive, - currentPartSizeExtension = defaults.currentPartSizeExtension, - currentBorderWidth, - onMouseEnter, - onMouseMove, - onMouseLeave, - onClick, - tooltip, -}: { - data: FunnelDataProps['data'] - width: number - height: number - direction?: FunnelCommonProps['direction'] - fixedShape?: FunnelCommonProps['fixedShape'] - neckWidthRatio?: FunnelCommonProps['neckWidthRatio'] - neckHeightRatio?: FunnelCommonProps['neckHeightRatio'] - interpolation?: FunnelCommonProps['interpolation'] - spacing?: FunnelCommonProps['spacing'] - shapeBlending?: FunnelCommonProps['shapeBlending'] - valueFormat?: FunnelCommonProps['valueFormat'] - colors?: FunnelCommonProps['colors'] - fillOpacity?: FunnelCommonProps['fillOpacity'] - borderWidth?: FunnelCommonProps['borderWidth'] - borderColor?: FunnelCommonProps['borderColor'] - borderOpacity?: FunnelCommonProps['borderOpacity'] - labelColor?: FunnelCommonProps['labelColor'] - enableBeforeSeparators?: FunnelCommonProps['enableBeforeSeparators'] - beforeSeparatorLength?: FunnelCommonProps['beforeSeparatorLength'] - beforeSeparatorOffset?: FunnelCommonProps['beforeSeparatorOffset'] - enableAfterSeparators?: FunnelCommonProps['enableAfterSeparators'] - afterSeparatorLength?: FunnelCommonProps['afterSeparatorLength'] - afterSeparatorOffset?: FunnelCommonProps['afterSeparatorOffset'] - isInteractive?: FunnelCommonProps['isInteractive'] - currentPartSizeExtension?: FunnelCommonProps['currentPartSizeExtension'] - currentBorderWidth?: FunnelCommonProps['currentBorderWidth'] - onMouseEnter?: FunnelCommonProps['onMouseEnter'] - onMouseMove?: FunnelCommonProps['onMouseMove'] - onMouseLeave?: FunnelCommonProps['onMouseLeave'] - onClick?: FunnelCommonProps['onClick'] - tooltip?: (props: PartTooltipProps) => JSX.Element -}) => { - const theme = useTheme() - const getColor = useOrdinalColorScale(colors, 'id') - const getBorderColor = useInheritedColor(borderColor, theme) - const getLabelColor = useInheritedColor(labelColor, theme) - - const formatValue = useValueFormatter(valueFormat) - - const [areaGenerator, borderGenerator] = useMemo( - () => computeShapeGenerators(interpolation, direction), - [interpolation, direction] - ) - - let innerWidth: number - let innerHeight: number - const paddingBefore = enableBeforeSeparators ? beforeSeparatorLength + beforeSeparatorOffset : 0 - const paddingAfter = enableAfterSeparators ? afterSeparatorLength + afterSeparatorOffset : 0 - if (direction === 'vertical') { - innerWidth = width - paddingBefore - paddingAfter - innerHeight = height - } else { - innerWidth = width - innerHeight = height - paddingBefore - paddingAfter - } - - const [bandScale, linearScale] = useMemo( - () => - computeScales({ - data, - direction, - width: innerWidth, - height: innerHeight, - spacing, - }), - [data, direction, innerWidth, innerHeight, spacing] - ) - - const [currentPartId, setCurrentPartId] = useState(null) - - const parts: FunnelPart[] = useMemo(() => { - if (fixedShape) { - return computeShapedParts( - data, - direction, - innerWidth, - innerHeight, - paddingBefore, - neckHeightRatio, - neckWidthRatio, - fillOpacity, - currentPartId, - currentPartSizeExtension, - getColor, - getBorderColor, - getLabelColor, - formatValue - ) - } else { - return computeParts( - data, - direction, - innerWidth, - innerHeight, - paddingBefore, - linearScale, - bandScale, - fillOpacity, - borderOpacity, - borderWidth, - currentBorderWidth, - rawShapeBlending, - currentPartId, - currentPartSizeExtension, - getColor, - getBorderColor, - getLabelColor, - formatValue - ) - } - }, [ - data, - direction, - linearScale, - bandScale, - innerWidth, - innerHeight, - paddingBefore, - paddingAfter, - rawShapeBlending, - getColor, - formatValue, - getBorderColor, - getLabelColor, - currentPartId, - ]) - - const { showTooltipFromEvent, hideTooltip } = useTooltip() - const partsWithHandlers = useMemo( - () => - computePartsHandlers({ - parts, - setCurrentPartId, - isInteractive, - onMouseEnter, - onMouseLeave, - onMouseMove, - onClick, - showTooltipFromEvent, - hideTooltip, - tooltip, - }), - [ - parts, - setCurrentPartId, - isInteractive, - onMouseEnter, - onMouseLeave, - onMouseMove, - onClick, - showTooltipFromEvent, - hideTooltip, - tooltip, - ] - ) - - const [beforeSeparators, afterSeparators] = useMemo( - () => - computeSeparators({ - parts, - direction, - width, - height, - spacing, - enableBeforeSeparators, - beforeSeparatorOffset, - enableAfterSeparators, - afterSeparatorOffset, - }), - [ - parts, - direction, - width, - height, - spacing, - enableBeforeSeparators, - beforeSeparatorOffset, - enableAfterSeparators, - afterSeparatorOffset, - ] - ) - - const customLayerProps: FunnelCustomLayerProps = useMemo( - () => ({ - width, - height, - parts: partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, - }), - [ - width, - height, - partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, - ] - ) - - return { - parts: partsWithHandlers, - areaGenerator, - borderGenerator, - beforeSeparators, - afterSeparators, - setCurrentPartId, - currentPartId, - customLayerProps, - } -} - function computeParts( data: FunnelDataProps['data'], direction: FunnelDirection, @@ -1033,6 +776,264 @@ function computeShapedParts( return enhancedParts } +/** + * Creates required layout to generate a funnel chart, + * it uses almost the same parameters as the Funnel component. + * + * For purpose/constrains on the parameters, please have a look + * at the component's props. + */ +export const useFunnel = ({ + data, + width, + height, + direction = defaults.direction, + fixedShape = defaults.fixedShape, + neckWidthRatio = defaults.neckWidthRatio, + neckHeightRatio = defaults.neckHeightRatio, + interpolation = defaults.interpolation, + spacing = defaults.spacing, + shapeBlending: rawShapeBlending = defaults.shapeBlending, + valueFormat, + colors = defaults.colors, + fillOpacity = defaults.fillOpacity, + borderWidth = defaults.borderWidth, + borderColor = defaults.borderColor, + borderOpacity = defaults.borderOpacity, + labelColor = defaults.labelColor, + enableBeforeSeparators = defaults.enableBeforeSeparators, + beforeSeparatorLength = defaults.beforeSeparatorLength, + beforeSeparatorOffset = defaults.beforeSeparatorOffset, + enableAfterSeparators = defaults.enableAfterSeparators, + afterSeparatorLength = defaults.afterSeparatorLength, + afterSeparatorOffset = defaults.afterSeparatorOffset, + isInteractive = defaults.isInteractive, + currentPartSizeExtension = defaults.currentPartSizeExtension, + currentBorderWidth, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, +}: { + data: FunnelDataProps['data'] + width: number + height: number + direction?: FunnelCommonProps['direction'] + fixedShape?: FunnelCommonProps['fixedShape'] + neckWidthRatio?: FunnelCommonProps['neckWidthRatio'] + neckHeightRatio?: FunnelCommonProps['neckHeightRatio'] + interpolation?: FunnelCommonProps['interpolation'] + spacing?: FunnelCommonProps['spacing'] + shapeBlending?: FunnelCommonProps['shapeBlending'] + valueFormat?: FunnelCommonProps['valueFormat'] + colors?: FunnelCommonProps['colors'] + fillOpacity?: FunnelCommonProps['fillOpacity'] + borderWidth?: FunnelCommonProps['borderWidth'] + borderColor?: FunnelCommonProps['borderColor'] + borderOpacity?: FunnelCommonProps['borderOpacity'] + labelColor?: FunnelCommonProps['labelColor'] + enableBeforeSeparators?: FunnelCommonProps['enableBeforeSeparators'] + beforeSeparatorLength?: FunnelCommonProps['beforeSeparatorLength'] + beforeSeparatorOffset?: FunnelCommonProps['beforeSeparatorOffset'] + enableAfterSeparators?: FunnelCommonProps['enableAfterSeparators'] + afterSeparatorLength?: FunnelCommonProps['afterSeparatorLength'] + afterSeparatorOffset?: FunnelCommonProps['afterSeparatorOffset'] + isInteractive?: FunnelCommonProps['isInteractive'] + currentPartSizeExtension?: FunnelCommonProps['currentPartSizeExtension'] + currentBorderWidth?: FunnelCommonProps['currentBorderWidth'] + onMouseEnter?: FunnelCommonProps['onMouseEnter'] + onMouseMove?: FunnelCommonProps['onMouseMove'] + onMouseLeave?: FunnelCommonProps['onMouseLeave'] + onClick?: FunnelCommonProps['onClick'] + tooltip?: (props: PartTooltipProps) => JSX.Element +}) => { + const theme = useTheme() + const getColor = useOrdinalColorScale(colors, 'id') + const getBorderColor = useInheritedColor(borderColor, theme) + const getLabelColor = useInheritedColor(labelColor, theme) + + const formatValue = useValueFormatter(valueFormat) + + const [areaGenerator, borderGenerator] = useMemo( + () => computeShapeGenerators(interpolation, direction), + [interpolation, direction] + ) + + let innerWidth: number + let innerHeight: number + const paddingBefore = enableBeforeSeparators ? beforeSeparatorLength + beforeSeparatorOffset : 0 + const paddingAfter = enableAfterSeparators ? afterSeparatorLength + afterSeparatorOffset : 0 + if (direction === 'vertical') { + innerWidth = width - paddingBefore - paddingAfter + innerHeight = height + } else { + innerWidth = width + innerHeight = height - paddingBefore - paddingAfter + } + + const [bandScale, linearScale] = useMemo( + () => + computeScales({ + data, + direction, + width: innerWidth, + height: innerHeight, + spacing, + }), + [data, direction, innerWidth, innerHeight, spacing] + ) + + const [currentPartId, setCurrentPartId] = useState(null) + + const parts: FunnelPart[] = useMemo(() => { + if (fixedShape) { + return computeShapedParts( + data, + direction, + innerWidth, + innerHeight, + paddingBefore, + neckHeightRatio, + neckWidthRatio, + fillOpacity, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } else { + return computeParts( + data, + direction, + innerWidth, + innerHeight, + paddingBefore, + linearScale, + bandScale, + fillOpacity, + borderOpacity, + borderWidth, + currentBorderWidth, + rawShapeBlending, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } + }, [ + data, + direction, + linearScale, + bandScale, + innerWidth, + innerHeight, + paddingBefore, + paddingAfter, + rawShapeBlending, + getColor, + formatValue, + getBorderColor, + getLabelColor, + currentPartId, + ]) + + const { showTooltipFromEvent, hideTooltip } = useTooltip() + const partsWithHandlers = useMemo( + () => + computePartsHandlers({ + parts, + setCurrentPartId, + isInteractive, + onMouseEnter, + onMouseLeave, + onMouseMove, + onClick, + showTooltipFromEvent, + hideTooltip, + tooltip, + }), + [ + parts, + setCurrentPartId, + isInteractive, + onMouseEnter, + onMouseLeave, + onMouseMove, + onClick, + showTooltipFromEvent, + hideTooltip, + tooltip, + ] + ) + + const [beforeSeparators, afterSeparators] = useMemo( + () => + computeSeparators({ + parts, + direction, + width, + height, + spacing: fixedShape ? 0 : spacing, + enableBeforeSeparators, + beforeSeparatorOffset, + enableAfterSeparators, + afterSeparatorOffset, + }), + [ + parts, + direction, + width, + height, + spacing, + fixedShape, + enableBeforeSeparators, + beforeSeparatorOffset, + enableAfterSeparators, + afterSeparatorOffset, + ] + ) + + const customLayerProps: FunnelCustomLayerProps = useMemo( + () => ({ + width, + height, + parts: partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + }), + [ + width, + height, + partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + ] + ) + + return { + parts: partsWithHandlers, + areaGenerator, + borderGenerator, + beforeSeparators, + afterSeparators, + setCurrentPartId, + currentPartId, + customLayerProps, + } +} + export const useFunnelAnnotations = ( parts: FunnelPart[], annotations: FunnelCommonProps['annotations'] From bc752d573fc73dc01d82c367c3361adaffe0a2a7 Mon Sep 17 00:00:00 2001 From: bdknox Date: Tue, 9 May 2023 00:42:06 -0400 Subject: [PATCH 6/7] Add controls to website to preview fixedShape --- website/src/data/components/funnel/props.ts | 48 +++++++++++++++++++-- website/src/pages/funnel/index.tsx | 3 ++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/website/src/data/components/funnel/props.ts b/website/src/data/components/funnel/props.ts index f01911ac95..4747c9f4e9 100644 --- a/website/src/data/components/funnel/props.ts +++ b/website/src/data/components/funnel/props.ts @@ -23,10 +23,10 @@ const props: ChartProperty[] = [ value: number }[] \`\`\` - + Datum is a generic and can be overriden, this can be useful to attach a color to each datum for example, and then use - this for the \`colors\` property. + this for the \`colors\` property. `, }, ...chartDimensions(allFlavors), @@ -92,6 +92,46 @@ const props: ChartProperty[] = [ step: 0.01, }, }, + { + key: 'fixedShape', + group: 'Base', + help: `Use a fixed shape. If true, spacing and shapeBlending are ignored.`, + type: 'boolean', + required: false, + defaultValue: defaults.fixedShape, + flavors: ['svg'], + control: { type: 'switch' }, + }, + { + key: 'neckHeightRatio', + group: 'Base', + help: 'Set the neck height ratio for a fixedShape funnel.', + type: 'number', + required: false, + defaultValue: defaults.neckHeightRatio, + flavors: ['svg'], + control: { + type: 'range', + min: 0, + max: 1, + step: 0.01, + }, + }, + { + key: 'neckWidthRatio', + group: 'Base', + help: 'Set the neck width ratio for a fixedShape funnel.', + type: 'number', + required: false, + defaultValue: defaults.neckWidthRatio, + flavors: ['svg'], + control: { + type: 'range', + min: 0, + max: 1, + step: 0.01, + }, + }, { key: 'valueFormat', group: 'Base', @@ -287,7 +327,7 @@ const props: ChartProperty[] = [ description: ` You can also use this to insert extra layers to the chart, the extra layer must be a function. - + The layer function which will receive the chart's context & computed data and must return a valid SVG element. `, @@ -305,7 +345,7 @@ const props: ChartProperty[] = [ group: 'Interactivity', help: ` Expand part size by this amount of pixels on each side - when it's active + when it's active `, required: false, defaultValue: defaults.currentPartSizeExtension, diff --git a/website/src/pages/funnel/index.tsx b/website/src/pages/funnel/index.tsx index 5dacb9da23..16355c09a0 100644 --- a/website/src/pages/funnel/index.tsx +++ b/website/src/pages/funnel/index.tsx @@ -27,6 +27,9 @@ const initialProperties: UnmappedFunnelProps = { }, direction: svgDefaultProps.direction, + fixedShape: svgDefaultProps.fixedShape, + neckHeightRatio: svgDefaultProps.neckHeightRatio, + neckWidthRatio: svgDefaultProps.neckWidthRatio, interpolation: svgDefaultProps.interpolation, shapeBlending: svgDefaultProps.shapeBlending, spacing: svgDefaultProps.spacing, From 9ee635276f622715eee8a1742dffa8915165502c Mon Sep 17 00:00:00 2001 From: bdknox Date: Tue, 9 May 2023 01:05:11 -0400 Subject: [PATCH 7/7] Fix issue when neckHeightRatio was set to 1 --- packages/funnel/src/hooks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index 1af301d451..899a91a8b0 100644 --- a/packages/funnel/src/hooks.ts +++ b/packages/funnel/src/hooks.ts @@ -523,9 +523,9 @@ function computeShapedParts( partHeight = (datum.value / totalValue) * innerHeight y0 = currentHeight y1 = y0 + partHeight - const inBottomThird = y0 > neckHeight - partWidth = inBottomThird + const inNeck = neckHeightRatio >= 1 || y0 > neckHeight + partWidth = inNeck ? neckWidth : innerWidth - 2 * (Math.round((currentHeight / slope) * 10) / 10) x0 = paddingBefore + (innerWidth - partWidth) * 0.5 @@ -535,9 +535,9 @@ function computeShapedParts( partWidth = (datum.value / totalValue) * innerWidth x0 = currentWidth x1 = x0 + partWidth - const inLastThird = x0 > neckHeight - partHeight = inLastThird + const inNeck = neckHeightRatio >= 1 || x0 > neckHeight + partHeight = inNeck ? neckWidth : innerHeight - 2 * (Math.round((currentWidth / slope) * 10) / 10) y0 = paddingBefore + (innerHeight - partHeight) * 0.5