@@ -98,10 +105,6 @@ export const LineDashed: Story = {
),
};
-/* -------------------------------------------------------------------------- */
-/* 4. LineReferenceLines */
-/* -------------------------------------------------------------------------- */
-
export const LineReferenceLines: Story = {
render: () => (
@@ -127,104 +130,98 @@ export const LineReferenceLines: Story = {
),
};
-/* -------------------------------------------------------------------------- */
-/* 5. SparklineLine */
-/* -------------------------------------------------------------------------- */
+const SPARKLINE_DATA = [{ v: 10 }, { v: 15 }, { v: 12 }, { v: 18 }, { v: 14 }, { v: 22 }, { v: 19 }, { v: 25 }];
export const SparklineLine: Story = {
- render: () => (
+ args: {
+ height: 40,
+ strokeWidth: 2,
+ loading: false,
+ color: 'var(--color-blue-500)',
+ },
+ argTypes: {
+ variant: { control: 'radio', options: ['line', 'bar'] },
+ },
+ render: (args) => (
-
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 6. SparklineBar */
-/* -------------------------------------------------------------------------- */
-export const SparklineBar: Story = {
- render: () => (
-
-
-
- ),
-};
+const STACKED_AREA_DATA = [
+ { month: 'Jan', payments: 400, transfers: 200, fees: 50 },
+ { month: 'Feb', payments: 450, transfers: 250, fees: 60 },
+ { month: 'Mar', payments: 420, transfers: 280, fees: 55 },
+ { month: 'Apr', payments: 500, transfers: 300, fees: 70 },
+ { month: 'May', payments: 480, transfers: 320, fees: 65 },
+ { month: 'Jun', payments: 550, transfers: 350, fees: 80 },
+];
-/* -------------------------------------------------------------------------- */
-/* 7. StackedArea */
-/* -------------------------------------------------------------------------- */
+const STACKED_AREA_SERIES = [
+ { key: 'payments', label: 'Payments', color: 'var(--color-blue-700)' },
+ { key: 'transfers', label: 'Transfers', color: 'var(--color-blue-400)' },
+ { key: 'fees', label: 'Fees', color: 'var(--color-blue-200)' },
+];
export const StackedArea: Story = {
- render: () => (
+ args: {
+ height: 250,
+ grid: true,
+ tooltip: true,
+ animate: true,
+ interactive: true,
+ legend: false,
+ loading: false,
+ fillOpacity: 0.6,
+ },
+ argTypes: {
+ curve: { control: 'radio', options: ['monotone', 'linear'] },
+ tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] },
+ },
+ render: (args) => (
-
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 8. BarGrouped */
-/* -------------------------------------------------------------------------- */
+const BAR_DATA = [
+ { month: 'Jan', incoming: 400, outgoing: 240 },
+ { month: 'Feb', incoming: 500, outgoing: 300 },
+ { month: 'Mar', incoming: 450, outgoing: 280 },
+ { month: 'Apr', incoming: 600, outgoing: 350 },
+ { month: 'May', incoming: 550, outgoing: 320 },
+];
+
+const BAR_SERIES = [
+ { key: 'incoming', label: 'Incoming' },
+ { key: 'outgoing', label: 'Outgoing' },
+];
export const BarGrouped: Story = {
- render: () => (
+ args: {
+ height: 220,
+ grid: true,
+ tooltip: true,
+ animate: true,
+ interactive: true,
+ legend: false,
+ loading: false,
+ stacked: false,
+ },
+ argTypes: {
+ orientation: { control: 'radio', options: ['vertical', 'horizontal'] },
+ tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] },
+ },
+ render: (args) => (
-
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 9. BarStacked */
-/* -------------------------------------------------------------------------- */
-
export const BarStacked: Story = {
render: () => (
@@ -250,87 +247,73 @@ export const BarStacked: Story = {
),
};
-/* -------------------------------------------------------------------------- */
-/* 10. BarHorizontal */
-/* -------------------------------------------------------------------------- */
-export const BarHorizontal: Story = {
- render: () => (
-
-
-
- ),
-};
+const COMPOSED_DATA = [
+ { month: 'Jan', revenue: 4200, rate: 3.2 },
+ { month: 'Feb', revenue: 5100, rate: 3.8 },
+ { month: 'Mar', revenue: 4800, rate: 3.5 },
+ { month: 'Apr', revenue: 6200, rate: 4.1 },
+ { month: 'May', revenue: 5800, rate: 3.9 },
+ { month: 'Jun', revenue: 7100, rate: 4.5 },
+];
-/* -------------------------------------------------------------------------- */
-/* 11. Composed */
-/* -------------------------------------------------------------------------- */
+const COMPOSED_SERIES = [
+ { key: 'revenue', label: 'Revenue', type: 'bar' as const, color: 'var(--color-blue-300)' },
+ { key: 'rate', label: 'Conversion %', type: 'line' as const, axis: 'right' as const, color: 'var(--text-primary)' },
+];
export const Composed: Story = {
- render: () => (
+ args: {
+ height: 250,
+ grid: true,
+ tooltip: true,
+ animate: true,
+ interactive: true,
+ legend: false,
+ loading: false,
+ connectNulls: true,
+ },
+ argTypes: {
+ curve: { control: 'radio', options: ['monotone', 'linear'] },
+ tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] },
+ },
+ render: (args) => (
`${v}%`}
+ {...args}
/>
),
};
-/* -------------------------------------------------------------------------- */
-/* 12. Donut */
-/* -------------------------------------------------------------------------- */
+const PIE_DATA = [
+ { name: 'Payments', value: 4200, color: 'var(--color-blue-700)' },
+ { name: 'Transfers', value: 2800, color: 'var(--color-blue-500)' },
+ { name: 'Fees', value: 650, color: 'var(--color-blue-300)' },
+ { name: 'Refunds', value: 320, color: 'var(--color-blue-100)' },
+];
export const Donut: Story = {
- render: () => (
+ args: {
+ height: 200,
+ innerRadius: 0.65,
+ legend: false,
+ tooltip: true,
+ animate: true,
+ loading: false,
+ },
+ render: (args) => (
-
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 13. Live */
-/* -------------------------------------------------------------------------- */
-
-function LiveChartWrapper() {
+function LiveChartWrapper(props: Record
) {
const [data, setData] = React.useState<{ time: number; value: number }[]>([]);
const [value, setValue] = React.useState(100);
const valueRef = React.useRef(100);
@@ -366,173 +349,735 @@ function LiveChartWrapper() {
v.toFixed(1)}
+ {...props}
/>
);
}
export const Live: Story = {
+ args: {
+ height: 200,
+ grid: true,
+ fill: true,
+ pulse: true,
+ interactive: true,
+ loading: false,
+ window: 30,
+ lerpSpeed: 0.15,
+ color: 'var(--color-blue-500)',
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+const GAUGE_THRESHOLDS = [
+ { upTo: 0.5, color: 'var(--color-green-500)', label: 'Great' },
+ { upTo: 0.8, color: 'var(--color-yellow-500)', label: 'Needs work' },
+ { upTo: 1, color: 'var(--color-red-500)', label: 'Poor' },
+];
+
+export const Gauge: Story = {
+ args: {
+ value: 0.32,
+ min: 0,
+ max: 1,
+ markerLabel: 'P75',
+ loading: false,
+ },
+ argTypes: {
+ variant: { control: 'radio', options: ['default', 'minimal'] },
+ },
+ render: (args) => (
+
+ `${v.toFixed(2)}s`}
+ {...args}
+ />
+
+ ),
+};
+
+
+const BAR_LIST_DATA = [
+ { name: '/', value: 2340, displayValue: '0.28s' },
+ { name: '/pricing', value: 326, displayValue: '0.34s' },
+ { name: '/blog', value: 148, displayValue: '0.31s' },
+ { name: '/docs', value: 89, displayValue: '0.42s' },
+ { name: '/about', value: 45, displayValue: '0.25s' },
+];
+
+export const BarList: Story = {
+ args: {
+ showRank: false,
+ loading: false,
+ max: 10,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+const uptimeData = Array.from({ length: 90 }, (_, i) => ({
+ status: (i % 17 === 0 ? 'down' : i % 11 === 0 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded',
+ label: `Day ${i + 1}`,
+}));
+
+export const Uptime: Story = {
+ args: {
+ height: 32,
+ loading: false,
+ label: '90 days — 97.8% uptime',
+ },
+ argTypes: {
+ labelStatus: { control: 'radio', options: ['up', 'down', 'degraded', 'unknown'] },
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+const SCATTER_DATA = [
+ {
+ key: 'product-a',
+ label: 'Product A',
+ color: 'var(--color-blue-600)',
+ data: [
+ { x: 10, y: 30, label: 'Jan' },
+ { x: 25, y: 55, label: 'Feb' },
+ { x: 40, y: 70, label: 'Mar' },
+ { x: 55, y: 45, label: 'Apr' },
+ { x: 70, y: 85, label: 'May' },
+ { x: 85, y: 60, label: 'Jun' },
+ ],
+ },
+ {
+ key: 'product-b',
+ label: 'Product B',
+ color: 'var(--color-purple-500)',
+ data: [
+ { x: 15, y: 60 },
+ { x: 30, y: 40 },
+ { x: 50, y: 80 },
+ { x: 65, y: 35 },
+ { x: 80, y: 90 },
+ ],
+ },
+];
+
+export const Scatter: Story = {
+ args: {
+ height: 300,
+ grid: true,
+ tooltip: true,
+ legend: true,
+ animate: true,
+ interactive: true,
+ loading: false,
+ dotSize: 6,
+ },
+ argTypes: {
+ tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] },
+ },
+ render: (args) => (
+
+ `${v}%`}
+ formatYLabel={(v: number) => `$${v}`}
+ {...args}
+ />
+
+ ),
+};
+
+const SPLIT_DATA = [
+ { label: 'Payments', value: 4200, color: 'var(--color-blue-700)' },
+ { label: 'Transfers', value: 2800, color: 'var(--color-blue-400)' },
+ { label: 'Fees', value: 650, color: 'var(--color-blue-200)' },
+ { label: 'Refunds', value: 320, color: 'var(--color-blue-100)' },
+];
+
+export const Split: Story = {
+ args: {
+ height: 24,
+ showValues: true,
+ showPercentage: true,
+ legend: true,
+ loading: false,
+ },
+ render: (args) => (
+
+ `$${v.toLocaleString()}`} {...args} />
+
+ ),
+};
+
+export const BarListRanked: Story = {
render: () => (
+
+ `$${v.toLocaleString()}`}
+ formatSecondaryValue={(v) => `${v}%`}
+ showRank
+ />
+
+ ),
+};
+
+
+const WATERFALL_DATA = [
+ { label: 'Revenue', value: 420, type: 'total' as const },
+ { label: 'Product', value: 280 },
+ { label: 'Services', value: 140 },
+ { label: 'Refunds', value: -85 },
+ { label: 'Fees', value: -45 },
+ { label: 'Tax', value: -62 },
+ { label: 'Net', value: 648, type: 'total' as const },
+];
+
+export const Waterfall: Story = {
+ args: {
+ height: 300,
+ grid: true,
+ tooltip: true,
+ showValues: true,
+ showConnectors: true,
+ animate: true,
+ interactive: true,
+ loading: false,
+ },
+ argTypes: {
+ tooltip: { control: 'radio', options: [true, false, 'simple', 'compact'] },
+ },
+ render: (args) => (
-
+ `$${v}`} {...args} />
),
};
-/* -------------------------------------------------------------------------- */
-/* 14. LiveValueDemo */
-/* -------------------------------------------------------------------------- */
+const FUNNEL_DATA = [
+ { label: 'Visitors', value: 10000, color: 'var(--color-blue-700)' },
+ { label: 'Sign ups', value: 4200, color: 'var(--color-blue-500)' },
+ { label: 'Activated', value: 2800, color: 'var(--color-blue-400)' },
+ { label: 'Subscribed', value: 1200, color: 'var(--color-blue-300)' },
+ { label: 'Retained', value: 900, color: 'var(--color-blue-200)' },
+];
-function LiveValueWrapper() {
- const [value, setValue] = React.useState(12847);
- React.useEffect(() => {
- const interval = setInterval(() => {
- setValue((v) => v + Math.floor(Math.random() * 5) + 1);
- }, 800);
- return () => clearInterval(interval);
- }, []);
- return (
- `$${Math.round(v).toLocaleString()}`}
- style={{ fontSize: 32, fontWeight: 500 }}
- />
- );
-}
+export const Funnel: Story = {
+ args: {
+ height: 220,
+ showRates: true,
+ showLabels: true,
+ tooltip: true,
+ animate: true,
+ grid: false,
+ loading: false,
+ },
+ render: (args) => (
+
+ v.toLocaleString()} {...args} />
+
+ ),
+};
-export const LiveValueDemo: Story = {
- render: () => ,
+const SANKEY_DATA = {
+ nodes: [
+ { id: 'revenue', label: 'Revenue', color: 'var(--color-blue-700)' },
+ { id: 'grants', label: 'Grants', color: 'var(--color-blue-400)' },
+ { id: 'investments', label: 'Investments', color: 'var(--color-blue-200)' },
+ { id: 'engineering', label: 'Engineering', color: 'var(--color-purple-600)' },
+ { id: 'marketing', label: 'Marketing', color: 'var(--color-purple-400)' },
+ { id: 'operations', label: 'Operations', color: 'var(--color-purple-200)' },
+ { id: 'product', label: 'Product', color: 'var(--color-green-600)' },
+ { id: 'growth', label: 'Growth', color: 'var(--color-green-400)' },
+ { id: 'infra', label: 'Infrastructure', color: 'var(--color-green-200)' },
+ ],
+ links: [
+ { source: 'revenue', target: 'engineering', value: 400 },
+ { source: 'revenue', target: 'marketing', value: 200 },
+ { source: 'revenue', target: 'operations', value: 150 },
+ { source: 'grants', target: 'engineering', value: 80 },
+ { source: 'grants', target: 'operations', value: 40 },
+ { source: 'investments', target: 'marketing', value: 60 },
+ { source: 'investments', target: 'engineering', value: 30 },
+ { source: 'engineering', target: 'product', value: 350 },
+ { source: 'engineering', target: 'infra', value: 160 },
+ { source: 'marketing', target: 'growth', value: 220 },
+ { source: 'marketing', target: 'product', value: 40 },
+ { source: 'operations', target: 'infra', value: 120 },
+ { source: 'operations', target: 'growth', value: 70 },
+ ],
};
-/* -------------------------------------------------------------------------- */
-/* 15. LiveDotStates */
-/* -------------------------------------------------------------------------- */
+export const Sankey: Story = {
+ args: {
+ height: 380,
+ showValues: true,
+ showLabels: true,
+ tooltip: true,
+ animate: true,
+ loading: false,
+ nodeWidth: 12,
+ nodePadding: 16,
+ },
+ render: (args) => (
+
+ `$${v}k`}
+ {...args}
+ />
+
+ ),
+};
export const LiveDotStates: Story = {
- render: () => (
-
- {(['active', 'processing', 'idle', 'error'] as const).map((status) => (
-
-
{status}
-
+ args: {
+ size: 8,
+ },
+ argTypes: {
+ status: { control: 'radio', options: ['active', 'degraded', 'down', 'unknown'] },
+ },
+ render: (args) => (
+
+ {(['active', 'degraded', 'down', 'unknown'] as const).map((status) => (
+
+
+
+ {status}
+
))}
),
};
-/* -------------------------------------------------------------------------- */
-/* 16. Gauge */
-/* -------------------------------------------------------------------------- */
+export const LiveValueAnimated: Story = {
+ args: {
+ value: 1234,
+ },
+ render: function Render(args) {
+ const [value, setValue] = React.useState(args.value as number);
-export const Gauge: Story = {
+ React.useEffect(() => {
+ setValue(args.value as number);
+ }, [args.value]);
+
+ return (
+
+
v.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+ className="headline-lg"
+ style={{ color: 'var(--text-primary)' }}
+ />
+
+
+
+
+
+
+ Target: {value.toLocaleString()} — the displayed value lerps smoothly toward the target.
+
+
+ );
+ },
+};
+
+export const BarWithAnalytics: Story = {
+ render: function Render() {
+ const [events, setEvents] = React.useState
([]);
+
+ const handler = React.useMemo(
+ () => ({
+ onInteraction: (info: InteractionInfo) => {
+ const entry = `${info.component} · ${info.interaction} · "${info.name}" ${info.metadata ? JSON.stringify(info.metadata) : ''}`;
+ setEvents((prev) => [entry, ...prev].slice(0, 10));
+ },
+ }),
+ [],
+ );
+
+ return (
+
+
+
+ {}}
+ />
+
+
+
+ Click a bar to fire an analytics event
+
+
+ {events.length === 0 ? '(no events yet)' : events.join('\n')}
+
+
+
+
+ );
+ },
+};
+
+
+
+
+
+
+
+export const FunnelActiveChange: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState(null);
+ return (
+
+
+
+
+
+ Active index: {active ?? 'none'}
+
+
+ );
+ },
+};
+
+export const WaterfallActiveChange: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState(null);
+ return (
+
+
+
+
+
+ Active index: {active ?? 'none'}
+
+
+ );
+ },
+};
+
+export const SplitActiveChange: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState(null);
+ return (
+
+
+
+
+
+ Active index: {active ?? 'none'}
+
+
+ );
+ },
+};
+
+export const ScatterActiveChange: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState<{ seriesIndex: number; pointIndex: number } | null>(null);
+ return (
+
+
+
+
+
+ Active: {active ? `series ${active.seriesIndex}, point ${active.pointIndex}` : 'none'}
+
+
+ );
+ },
+};
+
+export const ComposedFixedDomain: Story = {
render: () => (
-
-
+ `${v.toFixed(2)}s`}
+ xKey="month"
+ height={240}
+ grid
+ tooltip
+ yDomain={[0, 1000]}
+ yDomainRight={[0, 5]}
/>
),
};
-/* -------------------------------------------------------------------------- */
-/* 17. GaugeMinimal */
-/* -------------------------------------------------------------------------- */
-
-export const GaugeMinimal: Story = {
+export const WaterfallCustomTooltip: Story = {
render: () => (
-
-
+ `${v.toFixed(2)}s`}
+ height={200}
+ grid
+ tooltip={(d) => {
+ const datum = d as WaterfallSegment;
+ return (
+
+ {datum.label}
+
+ {`$${Math.abs(datum.value).toLocaleString()}`}
+
+ );
+ }}
/>
),
};
-/* -------------------------------------------------------------------------- */
-/* 18. BarList */
-/* -------------------------------------------------------------------------- */
+export const SankeyNoTooltip: Story = {
+ render: () => (
+
+
+
+ ),
+};
-export const BarList: Story = {
+export const FunnelNoTooltip: Story = {
render: () => (
-
-
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 19. Uptime */
-/* -------------------------------------------------------------------------- */
-
-const uptimeData = Array.from({ length: 90 }, (_, i) => ({
- status: (i % 17 === 0 ? 'down' : i % 11 === 0 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded',
- label: `Day ${i + 1}`,
-}));
-
-export const Uptime: Story = {
+export const UptimeLoading: Story = {
render: () => (
-
-
+
+
),
};
-/* -------------------------------------------------------------------------- */
-/* 20. ActivityGrid */
-/* -------------------------------------------------------------------------- */
+export const UptimeEmpty: Story = {
+ render: () => (
+
+
+
+ ),
+};
-const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
-const weeks = Array.from({ length: 20 }, (_, i) => `W${i + 1}`);
+export const GaugeLoading: Story = {
+ render: () => (
+
+
+
+ ),
+};
-const activityData = weeks.flatMap((week, ci) =>
- days.map((day) => ({
- row: day,
- col: week,
- value: ((ci * 7 + days.indexOf(day)) * 37) % 10,
- })),
-);
+export const LiveLoading: Story = {
+ render: () => (
+
+
+
+ ),
+};
-export const ActivityGrid: Story = {
+export const LiveEmpty: Story = {
render: () => (
-
+
+
+
),
};
+
+export const PieKeyboardNav: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState
(null);
+ return (
+
+
+
+
+
+ Focus the chart and use arrow keys to cycle segments. Active: {active ?? 'none'}
+
+
+ );
+ },
+};
+
+export const UptimeKeyboardNav: Story = {
+ render: function Render() {
+ const [active, setActive] = React.useState(null);
+ const data = Array.from({ length: 30 }, (_, i) => ({
+ status: i === 12 || i === 13 ? ('down' as const) : ('up' as const),
+ label: new Date(Date.now() - (30 - i) * 60_000).toLocaleTimeString(),
+ }));
+ return (
+
+
+ setActive(index)}
+ />
+
+
+ Focus the bars and use arrow keys to navigate. Active: {active ?? 'none'}
+
+
+ );
+ },
+};
diff --git a/src/components/Chart/Chart.test-stories.tsx b/src/components/Chart/Chart.test-stories.tsx
index 69a72d7..667f101 100644
--- a/src/components/Chart/Chart.test-stories.tsx
+++ b/src/components/Chart/Chart.test-stories.tsx
@@ -27,6 +27,7 @@ export function Sparkline() {
data={SAMPLE_DATA}
dataKey="value"
height={170}
+ interactive={false}
data-testid="chart"
/>
);
@@ -38,6 +39,7 @@ export function SparklineWithColor() {
data={SAMPLE_DATA}
dataKey="value"
height={170}
+ interactive={false}
color="rgb(0, 0, 255)"
data-testid="chart"
/>
@@ -222,3 +224,166 @@ export function CustomTooltip() {
/>
);
}
+
+export function ScatterBasic() {
+ return (
+
+ );
+}
+
+export function ScatterMultiSeries() {
+ return (
+
+ );
+}
+
+export function SplitBasic() {
+ return (
+
+ );
+}
+
+export function BarListRanked() {
+ return (
+
+ );
+}
+
+
+export function WaterfallBasic() {
+ return (
+
+ );
+}
+
+export function FunnelBasic() {
+ return (
+
+ );
+}
+
+export function BarBasic() {
+ return (
+
+ );
+}
+
+export function SankeyBasic() {
+ return (
+
+ );
+}
diff --git a/src/components/Chart/Chart.test.tsx b/src/components/Chart/Chart.test.tsx
index d1028da..d9d194f 100644
--- a/src/components/Chart/Chart.test.tsx
+++ b/src/components/Chart/Chart.test.tsx
@@ -16,6 +16,14 @@ import {
SimpleTooltip,
DetailedTooltipExplicit,
CustomTooltip,
+ ScatterBasic,
+ ScatterMultiSeries,
+ SplitBasic,
+ BarListRanked,
+ WaterfallBasic,
+ SankeyBasic,
+ FunnelBasic,
+ BarBasic,
} from './Chart.test-stories';
const axeConfig = {
@@ -447,3 +455,333 @@ test.describe('Chart props', () => {
expect(cls).toBeTruthy();
});
});
+
+// ---------------------------------------------------------------------------
+// Scatter chart
+// ---------------------------------------------------------------------------
+
+test.describe('Scatter chart', () => {
+ test('renders circles for data points', async ({ mount, page }) => {
+ await mount();
+ const circles = page.locator('[data-testid="scatter-chart"] svg circle');
+ const count = await circles.count();
+ expect(count).toBe(4);
+ });
+
+ test('has role="graphics-document" and aria-roledescription', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="scatter-chart"] svg');
+ await expect(svg).toHaveAttribute('role', 'graphics-document document');
+ await expect(svg).toHaveAttribute('aria-roledescription', 'Scatter chart');
+ await expect(svg).toHaveAttribute('aria-label');
+ });
+
+ test('renders grid lines when grid=true', async ({ mount, page }) => {
+ await mount();
+ const lines = page.locator('[data-testid="scatter-chart"] svg line');
+ const count = await lines.count();
+ expect(count).toBeGreaterThanOrEqual(2);
+ });
+
+ test('multi-series renders legend when legend=true', async ({ mount, page }) => {
+ await mount();
+ const legendItems = page.locator('[data-testid="scatter-chart"]').locator('..').getByText('Series A', { exact: true });
+ await expect(legendItems).toBeVisible();
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options(axeConfig)
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Split chart
+// ---------------------------------------------------------------------------
+
+test.describe('Split chart', () => {
+ test('renders segments for data', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="split-chart"]');
+ await expect(root).toBeVisible();
+ const barWrap = root.locator('[role="graphics-document document"]');
+ await expect(barWrap).toBeAttached();
+ });
+
+ test('renders legend items', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="split-chart"]');
+ await expect(root.getByText('Payments')).toBeVisible();
+ await expect(root.getByText('Transfers')).toBeVisible();
+ await expect(root.getByText('Fees')).toBeVisible();
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options({
+ ...axeConfig,
+ rules: { ...axeConfig.rules, 'color-contrast': { enabled: false } },
+ })
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// BarList ranked variant
+// ---------------------------------------------------------------------------
+
+test.describe('BarList ranked variant', () => {
+ test('renders ranked rows with rank numbers', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="barlist-ranked"]');
+ await expect(root).toBeVisible();
+ await expect(root.getByText('United States')).toBeVisible();
+ await expect(root.getByText('Japan')).toBeVisible();
+ await expect(root.getByText('1', { exact: true })).toBeVisible();
+ });
+
+ test('has role="list"', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="barlist-ranked"]');
+ await expect(root).toHaveAttribute('role', 'list');
+ });
+
+ test('shows change indicators', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="barlist-ranked"]');
+ const arrows = root.getByText('\u2191');
+ await expect(arrows).toBeVisible();
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options(axeConfig)
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+
+// ---------------------------------------------------------------------------
+// Waterfall chart
+// ---------------------------------------------------------------------------
+
+test.describe('Waterfall chart', () => {
+ test('renders bars for each segment', async ({ mount, page }) => {
+ await mount();
+ const rects = page.locator('[data-testid="waterfall-chart"] svg rect[fill]');
+ const count = await rects.count();
+ expect(count).toBeGreaterThanOrEqual(7);
+ });
+
+ test('renders connector lines when showConnectors=true', async ({ mount, page }) => {
+ await mount();
+ const connectors = page.locator('[data-testid="waterfall-chart"] svg line[stroke-dasharray="2 2"]');
+ const count = await connectors.count();
+ expect(count).toBeGreaterThanOrEqual(1);
+ });
+
+ test('has role="graphics-document" and aria-roledescription', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="waterfall-chart"] svg');
+ await expect(svg).toHaveAttribute('role', 'graphics-document document');
+ await expect(svg).toHaveAttribute('aria-roledescription', 'Waterfall chart');
+ await expect(svg).toHaveAttribute('aria-label');
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options(axeConfig)
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Sankey chart
+// ---------------------------------------------------------------------------
+
+test.describe('Sankey chart', () => {
+ test('renders nodes as rects', async ({ mount, page }) => {
+ await mount();
+ const rects = page.locator('[data-testid="sankey-chart"] svg rect[role="graphics-symbol"]');
+ const count = await rects.count();
+ expect(count).toBe(4);
+ });
+
+ test('renders links as paths', async ({ mount, page }) => {
+ await mount();
+ const paths = page.locator('[data-testid="sankey-chart"] svg path[role="graphics-symbol"]');
+ const count = await paths.count();
+ expect(count).toBe(4);
+ });
+
+ test('has role="graphics-document" and aria-roledescription', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="sankey-chart"] svg');
+ await expect(svg).toHaveAttribute('role', 'graphics-document document');
+ await expect(svg).toHaveAttribute('aria-roledescription', 'Flow diagram');
+ });
+
+ test('renders node labels', async ({ mount, page }) => {
+ await mount();
+ const root = page.locator('[data-testid="sankey-chart"]');
+ const labels = root.locator('svg text');
+ const count = await labels.count();
+ expect(count).toBe(4);
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options(axeConfig)
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Funnel chart
+// ---------------------------------------------------------------------------
+
+test.describe('Funnel chart', () => {
+ test('renders a path for each stage', async ({ mount, page }) => {
+ await mount();
+ const paths = page.locator(
+ '[data-testid="funnel-chart"] svg path[role="graphics-symbol"]',
+ );
+ const count = await paths.count();
+ expect(count).toBe(5);
+ });
+
+ test('shows conversion rate in tooltip on hover', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="funnel-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="funnel-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await expect(tooltip).toContainText('42%');
+ });
+
+ test('has role="graphics-document" and aria-roledescription', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="funnel-chart"] svg');
+ await expect(svg).toHaveAttribute('role', 'graphics-document document');
+ await expect(svg).toHaveAttribute('aria-roledescription', 'Funnel chart');
+ await expect(svg).toHaveAttribute('aria-label');
+ });
+
+ test('has no accessibility violations', async ({ mount, page }) => {
+ await mount();
+ const results = await new AxeBuilder({ page })
+ .options(axeConfig)
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Keyboard interaction
+// ---------------------------------------------------------------------------
+
+test.describe('Keyboard interaction', () => {
+ test('Line chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('Bar chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="bar-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="bar-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('Scatter chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="scatter-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="scatter-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('Split chart: arrow keys swap legend to active segment', async ({ mount, page }) => {
+ await mount();
+ const bar = page.locator('[data-testid="split-chart"] [class*="splitBarWrap"]');
+ const legend = page.locator('[data-testid="split-chart"] [class*="legend"]').first();
+ await expect(legend).toContainText('Payments');
+ await expect(legend).toContainText('Transfers');
+ await bar.focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(legend).toContainText('Payments');
+ await expect(legend).not.toContainText('Transfers');
+ await page.keyboard.press('Escape');
+ await expect(legend).toContainText('Transfers');
+ });
+
+ test('Funnel chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="funnel-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="funnel-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('Waterfall chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="waterfall-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="waterfall-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('Sankey chart: arrow keys show tooltip, Escape dismisses', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="sankey-chart"] svg');
+ await svg.focus();
+ await page.keyboard.press('ArrowRight');
+ const tooltip = page.locator('[data-testid="sankey-chart"] > div[class*="tooltip"]').first();
+ await expect(tooltip).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(tooltip).toBeHidden();
+ });
+
+ test('focus-visible ring appears on SVG charts', async ({ mount, page }) => {
+ await mount();
+ const svg = page.locator('[data-testid="chart"] svg');
+ await svg.focus();
+ const outline = await svg.evaluate((el) => getComputedStyle(el).outlineStyle);
+ expect(outline).not.toBe('none');
+ });
+});
diff --git a/src/components/Chart/Chart.unit.test.ts b/src/components/Chart/Chart.unit.test.ts
index 5212913..d003d1d 100644
--- a/src/components/Chart/Chart.unit.test.ts
+++ b/src/components/Chart/Chart.unit.test.ts
@@ -23,6 +23,7 @@ import {
type Point,
} from './utils';
import { resolveTooltipMode, resolveSeries, SERIES_COLORS, axisTickTarget } from './types';
+import { computeSankeyLayout, sankeyLinkPath } from './sankeyLayout';
// ---------------------------------------------------------------------------
// linearScale
@@ -725,3 +726,103 @@ describe('axisPadForLabels', () => {
expect(withNeg).toBeGreaterThan(positive);
});
});
+
+// ---------------------------------------------------------------------------
+// Sankey layout
+// ---------------------------------------------------------------------------
+
+describe('computeSankeyLayout', () => {
+ const simpleData = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ { id: 'c', label: 'C' },
+ ],
+ links: [
+ { source: 'a', target: 'c', value: 30 },
+ { source: 'b', target: 'c', value: 20 },
+ ],
+ };
+
+ it('returns all nodes and links', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 12, 8);
+ expect(result.nodes).toHaveLength(3);
+ expect(result.links).toHaveLength(2);
+ });
+
+ it('assigns columns via BFS', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 12, 8);
+ const nodeA = result.nodes.find((n) => n.id === 'a')!;
+ const nodeB = result.nodes.find((n) => n.id === 'b')!;
+ const nodeC = result.nodes.find((n) => n.id === 'c')!;
+ expect(nodeA.column).toBe(0);
+ expect(nodeB.column).toBe(0);
+ expect(nodeC.column).toBe(1);
+ });
+
+ it('source nodes are left of target nodes', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 12, 8);
+ const nodeA = result.nodes.find((n) => n.id === 'a')!;
+ const nodeC = result.nodes.find((n) => n.id === 'c')!;
+ expect(nodeA.x1).toBeLessThanOrEqual(nodeC.x0);
+ });
+
+ it('node value equals max of in/out flow', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 12, 8);
+ const nodeC = result.nodes.find((n) => n.id === 'c')!;
+ expect(nodeC.value).toBe(50);
+ });
+
+ it('node width matches nodeWidth param', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 16, 8);
+ for (const node of result.nodes) {
+ expect(node.x1 - node.x0).toBe(16);
+ }
+ });
+
+ it('link widths are proportional to values', () => {
+ const result = computeSankeyLayout(simpleData, 400, 200, 12, 8);
+ const link30 = result.links.find((l) => l.value === 30)!;
+ const link20 = result.links.find((l) => l.value === 20)!;
+ expect(link30.width).toBeGreaterThan(link20.width);
+ });
+
+ it('handles empty input', () => {
+ const result = computeSankeyLayout({ nodes: [], links: [] }, 400, 200, 12, 8);
+ expect(result.nodes).toHaveLength(0);
+ expect(result.links).toHaveLength(0);
+ });
+
+ it('handles multi-column layout', () => {
+ const data = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ { id: 'c', label: 'C' },
+ ],
+ links: [
+ { source: 'a', target: 'b', value: 50 },
+ { source: 'b', target: 'c', value: 50 },
+ ],
+ };
+ const result = computeSankeyLayout(data, 600, 200, 12, 8);
+ const cols = result.nodes.map((n) => n.column);
+ expect(new Set(cols).size).toBe(3);
+ });
+});
+
+describe('sankeyLinkPath', () => {
+ it('produces a valid SVG path with cubic bezier', () => {
+ const data = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ ],
+ links: [{ source: 'a', target: 'b', value: 100 }],
+ };
+ const result = computeSankeyLayout(data, 400, 200, 12, 8);
+ const path = sankeyLinkPath(result.links[0]);
+ expect(path).toMatch(/^M/);
+ expect(path).toContain('C');
+ });
+});
diff --git a/src/components/Chart/ChartWrapper.tsx b/src/components/Chart/ChartWrapper.tsx
index 30704b5..7eaee30 100644
--- a/src/components/Chart/ChartWrapper.tsx
+++ b/src/components/Chart/ChartWrapper.tsx
@@ -3,46 +3,51 @@
import * as React from 'react';
import clsx from 'clsx';
import type { ResolvedSeries } from './types';
+import { Skeleton } from '../Skeleton';
import styles from './Chart.module.scss';
export interface ChartWrapperProps {
+ ref?: React.Ref;
loading?: boolean;
empty?: React.ReactNode;
dataLength: number;
+ isEmpty?: boolean;
height: number;
legend?: boolean;
series?: ResolvedSeries[];
children: React.ReactNode;
className?: string;
- activeIndex?: number | null;
ariaLiveContent?: string;
}
export function ChartWrapper({
+ ref,
loading,
empty,
dataLength,
+ isEmpty,
height,
legend,
series,
children,
className,
- activeIndex: _activeIndex,
ariaLiveContent,
}: ChartWrapperProps) {
+ const showEmpty = isEmpty ?? (dataLength === 0);
+
if (loading) {
return (
-
+
);
}
- if (dataLength === 0 && empty !== undefined) {
+ if (showEmpty && empty !== undefined) {
return (
-
+
{typeof empty === 'boolean' ? 'No data' : empty}
diff --git a/src/components/Chart/ComposedChart.tsx b/src/components/Chart/ComposedChart.tsx
index ab3b10e..944c535 100644
--- a/src/components/Chart/ComposedChart.tsx
+++ b/src/components/Chart/ComposedChart.tsx
@@ -7,18 +7,23 @@ import {
niceTicks,
monotonePath,
linearPath,
+ monotonePathGroups,
+ linearPathGroups,
monotoneInterpolator,
linearInterpolator,
thinIndices,
axisPadForLabels,
type Point,
} from './utils';
-import { useResizeWidth, useChartScrub } from './hooks';
+import { useTrackedCallback } from '../Analytics/useTrackedCallback';
+import { useResizeWidth, useChartInteraction } from './hooks';
+import { useMergedRef } from './useMergedRef';
import {
type Series,
type ResolvedSeries,
type TooltipProp,
type ReferenceLine,
+ type ReferenceBand,
SERIES_COLORS,
DASH_PATTERNS,
PAD_TOP,
@@ -58,6 +63,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div'
curve?: 'monotone' | 'linear';
/** Reference lines on the left Y axis. */
referenceLines?: ReferenceLine[];
+ /** Shaded bands spanning a value range on the left Y axis. Rendered behind bars and lines. */
+ referenceBands?: ReferenceBand[];
/** Show legend below chart. */
legend?: boolean;
/** Show loading skeleton. */
@@ -67,6 +74,8 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div'
/** Control animation. */
animate?: boolean;
ariaLabel?: string;
+ /** Disables interaction, cursor, dots, and tooltip. */
+ interactive?: boolean;
onActiveChange?: (
index: number | null,
datum: Record
| null,
@@ -76,15 +85,25 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div'
index: number,
datum: Record,
) => void;
+ /** Analytics name for event tracking. */
+ analyticsName?: string;
formatValue?: (value: number) => string;
formatXLabel?: (value: unknown) => string;
formatYLabel?: (value: number) => string;
/** Formatter for the right Y axis labels. */
formatYLabelRight?: (value: number) => string;
+ /** Connect across null/NaN gaps in line series. When false, gaps break the line. */
+ connectNulls?: boolean;
+ /** Lock the left Y-axis domain instead of auto-scaling from data. */
+ yDomain?: [number, number];
+ /** Lock the right Y-axis domain instead of auto-scaling from data. */
+ yDomainRight?: [number, number];
}
const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const;
+const clickIndexMeta = (index: number) => ({ index });
+
export const Composed = React.forwardRef(
function Composed(
{
@@ -96,36 +115,39 @@ export const Composed = React.forwardRef(
tooltip: tooltipProp,
curve = 'monotone',
referenceLines,
+ referenceBands,
legend,
loading,
empty,
animate = true,
ariaLabel,
+ interactive = true,
onActiveChange,
onClickDatum,
+ analyticsName,
formatValue,
formatXLabel,
formatYLabel,
formatYLabelRight,
+ connectNulls = true,
+ yDomain: yDomainProp,
+ yDomainRight: yDomainRightProp,
className,
...props
},
ref,
) {
const { width, attachRef } = useResizeWidth();
+ const trackedClick = useTrackedCallback(
+ analyticsName, 'Chart.Composed', 'click', onClickDatum,
+ onClickDatum ? clickIndexMeta : undefined,
+ );
const tooltipMode = resolveTooltipMode(tooltipProp);
- const showTooltip = tooltipMode !== 'off';
+ const showTooltip = interactive && tooltipMode !== 'off';
const tooltipRender =
typeof tooltipProp === 'function' ? tooltipProp : undefined;
- const mergedRef = React.useCallback(
- (node: HTMLDivElement | null) => {
- attachRef(node);
- if (typeof ref === 'function') ref(node);
- else if (ref) ref.current = node;
- },
- [ref, attachRef],
- );
+ const mergedRef = useMergedRef(ref, attachRef);
// Resolve series
const series = React.useMemo(
@@ -156,6 +178,7 @@ export const Composed = React.forwardRef(
// Left Y domain (bar series + left-axis lines)
const leftDomain = React.useMemo(() => {
+ if (yDomainProp) return niceTicks(yDomainProp[0], yDomainProp[1], tickTarget);
let max = -Infinity;
for (const s of series.filter((s) => s.axis === 'left')) {
for (const d of data) {
@@ -168,13 +191,20 @@ export const Composed = React.forwardRef(
if (rl.value > max) max = rl.value;
}
}
+ if (referenceBands) {
+ for (const rb of referenceBands) {
+ const hi = Math.max(rb.from, rb.to);
+ if (hi > max) max = hi;
+ }
+ }
if (max === -Infinity) max = 1;
return niceTicks(0, max, tickTarget);
- }, [data, series, referenceLines, tickTarget]);
+ }, [data, series, referenceLines, referenceBands, tickTarget, yDomainProp]);
// Right Y domain (right-axis lines)
const rightDomain = React.useMemo(() => {
if (!hasRightAxis) return EMPTY_TICKS;
+ if (yDomainRightProp) return niceTicks(yDomainRightProp[0], yDomainRightProp[1], tickTarget);
let min = Infinity;
let max = -Infinity;
for (const s of series.filter((s) => s.axis === 'right')) {
@@ -188,7 +218,7 @@ export const Composed = React.forwardRef(
}
if (min === Infinity) return EMPTY_TICKS;
return niceTicks(min, max, tickTarget);
- }, [data, series, hasRightAxis, tickTarget]);
+ }, [data, series, hasRightAxis, tickTarget, yDomainRightProp]);
const padLeft = React.useMemo(() => {
if (!showYAxis) return 0;
@@ -210,28 +240,48 @@ export const Composed = React.forwardRef(
: 0;
// Line points and paths
- const linePoints = React.useMemo(() => {
- if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0) return [];
- return lineSeries.map((s) => {
+ const { linePoints, lineGroups } = React.useMemo(() => {
+ if (plotWidth <= 0 || plotHeight <= 0 || data.length === 0)
+ return { linePoints: [] as Point[][], lineGroups: [] as Point[][][] };
+ const allPoints: Point[][] = [];
+ const allGroups: Point[][][] = [];
+ for (const s of lineSeries) {
const domain = s.axis === 'right' ? rightDomain : leftDomain;
const points: Point[] = [];
+ const groups: Point[][] = [];
+ let currentGroup: Point[] = [];
for (let i = 0; i < data.length; i++) {
const v = Number(data[i][s.key]);
- if (isNaN(v)) continue;
+ if (isNaN(v)) {
+ if (!connectNulls && currentGroup.length > 0) {
+ groups.push(currentGroup);
+ currentGroup = [];
+ }
+ continue;
+ }
const x = data.length === 1
? plotWidth / 2
: (i + 0.5) * slotWidth;
const y = linearScale(v, domain.min, domain.max, plotHeight, 0);
- points.push({ x, y });
+ const pt = { x, y };
+ points.push(pt);
+ currentGroup.push(pt);
}
- return points;
- });
- }, [data, lineSeries, plotWidth, plotHeight, slotWidth, leftDomain, rightDomain]);
+ if (currentGroup.length > 0) groups.push(currentGroup);
+ allPoints.push(points);
+ allGroups.push(groups);
+ }
+ return { linePoints: allPoints, lineGroups: allGroups };
+ }, [data, lineSeries, plotWidth, plotHeight, slotWidth, leftDomain, rightDomain, connectNulls]);
const linePaths = React.useMemo(() => {
- const build = curve === 'monotone' ? monotonePath : linearPath;
- return linePoints.map((pts) => build(pts));
- }, [linePoints, curve]);
+ if (connectNulls) {
+ const build = curve === 'monotone' ? monotonePath : linearPath;
+ return linePoints.map((pts) => build(pts));
+ }
+ const build = curve === 'monotone' ? monotonePathGroups : linearPathGroups;
+ return lineGroups.map((groups) => build(groups));
+ }, [linePoints, lineGroups, curve, connectNulls]);
// Interpolators for line dot tracking
const interpolators = React.useMemo(() => {
@@ -245,15 +295,16 @@ export const Composed = React.forwardRef(
}, [interpolators]);
// Scrub
- const scrub = useChartScrub({
+ const scrub = useChartInteraction({
dataLength: data.length,
seriesCount: lineSeries.length,
plotWidth,
padLeft,
- tooltipMode,
+ tooltipMode: interactive ? tooltipMode : 'off',
interpolatorsRef,
data,
onActiveChange,
+ onActivate: onClickDatum,
});
// Y axis labels
@@ -282,8 +333,8 @@ export const Composed = React.forwardRef(
const handleClick = React.useCallback(() => {
if (!onClickDatum || scrub.activeIndex === null || scrub.activeIndex >= data.length) return;
- onClickDatum(scrub.activeIndex, data[scrub.activeIndex]);
- }, [onClickDatum, scrub.activeIndex, data]);
+ trackedClick(scrub.activeIndex, data[scrub.activeIndex]);
+ }, [onClickDatum, trackedClick, scrub.activeIndex, data]);
const svgDesc = React.useMemo(() => {
if (series.length === 0 || data.length === 0) return undefined;
@@ -310,14 +361,15 @@ export const Composed = React.forwardRef(
return (
(
{ready && (
<>