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:
parent
839f3ff794
commit
2eaec32849
34
packages/charts/package.json
Normal file
34
packages/charts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
121
packages/charts/src/AreaChart.tsx
Normal file
121
packages/charts/src/AreaChart.tsx
Normal 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);
|
||||
}
|
||||
145
packages/charts/src/BarChart.tsx
Normal file
145
packages/charts/src/BarChart.tsx
Normal 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);
|
||||
}
|
||||
209
packages/charts/src/Donut.tsx
Normal file
209
packages/charts/src/Donut.tsx
Normal 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)} />;
|
||||
}
|
||||
127
packages/charts/src/Gauge.tsx
Normal file
127
packages/charts/src/Gauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
packages/charts/src/LineChart.tsx
Normal file
160
packages/charts/src/LineChart.tsx
Normal 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);
|
||||
}
|
||||
174
packages/charts/src/__tests__/charts.test.tsx
Normal file
174
packages/charts/src/__tests__/charts.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
37
packages/charts/src/index.ts
Normal file
37
packages/charts/src/index.ts
Normal 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';
|
||||
64
packages/charts/src/utils.ts
Normal file
64
packages/charts/src/utils.ts
Normal 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;
|
||||
}
|
||||
11
packages/charts/tsconfig.json
Normal file
11
packages/charts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
2
packages/charts/vitest.config.ts
Normal file
2
packages/charts/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||
Loading…
Reference in New Issue
Block a user