Step 1
The point: three integers
Every point is a time, a value and a weight, each a 64-bit integer. The value is
scaled: with an exponent of 2, 10800000000 means 108,000,000.00, exactly.
ti64
1788220800000000000
nanoseconds since 1970: 2026-09-01 00:00:00 UTC
vi64
10800000000
× 10−2, the series' exponent: 108,000,000.00
wu64
3
a weight: a volume, a sample count; 1 when there is none
Why not floats
Near 2026, the smallest step between two times a number can hold:
32-bit float137 s
64-bit float256 ns
64-bit integer, Nonius1 ns
Floats appear only at the last moment, after the start of the visible window has been subtracted, when the numbers left are small.
Step 2
NSB1: columns that start on a multiple of 64
Points travel as columns: all the times together, then all the values, then the weights. A container is a 64-byte header, a 48-byte entry per column saying where it starts, the columns themselves, and a block of JSON saying what the tile is. Here is a whole raw tile, the kind that carries the points themselves (step 4), holding two points and no neighbours: all 477 bytes of it.
- header, 0–63
- column directory, 64–207
- zero padding
- t at 256, v at 320, w at 384
- metadata, 448–476
The header, 64 bytes
- 0
- magic, ASCII
NONIUSB1 - 8
- version, 1
- 10
- flags: times in order, times unique
- 12
- column count
- 16
- row count
- 24
- where the columns start
- 32
- where the metadata starts
- 40
- the metadata's length in bytes
- 48
- FNV-1a-64 hash of the columns' bytes, padding included
Byte offsets. Every integer in the container is little-endian; the fields between these are zero.
A column's entry, 48 bytes
- 0
- name, 16 bytes of ASCII padded with zeros
- 16
- type: 1 a signed, 2 an unsigned 64-bit integer, 3 a float
- 17
- exponent: 9 for t, the series' for values, 0 for weights
- 24
- where its bytes start
- 32
- its length: 8 bytes a row, without padding
So a value's exponent travels with the tile: in this one, v's entry says 2, and 10800000000 is 108,000,000.00.
The t column, at byte 256
0100: 00 00 f7 db 57 09 d1 18 dc 05 f7 db 57 09 d1 18
Eight bytes a row, least significant first: 0x18d10957dbf70000
is 1788220800000000000, and the next row is 1,500 more, 1.5 µs later.
Read in place
const t = new BigInt64Array(bytes.buffer, 256, 2);
// 1788220800000000000n, 1788220800000001500n
Every column starts on a multiple of 64, so a reader views it as an array where it lies, without parsing it: the layout of Arrow's buffers. The browser package copies a tile once, into the chart's memory, and reads it there. A hash of the columns lets a reader refuse a damaged tile.
Step 3
The pyramid: summaries at every zoom
A day can hold millions of points, and a screen has about two thousand columns of pixels. So the server keeps the series summarised in levels, each level's buckets four times wider than the level below, all aligned to 1970.
A bucket is eight numbers
- first_t
- first_v
- last_t
- last_v
- min
- max
- count
- weight
The first and last points, the least and greatest value, how many, and their weight. Drawn as a span from least to greatest, each column joined to the next, the line is the same, pixel for pixel, as the line drawn from every point.
A view picks its level
Summarised from 1 ms up, a day on 1,920 pixels wants columns 45 s wide. The coarsest level that still gives 16 buckets or more per column is level 5, 1.024 s: 43 of its buckets make a column, 1,964 columns for the day. Zoom in far enough and no level is fine enough: the view reads the raw points.
Step 4
Tiles over HTTP, like a map's
The client asks only for the tiles its view needs. A tile is a fixed run of columns of one level, at an address whose answer never changes, so a browser and every cache on the way keep it for a year.
GET /v1/series/day/columns/0/16/27940950000
- day
- the series
- 0
- the level
- 16
- buckets merged into a column
- 27940950000
- which run of columns: here, four columns a tile
Each tile covers a fixed span: a column tile a run of columns, a raw tile a span of
time: raw tile i covers [i · R, (i + 1) · R) nanoseconds since
1970. The series' metadata, at /v1/series/{id}, gives those widths
(R is its raw_tile_ns). A tile also carries its nearest
neighbour on each side, a column or a point just outside its span, when there is one, so
the line can be drawn into and out of the view. They count among its rows, and a reader
tells them apart by their time.
What comes back
200 OK
Content-Type: application/vnd.nonius.nsb1
ETag: "e490c413ec950166"
Cache-Control: public, max-age=31536000, immutable
An NSB1 container of eight columns, one per bucket field, and three rows: the tile's two columns that hold points, and the next one after it, which the line needs to leave the view.
Its last line says what it is
{"tile":"columns","level":0,"k":16,"i":"27940950000"}
The client checks it against the address it asked for. The ETag is the hash in the
header, so asking again for an unchanged tile costs a 304. Deep in, raw
tiles at /raw/{i} carry the points themselves: three columns, t, v
and w, like step 2's.
Step 5
The live feed: numbered frames
New points arrive on a WebSocket in frames, each numbered and holding the same three columns, 24 bytes a point. Networks lose frames; the numbers show where.
NLS1start: from 0NLF1 · 0appliedNLF1 · 1lostNLF1 · 2a gap: reconnect from 0NLF1 · 1, 2applied, in order
A frame
The magic, the row count, the frame's number, then the columns.
Nothing is guessed
A frame out of turn is never applied. The client reconnects from the last frame it applied, and everything before it is still right. When the feed is quiet, heartbeats name the next number, so even a lost last frame is noticed.
The whole of it
The specifications are written so that a client can be built from them alone, and one was: its columns match the core's.
- The protocol: the container, the pyramid, tiles, metadata, HTTP, picking and images.
- The pyramid file: a series and its summaries, read in place.
- The live feed's wire, in
nonius-live.