diff --git a/packages/funnel/src/Funnel.tsx b/packages/funnel/src/Funnel.tsx index 6d6d8f8f42..55e7db68b2 100644 --- a/packages/funnel/src/Funnel.tsx +++ b/packages/funnel/src/Funnel.tsx @@ -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, diff --git a/packages/funnel/src/hooks.ts b/packages/funnel/src/hooks.ts index ca026177ab..899a91a8b0 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 = ( @@ -263,6 +269,513 @@ export const computePartsHandlers = ({ }) } +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 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: [], + } + + 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 +} + +function computeShapedParts( + data: FunnelDataProps['data'], + direction: FunnelDirection, + innerWidth: number, + innerHeight: number, + paddingBefore: number, + neckHeightRatio: number, + neckWidthRatio: number, + fillOpacity: FunnelCommonProps['fillOpacity'], + 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, + currentWidth = 0 + let slope: number, + neckHeight: number, + beforeNeckDistance: number, + afterNeckDistance: number, + neckWidth: number + const totalValue = data.reduce((acc, datum) => acc + datum.value, 0) + if (direction === 'vertical') { + neckWidth = innerWidth * neckWidthRatio + beforeNeckDistance = paddingBefore + (innerWidth - neckWidth) * 0.5 + afterNeckDistance = beforeNeckDistance + neckWidth + neckHeight = innerHeight * (1 - neckHeightRatio) + slope = neckHeight / (beforeNeckDistance - paddingBefore) + } else { + neckWidth = innerHeight * neckWidthRatio + beforeNeckDistance = paddingBefore + (innerHeight - neckWidth) * 0.5 + afterNeckDistance = beforeNeckDistance + neckWidth + neckHeight = innerWidth * (1 - neckHeightRatio) + slope = neckHeight / (beforeNeckDistance - paddingBefore) + } + + const enhancedParts = data.map(datum => { + const isCurrent = datum.id === currentPartId + + let partWidth: number, partHeight: number, x0: number, x1: number, y0: number, y1: number + + if (direction === 'vertical') { + partHeight = (datum.value / totalValue) * innerHeight + y0 = currentHeight + y1 = y0 + partHeight + + const inNeck = neckHeightRatio >= 1 || y0 > neckHeight + partWidth = inNeck + ? 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 inNeck = neckHeightRatio >= 1 || x0 > neckHeight + partHeight = inNeck + ? neckWidth + : innerHeight - 2 * (Math.round((currentWidth / slope) * 10) / 10) + y0 = paddingBefore + (innerHeight - partHeight) * 0.5 + y1 = y0 + partHeight + currentWidth = x1 + } + + const x = x0 + partWidth * 0.5 + const y = y0 + partHeight * 0.5 + + const part: FunnelPart = { + data: datum, + width: partWidth, + height: partHeight, + color: getColor(datum), + fillOpacity, + borderWidth: 0, + borderOpacity: 0, + 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] + + 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.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, + }) + }) + } 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: 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.borderPoints.push(null) + ;[2, 1, 0].map(index => { + part.borderPoints.push({ + x: part.areaPoints[index].x, + y: part.areaPoints[index].y1, + }) + }) + } + }) + + return enhancedParts +} + /** * Creates required layout to generate a funnel chart, * it uses almost the same parameters as the Funnel component. @@ -275,6 +788,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 +820,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 +887,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, + direction, + innerWidth, + innerHeight, + paddingBefore, + neckHeightRatio, + neckWidthRatio, + fillOpacity, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } else { + return computeParts( + data, + direction, + innerWidth, + innerHeight, + paddingBefore, + linearScale, + bandScale, 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 + borderWidth, + currentBorderWidth, + rawShapeBlending, + currentPartId, + currentPartSizeExtension, + getColor, + getBorderColor, + getLabelColor, + formatValue + ) + } }, [ data, direction, @@ -600,7 +979,7 @@ export const useFunnel = ({ direction, width, height, - spacing, + spacing: fixedShape ? 0 : spacing, enableBeforeSeparators, beforeSeparatorOffset, enableAfterSeparators, @@ -612,6 +991,7 @@ export const useFunnel = ({ width, height, spacing, + fixedShape, enableBeforeSeparators, beforeSeparatorOffset, enableAfterSeparators, diff --git a/packages/funnel/src/props.tsx b/packages/funnel/src/props.tsx index ed8b056365..c4d30e0e0f 100644 --- a/packages/funnel/src/props.tsx +++ b/packages/funnel/src/props.tsx @@ -7,6 +7,9 @@ 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 1c4b35caba..69dbd467a2 100644 --- a/packages/funnel/src/types.ts +++ b/packages/funnel/src/types.ts @@ -90,6 +90,9 @@ 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 060a70c99e..7fc3134b48 100644 --- a/packages/funnel/tests/Funnel.test.tsx +++ b/packages/funnel/tests/Funnel.test.tsx @@ -116,6 +116,43 @@ 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 neckSideRatio = (1 - 0.33) / 2 + const neckSideWidth = Math.round(baseProps.width * neckSideRatio * 10) / 10 + const slope = (baseProps.height * 0.67) / neckSideWidth + + 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(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(neckSideWidth) + expect(part2.prop('part').x1).toBe( + 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) + 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: () => , +} 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,