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.
128 lines
3.3 KiB
TypeScript
128 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|