Skip to content
Chromatic Coherence

Hue draws · The library

Charts grown from code.

One recipe, and the library picks the machine. It probes what the browser can do, budgets live GL contexts across the page, and falls back per chart — so the same recipe runs on a GPU or a 2D canvas and a failed renderer never takes a page down.

Install

npm i @hue/chartsv1.0.0

Zero dependencies. ESM only. Types are in the package.

The public surface

Measured with the TypeScript checker against the published types, not counted by eye — a star re-export cannot be read with a search.

Public exports

81

Values

53

Types

28

Build a chart

createChart
export declare function createChart(o: ChartOpts): ChartWorld

THE FRONT DOOR — build a chart and let the library pick the machine.

capturePoster
export declare function capturePoster(o: Omit<ChartOpts, "canvas" | "controls" | "maxGpu">, build: (w: ChartWorld) => void, width: number, height: number, t?: number, /** * Frame the camera on the drawing before the frame is taken. * * ⚠ `at` DEFAULTS TO `t` HERE, not to 0 — a poster is a picture of the settled chart, so the clock * it is fitted at should be the clock it is captured at. Getting th…

Render a chart's poster frame OFF-SCREEN and return it as a PNG data URL — the placeholder-image pattern: show it in an `<img>` at page load and build the live chart only when the window scrolls into view, so a page of many charts spends no live GL context until it needs one. The scratch world renders on a hidden staged canvas (sizing reads `clientWidth`, so it must be in the DOM) and its context is released before returning.

ChartOpts
export type ChartOpts = { canvas: HTMLCanvasElement; /** "auto" (default): GPU when available and under budget, CPU otherwise. * "3d": prefer GPU, still falls back to CPU rather than fail. * "2d": CPU, always. Read `world.mode` for what you actually got. */ renderer?: "auto" | ChartMode; /** page-wide budget of live GPU charts (default 5) — browsers cap live WebGL * contexts per page; charts over …

Everything `createChart` accepts. Only `canvas` is required; every other field has a stated default carried on its own doc below.

ChartWorld
export interface ChartWorld { /** Which renderer this world actually runs on. * ⛔ THIS SAID "set by createChart" AND THAT WAS THE DEFECT, NOT THE DESIGN. It really was set * by `createChart`, which meant a direct `createWorld2d`/`createWorld3d` caller got `undefined` * while this interface promised a value. Each engine now declares its own. */ readonly mode: ChartMode; cam: W2D["cam"]; light: { di…

The common surface both renderers satisfy — what a chart recipe writes against.

ChartMode
export type ChartMode = "2d" | "3d"

Which renderer a chart is actually running on — `"2d"` is the CPU canvas, `"3d"` the WebGL one. Read it back off `world.mode` after `createChart`: with `renderer: "auto"` (the default) the answer is decided at build time by GPU availability and the page-wide budget, so it is a RESULT, never a setting.

Pick the machine

gpuAvailable
export declare function gpuAvailable(): boolean

Is a WebGL context obtainable right now? Probed on a scratch canvas (freed at once), so the caller's canvas is never touched by a failed attempt.

gpuInUse
export declare function gpuInUse(): number

How many GPU chart worlds are currently live on this page (budget bookkeeping).

inkFlip
export declare function inkFlip(r: number, g: number, b: number): [number, number, number]

⛔ IT IS NOT `1 − rgb`. The complement does invert lightness, but it also rotates hue by 180° — red comes back cyan — which is why the obvious one-liner is wrong.

inkFlipCss
export declare function inkFlipCss(color: string): string

The same rule on a CSS colour STRING — the form the CPU renderer works in.

The two engines direct

createWorld2d
export declare function createWorld(o: WorldOpts): World

Build a CPU-renderer {@link World} directly. Exported openly — the facade is the front door, not a wall — but prefer `createChart({ renderer: "2d" })` from the package root unless you specifically want to bypass renderer selection and the GPU budget.

World2D
export declare class World implements ChartWorld { /** Which renderer this is. ⛔ DECLARED HERE FROM 2026-09-01, AND IT USED TO BE BOLTED ON AFTER * CONSTRUCTION BY THE FACADE (`index.ts`'s `tag()`), which ran only via `createChart`. So a * caller who took the documented direct route — `createWorld2d`, "the facade is the front door, * not a wall" — got a world whose `.mode` was **undefined**, and `…

THE CPU RENDERER — the same recipe vocabulary as the 3D world, drawn with additive compositing on a plain 2D canvas. It runs anywhere, needs no GL context, and is what `createChart` falls back to when the GPU is unavailable or the page is over its context budget.

WorldOpts2D
export type WorldOpts = { canvas: HTMLCanvasElement; maxDpr?: number; drift?: number; parallax?: number; controls?: boolean; occludeLight?: boolean; shadowPlane?: number; mirrorPlane?: number; mirrorAlpha?: number; /** v1.1 — THE INK EDITION: theme "light" renders the same recipe on paper instead of night. * One rule, applied everywhere: lightness inverts (the dark ground becomes paper, bright * l…

Everything this renderer's `createWorld` accepts. Only `canvas` is required; the trailing comment on each field carries its default. Most callers should use `createChart` from the package root instead and let the facade pick the renderer.

GrowFn2D
export type GrowFn = (t: number, dt: number, w: World) => void

A per-frame callback: elapsed seconds `t`, seconds since the last frame `dt`, and the world. ⚠ `settle(t)` calls this with `dt = 0`, so a recipe that INTEGRATES will render a different still than the live view at the same `t`. Write scene content as a function of `t`.

FaceOpts2D
export type FaceOpts = { hue: number; sat?: number; light?: number; alpha?: number; group?: string; }

no note in the package

createWorld3d
export declare function createWorld(o: WorldOpts): World

Build a GPU-renderer {@link World} directly. Exported openly — the facade is the front door, not a wall — but prefer `createChart` from the package root: it budgets live GL contexts page-wide and falls back to the CPU renderer rather than throwing when one cannot be had.

World3D
export declare class World implements ChartWorld { /** Which renderer this is. See `engine2d.ts`'s note: this was bolted on by the facade until * 2026-09-01, so a direct `createWorld3d` caller had `.mode === undefined` while `ChartWorld` * promised otherwise. Found by the compiler the moment `implements` replaced the cast. */ readonly mode: "3d"; private canvas; private gl; private overlay; privat…

THE GPU RENDERER — the same recipe vocabulary as the CPU world, drawn through WebGL with real lighting and depth. Richer, and it costs a live GL context, which browsers cap per page.

WorldOpts3D
export type WorldOpts = { canvas: HTMLCanvasElement; maxDpr?: number; drift?: number; parallax?: number; controls?: boolean; shadows?: boolean; /** v0.13 — THE INK EDITION: theme "light" renders the same recipe on paper instead of night. * One rule, applied everywhere: lightness inverts (the dark ground becomes paper, bright * light becomes ink), the light pass blends normally instead of additivel…

Everything this renderer's `createWorld` accepts. Only `canvas` is required. Most callers should use `createChart` from the package root instead and let the facade pick the renderer.

GrowFn3D
export type GrowFn = (t: number, dt: number, w: World) => void

A per-frame callback: elapsed seconds `t`, seconds since the last frame `dt`, and the world. ⚠ `settle(t)` calls this with `dt = 0`, so a recipe that INTEGRATES will render a different still than the live view at the same `t`. Write scene content as a function of `t`.

FaceOpts3D
export type FaceOpts = { hue: number; sat?: number; light?: number; alpha?: number; group?: string; /** v0.5 — per-face material gloss 0..1 (multiplies the specular): skin ~0.5, cloth ~0.05. */ gloss?: number; /** v0.8 — per-face perfusion 0..1: blood near the surface. Ruddiness + red subsurface bleed; * the world's bloodPulse rides on it. Skin ~0.1; a worked muscle flushes higher. */ blood?: numb…

no note in the package

Framing

Bounds
export declare class Bounds { min: V3; max: V3; n: number; note(p: V3): void; get empty(): boolean; centre(): V3; /** The eight corners of the model-space box. * * ⚠ CORNERS, NOT THE REAL HULL — so the fit is a slight OVER-estimate of the drawing's screen * size and lands a little smaller than the target. That direction is the safe one: a chart * framed 2% loose is a chart; a chart framed 2% tight…

The running model-space bounds of everything a world has been asked to draw.

ScreenBox
export declare class ScreenBox { x0: number; y0: number; x1: number; y1: number; n: number; /** * How many points could not be projected — behind the near plane. * * ⛔ THIS IS A RUNAWAY GUARD, AND WITHOUT IT THE FIT EATS ITSELF. A dropped point makes the * measured box SMALLER, which makes the fit pull the camera in FURTHER, which pushes more points * behind the near plane. The static path already…

A screen-space box, accumulated in px.

Basis
export type Basis = { right: V3; up: V3; forward: V3; focal: number; }

The frame's camera basis, as both engines can report it.

Projected
export type Projected = { x: number; y: number; s: number; z: number; }

A projected point. ⚠ `s` IS THE PER-UNIT SCREEN SCALE AT THAT POINT (`focal / z`), which is what makes a depth-scale reading possible at all — measured 2026-09-01 across the shipped corpus: median depth error 0.07%, and 15 of 31 charts exactly 0 because they draw in one z-plane.

FitOpts
export type FitOpts = { /** the fraction of the available box (plate minus gutter) the drawing should span. Default 0.86 */ span?: number; /** px of edge space to keep clear. Default none */ gutter?: Gutter; /** * the clock to measure at. Default 0. * * ⚠ THIS MATTERS MORE THAN IT LOOKS. Most recipes grow — at `t = 0` a column chart is a flat line * and a donut is nothing at all. Fit at 0 and you …

How a drawing is fitted into its plate — how much of the box to span, what edge space to keep, and the aspect discipline. All optional with stated defaults; the point is that a recipe states its intent once and the fit survives a resize rather than being recomputed by the caller.

FitResult
export type FitResult = { ok: boolean; /** why the fit refused — present exactly when `ok` is false */ reason?: string; /** the distance in force after the call. On a refusal this is the caller's own, unchanged */ dist: number; /** the camera target in force after the call */ target: V3; /** what fraction of the plate the drawing spans, as finally measured */ span: { x: number; y: number; }; /** h…

What a fit attempt returns. ⛔ A REFUSAL IS DATA, NOT A THROW — check `ok` and read `reason`. On a refusal the camera fields carry the caller's own UNCHANGED values, so a caller that applies them regardless is left exactly where it was rather than somewhere wrong.

Gutter
export type Gutter = { top?: number; right?: number; bottom?: number; left?: number; }

Reserved edge space, in CSS px, that the drawing must stay out of — axis labels live here.

The shared maths

v3
v3: (x?: number, y?: number, z?: number) => V3

no note in the package

add
add: (a: V3, b: V3) => V3

no note in the package

sub
sub: (a: V3, b: V3) => V3

no note in the package

scale
scale: (a: V3, s: number) => V3

no note in the package

dot
dot: (a: V3, b: V3) => number

no note in the package

cross
cross: (a: V3, b: V3) => V3

no note in the package

len
len: (a: V3) => number

no note in the package

norm
norm: (a: V3) => V3

no note in the package

TAU
TAU: number

no note in the package

clamp
clamp: (v: number, lo: number, hi: number) => number

no note in the package

lerp
lerp: (a: number, b: number, u: number) => number

no note in the package

easeInOut
easeInOut: (u: number) => number

no note in the package

rnd
export declare function rnd(i: number, k: number): number

Deterministic pseudo-random in [0,1) — a fixed field that doesn't churn on resize.

V3
export type V3 = { x: number; y: number; z: number; }

no note in the package

Drawing options

FaceOpts
export type FaceOpts = { hue: number; sat?: number; light?: number; alpha?: number; group?: string; gloss?: number; blood?: number; }

FaceOpts across both renderers (gloss/blood are 3D-only material extras; 2D ignores them).

LineOpts
export type LineOpts = { hue: number; sat?: number; light?: number; alpha: number; group?: string; }

A line's ink. `light` is stated in the DARK edition and flipped at draw time (see `ink.ts`).

GlowOpts
export type GlowOpts = { hue?: number; white?: boolean; size: number; alpha: number; group?: string; }

A glow's ink and size. `white: true` takes the sheet's achromatic sprite.

PointLight
export type PointLight = { p: V3; intensity: number; falloff: number; }

A steerable place, not a direction: Lambert x inverse-square falloff, plus a facet glint.

Every option on createChart

The full set, with the types the package declares. Optional fields are marked; the notes are the library’s own, where it wrote one.

canvas
canvas: HTMLCanvasElement

no note in the package

rendereroptional
renderer?: "auto" | ChartMode

"auto" (default): GPU when available and under budget, CPU otherwise. "3d": prefer GPU, still falls back to CPU rather than fail. "2d": CPU, always. Read `world.mode` for what you actually got.

maxGpuoptional
maxGpu?: number

page-wide budget of live GPU charts (default 5) — browsers cap live WebGL contexts per page; charts over budget build on the CPU renderer instead.

maxDproptional
maxDpr?: number

no note in the package

driftoptional
drift?: number

no note in the package

parallaxoptional
parallax?: number

no note in the package

controlsoptional
controls?: boolean

no note in the package

themeoptional
theme?: "dark" | "light"

no note in the package

labelFontoptional
labelFont?: string

The CSS font `drawLabel` renders with, e.g. `"500 12px Inter, sans-serif"`.

cameraoptional
camera?: { target?: V3; dist?: number; pitch?: number; yaw?: number; fov?: number; }

no note in the package

occludeLightoptional
occludeLight?: boolean

no note in the package

shadowPlaneoptional
shadowPlane?: number

no note in the package

mirrorPlaneoptional
mirrorPlane?: number

no note in the package

mirrorAlphaoptional
mirrorAlpha?: number

no note in the package

shadowsoptional
shadows?: boolean

no note in the package

What a chart draws through

These are members of the chart world you get back, not separate imports. Both renderers satisfy the same interface, which is what lets one recipe run on either. Every signature below is the package’s own, read from the types it publishes.

The world you get

mode
readonly mode: ChartMode

Which renderer this world actually runs on. ⛔ THIS SAID "set by createChart" AND THAT WAS THE DEFECT, NOT THE DESIGN. It really was set by `createChart`, which meant a direct `createWorld2d`/`createWorld3d` caller got `undefined` while this interface promised a value. Each engine now declares its own.

theme
readonly theme: "dark" | "light"

Which edition is rendering right now — the counterpart to `mode`.

surface
readonly surface: HTMLCanvasElement
W
W: number
H
H: number
cam
cam: W2D["cam"]
light
light: { dir: V3; ambient: number; diffuse: number; signed: boolean; }
lights
lights: PointLight[]

Lifecycle

grow
grow(fn: (t: number, dt: number, w: ChartWorld) => void): void
start
start(): void
settle
settle(t: number): void
dispose
dispose(releaseContext?: boolean): void

Normalized across renderers: on the GPU renderer `releaseContext: true` also frees the WebGL context immediately (browsers cap live contexts page-wide; a releasing caller must mount a FRESH canvas to build again). The CPU renderer ignores it.

Geometry — laid once

face
face(a: V3, b: V3, c: V3, o: FaceOpts): void
line
line(a: V3, b: V3, o: LineOpts): void
glow
glow(p: V3, o: GlowOpts): void
dust
dust(p: V3, alpha: number): void

Drawn per frame

drawFace
drawFace(a: V3, b: V3, c: V3, o: FaceOpts): void
drawLine
drawLine(a: V3, b: V3, color: string, alpha: number, width?: number): void
drawPath
drawPath(pts: { p: V3; }[], color: string, alpha: number, width?: number): void
drawGlow
drawGlow(p: V3, hue: number | null, size: number, alpha: number): void
drawLabel
drawLabel(p: V3, text: string, color: string, alpha: number, dy?: number, size?: number): void

Text at a point.

Camera and space

project
project(p: V3): Projected | null
pick
pick(sx: number, sy: number, planeY?: number): V3 | null
fog
fog(z: number): number
fitToContent
fitToContent(o?: FitOpts): FitResult

FRAME THE CAMERA ON WHAT WAS ACTUALLY DRAWN — call it after your recipe has run.

Changed live

fade
fade(name: string, target: number): void
setTheme
setTheme(theme: "dark" | "light"): void

Switch editions live. `theme` used to be construction-only, so following a reader's dark/light toggle meant disposing the world and rebuilding on a fresh canvas — and on the GPU renderer, spending one of the browser's capped WebGL contexts to do it. Both renderers read the ink rule per frame, so this is cheap on either.

One canvas, one kind of context

Once a WebGL context has been bound to a canvas element, that element can never return a 2D one, and losing the context does not release the binding. After disposing, mount a fresh canvas to build again — and if you build inside a framework effect that can run twice, create the element inside the effect.

What it costs to run

Both packages carry their own frame-cost gate and publish measured figures with the conditions attached. Those numbers belong with their conditions, so they are not repeated here.

Licence · MIT