A live chart at 120 fps, off the main thread
I wanted a live market chart that scrolls at the screen's full frame rate,
60 or 120 frames a second, with thousands of ticks (one tick is one trade:
a time, a price and a size) arriving every second, and that keeps doing so
while the rest of the page is busy. On the page's own thread (the "main
thread", which also runs your app, React, layout and input) that last part
is the problem: one 40 ms task and the chart misses frames. So the chart
below runs in a Web Worker and draws on an OffscreenCanvas, a second
worker owns the WebSocket, and the page only passes its size and the
pointer along.
The short version:
- Hand the canvas to a worker with
transferControlToOffscreen(). The worker draws on every frame with its ownrequestAnimationFrame, and a busy page doesn't slow it down. - Give the WebSocket its own worker, which turns each message into columns and passes them on without copying.
- Keep the ticks in a
LiveSeries, fed withpushSeries, for hover lookups and anything else that needs the raw data. - Draw one bucket per pixel column, filled as ticks arrive, so a frame costs the same at 10 thousand points or 30 million.
The code is in the pond repo at
apps/worker-chart,
about 900 lines including a made-up feed, plus a headless browser check
and the Node benchmarks behind the numbers below. File paths in square
brackets point at it. It builds on the
offscreen-stream spike,
which measured the method on a MacBook Pro's 120 Hz screen. This example
itself has only been run in a headless browser so far; both sets of
numbers are in section 8.
pushSeriesneeds pond-ts 0.76.0 or newer. (The example links the repo's own copy of pond-ts.)- pond's charts can't run in a worker yet.
LineChartdraws inside React and hands the canvas functions, which can't be sent to a worker. So this guide draws with its own code: about 100 lines for the line, plus about 60 for axes and labels. A worker mode for the charts is planned (section 10).
feed ──WebSocket──▶ socket worker ──columns──▶ render worker
decodes each (transferred) LiveSeries + buckets,
message draws every frame on
the OffscreenCanvas
▲ │
size, pointer x │ │ stats, picked tick
page (DOM, tooltip) ────────────┘ ◀──────────┘
1. Why the main thread is the problem
Everything a page does in JavaScript runs on one thread, and a chart drawn by the page's JavaScript can only update when that thread is free. At 120 Hz a frame comes every 8.3 ms. A chart drawn on the page thread therefore shares those 8.3 ms with everything else the app does, and a long task delays the frame. Moving the drawing into a worker gives it a thread of its own.
The spike measured exactly this on the Mac, at the screen's full resolution, with 100,000 ticks a second and a 10-second window (about 990,000 points), while the page burned 40 ms of every 50:
| Chart drawn on | Frames a second | Gap before the slowest 5% of frames | Age of the newest tick |
|---|---|---|---|
| a worker | 120 | 10.3 ms | 15 ms |
| the page thread | 33 | 47 ms | 56 ms |
| a worker, buckets | 120 | 10.2 ms | 10 ms |
| the page thread, buckets | 60 | 44 ms | 30 ms |
The gap column means the slowest 5% of frames each came at least that long after the frame before; at 120 fps the normal gap is 8.3 ms. The age column is the median, taken at the end of each frame, of how long ago the newest tick on screen was sent.
The first two rows used @pond-ts/charts' own line drawing; the last two
the bucket drawing in section 5. All four took ticks in with the spike's
code (pushMany plus an internal hook), not pushSeries. Either way the
worker chart didn't notice the busy page, while the page's own frame rate
fell to 62. The ticks get older on the page thread too, because the
socket's messages wait behind the page's work. (The 10 ms gap on the
worker is the Mac's normal frame-to-frame jitter: on a quiet page the
page-thread chart showed the same.)
2. The page hands over the canvas
The page creates both workers, connects them with a MessageChannel, and
sends the render worker the canvas:
// [src/page.ts], trimmed
const render = new Worker('render-worker.js', { type: 'module' });
const socket = new Worker('socket-worker.js', { type: 'module' });
const toRender = (msg: ToRender, transfer: Transferable[] = []) =>
render.postMessage(msg, transfer);
// The socket worker sends batches straight to the render worker over this
// channel; they never pass through the page.
const feed = new MessageChannel();
const offscreen = canvas.transferControlToOffscreen();
const box = canvas.getBoundingClientRect();
toRender(
{
type: 'init',
canvas: offscreen,
feed: feed.port2,
width: box.width,
height: box.height,
dpr: devicePixelRatio,
columns: Math.ceil(screen.width * devicePixelRatio),
windowMs: windowSec * 1000,
},
[offscreen, feed.port2],
);
socket.postMessage(
{
url: `ws://${location.host}/stream?rate=${rate}&prefill=${windowSec}`,
port: feed.port1,
},
[feed.port1],
);
Three things to notice:
-
The canvas and the port go in the transfer list (the second argument). A transferred object moves to the worker; the page can't use it afterwards.
-
After the transfer the page can't set the canvas's pixel size. The
canvaselement still sits in the page and CSS still sizes it, but only the worker can setwidthandheight. So the page watches its size and tells the worker:// [src/page.ts]const sendSize = () => {const { width, height } = canvas.getBoundingClientRect();toRender({ type: 'resize', width, height, dpr: devicePixelRatio });};new ResizeObserver(sendSize).observe(canvas);A
ResizeObserverdoesn't fire when only the pixel ratio changes (the window moved to a screen with a different one), so the example also listens to amatchMedia('(resolution: …dppx)')query. -
columnsis the width in device pixels of the screen the page opened on, fixed for the session. Section 5 explains why.
The example uses three things from the browser: OffscreenCanvas,
requestAnimationFrame inside a worker, and module workers
(type: 'module'). Every current major browser has all three. The last to
arrive were Safari 16.4 (March 2023) and Firefox 114 (June 2023); Chrome
and Edge had them by version 80. Where a worker has no
requestAnimationFrame the example falls back to a 60 Hz timer, but
Chrome, the only browser I ran it in, didn't need it, so that path is
untested. The numbers in this
guide are from Chrome only.
Errors inside a worker are easy to miss, and the chart can look alive with
its feed dead. So the example shows any worker error (worker.onerror)
and a closed socket in red above the status line, where they stay until
the page is reloaded.
3. The socket gets its own worker
The socket worker does one thing: it reads each WebSocket message and
passes the ticks on as one Float64Array per column. The feed sends
binary messages,
[sendTime, n, droppedSoFar, sentSoFar, time0, price0, size0, time1, …],
every 16 ms. droppedSoFar counts the ticks the feed has dropped because
the browser fell behind (once the browser has caught up with the history,
the feed lets about half a second of ticks wait on the socket, then
drops). sentSoFar counts the ticks sent before this message,
so the render worker can tell if any went missing on the way; the headless
check fails if any did.
// [src/socket-worker.ts], trimmed
self.onmessage = (ev: MessageEvent<{ url: string; port: MessagePort }>) => {
const { url, port } = ev.data;
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onmessage = (m: MessageEvent<ArrayBuffer>) => {
const frame = new Float64Array(m.data);
const n = frame[1]!;
const batch: Batch = {
time: new Float64Array(n),
price: new Float64Array(n),
size: new Float64Array(n),
dropped: frame[2]!,
sentSoFar: frame[3]!,
};
for (let i = 0, o = 4; i < n; i++, o += 3) {
batch.time[i] = frame[o]!;
batch.price[i] = frame[o + 1]!;
batch.size[i] = frame[o + 2]!;
}
// Transfer, don't copy: after this the arrays belong to the render worker.
port.postMessage(batch, [
batch.time.buffer,
batch.price.buffer,
batch.size.buffer,
]);
};
// A dead feed would otherwise look like a quiet market.
ws.onclose = (e) => {
const msg: FeedClosed = { type: 'closed', code: e.code, reason: e.reason };
self.postMessage(msg);
};
};
Whatever format your feed uses (JSON, protobuf, a vendor's binary), this
is where it becomes columns. binaryType = 'arraybuffer' matters: the
default hands you a Blob, which has to be read asynchronously.
Why a separate worker, when the render worker could open the socket
itself? The spike tested it at 1M ticks a second by adding a fixed
amount of busy work to every frame and drawing from a plain array (no
LiveSeries). On the Mac, a socket on the drawing worker took in every
tick until that busy work filled about 70% of the thread, and fell behind
from 89%. With the socket on its own worker, every tick got through even
at 98%. On a 4-core cloud machine the difference showed much earlier: the
drawing worker's own socket fell behind with the thread only 3–4% busy,
while with a separate socket worker every tick got through and the newest
tick reached the screen in a median of 19–36 ms. Why the shared socket
falls behind so early wasn't established, and each machine had only three
runs. The extra worker costs about 40 lines, so I'd use it anyway.
4. Ticks into a LiveSeries
The render worker keeps the ticks in a LiveSeries that holds one
window: maxAge drops rows older than the newest tick minus the window.
// [src/render-worker.ts], trimmed
import { LiveSeries, TimeSeries } from 'pond-ts';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'price', kind: 'number' },
{ name: 'size', kind: 'number' },
] as const;
live = new LiveSeries({
name: 'ticks',
schema,
retention: { maxAge: windowMs },
});
function ingest(batch: Batch) {
const n = batch.time.length;
if (n === 0) return;
const { time, price, size } = batch;
const series = TimeSeries.fromColumns({
name: 'ticks',
schema,
columns: { time, price, size },
});
// The live series keeps these arrays as they are, so nothing may write to
// them after this line. Reading them, as the loop below does, is fine.
live.pushSeries(series);
for (let i = 0; i < n; i++) buckets.add(time[i]!, price[i]!);
buckets.evictBefore(time[n - 1]! - windowMs - buckets.ms);
newestTime = time[n - 1]!;
newestArrival = performance.now();
changed = true;
}
pushSeries
takes a batch built by
TimeSeries.fromColumns and
stores its columns as they are: no row objects, no check per cell. Things
to know:
- The live series keeps your arrays. It doesn't copy them, so don't write to them, reuse them or transfer them again while their rows are in the window. Here each message gets fresh arrays and the render worker never sends them on, so that holds. (Batches of 8 rows or fewer are copied anyway.)
- Push first, then add the ticks to the buckets.
pushSeriesstores all of a batch or none of it: it throws on a batch that goes back in time or has a gap in a required column. Adding to the buckets first would put a refused batch on the chart. - Retention follows the data's time, not the clock, and runs only inside a push. If the feed stops, the window stops ageing too.
- Only the render worker imports pond. The socket worker imports types only, so its bundle stays under 1 KB. The render worker's bundle, minified, is about 120 KB gzipped.
Why keep a LiveSeries at all, when the drawing reads only the
buckets? Because the buckets throw the ticks away. Hover needs the real
tick under the pointer (section 7), and anything beyond the line (an
aggregate, a rolling average, a snapshot to export) needs the raw window.
5. Drawing one bucket per pixel column
A chart that redraws a line through every point gets slower as the window
fills. @pond-ts/charts avoids most of that cost on still charts by
thinning the points to a few per pixel column and caching the result. A
chart that scrolls every frame can't reuse that cache (the data and the
time range both change each frame), so every frame thins every point
again. In the spike, drawing from a plain array, that held 120 fps up to
about a million points (4.3 ms a frame) and fell to 74 fps at 3 million.
So this chart never draws the ticks. It keeps one bucket per device-pixel column of the window, holding the first, lowest, highest and last price that fell in it, and updates the newest bucket as each tick arrives:
// [src/buckets.ts], trimmed
export class Buckets {
constructor(windowMs: number, columns: number) {
this.ms = windowMs / columns; // time covered by one bucket
this.cap = columns + 2; // the window plus the partly visible edges
// k, lo, hi, first, last, n: one Float64Array each, used as a ring
}
add(time: number, value: number): void {
const k = Math.floor(time / this.ms);
if (this.len > 0) {
const i = (this.head + this.len - 1) % this.cap;
if (this.k[i] === k) {
if (value < this.lo[i]!) this.lo[i] = value;
if (value > this.hi[i]!) this.hi[i] = value;
this.last[i] = value;
this.n[i]!++;
return;
}
}
if (this.len === this.cap) this.dropOldest();
const i = (this.head + this.len++) % this.cap;
this.k[i] = k;
this.lo[i] = this.hi[i] = this.first[i] = this.last[i] = value;
this.n[i] = 1;
}
}
A frame draws one line through the buckets: into each column at its first value, down to its lowest, up to its highest, out at its last. A pixel column can't show more than its lowest and highest point, so this should look close to a line through every tick. (I haven't compared the two image by image, and the buckets sit on a fixed time grid while the chart scrolls smoothly, so their edges don't line up exactly with pixels.)
// [src/buckets.ts], trimmed
export function drawBuckets(ctx, b: Buckets, x, y): void {
ctx.beginPath();
for (let j = 0; j < b.len; j++) {
const i = (b.head + j) % b.cap;
const px = x((b.k[i]! + 0.5) * b.ms);
if (j === 0) ctx.moveTo(px, y(b.first[i]!));
else ctx.lineTo(px, y(b.first[i]!));
if (b.n[i]! > 1) {
ctx.lineTo(px, y(b.lo[i]!));
ctx.lineTo(px, y(b.hi[i]!));
ctx.lineTo(px, y(b.last[i]!));
}
}
ctx.stroke();
}
Each tick costs a compare and a store when it arrives, and a frame costs the number of columns, whatever the window holds. In the spike the bucket drawing held 120 fps in every run, at 0.48–0.60 ms a frame on full windows, including a 5-minute window of 29.4 million points (again from a plain array).
Why the bucket count is the screen's width, not the canvas's. The
buckets are fixed to one time per column when they're made. If the canvas
gets wider, more buckets would need rebuilding from the raw ticks, and
reading a window back out of a LiveSeries is slow today
(toTimeSeries() takes about half a second at 600,000 rows in Node).
Sizing the buckets to the screen the page opened on means the canvas can't
outgrow them there: a resize only changes the scale they're drawn at, and
a canvas narrower than the screen draws a few buckets per pixel, which
costs little. If the window moves to a wider or denser screen, each bucket
covers more than a pixel and the line gets coarser until a reload.
6. The frame loop
The worker draws on every animation frame, whether or not new ticks came, because the time axis moves every frame:
// [src/render-worker.ts], trimmed
// Workers in current browsers have requestAnimationFrame; fall back to a
// timer where one doesn't.
const nextFrame = () =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(frame)
: setTimeout(frame, 1000 / 60);
// Ticks arrive every 16 ms; between them, move the right edge on with the
// clock so the chart scrolls smoothly instead of in steps. A batch that
// arrives later than the last one (relative to its times) would pull the
// edge back a few pixels, so the edge waits for the clock to catch up
// instead.
const rightEdge = (now: number) =>
(lastEdge = Math.max(lastEdge, newestTime + (now - newestArrival)));
function frame() {
nextFrame();
const now = performance.now();
if (changed) {
const [lo, hi] = buckets.range()!;
// At least 0.01% of the price, so a flat price (even one that differs
// only by rounding) still gets a range the axis can label.
const pad = Math.max((hi - lo) * 0.05, Math.abs(hi) * 1e-4) || 1;
yLow = lo - pad;
yHigh = hi + pad;
changed = false;
}
const edge = rightEdge(now);
const start = edge - windowMs;
const x = (t: number) => PAD.left + ((t - start) / windowMs) * plotW;
const y = (v: number) => PAD.top + ((yHigh - v) / (yHigh - yLow)) * plotH;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
ctx.fillStyle = COLOR.background;
ctx.fillRect(0, 0, width, height);
drawAxes(start, edge, x, y, plotW, plotH);
// clip to the plot, then drawBuckets(ctx, buckets, x, y)
// the last-price tag reads buckets.newest(), not the live series
// the hover cursor: section 7
}
- Without the moving edge the chart would step every 16 ms when a batch lands, instead of gliding. With it, a batch that arrives a few milliseconds late would make the edge jump back, so the edge never moves left.
- The y range is recomputed only when ticks arrive, from the buckets' lows and highs, so it's a few thousand comparisons, not a pass over the ticks.
- The y range has a floor. A review of this example found that prices differing only by rounding (0.3 and 0.1 × 3) gave a range so small the axis loop's step couldn't move its counter, and the worker hung. The floor fixes that, and the axis loop also stops after 20 lines.
- Nothing in the frame reads the
LiveSeries. The newest price comes from the buckets. Per-frame reads from a live series cost more than they look (section 7).
7. Hover: the page asks, the worker answers
The tooltip is a div on the page, but the data is in the worker. The
page sends the pointer's x; the worker turns it into a time, asks the
LiveSeries for the tick at or before it, and sends that tick back. The
worker draws the cursor line; the page draws the tooltip, in local time
like the axis.
// [src/render-worker.ts]
function pick(px: number | null) {
const plotW = width - PAD.left - PAD.right;
const inside =
px !== null &&
newestTime > -Infinity &&
plotW > 0 &&
px >= PAD.left &&
px <= PAD.left + plotW;
const start = rightEdge(performance.now()) - windowMs;
const event = inside
? live.atOrBefore(start + ((px - PAD.left) / plotW) * windowMs)
: undefined;
hovered = event
? {
time: event.begin(),
price: event.get('price'),
size: event.get('size'),
}
: null;
post({ type: 'hover', tick: hovered });
}
The picked tick stays picked until the pointer moves again, so the cursor
scrolls left with the data and the tooltip holds still long enough to
read. That also keeps the lookups out of the frame loop, which matters
more than it looks, for two reasons. I measured both in Node
(bench/lookups.mjs, output in results/lookups.txt):
- A lookup in the middle of the window walks the stored batches. At
600,000 rows, an
atOrBeforetook a median of about 4 µs with the rows in one batch, 11–15 µs in 375 batches, and 74–80 µs in 3,750 batches, which is what a 60-second window fed every 16 ms holds. - Lookups slow down later pushes. The live series caches every row a
lookup returns, and each push that drops old rows rebuilds that cache.
At 10,000 ticks a second with a 60-second window, a
pushSeriestook 0.005–0.007 ms with no lookups, 0.13–0.18 ms with one lookup per push and 0.27–0.32 ms with two.
At 120 fps the frame loop runs about twice per push, so a lookup on every frame would cost 30–40 ms of every second (3–4% of the thread), the lookups and the slower pushes together, for a tooltip nobody can read that fast.
8. What it costs
On the Mac (from the spike). Chrome on a MacBook Pro M1 Pro with its
120 Hz screen, at full resolution (about 2,260 pixel columns), one run on
battery with Spotlight indexing in the background. These used the
spike's own code, not this example. Rows 1, 2 and 5 took ticks in with
pushMany plus an internal hook rather than pushSeries; rows 3 and 4
drew from a plain array, with no LiveSeries:
| Setup | Result |
|---|---|
1M ticks/s into a LiveSeries in a worker, bucket drawing, 10 s window | 120 fps, newest tick 11 ms old |
| Same, socket on its own worker | 120 fps, 12 ms |
| Bucket drawing alone, full windows up to 29.4M points | 120 fps, 0.48–0.60 ms a frame |
@pond-ts/charts line drawing, scrolling | 120 fps at 1M points (4.3 ms a frame), 74 fps at 3M |
Taking ticks in at 1M/s (pushMany, an internal hook, buckets) | 75 ms of each second, under 8% of the worker |
(The 1M ticks/s window was about 89% full. "Newest tick 11 ms old" is the median age of the newest tick on screen at the end of each frame; it includes up to 16 ms waiting for the next message, and it needs the feed and the browser on one machine's clock.)
This example, in a headless browser. npm run check in a 4-core
cloud container, headless Chromium at one device pixel per CSS pixel:
1,200 buckets (the headless screen's width) drawn into a plot 1,096 pixels
wide. Headless frame rates are capped at 60 and say nothing about a real
screen, so the table shows work, not frames. Each cell is the range over
six one-second samples, three of them with the page busy, all with the
window full (results/check-headless.txt):
| Ticks/s, window | Ticks held | fromColumns, ms a second | pushSeries, ms a second | Buckets, ms a second | JavaScript per frame | Age of the newest tick |
|---|---|---|---|---|---|---|
| 10k, 60 s | 600k | 2.2–4.3 | 3.8–5.4 | 0.4–0.8 | 0.57–0.69 ms | 9–13 ms |
| 100k, 30 s | 3M | 3.8–5.0 | 5.4–7.1 | 2.1–2.9 | 0.63–0.76 ms | 10–15 ms |
| 1M, 5 s | 5M | 11–20 | 33–44 | 23–31 | 0.63–0.81 ms | 19–27 ms |
So at 100,000 ticks a second, taking ticks in (building each batch's
TimeSeries, pushSeries and the buckets together) took about 1% of the
render worker's time, and at 1M a second 7–9%. A Node run of the same
pushes shows where pushSeries' share goes at 1M (bench/retention.mjs,
results/retention.txt): with a 5-second maxAge, pushSeries cost
1–4 ms a second while the window filled and 29–67 once rows had to be
dropped; with maxEvents holding the same 5 million rows it cost 1–2,
and with no retention 0–1. Building each batch with fromColumns cost
10–31 ms a second in every case. So in Node nearly all of pushSeries'
cost is maxAge working out which rows to drop, which it does by checking
the oldest rows' times one at a time ([PND-AGESCAN]). In every run, the
busy page left the chart's frame rate where it was.
9. When LineChart is the better choice
The worker route trades pond's charts for speed and a free page thread.
Use LineChart instead when:
- The page isn't busy, and the window is small or updates can wait.
LineChartdraws aTimeSeries, so live data reaches it as a snapshot (toTimeSeries(), usually throughuseSnapshot), and copying that snapshot, not drawing it, is what limits a liveLineChart. In the spike a snapshot on every frame was too slow for 120 fps somewhere between 10,000 and 60,000 points.useSnapshotrefreshes at most 10 times a second by default, which suits a chart that doesn't need to glide (see Rendering large time series). - You want what the charts give you: axes and formats, themes, several layers and series, annotations, zoom, cursors and readouts, and React. Everything in this example beyond the line is hand-written.
- The chart is mostly still. Buckets suit a window that scrolls; they
are fixed to one zoom level, so zooming or panning needs the raw ticks
again. A still
LineChartthins and caches its points, and the same guide shows it panning at about 90 fps at a million points.
Reach for the worker when the page thread is genuinely busy (a heavy React app, big tables, other charts), when the window holds millions of points that scroll every frame, or when steady 120 fps matters more than the charts' features.
10. What's coming
These are planned in pond and would shrink this example. The bracketed names are tasks in pond's PLAN.md.
- A worker mode for the charts ([PND-OFFSCREEN]). Today the charts can't move into a worker: each layer hands the canvas a function with its data built in, the drawing functions aren't exported, and hover and CSS themes read the page. When it lands, the render worker would draw a pond chart instead of section 5's code.
- A streaming draw for scrolling charts ([PND-STREAMDRAW]): buckets like section 5's, filled as ticks arrive, either as a charts drawing mode or as a live layer (not decided yet).
- A faster snapshot ([PND-LSNAP]): a
toTimeSeries()that copies the stored columns instead of rebuilding rows. In the spike, copying the columns of 600,000 rows took 16 ms in Node, against about half a second for today'stoTimeSeries(). Rebuilding the buckets after a resize or a zoom would then cost one copy of the window. - Pushes that don't slow down after lookups ([PND-LATCACHE]), and lookups that don't walk every batch ([PND-LIVELOCATE]): the two costs in section 7.
- A cheap
clear()([PND-LIVECLEAR]): see "Reconnecting" below. - A cheaper
maxAge([PND-AGESCAN]): finding the cutoff by binary search instead of row by row, the cost in section 8.
11. Sharp edges
- No limit between the workers. The socket worker forwards every message the moment it arrives. If the render worker fell behind, the messages would queue in the browser rather than the feed dropping ticks. It never fell behind in these runs; a real app might count unanswered batches and drop or merge when there are too many.
- Gaps are drawn as straight lines. An empty column is skipped, so the
line joins its neighbours. When the feed drops ticks, the hole looks
like a straight stretch of data. Break the line where
kjumps if your feed can have holes. - Reconnecting needs a new series. A new connection resends history
that's older than the rows already held, and
pushSeriesrefuses it. Make a newLiveSeriesand new buckets rather than callinglive.clear(): on this seriesclear()builds an object for every row it drops, and took 3.2 s at 600,000 rows and 12.4 s at 5 million in Node, against 0.1–0.2 ms for a new series (bench/clear.mjs,results/clear.txt). The example shows that the feed closed and stops there. - The edge keeps scrolling when the feed stops. That's honest (time is passing) but it can hide a dead feed. The example also shows the closed socket; for a feed that stays open but goes quiet, watch the ticks-per-second figure, not the motion.
- The age figure needs one clock. It compares the feed's timestamp with the browser's clock, which only works with both on one machine.
- Fonts. The worker's canvas can't use web fonts the page loaded
through CSS; the example uses system fonts. Load a
FontFacein the worker if you need one. - The y range jumps with every new high or low. Ease it if that bothers you.
- Not covered: several series, zoom and pan, touch, browsers other than Chrome for the numbers, and this example on a real 120 Hz screen (the spike's code ran on one).
12. Running it yourself
git clone https://github.com/pond-ts/pond && cd pond
npm install && npm run build --workspace=pond-ts
cd apps/worker-chart && npm install
npm start # http://localhost:8787/?rate=10000&window=60
PORT=9000 npm start uses another port. Tick the "busy page" box and
watch the chart's frame rate stay put while the page's falls.
npm run check runs the headless check (after
npx playwright install chromium, or with CHROMIUM_PATH set to a Chrome
or Chromium you already have); RATE=100000 WINDOW=30 npm run check
tries a heavier feed. npm run bench runs the Node benchmarks behind
sections 7, 8 and 11. CI doesn't build the example, so the check is how
it's verified.
For the pieces on their own: live series,
pushSeries,
live charts with @pond-ts/charts and
rendering large time series. The spike's
full report
has the Mac runs; this example's headless and Node runs are in
apps/worker-chart/results.