npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Downloads

305

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-gauges
import '@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-accent and 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 devicePixelRatio juggling, 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 primitives

Layer 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 rightward

Two 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 line

EngineTelegraph 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. expandedScale gives 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 hour

predictSpread 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-face yourself and point --gauge-font-readout at 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, build

The 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