Millions of points, measured in this browser rather than claimed: the time to prepare and draw them, and the frame rate while the pointer moves.
import { useChart } from "@umriss-ui/charts";
A hover redraws only the overlay layer, so the frame rate stays up over a million points. The figures in packages/charts/docs/capabilities.md come from here.
Load up to a million points per series and hover: the times and the frame rate say what drawing and a hover cost here.
/* Lines and the area downsample on their own above two points per pixel
column; a hover draws the overlay layer only, which the FPS counter shows.
Live appends 10 points a second in a moving window, and the axis width stays
calm through its hysteresis. Not photographed: it measures the
moment it runs. */
import { useCallback, useEffect, useRef, useState } from "react";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { ChartPerf } from "@umriss-ui/charts";
/* A small LCG - the same numbers on every run. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
interface LoadPoint {
t: number;
s1: number;
s2: number;
s3: number;
/** A state code 0..3 that holds for a run of about ten points. */
st: number;
/** Column, row and level of the same points laid out as a grid 200 rows high. */
c: number;
r: number;
lvl: number;
}
const STATES = [
{ label: "A", color: "#2f6fb4" },
{ label: "B", color: "#c08a2e" },
{ label: "C", color: "#8a8f98" },
{ label: "D", color: "#6b8e5a" },
] as const;
const ROWS = 200;
/** Three random walks of any length, with a state, and a grid laid over them. */
function load(seed: number, n: number): LoadPoint[] {
const r = random(seed);
const points = new Array<LoadPoint>(n);
let s1 = 100;
let s2 = 60;
let s3 = 140;
let st = 0;
const columns = Math.max(1, Math.ceil(n / ROWS));
for (let i = 0; i < n; i++) {
s1 += r() - 0.5;
s2 += (r() - 0.5) * 0.8;
s3 += (r() - 0.5) * 1.2;
if (r() < 0.1) st = Math.floor(r() * 4);
const c = i % columns;
const row = Math.floor(i / columns);
points[i] = { t: i, s1, s2, s3, st, c, r: row, lvl: 0.5 + 0.5 * Math.sin(c / 40) * Math.cos(row / 15) + (r() - 0.5) * 0.2 };
}
return points;
}
const SIZES = [1_000, 100_000, 1_000_000] as const;
const WINDOW = 400;
/** Series kinds of the measuring run. A filled mark and a rectangle per point are
different work from a stroked path - the measurements in the capability record
must not hold for lines only. */
type Mode = "lines" | "mixed" | "bars" | "scatter" | "state" | "matrix";
const MODES: readonly Mode[] = ["lines", "mixed", "bars", "scatter", "state", "matrix"];
/** How many points along x (columns for the matrix) a zoom shows. */
const ZOOMED = 400;
export default function Benchmark() {
const [data, setData] = useState<readonly LoadPoint[]>(() => load(5, 1_000));
const { Chart, XAxis, YAxis, Line, Bar, Area, Scatter, StateBand, Matrix, setDomain } = useChart(data);
const [perf, setPerf] = useState<ChartPerf>({
materializeMs: 0,
seriesDrawMs: 0,
points: 0,
});
const [fps, setFps] = useState<number | null>(null);
const [live, setLive] = useState(false);
const [mode, setMode] = useState<Mode>("lines");
/* The time the chart's handlers take for one pointer move, the mean of the
last twenty: the hit test of the matrix is in it. */
const [hoverMs, setHoverMs] = useState<number | null>(null);
const moves = useRef<number[]>([]);
const moveStart = useRef(0);
const matrix = mode === "matrix";
const columns = matrix ? Math.max(1, Math.ceil(data.length / ROWS)) : data.length;
const course = mode === "lines" || mode === "mixed";
const onPerf = useCallback((p: ChartPerf) => setPerf(p), []);
/* FPS measurement: runs only while the pointer is over the plot area. What is
measured is the actual frame rate of the page - if it drops, the series layer
is drawing along (which it must not do on a hover). */
const measuring = useRef<number | null>(null);
const startMeasuring = useCallback(() => {
if (measuring.current !== null) return;
let frames = 0;
let start = performance.now();
const step = () => {
frames++;
const now = performance.now();
if (now - start >= 400) {
setFps(Math.round((frames * 1000) / (now - start)));
frames = 0;
start = now;
}
measuring.current = requestAnimationFrame(step);
};
measuring.current = requestAnimationFrame(step);
}, []);
const stopMeasuring = useCallback(() => {
if (measuring.current !== null) cancelAnimationFrame(measuring.current);
measuring.current = null;
setFps(null);
}, []);
useEffect(() => stopMeasuring, [stopMeasuring]);
/* Streaming test: 10 points/s, a travelling window. */
useEffect(() => {
if (!live) return;
const r = random(4711);
const timer = window.setInterval(() => {
setData((previous) => {
const last = previous[previous.length - 1];
if (last === undefined) return previous;
const next: LoadPoint = {
t: last.t + 1,
s1: last.s1 + (r() - 0.5) * 3,
s2: last.s2 + (r() - 0.5) * 2.4,
s3: last.s3 + (r() - 0.5) * 3.6,
st: r() < 0.1 ? Math.floor(r() * 4) : last.st,
c: last.c + 1,
r: last.r,
lvl: last.lvl,
};
const grown = [...previous, next];
return grown.length > WINDOW ? grown.slice(grown.length - WINDOW) : grown;
});
}, 100);
return () => window.clearInterval(timer);
}, [live]);
const loadPoints = (n: number) => {
setLive(false);
setData(load(5, n));
setDomain("x", null);
moves.current = [];
};
const pick = (m: Mode) => {
setMode(m);
setDomain("x", null);
moves.current = [];
};
/* A span in the middle of the data: a small part of the volume is in view. */
const zoom = () => {
const from = Math.floor(columns / 2);
setDomain("x", [from, from + ZOOMED]);
};
return (
<>
<div className="demo-actions">
{SIZES.map((n) => (
<button key={n} className="demo-button" data-role={`points-${n}`} onClick={() => loadPoints(n)}>
{n.toLocaleString("en-US")} points
</button>
))}
<button className="demo-button" onClick={() => setLive((v) => !v)}>{live ? "Stop live" : "Live"}</button>
{MODES.map((m) => (
<button key={m} className="demo-button" data-role={`kind-${m}`} aria-pressed={mode === m} onClick={() => pick(m)}>
{m}
</button>
))}
<button className="demo-button" data-role="zoom" onClick={zoom}>
Zoom in
</button>
<button className="demo-button" data-role="unzoom" onClick={() => setDomain("x", null)}>
Whole
</button>
</div>
<dl className="metrics">
<div>
<dt>Points</dt>
<dd data-role="points">{perf.points.toLocaleString("en-US")}</dd>
</div>
<div>
<dt>Materialisation</dt>
<dd data-role="materialize-ms">{perf.materializeMs.toFixed(1)} ms</dd>
</div>
<div>
<dt>Series draw</dt>
<dd data-role="series-draw-ms">{perf.seriesDrawMs.toFixed(1)} ms</dd>
</div>
<div>
<dt>FPS (hover)</dt>
<dd>{fps === null ? "–" : fps}</dd>
</div>
<div>
<dt>Pointer move</dt>
<dd data-role="hover-ms">{hoverMs === null ? "–" : `${hoverMs.toFixed(2)} ms`}</dd>
</div>
</dl>
<div
data-role="plot"
onPointerEnter={startMeasuring}
onPointerLeave={stopMeasuring}
onPointerMoveCapture={() => {
moveStart.current = performance.now();
}}
onPointerMove={() => {
moves.current.push(performance.now() - moveStart.current);
if (moves.current.length > 20) moves.current.shift();
setHoverMs(moves.current.reduce((a, b) => a + b, 0) / moves.current.length);
}}
>
<Chart
height={320}
ariaLabel="Benchmark with three series"
onPerf={onPerf}
>
<XAxis value={matrix ? "c" : "t"} label="Index" domain="data" zoomable />
<YAxis domain={mode === "state" ? [0, 1] : undefined} />
{mode === "scatter" ? <Scatter value="s1" name="S1" /> : null}
{mode === "state" ? <StateBand value="st" states={STATES} name="State" /> : null}
{matrix ? <Matrix value="r" level="lvl" name="Level" /> : null}
{mode === "bars" ? <Bar value="s1" name="S1" /> : null}
{course ? mode === "lines" ? <Line value="s1" name="S1" /> : <Bar value="s1" name="S1" /> : null}
{course ? mode === "lines" ? <Line value="s2" name="S2" /> : <Area value="s2" name="S2" /> : null}
{course ? <Line value="s3" name="S3" /> : null}
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
</div>
</>
);
}
Two series of 604,800 readings each: every pixel column draws first, lowest, highest and last, so a spike stays; zoom in for every reading.
/* Not photographed, like the benchmark above it: it measures the series draw. */
import { useCallback, useState } from "react";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { ChartPerf } from "@umriss-ui/charts";
/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface MetricPoint {
t: number;
/** Median and 95th percentile latency, in ms. */
p50: number;
p95: number;
/** Failed requests, in per cent. */
errorRate: number;
/** Requests per minute. */
requests: number;
}
/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): MetricPoint[] {
const index = SERVICES.findIndex((one) => one.id === serviceId);
const service = SERVICES[index];
if (service === undefined) throw new Error(`No service "${serviceId}".`);
const r = random(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array<MetricPoint>(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
export default function WeekOfSeconds() {
// Made on first render, not on import: 604,800 readings are no page's
// business until this one is open.
const [data] = useState(() => week("search", 1000));
const { Chart, XAxis, YAxis, Line } = useChart(data);
const [drawMs, setDrawMs] = useState(0);
const onPerf = useCallback((p: ChartPerf) => setDrawMs(p.seriesDrawMs), []);
return (
<>
<dl className="metrics">
<div>
<dt>Points</dt>
<dd>{(2 * data.length).toLocaleString("en-US")}</dd>
</div>
<div>
<dt>Series draw</dt>
<dd>{drawMs.toFixed(1)} ms</dd>
</div>
</dl>
<Chart height={300} ariaLabel="Search latency and requests over last week, a reading a second" onPerf={onPerf}>
<XAxis value="t" time domain="data" zoomable />
<YAxis label="ms" domain="visible" />
<YAxis id="requests" position="right" label="Requests/min" domain="visible" />
<Line value="p95" name="p95" />
<Line value="requests" yAxisId="requests" name="Requests" />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
</>
);
}
The keys of Chart apply here.
From ChartProps — the full table stands on Chart.
| Name | Type | Default | Description |
|---|---|---|---|
height | number | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a
stretched grid cell, a card the grid stretches, a container of definite
height - and never goes below 300 px, which is also its height in a
frame without one. A different floor goes through style's minHeight
(ADR-0050). |
children | ReactNode | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
| Name | Type | Default | Description |
|---|---|---|---|
onPerf | (perf: ChartPerf) => void | — | Instrumentation for the benchmark page; not needed otherwise. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabel | string | — | Required in DEV through a warning. |
From XAxisProps — the full table stands on Axis.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | AxisField<T> | ((d: T, index: number) => AxisValue) | — | Where a row lies on this axis: a field of the row, compared by its
name, or a function of it, compared by its source text as a series' is
(ValueFunction) - with the same closure limit. A number is a position,
a Date an instant, a string a category: every distinct one gets a
place of its own, in the order the rows bring them, a tick and its name
as the label (ADR-0052). The type decides, never the content - "2024"
is a category, 2024 a number - and a row without a value has no
place. The axis reads the rows
of every series bound to it; where those bring their own data, name
their row: <XAxis<HourCount> value="hour" />. |
domain | | "nice" | "nice" | "nice" widens the data's extent to ticks - to named ticks where
there are any, on a time axis to whole units of a step of hours or
longer -, "data" keeps it, a pair is fixed: numbers, on a time axis
milliseconds or two Dates, on a category axis places. On a zoomable
axis it is the start: what the axis shows while the chart's view names
no span for it. |
time | boolean | — | The values are instants: milliseconds since the epoch, or with time
said ISO 8601 strings as an API sends them, read by the platform's rule -
a date with a time and no offset is local time, a date alone is UTC; a
string that is none is not drawn. Rows that hold a Date put the axis
in time without time. Ticks on local boundaries from the minute to the
month, labels by level in en-GB with a 24-hour clock - 15:00, 17 Mar,
Mar 2026 - and the date on the first tick and on the first of a new
day (17 Mar 00:00); a day tick there carries the year. The tooltip's x
value carries the seconds where the readings lie less than a minute
apart. Another language is a tickFormat, which is handed the instant
in milliseconds. calendar implies it. |
zoomable | boolean | false | Zoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the
pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double
click goes back to domain; with the focus on the chart, + and − zoom,
Shift with ← or → pans and 0 goes back. The span shown is part of the
chart's view, by this axis' id (useChart's initialView,
onViewChange, setView, setDomain). The plain wheel always scrolls the page. |
id | string | "x" | Axis id, through which series bind themselves. |
label | string | — | Axis title. |
From YAxisProps — the full table stands on Axis.
| Name | Type | Default | Description |
|---|---|---|---|
domain | | "nice" | "nice" | As on the x axis, and "visible": what the series show inside their x
axis' domain - a zoomed hour gets the hour's range, not the week's -,
widened to ticks as "nice" is. Where the x domain is not fixed that is
every point. |
position | "left" | "right" | "left" | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable. |
id | string | "y" | Axis id, through which series bind themselves. |
label | string | — | Axis title. |
From LineProps — the full table stands on Line.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
yAxisId | string | "y" | Binding to a y axis. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From AreaProps — the full table stands on Area.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Upper edge - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From BarProps — the full table stands on Bar.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From ScatterProps — the full table stands on Scatter.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From StateBandProps — the full table stands on StateBand.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Index of the state in states - a number field of the row or a
function of it; null/undefined/NaN/±Infinity is a gap -
and a gap stays a hole, it gets no colour for "unknown". A colour would
be a claim about the interval. |
statesrequired | readonly StateEntry[] | — | The closed set of states, in the order of their codes. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From MatrixProps — the full table stands on Matrix.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | AxisField<T, number | string | null | undefined> | ((d: T, index: number) => number | string | null | undefined) | — | The row on the y axis - a field of the data's row or a function of it.
A number is a position, as every series' y is; a string is a category:
the y axis then has a named row per distinct one, the first at the top,
in the order the data bring them or the y axis' categories
(ADR-0052). Never an instant: a Date is refused. |
levelrequired | Value<T> | — | The level that decides the colour - a number field of the row or a function of it. A missing level is a hole, not a zero. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |