Your data
Every package takes the same thing: points { t, v, w }, in time order.
A sensor reading, a trade and a CPU sample all enter this way, and
Nonius never knows which. This chapter is how your readings become
points, and how points become a series file.
A point
| Field | What it is | Type |
|---|---|---|
t | the time: nanoseconds since 1970-01-01 00:00 UTC | a signed 64-bit integer |
v | the value: an integer with a fixed number of decimal places | a signed 64-bit integer |
w | the weight: 1 for a plain reading | an unsigned 64-bit integer |
All three are integers, and that is the point of the format: nothing passes through a floating-point number on the way to the screen, so nothing is rounded.
Time
A time is a count of nanoseconds since the Unix epoch, in UTC — never a local time. A 64-bit count reaches about 292 years either side of 1970, and keeps every nanosecond: a view of a few nanoseconds in 2026 still draws each point where it is. That is the “exact at any zoom” of Nonius.
Converting is a multiplication:
| From | To nanoseconds |
|---|---|
| Unix seconds | × 1,000,000,000 |
JavaScript Date.parse (milliseconds) | × 1,000,000, as a BigInt |
.NET DateTime (UTC) | (time - DateTime.UnixEpoch).Ticks * 100 |
| An ISO 8601 text, in a CSV | nonius-server csv reads it, with Z or an offset such as -03:00, up to nine decimal places of seconds |
Points come in time order. Two points may share a time; a point earlier than the one before it is refused, with the row where it happened.
Values
A value is an integer, and the series says how many decimal places it
has: its scale (also called its exponent). At two places, 21.37 °C is
2137; at three, a voltage of 4.217 is 4217. The scale is one number
for the whole series, chosen once: the most decimal places your values
have.
Why not a floating-point number? Because 0.1 + 0.2 is not 0.3 in
one, and a chart that summarises millions of points would carry those
errors into its labels. An integer is exact, and the scale puts the
decimal point back only when a label is drawn.
A 64-bit integer holds about 9.2 × 10¹⁸: at two decimal places, values up to about 92 million billion. Choosing more places than your data has costs nothing but range.
The weight
w is how much a point counts. For a reading, it is 1. For a trade, it
can be its volume. The chart’s summaries add the weights
up, so a summary of a minute knows how much happened in it, not only its high
and low. Left out (in JavaScript’s encodeNsb, in a CSV without a
weight column), every weight is 1.
Gaps
The line joins each point to the next, however far apart they are. A
sensor that reports in bursts, or one that was switched off for a day,
is drawn as one continuous line unless you say where the line breaks:
chart.setSeriesGap(0, 60_000_000_000n) in the browser (the series’
id, 0 for the first, then nanoseconds as a BigInt), gap= on a
server’s image, Gap in .NET. The line breaks wherever a point comes
more than that many nanoseconds after the one before.
The chart does not guess the gap: you know your data’s rhythm, and a guess would break the line in different places at different zooms. A few times the usual spacing is a good start: for a reading every 10 seconds, a minute keeps a lost reading or two joined and still breaks at an outage.
Time zones
The data is always in UTC. A time zone changes only the labels: name
one (timeZone: "Europe/Lisbon" in the browser, tz= on a server’s
image, a TimeZoneInfo in .NET) and the axis, the crosshair and the
tooltip read the local wall clock, on both sides of each change to or
from daylight saving time.
Writing a series file
A series file — format NSB1 — holds the three columns, the scale and
a checksum. Every package writes the same bytes for the same points,
through the same Rust code, so a file written in .NET is read by the
browser and served by nonius-server without a step between.
From a CSV, with no code
nonius-server turns a CSV into a series file. A line is a time and a
value, a header first or not:
time,price
1788220800000000000,101.25
1788220800000001500,99.5
nonius-server csv prices.csv prices.nsb
nonius-server csv sheet.csv day.nsb --delimiter ';' --time time --value price --weight volume
The first line takes the first column as the time and the second as the
value. The second names the columns of a spreadsheet’s export, with
; between fields and a decimal comma. Nothing is rounded: a value is
kept with the most decimal places any value in the file has. The
options (--time-unit, --decimals and the rest) are on
nonius-server’s page.
From Rust
#![allow(unused)]
fn main() {
// A reading of 21.37 °C at two decimal places is 2137; w is 1 for a
// plain reading.
let points = [Point { t: 1_735_689_600_000_000_000, v: 2137, w: 1 }];
let file = File::options().read(true).write(true).create(true).truncate(true).open("day.nsb")?;
write_series(file, &points, Scale::new(2)?)?;
}
write_series is in nonius-core, with its std feature. The file is
opened readable as well as writable: the writer reads what it wrote
back, to hash it.
From .NET
(DateTime Time, decimal Celsius)[] readings =
[
(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), 21.37m),
(new DateTime(2026, 3, 1, 0, 0, 1, 500, DateTimeKind.Utc), 21.4m),
(new DateTime(2026, 3, 1, 0, 0, 3, DateTimeKind.Utc), -0.05m),
];
// A time is nanoseconds since 1970 (UTC), and a tick is 100 of
// them. A value is an integer of the decimal places kept, here 2:
// 21.4 is 2140. w is 1 for a plain reading.
var points = readings
.Select(r => new Point((r.Time - DateTime.UnixEpoch).Ticks * 100, (long)decimal.Round(r.Celsius * 100m), 1))
.ToArray();
using var file = File.Create(path);
Nsb.Write(file, points, 2);
Nsb.Encode(points, 2) gives the same bytes as a byte[]. Both are in
the NuGet package Nonius.
From JavaScript
In Node (20.16 or later) or in the browser, encodeNsb from the npm
package nonius:
const readings = [
{ time: "2026-03-01T00:00:00Z", celsius: "21.37" },
{ time: "2026-03-01T00:00:01.5Z", celsius: "21.4" },
{ time: "2026-03-01T00:00:03Z", celsius: "-0.05" },
];
// A time is nanoseconds since 1970 (UTC), a BigInt. `Date` counts
// milliseconds, so a finer time needs its own parsing.
const nanoseconds = (iso) => BigInt(Date.parse(iso)) * 1_000_000n;
// A value is an integer of the decimal places you keep, here 2, read
// from its text and never through a float: "21.4" is 2140.
function hundredths(text) {
const [whole, fraction = ""] = text.split(".");
if (fraction.length > 2) throw new Error(`${text} has more than 2 decimal places`);
const sign = whole.startsWith("-") ? -1n : 1n;
return sign * (BigInt(whole.replace("-", "")) * 100n + BigInt(fraction.padEnd(2, "0")));
}
const t = BigInt64Array.from(readings, (r) => nanoseconds(r.time));
const v = BigInt64Array.from(readings, (r) => hundredths(r.celsius));
writeFileSync("readings.nsb", await encodeNsb({ t, v }, 2));
The value is read from its text on purpose: Math.round(21.4 * 100) is
right here, but a float cannot hold every decimal, and the text always
can.
Next
With a series file, choose a path to the chart. If it will be served in tiles, the pyramid is how the server summarises it.