diff --git a/src/charts/barChart/barChart.type.ts b/src/charts/barChart/barChart.type.ts index b871eee..3a7f2ed 100644 --- a/src/charts/barChart/barChart.type.ts +++ b/src/charts/barChart/barChart.type.ts @@ -8,6 +8,7 @@ import type { TickData } from '@/components/tick/tick.types'; import type { CanvasConfig } from '@/types/canvas.type'; import type { ChartError, ChartErrorCollection, ErrorType } from '@/types/errors.type'; import type { Positions } from '@/types/position.enum'; +import type { ValueFormatter } from '@/types/valueFormatter.type'; export type BarChartChildrenType = ReactNode | ReactElement; @@ -121,6 +122,7 @@ export interface BarChartXAxisProps extends Omit, React.A */ ariaLabel?: string; tickValues?: BarChartTickValuesAxisProps; + valueFormatter?: ValueFormatter; } export interface BarChartYAxisProps extends Omit, React.AriaAttributes { /** @@ -128,6 +130,7 @@ export interface BarChartYAxisProps extends Omit, React.A */ ariaLabel?: string; tickValues?: BarChartTickValuesAxisProps; + valueFormatter?: ValueFormatter; } export interface BarChartSeparatorProps { topSeparator?: StyleProps; diff --git a/src/charts/barChart/fragments/__tests__/barChartXAxis.test.tsx b/src/charts/barChart/fragments/__tests__/barChartXAxis.test.tsx index 9937d69..75632a4 100644 --- a/src/charts/barChart/fragments/__tests__/barChartXAxis.test.tsx +++ b/src/charts/barChart/fragments/__tests__/barChartXAxis.test.tsx @@ -15,4 +15,20 @@ describe('LineChartXAxis', () => { const xAxis = getByTestId('testxAxis'); expect(xAxis).toBeInTheDocument(); }); + + it('renders formatted tick labels', () => { + const { getByText } = render( + + `Label ${value}`} + /> + + ); + + expect(getByText('Label 1')).toBeInTheDocument(); + expect(getByText('Label 2')).toBeInTheDocument(); + expect(getByText('Label 3')).toBeInTheDocument(); + }); }); diff --git a/src/charts/barChart/fragments/__tests__/barChartYAxis.test.tsx b/src/charts/barChart/fragments/__tests__/barChartYAxis.test.tsx index 5ea56be..60bc77a 100644 --- a/src/charts/barChart/fragments/__tests__/barChartYAxis.test.tsx +++ b/src/charts/barChart/fragments/__tests__/barChartYAxis.test.tsx @@ -15,4 +15,20 @@ describe('LineChartYAxis', () => { const yAxis = getByTestId('testyAxis'); expect(yAxis).toBeInTheDocument(); }); + + it('renders formatted tick labels', () => { + const { getByText } = render( + + `${value}%`} + /> + + ); + + expect(getByText('10%')).toBeInTheDocument(); + expect(getByText('20%')).toBeInTheDocument(); + expect(getByText('30%')).toBeInTheDocument(); + }); }); diff --git a/src/charts/barChart/fragments/barChartXAxis.tsx b/src/charts/barChart/fragments/barChartXAxis.tsx index abaa047..849b52f 100644 --- a/src/charts/barChart/fragments/barChartXAxis.tsx +++ b/src/charts/barChart/fragments/barChartXAxis.tsx @@ -1,6 +1,7 @@ import { type FC, type ReactElement, useContext } from 'react'; import { XAxis } from '@/components/axisChart/xAxis/xAxis'; +import { TickDataUtils } from '@/components/tick/tick.types'; import { Positions } from '@/types/position.enum'; import { getTickTextYCoordinate } from '@/utils/getTickTextCoordinate/getTickTextCoordinates'; @@ -12,6 +13,7 @@ export const BarChartXAxis: FC = ({ position = Positions.BOTTOM, tickLine, tickText, + valueFormatter = (value: string) => value, ...props }): ReactElement => { const { @@ -29,6 +31,9 @@ export const BarChartXAxis: FC = ({ const y1 = context.extraSpaceTopY; const y2 = Number(context.canvasHeight) - context.extraSpaceBottomY; + const formattedTickValues = tickText + ? TickDataUtils.formatTicksValues(tickValues, valueFormatter) + : undefined; return ( = ({ ...tickText, y: tickTextY, }} - tickValues={tickText ? tickValues : undefined} + tickValues={formattedTickValues} /> ); }; diff --git a/src/charts/barChart/fragments/barChartYAxis.tsx b/src/charts/barChart/fragments/barChartYAxis.tsx index 264be82..f0a5383 100644 --- a/src/charts/barChart/fragments/barChartYAxis.tsx +++ b/src/charts/barChart/fragments/barChartYAxis.tsx @@ -1,6 +1,7 @@ import { type FC, type ReactElement, useContext } from 'react'; import { YAxis } from '@/components/axisChart/yAxis/yAxis'; +import { TickDataUtils } from '@/components/tick/tick.types'; import { Positions } from '@/types/position.enum'; import { ajustedTextSpace } from '@/utils/ajustedTextSpace/ajustedTextSpace'; import { getTickTextXCoordinate } from '@/utils/getTickTextCoordinate/getTickTextCoordinates'; @@ -13,6 +14,7 @@ export const BarChartYAxis: FC = ({ position = Positions.LEFT, tickLine, tickText, + valueFormatter = (value: string) => value, ...props }): ReactElement => { const { @@ -30,6 +32,9 @@ export const BarChartYAxis: FC = ({ coordinates.x1, ajustedText ); + const formattedTickValues = tickText + ? TickDataUtils.formatTicksValues(tickValues, valueFormatter) + : undefined; return ( = ({ x2: Number(context.canvasWidth) - context.extraSpaceRightX, }} tickText={{ ...tickText, x: xTickText }} - tickValues={tickText ? tickValues : undefined} + tickValues={formattedTickValues} /> ); }; diff --git a/src/charts/barChart/stories/children/XAxis/xAxis.argtypes.ts b/src/charts/barChart/stories/children/XAxis/xAxis.argtypes.ts index b75f431..b5d3333 100644 --- a/src/charts/barChart/stories/children/XAxis/xAxis.argtypes.ts +++ b/src/charts/barChart/stories/children/XAxis/xAxis.argtypes.ts @@ -181,6 +181,26 @@ export const xAxisArgTypes = (): ArgTypes => { }, }, + valueFormatter: { + control: { + labels: { + currency: 'Currency ($)', + custom: 'Custom Format [val]', + millions: 'Millions (M)', + none: 'None (no formatting)', + percentage: 'Percentage (%)', + thousands: 'Thousands (K)', + }, + type: 'select', + }, + description: 'Select a formatting style for tick labels in this story.', + options: ['none', 'currency', 'percentage', 'thousands', 'millions', 'custom'], + table: { + category: CATEGORY_CONTROL.DATA, + type: { summary: 'ValueFormatter | undefined' }, + }, + }, + transform: { control: { type: 'text' }, description: 'SVG transform attribute for positioning and scaling.', diff --git a/src/charts/barChart/stories/children/XAxis/xAxis.stories.tsx b/src/charts/barChart/stories/children/XAxis/xAxis.stories.tsx index a3fece2..388a023 100644 --- a/src/charts/barChart/stories/children/XAxis/xAxis.stories.tsx +++ b/src/charts/barChart/stories/children/XAxis/xAxis.stories.tsx @@ -4,15 +4,18 @@ import { BarOrientation } from '@/components/bar/bar.type'; import { Note } from '@/storybook/components/note/note'; import { DefaultCanvasConfig } from '@/types/canvas.type'; import { Positions } from '@/types/position.enum'; +import type { ValueFormatter } from '@/types/valueFormatter.type'; import { BarChart } from '../../../barChart'; import type { BarChartXAxisProps } from '../../../barChart.type'; -import { BarChartXAxis } from '../../../fragments/barChartXAxis'; import { xAxisArgTypes } from './xAxis.argtypes'; +type XAxisStoryArgs = Omit & { + valueFormatter?: ValueFormatter | string; +}; + const meta = { argTypes: xAxisArgTypes(), - component: BarChartXAxis, decorators: [ (Story: React.ComponentType) => ( <> @@ -33,6 +36,11 @@ const meta = { • tickValues - Define custom tick positions using numeric or custom format , + <> + • valueFormatter - Format tick labels using a callback function{' '} + (val) => string. The dropdown below shows preset examples, but in your + code you'll pass actual functions. + , <> • tickText - Complete text styling control including font, color, and positioning @@ -61,10 +69,28 @@ const meta = { ], tags: ['autodocs'], title: 'Charts/BarChart/Child Components/BarChartXAxis', -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; + +const getValueFormatter = (formatterType: string) => { + switch (formatterType) { + case 'currency': + return val => `$${val}`; + case 'percentage': + return val => `${val}%`; + case 'thousands': + return val => `${val}K`; + case 'millions': + return val => `${val}M`; + case 'custom': + return val => `[${val}]`; + case 'none': + default: + return undefined; + } +}; export const XAxisCustomization: Story = { args: { @@ -159,11 +185,17 @@ export const XAxisCustomization: Story = { values: ['2001', '2002', '2003', '2004'], }, }, + valueFormatter: 'thousands', transform: undefined, }, - render: (args: BarChartXAxisProps) => { + render: (args: XAxisStoryArgs) => { + const actualArgs = { + ...args, + valueFormatter: getValueFormatter(args.valueFormatter as string), + }; + // Simplified data for better visualization (single series per year) const simplifiedData = [ { value: 50, year: 2001 }, @@ -208,7 +240,7 @@ export const XAxisCustomization: Story = { orientation={BarOrientation.VERTICAL} pKey="year" > - + {simplifiedData.map((dataPoint, index) => ( => { }, }, + valueFormatter: { + control: { + labels: { + currency: 'Currency ($)', + custom: 'Custom brackets [val]', + millions: 'Millions (M)', + none: 'None (no formatting)', + percentage: 'Percentage (%)', + thousands: 'Thousands (K)', + units: 'Units (k)', + }, + type: 'select', + }, + description: 'Select a formatting style for tick labels in this story.', + options: ['none', 'currency', 'percentage', 'units', 'thousands', 'millions', 'custom'], + table: { + category: CATEGORY_CONTROL.DATA, + defaultValue: { summary: 'undefined' }, + type: { summary: 'ValueFormatter' }, + }, + }, + transform: { control: { type: 'text' }, description: 'SVG transform attribute for positioning and scaling.', diff --git a/src/charts/barChart/stories/children/YAxis/yAxis.stories.tsx b/src/charts/barChart/stories/children/YAxis/yAxis.stories.tsx index 8efc5c4..4f910a5 100644 --- a/src/charts/barChart/stories/children/YAxis/yAxis.stories.tsx +++ b/src/charts/barChart/stories/children/YAxis/yAxis.stories.tsx @@ -4,16 +4,19 @@ import { BarOrientation } from '@/components/bar/bar.type'; import { Note } from '@/storybook/components/note/note'; import { DefaultCanvasConfig } from '@/types/canvas.type'; import { Positions } from '@/types/position.enum'; +import type { ValueFormatter } from '@/types/valueFormatter.type'; import { BarChart } from '../../../barChart'; import type { BarChartYAxisProps } from '../../../barChart.type'; -import { BarChartYAxis } from '../../../fragments/barChartYAxis'; import { simplifiedData } from '../../templates/data'; import { yAxisArgTypes } from './yAxis.argtypes'; +type YAxisStoryArgs = Omit & { + valueFormatter?: ValueFormatter | string; +}; + const meta = { argTypes: yAxisArgTypes(), - component: BarChartYAxis, decorators: [ (Story: React.ComponentType) => ( <> @@ -30,6 +33,11 @@ const meta = { <> Key Features: , + <> + • valueFormatter - Format tick labels using a callback function{' '} + (val) => string. The dropdown below shows preset examples, but in your + code you'll pass actual functions. + , <> • position - Axis placement (left or right, refresh story after setting in controls) @@ -68,10 +76,30 @@ const meta = { ], tags: ['autodocs'], title: 'Charts/BarChart/Child Components/BarChartYAxis', -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; + +const getValueFormatter = (formatterType: string) => { + switch (formatterType) { + case 'currency': + return val => `$${val}`; + case 'percentage': + return val => `${val}%`; + case 'thousands': + return val => `${val}K`; + case 'millions': + return val => `${val}M`; + case 'units': + return val => `${val}k`; + case 'custom': + return val => `[${val}]`; + case 'none': + default: + return undefined; + } +}; export const YAxisCustomization: Story = { args: { @@ -161,11 +189,17 @@ export const YAxisCustomization: Story = { step: 1, }, }, + valueFormatter: 'currency', transform: undefined, }, - render: (args: BarChartYAxisProps) => { + render: (args: YAxisStoryArgs) => { + const actualArgs = { + ...args, + valueFormatter: getValueFormatter(args.valueFormatter as string), + }; + const chartData = simplifiedData; const barConfigs = [ @@ -203,7 +237,7 @@ export const YAxisCustomization: Story = { orientation={BarOrientation.HORIZONTAL} pKey="year" > - + {chartData.map((dataPoint, index) => ( { yData: [], }); }); + + it('should use formatted X axis labels to calculate spacing while preserving raw xData', () => { + SVGElement.prototype.getBBox = vi.fn(function (this: SVGElement) { + return { + height: 50, + width: (this.textContent ?? '').length * 10, + x: 0, + y: 0, + }; + }); + + const children = [ + , + `Long ${value}`} + />, + ]; + + const result = getAxisExtraSpacing({ + ajustedX: 1, + ajustedY: 1, + canvasHeight: 100, + canvasWidth: 70, + children, + data: mockData, + gapBetweenBars: 5, + orientation: BarOrientation.VERTICAL, + pKey: 'key', + viewBox: '0 0 100, 80', + }); + + expect(result.securityXSpace).toBe(60); + expect(result.xData).toEqual(['A', 'B', 'C']); + }); + + it('should use formatted Y axis labels to calculate spacing while preserving raw yData', () => { + SVGElement.prototype.getBBox = vi.fn(function (this: SVGElement) { + return { + height: 50, + width: (this.textContent ?? '').length * 10, + x: 0, + y: 0, + }; + }); + + const children = [ + , + `${value}% growth`} + />, + ]; + + const result = getAxisExtraSpacing({ + ajustedX: 1, + ajustedY: 1, + canvasHeight: 100, + canvasWidth: 70, + children, + data: mockData, + gapBetweenBars: 5, + orientation: BarOrientation.HORIZONTAL, + pKey: 'key', + viewBox: '0 0 100, 80', + }); + + expect(result.extraSpaceLeftX).toBe(105); + expect(result.yAxisText).toBe(100); + expect(result.yData).toEqual(['10', '20', '30']); + }); }); diff --git a/src/charts/barChart/utils/getAxisExtraSpacing.ts b/src/charts/barChart/utils/getAxisExtraSpacing.ts index d446f2a..e64a959 100644 --- a/src/charts/barChart/utils/getAxisExtraSpacing.ts +++ b/src/charts/barChart/utils/getAxisExtraSpacing.ts @@ -26,16 +26,17 @@ const handleBarChartXAxis = ( canvasHeight: number, canvasWidth: number ) => { - const { position, tickText, tickValues } = child.props as any; + const { position, tickText, tickValues, valueFormatter } = child.props; const fontSize = tickText?.fontSize ?? 0; const spaceFontSize = fontSize * ajustedX; const xData = tickValues ? (getBarDataValues(tickValues) as string[]) : (data.map(d => d[pKey]) as string[]); + const formattedXData: string[] = valueFormatter ? xData.map(valueFormatter) : xData; const fontSpacing = textBound({ bound: 'width', - data: xData, + data: formattedXData, fontSize, svgHeight: `${canvasHeight}`, svgWidth: `${canvasWidth}`, @@ -71,7 +72,7 @@ const handleBarChartYAxis = ( canvasHeight: number, canvasWidth: number ) => { - const { position, tickText, tickValues } = child.props as any; + const { position, tickText, tickValues, valueFormatter } = child.props; const fontSize = tickText?.fontSize ?? 0; const spaceFontSize = fontSize * ajustedY; //! review @@ -79,11 +80,12 @@ const handleBarChartYAxis = ( tickValues || buildTickValues([...new Set(getBarKeyRoundMaxValue(data, pKey) as unknown as string[])]); const yData = getBarDataValues(dataValues) as string[]; + const formattedYData: string[] = valueFormatter ? yData.map(valueFormatter) : yData; const securityYSpace = (() => (barSpacing > spaceFontSize ? barSpacing : spaceFontSize))(); const textWidth = textBound({ bound: 'width', - data: yData, + data: formattedYData, fontSize, svgHeight: `${canvasHeight}`, svgWidth: `${canvasWidth}`, @@ -154,9 +156,9 @@ export const getAxisExtraSpacing = ({ Children.forEach(children, (child: React.ReactNode) => { if (isValidElement(child)) { - if (child.type === BarChartPath && !reviews.includes((child.props as any).order)) { - reviews.push((child.props as any).order); - barsSpacing += (child.props as any).barConfig.barWidth ?? 0; + if (child.type === BarChartPath && !reviews.includes(child.props.order)) { + reviews.push(child.props.order); + barsSpacing += child.props.barConfig.barWidth ?? 0; } if (child.type === BarChartXAxis) { const securitySpace = orientation === BarOrientation.VERTICAL ? barsSpacing : 0; diff --git a/src/charts/pieChart/fragments/__tests__/pieChartPath.test.tsx b/src/charts/pieChart/fragments/__tests__/pieChartPath.test.tsx index 4735206..3a07073 100644 --- a/src/charts/pieChart/fragments/__tests__/pieChartPath.test.tsx +++ b/src/charts/pieChart/fragments/__tests__/pieChartPath.test.tsx @@ -109,4 +109,28 @@ describe('PieChartPath', () => { const pathsFinal = segmentsFinal.map(s => s.getAttribute('d')); expect(pathsFinal).toEqual(pathsBefore); }); + + it('renders a single halfChart segment without mirrored singleStroke fallback', () => { + const data = { + testKey: [{ name: 'Empty', value: 400, color: '#d9d9d9' }], + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('testpath-0').getAttribute('d')).toBe( + 'M 100 100 A 50,50 0 0,0 0, 100 L 50,100 Z' + ); + }); }); diff --git a/src/charts/pieChart/utils/__tests__/calculateSegmentPath.test.ts b/src/charts/pieChart/utils/__tests__/calculateSegmentPath.test.ts index c08d7f2..4a0fd22 100644 --- a/src/charts/pieChart/utils/__tests__/calculateSegmentPath.test.ts +++ b/src/charts/pieChart/utils/__tests__/calculateSegmentPath.test.ts @@ -121,4 +121,19 @@ describe('calculateSegmentPath', () => { 'M 100 50 A 50,50 0 0,0 0, 49.99999999999999 M 50,50 M 100,50 A 50,50 0 0 1 0,49.99999999999999 M 50,50 A 0,0 0 0 0 50,50' ); }); + + it('should not mirror a single segment when halfChart is true', () => { + const path = calculateSegmentPath({ + canvasHeight: 100, + canvasWidth: 100, + gap: 0, + halfChart: true, + innerRadius: 0, + singleStroke: true, + startAngle: { current: 0 }, + total: 100, + value: 100, + }); + expect(path).toBe('M 100 100 A 50,50 0 0,0 0, 100 L 50,100 Z'); + }); }); diff --git a/src/charts/pieChart/utils/calculateSegmentPath.ts b/src/charts/pieChart/utils/calculateSegmentPath.ts index 8683d17..bc47453 100644 --- a/src/charts/pieChart/utils/calculateSegmentPath.ts +++ b/src/charts/pieChart/utils/calculateSegmentPath.ts @@ -46,6 +46,7 @@ export const calculateSegmentPath = ({ total, value, }: CalculateSegmanentPathProps): string => { + const useSingleStroke = singleStroke && !halfChart; const segmentCanvasHeight = halfChart ? canvasHeight * 2 : canvasHeight; const maxRadius = Math.min(canvasWidth, segmentCanvasHeight) / 2; const radius = customRadius && customRadius < maxRadius ? customRadius : maxRadius; @@ -53,7 +54,7 @@ export const calculateSegmentPath = ({ const center = { x: canvasWidth / 2, y: halfChart ? canvasHeight : canvasHeight / 2 }; // Total * 2 is needed when a single stroke is used, to prevent the segment from being drawn as a full circle - const segmentTotal = singleStroke ? total * 2 : total; + const segmentTotal = useSingleStroke ? total * 2 : total; const piePortion = (value * 100) / segmentTotal; const angleEquivalent = (piePortion * maxAngle) / 100; const gapAngle = gap / radius; @@ -84,6 +85,6 @@ export const calculateSegmentPath = ({ outerStart, radius, rotateDirection, - singleStroke, + singleStroke: useSingleStroke, }); };