# Quint timeline and convergence

> WestCoast Charts 1.1.5, release, public Core release, on npm. HTML: https://westcoast-charts.com/docs/quint/ · Index for agents: https://westcoast-charts.com/llms.txt · Source: library guide, commit 95f093ba

`quintTimeline` and `quintConvergence` put up to five kinds of lanes on **one shared time axis**:
request columns coloured by error rate, heatmap strips, error-budget bands, lines and stacked
areas. They are meant for an operations day: what happened, where, and what changed right before.

- **Quint timeline**: the lanes with one shared crosshair, incident bands across all lanes, deploy
  and pipeline markers, and a crossfilter: click a service, cluster or time slot to pin it, Esc clears.
- **Quint convergence**: the same lanes plus a visibly labelled **focus lens** that magnifies the
  time axis around the incident, and one callout per lane with its value at the incident. The lens
  changes only the time axis, never a value.

Both types come from one premium type module, `west-coast-charts/layers/quint` (about 14 kB gzip). It is
loaded only by pages that use it and is not part of the premium bundle.

- [Loading](#loading)
- [Data model](#data-model)
- [Lanes](#lanes)
- [The focus lens](#the-focus-lens)
- [Interaction and keyboard](#interaction-and-keyboard)
- [describe(), export and accessibility](#describe-export-and-accessibility)
- [API](#api)

## Loading

```js
import WestCoastCharts from 'west-coast-charts'
import premium from 'west-coast-charts/premium'
import quint from 'west-coast-charts/layers/quint'
WestCoastCharts.use([premium, quint]) // any order; one use() registers both types
```

With script tags, load `dist/layers/quint.min.js` after the core and the premium
script; it installs itself.

Without an explicit import the module is loaded on demand, like the radial and network layer
modules: premium knows the names `quintTimeline` and `quintConvergence`, draws a placeholder
("needs west-coast-charts/layers/quint"), loads the module (bundlers: `import()` of
`layers/quint.esm.js` next to premium, a lazy chunk; CommonJS: `require`; script tags: a
`<script>` for `layers/quint.min.js` from premium's directory or `premium.layerBase`) and redraws
the chart. One load registers both types and the `incident` layer. With `premium.autoload = false`
(strict CSP, offline bundles) the placeholder stays and the console says once:
`"quintTimeline" needs west-coast-charts/layers/quint (autoload is off: use() it)`;
`premium.setLayerLoader(name => import(...))` replaces the loader. Without premium at all, the core
error says `chart.type "quintTimeline" is a premium type` and how to load premium.

Licensing is as for every premium type: without a key the chart renders fully and shows the
small "unlicensed" line on public hosts ([Premium and licensing](https://westcoast-charts.com/docs/premium/index.md)).

## Data model

The time axis is a sequence of equal slots: `plotOptions.quint.time = { start, step }` (ISO string
or ms, slot length in ms). Every series is a value per slot and names its lane in `lane`
(not `type`). `keys` say what a series *is* (service, cluster, region); a click on anything with
keys lights everything that shares one, in every lane.

```js
import WestCoastCharts from 'west-coast-charts'
import premium from 'west-coast-charts/premium'
import quint from 'west-coast-charts/layers/quint'
WestCoastCharts.use([premium, quint])

// 24 hourly slots of an invented service
const hours = Array.from({ length: 24 }, (_, i) => i)
const wave = (base, amp) => hours.map((h) => Math.round(base + amp * Math.sin(((h - 6) / 24) * 2 * Math.PI)))

await new WestCoastCharts(document.querySelector('#chart'), {
  chart: { type: 'quintConvergence', height: 'auto' },
  series: [
    { name: 'Requests', lane: 'req', data: wave(52000, 20000),
      errorRate: hours.map((h) => (h === 14 ? 6.2 : h === 15 ? 2.4 : 0.3)) },
    { name: 'api-1', lane: 'load', keys: ['api-1'], data: wave(55, 20) },
    { name: 'api-2', lane: 'load', keys: ['api-2'], data: hours.map((h, i) => (h === 14 ? 97 : wave(48, 18)[i])) },
    { name: 'Orders', lane: 'p95', keys: ['Orders', 'api-2'], data: hours.map((h) => (h === 14 ? 910 : 160)) },
  ],
  plotOptions: {
    quint: {
      time: { start: '2026-03-02T00:00:00Z', step: 3600000 },
      lanes: [
        { id: 'req', kind: 'events', title: 'Requests', unit: 'requests / h' },
        { id: 'load', kind: 'heatmap', title: 'CPU load', unit: '%' },
        { id: 'p95', kind: 'lines', title: 'p95 latency', unit: 'ms' },
      ],
      markers: [{ time: '2026-03-02T13:40:00Z', kind: 'deploy', label: 'orders v7.2', keys: ['Orders'] }],
      incidents: [{ from: '2026-03-02T14:00:00Z', to: '2026-03-02T15:00:00Z', label: 'Orders timeouts', keys: ['Orders', 'api-2'] }],
    },
  },
}).render()
```

## Lanes

`plotOptions.quint.lanes[]`, top to bottom: `{ id, kind, title, unit, aggregate, color, max, height }`.

| `kind` | Draws | Series fields | Default `aggregate` |
| --- | --- | --- | --- |
| `events` | one column per slot (a count per slot); colour = error rate in three classes; a dashed step line for the typical day; ◆ deploys and ● pipeline runs on top | `data`, `errorRate` (% per slot), `typical` | `sum` |
| `heatmap` | one strip per series, one cell per slot, with a scale key | `data` | `mean` |
| `budget` | one band per series: error budget left in %, coloured ≥ 50 / ≥ 20 / below; the row label reads "availability · p95 latency" | `data` (% left), `availability` (%), `p95` (ms) | `last` |
| `lines` | one line per series | `data` | `mean` |
| `stacked` | stacked areas | `data` | `mean` |

The **unit** belongs to the lane: it is shown in the lane title ("Requests · requests / 10 min"),
in every tooltip row, in the CSV header and in describe(). Give a rate its period
(`'requests / 10 min'`, `'€ / h'`, `'requests / s'`) so that a reader never has to guess.
`aggregate` says how a lane adds up over the day in describe() and the keyboard items: a count per
slot sums (and the sum is reported without the "per slot" part: "total 2,831,640 requests"),
levels and rates average, a budget is read at the end. The error rate of an events lane is always
averaged **weighted by requests**.

Without `height`, lanes share the free height (events 1, lines 1.25, stacked 1); heatmap and budget
rows have a fixed pitch. `chart.height: 'auto'` gives the natural height for the lanes.

**Markers** `[{ time, kind: 'deploy' | 'pipeline', label, keys, emphasis }]` sit on the events lane.
The deploy within 2 h before the first incident that shares one of its keys is drawn in the warning
colour and labelled, and describe() names it ("checkout v2.41 at 18:30 right before the first
incident"). **Incidents** `[{ from, to, label, keys }]` are bands across all lanes, with a chip in the
header.

## The focus lens

`plotOptions.quint.lens = { enabled, factor, from, to, pad }`, on by default for `quintConvergence`.

- Between `from` and `to` (default: the first incident ± `pad`, default max(3 slots, 30 min)) the
  time axis runs `factor` times faster than on the rest of the axis (1.5–12, default 4). The warp is
  piecewise linear and monotone: order and adjacency stay, every slot keeps its own column.
- **Values are never warped.** Column heights, line values, heatmap colours and budget levels are
  the same with the lens on or off (the unit tests compare them).
- The lens is always **labelled**: a header chip ("Lens ×4 · 18:10–19:50", a toggle button), dashed
  edges with a bracket and bold edge times on the axis, a tooltip footer inside it ("inside the focus
  lens: time ×4, values unchanged") and a sentence in describe(), for example "Focus lens: time axis magnified ×4
  between 18:10 and 19:50, relative to the rest of the axis (against a linear axis: lens ×3.31,
  rest ×0.83); values are not changed."
- Click the chip or press **L** (when the lens is enabled) to switch to a linear axis ("Focus lens off: linear time axis");
  `premium.api(chart).lens(true | false)` does the same from code.

`quintConvergence` is `quintTimeline` with `lens.enabled` and `focus.enabled` on by default. Either
type can switch either option, so a timeline can get a lens and a convergence chart can go linear.

## Interaction and keyboard

- One crosshair across all lanes; its tooltip lists every lane at that slot, with units.
- Click a row label, line, band, strip or marker: lights everything that shares its keys in every
  lane (the rest fades). Click a time slot elsewhere: pins it. Esc (anywhere while the chart is the
  last one used) or a click on empty space clears.
- Keyboard: Tab to the chart, arrow keys move through the lane rows, markers and incidents, Enter
  selects, Esc clears, L toggles the lens.
- Events: `quintSelect` (`{ keys }`), `quintPin` (`{ time, slot }`), `quintLens` (`{ enabled }`).

## describe(), export and accessibility

`describe()` (and `chart.describe()` of the agent module, used by the CLI and MCP tools) gives one
sentence per lane with unit and aggregation, peaks with times, the request-weighted error rate,
markers, incidents, the lens sentence and, in convergence mode, the callouts. Times carry their
zone: "from 2026-10-24 00:00 UTC to 2026-10-25 00:00 UTC in 10 min slots (times are slot starts)".

The CSV export and the accessible table are wide: one row per slot, one column per lane row, the
unit in every header ("Cost per cluster · eu-k8s-2 (€ / h)").

Below 560 px the layout turns compact: shorter row names, the budget detail under the name, fewer
time labels (never two for the same instant), and the legend wraps. Light and dark follow the theme.

## Incident wedge on a radial column

The same module adds a radialColumn layer type, `incident`, for the clock view of this data (the
"Quint semantics on a clock" showcase card). Like the types it is autoloaded when a chart asks for
it (the other rings draw first, then the chart redraws); `use()` the module, or load
`dist/layers/quint.min.js` on the page, to have it in the first frame:

```js
layers: [
  // ... inner rings, innermost first
  { type: 'incident', name: 'Incident', gap: 0, data: [{ from: '18:40', to: '19:10', label: 'EU checkout errors', keys: ['Checkout', 'eu-k8s-2'] }] },
]
```

- `from` / `to` are category names or slot indices; `to` is inclusive, so `'19:10'` in 10-min slots
  ends the window at 19:20. The label reads "Incident 18:40–19:20 · EU checkout errors" (on phones
  "Incident / 18:40–19:20").
- The wedge runs from the centre to the rim across every ring and takes no ring space of its own.
  Hovering inside it picks the slot under the pointer, so the linked bars, heatmap cells and
  segments light up together; the wedge is emphasised while a slot of the window is hovered or
  pinned (click, Esc clears).
- `ringTitles` (default true) names the heatmap rings with their full layer `name`, unit included,
  as curved text in the free gap outside each ring at 12 o'clock, so no cell is covered. Give each
  heatmap layer a `gap` of about 12–14 px for it; a ring without a gap of at least 10 px gets no
  title. It also adds each heatmap ring's colour scale (name, ramp, min / ticks / max; 1-2-5 ticks
  on a log scale) to the key strip under the ring.
- A heatmap layer with `scale: 'log'` spaces its colour ramp by ratio, so a few slow services or a
  spike do not leave every other cell at the dark end.
- describe(), the data table and `info()` list each window with its slot count. Values are never
  changed.

## API

`premium.api(chart)` of a quint chart adds `lens(on?)`, `pin(time | null)`, `select(keys | null)`,
`clear()`, `selection()` and `ticks()`; see the [premium options reference](https://westcoast-charts.com/docs/reference/premium-options/index.md#runtime-api-quinttimeline--quintconvergence).
