@heojeongbo/fluxion-render
v1.6.0
Published
High-performance OffscreenCanvas rendering engine for real-time robotics data (charts, LiDAR, streaming).
Downloads
3,610
Maintainers
Readme
@heojeongbo/fluxion-render
High-performance OffscreenCanvas rendering engine for real-time data visualization.
Built for robotics and sensor systems: streaming line charts, LiDAR point clouds, and high-frequency data pipelines up to 120Hz+. Rendering runs entirely in Web Workers — the main thread is never blocked.
npm install @heojeongbo/fluxion-renderNeed time-travel replay? See
@heojeongbo/fluxion-replay— record any data stream and scrub back through the last N minutes, including video. Part of a three-package set: fluxion-worker ← fluxion-render ← fluxion-replay.
Requirements & entry points
ESM-only. The package ships ES modules (no CommonJS
requirebuild). Use a bundler or Node's native ESM.Browser baseline. Rendering uses
OffscreenCanvas+canvas.transferControlToOffscreen()in a Web Worker: Chrome/Edge 69+, Firefox 105+, Safari 16.4+. There is no main-thread fallback.Client-only. The engine touches
Worker,OffscreenCanvas, and (in/react) DOM refs — it does not run during SSR. In Next.js/Remix, render<FluxionCanvas>(and anyuseFluxion*hook) only on the client; server-render a placeholder. In Next.js the cleanest guard is a client-only dynamic import:// ChartPanel.tsx starts with 'use client' and imports from '@heojeongbo/fluxion-render/react' const ChartPanel = dynamic(() => import('./ChartPanel'), { ssr: false });In Remix wrap the chart in a
<ClientOnly>(fromremix-utils) or a mounted-state guard (const [m, setM] = useState(false); useEffect(() => setM(true), [])). Optionally feature-detecttypeof OffscreenCanvas !== 'undefined'before mounting to degrade gracefully on unsupported browsers.Import paths:
@heojeongbo/fluxion-render— framework-agnostic core:FluxionHost,FluxionWorkerPool, layer factories, protocol types. No React.@heojeongbo/fluxion-render/react— a superset of the core plus all hooks/components. React apps import from here (core types likeAreaChartConfigare re-exported, so one import is enough).@heojeongbo/fluxion-render/worker— for a custom worker script:Engine,Op/WorkerOp, and message types (HostMsg,EngineOutMsg, …).@heojeongbo/fluxion-render/testing— deterministic test helpers (flushLifecycleScheduler, signal synths).
Contents
- Quick Start · Worker Pool · Performance / many charts
- Layer Types — every streaming / static / robot layer and its config
- React API — hooks (
useFluxionCanvas,useFluxionStream, crosshair, table, …) + components - Vanilla JS API —
FluxionHost/FluxionWorkerPoolwithout React - Data Format · Custom Worker Script · Architecture
- Troubleshooting · Upgrading · Testing
Recipes — jump to the answer
| I want to… | Go to |
| --- | --- |
| Theme charts (light/dark toggle, FluxionThemeProvider) | Theming |
| Set app-wide defaults once (configureFluxionDefaults) | Theming → app-wide defaults |
| Render 100s of charts smoothly | Performance / many charts |
| Stop rendering scrolled-off charts | pauseWhenOffscreen |
| Speed up Firefox with the GPU backend | WebGL renderer |
| Reuse hosts under mount/unmount churn | recyclePool |
| Use it in Next.js / Remix (SSR) | Requirements & entry points |
| Use it without React (vanilla) | Vanilla JS API |
Features
- Worker Pool — charts share an adaptive pool that grows with load. Zero config required.
- Automatic load shedding — per-worker frame governors (JS budget + rAF cadence) and a main-thread flush governor degrade render rate gracefully under saturation instead of janking the whole browser. Nothing is dropped;
maxFpsremains the explicit ceiling. - Inline axes —
inlineAxesrenders axes into main-canvas margins: one compositor surface per chart (vs up to three with external axis canvases), the preferred mode for large grids - Pause off-screen charts —
pauseWhenOffscreenstops rendering scrolled-out charts via a shared IntersectionObserver; data keeps buffering, so scrolling back shows full history (no gap). The big win for tall scroll grids - WebGL renderer —
renderer: 'webgl'bypasses Firefox's fixed ~1 ms/render worker-canvas2d pipeline cost with GPU line/grid/label programs (−78 % worker busy at 60×25 Hz, 2.8× throughput at 200×60 Hz). Firefox-targeted; keep'2d'on Chromium (live-context cap) - Host recycling — reuse warm chart hosts across mount/unmount for churny UIs (virtualized lists, accordions) instead of paying create/destroy each time
- OffscreenCanvas — all rendering happens off the main thread
- Zero-copy data —
Float32Arrayownership is transferred to the worker, never copied - React integration — hooks and components included (
/reactsubpath) - Framework-agnostic core — use
FluxionHostdirectly without React
Quick Start
React (recommended)
import {
axisGridLayer,
lineLayer,
useFluxionCanvas,
useFluxionStream,
useTimeOrigin,
} from '@heojeongbo/fluxion-render/react';
function Chart() {
const timeOrigin = useTimeOrigin(); // stable Date.now() snapshot from first render
const { containerRef, host } = useFluxionCanvas({
layers: [
axisGridLayer('axis', {
xMode: 'time',
timeWindowMs: 5000,
timeOrigin,
yMode: 'auto',
}),
lineLayer('signal', { color: '#4fc3f7', lineWidth: 1.5, capacity: 4096 }),
],
});
useFluxionStream({
host,
intervalMs: 1000 / 60,
setup: (h) => h.line('signal'),
tick: (tMs, handle) => {
handle.push({ t: tMs, y: Math.sin(tMs / 500) });
return 1;
},
});
return <div ref={containerRef} style={{ width: '100%', height: 300 }} />;
}Even simpler: useSimpleChart
For the common "just show me live data" case, useSimpleChart bundles the time
origin, the axis-grid + line pair (capacity auto-sized from hz + windowMs),
and the stream pump behind a single sample callback:
import { FluxionCanvas, useSimpleChart } from '@heojeongbo/fluxion-render/react';
function Live() {
const { layers, setHost } = useSimpleChart({
hz: 60,
windowMs: 5000,
color: '#4fc3f7',
sample: (t) => Math.sin(t / 500), // y at host-relative t (ms)
axis: { gridDashArray: [3, 3] }, // optional theme overrides
});
return <FluxionCanvas layers={layers} onReady={setHost} style={{ height: 300 }} />;
}Multiple series? useMultiSeriesChart takes a series: { id, color, sample }[]
and fans each tick out to every line — no manual layers/setup/tick triple-edit.
(Changing the number of series at runtime needs a <FluxionCanvas key={...}>
remount — config changes are reconciled, structural ones aren't.)
const { layers, setHost } = useMultiSeriesChart({
hz: 60,
windowMs: 5000,
distinguishBy: 'dash', // ← solid / dashed / dotted across the series
series: [
{ id: 'a', color: '#4fc3f7', sample: (t) => Math.sin(t / 500) },
{ id: 'b', color: '#ffb060', sample: (t) => Math.cos(t / 400) },
],
});
return <FluxionCanvas key={2} layers={layers} onReady={setHost} />;Overlapping series? When values sit on top of each other (flat or
slowly-varying signals), color alone can't separate the lines. distinguishBy
keeps them readable, deterministically (no runtime overlap detection):
distinguishBy: 'dash'— each series gets a distinct dash pattern, cycling the exportedDASH_PATTERNSpalette (dashPatternFor(i)). Honest about position.distinguishBy: 'offset'(withoffsetStepin data units) — spreads the series vertically (waterfall), lifting series i byi * offsetStep.- Combine:
distinguishBy: ['dash', 'offset'].
Both are color-independent and skip any series that sets the matching field
itself (dashArray / yOffset). It's pure styling — hover, export, and the
underlying samples are unaffected; with 'offset', auto-scaling grows to fit
the shifted lines so nothing clips. (You can also set dashArray / yOffset
directly on any lineLayer / areaLayer / stepLayer.)
Heavily overlapping? Use lanes. 'offset' keeps one shared y-axis, so a
big spread makes the axis labels misleading. For genuinely overlapping streams,
layout: 'lanes' draws each series in its own horizontal band, auto-
normalized to its own range (small multiples / ECG style) — there is no
shared y-axis to lie about. The helper suppresses the y grid/labels and ignores
offset in this mode (dash still works per lane).
const { layers, setHost } = useMultiSeriesChart({
hz: 60, windowMs: 5000, layout: 'lanes',
series: [
{ id: 'a', color: '#4fc3f7', sample: (t) => Math.sin(t / 500) },
{ id: 'b', color: '#ffb060', sample: (t) => 0.5 + Math.sin(t / 510) * 0.02 },
],
});Low-level: set laneIndex / laneCount (+ optional laneGapPx) on any
lineLayer / areaLayer / stepLayer to band it yourself.
Dash palette (DASH_PATTERNS / dashPatternFor)
distinguishBy: 'dash' cycles a deterministic 5-entry palette. Import it to set
dashArray on a layer by hand, or to mirror the palette in a legend:
import { DASH_PATTERNS, dashPatternFor } from '@heojeongbo/fluxion-render';
dashPatternFor(i); // → a fresh copy of DASH_PATTERNS[i % 5], safe to pass to a config| i | pattern | look |
|----|---------|------|
| 0 | [] | solid |
| 1 | [6, 4] | dashed |
| 2 | [2, 3] | dotted |
| 3 | [10, 4, 2, 4] | dash-dot |
| 4 | [8, 3] | long dash |
DASH_PATTERNS is readonly; dashPatternFor(i) returns a mutable copy so it
can be handed straight to lineLayer({ dashArray }).
Vanilla JS
import { FluxionHost } from '@heojeongbo/fluxion-render';
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const host = new FluxionHost(canvas, { bgColor: '#0b0d12' });
host.addLayer('axis', 'axis-grid', { xMode: 'time', timeWindowMs: 5000, yMode: 'auto' });
const line = host.addLineLayer('signal', { color: '#4fc3f7', capacity: 4096 });
const t0 = Date.now();
setInterval(() => {
line.push({ t: Date.now() - t0, y: Math.sin(Date.now() / 500) });
}, 1000 / 60);Worker Pool
Every FluxionHost automatically uses a shared module-level pool — no setup
needed. Mounting 60 charts creates 60 hosts but only a handful of OS threads.
The default pool is adaptive: it starts small (2 workers) and grows on
demand toward a cap of min(16, hardwareConcurrency − 1) as charts mount, with
targetPerWorker tuned low because render-heavy charts benefit from spreading
across more threads. (Previously the default was a fixed 4 workers.)
// No config — workers shared automatically, pool grows as charts mount
<FluxionCanvas layers={[...]} />
<FluxionCanvas layers={[...]} />
// ... 60 of these all share the same adaptive poolAdjust the pool (call before creating any host):
import { configureDefaultPool } from '@heojeongbo/fluxion-render';
configureDefaultPool({ size: 2 }); // fixed 2-worker pool
configureDefaultPool({ size: 2, maxSize: 8 }); // start at 2, grow to 8 on demandgetDefaultPool() returns the current singleton pool (lazily created on first
use), and configureDefaultPool({ size?, maxSize?, targetPerWorker?, workerFactory? })
replaces it (disposing the old one) — call it before creating any host.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| size | number | 2 (default pool) | Initial worker count, clamped [1, 16] |
| maxSize | number | = size (growth off) | Upper bound for runtime growth. When > size, the pool starts at size and spawns more on demand (up to maxSize) as average active hosts per worker reaches targetPerWorker. Clamped [size, 16] |
| targetPerWorker | number | 12 (min 1) | Active hosts per worker that triggers growth toward maxSize. Lower it for heavier per-host workloads (e.g. high-Hz streaming charts) so hosts spread across more workers sooner. Only matters when maxSize > size |
Since the pool can't observe per-stream Hz, targetPerWorker is the tuning
knob: high-Hz apps set it lower for more headroom per worker.
Scoped pool (React) — useful when a page needs its own isolated pool:
import { useFluxionWorkerPool, FluxionCanvas } from '@heojeongbo/fluxion-render/react';
function Dashboard() {
const pool = useFluxionWorkerPool({ size: 4 }); // disposed on unmount
return (
<>
{charts.map((id) => (
<FluxionCanvas key={id} hostOptions={{ pool }} layers={[...]} />
))}
</>
);
}Custom worker factory — bypasses the pool entirely (solo mode):
const host = new FluxionHost(canvas, {
workerFactory: () => new Worker('/my-worker.js', { type: 'module' }),
});Performance / many charts
FluxionHostOptions (the second arg to new FluxionHost, also passed as
hostOptions to <FluxionCanvas> / useFluxionCanvas) carries the throughput
knobs below. The defaults already coalesce and decimate, so a grid of high-rate
streams is cheap out of the box; the rest are opt-outs for niche cases.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| coalesce | boolean | true | Coalesce high-frequency per-sample handle push() calls into one Op.DATA message per layer per animation frame instead of one postMessage per sample. Cuts postMessage volume from O(samples/sec) to O(layers × fps) and removes the per-sample Float32Array(2) allocation — essential for many high-rate (e.g. 500 Hz) streams. Adds up to one frame (~16 ms) of latency. Only the streaming handles' push() fast-path is coalesced; raw pushData, pushBatch, and the replace-style set* calls always post immediately (and first flush any pending staged data for that layer, preserving order). Set false to restore immediate per-sample posting |
| coalesceMaxFloats | number | 1_000_000 | Backpressure cap on staged Float32 elements per layer between flushes; on overflow the layer flushes immediately (never drops samples) |
| maxFps | number | uncapped | Cap the worker engine's render rate. For a large grid of streaming charts sharing a worker, capping to e.g. 30 roughly halves worker scan+draw CPU and is visually indistinguishable for a scrolling time window. Skipped frames keep pending data — nothing is dropped |
| emitBounds | boolean | true | Whether the worker posts BOUNDS_UPDATE to the main thread on auto y-bounds change. Set false when nothing consumes onBoundsChange / getMetrics().bounds (e.g. a thumbnail grid) to skip the per-frame postMessage |
| emitTicks | boolean | true | Whether the worker posts TICK_UPDATE for React-side axis rendering. Only relevant with externalAxes={false} and no onTickUpdate consumer; set false to skip per-frame tick computation + postMessage. No effect when axis canvases render in the worker (externalAxes / xAxisElement / yAxisElement) |
| transparent | boolean | false | Keep the canvas's alpha channel so the page shows through where the chart doesn't paint. Default false (opaque): the engine fills bgColor every frame, so an opaque 2D context (alpha: false) composites faster — a real win for a wall of many charts. Set true only if you use a translucent bgColor and want the page visible behind the plot |
| emitRenderStats | boolean | false | Diagnostics opt-in (not a throughput knob): periodically post worker-side render load to onRenderStats for a perf HUD. Off by default — zero overhead. See Diagnostics |
| inlineAxes | boolean | false | Reserve yAxisWidth/xAxisHeight margins INSIDE the main canvas and let the worker draw axis ticks/labels there — ONE canvas surface per chart instead of up to three. See Inline axes. Construction-fixed (part of the recycle key); mutually exclusive with xAxisElement/yAxisElement |
| renderer | '2d' \| 'webgl' | '2d' | Paint backend for the worker engine. 'webgl' bypasses Firefox's worker-canvas2d pipeline (a measured fixed ~0.5–1.2 ms cost per render, regardless of content) and draws lines/grid/labels with GPU programs instead — ~4–30× less worker busy time per render on Firefox. See WebGL renderer. Construction-fixed (part of the recycle key) |
(Plus bgColor, pool, workerFactory covered above.)
Putting it together for a wall of charts. The adaptive default pool spreads
hosts across workers as charts mount; coalesce (on by default) collapses
per-sample posts to one message per layer per frame; per-layer decimate (auto)
makes each draw O(width) rather than O(samples); yMode: 'auto' tracks each
layer's visible-window min/max with a sliding-window deque (O(log n) per frame)
instead of rescanning the ring, so auto-scaling cost stays flat as the retained
window grows; and maxFps caps the shared worker's frame rate. On top of the
explicit knobs, the engine load-sheds automatically under saturation: each
worker's shared frame loop throttles itself when its per-frame render budget is
exceeded or its rAF delivery degrades, and the main-thread flush frame sheds
the data cadence when the page's own rAF slows under compositor pressure (the
many-canvas present flood). Both engage only under overload, keep skipped
frames' data latched (nothing is dropped), and back off in steps once the
system recovers — maxFps remains the deterministic ceiling when you want a
guaranteed rate. For a read-only
thumbnail grid, also set
emitBounds: false / emitTicks: false to drop the per-frame bookkeeping
postMessages. And when the grid scrolls (most charts out of view at any
moment), add pauseWhenOffscreen — off-screen charts stop rendering entirely
while still buffering data (see Pausing off-screen charts),
the single biggest lever for a tall scroll grid:
<FluxionCanvas
pauseWhenOffscreen // stop rendering scrolled-out charts
hostOptions={{ coalesce: true, maxFps: 30, emitBounds: false, emitTicks: false }}
layers={[
axisGridLayer('axis', { xMode: 'time', timeWindowMs: 5000, yMode: 'auto' }),
lineLayer('s', { color: '#4fc3f7', maxHz: 500 }), // decimate auto-engages
]}
/>Robustness. A single throwing frame or listener no longer permanently stops an engine: the worker render loop isolates the error (logs it and keeps the loop running), and the bounds/tick/metrics emitters and the shared streaming ticker isolate a throwing listener so it can't skip the others.
Mount/unmount churn is safe. Charts can be freely mounted and unmounted in
bulk — accordions, tabs, virtualized grids — without leaking GPU memory. Each
host's OffscreenCanvas backing store is released on dispose() (the canvas is
shrunk to 0×0 immediately rather than waiting for garbage collection), so
rapidly opening/closing a section of many charts can't accumulate orphaned GPU
surfaces and exhaust the context budget.
Staggered mounts are on by default (staggerMount). Mounting many charts in
a single frame — an accordion section expanding, a grid appearing — would run
every host's transferControlToOffscreen + worker init + first render at once,
spiking the main thread. To prevent that, <FluxionCanvas> and
useFluxionCanvas defer host creation through a shared frame-throttled queue by
default: the placeholder <canvas> is attached immediately, but the host spins
up on a later frame, so a burst spreads out instead of landing in one frame.
host / onReady therefore arrive one frame deferred (even for a lone
chart) — always read the host from onReady, never synchronously after mount.
Pass staggerMount={false} to opt out (synchronous creation) — e.g. when you
must call getHost() imperatively the moment the chart mounts. Tune the rate
globally:
import { configureLifecycleScheduler } from '@heojeongbo/fluxion-render/react';
configureLifecycleScheduler({
perFrame: 6, // host creations/teardowns per frame (default 4)
resizePerFrame: 12, // host resizes applied per frame (default 8)
});
<FluxionCanvas layers={[/* … */]} hostOptions={{ pool }} /> // staggered (default)
<FluxionCanvas staggerMount={false} layers={[/* … */]} /> // synchronous (opt out)A chart unmounted before its turn in the queue is simply dropped — it never creates a host, and a host that was created is always disposed on unmount (GPU backing released), so rapid mount/unmount churn leaks nothing.
Because the host only spins up on a later frame, a deferred chart receives no
stream data until it mounts — anything pushed in the meantime had no engine to
land in. This is most visible with a shared broadcast feed (one packet fans out
to every chart each tick): a late-mounting chart starts empty and fills forward
from its mount moment rather than showing the history that already streamed past.
If a chart must look full the instant it appears, backfill the trailing window in
onReady from history you retained (or, for a synthetic/recomputable source,
regenerate it) and push it once with handle.pushBatch(...) — or
handle.reset(latestT) then pushBatch(...) to also rewind the time axis. The
backfill and the live stream share one ring and merge in push order, so keep the
backfilled timestamps <= the next live sample.
Resize bursts are frame-budgeted too. One layout change — a split-pane drag,
a window resize, a devicePixelRatio flip — fires every chart's ResizeObserver
in the same tick, and each resize reallocates up to three GPU backing stores
(main + axis canvases) in the worker. Applying all of them at once is the same
freeze as an unstaggered mount burst, so resizes flow through the shared
scheduler as a latest-wins-per-chart lane: at most resizePerFrame
(default 8) charts are resized per animation frame, repeated schedules for the
same chart coalesce to the newest size, and a pending resize is dropped if the
chart unmounts first. A grid-wide resize settles over a few frames (charts
CSS-stretch briefly instead of the page freezing); a lone chart's resize gains
at most one frame of latency. This is on top of the per-chart 100 ms debounce,
which merges repeated changes for one chart but can't spread a cross-chart
burst.
To see what the queues are doing (e.g. while profiling a freeze), read the live counters:
import { getLifecycleStats } from '@heojeongbo/fluxion-render/react';
getLifecycleStats();
// { mountsRun, disposesRun, resizesApplied, pendingTasks, pendingResizes }Pair it with recyclePool.stats (below) to tell cold-create storms apart from
resize storms.
Pausing off-screen charts (pauseWhenOffscreen)
In a tall scroll grid most charts are out of view, yet each still pays the
per-frame render + present cost — the dominant cost on both engines (Firefox's
fixed per-render overhead, Chromium's present flood). Pass pauseWhenOffscreen
to <FluxionCanvas> / useFluxionCanvas to pause a chart's RENDERING while
it's scrolled off-screen, via a single shared IntersectionObserver for the
whole grid. It composes with page visibility: a chart renders only while it's
both on-screen and its tab is visible (a hidden tab now fully stops rendering,
not just its scrolling clock).
<FluxionCanvas pauseWhenOffscreen layers={[/* … */]} hostOptions={{ pool }} />Measured with scroll-bench (headed, dpr 2, grid scrolled so most charts are
off-screen, one shared feed pushing to every chart in both cases — so the
delta is pure render saving; median of 3). Aggregate worker render time summed
across all engines:
| Browser | Charts | Worker busy off | Worker busy on | Reduction |
|---|---|---|---|---|
| Chromium | 60 | 78 ms/s | 15 ms/s | −80 % |
| Chromium | 200 | 147 ms/s | 24 ms/s | −84 % |
| Firefox | 60 | 1135 ms/s | 208 ms/s | −82 % |
| Firefox | 200 | 4340 ms/s | 255 ms/s | −94 % |
Renders/sec drops from ~1300–4500 to ~110 (only the on-screen charts still draw), and the saving scales with the off-screen fraction. Firefox's absolute numbers are ~15× Chromium's (its fixed per-render cost), so the absolute relief is largest exactly where it hurts most.
Data is never paused — only the paint is. Samples keep streaming into the worker's ring buffer while a chart is off-screen, so scrolling it back into view repaints the full buffered history in one frame rather than starting empty at the moment it reappeared. There is nothing to backfill (contrast the staggered-mount note above, where a late-created host genuinely missed the stream): here the host exists the whole time and just stops drawing.
The one requirement for "full history on scroll-in" is the usual streaming
contract — the layer's ring capacity must cover the visible window (set
capacity, or retentionMs + maxHz). If capacity is smaller than the window,
the oldest in-window samples are evicted while off-screen exactly as they would
be on-screen; size it so the window fits. (The undersized-capacity warning only
fires while a chart is rendering, so verify sizing on-screen.)
Tune the shared observer globally — a pre-warm margin so charts paint just
before they scroll into view (default "200px"), and an alternate scroll root
for a nested scroll pane:
import { configureOnScreenObserver } from '@heojeongbo/fluxion-render/react';
configureOnScreenObserver({ rootMargin: '400px', root: scrollPaneEl });Opt-in (default off) — existing charts render exactly as before. Where no
IntersectionObserver exists (SSR, old runtimes) charts simply always render.
It's a runtime signal, not construction-fixed, so it composes freely with host
recycling.
WebGL renderer (renderer: 'webgl') — the Firefox prescription
Firefox executes worker-canvas2d through a remote command pipeline with a
measured fixed ~0.5–1.2 ms submission cost per render — insensitive to
pixels, command count, and messages, so no amount of draw-path trimming
removes it. renderer: 'webgl' sidesteps the pipeline entirely: the engine
draws lines (raw (t, y) vertices, data→pixel affine in the vertex shader),
grid/ticks (batched gl.LINES snapped to the same pixel centers as the 2d
strokes), and labels (the shared label-sprite cache uploaded as textures,
blitted at identical positions) with GPU programs.
<FluxionCanvas
hostOptions={{ renderer: 'webgl', inlineAxes: true, maxFps: 30 }}
layers={[/* … */]}
/>Measured (Playwright headed, dpr 2, inline axes, median of 3):
| Browser | Load | '2d' busy/render | 'webgl' busy/render | renders/s delivered |
|---|---|---|---|---|
| Firefox | 60 charts × 25 Hz | 1.18 ms | 0.27 ms (−78 %) | 1408 → 1346 |
| Firefox | 200 charts × 60 Hz | 0.94 ms (saturated, 0.5 % jank) | 0.27 ms (0 % jank) | 3749 → 10392 (2.8×) |
| Chromium | 60 charts × 25 Hz | 0.038 ms | 0.033 ms | ≈ same |
Use it on Firefox; keep '2d' on Chromium.
- Chromium caps live WebGL contexts (~16 per process). Mounting more
webgl charts than the cap forcibly loses the oldest contexts, and they do
not restore while over the cap — charts freeze (measured: 28 lost at
60 charts). Chromium's worker canvas2d has no fixed per-render cost, so
'webgl'buys nothing there anyway. Firefox's cap is far higher (~300): 60–200 charts run without a single context loss. - Pair with
inlineAxes(recommended): external axis canvases are 2d-drawn and are skipped with a warn under'webgl'(React-sideuseAxisTicksfallback still works). - Construction-fixed, like
inlineAxes— part of the recycle-pool key, so recycled'webgl'hosts never mix with'2d'hosts. - If the WebGL context can't be created at init (blocklisted driver,
headless), the engine logs a warn and falls back to
'2d'— the chart always renders.
v1 limitations (each warns once and degrades gracefully): only line and
axis-grid layers have GPU paths — other layer kinds are skipped under
'webgl'; dashArray/gridDashArray draw solid; line width is clamped to
the device's aliased-line range (commonly 1 device px under ANGLE); GL lines
are not antialiased on most drivers (crisper, slightly more stair-stepped
than 2d); colors must be #hex / rgb() / rgba() (named CSS colors warn
and fall back to white).
Theming (light/dark) — colors CSS can't reach
The chart is drawn on an OffscreenCanvas inside a Web Worker, so CSS
variables and Tailwind dark: classes never touch its pixels. A light/dark
toggle re-themes the chart by pushing resolved color values in. Three
surfaces, all reconciled to the live host with no key remount — flip a
theme and the chart repaints in place:
| Surface | How to set it | Runtime path |
| --- | --- | --- |
| Canvas background | hostOptions.bgColor (or host.setBgColor()) | reconciled on change |
| External axis strip | <FluxionCanvas axisColor=… axisFont=…> / host.setAxisStyle() | SET_AXIS_STYLE |
| In-canvas grid / axis / labels | axis-grid layer config (gridColor, axisColor, labelColor) | configLayer |
// theme is your app's resolved palette; changing it re-themes without remount.
const layers = useMemo(() => [
axisGridLayer('axis', { gridColor: theme.grid, axisColor: theme.axis, labelColor: theme.label }),
lineLayer('s1', { color: seriesColor }), // series colors are usually theme-independent identities
], [theme, seriesColor]);
<FluxionCanvas layers={layers} axisColor={theme.label} hostOptions={{ bgColor: theme.bg }} />bgColor and axisStyle are otherwise mount-only; the hook reconciles just
these two on change (seeded at mount, so an unchanged value never re-posts
across a large grid). Series colors already reconcile through the normal layer
config path.
Batteries-included: <FluxionThemeProvider>. Rather than thread bgColor
and axisStyle through every chart, wrap a subtree once — every FluxionCanvas
underneath inherits them from the theme, and a light/dark switch re-themes them
all at once (no remount, thanks to the reconcile above). Precedence is
theme < per-chart hostOptions < axis props, so a chart can still override.
import { FluxionThemeProvider, useFluxionTheme } from '@heojeongbo/fluxion-render/react';
// Wrap the app (or any chart subtree). `defaultMode="system"` tracks the OS
// prefers-color-scheme and follows it live; use "light" / "dark" to pin.
<FluxionThemeProvider defaultMode="system">
<Dashboard />
</FluxionThemeProvider>;
// A toggle anywhere inside — setMode re-themes every chart under the provider.
function ThemeToggle() {
const { resolvedMode, setMode } = useFluxionTheme();
return (
<button onClick={() => setMode(resolvedMode === 'dark' ? 'light' : 'dark')}>
{resolvedMode === 'dark' ? '🌙 Dark' : '☀️ Light'}
</button>
);
}Customize the palettes with themes (a partial merges over the built-in
darkTheme / lightTheme, so overriding one field keeps the rest):
<FluxionThemeProvider
defaultMode="dark"
themes={{ dark: { bgColor: 'oklch(0.2 0.02 260)' } }} // keeps the preset axis style
/>The themed bgColor reaches INIT, so a first frame is correctly themed (no
flash — same guarantee as configureFluxionDefaults below). The provider covers
the canvas background + external/inline axis styling; in-canvas grid/label
colors (the axis-grid layer's gridColor/axisColor/labelColor) are still
per-layer — theme those via the layer config shown above. useFluxionTheme()
returns { theme, mode, resolvedMode, setMode } and throws outside a provider.
Pass bgColor at construction for a correct first frame. The default
canvas context is opaque, so the worker fills bgColor into the backing
synchronously at INIT — a light-theme chart paints its background on the very
first frame instead of flashing the opaque-black default. That fill uses the
color known at INIT, i.e. hostOptions.bgColor. If you instead leave bgColor
unset at mount and apply the theme only via a later setBgColor (e.g. after
resolving CSS variables in an effect), the first frame uses the dark default
#0b0d12 and the light color lands on the next frame. So resolve the theme
before mount and pass it in hostOptions.bgColor.
App-wide defaults — configureFluxionDefaults(). Rather than repeat
hostOptions={{ bgColor }} (and maxFps, renderer, axisStyle, …) on every
chart — and risk forgetting it and getting the black first frame — set them once
at app startup. Every chart then inherits them; a per-chart hostOptions field
still overrides. Because it feeds the same INIT as hostOptions.bgColor, the
first frame is correctly themed everywhere.
import { configureFluxionDefaults } from '@heojeongbo/fluxion-render';
// once, at app entry (before any chart mounts)
configureFluxionDefaults({ bgColor: theme.canvasBg, maxFps: 30, renderer: 'webgl' });It accepts any FluxionHostOptions fields and accumulates across calls. The
default is merged before the recycle-pool key is computed, so a default
renderer/maxFps/etc. buckets warm hosts correctly. (Set the worker pool
via configureDefaultPool instead.) The values are read at chart construction,
so call it before mounting; per-chart props remain the way to override or to
re-theme a live chart.
Color format — oklch() and CSS variables work. Every color field
(bgColor, layer color, axis color) is assigned straight to the canvas
fillStyle/strokeStyle, so it accepts any CSS <color> the browser's canvas
supports — including oklch(…), rgb()/rgba(), hsl(), and named colors
(OffscreenCanvas in a worker supports the same set as the main thread; oklch
in canvas is Chrome 111+ / Safari 15.4+ / Firefox 113+). So you can read a
shadcn-style --background: oklch(…) token and pass it through as-is on modern
browsers. Two caveats: (1) the area layer fill and the colormap /
gradient utilities (heatmap, scatter-colored, occupancy) parse hex
only — give those #rrggbb; (2) to support browsers without canvas-oklch,
resolve the token to rgb() on the main thread first (set it on a throwaway
element's style.color, then read back getComputedStyle(el).color) and pass
the normalized string in. The worker can't do this — CSS resolution is
main-thread only.
Spreading the React mount of a big grid (useStaggeredMount)
staggerMount defers each host's worker creation, but React still mounts all N
<FluxionCanvas> components in a single commit — reconciling N components and
creating N canvases (and their layout) synchronously. For a large grid appearing
at once that synchronous commit can spike the main thread on its own.
useStaggeredMount lets the library spread the component mount across frames:
it returns a shown count that grows from one batch up to total at perFrame
items per frame; render shown of your list. Every chart still mounts — this is a
fast progressive reveal (a few frames), NOT virtualization (nothing is unmounted
offscreen) and NOT a slow drip.
import { useStaggeredMount, FluxionCanvas } from '@heojeongbo/fluxion-render/react';
function Grid({ items }) {
const shown = useStaggeredMount(items.length, { perFrame: 24 });
return items.slice(0, shown).map((it) => <FluxionCanvas key={it.id} {...it} />);
}- A
total <= perFramelist shows in full on the first frame (no delay for small grids). To re-run the reveal for a grid that periodically remounts, give the rendering subtree a Reactkeyso the hook re-mounts. - It composes with
staggerMount(host stagger) andrecyclePool: the reveal bounds how many components mount per frame, the host scheduler bounds how many workers spin up per frame, and recycling makes each of those cheap. - The internal
ResizeObserverreads each chart's size from the observer entry (not a synchronousgetBoundingClientRect), so a large reveal batch doesn't force a per-chart reflow.
Recycling hosts under heavy churn (recyclePool)
Staggering spreads the cost of a host's creation across frames, but it doesn't
remove it. In a UI that mounts and unmounts charts continuously — a virtualized
list scrolling, an accordion toggling sections, a grid that periodically remounts
— the create→destroy cycle itself dominates CPU: every mount runs
transferControlToOffscreen + worker init (new engine + GPU alloc) + a first
render, and every unmount tears it all down.
A host recycle pool keeps a host warm on unmount instead of destroying it,
then hands it back on the next compatible mount. Reuse is cheap: the parked host
is reset to a pristine state and its <canvas> is re-parented into the new slot —
none of the transfer/init/first-render cost. Create one with useHostRecyclePool
and pass it to each <FluxionCanvas> whose churn you want to absorb:
import { useHostRecyclePool, FluxionCanvas } from '@heojeongbo/fluxion-render/react';
function Grid({ items }) {
const recyclePool = useHostRecyclePool({ max: 16 }); // disposed on unmount
return items.map((it) => (
<FluxionCanvas
key={it.id}
recyclePool={recyclePool}
layers={it.layers}
hostOptions={{ pool, maxFps: 30 }}
/>
));
}- Compatibility. Warm hosts are only reused for a mount with matching
construction-fixed options — same worker
pool(orworkerFactory), axis-canvas presence,maxFps,transparent,emitBounds/emitTicks/emitRenderStats. The key is derived automatically; passrecycleKey="…"to force-separate structurally different chart families that share one pool. A request with no compatible warm host simply falls back to a cold create — recycling never changes correctness, only cost. maxcaps warm hosts kept per key (default8). Higher = fewer cold creates under churn, but more idle memory held (each warm host keeps its worker-side engine + OffscreenCanvas alive). For a virtualized list whose visible working set is small,8–16is plenty; for a grid that remounts everything at once, raise it toward the concurrent count so the whole set recycles —stats.highWaterreports the peak working set actually observed, and the poolconsole.warns once per bucket when overflow churn saysmaxis undersized (thresholdwarnAfterOverflow, default 16 overflow disposes;0disables).idleShrinkMs(default off) makes a largemaxsafe: after a host has been parked that long, its worker-side GPU backings are released (canvases shrink to0×0) while the host stays warm and reusable — the next acquire's resize re-allocates them inside the frame-budgeted mount task. Without it, a pool sized for a 64-chart grid parks up to ~200 full-size idle GPU surfaces.- Teardown is deferred. When a release overflows a full bucket, or the pool
itself is disposed (route change), the real
host.dispose()calls drain through the same frame-throttled queue as staggered mounts — a bulk unmount can't burst-free dozens of GPU backings inside one React commit. Bundles become unreachable immediately; only the teardown work is spread out. - Stacks with
staggerMount. A warm reuse is far cheaper than a cold create, so the per-frame mount budget goes much further. The pool is disposed (tearing down every warm host) when the component holdinguseHostRecyclePoolunmounts.
The Mount/Unmount Churn demo (examples/vite-demo) has a recycle toggle and
a created / recycled readout: flip it on and created stops climbing while
recycled rises and CPU drops.
Layer Types
line — Streaming time-series
Appends { t, y } samples to a ring buffer. Ideal for sensor data at 30–120Hz.
lineLayer('signal', {
color?: string, // e.g. '#4fc3f7'
lineWidth?: number, // default 1
capacity?: number, // ring buffer size in samples (explicit)
retentionMs?: number, // data retention window in ms
maxHz?: number, // expected max sample rate — auto-calculates capacity
visible?: boolean, // show/hide without reinitialising the layer (default true)
decimate?: boolean, // min/max-decimate the DRAW at high sample density.
// Tri-state: omitted = AUTO (decimate only when oversampled),
// true = always when oversampled, false = always draw every
// sample. Also on area/step/scatter — see "Streaming decimation"
maxGapMs?: number, // break the stroke when consecutive samples are farther apart
// than this (bursty/intermittent streams show real holes
// instead of a bridging diagonal); also on area/step layers
dashArray?: number[], // setLineDash pattern in CSS px, default [] (solid). Use to
// distinguish overlapping series, e.g. [6, 4]; also on
// area/step layers (area dashes the outline, not the fill).
// Visual only — data, hover, and auto-scaling are unaffected.
yOffset?: number, // vertical offset added to every y at draw time, in DATA
// units, default 0. Lifts the series up/down to spread
// overlapping lines (waterfall); auto-scale grows to fit.
// Also on area/step layers. Visual only (hover/export = raw y).
laneIndex?: number, // lane (small-multiples) mode: draw this series in band
laneCount?: number, // `laneIndex` of `laneCount`, auto-normalized to its OWN
laneGapPx?: number, // y-range (own band, no shared y-axis). gap default 6 px.
// Also on area/step. See useMultiSeriesChart layout:'lanes'.
opacity?: number, // global stroke opacity 0–1, default 1. De-emphasize a
// series or let overlapping lines show through. Saved/
// restored around the draw so it never leaks into other
// layers. Also on `scatterLayer`. Visual only.
})retentionMs + maxHz auto-calculate capacity = ceil(retentionMs/1000 * maxHz * 1.1).
Explicit capacity always takes priority when both are set. If the ring is too
small for the visible window — i.e. samples are evicted while still on screen —
the layer logs a one-time [fluxion] Layer "id": ring capacity … is smaller than
the visible window warning so silent data loss is visible during development.
Streaming decimation (decimate)
decimate is a shared, tri-state option on the line, step, area, and
scatter streaming layers that makes high-rate (e.g. 500 Hz) charts O(width)
instead of O(samples) to draw:
- omitted (default) → AUTO — decimate only when oversampled (visible samples
2× pixel width).
true— decimate whenever oversampled (same effect as auto).false— always draw every sample.
For line / step / area it draws a min/max envelope (~2–4 points per x-pixel
column), so it's visually lossless — every peak/trough at display resolution
is preserved; for scatter it thins to each column's min-y / max-y points. The
ring buffer still holds every sample, so hover, scan (y-auto bounds), and
export are unaffected.
Toggling series visibility — set visible to show/hide a layer without reinitialising the host or losing buffered data. For a single layer, useLayerConfig sends one lightweight CONFIG message:
const [enabled, setEnabled] = useState({ s1: true, s2: true, s3: false });
// layers is fixed on mount — never recreated on toggle
const layers = useMemo(() => [
axisGridLayer('axis', { ... }),
lineLayer('s1', { color: '#4fc3f7' }),
lineLayer('s2', { color: '#80ffa0' }),
lineLayer('s3', { color: '#ffb060' }),
], []);
useLayerConfig(host, lineLayer('s1', { visible: enabled.s1 }));Toggling many series at once — calling useLayerConfig per layer fires N postMessages and trips the rules-of-hooks lint when done in a loop. Use useLayersConfig (plural): it diffs the whole array and sends a single batched CONFIG_BATCH message containing only the changed layers:
// One message per toggle, no matter how many series — and loop-friendly.
useLayersConfig(
host,
keys.map((k) => lineLayer(k, { visible: enabled[k] })),
);Outside React, the host exposes the same batching directly:
host.configLayers([
{ id: 's1', config: { visible: false } },
{ id: 's2', config: { lineWidth: 2 } },
]); // one postMessage, applied + redrawn once
host.setLayerVisibility('s1', false); // single-layer convenience
host.setLayerVisibility({ s1: true, s2: false, s3: true }); // map → one batch// Keep 10 seconds of data at up to 60Hz → capacity = 660
lineLayer('signal', { retentionMs: 10_000, maxHz: 60 })Push data via LineLayerHandle:
const handle = host.addLineLayer('signal', { color: '#4fc3f7', capacity: 4096 });
// Single sample
handle.push({ t: tMs, y: value });
// Batch (more efficient at high rates)
handle.pushBatch([{ t: t1, y: v1 }, { t: t2, y: v2 }]);line-static — One-shot XY plot
Replaces the entire dataset on each push. For pre-computed or snapshot data.
lineStaticLayer('plot', {
color?: string,
lineWidth?: number,
layout?: 'xy' | 'y', // 'xy': interleaved [x,y,x,y,...], 'y': y-only array
})const handle = host.addLineStaticLayer('plot', { color: '#80ffa0' });
// XY pairs
handle.pushXy([{ x: 0, y: 0 }, { x: 1, y: 1 }]);
// Y-only (x = index)
handle.pushY([0.1, 0.4, 0.9, 1.6]);lidar — Point cloud scatter
Efficient batch rendering of large point clouds (30k+ points at 120Hz). Uses counting-sort by intensity to minimize GPU state changes.
lidarLayer('scan', {
stride?: 2 | 3 | 4, // points per element: [x,y] | [x,y,z] | [x,y,z,intensity]
pointSize?: number,
intensityMax?: number,
color?: string, // base color (used when stride < 4)
})const handle = host.addLidarLayer('scan', { stride: 4, pointSize: 2 });
// Push raw Float32Array: [x, y, z, intensity, x, y, z, intensity, ...]
handle.pushRaw(float32Array);
// Or push structured points
handle.push([{ x: 1.2, y: -0.4, z: 0, intensity: 0.8 }]);area / step — Filled / stepped time-series
Same streaming { t, y } model and config as line (including capacity /
retentionMs / maxHz, decimate, maxGapMs, dashArray, yOffset, and the
laneIndex / laneCount / laneGapPx lane fields). areaLayer fills below the
stroke (dash applies to the outline, not the fill); stepLayer draws a
sample-and-hold staircase. Handles AreaLayerHandle / StepLayerHandle push the
same way as LineLayerHandle (push / pushBatch / reset).
areaLayer('a', { color: '#4fc3f7', /* …same fields as lineLayer */ });
stepLayer('s', { color: '#80ffa0' });More chart layers
The same host.addLayer(id, kind, config) / factory-spec pattern covers a family
of additional layer types. Each takes a Float32Array (or a typed handle method)
in the layout shown below; t is host-relative ms for streaming layers.
| Factory | kind | Data layout (stride) | Flow | Handle → key methods |
|---------|--------|----------------------|------|----------------------|
| barLayer | bar | [x,y,…] (2) or [y,…] (1, layout:'y') | static | BarLayerHandle → setXY / setY |
| scatterLayer | scatter | [t,y,…] (2) | stream | ScatterLayerHandle → push / pushBatch / reset |
| scatterColoredLayer | scatter-colored | [t,y,color,size,…] (4, color/size 0–1) | stream | ScatterColoredHandle → push / pushBatch / reset |
| candlestickLayer | candlestick | [t,open,high,low,close,…] (5) | stream | CandlestickLayerHandle → push / pushBatch / reset |
| eventMarkerLayer | event-marker | [t,severity,…] (2; sev 0/1/2) | static | EventMarkerHandle → setEvents / clearEvents |
| heatmapLayer | heatmap | [x,y,value,…] (3) | static | HeatmapLayerHandle → setGrid |
| heatmapStreamLayer | heatmap-stream | [t, v0…v_{yBins-1}] (yBins+1) | stream | HeatmapStreamHandle → pushColumn(t, values) |
| poseArrowLayer | pose-arrow | [t,y,theta,…] (3; θ rad) | stream | PoseArrowHandle → push / pushBatch / reset |
| referenceLineLayer | reference-line | config-only (no data) | config | ReferenceLineHandle → setReference(config) |
Notable config fields (all have sensible defaults):
barLayer—color,barWidth=8,layout='xy'|'y',xRange=[0,1](for'y').scatterLayer—color,pointSize=3,shape='square'|'circle',opacity=1 (global point opacity 0–1),decimate(tri-state, see Streaming decimation — thins to per-column min-y/max-y points), ring sizing viacapacity=2048 /retentionMs/maxHz.scatterColoredLayer—colormap='viridis'|'plasma'|'hot'|'gradient'(+minColor/maxColorfor'gradient'),minSize=2 /maxSize=8,shape='circle'.candlestickLayer—upColor=#26a69a,downColor=#ef5350,bodyWidth=6.eventMarkerLayer—colors=[info, warning, error],markerSize=8,lineWidth=1.heatmapLayer/heatmapStreamLayer—colormap='viridis'|'plasma'|'hot', optionalminValue/maxValue(auto if omitted); stream addsyBins=32,maxCols=256,yRange=[0,1].poseArrowLayer—arrowLength=14,arrowWidth=5,color.referenceLineLayer—y(required), optionalbandMin/bandMax(+bandOpacity=0.12),color,label,lineWidth=1.5.
Robot & distribution layers
Domain layers for robot dashboards and statistics. t is host-relative ms;
world-coordinate layers expect axisGridLayer({ xMode: "fixed" }).
| Factory | kind | Data layout (stride) | Flow | Handle → key methods |
|---------|--------|----------------------|------|----------------------|
| trajectoryLayer | trajectory | [x,y,t,…] (3; world x/y) | stream | TrajectoryHandle → push / pushBatch / reset |
| occupancyGridLayer | occupancy-grid | [originX,originY,res,cols,rows,…cells] | static | OccupancyGridHandle → setGrid |
| histogramLayer | histogram | [v0,v1,…] raw values (binned in-layer) | static | HistogramHandle → setValues |
| stackedAreaLayer | stacked-area | [t,y0,y1,…] (seriesCount+1) | stream | StackedAreaHandle → push / pushBatch / reset |
| boxPlotLayer | box-plot | [x,min,q1,median,q3,max,…] (6) | static | BoxPlotHandle → setBoxes |
| polarLayer | polar | [theta,r,…] (2; θ rad, r≥0) | static | PolarHandle → setPoints |
| spectrogramLayer | (heatmap-stream preset) | columns via pushColumn(t, magnitudes) | stream | HeatmapStreamHandle → pushColumn |
Notable config fields:
trajectoryLayer—color,colorByTime(+colormap='viridis'|'plasma'|'hot'),headMarker=true /headMarkerSize=4,fadeOlderMs=0, ring sizing viacapacity/retentionMs/maxHz.occupancyGridLayer—occupiedColor/freeColor/unknownColor(cell-1=unknown,0..100=probability),showGridLines,gridLineColor.histogramLayer—binCount=20, fixed or autorange,density,gapPx=1,color.stackedAreaLayer—seriesCount(sets stride),colors[],fillOpacity=0.85,normalize(percent-stacked),lineWidth.boxPlotLayer—color/lineColor,fillOpacity=0.35,boxWidth=24,capRatio=0.5,lineWidth=1.5.polarLayer—rMax(auto if omitted),closed=true,fillOpacity,showPoints/pointSize,showRings=true /ringCount=4,gridColor,insetPx=8. Self-contained polar→pixel mapping (give it its own canvas; ignores cartesian y-scaling).spectrogramLayer—freqBins=64,freqRange=[0,1],maxCols=256,colormap,minDb/maxDb. Thin preset overheatmap-stream(push a magnitude/dB column per frame).
axis-grid — Axes and grid
Controls the viewport bounds for all layers. Does not receive data — configure via axisGridLayer() or host.configLayer().
axisGridLayer('axis', {
// X axis
xMode?: 'fixed' | 'time', // 'fixed': static range, 'time': sliding window
xRange?: [min, max], // xMode: 'fixed' only
timeWindowMs?: number, // xMode: 'time' only
timeOrigin?: number, // Date.now() at stream start (for clock labels)
followClock?: boolean, // xMode: 'time' — right edge tracks Date.now()-timeOrigin every
// frame (scrolls continuously with no data); requires timeOrigin
xTickFormat?: string | { pattern?, precision?, suffix?, si? } | ((v: number) => string),
// string clock-pattern, worker-safe object, or function.
// object form works on every render path (see table below);
// function form applies React-side only
// Y axis
yMode?: 'fixed' | 'auto', // 'auto': fits to visible data
yRange?: [min, max], // yMode: 'fixed' only
yAutoPadding?: number, // fractional padding for auto mode (default 0.1)
yTickFormat?: { precision?, suffix?, si? } | ((v: number) => string),
// object form is worker-safe (works with externalAxes:
// precision via toFixed, unit suffix, k/M/G scaling);
// function form applies on the React side only
// Appearance
gridColor?: string,
gridLineWidth?: number, // grid line width in CSS px (default 1)
axisColor?: string,
labelColor?: string,
font?: string,
showXGrid?: boolean,
showYGrid?: boolean,
showAxes?: boolean,
showXLabels?: boolean,
showYLabels?: boolean,
})Inline axes (inlineAxes) — one canvas surface per chart
externalAxes renders axes on separate canvases, so every frame of a
scrolling chart presents two or three surfaces to the compositor (main +
x-axis, + y-axis when bounds move). For large grids that per-surface present
cost is the dominant axis expense. inlineAxes instead reserves margins
INSIDE the main canvas (yAxisWidth left, xAxisHeight bottom) and has the
worker draw ticks/labels there itself — one canvas surface per chart:
<FluxionCanvas
inlineAxes // takes precedence over externalAxes
yAxisWidth={60} // left margin (CSS px)
xAxisHeight={30} // bottom margin
layers={[axisGridLayer('axis', { xMode: 'time', timeWindowMs: 5000, yMode: 'auto' }), …]}
/>Data and grid are clipped to the plot rect; in-plot labels are automatically
suppressed; axisColor/axisFont/axisTickSize/axisTickMargin style the
margin ticks exactly like external axes. Caveat: pointer→data overlays map px
over the whole element, so pass the same left margin to the crosshair
(useFluxionCrosshair({ insetLeft: 60, … })); the brush overlay currently
assumes a full-width plot.
Tick formatters and externalAxes
By default (externalAxes, the recommended path) tick labels are drawn by the
worker on a dedicated axis canvas. In-plot labels (showXLabels/showYLabels)
are automatically suppressed for a side whose external axis canvas is
attached, so labels are never formatted and drawn twice per frame. Tick
values/labels are cached between step crossings and label strings are
rasterized once into a sprite cache, so a steadily scrolling axis costs a
handful of bitmap blits per frame instead of per-label text rasterization. A function formatter can't cross the
worker boundary — it's stripped before postMessage and only re-applied on the
React side. Use the string or object form for worker-drawn labels:
| xTickFormat / yTickFormat form | Worker-drawn axis (externalAxes) | React-side tick set |
| --- | --- | --- |
| string (x: clock pattern "HH:mm:ss") | ✅ | ✅ |
| object ({ pattern?, precision?, suffix?, si? }) | ✅ | ✅ |
| function (v) => string | ❌ (falls back to raw value) | ✅ |
For non-time axes or numeric labels, prefer the object form:
xTickFormat: { precision: 1, suffix: 'ms' }, yTickFormat: { si: true, suffix: 'B' }.
For wall-clock strings outside the axis (HUDs, table cells), formatClock and
makeClockFormatter apply the same pattern tokens (HH H mm m ss s
SSS S; anything else is literal):
import { formatClock, makeClockFormatter } from '@heojeongbo/fluxion-render';
formatClock(Date.now(), 'HH:mm:ss.SSS'); // → "14:07:32.481"
const fmt = makeClockFormatter('HH:mm:ss'); // reusable formatter
fmt(epochMs);React API
useFluxionCanvas(options)
Creates the canvas, worker, and all layers. Returns a ref to attach to a container <div> and the FluxionHost instance.
const { containerRef, host } = useFluxionCanvas({
layers: FluxionLayerSpec[], // layer declarations (configs are live — see below)
hostOptions?: FluxionHostOptions, // bgColor, pool, workerFactory + perf knobs
// (coalesce / maxFps / emitBounds / emitTicks —
// see "Performance / many charts")
onReady?: (host) => void, // called once after initialization
staggerMount?: boolean, // defer host creation across frames (default true)
recyclePool?: HostRecyclePool, // reuse warm hosts on mount/unmount instead of
recycleKey?: string, // create/destroy — see "Recycling hosts under
// heavy churn" in Performance / many charts
pauseWhenOffscreen?: boolean, // pause rendering while scrolled off-screen
// (default false; data keeps buffering) — see
// "Pausing off-screen charts". Tune the shared
// observer with configureOnScreenObserver.
});Layer configs inside layers are reconciled: when the array reference
changes, each layer's config is diffed by content and only changed ones are
re-sent to the worker. Memoize the array and list your config inputs as deps:
const layers = useMemo(() => [
axisGridLayer('axis', { xMode: 'time', followClock: isLive }),
lineLayer('s1', { color, visible }),
], [isLive, color, visible]); // config changes auto-apply — no manual configLayerStructural changes (adding/removing layers, changing a layer's kind) are
not reconciled — remount with a different key for those.
<FluxionThemeProvider> / useFluxionTheme()
App-wide chart theming (see Theming).
Supplies bgColor + axisStyle as the option-merge base for every
FluxionCanvas below it, so a light/dark switch re-themes all charts with no
remount.
<FluxionThemeProvider
defaultMode?="system" // "system" (tracks + follows prefers-color-scheme) | "light" | "dark"
themes?={{ light?, dark? }} // partial overrides merged over the built-in presets
onModeChange?={(mode) => …} // fires on toggle or a live OS change
>{children}</FluxionThemeProvider>;
const { theme, mode, resolvedMode, setMode } = useFluxionTheme(); // throws outside a providerresolvedMode is the concrete "light" | "dark" ("system" resolved); setMode
drives a toggle. darkTheme / lightTheme (the presets) and the FluxionTheme
type are exported for building custom palettes.
useFluxionStream(options)
Drives a data loop via setInterval. Returns a measured sample rate.
const { rate } = useFluxionStream({
host, // from useFluxionCanvas
intervalMs: number, // e.g. 1000/60 for 60Hz
setup: (host) => T, // called once — resolve typed handles here
tick: (tMs, state) => number, // called every interval, return sample count
shared?: boolean, // opt into the shared ticker (default false) — see below
trackRate?: boolean, // measure pushed-samples/sec into `rate` (default true) — see below
});tMs is milliseconds since the first tick (not Date.now()). Use it as the t value for line samples.
Many streams at the same rate? Pass shared: true. Instead of each stream
owning its own setInterval, all same-intervalMs streams coalesce onto one
process-wide timer that fans out to every subscriber — and it pauses while the
page is hidden (document.hidden), so background tabs stop pumping. This cuts
timer overhead dramatically on dashboards with dozens of small charts. Default
false preserves the original one-interval-per-stream behavior exactly.
Not displaying rate? Pass trackRate: false. By default (true) the hook
tracks the pushed-samples-per-second rate and refreshes it every 500 ms via
setState — a periodic re-render per stream. With it off, that periodic
re-render is skipped (and rate stays 0), which matters for large grids of
hundreds of charts.
Need the shared timer outside useFluxionStream? Use the primitive directly:
import { useSharedTicker, subscribeTicker } from '@heojeongbo/fluxion-render/react';
// React: subscribe for the component's lifetime
useSharedTicker(1000 / 60, (now) => { /* … */ });
// Imperative: returns an unsubscribe; timer is cleared when the last sub leaves
const unsubscribe = subscribeTicker(1000 / 60, (now) => { /* … */ });useTimeOrigin()
Returns a stable Date.now() snapshot captured on the first render of the component. Use it as timeOrigin for axisGridLayer so timestamps on all charts are relative to the same epoch.
const timeOrigin = useTimeOrigin();
// timeOrigin is fixed for the lifetime of the component — never changes on re-renderuseSyncedTimeWindow(initialMs?)
Manages a shared timeWindowMs across a set of charts. Returns a state value plus utilities for syncing it to multiple hosts.
const {
windowMs, // current time window in ms (default 5000)
setWindowMs, // update the window and re-render
timeOrigin, // stable Date.now() snapshot (same as useTimeOrigin)
syncConfig, // () => { timeWindowMs, timeOrigin } — pass to axisGridLayer
bind, // (host, axisId?) => void — apply config to a live host
} = useSyncedTimeWindow(initialMs?);const tw = useSyncedTimeWindow(5000);
// ...
axisGridLayer('axis', { xMode: 'time', ...tw.syncConfig() })
// later, to change window for all bound hosts:
tw.setWindowMs(10000);useFluxionWorkerPool(options)
Creates a scoped FluxionWorkerPool that is disposed when the component unmounts.
const pool = useFluxionWorkerPool({
size?: number, // initial worker count, default 4
maxSize?: number, // grow on demand up to this (default = size, growth off)
targetPerWorker?: number, // hosts/worker that triggers growth (default 12)
workerFactory: () => Worker, // required
});useHostRecyclePool(options)
Creates a scoped host recycle pool (disposed, tearing down every warm host,
when the component unmounts). Pass it as recyclePool to each <FluxionCanvas> /
useFluxionCanvas whose mount/unmount churn you want to absorb — warm hosts are
reused instead of re-created. See
Recycling hosts under heavy churn
for when and how.
const recyclePool = useHostRecyclePool({
max?: number, // warm hosts kept per recycle key, default 8 (higher =
// fewer cold creates, more idle worker/GPU memory held)
idleShrinkMs?: number, // release a parked host's GPU backings after this long
// idle (default off) — makes a large `max` safe
warnAfterOverflow?: number, // once-per-bucket console.warn after this many overflow
// disposes (default 16, 0 disables)
});
// recyclePool.stats → { created, recycled, overflowDisposed, highWater, shrunk }
// recyclePool.size // currently parked hosts
<FluxionCanvas recyclePool={recyclePool} hostOptions={{ pool }} layers={…} />;stats is handy for a HUD that shows the recycling working (flip the chart churn
on and watch recycled climb while created plateaus). highWater is the peak
concurrent
