feat(charts): @bytelyst/charts@0.1.0 — Wave 9.A.1-4 LineChart / BarChart / AreaChart / Donut / Gauge

A new package — pure-SVG token-themed chart primitives, zero deps,
SSR-safe. Slots into the existing dataviz family next to
@bytelyst/data-viz (which owns Sparkline + KpiCard + Heatmap).

──────────────────────────────────────────────────────────────────
Components
──────────────────────────────────────────────────────────────────
<LineChart>   Wave 9.A.1
  - Multi-series with per-series colour override
  - Smooth (catmull-rom) or straight polyline
  - 3-tick subtle Y grid + per-tick label
  - SSR-safe title id via useId()

<BarChart>    Wave 9.A.2
  - Negative-value support via configurable baseline
  - Per-bar colour override; per-bar accessible label
  - Compact-K tick labels

<AreaChart>   Wave 9.A.3
  - Single-series with gradient fill + line stroke
  - Zero-baseline included automatically when data >= 0

<Donut>       Wave 9.A.4 (a)
  - Categorical share-of-total ring
  - Token palette walks 6 colours; per-slice override
  - Empty / all-zero data renders a muted ring (no NaN slices)
  - Single near-100% slice collapses to a closed ring
  - centerContent slot via <foreignObject>

<Gauge>       Wave 9.A.4 (b)
  - Half-circle 'fuel-tank' dial
  - NaN / out-of-range clamped safely
  - Caption slot below the dial

──────────────────────────────────────────────────────────────────
Shared utilities (src/utils.ts) — also exported
──────────────────────────────────────────────────────────────────
  - linearScale, extent (NaN-safe), smoothPath, formatNumber

──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
  ✓ pnpm -F @bytelyst/charts test  →  19/19 passing
    - utils:   3 cases (extent / linearScale / smoothPath edge cases)
    - Line:    3 cases (series count, colour override, useId aria)
    - Bar:     3 cases (count, diverging negative, colour)
    - Area:    2 cases (gradient + line, single-point safety)
    - Donut:   4 cases (slice count, empty ring, full-ring collapse,
                        centerContent slot)
    - Gauge:   4 cases (in-domain, clamp >max, NaN→min, caption)
  ✓ pnpm -F @bytelyst/charts build  →  tsc clean
  ✓ No console.log / no Math.random for ids
  ✓ All primitives honour the design-system anti-patterns doc

──────────────────────────────────────────────────────────────────
Deferred to 0.2.x
──────────────────────────────────────────────────────────────────
  - <StackedBar>, <RadarChart> (see roadmap §9.A)

Showcase routes + roadmap flips land in the paired commit.
This commit is contained in:
saravanakumardb1 2026-05-27 17:16:46 -07:00
parent 839f3ff794
commit 2eaec32849
11 changed files with 1084 additions and 0 deletions

View File

