Matrix

One value per cell across two axes (a heatmap), coloured by its limits or along a gradient. Reach for it to see whether a bad hour hits everyone or one row.

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

value places a cell's row, as it places every series along y - a string names the row, and the y axis lists one per name, the first at the top (ADR-0052); level decides its colour. A cell without a level stays a hole, not a zero. The tooltip carries the level as text, since colour alone carries no number.

Examples

Colour cells by their limits

With coloring of kind assessment a cell takes the tone its limits give it: a bad hour everywhere reads differently from one bad service. value names the row - a string is a category, and the y axis lists one per service, the first at the top; format writes the level in the tooltip.

import { 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;
  };
}

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 SuccessCell {
  hour: number;
  /** The row: the service's name. */
  service: string;
  /** Requests that succeeded, in per cent; `null` where there were none. */
  success: number | null;
}

/** Yesterday, per service and hour: the share of requests that succeeded.
    Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
    and Reporting takes no requests before 06:00 - no requests, no share. */
const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
  const r = random(23);
  const cells: SuccessCell[] = [];
  SERVICES.forEach((one) => {
    for (let hour = 0; hour < 24; hour++) {
      let success: number | null = 99.2 + r() * 0.8;
      if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
      if (hour === 3) success = 97.4 + r() * 1;
      if (one.id === "reports" && hour < 6) success = null;
      cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
    }
  });
  return cells;
})();

export default function ByLimits() {
  const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
  return (
    <Chart height={280} ariaLabel="Successful requests per service and hour yesterday, by limits">
      <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
      <YAxis />
      <Matrix
        value="service"
        level="success"
        coloring={{
          kind: "assessment",
          limits: {
            limits: [
              { value: 99, side: "lower", severity: "warning" },
              { value: 98, side: "lower", severity: "alarm" },
            ],
          },
        }}
        name="Success rate"
        format={(v) => `${v.toFixed(2)} %`}
      />
      <Tooltip mode="nearest" />
    </Chart>
  );
}

Colour cells along a gradient

Without coloring the cells run along DEFAULT_GRADIENT from the lowest value to the highest; a cell without a value stays a hole.

import { 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;
  };
}

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 SuccessCell {
  hour: number;
  /** The row: the service's name. */
  service: string;
  /** Requests that succeeded, in per cent; `null` where there were none. */
  success: number | null;
}

/** Yesterday, per service and hour: the share of requests that succeeded.
    Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
    and Reporting takes no requests before 06:00 - no requests, no share. */
const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
  const r = random(23);
  const cells: SuccessCell[] = [];
  SERVICES.forEach((one) => {
    for (let hour = 0; hour < 24; hour++) {
      let success: number | null = 99.2 + r() * 0.8;
      if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
      if (hour === 3) success = 97.4 + r() * 1;
      if (one.id === "reports" && hour < 6) success = null;
      cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
    }
  });
  return cells;
})();

export default function Gradient() {
  const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
  return (
    <Chart height={280} ariaLabel="Successful requests per service and hour yesterday, as a gradient">
      <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
      <YAxis />
      <Matrix value="service" level="success" name="Success rate" />
      <Tooltip mode="nearest" />
    </Chart>
  );
}

Keyboard

The keys of Chart apply here.

API

MatrixProps<T>

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.

Shown in: Colour cells by their limits, Colour cells along a gradient, Mark bands, limits and cells (Chart) and 1 more

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.

Shown in: Colour cells by their limits, Colour cells along a gradient, Mark bands, limits and cells (Chart) and 1 more

coloringMatrixColoring{ kind: "gradient", stops: DEFAULT_GRADIENT }How the level colours a cell: by assessment against limits, or across a gradient.

Shown in: Colour cells by their limits, Mark bands, limits and cells (Chart)

xAxisIdstring"x"Binding to an x axis.
yAxisIdstring"y"Binding to a y axis.
datareadonly T[]—Series-own data; overrides the container data.
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: Colour cells by their limits, Colour cells along a gradient, Mark bands, limits and cells (Chart) and 1 more

format(value: number) => stringthe built-in number formatThe level as the tooltip writes it. The y axis' tickFormat writes the row, not the level.

Shown in: Colour cells by their limits

Types on this page

AxisField<T, V = AxisValue>

The fields of a row an axis can read - or, with V, those that hold only some of what an axis reads: a matrix' row is a position or a category, never an instant.

type AxisField<T, V = AxisValue> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends V ? K : never;
  }[keyof T & string]
>;

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>;

MatrixColoring

How a matrix colours its cells. Discretely by assessment - the same limits that colour a tile - or across a gradient.

type MatrixColoring =
  | { kind: "assessment"; limits: LimitSet }
  | {
      kind: "gradient";
      /** Colour stops from small to large; at least two. The stops ARE the
          steps - the library does not interpolate between them, because that
          would mean a colour parser for arbitrary CSS colour values, which this
          package does not have and is not to get (ADR-0011). Whoever wants a
          finer gradation names more colours. */
      stops: readonly string[];
      /** Value range of the gradient.
          @default the range of the data */
      range?: readonly [number, number];
    };

AxisValue

What an axis reads from a row (ADR-0052): a number - a position, or an instant on a time axis -, a string, which is a category, or a Date, which is an instant. The type of the value decides what kind the axis is, never its content: "2024" is a category, 2024 a number. On an axis that says time a string is an ISO 8601 instant. null/undefined is no place.

type AxisValue = number | string | Date | null | undefined;

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;

LimitSet

The set a value is read against: its bounds and, where there is one, its target. The target is not a limit. It is missed, never violated, and it is never the edge of a region. Conflating the two is how a tile turns red for being above target.

NameTypeDefaultDescription
limitsreadonly Limit[]—In any order; none, one or several.

Shown in: Colour cells by their limits, Mark bands, limits and cells (Chart)

targetnumber—

Limit

A bound: a value, a side and a severity. No more - a tolerance band is derived and never an input (two limits of equal severity on opposite sides enclose one). That keeps the one-sided case, which is the more common one, free of a null on the other edge.

Side

Whether a limit is an upper or a lower bound.

type Side = "upper" | "lower";

Severity

How seriously a violation weighs. Exactly two: one severity that advises and one that calls for action. Five severities would be five colours, and five colours on a dense screen are noise. Whoever needs a third distinction sets a second limit.

type Severity = "warning" | "alarm";

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index