# Matrix

> Package `@umriss-ui/charts`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/charts/matrix/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/charts/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/charts/llms-full.txt).

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.

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

Demo page: https://romanhaendler.github.io/umriss-ui/charts/matrix/

`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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); `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.

```tsx
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.

```tsx
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](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-chart) apply here.

## API

### `MatrixProps<T>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`AxisField`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Never an instant: a `Date` is refused.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Colour cells along a gradient](https://romanhaendler.github.io/umriss-ui/charts/matrix/#gradient), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) and 1 more |
| `level` *required* | [`Value`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-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.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Colour cells along a gradient](https://romanhaendler.github.io/umriss-ui/charts/matrix/#gradient), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) and 1 more |
| `coloring` | [`MatrixColoring`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-MatrixColoring) | `{ kind: "gradient", stops: DEFAULT_GRADIENT }` | How the level colours a cell: by assessment against limits, or across a gradient.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. |
| `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.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Colour cells along a gradient](https://romanhaendler.github.io/umriss-ui/charts/matrix/#gradient), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) and 1 more |
| `format` | `(value: number) => string` | the built-in number format | The level as the tooltip writes it. The y axis' `tickFormat` writes the row, not the level.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-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.

```ts
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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).

```ts
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.

```ts
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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): 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.

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

#### `NumberField<T>`

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

```ts
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.

```ts
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.

| Prop | Type | Default | Description |
|---|---|---|---|
| `limits` | `readonly `[`Limit`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-Limit)`[]` | — | In any order; none, one or several.<br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) |
| `target` | `number` | — |  |

#### `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.

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number` | — | <br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) |
| `side` *required* | [`Side`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-Side)<br>`"upper" \| "lower"` | — | <br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) |
| `severity` *required* | [`Severity`](https://romanhaendler.github.io/umriss-ui/charts/matrix/#type-Severity)<br>`"warning" \| "alarm"` | — | <br>Shown in: [Colour cells by their limits](https://romanhaendler.github.io/umriss-ui/charts/matrix/#by-limits), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) |

#### `Side`

Whether a limit is an upper or a lower bound.

```ts
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.

```ts
type Severity = "warning" | "alarm";
```
