Nonius, home

How a point travels

Nonius sends a series as columns of integers that the browser reads where they land: no JSON, no decoder. Five steps, from one point to a live feed, shown with the bytes of a real tile.

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.

  1. NLS1start: from 0
  2. NLF1 · 0applied
  3. NLF1 · 1lost
  4. NLF1 · 2a gap: reconnect from 0
  5. NLF1 · 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.