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.0Zero 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
createChartexport declare function createChart(o: ChartOpts): ChartWorldTHE FRONT DOOR — build a chart and let the library pick the machine.
capturePosterexport 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.
ChartOptsexport 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.
ChartWorldexport 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.
ChartModeexport 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
gpuAvailableexport declare function gpuAvailable(): booleanIs 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.
gpuInUseexport declare function gpuInUse(): numberHow many GPU chart worlds are currently live on this page (budget bookkeeping).
inkFlipexport 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.
inkFlipCssexport declare function inkFlipCss(color: string): stringThe same rule on a CSS colour STRING — the form the CPU renderer works in.
The two engines direct
createWorld2dexport declare function createWorld(o: WorldOpts): WorldBuild 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.
World2Dexport 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.
WorldOpts2Dexport 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.
GrowFn2Dexport type GrowFn = (t: number, dt: number, w: World) => voidA 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`.
FaceOpts2Dexport type FaceOpts = { hue: number; sat?: number; light?: number; alpha?: number; group?: string; }no note in the package
createWorld3dexport declare function createWorld(o: WorldOpts): WorldBuild 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.
World3Dexport 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.
WorldOpts3Dexport 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.
GrowFn3Dexport type GrowFn = (t: number, dt: number, w: World) => voidA 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`.
FaceOpts3Dexport 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
Boundsexport 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.
ScreenBoxexport 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.
Basisexport type Basis = { right: V3; up: V3; forward: V3; focal: number; }The frame's camera basis, as both engines can report it.
Projectedexport 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.
FitOptsexport 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.
FitResultexport 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.
Gutterexport 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
v3v3: (x?: number, y?: number, z?: number) => V3no note in the package
addadd: (a: V3, b: V3) => V3no note in the package
subsub: (a: V3, b: V3) => V3no note in the package
scalescale: (a: V3, s: number) => V3no note in the package
dotdot: (a: V3, b: V3) => numberno note in the package
crosscross: (a: V3, b: V3) => V3no note in the package
lenlen: (a: V3) => numberno note in the package
normnorm: (a: V3) => V3no note in the package
TAUTAU: numberno note in the package
clampclamp: (v: number, lo: number, hi: number) => numberno note in the package
lerplerp: (a: number, b: number, u: number) => numberno note in the package
easeInOuteaseInOut: (u: number) => numberno note in the package
rndexport declare function rnd(i: number, k: number): numberDeterministic pseudo-random in [0,1) — a fixed field that doesn't churn on resize.
V3export type V3 = { x: number; y: number; z: number; }no note in the package
Drawing options
FaceOptsexport 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).
LineOptsexport 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`).
GlowOptsexport 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.
PointLightexport 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.
canvascanvas: HTMLCanvasElementno note in the package
rendereroptionalrenderer?: "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.
maxGpuoptionalmaxGpu?: numberpage-wide budget of live GPU charts (default 5) — browsers cap live WebGL contexts per page; charts over budget build on the CPU renderer instead.
maxDproptionalmaxDpr?: numberno note in the package
driftoptionaldrift?: numberno note in the package
parallaxoptionalparallax?: numberno note in the package
controlsoptionalcontrols?: booleanno note in the package
themeoptionaltheme?: "dark" | "light"no note in the package
labelFontoptionallabelFont?: stringThe CSS font `drawLabel` renders with, e.g. `"500 12px Inter, sans-serif"`.
cameraoptionalcamera?: { target?: V3; dist?: number; pitch?: number; yaw?: number; fov?: number; }no note in the package
occludeLightoptionaloccludeLight?: booleanno note in the package
shadowPlaneoptionalshadowPlane?: numberno note in the package
mirrorPlaneoptionalmirrorPlane?: numberno note in the package
mirrorAlphaoptionalmirrorAlpha?: numberno note in the package
shadowsoptionalshadows?: booleanno 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
modereadonly mode: ChartModeWhich 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.
themereadonly theme: "dark" | "light"Which edition is rendering right now — the counterpart to `mode`.
surfacereadonly surface: HTMLCanvasElementWW: numberHH: numbercamcam: W2D["cam"]lightlight: { dir: V3; ambient: number; diffuse: number; signed: boolean; }lightslights: PointLight[]Lifecycle
growgrow(fn: (t: number, dt: number, w: ChartWorld) => void): voidstartstart(): voidsettlesettle(t: number): voiddisposedispose(releaseContext?: boolean): voidNormalized 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
faceface(a: V3, b: V3, c: V3, o: FaceOpts): voidlineline(a: V3, b: V3, o: LineOpts): voidglowglow(p: V3, o: GlowOpts): voiddustdust(p: V3, alpha: number): voidDrawn per frame
drawFacedrawFace(a: V3, b: V3, c: V3, o: FaceOpts): voiddrawLinedrawLine(a: V3, b: V3, color: string, alpha: number, width?: number): voiddrawPathdrawPath(pts: { p: V3; }[], color: string, alpha: number, width?: number): voiddrawGlowdrawGlow(p: V3, hue: number | null, size: number, alpha: number): voiddrawLabeldrawLabel(p: V3, text: string, color: string, alpha: number, dy?: number, size?: number): voidText at a point.
Camera and space
projectproject(p: V3): Projected | nullpickpick(sx: number, sy: number, planeY?: number): V3 | nullfogfog(z: number): numberfitToContentfitToContent(o?: FitOpts): FitResultFRAME THE CAMERA ON WHAT WAS ACTUALLY DRAWN — call it after your recipe has run.
Changed live
fadefade(name: string, target: number): voidsetThemesetTheme(theme: "dark" | "light"): voidSwitch 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