npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@heojeongbo/fluxion-render

v1.6.0

Published

High-performance OffscreenCanvas rendering engine for real-time robotics data (charts, LiDAR, streaming).

Downloads

3,610

Readme

@heojeongbo/fluxion-render

npm coverage

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-render

Need 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 require build). 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 any useFluxion* 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> (from remix-utils) or a mounted-state guard (const [m, setM] = useState(false); useEffect(() => setM(true), [])). Optionally feature-detect typeof 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 like AreaChartConfig are 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

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; maxFps remains the explicit ceiling.
  • Inline axesinlineAxes renders 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 chartspauseWhenOffscreen stops 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 rendererrenderer: '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 dataFloat32Array ownership is transferred to the worker, never copied
  • React integration — hooks and components included (/react subpath)
  • Framework-agnostic core — use FluxionHost directly 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 exported DASH_PATTERNS palette (dashPatternFor(i)). Honest about position.
  • distinguishBy: 'offset' (with offsetStep in data units) — spreads the series vertically (waterfall), lifting series i by i * 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 pool

Adjust 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 demand

getDefaultPool() 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-side useAxisTicks fallback 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 <= perFrame list 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 React key so the hook re-mounts.
  • It composes with staggerMount (host stagger) and recyclePool: 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 ResizeObserver reads each chart's size from the observer entry (not a synchronous getBoundingClientRect), 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 (or workerFactory), axis-canvas presence, maxFps, transparent, emitBounds/emitTicks/emitRenderStats. The key is derived automatically; pass recycleKey="…" 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.
  • max caps warm hosts kept per key (default 8). 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, 816 is plenty; for a grid that remounts everything at once, raise it toward the concurrent count so the whole set recycles — stats.highWater reports the peak working set actually observed, and the pool console.warns once per bucket when overflow churn says max is undersized (threshold warnAfterOverflow, default 16 overflow disposes; 0 disables).
  • idleShrinkMs (default off) makes a large max safe: after a host has been parked that long, its worker-side GPU backings are released (canvases shrink to 0×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 holding useHostRecyclePool unmounts.

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 | BarLayerHandlesetXY / setY | | scatterLayer | scatter | [t,y,…] (2) | stream | ScatterLayerHandlepush / pushBatch / reset | | scatterColoredLayer | scatter-colored | [t,y,color,size,…] (4, color/size 0–1) | stream | ScatterColoredHandlepush / pushBatch / reset | | candlestickLayer | candlestick | [t,open,high,low,close,…] (5) | stream | CandlestickLayerHandlepush / pushBatch / reset | | eventMarkerLayer | event-marker | [t,severity,…] (2; sev 0/1/2) | static | EventMarkerHandlesetEvents / clearEvents | | heatmapLayer | heatmap | [x,y,value,…] (3) | static | HeatmapLayerHandlesetGrid | | heatmapStreamLayer | heatmap-stream | [t, v0…v_{yBins-1}] (yBins+1) | stream | HeatmapStreamHandlepushColumn(t, values) | | poseArrowLayer | pose-arrow | [t,y,theta,…] (3; θ rad) | stream | PoseArrowHandlepush / pushBatch / reset | | referenceLineLayer | reference-line | config-only (no data) | config | ReferenceLineHandlesetReference(config) |

Notable config fields (all have sensible defaults):

  • barLayercolor, barWidth=8, layout='xy'|'y', xRange=[0,1] (for 'y').
  • scatterLayercolor, 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 via capacity=2048 / retentionMs / maxHz.
  • scatterColoredLayercolormap='viridis'|'plasma'|'hot'|'gradient' (+ minColor/maxColor for 'gradient'), minSize=2 / maxSize=8, shape='circle'.
  • candlestickLayerupColor=#26a69a, downColor=#ef5350, bodyWidth=6.
  • eventMarkerLayercolors=[info, warning, error], markerSize=8, lineWidth=1.
  • heatmapLayer / heatmapStreamLayercolormap='viridis'|'plasma'|'hot', optional minValue/maxValue (auto if omitted); stream adds yBins=32, maxCols=256, yRange=[0,1].
  • poseArrowLayerarrowLength=14, arrowWidth=5, color.
  • referenceLineLayery (required), optional bandMin/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 | TrajectoryHandlepush / pushBatch / reset | | occupancyGridLayer | occupancy-grid | [originX,originY,res,cols,rows,…cells] | static | OccupancyGridHandlesetGrid | | histogramLayer | histogram | [v0,v1,…] raw values (binned in-layer) | static | HistogramHandlesetValues | | stackedAreaLayer | stacked-area | [t,y0,y1,…] (seriesCount+1) | stream | StackedAreaHandlepush / pushBatch / reset | | boxPlotLayer | box-plot | [x,min,q1,median,q3,max,…] (6) | static | BoxPlotHandlesetBoxes | | polarLayer | polar | [theta,r,…] (2; θ rad, r≥0) | static | PolarHandlesetPoints | | spectrogramLayer | (heatmap-stream preset) | columns via pushColumn(t, magnitudes) | stream | HeatmapStreamHandlepushColumn |

Notable config fields:

  • trajectoryLayercolor, colorByTime (+ colormap='viridis'|'plasma'|'hot'), headMarker=true / headMarkerSize=4, fadeOlderMs=0, ring sizing via capacity/retentionMs/maxHz.
  • occupancyGridLayeroccupiedColor/freeColor/unknownColor (cell -1=unknown, 0..100=probability), showGridLines, gridLineColor.
  • histogramLayerbinCount=20, fixed or auto range, density, gapPx=1, color.
  • stackedAreaLayerseriesCount (sets stride), colors[], fillOpacity=0.85, normalize (percent-stacked), lineWidth.
  • boxPlotLayercolor/lineColor, fillOpacity=0.35, boxWidth=24, capRatio=0.5, lineWidth=1.5.
  • polarLayerrMax (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).
  • spectrogramLayerfreqBins=64, freqRange=[0,1], maxCols=256, colormap, minDb/maxDb. Thin preset over heatmap-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 configLayer

Structural 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 provider

resolvedMode 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-render

useSyncedTimeWindow(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