Benchmark

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.

Examples

Measure a million points

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>
    </>
  );
}

Draw a week of seconds

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>
    </>
  );
}

Keyboard

The keys of Chart apply here.

Props on this page

From ChartProps — the full table stands on Chart.

NameTypeDefaultDescription
heightnumberfills the frame, at least 300 pxHeight 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).
childrenReactNode—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.

Events

NameTypeDefaultDescription
onPerf(perf: ChartPerf) => void—Instrumentation for the benchmark page; not needed otherwise.

Accessibility

NameTypeDefaultDescription
ariaLabelstring—Required in DEV through a warning.

From XAxisProps — the full table stands on Axis.

NameTypeDefaultDescription
valuerequiredAxisField<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"
| "data"
| readonly [number, number]
| readonly [Date, Date]
"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.
timeboolean—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.
zoomablebooleanfalseZoom 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.
idstring"x"Axis id, through which series bind themselves.
labelstring—Axis title.

From YAxisProps — the full table stands on Axis.

NameTypeDefaultDescription
domain| "nice"
| "data"
| "visible"
| readonly [number, number]
"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.
idstring"y"Axis id, through which series bind themselves.
labelstring—Axis title.

From LineProps — the full table stands on Line.

NameTypeDefaultDescription
valuerequiredValue<T>—Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.
yAxisIdstring"y"Binding to a y axis.
namestring"Series n", after its place in the chartThe 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.

NameTypeDefaultDescription
valuerequiredValue<T>—Upper edge - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.
namestring"Series n", after its place in the chartThe 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.

NameTypeDefaultDescription
valuerequiredValue<T>—Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.
namestring"Series n", after its place in the chartThe 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.

NameTypeDefaultDescription
valuerequiredValue<T>—Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.
namestring"Series n", after its place in the chartThe 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.

NameTypeDefaultDescription
valuerequiredValue<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.
statesrequiredreadonly StateEntry[]—The closed set of states, in the order of their codes.
namestring"Series n", after its place in the chartThe 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.

NameTypeDefaultDescription
valuerequiredAxisField<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.
levelrequiredValue<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.
namestring"Series n", after its place in the chartThe name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.

From TooltipProps — the full table stands on Tooltip.

NameTypeDefaultDescription
mode"x" | "nearest""x""x": every series at the x position; "nearest": only the nearest point.

From LegendProps — the full table stands on Legend.

NameTypeDefaultDescription
placement"top" | "bottom""top"Which side of the plot area the legend stands on.

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index