fix(chart): mask stacked bar edges (#439)

## Summary
- mask stacked bar rounded edges with the card background
- clamp bar radius for tiny stacked segments
- add regression tests for shape stroke and radius

## Tests
- npx vitest run resources/js/components/ui/stacked-bar-chart.test.tsx
--reporter=verbose
- npx eslint resources/js/components/ui/stacked-bar-chart.tsx
resources/js/components/ui/stacked-bar-chart.test.tsx
This commit is contained in:
Víctor Falcón 2026-05-26 17:03:06 +02:00 committed by GitHub
parent 534a14790e
commit d5d262e9fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 95 additions and 34 deletions

View File

@ -0,0 +1,51 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { StackedBarShape } from './stacked-bar-chart';
describe('StackedBarShape', () => {
it('uses card background for the rounded edge stroke', () => {
const { container } = render(
<svg>
<StackedBarShape
x={0}
y={0}
width={20}
height={10}
fill="var(--color-chart-2)"
payload={{ asset: 10 }}
dataKey="asset"
dataKeys={['asset']}
/>
</svg>,
);
const path = container.querySelector('path');
expect(path?.getAttribute('stroke')).toBe('var(--card)');
expect(path?.getAttribute('stroke-width')).toBe('1');
expect(path?.getAttribute('stroke-linejoin')).toBe('round');
});
it('clamps radius for very short bar segments', () => {
const { container } = render(
<svg>
<StackedBarShape
x={0}
y={0}
width={20}
height={2}
fill="var(--color-chart-2)"
payload={{ asset: 2 }}
dataKey="asset"
dataKeys={['asset']}
/>
</svg>,
);
const path = container.querySelector('path');
expect(path?.getAttribute('d')).toContain('M 1 0');
expect(path?.getAttribute('d')).toContain('H 19');
});
});

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { Bar, BarChart, Rectangle, XAxis } from 'recharts';
import { Bar, BarChart, Rectangle, XAxis, type BarShapeProps } from 'recharts';
import {
ChartConfig,
@ -31,13 +31,13 @@ interface StackedBarShapeProps {
y: number;
width: number;
height: number;
fill: string;
payload: Record<string, unknown>;
fill?: string;
payload?: Record<string, unknown>;
dataKey: string;
dataKeys: string[];
}
function StackedBarShape({
export function StackedBarShape({
x,
y,
width,
@ -49,8 +49,10 @@ function StackedBarShape({
}: StackedBarShapeProps) {
if (height <= 0) return null;
const segmentPayload = payload ?? {};
const radius = Math.min(BORDER_RADIUS, width / 2, height / 2);
const visibleKeys = dataKeys.filter((key) => {
const value = payload[key];
const value = segmentPayload[key];
return typeof value === 'number' && value > 0;
});
@ -61,36 +63,36 @@ function StackedBarShape({
if (isFirstVisible && isLastVisible) {
path = `
M ${x + BORDER_RADIUS} ${y}
H ${x + width - BORDER_RADIUS}
Q ${x + width} ${y} ${x + width} ${y + BORDER_RADIUS}
V ${y + height - BORDER_RADIUS}
Q ${x + width} ${y + height} ${x + width - BORDER_RADIUS} ${y + height}
H ${x + BORDER_RADIUS}
Q ${x} ${y + height} ${x} ${y + height - BORDER_RADIUS}
V ${y + BORDER_RADIUS}
Q ${x} ${y} ${x + BORDER_RADIUS} ${y}
M ${x + radius} ${y}
H ${x + width - radius}
Q ${x + width} ${y} ${x + width} ${y + radius}
V ${y + height - radius}
Q ${x + width} ${y + height} ${x + width - radius} ${y + height}
H ${x + radius}
Q ${x} ${y + height} ${x} ${y + height - radius}
V ${y + radius}
Q ${x} ${y} ${x + radius} ${y}
Z
`;
} else if (isLastVisible) {
path = `
M ${x + BORDER_RADIUS} ${y}
H ${x + width - BORDER_RADIUS}
Q ${x + width} ${y} ${x + width} ${y + BORDER_RADIUS}
M ${x + radius} ${y}
H ${x + width - radius}
Q ${x + width} ${y} ${x + width} ${y + radius}
V ${y + height}
H ${x}
V ${y + BORDER_RADIUS}
Q ${x} ${y} ${x + BORDER_RADIUS} ${y}
V ${y + radius}
Q ${x} ${y} ${x + radius} ${y}
Z
`;
} else if (isFirstVisible) {
path = `
M ${x} ${y}
H ${x + width}
V ${y + height - BORDER_RADIUS}
Q ${x + width} ${y + height} ${x + width - BORDER_RADIUS} ${y + height}
H ${x + BORDER_RADIUS}
Q ${x} ${y + height} ${x} ${y + height - BORDER_RADIUS}
V ${y + height - radius}
Q ${x + width} ${y + height} ${x + width - radius} ${y + height}
H ${x + radius}
Q ${x} ${y + height} ${x} ${y + height - radius}
V ${y}
Z
`;
@ -105,15 +107,19 @@ function StackedBarShape({
`;
}
return <path d={path} fill={fill} />;
return (
<path
d={path}
fill={fill ?? 'currentColor'}
stroke="var(--card)"
strokeLinejoin="round"
strokeWidth={1}
/>
);
}
const CustomCursor = (props) => (
<Rectangle
{...props}
fillOpacity={0.25}
radius={5}
/>
const CustomCursor = (props: React.ComponentProps<typeof Rectangle>) => (
<Rectangle {...props} fillOpacity={0.25} radius={5} />
);
export interface StackedBarChartProps<T extends Record<string, unknown>> {
@ -169,16 +175,20 @@ export function StackedBarChart<T extends Record<string, unknown>>({
const shapeRenderers = useMemo(() => {
return dataKeys.reduce(
(acc, key) => {
acc[key] = (props: Record<string, unknown>) => (
acc[key] = (props: BarShapeProps) => (
<StackedBarShape
{...(props as Omit<StackedBarShapeProps, 'dataKey' | 'dataKeys'>)}
{...props}
dataKey={key}
dataKeys={dataKeys}
/>
);
return acc;
},
{} as Record<string, (props: Record<string, unknown>) => React.ReactNode>,
{} as Record<
string,
(props: BarShapeProps) => React.ReactElement | null
>,
);
}, [dataKeys]);
@ -201,7 +211,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
tickFormatter={xAxisFormatter}
/>
<ChartTooltip
cursor={<CustomCursor/>}
cursor={<CustomCursor />}
content={
<ChartTooltipContent
hideLabel