# Vitals Clock and Practice Schedule

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

Two premium type modules for clinical and practice dashboards:

- `west-coast-charts/layers/vitals` (about 11 kB gzip): chart type **`vitalsClock`**, a 24 h (ward) or
  12 h (practice) clock of vital signs, and four `radialColumn` layer types: `bands` (threshold-band
  cells), `trace` (measurements at their true time), `span` (high / low pairs such as blood pressure)
  and `vitalsNow` (the now hand, caller alarm wedges and a strip with the last value per parameter).
- `west-coast-charts/layers/schedule` (about 14 kB gzip): chart type **`schedule`**, a practice day or
  week with rooms, devices or people as lanes, a route per doctor, conflicts and a vertical board,
  plus headless helpers: `conflicts()`, `utilization()`, `utilizationSeries()` and
  `flow.fromEvents()` (patient flow with median / P90 waiting times for the core `rangeBar` and the
  premium `sankey`).

**A chart library, not a medical device.** Neither module computes a score (no NEWS2, no MEWS),
decides an alarm or ships a clinical threshold. Bands, levels, alarms and scores always come from
your software; the chart only draws them. The sample pages use invented demo thresholds and data and
say so on the chart.

- [Loading](#loading)
- [vitalsClock](#vitalsclock)
- [Layers on a plain radialColumn](#layers-on-a-plain-radialcolumn)
- [schedule](#schedule)
- [Headless helpers](#headless-helpers)
- [Patient IDs and privacy](#patient-ids-and-privacy)

## Loading

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

With script tags, load `dist/layers/vitals.min.js` and / or `dist/layers/schedule.min.js` after the
core and the premium script; they install themselves. With script tags the schedule helpers are on
the global `WestCoastChartsLayer_schedule`.

Unlike `quint`, these two modules are **not autoloaded**: premium does not know the names
`vitalsClock` and `schedule` (that keeps the premium bundle small). Without the module the core says
the type is unknown; load it with `use()` or a script tag.

## vitalsClock

The data lives in `plotOptions.vitalsClock` (the series stay empty). Each parameter is a list of
measurements `[time, value]` (or `[time, high, low]` for `kind: 'range'`), time as ms or ISO string.
The chart puts them on time slots (`time.step`, default 60 min) and draws one ring per parameter:

| kind | ring |
| --- | --- |
| `columns` | the column ring: one bar per slot (median by default, `aggregate` changes it) |
| `cells` | threshold-band cells (colour **and** pattern per level, so it works without colour) |
| `trace` | a line at the true measurement times, bands as background zones |
| `range` | high / low pairs (e.g. systolic / diastolic) |

A slot without a measurement is **not measured** (hatched), never 0. Slots after `now` are
"no data yet". The last value of each parameter and its age appear under the ring; `staleAfter`
(minutes) flags an old value. `now` is a shared cutoff: measurements later than it do not enter
the dial, aggregates, description, table or last-value strip until `now` advances.

For `kind: 'range'`, `bands` classifies the high value and `bandsLow` classifies the low value.
Use separate bands for different measures such as systolic and diastolic pressure. If `bandsLow`
is absent, the vitals clock defaults to `bandsLowPolicy: 'high-only'`: it classifies only the high
value and emits one warning per parameter unless the policy is explicit. Set
`bandsLowPolicy: 'shared'` only when both values should use the same thresholds. Explicit
`bandsLow` always takes precedence. These options only select caller-supplied bands; the library
does not infer any medical thresholds.

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

const t0 = Date.parse('2026-10-26T07:00:00+01:00')
const h = (n) => t0 + n * 3600000
// invented demo bands, not clinical thresholds
const demo = [{ to: 90, level: 2 }, { to: 94, level: 1 }, { level: 0 }]
const el = document.createElement('div')
document.body.appendChild(el)
const chart = new WestCoastCharts(el, {
  chart: { type: 'vitalsClock', height: 420 },
  series: [],
  plotOptions: {
    vitalsClock: {
      time: { start: t0, span: '12h', step: '60min' },
      now: h(5.3),
      timeZone: 'Europe/Berlin',
      params: [
        { id: 'hr', title: 'Heart rate', short: 'HR', unit: '/min', kind: 'columns', data: [[h(0.2), 72], [h(2.1), 80], [h(4.6), 77]] },
        { id: 'spo2', title: 'SpO₂', short: 'SpO₂', unit: '%', kind: 'cells', aggregate: 'min', bands: demo, data: [[h(0.2), 97], [h(2.1), 93], [h(4.6), 96]] },
      ],
      demo: 'Demo data',
    },
  },
})
await chart.render()
```

`describe()` says what is shown, per parameter range, median, the slots per level and the
not-measured slots, e.g. *"SpO₂ (%, lowest per slot): 93–97 …; 9 slots not measured"*.
`api(chart).lastValues()` returns the strip's values; `api(chart).append(id, [[t, v], ...])` adds
measurements to that parameter's internal `data` and redraws at most once a second (a light live
feed for demos). It returns `false` for an unknown parameter or a non-array input. It copies the
appended tuples and does **not** mutate the caller's original options object. A retained API
object continues to return the current last values after redraws.

By default `followNow: false`, appending future measurements does not advance `now`; those
measurements stay excluded from every output channel until the caller advances the time with
`updateOptions()`. For a live feed, set `plotOptions.vitalsClock.followNow: true`: `append()`
advances the internal `now` to the newest valid appended timestamp, never backwards. The status
strip, dial, table and description then update together on the throttled redraw. This does not
slide the configured `time.start`/`span` window; the caller controls the window and persists any
history it needs. Supplying replacement data in `updateOptions()` replaces the corresponding
internal data, so keep the authoritative dataset in the application.

The centre (`core`) takes any `radialColumn` layer of yours, e.g. a `matrix` of your own scores.
`events` (e.g. medication) are marked on the dial; `alarms` are drawn as wedges and listed, never
computed. Orient (`features/orient`) turns the clock like any radialColumn.

## Layers on a plain radialColumn

The four layer types also work in `plotOptions.radialColumn.layers` of a normal `radialColumn`:

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

const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0') + ':00')
const el = document.createElement('div')
document.body.appendChild(el)
const chart = new WestCoastCharts(el, {
  chart: { type: 'radialColumn', height: 420 },
  series: [{ name: 'Measurements', data: hours.map((_, i) => 2 + (i % 3)) }],
  xaxis: { categories: hours },
  plotOptions: {
    radialColumn: {
      layers: [{
        type: 'bands',
        rows: [{
          name: 'SpO₂', unit: '%', aggregate: 'min',
          bands: [{ to: 90, level: 2 }, { to: 94, level: 1 }, { level: 0 }],
          data: hours.map((_, i) => (i % 5 === 0 ? null : 95 - (i % 4))),
        }],
      }],
    },
  },
})
await chart.render()
```

A plain `span` layer retains the shared-band fallback for compatibility: without `bandsLow` or
`bandsLowPolicy` it classifies both values with `bands` and warns once per layer. State the policy
explicitly: `bandsLowPolicy: 'shared'` for a common scale, `bandsLowPolicy: 'high-only'` to grade
only the high value, or separate `bandsLow` for the low value. This differs from the safer
high-only default of `vitalsClock` range parameters.

## schedule

`plotOptions.schedule` holds the practice model and the view options:

- `resources` (rooms, devices, staff), `people` (doctors and staff; `color` + `dash` identify a
  route, so it is readable without colour), `items` (appointments with `resource`, `start`, `end`,
  `people` – one appointment can need several people –, a pseudonymous `patient`, `label`, `status`),
- `hours` (open, close, days, breaks; closed time is hatched), `rules.minTransfer` (default 5 min),
- `date`, `range: 'day' | 'week'`, `timeZone`, `now`.

Times are ms, ISO strings with offset, or `'HH:MM'` on `date` (wall clock in `timeZone`).

`view: 'lanes'` draws one horizontal lane per room (`lanes: 'people'`: per person), `view: 'board'`
a vertical day or week board (columns = days × rooms); `'auto'` switches to the board below 560 px.

Conflicts are drawn and listed in the header: a person in two rooms at once and a double-booked
room are **errors**; a changeover between two rooms below `minTransfer` and an appointment outside
the opening hours are **warnings**. The chart is **controlled**: a drag, resize or keyboard move
(Enter, arrows, Enter) never changes your data, it emits `scheduleChange` with
`{ id, from, to, conflicts, added, resolved }` and shows a ghost; your software decides and redraws.

`api(chart).propose(id, { start, end, resource, people })` accepts the same time formats as items,
including `'HH:MM'` on the model's date in `timeZone`. Omitting `end` when changing `start`
preserves the appointment's duration. Invalid times or an end before the start produce a warning
and return `null`, without changing the current proposal or the source appointments.

Proposal conflict sets describe the **whole schedule**, independently of a display filter:
`conflicts` contains all conflicts after the proposed move, `added` contains new conflicts and
`resolved` contains removed conflicts. This includes indirect changes, such as a new transfer
conflict between two appointments after the appointment between them moves away. Conflict IDs
include the conflict type and its resource/person where relevant; pair IDs remain stable if the
appointments change chronological order. Treat them as opaque identifiers. A change from a
transfer warning to a person-overlap error resolves the former and adds the latter.

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

const el = document.createElement('div')
document.body.appendChild(el)
const chart = new WestCoastCharts(el, {
  chart: {
    type: 'schedule', height: 320, width: 900,
    events: { scheduleChange: (e) => console.log('proposal', e.id, e.conflicts.length) },
  },
  series: [],
  plotOptions: {
    schedule: {
      date: '2026-10-26',
      timeZone: 'Europe/Berlin',
      hours: { open: '08:00', close: '12:00' },
      resources: [{ id: 'R1', name: 'Room 1' }, { id: 'R2', name: 'Room 2' }],
      people: [{ id: 'D1', name: 'Dr. A' }],
      items: [
        { id: 'a', resource: 'R1', start: '08:00', end: '08:30', people: ['D1'], patient: 'P-1042' },
        { id: 'b', resource: 'R2', start: '08:20', end: '08:40', people: ['D1'], patient: 'P-2210' },
      ],
    },
  },
})
await chart.render()
```

Events (in `chart.events`): `scheduleChange` (a proposal), `scheduleSelect`, `slotClick` (`{ time, lane, by, day }`),
`scheduleConflict` (once per render with conflicts). `api(chart)`: `conflicts()`, `utilization()`,
`propose()`, `proposal()`, `clearProposal()`, `select()`, `highlightPerson()`, `selection()`, `view()`.

## Headless helpers

The schedule module object carries helpers that need no chart:

```js
import schedule from 'west-coast-charts/layers/schedule'

const day = {
  date: '2026-10-26', timeZone: 'Europe/Berlin', hours: { open: '08:00', close: '12:00' },
  resources: [{ id: 'R1', name: 'Room 1' }],
  items: [{ id: 'a', resource: 'R1', start: '08:00', end: '09:00' }],
}
const list = schedule.conflicts(day)            // [] here
const u = schedule.utilization(day, { bucket: '60min' })
// u.value = 25 (%); closed buckets are null, never 0 %
const series = schedule.utilizationSeries(u)    // series for a core heatmap
if (list.length || u.value !== 25 || series.length !== 1) throw new Error('unexpected')
```

Closed buckets should read as closed, not as the lowest colour: with the chart types bundle
(`west-coast-charts/types`) loaded, `plotOptions.heatmap.empty: { label: 'closed' }` draws null
cells of a heatmap or calendar with a neutral hatch, keeps them out of the colour scale, adds a
legend entry next to the `colorScale.ranges` (`legend: false` drops it; `color` sets the hatch
ink) and puts the label into the tooltip, the data table and `describe()`.

`schedule.flow.fromEvents(visits)` takes per patient the stage times
(`checkin`, `room`, `lab`, `exam`, `checkout`) and returns the segments, the transitions with
**median and P90** waiting times (never a sum or mean of waits), a `rangeBar` series, `sankey`
links and a sankey tooltip plugin that adds patients / median / P90 to each flow.

## Patient IDs and privacy

Use pseudonymous IDs only. With the default `redact: true` the patient ID appears nowhere in
library-generated descriptions, tooltips, keyboard announcements, focus labels, appointment
sublabels or the data table; exported drawings inherit that redaction. `redact: false` allows
these patient fields to be shown (for staff screens). A display hint such as `showPatient: true`
does not override `redact: true`.

Redaction controls generated output, not the source data stored in memory. Do not put patient
identifiers into appointment `id`/`label`, resource names or custom callback output: arbitrary
caller text is not rewritten by this option. The charts send nothing anywhere.
