@geomak/ui-gauges
v1.1.0
Published
Scientific, marine and wildfire gauges for the Oxygen Design System. A composable SVG gauge engine with optional 3D, and a Rothermel fire model.
Maintainers
Readme
@geomak/ui-gauges
Scientific, marine and wildfire gauges for the Oxygen Design System.
Documentation and live examples
Ready-made instruments, plus the engine they're built on, so a gauge this package doesn't ship is something you can build yourself with the same pieces.
yarn add @geomak/ui-gaugesimport '@geomak/ui/styles'
import '@geomak/ui-gauges/styles' // both sheets, in this order
import { RadialGauge } from '@geomak/ui-gauges'
<RadialGauge value={rpm} min={0} max={8000} label="Engine RPM" unit=" rpm" />Why SVG
Gauges here render as SVG rather than canvas. That single decision buys most of what makes them usable in a design system:
- Theming is free. Every fill and stroke is a
var()reference into@geomak/ui's tokens. Flip to dark mode or override--color-accentand the gauges follow, with no repaint logic and no re-render. - They're accessible. Real DOM nodes mean
role="meter", real focusable controls, and text a screen reader can read. Canvas output is an opaque bitmap. - They're crisp everywhere. No
devicePixelRatiojuggling, no blurry needles on a HiDPI display. - They're testable. The whole suite runs in jsdom.
- Text behaves like text. A webfont that loads after first paint reflows correctly; canvas text is baked at draw time.
For genuinely high-density scientific output, such as strip charts and waterfall plots, a canvas renderer is the right tool, and the engine is renderer-agnostic so one can slot in behind the same scales. SVG is the default because gauge faces have tens of elements, not tens of thousands.
The three layers
engine/ scales, polar geometry, ticks, value animation → pure maths + hooks
primitives/ Gauge, GaugeArc, GaugeTicks, GaugeNeedle, … → the custom-gauge API
gauges/ RadialGauge, … → composed from primitivesLayer 3 has no privileged access to layer 1. Every shipped gauge is written
with exactly the primitives exported to you, and that's enforced by a test
(src/tests/dogfooding.test.ts) that fails the build if a gauge reaches past
the public barrels. If a built-in can do something your custom gauge can't,
that's a bug. Please report it.
Building a custom gauge
<Gauge> establishes a coordinate system and draws nothing. You compose the
face from primitives, in paint order:
import {
Gauge, GaugeTrack, GaugeTicks, GaugeNeedle, GaugeValue, GaugeLabel,
} from '@geomak/ui-gauges'
function Compass({ heading }: { heading: number }) {
return (
<Gauge
value={heading}
min={0}
max={360}
angles={{ start: 0, end: 360 }}
label="Vessel heading"
formatValue={(v) => `${v} degrees`}
>
<GaugeTrack width={14} />
<GaugeTicks count={36} format={(v, i) => (i % 3 === 0 ? String(v) : undefined)} />
<GaugeLabel value={0} radius={0.62} legend>N</GaugeLabel>
<GaugeLabel value={90} radius={0.62} legend>E</GaugeLabel>
<GaugeLabel value={180} radius={0.62} legend>S</GaugeLabel>
<GaugeLabel value={270} radius={0.62} legend>W</GaugeLabel>
<GaugeValue suffix="°" label="HDG" />
<GaugeNeedle shape="line" length={0.55} />
</Gauge>
)
}None of those children take geometry props. They read the centre, radius, sweep and scale from context, so the gauge scales as a whole and stays correct at any size.
Primitives
| Primitive | Draws |
|---|---|
| Gauge | The coordinate system, scale, animation and a11y. Draws nothing itself. |
| GaugeTrack | The unfilled arc a value travels along |
| GaugeProgress | The filled portion, from from to the current value |
| GaugeBands | Coloured qualitative ranges (nominal / caution / critical) |
| GaugeTicks | Tick marks and labels, positioned through the scale |
| GaugeNeedle | The pointer. Multiple allowed, for setpoints and targets |
| GaugeMarker | A fixed indicator at one value: threshold, limit, peak-hold |
| GaugeValue | The numeric readout, as real selectable text |
| GaugeLabel | Free-standing text, positioned polar or cartesian |
| GaugeHotspot | An accessible, keyboard-reachable interactive region |
| GaugeRotate | Rotates its children about the centre: moving cards, bezels |
| GaugeSegments | Discrete segments that fill with the reading |
GaugeNeedle and GaugeRotate are radial-only, since a bar has nothing to
rotate.
Everything else works in every layout.
Layouts
A gauge face is either circular or straight, and primitives don't care which. They position themselves through a layout rather than doing polar maths, so one set of primitives serves both:
<Gauge value={v} /> // radial (default)
<Gauge value={v} orientation="vertical" /> // fills upward
<Gauge value={v} orientation="horizontal" /> // fills rightwardTwo coordinates address any point on a face:
| Coordinate | Meaning |
|---|---|
| t | progress along the scale, 0…1, the long axis |
| u | position across the track, 0 inner edge → 1 outer edge |
On a dial the inner edge is the centre, so u is a fraction of the radius and
u = 1 is the rim. On a bar the edges are its two sides, so u = 0.5 is the
centreline. In both, u = 1 is the outer boundary, which is what keeps
primitive defaults meaningful across layouts.
Implement GaugeLayout to add a shape the package doesn't ship.
Scales
The scale is the single seam every gauge shares. It maps a domain value onto
[0, 1], and everything else (angle, offset, 3D rotation) derives from that.
import { createLinearScale, createLogScale, createPiecewiseScale } from '@geomak/ui-gauges'
createLinearScale(0, 8000) // proportional
createLogScale(20, 20_000) // decades: frequency, pressure, conductivity
createPiecewiseScale([ // non-uniform, like a real instrument face
{ value: 0, t: 0 },
{ value: 10, t: 0.5 }, // first 10 units get half the travel
{ value: 40, t: 1 },
])
createOrdinalScale([ // discrete positions, optionally unequal
{ key: 'astern', label: 'Astern' },
{ key: 'stop', label: 'Stop', weight: 2 },
{ key: 'ahead', label: 'Ahead' },
])createOrdinalScale covers gauges whose domain is a set of named positions
rather than a quantity: mode selectors, engine telegraphs, gear indicators.
Its domain is a continuous position coordinate underneath, which is what lets
the needle sweep between positions instead of teleporting, and means every
existing primitive works against it untouched.
Ticks route through scale.normalize, so a log or piecewise scale places its
ticks correctly with no extra work. Implement the GaugeScale interface to add
a scale type that isn't shipped.
Pressure
BarometerGauge ships with a small pressure toolkit, exported so you can use
the maths without the dial:
import {
toMillibars, toInchesOfMercury, pressureCondition, pressureTrend, MBAR_PER_INHG,
} from '@geomak/ui-gauges'
pressureCondition(985) // → 'rain'
pressureTrend(1013, 1008) // → 'rising'Conversion goes through one constant (33.8639 mbar/inHg), so round trips
are exact. The reference implementation used 34.433 one way and 33.864 the
other, which drifted about 17 mbar per round trip, wider than the entire span
between "rain" and "fair" on its own dial. Its zone table also left 990–991 and
1030–1031 matching nothing and crashed when the lookup was destructured;
PRESSURE_ZONES is contiguous by construction, with a test sweeping the range
to prove it.
The set hand is a real barometer's second pointer: park it on the current reading and the gap between the hands shows the trend. It's fully controlled; the gauge stores nothing:
const [mark, setMark] = useState<number | null>(null)
<BarometerGauge value={pressure} setHand={mark} onSetHand={setMark} />Marine
@geomak/ui-gauges/marine holds the bridge-console set, behind a subpath so a
project that only wants a tachometer never pays for it.
import { RudderAngleGauge, CompassGauge, EngineTelegraph } from '@geomak/ui-gauges/marine'
<EngineTelegraph value="HALF_AHEAD" /> // repeater (display only)
<EngineTelegraph value={order} onChange={setOrder} /> // working telegraph
<RudderAngleGauge value={-22.5} /> // 22.5° to port
<RudderAngleGauge value={12} expandedScale />
<CompassGauge value={287} /> // moving needle over a fixed card
<CompassGauge value={287} mode="card" /> // moving card under a fixed lubber lineEngineTelegraph takes a single EngineOrder key rather than the reference's
(value, source) pair, which couldn't tell 'FULL' ahead from 'FULL' astern
without consulting both fields. Pass onChange to make it a control: every
sector becomes a focusable, keyboard-operable button. For a feed that reports
the two separately, toEngineOrder('HALF', 'AHEAD') resolves the pair.
Circular domains
CompassGauge is the one gauge whose animation can't be plain interpolation.
A heading wraps, so 350° → 010° is a 20° turn through north, not a 340° sweep
back through south. That's wrap: 360 on the animation:
const displayed = useAnimatedValue(heading, { wrap: 360 })Available on any gauge via animate={{ wrap: 360 }}. The tween runs on the
unwrapped line and only the emitted value is folded back into range, so the
motion stays continuous while the output never leaves [0, wrap).
RudderAngleGauge takes a single signed angle: negative to port, positive
to starboard: rather than the (value, side) pair the reference project used.
That pair can't express the transition through amidships as continuous motion:
going from 5° port to 5° starboard is a jump between two representations, so the
needle teleports across centre instead of sweeping through it.
For a feed that reports a magnitude and a side, convert once at the edge:
import { toSignedRudderAngle } from '@geomak/ui-gauges/marine'
<RudderAngleGauge value={toSignedRudderAngle(22.5, 'PORT')} />Behaviour change from the reference. Its angle table gave starboard 125° of total travel and port 120°, with the per-segment compression differing between sides and not varying monotonically. That reads as hand-tuning that drifted rather than a deliberate scale, and an asymmetric rudder indicator misreports how hard the vessel is turning depending on which way it turns, so it's now symmetric by construction.
expandedScalegives the inner quarter of deflection half the angular travel: what that table appeared to be reaching for: as one symmetric piecewise scale.
Port red and starboard green come from --gauge-port / --gauge-starboard
rather than the severity tokens. It's a navigation convention (COLREGs
sidelights), not a warning, a red port indicator doesn't mean anything is
wrong.
3D
@geomak/ui-gauges/three projects the same scales and the same angular
convention onto three.js objects, so a 3D instrument and its 2D counterpart
driven by one scale can't drift out of agreement.
import { useGaugeSpin, useGaugeRotation } from '@geomak/ui-gauges/three'
// Continuous rotation at a rate proportional to the reading
const ref = useGaugeSpin({ value: rpm, max: 12_000, maxRpm: 240 })
// Positional rotation from a scale, shared with the on-screen 2D gauge
const rotation = useGaugeRotation({ value: pressure, scale: pressureScale })Requires three, @react-three/fiber and @react-three/drei as peer
dependencies.
VesselTachometer lives in /marine alongside the other bridge instruments,
and a 2D-only import doesn't pay for three.js. Measured with esbuild against a
real consumer bundle:
| Import from @geomak/ui-gauges/marine | Minified |
|---|---|
| CompassGauge + RudderAngleGauge | ~15 KB |
| VesselTachometer | ~985 KB |
That's tree-shaking, not code-splitting, the package ships ESM and declares
itself side-effect free, so bundlers drop the unused export with its imports. A
CJS require of the subpath gets everything, three.js included.
3D assets
Models are fetched at runtime from an asset server; nothing is bundled. Point the library at yours once, near the root:
import { GaugeAssetProvider, GAUGE_MODELS } from '@geomak/ui-gauges/three'
<GaugeAssetProvider baseUrl="https://assets.example.com/gauges/v1/">
<App />
</GaugeAssetProvider>References resolve in three ways, checked in order:
| Reference | Behaviour |
|---|---|
| https://…, //…, data:, blob: | used as-is, no provider needed |
| /models/rotor.glb | used as-is, served by your own app |
| marine/propeller.glb | joined to baseUrl |
A bare reference with no base configured throws with guidance, rather than requesting a malformed URL that surfaces later as an opaque network error.
GAUGE_MODELS is a manifest of paths, not URLs, the library defines the
layout, each deployment supplies the host. Mirror it on your server, or ignore
it and pass absolute URLs.
Without a model, rotors are drawn procedurally from three.js primitives: no
asset server to stand up, nothing to download, and no third-party model licence
to carry.
That same rotor covers both failure modes: a model still downloading (via
Suspense) and one that fails outright, a 404, a CORS rejection, a
malformed file, via an error boundary. Suspense alone only handles the first;
an unhandled throw inside a Canvas takes the surrounding UI with it, and a
missing decorative asset should never do that.
The reference project loaded its propeller from
${process.env.PUBLIC_URL}/…, a Create React App build-time substitution that resolves to nothing inside a published package, the model would have 404'd for every consumer. Its model was also CC-BY-4.0, which requires attribution wherever it's displayed; host your own copy and carry its attribution, or use the procedural rotor.
Fire
@geomak/ui-gauges/fire is the one subpath that carries a model rather
than only rendering. Give it the conditions a weather feed and a DEM produce,
and it runs the Rothermel surface fire model in the browser to get the answer.
import { FireSpreadGauge, WIND_ADJUSTMENT } from '@geomak/ui-gauges/fire'
<FireSpreadGauge
conditions={{
fuel: 'FM2',
moisture: { dead1h: 0.06, dead10h: 0.07, dead100h: 0.08, liveHerb: 0.9, liveWoody: 1.2 },
windSpeed: 18,
windHeading: 225,
windUnit: 'km/h',
windAdjustment: WIND_ADJUSTMENT.open,
slope: 0.25,
aspect: 90,
}}
/>The whole kernel costs about 12 microseconds for one location, so there is nothing to gain by moving it to a server. The physics is exported separately from the components, so it is usable headlessly:
import { predictSpread, fuelModel, perimeterSeries, burnedArea } from '@geomak/ui-gauges/fire'
const prediction = predictSpread({ fuel: fuelModel('FM2')!, moisture, windSpeed, windHeading, slope, aspect })
perimeterSeries(origin, prediction!, [15, 30, 60]) // GeoJSON rings for a map
burnedArea(prediction!, 60) / 10_000 // hectares after an hourpredictSpread returns SpreadPrediction | null and never NaN. A fire that
cannot be predicted and a fire that is not spreading look identical once drawn,
and mean opposite things, so the gauge renders the difference explicitly.
The perimeter functions have no mapping dependency: they return plain numbers and GeoJSON-ordered pairs, so they work with Leaflet, OpenLayers, MapLibre or Turf. The Examples stories demonstrate that with Leaflet, which is a dev dependency and never enters the package.
This is a surface fire model over temperate fuel beds. It does not represent crown fire, spotting, or the subsurface smouldering that governs tropical peatland fire, where behaviour follows water table depth rather than wind and fuel.
Theming
Gauge tokens layer on top of @geomak/ui's and resolve to them by default, so
gauges match your app without configuration. Override any of them in your own
:root:
:root {
--gauge-fill: var(--color-accent);
--gauge-needle: var(--color-foreground);
--gauge-nominal: var(--color-success);
--gauge-caution: var(--color-warning);
--gauge-critical: var(--color-error);
/* Numeric readout font. Defaults to the system tabular stack. */
--gauge-font-readout: 'Orbitron', monospace;
}Reference them from TypeScript with gaugeVars from
@geomak/ui-gauges/tokens so a rename is a compile error rather than a
silently-unstyled arc.
Fonts are not bundled. Digital-7's common distributions are freeware for personal use only, which is incompatible with an MIT package, and shipping any binary font forces a loading strategy on you. Orbitron is SIL OFL and safe to self-host: load the
@font-faceyourself and point--gauge-font-readoutat it.
Responsive sizing
Gauges are SVG, so they scale to their container with no adaptation. <Gauge>
renders width: 100%; height: auto, keeping its aspect ratio: put it in a box
and it fills it:
<div style={{ maxWidth: 280 }}>
<RadialGauge value={rpm} max={8000} label="Engine RPM" />
</div>Nothing is hidden or rearranged at smaller sizes; the whole face just gets
smaller. That's verified, not assumed: yarn audit:layout checks every story
at 375px, 768px and 1280px for text collisions, viewBox overflow, and
undersized touch targets.
Minimum sizes for interactive gauges. A gauge you can press needs enough room for its targets to clear the 24x24 CSS pixel minimum (WCAG 2.5.8). The limit is angular, so it depends on how many positions the control has:
| Control | Minimum rendered width |
|---|---|
| EngineTelegraph (11 sectors) | ~240px |
| BarometerGauge set-hand button | ~180px |
| Display-only gauges | no minimum, they simply shrink |
Below those, stack or wrap rather than scaling down, an eleven-position dial squeezed to half a phone screen has sectors barely 17px of arc, and no geometry change fixes that.
Accessibility
Every gauge exposes role="meter" with aria-valuenow / valuemin /
valuemax, and an aria-valuetext carrying the formatted reading. Pass a
label: without one, a screen reader announces a bare number with no
indication of what it measures.
The announced value is the settled target, not the in-flight animated one, so assistive tech isn't read a sweep of intermediate numbers.
GaugeHotspot regions are real buttons: focusable, Enter/Space activated, and
named.
Value animation honours prefers-reduced-motion: reduce and snaps instantly.
Robustness
Gauges are displays, so they degrade rather than throw. NaN, Infinity,
null and undefined readings: all of which real telemetry produces: clamp
to the scale floor and let the surrounding UI decide how to flag staleness.
Development
yarn install
yarn storybook # component explorer on :6007
yarn test # vitest
yarn ci # typecheck, lint, prose, snippets, props, tests, buildThe audits
Beyond the test suite, several checks run against a real browser because they cannot be done any other way:
| Command | Catches |
|---|---|
| yarn audit:layout | Text collisions and viewBox overflow at three widths. Needs real font metrics, which jsdom does not have. |
| yarn audit:docs | A docs page that compiles but renders empty, and markdown tables that never became tables. |
| yarn audit:controls | A story whose control panel is present but inert. |
| yarn audit:snippets | Every tsx fence in the guides, compiled against src. |
| yarn audit:props | Props tables that disagree with the actual prop types. |
| yarn audit:prose | Banned characters in anything a reader sees. |
Each one exists because the failure it catches is invisible: the build stays green, the page still renders, and only a reader finds out.
Publishing and deployment
See DEPLOYMENT.md. In short: semantic-release publishes the
package from main based on commit messages, and Netlify deploys the Storybook
to gauges.oxygenui.com from the same push.
License
MIT
