Charts

Gauge Chart

A needle dial for one metric against contract-driven threshold bands, with explicit boundary attribution and an out-of-range caret.

Preview in your theme

Loading preview…

"use client"

import { cn } from "@/lib/utils"
import type { ChartGaugeBand, ChartGaugeData, ChartGaugeTone } from "./chart-gauge.contract"

export type ChartGaugeSweep = "semi" | "three-quarter"

export interface ChartGaugeProps extends ChartGaugeData {
  /** 180° dial (default) or 270° dial — same geometry, taller viewBox */
  sweep?: ChartGaugeSweep
  onRetry?: () => void
  className?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-gauge.json

Prompt

Build a React + TypeScript + Tailwind "ChartGauge" component — a needle dial
for one metric read against threshold bands — using zod and hand-composed SVG
(no charting library: the shape is arcs plus a pointer, and every coordinate
has to be exact).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    value: number | null; min: number; max: number; unit?: string;
    bands: { id, label, from, to, tone: "ok" | "warn" | "danger" }[] }.
- The schema refines four invariants so a bad feed fails loudly instead of
  drawing nonsense: max > min; ready implies value !== null; ready implies at
  least one band; and the bands ascend and *tile* [min, max] exactly — first
  band starts at min, last ends at max, each band's `to` is the next band's
  `from`, no gap and no overlap.
- Tiling is what makes band attribution total: every in-range value falls in
  exactly one band, so "which band am I in?" never depends on iteration order
  or on a fallback branch.
- Thresholds live in the data, never in the component — the buyer's warning
  line is not the author's. Tone is *meaning* (ok/warn/danger), never a color.
- Component props = z.infer of the schema, plus sweep?: "semi" |
  "three-quarter" (presentation, not data), onRetry?: () => void, className.

Behavior
- Angles: math convention, 0° at 3 o'clock, counter-clockwise positive. semi
  sweeps 180°→0°, three-quarter sweeps 225°→-45°; both run from a larger angle
  down to a smaller one, so "forward along the dial" is always clockwise on
  screen. angle(v) = start + (end - start) * clamp((v - min) / (max - min)).
- Band membership is half-open — [from, to) for every band, [from, to] for the
  last one. A value sitting exactly on an interior boundary therefore belongs
  to the band that *starts* there, i.e. the more severe one. State this in the
  docs; a gauge that silently rounds a threshold the friendly way is a lie.
- Out of range is stated, never drawn through: the needle clamps to the end of
  the sweep, a caret takes over that end's tick-label slot (the one spot past
  the sweep guaranteed clear of the readout), the chip reads "Above range ·
  max N" / "Below range · min N", no band is marked active, and the readout
  still shows the *real* value.
- Boundary tick marks are drawn for every threshold; their number labels thin
  out by measured arc distance — a label survives only if the gap between its
  painted edge and its neighbour's clears a few units, so a wide sweep keeps
  labels that a narrow one has to drop. Never thin the ticks themselves.
- Defensive clamps for feeds that bypassed the schema: non-finite value → no
  needle and an em-dash readout; max <= min → span falls back to 1 so no path
  can contain NaN.
- Four first-class state branches inside one bg-card panel:
  - loading: the ready silhouette in muted (dial, readout block, chip, three
    legend rows), aria-hidden, animate-pulse — same card height as ready.
  - empty: dashed dial outline cropped to the arc + "No reading yet".
  - error: message + a "Try again" button rendered only when onRetry exists.
  - ready: dial + readout + band chip + legend.

Rendering & styling
- One SVG per sweep with a fixed viewBox (240 x 164 semi, 240 x 212
  three-quarter) and w-full, so the dial scales with zero JS, no
  ResizeObserver and no hydration mismatch.
- Band arcs are annulus sectors built from two arc commands; large-arc-flag
  from |Δangle| > 180, sweep-flag from the direction of travel.
- Color only from chart tokens: tone → var(--chart-2) / var(--chart-3) /
  var(--chart-5), ascending with severity so the ramp reads "darker = worse"
  on a monochrome palette and stays three distinct hues on a colored one.
  Three redundant channels carry "you are here" so no single fill has to:
  the active band is thickened inward *and* outlined in var(--primary) (which
  inverts with the surface), the needle is var(--foreground), and the legend
  row is highlighted. A var(--muted-foreground) ring outline at 50% opacity
  keeps the dial silhouette whole even where a band fill sits close in
  luminance to the card it is painted on.
- The needle is a kite path plus a hub, rotated with CSS
  transform: rotate(-angle) + transform-box: view-box, so it animates with
  transition-transform and is exactly assertable from the DOM;
  motion-reduce:transition-none keeps the position and drops the sweep.
- The readout is HTML overlaid on the SVG (real CSS typography, tabular-nums),
  positioned at a percentage derived from the same geometry constants, inside
  the wedge the needle can never enter — so the big number and the pointer
  cannot collide at any value.
- Accessibility: the dial is role="img" with an aria-label carrying value,
  scale and band ("… 78.4% on a scale of 0 to 100%. In the Warning band, 70
  to 90%."); the visual readout is aria-hidden as its duplicate; the legend is
  real text listing every band's label and range, with an sr-only "(current)"
  on the active row.

Customization levers
- Thresholds: shape and count come from `bands`. Two bands (pass/fail) or five
  (a graded score) work as-is; only the tone→token map caps the palette, and
  the enum plus that map are one edit — extend both together to add zones.
- Sweep: "semi" for a dashboard tile, "three-quarter" when the scale is wide
  and you want the ticks spread out. Adding a third sweep is one entry in
  SWEEPS (start, end, viewH, readoutY, arcH).
- Density: R_OUT/R_IN set ring thickness, R_LABEL the tick-label ring,
  NEEDLE_LEN the pointer; all are viewBox units, so the whole dial rescales
  together. Drop max-w-[280px] to let it fill a wider card.
- Readout: swap the value formatter for compact notation on large scales, or
  drop the unit and put it in the title instead.
- Trimming: the legend and the band chip are independent blocks — remove
  either for a bare dial, but keep at least one non-color channel naming the
  active band.
- Boundary rule: if your domain wants a value on a threshold to read as the
  *lower* band, flip the comparison in bandAt to (from, to] and make the first
  band closed instead of the last — then say so in the UI.

Concepts

  • Contract-driven thresholds — the warning and danger lines arrive as data, not as constants in the component. Two deployments of the same dashboard can disagree about what "critical" means without forking the chart.
  • Tiling as an invariant — the schema refuses bands that leave a gap or overlap, which turns "which band is this value in?" from a search with a fallback into a total function. Every in-range reading has exactly one answer.
  • Half-open attribution — bands are [from, to) with the last one closed, so a value landing exactly on a threshold belongs to the band that starts there — the more severe one. The rule is stated instead of left to floating-point luck.
  • Clamp, don't draw through — a reading past the ends of the scale pins the needle and hands that end's label slot to a caret; the readout still shows the true number and the chip says which side it ran off. An out-of-range value is never quietly rendered as "max".
  • Redundant emphasis — active-band emphasis rides on thickness, a --primary outline, the needle, the chip and the legend, so the dial still reads on a palette where one band's fill has little contrast against the card.
  • Measured label thinning — crowded threshold labels are dropped by comparing real arc distance against real label widths, so the 270° sweep keeps labels the 180° sweep has to hide, and tick marks always survive.
  • Geometry as constants — every radius, angle and viewBox lives in one block of viewBox units, so the dial is responsive through viewBox alone: no measurement, no ResizeObserver, no first-paint jump.

On This Page