Thank you for being patient! We're working hard on resolving the issue
Lona-js provides a pure-data chart model with no DOM dependencies. Frontend consumers — HTML5 canvas, SVG, PDF renderers, native apps — use these models to render charts from synced datasets.
Data rows connect to external sources (Garmin, weather, system metrics) and sync values as date-indexed cells. The SDK extracts those cells into typed point arrays, computes regression curves, and exposes everything through a single query API:
Cell data → DataIndex → DataFrame → Your renderer
The SDK handles:
[x, y] pointsYour renderer handles:
The primary API for chart data. Registered as a row index factory, it populates automatically when cell data arrives.
import { DataIndex } from "@lona-js";
// Register the factory when creating the client
const client = new LonaSdk.Client({
rowIndexFactories: [DataIndex.factory()],
// ...
});
Access the index through a data row's variant:
const row = sheet.rows.get(rowId);
const dataIndex = (row.variant as DataVariant).dataIndex;
if (!dataIndex) return; // No preset or no data yet
Each data row has one or more named series. A series holds extracted
[x, y] points and optional regression data:
const series = dataIndex.getSeries("default");
series.key; // "default"
series.type; // "regression" | "default"
series.label; // display name
series.points; // sorted [x, y][] — raw data points
series.regression; // KernelSmoothResult | null
series.yRange; // { min, max, center } | null
For multi-series presets (e.g., garmin:weight has "weight" + "bodyFat"):
for (const [key, series] of dataIndex.getAllSeries()) {
console.log(`${key}: ${series.points.length} points`);
}
Get points within a specific x-range (efficient binary search):
const points = dataIndex.getPointsInRange("default", xStart, xEnd);
For rendering a specific column (e.g., one week), getDataFrame returns
a complete snapshot with the x-range already computed:
import { NaiveDate, TimezoneRegion } from "@tento-chrono";
const range = new NaiveDate.Range(
NaiveDate.fromYmd1(2026, 3, 10).exp(),
NaiveDate.fromYmd1(2026, 3, 17).exp(),
);
const tz = TimezoneRegion.UTC;
const frame = dataIndex.getDataFrame(range, tz);
A DataFrame contains:
| Field | Type | Description |
|---|---|---|
xRange | {start, end} | X-axis bounds for the column |
yAxis | {min, max, center} | Y-axis configuration from preset |
series | Map<string, DataFrameSeries> | Per-series data |
rendererSpecs | RendererSpec[] | What visual layers to draw |
Each DataFrameSeries has:
| Field | Type | Description |
|---|---|---|
points | [number, number][] | Raw data points in x-range |
regressionCurve | RegressionPoint[] | Smoothed curve points |
yRange | {min, max, center} | Series-specific y-axis override |
The DataFrame is renderer-agnostic. Here's how a consumer maps it to a drawing surface:
function renderChart(
ctx: CanvasRenderingContext2D,
frame: DataIndex.DataFrame,
width: number,
height: number,
) {
const { xRange, yAxis } = frame;
if (!yAxis) return;
const toCanvasX = (x: number) =>
((x - xRange.start) / (xRange.end - xRange.start)) * width;
const toCanvasY = (y: number) =>
height - ((y - yAxis.min) / (yAxis.max - yAxis.min)) * height;
for (const [key, series] of frame.series) {
// Draw raw data points
ctx.fillStyle = "#fe887c";
for (const [x, y] of series.points) {
ctx.beginPath();
ctx.arc(toCanvasX(x), toCanvasY(y), 3, 0, Math.PI * 2);
ctx.fill();
}
// Draw regression curve
if (series.regressionCurve.length > 1) {
ctx.strokeStyle = "#fe887c";
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < series.regressionCurve.length; i++) {
const pt = series.regressionCurve[i];
const cx = toCanvasX(pt.x);
const cy = toCanvasY(pt.y);
if (i === 0) ctx.moveTo(cx, cy);
else ctx.lineTo(cx, cy);
}
ctx.stroke();
// Draw confidence band
ctx.fillStyle = "rgba(245, 158, 11, 0.1)";
ctx.beginPath();
for (const pt of series.regressionCurve) {
ctx.lineTo(toCanvasX(pt.x), toCanvasY(pt.y + pt.confidence));
}
for (let i = series.regressionCurve.length - 1; i >= 0; i--) {
const pt = series.regressionCurve[i];
ctx.lineTo(toCanvasX(pt.x), toCanvasY(pt.y - pt.confidence));
}
ctx.closePath();
ctx.fill();
}
}
}
The rendererSpecs array describes what visual layers the preset expects.
Consumers can use this to decide what to draw:
for (const spec of frame.rendererSpecs) {
switch (spec.type) {
case "points":
drawPoints(spec.key, spec.color, spec.radius);
break;
case "regression-curve":
drawCurve(spec.key, spec.color);
break;
case "regression-band":
drawConfidenceBand(spec.key);
break;
case "hrule":
drawHorizontalRule(spec.offset, spec.offsetType);
break;
}
}
| Renderer type | Description |
|---|---|
points | Scatter plot dots at data points |
regression-curve | Smoothed trend line |
regression-band | Confidence interval fill |
regression-fill | Fill between curve and horizontal rule |
regression-points | Dots at regression sample points |
hrule | Single horizontal reference line |
hrule-all | Three horizontal lines (min, center, max) |
bar-chart-with-width | Bars with dynamic width |
buckets | Aggregated bucket bars |
The kernelSmooth function computes a Nadaraya-Watson regression over
sorted point arrays:
import { kernelSmooth } from "@lona-js";
const result = kernelSmooth(points, halfWidth, {
confFactor: 1.96, // 95% confidence interval
maxPoints: 500, // limit output density
});
for (const pt of result.curve) {
pt.x; // evaluation x-coordinate
pt.y; // smoothed value (weighted local mean)
pt.confidence; // stdev-based confidence band width
pt.density; // data support (0 = no nearby data, 1 = dense)
}
The halfWidth parameter controls smoothing:
Fill gaps in sparse data via linear interpolation before smoothing:
import { impute } from "@lona-js";
const filled = impute(sparsePoints, step);
// step = 1 for daily DSE data, step = 3600000 for hourly MSE data
Each regression point includes a density value (0–1) indicating how
much data supports that region. Use this to fade the curve in sparse
areas:
const alpha = smoothstep(0.01, 0.2, pt.density);
ctx.globalAlpha = alpha;
The ChartViewModel namespace provides coordinate transforms between
data values and pixel coordinates:
import { ChartViewModel } from "@lona-js";
const yScaler = new ChartViewModel.LinearYScaler({
range: { min: 0, max: 100, center: 50 },
});
// Data value → canvas Y coordinate
const canvasY = yScaler.d2c(dataValue, height, padding);
// Canvas Y coordinate → data value
const dataValue = yScaler.c2d(canvasY, height, padding);
// Generate tick marks for the Y axis
const ticks = yScaler.getTicks();
Data rows use presets to configure how their cell data maps to chart series. Presets are registered by lookup key:
| Preset | Series | X-axis | Description |
|---|---|---|---|
:~garmin:stress | overall | DSE | Daily stress score (0–100) |
:~garmin:hrv | default | MSE | HRV readings (multiple per day) |
:~garmin:sleep | default, durations | seconds | Sleep levels + durations |
:~garmin:weight | weight, bodyFat | DSE | Weight (kg) + body fat (%) |
:~garmin:spo2 | default | DSE | Blood oxygen (85–100%) |
:~whoop:sleep | performance, efficiency | MSE | Sleep quality metrics |
:~whoop:recovery | recovery, hrv | MSE | Recovery + HRV scores |
Each preset defines:
[x, y] from cell JSON