From 2eaec32849fa165b05982121de471b7f77323e62 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 17:16:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(charts):=20@bytelyst/charts@0.1.0=20?= =?UTF-8?q?=E2=80=94=20Wave=209.A.1-4=20LineChart=20/=20BarChart=20/=20Are?= =?UTF-8?q?aChart=20/=20Donut=20/=20Gauge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ────────────────────────────────────────────────────────────────── 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() Wave 9.A.2 - Negative-value support via configurable baseline - Per-bar colour override; per-bar accessible label - Compact-K tick labels Wave 9.A.3 - Single-series with gradient fill + line stroke - Zero-baseline included automatically when data >= 0 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 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 ────────────────────────────────────────────────────────────────── - , (see roadmap §9.A) Showcase routes + roadmap flips land in the paired commit. --- packages/charts/package.json | 34 +++ packages/charts/src/AreaChart.tsx | 121 ++++++++++ packages/charts/src/BarChart.tsx | 145 ++++++++++++ packages/charts/src/Donut.tsx | 209 ++++++++++++++++++ packages/charts/src/Gauge.tsx | 127 +++++++++++ packages/charts/src/LineChart.tsx | 160 ++++++++++++++ packages/charts/src/__tests__/charts.test.tsx | 174 +++++++++++++++ packages/charts/src/index.ts | 37 ++++ packages/charts/src/utils.ts | 64 ++++++ packages/charts/tsconfig.json | 11 + packages/charts/vitest.config.ts | 2 + 11 files changed, 1084 insertions(+) create mode 100644 packages/charts/package.json create mode 100644 packages/charts/src/AreaChart.tsx create mode 100644 packages/charts/src/BarChart.tsx create mode 100644 packages/charts/src/Donut.tsx create mode 100644 packages/charts/src/Gauge.tsx create mode 100644 packages/charts/src/LineChart.tsx create mode 100644 packages/charts/src/__tests__/charts.test.tsx create mode 100644 packages/charts/src/index.ts create mode 100644 packages/charts/src/utils.ts create mode 100644 packages/charts/tsconfig.json create mode 100644 packages/charts/vitest.config.ts diff --git a/packages/charts/package.json b/packages/charts/package.json new file mode 100644 index 00000000..16ef51c2 --- /dev/null +++ b/packages/charts/package.json @@ -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" + } +} diff --git a/packages/charts/src/AreaChart.tsx b/packages/charts/src/AreaChart.tsx new file mode 100644 index 00000000..a18818e7 --- /dev/null +++ b/packages/charts/src/AreaChart.tsx @@ -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; +} + +/** + * `` — 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 ( + + {ariaLabel ?? 'Area chart'} + + + + + + + + + + + {/* baseline + max labels */} + + {compactNumber(yMinDisplay)} + + + {compactNumber(yMax)} + + + + ); +} + +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); +} diff --git a/packages/charts/src/BarChart.tsx b/packages/charts/src/BarChart.tsx new file mode 100644 index 00000000..586e28e5 --- /dev/null +++ b/packages/charts/src/BarChart.tsx @@ -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; +} + +/** + * `` — 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 ( + + {ariaLabel ?? 'Bar chart'} + + {grid && + [yMin, baseline, yMax].map((t, i) => ( + + + + {compactNumber(t)} + + + ))} + {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 ( + + + + {d.label ?? d.id} + + + ); + })} + + + ); +} + +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); +} diff --git a/packages/charts/src/Donut.tsx b/packages/charts/src/Donut.tsx new file mode 100644 index 00000000..85b700f6 --- /dev/null +++ b/packages/charts/src/Donut.tsx @@ -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)', +]; + +/** + * `` — 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 ( + + {ariaLabel ?? 'Donut chart'} + {total === 0 ? ( + + ) : ( + 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 ( + + ); + }) + )} + {centerContent !== undefined && ( + +
+ {centerContent} +
+
+ )} +
+ ); +} + +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 ; +} + +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 ; + } + 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 ; +} diff --git a/packages/charts/src/Gauge.tsx b/packages/charts/src/Gauge.tsx new file mode 100644 index 00000000..e5480ade --- /dev/null +++ b/packages/charts/src/Gauge.tsx @@ -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; +} + +/** + * `` — 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 ( + + {ariaLabel ?? `Gauge ${clamped} of ${max}`} + + {fillD && ( + + )} + + {Math.round(clamped)} + + {caption && ( + +
+ {caption} +
+
+ )} +
+ ); +} diff --git a/packages/charts/src/LineChart.tsx b/packages/charts/src/LineChart.tsx new file mode 100644 index 00000000..70920dcc --- /dev/null +++ b/packages/charts/src/LineChart.tsx @@ -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)', +]; + +/** + * `` — 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 + * `` 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 ( + + {ariaLabel ?? 'Line chart'} + {/* Grid */} + + {yTicks.map((t, i) => ( + + + + {compactNumber(t)} + + + ))} + {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 ( + + + {points.map(([x, y], i) => ( + + ))} + + ); + })} + + + ); +} + +/** 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); +} diff --git a/packages/charts/src/__tests__/charts.test.tsx b/packages/charts/src/__tests__/charts.test.tsx new file mode 100644 index 00000000..3760f497 --- /dev/null +++ b/packages/charts/src/__tests__/charts.test.tsx @@ -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 per series', () => { + render( + , + ); + expect(screen.getAllByTestId('bl-line-chart-series')).toHaveLength(2); + }); + + it('uses series.color when provided', () => { + render(); + 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(); + expect(screen.getByTestId('bl-line-chart').querySelector('title')?.textContent).toBe('My label'); + }); +}); + +describe('BarChart', () => { + it('renders one bar per datum', () => { + render( + , + ); + expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(3); + }); + + it('supports negative values via diverging baseline', () => { + render( + , + ); + // Just confirm no throw + both rendered. + expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(2); + }); + + it('uses datum.color override', () => { + render(); + 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(); + 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(); + expect(screen.getByTestId('bl-area-chart')).toBeDefined(); + }); +}); + +describe('Donut', () => { + it('renders one slice per data entry when total > 0', () => { + render( + , + ); + expect(screen.getAllByTestId('bl-donut-slice')).toHaveLength(3); + }); + + it('renders an empty muted ring when total is 0', () => { + render(); + 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( + , + ); + expect(screen.getByTestId('bl-donut').querySelector('[data-kind="full"]')).not.toBeNull(); + }); + + it('renders centerContent in the hole', () => { + render( + 75%} + />, + ); + expect(screen.getByTestId('donut-center')).toBeDefined(); + }); +}); + +describe('Gauge', () => { + it('clamps in-domain values', () => { + render(); + const el = screen.getByTestId('bl-gauge'); + expect(el.getAttribute('data-value')).toBe('60'); + }); + + it('clamps out-of-domain values', () => { + render(); + expect(screen.getByTestId('bl-gauge').getAttribute('data-value')).toBe('100'); + }); + + it('clamps NaN to min', () => { + render(); + expect(screen.getByTestId('bl-gauge').getAttribute('data-value')).toBe('0'); + }); + + it('renders caption slot', () => { + render(nps} />); + expect(screen.getByTestId('cap')).toBeDefined(); + }); +}); diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts new file mode 100644 index 00000000..2c7acf14 --- /dev/null +++ b/packages/charts/src/index.ts @@ -0,0 +1,37 @@ +/** + * @bytelyst/charts — Token-themed chart primitives. + * + * Exports (0.1.0 — Wave 9.A.1-4): + * multi-series line chart, smoothing optional + * vertical bar chart with diverging baseline + * single-series filled area chart with gradient + * categorical share-of-total ring chart + * half-circle dial for single-metric tanks + * + * Deferred to 0.2.x (per roadmap §9.A): + * , + * + * 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"` + `` 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'; diff --git a/packages/charts/src/utils.ts b/packages/charts/src/utils.ts new file mode 100644 index 00000000..ef38cece --- /dev/null +++ b/packages/charts/src/utils.ts @@ -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; +} diff --git a/packages/charts/tsconfig.json b/packages/charts/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/charts/tsconfig.json @@ -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"] +} diff --git a/packages/charts/vitest.config.ts b/packages/charts/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/charts/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });