learning_ai_common_plat/packages/charts/src/Gauge.tsx
saravanakumardb1 2eaec32849 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.
2026-05-27 17:16:46 -07:00

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>
);
}