@zakkster/lite-axis
v1.0.0
Published
Zero-GC, high-performance tick generation and label formatting for charts. Produces linear, logarithmic, and time-scale ticks without hot-path heap allocations.
Downloads
70
Maintainers
Readme
@zakkster/lite-axis
📈 What is lite-axis?
@zakkster/lite-axis is a zero-GC tick-generator and label-formatter toolkit for charts. Given a numeric domain [min, max] and a target tick count, it produces the "nice" tick values humans expect — 1, 2, 5, 10, 25, 50, 100, ... — without ever allocating on the hot path.
It gives you:
- 📏 3 tick generators — linear (1/2/5 family), log (decade + 2×/5×), and UTC time (ms → year)
- 🔤 3 label formatters — numbers, currency, and time → ASCII char codes
- ✂️ Collision-aware label thinning — culls overlapping labels by pixel distance
- 0️⃣ Zero allocations on the hot path — caller owns every buffer
- 🎯 Char-code output — drop straight into
@zakkster/lite-bmfont'sdrawFastAPI - 🛡️ Strictly UTC — no
Intl.DateTimeFormat, no DST surprises, no locale lookups - 🪶 Tiny — no dependencies, ESM, fully tree-shakeable
Part of the @zakkster/lite-* ecosystem — micro-libraries built for deterministic, cache-friendly game and chart rendering.
🚀 Install
npm i @zakkster/lite-axis⚡ Quick Start
import {
linearTicks, logTicks, timeTicks,
formatNumber, formatCurrency, formatTime,
thinLabels,
TIME_UNIT
} from '@zakkster/lite-axis';
// 1. Tick values land in a Float64Array you own
const ticks = new Float64Array(20);
const n = linearTicks(12, 87, 6, ticks);
// ticks ≈ [0, 20, 40, 60, 80, 100], n = 6
// 2. Format them into a Uint8Array of ASCII char codes
const buf = new Uint8Array(16);
const written = formatNumber(ticks[1], 0, buf, 0);
// buf[0..written] = "20"
// 3. Time axes pick the right unit for you
const timeOut = new Float64Array(20);
const res = timeTicks(Date.UTC(2024, 0, 1), Date.UTC(2024, 6, 1), 6, timeOut);
// res.unit === TIME_UNIT.MONTH, res.count === 7
// 4. Thin overlapping labels by pixel distance
const px = new Float64Array(20);
const idx = new Int32Array(20);
for (let i = 0; i < n; i++) px[i] = (ticks[i] / 100) * canvasWidth;
const kept = thinLabels(ticks, n, px, /* minPx */ 50, idx);
// idx[0..kept] holds indices of the labels to actually renderEvery function takes pre-allocated buffers and returns the number of items written. No allocations. No GC pressure. No reentrancy bugs.
📊 Comparison
| Library | Size | Allocations / call | Tick types | Format | |---------|------|--------------------|------------|--------| | d3-scale + d3-time-format | ~30 KB | many | linear / log / time | ESM | | chartjs-adapter-date-fns | ~80 KB | many | time only | ESM (peer dep) | | lite-axis | ~3 KB | 0 (caller-owned buffers) | linear / log / time | ESM, tree-shakeable |
⚙️ API
Tick Generators
linearTicks(min, max, target, out, mode?)
Generate tick values from the 1/2/5/10 family.
const out = new Float64Array(20);
const n = linearTicks(12, 87, 5, out, 'extend');
// out = [0, 20, 40, 60, 80, 100], n = 6 — rounds outward (default)
const n2 = linearTicks(12, 87, 5, out, 'inside');
// out = [20, 40, 60, 80], n2 = 4 — strictly within [min, max]'extend'(default) — rounds outward; ticks may exceed[min, max]. Best for grid lines.'inside'— bounds ticks strictly within[min, max]. Best when ticks are axis-bounded.- Reversed bounds (
max < min) are swapped internally. - Floating-point artifacts are snapped (
0.30000000000000004→0.3).
logTicks(min, max, target, out, minor?)
Generate base-10 logarithmic ticks.
const out = new Float64Array(20);
const n = logTicks(0.5, 1500, 5, out);
// out = [1, 10, 100, 1000], n = 4
const n2 = logTicks(10, 100, 10, out, /* minor */ true);
// out = [10, 20, 50, 100], n2 = 4 — adds 2×/5× sub-ticks- Always emits decade boundaries (
1, 10, 100, ...). minor=trueadds 2× and 5× sub-ticks per decade when there's room.- When the domain spans more decades than
targetallows, decades are sparse-skipped andminoris silently ignored. - Returns
0ifmin <= 0ormax <= 0.
timeTicks(minMs, maxMs, target, out)
Generate ticks for a time axis. Returns a shared result object indicating which time unit was chosen — pass that unit straight to formatTime.
const out = new Float64Array(20);
const res = timeTicks(
Date.UTC(2024, 0, 1),
Date.UTC(2024, 6, 1),
6,
out
);
// res.unit === TIME_UNIT.MONTH
// res.count === 7
// out[0..7] = monthly UTC timestampsThe function picks an interval from this ladder based on span and target:
| Span | Interval | |-----------------------|-----------------------------------------------------| | < 1 second | 1, 5, 10, 50, 100, 500 ms | | < 1 minute | 1, 5, 15, 30 s | | < 1 hour | 1, 5, 15, 30 min | | < 1 day | 1, 3, 6, 12 h | | < 1 month | 1 d, 2 d, 1 w, 2 w | | < ~6 months | calendar months (snapped to month boundaries) | | ≥ ~6 months | calendar years (snapped to year boundaries) |
⚠️ UTC contract:
timeTicksandformatTimeare strictly UTC. They never consult the host locale or DST rules. For exchange-local time (NYSE EST, Tokyo JST), apply the offset to your bounds before calling, then subtract it when mapping back to data coordinates.
⚠️ Shared result object:
timeTicksreturns a module-level singleton mutated in place. Read its fields (or copy them out) before calling again.
Label Thinning
thinLabels(ticks, count, pixels, minPixelSpacing, outIndices)
Given pre-mapped pixel positions, walk the tick array and keep only labels at least minPixelSpacing pixels apart. Greedy keep-from-left.
const ticks = new Float64Array([10, 20, 30, 40]);
const pixels = new Float64Array([400, 380, 320, 310]); // Y-axis: descending
const idx = new Int32Array(4);
const kept = thinLabels(ticks, 4, pixels, 50, idx);
// idx[0..kept] = [0, 2] — keeps tick @400px and @320pxDimension-agnostic: works for X-axes (pixels increase) and Y-axes (pixels decrease) because the spacing check uses absolute distance.
Char-Code Formatters
All formatters write directly into a Uint8Array — perfect for SDF/MSDF text renderers like @zakkster/lite-bmfont that consume char codes.
formatNumber(value, decimals, out, offset)
const buf = new Uint8Array(16);
let n;
n = formatNumber(-12.56, 1, buf, 0); // "-12.6"
n = formatNumber(99.999, 2, buf, 0); // "100.00" (carry-over)
n = formatNumber(0, 2, buf, 0); // "0.00"
n = formatNumber(NaN, 2, buf, 0); // n === 0 (don't render)formatCurrency(value, symbolCode, decimals, out, offset)
const buf = new Uint8Array(16);
let n = formatCurrency(1234.5, 36, 2, buf, 0); // "$1234.50" — 36 = '$'
n = formatCurrency(7, 35, 0, buf, 0); // "#7" — 35 = '#'If
valueisNaN/Infinity, returns0. The symbol byte may have leaked intoout[offset]— respect the return value and don't render. The stale byte is harmless (the buffer is caller-owned).
formatTime(ms, unit, out, offset)
| unit | Output | Bytes |
|-------------------------|------------|-------|
| MILLISECOND, SECOND | HH:MM:SS | 8 |
| MINUTE, HOUR | HH:MM | 5 |
| DAY, WEEK | MM/DD | 5 |
| MONTH | MMM YY | 6 |
| YEAR | YYYY | 4 |
const buf = new Uint8Array(16);
const ms = Date.UTC(2024, 2, 15, 14, 30, 45);
formatTime(ms, TIME_UNIT.SECOND, buf, 0); // "14:30:45"
formatTime(ms, TIME_UNIT.MONTH, buf, 0); // "Mar 24"
formatTime(ms, TIME_UNIT.YEAR, buf, 0); // "2024"TIME_UNIT
TIME_UNIT.MILLISECOND // 0
TIME_UNIT.SECOND // 1
TIME_UNIT.MINUTE // 2
TIME_UNIT.HOUR // 3
TIME_UNIT.DAY // 4
TIME_UNIT.WEEK // 5
TIME_UNIT.MONTH // 6
TIME_UNIT.YEAR // 7🔥 The full pipeline
import {
timeTicks, formatTime, thinLabels
} from '@zakkster/lite-axis';
// All buffers allocated ONCE, reused every frame
const tickBuf = new Float64Array(64);
const pixelBuf = new Float64Array(64);
const indexBuf = new Int32Array(64);
const charBuf = new Uint8Array(16);
function renderAxis(minMs, maxMs, axisWidth) {
// 1. Generate ticks
const r = timeTicks(minMs, maxMs, 8, tickBuf);
// 2. Map data → pixels
const span = maxMs - minMs;
for (let i = 0; i < r.count; i++) {
pixelBuf[i] = ((tickBuf[i] - minMs) / span) * axisWidth;
}
// 3. Thin overlapping labels
const kept = thinLabels(tickBuf, r.count, pixelBuf, 60, indexBuf);
// 4. Format and draw kept labels
for (let i = 0; i < kept; i++) {
const idx = indexBuf[i];
const n = formatTime(tickBuf[idx], r.unit, charBuf, 0);
// drawFast(charBuf, n, pixelBuf[idx], y); // your text renderer
}
}Zero allocations per frame. Predictable cache layout. No string objects ever created on the hot path.
🧪 Tests
npm test68 tests covering:
- Linear / log / time tick generation across all interval scales
- Edge cases:
min === max, reversed bounds, non-finite, negative ranges, sub-decimal log - Floating-point cleanup (no
0.30000000000000004) - Buffer-boundary correctness for every formatter
- Label thinning across both pixel directions and contract violations
- An end-to-end "tick → format" integration test
📦 TypeScript
Full declarations included in Axis.d.ts, including the TimeUnit type alias and TimeTicksResult interface.
📚 LLM-Friendly Documentation
See llms.txt for AI-optimized metadata and usage examples.
License
MIT
