Scatter

Single readings as points, joined by nothing (a scatter plot, dots). Reach for it for samples, checks and events - anything that is not a course.

import { useChart } from "@umriss-ui/charts";

Examples

Plot single measurements

A Scatter draws each reading as a point and joins nothing: here every stop of today's tours, late above zero.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;

interface Vehicle {
  id: string;
  plate: string;
  type: "van" | "e-van" | "truck";
  /** Payload, in kg. */
  capacity: number;
  depot: string;
}

const VEHICLES: readonly Vehicle[] = [
  { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
  { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
  { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
  { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
  { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
  { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
  { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
  { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];

const DRIVERS = [
  { id: "d1", name: "Martin Hale", depot: "north" },
  { id: "d2", name: "Nadia Petrova", depot: "north" },
  { id: "d3", name: "Owen Carter", depot: "north" },
  { id: "d4", name: "Lucia Romero", depot: "river" },
  { id: "d5", name: "Ben Adeyemi", depot: "river" },
  { id: "d6", name: "Hanna Berg", depot: "east" },
  { id: "d7", name: "Yusuf Demir", depot: "east" },
  { id: "d8", name: "Clara Wendt", depot: "east" },
] as const;

const CUSTOMERS = [
  "Holloway Garden Supplies",
  "Marlow & Finch Books",
  "Oakridge Pharmacy",
  "Brixley Cycles",
  "Tamsin's Bakery",
  "Northfold Office",
  "Pellham Hardware",
  "Greywick Studio",
  "Juniper Lane Florist",
  "Ashcombe Dental",
] as const;

interface Stop {
  id: string;
  shipment: string;
  customer: string;
  /** The window the customer was promised, `[from, to]`. */
  window: readonly [number, number];
  /** When the tour plans to be there. */
  arrival: number;
}

interface Tour {
  id: string;
  vehicle: string;
  driver: string;
  depot: string;
  /** Leaves the depot, loaded; back at the depot. */
  from: number;
  to: number;
  /** Minutes of loading before `from`. */
  loading: number;
  km: number;
  stops: readonly Stop[];
}

/** One tour per vehicle. Stops every half hour or so, each with a two-hour
    window; every seventh arrival misses its window - the late ones a
    dispatcher looks for. */
function tour(index: number): Tour {
  const r = random(700 + index);
  const vehicle = VEHICLES[index]!;
  const truck = vehicle.type === "truck";
  const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
  const count = truck ? 5 : 8 + Math.floor(r() * 4);
  const stops: Stop[] = [];
  let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
  for (let i = 0; i < count; i++) {
    const n = index * 20 + i;
    const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
    /* A late stop's window closed half an hour or more before the arrival. */
    const late = i >= 2 && n % 7 === 3;
    const halfHour = 30 * MINUTE;
    const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
    stops.push({
      id: `s-${index + 1}-${i + 1}`,
      shipment: `FP-${(1_004_210 + n * 13).toString()}`,
      customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
      window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
      arrival,
    });
    arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
  }
  return {
    id: `T-${String(index + 1).padStart(2, "0")}`,
    vehicle: vehicle.id,
    driver: DRIVERS[index]!.id,
    depot: vehicle.depot,
    from,
    to: arrival + 30 * MINUTE,
    loading: truck ? 45 : 30,
    km: Math.round((truck ? 140 : 70) + r() * 40),
    stops,
  };
}

const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));

interface Arrival {
  t: number;
  /** Minutes after the delivery window closed; below zero, in time. */
  late: number;
}

const ARRIVALS: Arrival[] = TOURS.flatMap((tour) =>
  tour.stops.map((stop) => ({ t: stop.arrival, late: Math.round((stop.arrival - stop.window[1]) / 60_000) })),
).sort((a, b) => a.t - b.t);

export default function Arrivals() {
  const { Chart, XAxis, YAxis, Scatter } = useChart(ARRIVALS);
  return (
    <Chart height={260} ariaLabel="Every stop of today's tours: minutes after its window closed">
      <XAxis value="t" time label="Arrival" />
      <YAxis label="Minutes late" />
      <Scatter value="late" name="Stop" radius={4} />
      <Tooltip mode="nearest" />
    </Chart>
  );
}

Mark samples that miss a target

Put the misses in a series of their own with tone="alarm": a scatter has one colour, and the tone is the theme's alarm red.

import { Legend, LimitLine, Tooltip, useChart } 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;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

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 Trace {
  t: number;
  /** How long the request took, end to end, in ms. */
  ms: number;
}

/** Single Checkout requests traced end to end this morning, one every few
    minutes from 06:00 - individual measurements, nothing in between. Slow
    during the bad half hour, and now and then on their own. */
const TRACES: readonly Trace[] = (() => {
  const r = random(4040);
  const traces: Trace[] = [];
  for (let t = at(17, 6, 2); t <= NOW; t += Math.round(1 + r() * 4) * MINUTE) {
    const bad = t >= at(17, 9, 40) && t < at(17, 10, 10);
    const ms = 150 + r() * 90 + (bad ? 110 + r() * 120 : r() < 0.05 ? 150 : 0);
    traces.push({ t, ms: Math.round(ms) });
  }
  return traces;
})();

const OBJECTIVE = SERVICES.find((one) => one.id === "checkout")!.latencySlo;

export default function AgainstATarget() {
  const { Chart, XAxis, YAxis, Scatter } = useChart(TRACES);
  return (
    <Chart height={300} ariaLabel="Traced Checkout requests against the latency objective">
      <XAxis value="t" time />
      <YAxis label="ms" />
      <LimitLine value={OBJECTIVE} severity="warning" label="Objective" />
      {/* The two channels never both carry a value, so no trace is drawn twice. */}
      <Scatter value={(d) => (d.ms <= OBJECTIVE ? d.ms : null)} name="Request" radius={2.5} />
      <Scatter value={(d) => (d.ms > OBJECTIVE ? d.ms : null)} name="Too slow" tone="alarm" radius={4} />
      <Legend placement="top" />
      <Tooltip mode="nearest" />
    </Chart>
  );
}

Keyboard

The keys of Chart apply here.

API

ScatterProps<T>

NameTypeDefaultDescription
valuerequiredValue<T>—Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.

Shown in: Plot single measurements, Mark samples that miss a target, Mix series kinds (Chart) and 3 more

xAxisIdstring"x"Binding to an x axis.

Shown in: Bind every kind of series to a second axis (Axis)

yAxisIdstring"y"Binding to a y axis.

Shown in: Bind every kind of series to a second axis (Axis)

datareadonly T[]—Series-own data; overrides the container data.

Shown in: Bind every kind of series to a second axis (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.

Shown in: Plot single measurements, Mark samples that miss a target, Mix series kinds (Chart) and 3 more

format(value: number) => stringthe y axis' tickFormat, else the built-in number formatThe value as the tooltip writes it.
colorstringthe palette's --uc-series-NAny CSS colour value.
tone| "ok"
| "warning"
| "alarm"
—A role instead of a colour value; the theme resolves it.

Shown in: Mark samples that miss a target

radiusnumber3Point radius in CSS pixels.

Shown in: Plot single measurements, Mark samples that miss a target

Types on this page

Value<T>

A value read from the row (ADR-0048): the name of a number field, compared by its name, or a function, compared by its source text (ValueFunction).

type Value<T> = NumberField<T> | ValueFunction<T>;

NumberField<T>

The fields of a row whose value is a number, or absent.

type NumberField<T> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
  }[keyof T & string]
>;

ValueFunction<T>

A value computed from the row - the function form of Value. Compared by its source text, not its identity - an inline function is new on every render. Known limit: one that reads a changed closure variable under the same text is not run again; pass a new data reference then. A native or bound function is compared by identity.

type ValueFunction<T> = (d: T, index: number) => number | null | undefined;

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index