@ -0,0 +1,34 @@
{
"name": "@bytelyst/charts",
"version": "0.1.0",
"type": "module",
"description": "Token-themed chart primitives — LineChart, BarChart, AreaChart, Donut, Gauge. Pure SVG, zero deps.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,121 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale, smoothPath } from './utils.js';
export interface AreaChartProps {
/** Series Y-values. */
values: number[];
/** Chart width in px. Default 480. */
width?: number;
/** Chart height in px. Default 240. */
height?: number;
/** Smooth (catmull-rom) vs straight polyline. Default smooth. */
smooth?: boolean;
/** Override stroke + fill base colour. */
color?: string;
/** Accessible label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<AreaChart>` single-series filled area chart with gradient.
*
* Wave 9.A.3.
*/
export function AreaChart({
values,
width = 480,
height = 240,
smooth = true,
color = 'var(--bl-accent, #6366f1)',
ariaLabel,
className,
style,
}: AreaChartProps) {
const titleId = useId();
const gradId = useId();
const padL = 36;
const padR = 12;
const padT = 12;
const padB = 24;
const innerW = Math.max(0, width - padL - padR);
const innerH = Math.max(0, height - padT - padB);
const [yMin, yMax] = extent(values);
const yMinDisplay = Math.min(0, yMin);
const yScale = linearScale(yMinDisplay, yMax, innerH, 0);
const xScale = (i: number) =>
values.length > 1 ? (i / (values.length - 1)) * innerW : innerW / 2;
const points: Array<[number, number]> = values.map((v, i) => [
xScale(i),
yScale(v),
]);
const linePath = smooth
? smoothPath(points)
: `M ${points.map(([x, y]) => `${x} ${y}`).join(' L ')}`;
const closeY = yScale(yMinDisplay);
const areaPath = points.length
? `${linePath} L ${points[points.length - 1]![0]} ${closeY} L ${points[0]![0]} ${closeY} Z`
: '';
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
data-testid="bl-area-chart"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Area chart'}</title>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<g transform={`translate(${padL} ${padT})`}>
<line
x1={0}
x2={innerW}
y1={closeY}
y2={closeY}
stroke="var(--bl-border-subtle, rgba(0,0,0,0.06))"
strokeWidth={1}
/>
<path d={areaPath} fill={`url(#${gradId})`} stroke="none" />
<path d={linePath} fill="none" stroke={color} strokeWidth={2} />
{/* baseline + max labels */}
<text
x={-6}
y={closeY}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill="var(--bl-text-tertiary, #888)"
>
{compactNumber(yMinDisplay)}
</text>
<text
x={-6}
y={yScale(yMax)}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill="var(--bl-text-tertiary, #888)"
>
{compactNumber(yMax)}
</text>
</g>
</svg>
);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -0,0 +1,145 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale } from './utils.js';
export interface BarDatum {
/** Stable id — React key + accessible label. */
id: string;
/** Y-value. Negative values are supported (render below the baseline). */
value: number;
/** Optional X-axis tick label (default: `id`). */
label?: string;
/** Optional override colour. */
color?: string;
}
export interface BarChartProps {
data: BarDatum[];
/** Chart width in px. Default 480. */
width?: number;
/** Chart height in px. Default 240. */
height?: number;
/** Bar corner radius (px). Default 4. */
cornerRadius?: number;
/** Y baseline value (default 0). Use a custom baseline for diverging
* charts (e.g. ±deltas around a target). */
baseline?: number;
/** Show subtle Y grid lines (3 ticks). Default true. */
grid?: boolean;
/** Accessible label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<BarChart>` vertical bar chart with diverging-baseline support.
*
* Wave 9.A.2. Token-tinted via `--bl-accent` with per-bar override.
*/
export function BarChart({
data,
width = 480,
height = 240,
cornerRadius = 4,
baseline = 0,
grid = true,
ariaLabel,
className,
style,
}: BarChartProps) {
const titleId = useId();
const padL = 36;
const padR = 12;
const padT = 12;
const padB = 28;
const innerW = Math.max(0, width - padL - padR);
const innerH = Math.max(0, height - padT - padB);
const values = data.map((d) => d.value);
let [yMin, yMax] = extent(values);
yMin = Math.min(yMin, baseline);
yMax = Math.max(yMax, baseline);
const yScale = linearScale(yMin, yMax, innerH, 0);
const yBase = yScale(baseline);
const n = data.length;
const slot = n > 0 ? innerW / n : 0;
const barW = Math.max(2, slot * 0.7);
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
data-testid="bl-bar-chart"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Bar chart'}</title>
<g transform={`translate(${padL} ${padT})`}>
{grid &&
[yMin, baseline, yMax].map((t, i) => (
<g key={`g${i}`}>
<line
x1={0}
x2={innerW}
y1={yScale(t)}
y2={yScale(t)}
stroke="var(--bl-border-subtle, rgba(0,0,0,0.06))"
strokeWidth={1}
/>
<text
x={-6}
y={yScale(t)}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill="var(--bl-text-tertiary, #888)"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{compactNumber(t)}
</text>
</g>
))}
{data.map((d, i) => {
const cx = slot * i + slot / 2;
const x = cx - barW / 2;
const y = Math.min(yBase, yScale(d.value));
const h = Math.abs(yBase - yScale(d.value));
const colour = d.color ?? 'var(--bl-accent, #6366f1)';
return (
<g key={d.id} data-testid="bl-bar-chart-bar" data-bar-id={d.id}>
<rect
x={x}
y={y}
width={barW}
height={Math.max(0.5, h)}
rx={cornerRadius}
ry={cornerRadius}
fill={colour}
opacity={d.value === baseline ? 0.4 : 0.9}
/>
<text
x={cx}
y={innerH + 14}
textAnchor="middle"
fontSize={10}
fill="var(--bl-text-tertiary, #888)"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{d.label ?? d.id}
</text>
</g>
);
})}
</g>
</svg>
);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -0,0 +1,209 @@
import { useId, type CSSProperties, type ReactNode } from 'react';
export interface DonutSlice {
/** Stable id — React key + accessible label. */
id: string;
/** Slice value — must be ≥ 0. */
value: number;
/** Optional override colour (defaults walk the token palette). */
color?: string;
/** Optional display label (falls back to `id`). */
label?: string;
}
export interface DonutProps {
slices: DonutSlice[];
/** Total diameter in px. Default 200. */
size?: number;
/** Donut hole radius as a fraction of the outer radius. Default 0.6. */
innerRatio?: number;
/** Slot rendered in the centre — typically a total / percent. */
centerContent?: ReactNode;
/** Accessible label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
const TOKEN_PALETTE = [
'var(--bl-accent, #6366f1)',
'var(--bl-success, #10b981)',
'var(--bl-warning, #f59e0b)',
'var(--bl-info, #0ea5e9)',
'var(--bl-danger, #ef4444)',
'color-mix(in srgb, var(--bl-accent, #6366f1) 50%, transparent)',
];
/**
* `<Donut>` categorical share-of-total chart. Pure SVG.
*
* Wave 9.A.4. Empty / all-zero data renders an unsegmented muted ring
* (no NaN slices, no console warnings).
*/
export function Donut({
slices,
size = 200,
innerRatio = 0.6,
centerContent,
ariaLabel,
className,
style,
}: DonutProps) {
const titleId = useId();
const cx = size / 2;
const cy = size / 2;
const rOuter = (size / 2) * 0.96;
const rInner = rOuter * Math.max(0, Math.min(0.95, innerRatio));
const total = slices.reduce((s, x) => s + Math.max(0, x.value), 0);
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
data-testid="bl-donut"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Donut chart'}</title>
{total === 0 ? (
<RingShape
cx={cx}
cy={cy}
rOuter={rOuter}
rInner={rInner}
fill="var(--bl-surface-muted, rgba(0,0,0,0.06))"
dataKind="empty"
/>
) : (
slices.map((slice, i) => {
const colour =
slice.color ?? TOKEN_PALETTE[i % TOKEN_PALETTE.length]!;
const start = priorSum(slices, i) / total;
const end = priorSum(slices, i + 1) / total;
return (
<ArcSlice
key={slice.id}
cx={cx}
cy={cy}
rOuter={rOuter}
rInner={rInner}
startFraction={start}
endFraction={end}
fill={colour}
testId="bl-donut-slice"
data-slice-id={slice.id}
/>
);
})
)}
{centerContent !== undefined && (
<foreignObject
x={cx - rInner}
y={cy - rInner}
width={rInner * 2}
height={rInner * 2}
>
<div
style={{
width: '100%',
height: '100%',
display: 'grid',
placeItems: 'center',
textAlign: 'center',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
{centerContent}
</div>
</foreignObject>
)}
</svg>
);
}
function priorSum(slices: DonutSlice[], n: number): number {
let s = 0;
for (let i = 0; i < n && i < slices.length; i++) {
s += Math.max(0, slices[i]!.value);
}
return s;
}
function RingShape({
cx,
cy,
rOuter,
rInner,
fill,
dataKind,
}: {
cx: number;
cy: number;
rOuter: number;
rInner: number;
fill: string;
dataKind: string;
}) {
// Outer circle minus inner circle via even-odd path.
const d = [
`M ${cx - rOuter} ${cy}`,
`a ${rOuter} ${rOuter} 0 1 0 ${rOuter * 2} 0`,
`a ${rOuter} ${rOuter} 0 1 0 ${-rOuter * 2} 0`,
`Z`,
`M ${cx - rInner} ${cy}`,
`a ${rInner} ${rInner} 0 1 0 ${rInner * 2} 0`,
`a ${rInner} ${rInner} 0 1 0 ${-rInner * 2} 0`,
`Z`,
].join(' ');
return <path d={d} fill={fill} fillRule="evenodd" data-kind={dataKind} />;
}
function ArcSlice({
cx,
cy,
rOuter,
rInner,
startFraction,
endFraction,
fill,
testId,
...rest
}: {
cx: number;
cy: number;
rOuter: number;
rInner: number;
startFraction: number;
endFraction: number;
fill: string;
testId?: string;
[k: string]: unknown;
}) {
// Treat near-full slice as a closed ring (single-slice donut).
if (endFraction - startFraction >= 0.999) {
return <RingShape cx={cx} cy={cy} rOuter={rOuter} rInner={rInner} fill={fill} dataKind="full" />;
}
const a0 = startFraction * 2 * Math.PI - Math.PI / 2;
const a1 = endFraction * 2 * Math.PI - Math.PI / 2;
const x0o = cx + rOuter * Math.cos(a0);
const y0o = cy + rOuter * Math.sin(a0);
const x1o = cx + rOuter * Math.cos(a1);
const y1o = cy + rOuter * Math.sin(a1);
const x0i = cx + rInner * Math.cos(a0);
const y0i = cy + rInner * Math.sin(a0);
const x1i = cx + rInner * Math.cos(a1);
const y1i = cy + rInner * Math.sin(a1);
const largeArc = endFraction - startFraction > 0.5 ? 1 : 0;
const d = [
`M ${x0o} ${y0o}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x1o} ${y1o}`,
`L ${x1i} ${y1i}`,
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x0i} ${y0i}`,
'Z',
].join(' ');
return <path d={d} fill={fill} data-testid={testId} {...(rest as object)} />;
}

View File

@ -0,0 +1,127 @@
import { useId, type CSSProperties, type ReactNode } from 'react';
export interface GaugeProps {
/** Current value (clamped to `[min, max]`). */
value: number;
/** Domain minimum. Default 0. */
min?: number;
/** Domain maximum. Default 100. */
max?: number;
/** SVG size in px (gauge is a half-circle). Default 200. */
size?: number;
/** Ring track thickness in px. Default 12. */
thickness?: number;
/** Optional override colour. */
color?: string;
/** Slot rendered below the gauge dial. */
caption?: ReactNode;
/** Accessible label. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<Gauge>` half-circle dial for single-metric "fuel-tank" surfaces
* (battery state, capacity used, NPS, etc.).
*
* Wave 9.A.4. Tinted by `--bl-accent`. Out-of-range / non-finite
* values clamp safely.
*/
export function Gauge({
value,
min = 0,
max = 100,
size = 200,
thickness = 12,
color = 'var(--bl-accent, #6366f1)',
caption,
ariaLabel,
className,
style,
}: GaugeProps) {
const titleId = useId();
const safeValue = Number.isFinite(value) ? value : min;
const clamped = Math.max(min, Math.min(max, safeValue));
const pct = max > min ? (clamped - min) / (max - min) : 0;
const cx = size / 2;
const cy = size * 0.7;
const r = Math.min(size / 2 - thickness, size * 0.4);
// Half-circle: angle goes from π (left) to 0 (right), i.e. 180° → 0°.
const startAngle = Math.PI;
const endAngle = Math.PI - pct * Math.PI;
const x0 = cx + r * Math.cos(startAngle);
const y0 = cy + r * Math.sin(startAngle);
const x1 = cx + r * Math.cos(endAngle);
const y1 = cy + r * Math.sin(endAngle);
const trackD = [
`M ${cx - r} ${cy}`,
`A ${r} ${r} 0 0 1 ${cx + r} ${cy}`,
].join(' ');
const fillD =
pct === 0
? ''
: [
`M ${x0} ${y0}`,
`A ${r} ${r} 0 0 1 ${x1} ${y1}`,
].join(' ');
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${size} ${size * 0.85}`}
width={size}
height={size * 0.85}
data-testid="bl-gauge"
data-value={clamped}
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? `Gauge ${clamped} of ${max}`}</title>
<path
d={trackD}
fill="none"
stroke="var(--bl-surface-muted, rgba(0,0,0,0.08))"
strokeWidth={thickness}
strokeLinecap="round"
/>
{fillD && (
<path
d={fillD}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
/>
)}
<text
x={cx}
y={cy - 4}
textAnchor="middle"
fontSize={Math.round(size * 0.18)}
fontWeight={700}
fill="var(--bl-text-primary, #111)"
fontFamily="ui-sans-serif, system-ui, sans-serif"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{Math.round(clamped)}
</text>
{caption && (
<foreignObject x={0} y={cy + 4} width={size} height={28}>
<div
style={{
textAlign: 'center',
fontSize: 11,
color: 'var(--bl-text-tertiary, #888)',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
{caption}
</div>
</foreignObject>
)}
</svg>
);
}

View File

@ -0,0 +1,160 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale, smoothPath } from './utils.js';
export interface LineSeries {
/** Stable id — React key + accessible series label. */
id: string;
/** Y-values (X is the array index, evenly spaced). */
values: number[];
/** Optional override colour. Defaults walk the token palette. */
color?: string;
}
export interface LineChartProps {
/** One or more series, plotted in z-order. */
series: LineSeries[];
/** Chart width in px. Default 480. */
width?: number;
/** Chart height in px. Default 240. */
height?: number;
/** Smooth (catmull-rom) vs straight polyline. Default smooth. */
smooth?: boolean;
/** Show subtle Y grid lines (3 ticks). Default true. */
grid?: boolean;
/** Accessible label for the SVG. */
ariaLabel?: string;
className?: string;
style?: CSSProperties;
}
const TOKEN_PALETTE = [
'var(--bl-accent, #6366f1)',
'var(--bl-success, #10b981)',
'var(--bl-warning, #f59e0b)',
'var(--bl-info, #0ea5e9)',
'var(--bl-danger, #ef4444)',
];
/**
* `<LineChart>` multi-series SVG line chart with optional smoothing.
*
* Pure-React, dependency-free. The series share a common Y extent;
* legend / tooltip overlays are intentionally out-of-scope wrap a
* `<LineChart>` in a host that handles those if needed.
*
* Wave 9.A.1.
*/
export function LineChart({
series,
width = 480,
height = 240,
smooth = true,
grid = true,
ariaLabel,
className,
style,
}: LineChartProps) {
const titleId = useId();
const padL = 36;
const padR = 12;
const padT = 12;
const padB = 24;
const innerW = Math.max(0, width - padL - padR);
const innerH = Math.max(0, height - padT - padB);
const allValues = series.flatMap((s) => s.values);
const [yMin, yMax] = extent(allValues);
// Find the longest series for the X domain.
const maxLen = Math.max(1, ...series.map((s) => s.values.length));
const yScale = linearScale(yMin, yMax, innerH, 0);
const xScale = (i: number) =>
maxLen > 1 ? (i / (maxLen - 1)) * innerW : innerW / 2;
const yTicks = grid ? niceTicks(yMin, yMax, 3) : [];
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
data-testid="bl-line-chart"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Line chart'}</title>
{/* Grid */}
<g transform={`translate(${padL} ${padT})`}>
{yTicks.map((t, i) => (
<g key={`t${i}`}>
<line
x1={0}
x2={innerW}
y1={yScale(t)}
y2={yScale(t)}
stroke="var(--bl-border-subtle, rgba(0,0,0,0.06))"
strokeWidth={1}
/>
<text
x={-6}
y={yScale(t)}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill="var(--bl-text-tertiary, #888)"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{compactNumber(t)}
</text>
</g>
))}
{series.map((s, idx) => {
const colour = s.color ?? TOKEN_PALETTE[idx % TOKEN_PALETTE.length]!;
const points: Array<[number, number]> = s.values.map((v, i) => [
xScale(i),
yScale(v),
]);
const d = smooth
? smoothPath(points)
: `M ${points.map(([x, y]) => `${x} ${y}`).join(' L ')}`;
return (
<g key={s.id} data-testid="bl-line-chart-series" data-series-id={s.id}>
<path
d={d}
fill="none"
stroke={colour}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map(([x, y], i) => (
<circle
key={`p-${i}`}
cx={x}
cy={y}
r={2.4}
fill={colour}
opacity={0.85}
/>
))}
</g>
);
})}
</g>
</svg>
);
}
/** Pick ~`n` nice ticks across `[lo, hi]`. */
function niceTicks(lo: number, hi: number, n: number): number[] {
if (lo === hi) return [lo];
const step = (hi - lo) / Math.max(1, n - 1);
return Array.from({ length: n }, (_, i) => lo + step * i);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { LineChart } from '../LineChart.js';
import { BarChart } from '../BarChart.js';
import { AreaChart } from '../AreaChart.js';
import { Donut } from '../Donut.js';
import { Gauge } from '../Gauge.js';
import { extent, linearScale, smoothPath } from '../utils.js';
beforeEach(() => cleanup());
describe('utils', () => {
it('extent ignores non-finite values + pads single-value domain', () => {
expect(extent([1, 2, 3])).toEqual([1, 3]);
expect(extent([NaN, 5, Infinity])).toEqual([5 - 1, 5 + 1]);
expect(extent([])).toEqual([0, 1]);
});
it('linearScale maps endpoints + handles zero-width domain', () => {
const s = linearScale(0, 10, 0, 100);
expect(s(0)).toBe(0);
expect(s(10)).toBe(100);
expect(s(5)).toBe(50);
const zero = linearScale(5, 5, 0, 100);
expect(zero(5)).toBe(50);
});
it('smoothPath returns empty for empty input, M-only for single', () => {
expect(smoothPath([])).toBe('');
expect(smoothPath([[1, 2]])).toBe('M 1 2');
expect(smoothPath([[0, 0], [10, 10]])).toMatch(/^M 0 0 C/);
});
});
describe('LineChart', () => {
it('renders one <g data-testid=bl-line-chart-series> per series', () => {
render(
<LineChart
series={[
{ id: 'a', values: [1, 2, 3] },
{ id: 'b', values: [3, 2, 1] },
]}
/>,
);
expect(screen.getAllByTestId('bl-line-chart-series')).toHaveLength(2);
});
it('uses series.color when provided', () => {
render(<LineChart series={[{ id: 'a', values: [1, 2, 3], color: 'red' }]} />);
const g = screen.getByTestId('bl-line-chart-series');
const path = g.querySelector('path');
expect(path?.getAttribute('stroke')).toBe('red');
});
it('records SSR-safe useId on the title', () => {
render(<LineChart series={[{ id: 'a', values: [1] }]} ariaLabel="My label" />);
expect(screen.getByTestId('bl-line-chart').querySelector('title')?.textContent).toBe('My label');
});
});
describe('BarChart', () => {
it('renders one bar per datum', () => {
render(
<BarChart
data={[
{ id: 'a', value: 10 },
{ id: 'b', value: 20 },
{ id: 'c', value: 15 },
]}
/>,
);
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(3);
});
it('supports negative values via diverging baseline', () => {
render(
<BarChart
data={[
{ id: 'a', value: -5 },
{ id: 'b', value: 5 },
]}
/>,
);
// Just confirm no throw + both rendered.
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(2);
});
it('uses datum.color override', () => {
render(<BarChart data={[{ id: 'a', value: 10, color: 'blue' }]} />);
const bar = screen.getByTestId('bl-bar-chart-bar').querySelector('rect');
expect(bar?.getAttribute('fill')).toBe('blue');
});
});
describe('AreaChart', () => {
it('renders a gradient + filled area + line', () => {
render(<AreaChart values={[1, 2, 3, 2, 4]} />);
const root = screen.getByTestId('bl-area-chart');
expect(root.querySelectorAll('path')).toHaveLength(2); // area + line
expect(root.querySelector('linearGradient')).not.toBeNull();
});
it('renders safely with a single point', () => {
render(<AreaChart values={[42]} />);
expect(screen.getByTestId('bl-area-chart')).toBeDefined();
});
});
describe('Donut', () => {
it('renders one slice per data entry when total > 0', () => {
render(
<Donut
slices={[
{ id: 's1', value: 30 },
{ id: 's2', value: 20 },
{ id: 's3', value: 50 },
]}
/>,
);
expect(screen.getAllByTestId('bl-donut-slice')).toHaveLength(3);
});
it('renders an empty muted ring when total is 0', () => {
render(<Donut slices={[{ id: 'a', value: 0 }, { id: 'b', value: 0 }]} />);
expect(screen.queryAllByTestId('bl-donut-slice')).toHaveLength(0);
expect(screen.getByTestId('bl-donut').querySelector('[data-kind="empty"]')).not.toBeNull();
});
it('collapses a single near-100% slice to a closed ring', () => {
render(
<Donut
slices={[
{ id: 'all', value: 100 },
{ id: 'tiny', value: 0 },
]}
/>,
);
expect(screen.getByTestId('bl-donut').querySelector('[data-kind="full"]')).not.toBeNull();
});
it('renders centerContent in the hole', () => {
render(
<Donut
slices={[{ id: 'a', value: 50 }, { id: 'b', value: 50 }]}
centerContent={<span data-testid="donut-center">75%</span>}
/>,
);
expect(screen.getByTestId('donut-center')).toBeDefined();
});
});
describe('Gauge', () => {
it('clamps in-domain values', () => {
render(<Gauge value={60} min={0} max={100} />);
const el = screen.getByTestId('bl-gauge');
expect(el.getAttribute('data-value')).toBe('60');
});
it('clamps out-of-domain values', () => {
render(<Gauge value={150} min={0} max={100} />);
expect(screen.getByTestId('bl-gauge').getAttribute('data-value')).toBe('100');
});
it('clamps NaN to min', () => {
render(<Gauge value={NaN} min={0} max={100} />);
expect(screen.getByTestId('bl-gauge').getAttribute('data-value')).toBe('0');
});
it('renders caption slot', () => {
render(<Gauge value={42} caption={<span data-testid="cap">nps</span>} />);
expect(screen.getByTestId('cap')).toBeDefined();
});
});

View File

@ -0,0 +1,37 @@
/**
* @bytelyst/charts Token-themed chart primitives.
*
* Exports (0.1.0 Wave 9.A.1-4):
* <LineChart> multi-series line chart, smoothing optional
* <BarChart> vertical bar chart with diverging baseline
* <AreaChart> single-series filled area chart with gradient
* <Donut> categorical share-of-total ring chart
* <Gauge> half-circle dial for single-metric tanks
*
* Deferred to 0.2.x (per roadmap §9.A):
* <StackedBar>, <RadarChart>
*
* Design rules:
* - Pure SVG; zero runtime deps
* - Token-driven palette (`var(--bl-accent)` etc.) with per-element
* overrides
* - SSR-safe `useId()` everywhere (no `Math.random()`)
* - `role="img"` + `<title>` for accessible labelling
*/
export { LineChart } from './LineChart.js';
export type { LineChartProps, LineSeries } from './LineChart.js';
export { BarChart } from './BarChart.js';
export type { BarChartProps, BarDatum } from './BarChart.js';
export { AreaChart } from './AreaChart.js';
export type { AreaChartProps } from './AreaChart.js';
export { Donut } from './Donut.js';
export type { DonutProps, DonutSlice } from './Donut.js';
export { Gauge } from './Gauge.js';
export type { GaugeProps } from './Gauge.js';
export { extent, linearScale, smoothPath, formatNumber } from './utils.js';

View File

@ -0,0 +1,64 @@
/**
* Shared utilities for chart primitives. Pure functions no React.
*/
/** Linear scale: maps `[d0, d1]` → `[r0, r1]`. */
export function linearScale(
d0: number,
d1: number,
r0: number,
r1: number,
): (v: number) => number {
const dr = d1 - d0;
if (dr === 0) return () => (r0 + r1) / 2;
const mr = r1 - r0;
return (v) => r0 + ((v - d0) / dr) * mr;
}
/** Min/max of a numeric array, ignoring NaN/non-finite. */
export function extent(values: readonly number[]): [number, number] {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const v of values) {
if (!Number.isFinite(v)) continue;
if (v < min) min = v;
if (v > max) max = v;
}
if (min === Number.POSITIVE_INFINITY) return [0, 1];
if (min === max) return [min - 1, max + 1];
return [min, max];
}
/** Format a number with `Intl.NumberFormat`, falling back to plain `.toString()`. */
export function formatNumber(
value: number,
opts: Intl.NumberFormatOptions = {},
): string {
try {
return new Intl.NumberFormat(undefined, opts).format(value);
} catch {
return String(value);
}
}
/** Build a smooth catmull-rom path through `pts`. */
export function smoothPath(pts: ReadonlyArray<[number, number]>): string {
if (pts.length === 0) return '';
if (pts.length === 1) {
const [x, y] = pts[0]!;
return `M ${x} ${y}`;
}
let d = `M ${pts[0]![0]} ${pts[0]![1]}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[Math.max(0, i - 2)]!;
const [x1, y1] = pts[i - 1]!;
const [x2, y2] = pts[i]!;
const [x3, y3] = pts[Math.min(pts.length - 1, i + 1)]!;
const cp1x = x1 + (x2 - x0) / 6;
const cp1y = y1 + (y2 - y0) / 6;
const cp2x = x2 - (x3 - x1) / 6;
const cp2y = y2 - (y3 - y1) / 6;
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`;
}
return d;
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });