@umriss-ui/charts 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4031 @@
1
+ # @umriss-ui/charts 0.7.0
2
+
3
+ > Canvas-based chart library - few chart kinds, uncompromisingly clean, high performance.
4
+
5
+ Install with `pnpm add @umriss-ui/charts`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/charts/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and why it is built the way it is. The pages index stands in https://romanhaendler.github.io/umriss-ui/charts/llms.txt.
6
+
7
+ ## Chart
8
+
9
+ The container and its axes: what is drawn on, and what the drawing is measured against.
10
+
11
+ ### Chart
12
+
13
+ The container: it holds the data, measures its host and draws its series on canvas - series and axes are its children, and their order in the JSX is the drawing order.
14
+
15
+ ```ts
16
+ import { Chart } from "@umriss-ui/charts";
17
+ ```
18
+
19
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/chart
20
+
21
+ #### Examples
22
+
23
+ ##### Mixed series kinds
24
+
25
+ ```tsx
26
+ /* One chart, four series kinds, shared axes: inflow and outflow as grouped bars,
27
+ a corridor as a band area with a gap, the stock as a line and samples as a
28
+ scatter.
29
+
30
+ The order in the JSX decides what lies over what - and at the same time the
31
+ palette colour. That is the whole composition rule: a series is an element,
32
+ not an entry in a configuration object. */
33
+
34
+ import { Area, Bar, Chart, Legend, Line, Scatter, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
35
+ import { mixedData, type MixedPoint } from "../../data";
36
+
37
+ /* The plant this is drawn from stands in the second tab, so that the
38
+ example can be copied whole. */
39
+
40
+ export default function Mixed() {
41
+ return (
42
+ <Chart data={mixedData} height={320} ariaLabel="Bars, area, line and scatter in one chart">
43
+ <XAxis accessor={(d: MixedPoint) => d.t} label="Period" tickCount={7} />
44
+ <YAxis accessor={(d: MixedPoint) => d.stock} label="Quantity" />
45
+ <Bar accessor={(d: MixedPoint) => d.inflow} name="Inflow" />
46
+ <Bar accessor={(d: MixedPoint) => d.outflow} name="Outflow" />
47
+ <Area
48
+ accessor={(d: MixedPoint) => d.corridorUpper}
49
+ baseline={(d: MixedPoint) => d.corridorLower}
50
+ name="Corridor (with a gap)"
51
+ />
52
+ <Line accessor={(d: MixedPoint) => d.stock} name="Stock" strokeWidth={2} />
53
+ <Scatter accessor={(d: MixedPoint) => d.sample} name="Samples" />
54
+ <Legend placement="top" />
55
+ <Tooltip mode="x" />
56
+ </Chart>
57
+ );
58
+ }
59
+ ```
60
+
61
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
62
+
63
+ ##### Size and resize
64
+
65
+ ```tsx
66
+ /* 100 % width in a draggable container, a fixed size beside it, and a chart in a
67
+ collapsible surrounding.
68
+
69
+ The three cases a chart meets in an application, and the one thing they have
70
+ in common: the chart measures its host rather than being told a pixel. A
71
+ container that collapses to nothing and comes back has to redraw at the width
72
+ it finds, not at the one it had. */
73
+
74
+ import { useState } from "react";
75
+ import { Chart, Line, XAxis, YAxis } from "@umriss-ui/charts";
76
+ import { basicData, type Point } from "../../data";
77
+
78
+ /* The plant this is drawn from stands in the second tab, so that the
79
+ example can be copied whole. */
80
+
81
+ export default function Sizes() {
82
+ const [collapsed, setCollapsed] = useState(false);
83
+
84
+ return (
85
+ <>
86
+ <div className="resizable">
87
+ <Chart data={basicData} height={200} ariaLabel="Full width, draggable">
88
+ <XAxis accessor={(d: Point) => d.t} />
89
+ <YAxis accessor={(d: Point) => d.a} />
90
+ <Line accessor={(d: Point) => d.a} name="Series A" />
91
+ </Chart>
92
+ </div>
93
+ <button className="demo-button" onClick={() => setCollapsed((v) => !v)}>{collapsed ? "Expand" : "Collapse"}</button>
94
+ {!collapsed && (
95
+ <div className="side-by-side">
96
+ <Chart data={basicData} width={420} height={180} ariaLabel="Fixed size">
97
+ <XAxis accessor={(d: Point) => d.t} />
98
+ <YAxis accessor={(d: Point) => d.a} />
99
+ <Line accessor={(d: Point) => d.a} name="Series A" />
100
+ </Chart>
101
+ <div className="narrow">
102
+ <Chart data={basicData} height={180} ariaLabel="Narrow container">
103
+ <XAxis accessor={(d: Point) => d.t} />
104
+ <YAxis accessor={(d: Point) => d.a} />
105
+ <Line accessor={(d: Point) => d.a} name="Series A" />
106
+ </Chart>
107
+ </div>
108
+ </div>
109
+ )}
110
+ </>
111
+ );
112
+ }
113
+ ```
114
+
115
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
116
+
117
+ ##### Nothing to show
118
+
119
+ ```tsx
120
+ /* A chart with nothing to show says so. No data, only gaps, or every series
121
+ hidden: the axes and the frame stay where they are, and the plot area says
122
+ "No data" - or whatever the caller puts into `empty`, in any language. An
123
+ empty frame that says nothing looks like a chart of a line that stood
124
+ still. */
125
+
126
+ import { Chart, Line, XAxis, YAxis } from "@umriss-ui/charts";
127
+
128
+ interface Reading {
129
+ t: number;
130
+ celsius: number;
131
+ }
132
+
133
+ const nothing: Reading[] = [];
134
+
135
+ function Furnace({ empty }: { empty?: string }) {
136
+ return (
137
+ <Chart data={nothing} height={220} ariaLabel="A furnace without readings" empty={empty}>
138
+ <XAxis accessor={(d: Reading) => d.t} time />
139
+ <YAxis accessor={(d: Reading) => d.celsius} label="°C" />
140
+ <Line accessor={(d: Reading) => d.celsius} name="Furnace 1" />
141
+ </Chart>
142
+ );
143
+ }
144
+
145
+ export default function Empty() {
146
+ return (
147
+ <div className="pair">
148
+ <div>
149
+ <p className="pair-caption">no empty - the default</p>
150
+ <Furnace />
151
+ </div>
152
+ <div>
153
+ <p className="pair-caption">empty="Furnace 1 is not reporting"</p>
154
+ <Furnace empty="Furnace 1 is not reporting" />
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+ ```
160
+
161
+ ##### Cursor sync
162
+
163
+ ```tsx
164
+ /* Three charts of one kiln, stacked: its temperature, the flue gas and the gas
165
+ it burns. `syncId` gives them one pointer - hover one, and the other two draw
166
+ their crosshair at the same instant; the tooltip stays where the pointer is.
167
+ Zoom is not shared by the library: the three pass one controlled domain
168
+ between them, so a zoom in any of them zooms all three. Each plot begins
169
+ where its own y axis ends; labels of one width - three figures here - keep
170
+ the three crosshairs in one column. */
171
+
172
+ import { useState } from "react";
173
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
174
+ import { kilnData, type KilnPoint } from "../../data";
175
+
176
+ /* The plant this is drawn from stands in the second tab, so that the
177
+ example can be copied whole. */
178
+
179
+ const CHANNELS = [
180
+ { name: "Kiln", unit: "°C", value: (d: KilnPoint) => d.temperature },
181
+ { name: "Flue gas", unit: "°C", value: (d: KilnPoint) => d.flue },
182
+ { name: "Gas", unit: "m³/h", value: (d: KilnPoint) => d.gas },
183
+ ] as const;
184
+
185
+ export default function CursorSync() {
186
+ const [domain, setDomain] = useState<"data" | readonly [number, number]>("data");
187
+ return (
188
+ <div>
189
+ {CHANNELS.map((c) => (
190
+ <Chart key={c.name} data={kilnData} height={140} syncId="kiln" ariaLabel={`${c.name} across a week`}>
191
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
192
+ <YAxis accessor={c.value} label={c.unit} domain="visible" tickCount={4} />
193
+ <Line accessor={c.value} name={c.name} />
194
+ <Tooltip mode="x" />
195
+ </Chart>
196
+ ))}
197
+ </div>
198
+ );
199
+ }
200
+ ```
201
+
202
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
203
+
204
+ ##### By keyboard and screen reader
205
+
206
+ ```tsx
207
+ /* A chart with a tooltip is one tab stop (ADR-0030). Tab into it: the Active
208
+ point stands at the newest value, ← and → walk the readings, ↑ and ↓ choose
209
+ the series read first, Home and End go to the ends, Escape clears. Where the
210
+ axis is zoomable, + and − zoom, Shift with ← or → pans, 0 shows the week
211
+ again. A screen reader hears the values once the keys rest, and the plot
212
+ is described by a summary of what it shows.
213
+
214
+ The words are the chart's own register (ADR-0031): English without a value,
215
+ German from `@umriss-ui/charts/wording/de`. */
216
+
217
+ import { useState } from "react";
218
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
219
+ import { GERMAN_CHARTS_WORDING } from "@umriss-ui/charts/wording/de";
220
+ import { kilnData, type KilnPoint } from "../../data";
221
+
222
+ /* The plant this is drawn from stands in the second tab, so that the
223
+ example can be copied whole. */
224
+
225
+ export default function KeyboardAndScreenReader() {
226
+ const [domain, setDomain] = useState<"data" | readonly [number, number]>("data");
227
+ return (
228
+ <div>
229
+ <Chart data={kilnData} height={200} ariaLabel="Kiln and flue gas across a week">
230
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
231
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" />
232
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Kiln" />
233
+ <Line accessor={(d: KilnPoint) => d.flue} name="Flue gas" />
234
+ <Tooltip mode="x" />
235
+ </Chart>
236
+ <Chart
237
+ data={kilnData}
238
+ height={200}
239
+ ariaLabel="Ofen und Rauchgas über eine Woche"
240
+ wording={GERMAN_CHARTS_WORDING}
241
+ >
242
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
243
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" />
244
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Ofen" />
245
+ <Line accessor={(d: KilnPoint) => d.flue} name="Rauchgas" />
246
+ <Tooltip mode="x" />
247
+ </Chart>
248
+ </div>
249
+ );
250
+ }
251
+ ```
252
+
253
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
254
+
255
+ ##### Told apart without colour
256
+
257
+ ```tsx
258
+ /* Four courses a reader has to tell apart - and colour is not enough for
259
+ every reader, nor for a grey printout. With `encoding="marks"` each series
260
+ carries a dash pattern and a marker shape as well as its colour, chosen by
261
+ the same palette place: the first stays solid with circles, the second is
262
+ dashed with squares, the third dotted with triangles, and so on. The legend's
263
+ chips show the same.
264
+
265
+ Left without it, as every chart is by default; right with it. */
266
+
267
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
268
+ import { configData, type Point } from "../../data";
269
+
270
+ /* The plant this is drawn from stands in the second tab, so that the
271
+ example can be copied whole. */
272
+
273
+ function Courses({ encoding }: { encoding?: "marks" }) {
274
+ return (
275
+ <Chart data={configData} height={240} ariaLabel="Four courses" encoding={encoding}>
276
+ <XAxis accessor={(d: Point) => d.t} label="Hour" />
277
+ <YAxis accessor={(d: Point) => d.a} />
278
+ <Line accessor={(d: Point) => d.a} name="Line A" />
279
+ <Line accessor={(d: Point) => d.b} name="Line B" />
280
+ <Line accessor={(d: Point) => d.c} name="Line C" />
281
+ <Line accessor={(d: Point) => d.d} name="Line D" />
282
+ <Legend />
283
+ <Tooltip mode="x" />
284
+ </Chart>
285
+ );
286
+ }
287
+
288
+ export default function ToldApartWithoutColour() {
289
+ return (
290
+ <div className="pair">
291
+ <div>
292
+ <p className="pair-caption">by colour - the default</p>
293
+ <Courses />
294
+ </div>
295
+ <div>
296
+ <p className="pair-caption">encoding="marks"</p>
297
+ <Courses encoding="marks" />
298
+ </div>
299
+ </div>
300
+ );
301
+ }
302
+ ```
303
+
304
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
305
+
306
+ ##### Marks on every kind
307
+
308
+ ```tsx
309
+ /* Encoding by marks on every kind in one chart: the bars take a hatch across
310
+ their fill, the corridor a dash on its outline and a hatch in its own
311
+ colour, the line its dash and markers, the samples their shape - each by
312
+ its palette place, so the pairing of colour and mark is the same in every
313
+ chart of an application. */
314
+
315
+ import { Area, Bar, Chart, Legend, Line, Scatter, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
316
+ import { mixedData, type MixedPoint } from "../../data";
317
+
318
+ /* The plant this is drawn from stands in the second tab, so that the
319
+ example can be copied whole. */
320
+
321
+ export default function MarksOnEveryKind() {
322
+ return (
323
+ <Chart data={mixedData} height={320} ariaLabel="Bars, area, line and scatter, told apart by marks" encoding="marks">
324
+ <XAxis accessor={(d: MixedPoint) => d.t} label="Period" tickCount={7} />
325
+ <YAxis accessor={(d: MixedPoint) => d.stock} label="Quantity" />
326
+ <Bar accessor={(d: MixedPoint) => d.inflow} name="Inflow" />
327
+ <Bar accessor={(d: MixedPoint) => d.outflow} name="Outflow" />
328
+ <Area
329
+ accessor={(d: MixedPoint) => d.corridorUpper}
330
+ baseline={(d: MixedPoint) => d.corridorLower}
331
+ name="Corridor"
332
+ />
333
+ <Line accessor={(d: MixedPoint) => d.stock} name="Stock" strokeWidth={2} />
334
+ <Scatter accessor={(d: MixedPoint) => d.sample} name="Samples" />
335
+ <Legend placement="top" />
336
+ <Tooltip mode="x" />
337
+ </Chart>
338
+ );
339
+ }
340
+ ```
341
+
342
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
343
+
344
+ ##### Bands, limits and cells
345
+
346
+ ```tsx
347
+ /* What a plant screen says in colour alone - a state, a tolerance, a verdict
348
+ per cell - told by marks as well. Each state is hatched by its name, in the
349
+ order the names first come (the first plain), the tolerance band is
350
+ hatched in its own colour, and a matrix hatches each step of its
351
+ colouring. A reader who
352
+ cannot tell the fault's red from the setup's amber still tells the hatches
353
+ apart, and the legend shows which is which. */
354
+
355
+ import { Chart, Legend, LimitBand, LimitLine, Line, Matrix, StateBand, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
356
+ import { MACHINES, PLANT_STATES, matrixData, shiftData, type CellPoint, type StatePoint } from "../../data";
357
+
358
+ /* The plant this is drawn from stands in the second tab, so that the
359
+ example can be copied whole. */
360
+
361
+ const timeOfDay = (v: number) =>
362
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
363
+
364
+ export default function BandsLimitsAndCells() {
365
+ return (
366
+ <div className="side-by-side">
367
+ <Chart data={shiftData} height={280} ariaLabel="Furnace temperature against its tolerance above its states" encoding="marks">
368
+ <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" tickCount={4} />
369
+ <YAxis accessor={(d: StatePoint) => d.temperature} domain={[740, 900]} label="°C" />
370
+ <YAxis id="lane" position="right" accessor={() => 0} domain={[0, 5]} ticks={[0.45]} tickFormat={() => "Furnace 1"} />
371
+ <LimitBand from={780} to={820} severity="warning" label="Tolerance" />
372
+ <LimitLine value={860} severity="alarm" label="Alarm" />
373
+ <StateBand
374
+ accessor={(d: StatePoint) => d.m1}
375
+ states={PLANT_STATES}
376
+ yAxisId="lane"
377
+ laneFrom={0}
378
+ laneTo={0.9}
379
+ name="Furnace 1"
380
+ />
381
+ <Line accessor={(d: StatePoint) => d.temperature} name="Temperature" strokeWidth={1.75} />
382
+ <Legend placement="top" />
383
+ <Tooltip mode="x" />
384
+ </Chart>
385
+ <Chart data={matrixData} height={280} ariaLabel="OEE per machine and hour, by assessment and by marks" encoding="marks">
386
+ <XAxis accessor={(d: CellPoint) => d.hour} ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
387
+ <YAxis
388
+ accessor={(d: CellPoint) => d.machine}
389
+ ticks={MACHINES.map((_, i) => i)}
390
+ tickFormat={(v) => MACHINES[v] ?? ""}
391
+ />
392
+ <Matrix
393
+ accessor={(d: CellPoint) => d.machine}
394
+ value={(d: CellPoint) => d.oee}
395
+ coloring={{
396
+ kind: "assessment",
397
+ limits: {
398
+ limits: [
399
+ { value: 70, side: "lower", severity: "warning" },
400
+ { value: 40, side: "lower", severity: "alarm" },
401
+ ],
402
+ },
403
+ }}
404
+ name="OEE"
405
+ />
406
+ <Legend placement="top" />
407
+ <Tooltip mode="nearest" />
408
+ </Chart>
409
+ </div>
410
+ );
411
+ }
412
+ ```
413
+
414
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
415
+
416
+ #### API
417
+
418
+ ##### `ChartProps<T>`
419
+
420
+ | Prop | Type | Default | Description |
421
+ |---|---|---|---|
422
+ | `data` (required) | `readonly T[]` | — | Shared data basis (required); access only through accessors (R-2.4). |
423
+ | `width` | `number \| "100%"` | — | Width in CSS pixels, or `"100%"` for the host's. Without a value the chart measures its host and follows it through a ResizeObserver. |
424
+ | `height` | `number` | — | Height in CSS pixels; 300 without a value. Unlike the width, the chart does not take its host's. |
425
+ | `padding` | `number \| Partial<Padding>` | — | Outer spacing of the plot area in CSS pixels. |
426
+ | `ariaLabel` | `string` | — | Required in DEV through a warning (R-7.6). |
427
+ | `className` | `string` | — | Goes to the root element, as it does everywhere in this workspace. |
428
+ | `style` | `CSSProperties` | — | Goes to the root element. The plot area is measured, never styled from here: a height set past the component is a height the scene does not know about. |
429
+ | `empty` | `ReactNode` | — | Shown in the middle of the plot area when no visible series has a point to show - no data, only gaps, or every series hidden. Axes and frame stay. Without a value the wording's `empty`; this prop wins over it. |
430
+ | `wording` | `Partial<ChartsWording>` | — | The chart's words (ADR-0031): English without a value, entries left out fall back to English. German: `GERMAN_CHARTS_WORDING` from `@umriss-ui/charts/wording/de`. |
431
+ | `encoding` | `"color" \| "marks"` | — | How series are told apart. `"color"` (the default): by their colour. `"marks"`: by a dash and a marker shape as well - a line, an area and a scatter -, and by a hatch - a bar, a state, a matrix cell, a limit band -, chosen by the same palette place as the colour, so that a reader who does not see the colours still tells them apart; the legend's chips show the same. A caller's own `dash` wins over the place's. |
432
+ | `syncId` | `string` | — | Charts with the same id share the pointer's x position, in domain units: each draws its crosshair there, the tooltip stays with the chart under the pointer. Zoom is not shared - give every chart the same controlled `domain`. |
433
+ | `onPerf` | `(perf: ChartPerf) => void` | — | Instrumentation for the benchmark page (R-5.1); not needed otherwise. |
434
+ | `children` | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it (R-2.1), and the order in the JSX is the drawing order. |
435
+
436
+ #### Why it is like this
437
+
438
+ ##### Every scale is affine, and the draw loop relies on it
439
+
440
+ A scale here is a slope and an offset, never a callback the loop asks per point. At a million points the difference is not a micro-optimisation but the difference between a chart and a freeze: the loop reads two numbers and multiplies. Everything that is not affine - a log axis, a category scale - is therefore not a scale in this library but a mapping that happens earlier, in materialisation. See `docs/adr/0001-affine-scale-contract.md`.
441
+
442
+ ##### Two layers, and a hover touches only one
443
+
444
+ The series lie on one canvas, crosshair and markers on a second above it. A hover redraws the overlay and leaves the series where they are - which is why the frame rate stays at sixty while the pointer travels over a million points. The benchmark page measures exactly that.
445
+
446
+ ##### Canvas for the marks, DOM for everything that is read
447
+
448
+ Axes, ticks, labels, legend and tooltip are elements. They can be selected, read by a screen reader, styled with tokens and found by a test. Only what would be thousands of nodes - the marks themselves - is drawn.
449
+
450
+ ### Axis
451
+
452
+ What a value is measured against: extent, ticks and labelling, several per orientation - and an operating time axis that leaves the empty hours out and says so.
453
+
454
+ ```ts
455
+ import { XAxis, YAxis, operatingCalendar } from "@umriss-ui/charts";
456
+ ```
457
+
458
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/axis
459
+
460
+ #### Examples
461
+
462
+ ##### Domain, ticks and tooltip
463
+
464
+ ```tsx
465
+ /* A fixed domain [0, 100], a tickFormat of its own, markers `always`, a custom
466
+ tooltip through the render prop, mode `nearest`.
467
+
468
+ The explicit type argument on `Tooltip<Point>` is what makes the hit fully
469
+ typed in the render prop - without it the callback would take the widest
470
+ shape the container can offer. */
471
+
472
+ import { useMemo } from "react";
473
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
474
+ import type { TooltipHit } from "@umriss-ui/charts";
475
+ import { configData, type Point } from "../../data";
476
+
477
+ /* The plant this is drawn from stands in the second tab, so that the
478
+ example can be copied whole. */
479
+
480
+ function CustomTooltip({ hit }: { hit: TooltipHit<Point> }) {
481
+ const point = hit.points[0];
482
+ return (
483
+ <div className="custom-tooltip">
484
+ <strong>Index {hit.xValue}</strong>
485
+ {point === undefined ? null : (
486
+ <span>
487
+ {point.seriesName}: {point.yValue.toFixed(2)}
488
+ </span>
489
+ )}
490
+ </div>
491
+ );
492
+ }
493
+
494
+ export default function Configuration() {
495
+ const configFormat = useMemo(() => (v: number) => `${v.toFixed(0)} pt`, []);
496
+
497
+ return (
498
+ <Chart
499
+ data={configData}
500
+ height={280}
501
+ padding={{ top: 12, right: 16, bottom: 8, left: 8 }}
502
+ ariaLabel="Configured course with a fixed domain"
503
+ >
504
+ <XAxis accessor={(d: Point) => d.t} label="Index" tickCount={6} />
505
+ <YAxis accessor={(d: Point) => d.a} domain={[0, 100]} tickFormat={configFormat} label="Points" />
506
+ <Line accessor={(d: Point) => d.a} name="Series A" markers="always" strokeWidth={2} />
507
+ <Line accessor={(d: Point) => d.b} name="Series B" markers="always" color="#7c3aed" />
508
+ <Legend placement="bottom" />
509
+ <Tooltip<Point> mode="nearest" render={(hit) => <CustomTooltip hit={hit} />} />
510
+ </Chart>
511
+ );
512
+ }
513
+ ```
514
+
515
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
516
+
517
+ ##### Multiple axes
518
+
519
+ ```tsx
520
+ /* Three y axes with clearly different extents: one on the left, two stacked on
521
+ the right (the second lies outside, separated by the gap). The series "large"
522
+ is bound to a second x axis on top and counts in relative steps - the tooltip
523
+ still compares the series correctly in pixel space.
524
+
525
+ Only the first registered axis per orientation draws a grid. Two grids over
526
+ one plot area are two rulers over one drawing. */
527
+
528
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
529
+ import { axesData, type DualPoint } from "../../data";
530
+
531
+ /* The plant this is drawn from stands in the second tab, so that the
532
+ example can be copied whole. */
533
+
534
+ export default function Axes() {
535
+ return (
536
+ <Chart data={axesData} height={340} ariaLabel="Three extents on three y axes">
537
+ <XAxis accessor={(d: DualPoint) => d.t} label="Step" />
538
+ <XAxis id="top" position="top" accessor={(d: DualPoint) => d.t - 2000} label="Step (relative)" />
539
+ <YAxis id="small" position="left" label="small" accessor={(d: DualPoint) => d.small} />
540
+ <YAxis id="large" position="right" label="large" accessor={(d: DualPoint) => d.large} />
541
+ <YAxis id="medium" position="right" label="medium" accessor={(d: DualPoint) => d.medium} />
542
+ <Line accessor={(d: DualPoint) => d.small} yAxisId="small" name="small" />
543
+ <Line
544
+ accessor={(d: DualPoint) => d.large}
545
+ xAxisId="top"
546
+ yAxisId="large"
547
+ name="large"
548
+ dash={[4, 4]}
549
+ />
550
+ <Line accessor={(d: DualPoint) => d.medium} yAxisId="medium" name="medium" />
551
+ <Legend placement="top" />
552
+ <Tooltip mode="x" />
553
+ </Chart>
554
+ );
555
+ }
556
+ ```
557
+
558
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
559
+
560
+ ##### Operating time axis
561
+
562
+ ```tsx
563
+ /* The same week, only without the hours in which nobody was there: Monday to
564
+ Friday, 6 to 22 o'clock.
565
+
566
+ On a wall clock axis the chart would consist, for a good forty per cent, of
567
+ flat lines over an empty hall. Every removed span carries a break mark - a
568
+ chart that takes a weekend out and does not say so claims a continuity it does
569
+ not have. The scale stays affine; the mapping happens in materialisation
570
+ (ADR-0001). */
571
+
572
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
573
+ import { WEEK_CALENDAR, weekData, type WeekPoint } from "../../data";
574
+
575
+ /* The plant this is drawn from stands in the second tab, so that the
576
+ example can be copied whole. */
577
+
578
+ const weekdayAndTime = (v: number) =>
579
+ new Date(v).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
580
+
581
+ export default function OperatingTime() {
582
+ return (
583
+ <Chart data={weekData} height={280} ariaLabel="Output across the operating time of one week">
584
+ <XAxis
585
+ accessor={(d: WeekPoint) => d.t}
586
+ calendar={WEEK_CALENDAR}
587
+ tickFormat={weekdayAndTime}
588
+ tickCount={8}
589
+ label="Operating time"
590
+ />
591
+ <YAxis accessor={(d: WeekPoint) => d.output} label="Pieces/h" />
592
+ <Line accessor={(d: WeekPoint) => d.output} name="Output" />
593
+ <Tooltip mode="x" />
594
+ </Chart>
595
+ );
596
+ }
597
+ ```
598
+
599
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
600
+
601
+ ##### Time axis
602
+
603
+ ```tsx
604
+ /* A day of hall temperature on a time axis: `time` says the values are
605
+ instants, and the axis stands its ticks on the local clock and labels them by
606
+ level - the hours, and the date where the day changes. No `tickFormat`; one
607
+ would take over, in any language. */
608
+
609
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
610
+ import { hallData, type HallPoint } from "../../data";
611
+
612
+ /* The plant this is drawn from stands in the second tab, so that the
613
+ example can be copied whole. */
614
+
615
+ export default function TimeAxis() {
616
+ return (
617
+ <Chart data={hallData} height={260} ariaLabel="Hall temperature across a day and a night">
618
+ <XAxis accessor={(d: HallPoint) => d.t} time />
619
+ <YAxis accessor={(d: HallPoint) => d.temperature} label="°C" />
620
+ <Line accessor={(d: HallPoint) => d.temperature} name="Hall" />
621
+ <Tooltip mode="x" />
622
+ </Chart>
623
+ );
624
+ }
625
+ ```
626
+
627
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
628
+
629
+ ##### Zoom and pan
630
+
631
+ ```tsx
632
+ /* A week of kiln temperature, one reading a minute - too long to read a shift
633
+ in. `onDomainChange` makes the x axis zoomable: Ctrl or ⌘ with the wheel, or
634
+ a pinch, zooms around the pointer, a drag pans, a double click shows the
635
+ whole week again. The axis only proposes; the domain stays the caller's,
636
+ who passes it back - and could clamp it on the way. */
637
+
638
+ import { useState } from "react";
639
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
640
+ import { kilnData, type KilnPoint } from "../../data";
641
+
642
+ /* The plant this is drawn from stands in the second tab, so that the
643
+ example can be copied whole. */
644
+
645
+ export default function ZoomAndPan() {
646
+ const [domain, setDomain] = useState<"data" | readonly [number, number]>("data");
647
+ return (
648
+ <Chart data={kilnData} height={260} ariaLabel="Kiln temperature across a week, zoomable">
649
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
650
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" />
651
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Kiln" />
652
+ <Tooltip mode="x" />
653
+ </Chart>
654
+ );
655
+ }
656
+ ```
657
+
658
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
659
+
660
+ ##### The visible domain
661
+
662
+ ```tsx
663
+ /* The same week, opened on Wednesday afternoon, when the burner tripped. With
664
+ `domain="visible"` the y axis fits what the x domain shows: the twenty
665
+ minutes the kiln fell and the hour it took to climb back, not the week's
666
+ 600 to 850 °C that would press them flat. Zoom out - Ctrl or ⌘ with the
667
+ wheel, or a double click - and the axis follows. */
668
+
669
+ import { useState } from "react";
670
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
671
+ import { DAY_MS, HOUR_MS, WEEK_START, kilnData, type KilnPoint } from "../../data";
672
+
673
+ /* The plant this is drawn from stands in the second tab, so that the
674
+ example can be copied whole. */
675
+
676
+ const WEDNESDAY = WEEK_START + 2 * DAY_MS;
677
+
678
+ export default function VisibleDomain() {
679
+ const [domain, setDomain] = useState<readonly [number, number]>([
680
+ WEDNESDAY + 12 * HOUR_MS,
681
+ WEDNESDAY + 17 * HOUR_MS,
682
+ ]);
683
+ return (
684
+ <Chart data={kilnData} height={260} ariaLabel="Kiln temperature on Wednesday afternoon, the y axis fitted to it">
685
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
686
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" domain="visible" />
687
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Kiln" />
688
+ <Tooltip mode="x" />
689
+ </Chart>
690
+ );
691
+ }
692
+ ```
693
+
694
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
695
+
696
+ ##### Aligned ticks
697
+
698
+ ```tsx
699
+ /* Two quantities, two y axes, one grid: the kiln's temperature on the left and
700
+ the gas it burns on the right. Only the first axis draws grid lines; with
701
+ `alignTicks` the gas axis takes its tick count and widens its own domain
702
+ until each of its ticks stands on one of those lines - in steps of 1, 2 or
703
+ 5, never 17.3. Without it the right labels would float between the lines,
704
+ and a reader would take a line for a value it is not. */
705
+
706
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
707
+ import { kilnData, type KilnPoint } from "../../data";
708
+
709
+ /* The plant this is drawn from stands in the second tab, so that the
710
+ example can be copied whole. */
711
+
712
+ export default function AlignedTicks() {
713
+ return (
714
+ <Chart data={kilnData} height={260} ariaLabel="Kiln temperature and gas flow across a week on one grid">
715
+ <XAxis accessor={(d: KilnPoint) => d.t} time />
716
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" />
717
+ <YAxis id="gas" position="right" accessor={(d: KilnPoint) => d.gas} label="m³/h" alignTicks />
718
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Temperature" />
719
+ <Line accessor={(d: KilnPoint) => d.gas} yAxisId="gas" name="Gas" />
720
+ <Legend placement="top" />
721
+ <Tooltip mode="x" />
722
+ </Chart>
723
+ );
724
+ }
725
+ ```
726
+
727
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
728
+
729
+ #### API
730
+
731
+ ##### `XAxisProps<T>`
732
+
733
+ | Prop | Type | Default | Description |
734
+ |---|---|---|---|
735
+ | `domain` | `"nice" \| "data" \| readonly [number, number]` | — | `"nice"` widens the data's extent to ticks, `"data"` keeps it, a pair is fixed - the one a zoom passes back. |
736
+ | `position` | `"bottom" \| "top"` | — | Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own. |
737
+ | `time` | `boolean` | — | The values are instants (milliseconds since the epoch): ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant. `calendar` implies it. |
738
+ | `calendar` | `readonly OperatingInterval[]` | — | Operating calendar: the intervals in which time counts. With it the axis stands in operating time - weekends and night shifts are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation (ADR-0001). |
739
+ | `onDomainChange` | `(domain: [number, number]) => void` | — | Zoom and pan, controlled: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click proposes the whole data range. Each proposes a domain in the axis' units and changes nothing; the caller passes it back as `domain`, clamped as it likes. Without a handler the axis does not zoom, and the plain wheel always scrolls the page. |
740
+ | `axisId` | `string` | — | Axis on which the value lies. Without one, the first axis of the orientation. |
741
+ | `orientation` | `"x" \| "y"` | — | Whether the value lies on the x or on the y axis. Without one, y: a limit is nearly always a value and not a moment. |
742
+ | `severity` | `Severity` | — | Two severities, not five: five urgencies are five colours, and five colours on a plant screen are noise. |
743
+ | `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. |
744
+ | `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. |
745
+ | `inExtent` | `boolean` | — | Whether the value widens the axis extent, so that the limit is certainly visible. Default true; the head of this file says why. |
746
+ | `role` | `LimitRole` | — | A chosen specification limit (the default) or a calculated control limit. They look different because they mean different things (ADR-0008). |
747
+
748
+ ##### `YAxisProps<T>`
749
+
750
+ | Prop | Type | Default | Description |
751
+ |---|---|---|---|
752
+ | `domain` | `"nice" \| "data" \| "visible" \| readonly [number, number]` | — | As on the x axis, and `"visible"`: what the series show inside their x axis' domain - a zoomed hour gets the hour's range, not the week's -, widened to ticks as `"nice"` is. Where the x domain is not fixed that is every point. |
753
+ | `alignTicks` | `boolean` | — | On a further y axis: take the first y axis' tick count and widen this axis' domain until its ticks fall on that one's grid lines, the steps still 1-2-5 - one grid serves both. `tickCount` then does not apply; on the first y axis it changes nothing. |
754
+ | `position` | `"left" \| "right"` | — | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable. |
755
+ | `axisId` | `string` | — | Axis on which the value lies. Without one, the first axis of the orientation. |
756
+ | `orientation` | `"x" \| "y"` | — | Whether the value lies on the x or on the y axis. Without one, y: a limit is nearly always a value and not a moment. |
757
+ | `severity` | `Severity` | — | Two severities, not five: five urgencies are five colours, and five colours on a plant screen are noise. |
758
+ | `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. |
759
+ | `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. |
760
+ | `inExtent` | `boolean` | — | Whether the value widens the axis extent, so that the limit is certainly visible. Default true; the head of this file says why. |
761
+ | `role` | `LimitRole` | — | A chosen specification limit (the default) or a calculated control limit. They look different because they mean different things (ADR-0008). |
762
+
763
+ #### Why it is like this
764
+
765
+ ##### Operating time is a mapping, not a scale
766
+
767
+ An operating calendar takes out the hours in which nobody was there - nights, weekends. The obvious implementation is a scale that skips them. This one does not: every scale in the library stays affine, a slope and an offset the draw loop multiplies with. Instead the x values are mapped into operating time once, during materialisation, and the scale never learns that a weekend existed. The drawing loop pays nothing for the calendar. See `docs/adr/0001-affine-scale-contract.md`.
768
+
769
+ ##### Removed time is a gap, and the axis says so
770
+
771
+ A measurement from an hour the calendar removed has no place on the axis. It is not laid on the edge of the interval, where it would pile up on what really happened there; it becomes a gap, like every missing value in this library - never drawn, never hit. The seam itself is where Friday evening and Monday morning now stand side by side, and every removed span gets a break mark on the axis at that seam. A chart that took a weekend out without saying so would claim a continuity it does not have.
772
+
773
+ ## Series
774
+
775
+ The kinds a chart can draw. They share the axes and mix freely; the order in the JSX decides what lies over what.
776
+
777
+ ### Line
778
+
779
+ A course over an axis - with markers, a dash pattern and a gap that stays a gap instead of being interpolated.
780
+
781
+ ```ts
782
+ import { Line } from "@umriss-ui/charts";
783
+ ```
784
+
785
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/line
786
+
787
+ #### Examples
788
+
789
+ ##### One series
790
+
791
+ ```tsx
792
+ /* One series, default axes, grid only on the first y axis. */
793
+
794
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
795
+ import { basicData, type Point } from "../../data";
796
+
797
+ /* The plant this is drawn from stands in the second tab, so that the
798
+ example can be copied whole. */
799
+
800
+ export default function Basic() {
801
+ return (
802
+ <Chart data={basicData} height={280} ariaLabel="Basic course of one series">
803
+ <XAxis accessor={(d: Point) => d.t} label="Index" />
804
+ <YAxis accessor={(d: Point) => d.a} />
805
+ <Line accessor={(d: Point) => d.a} name="Series A" />
806
+ <Tooltip />
807
+ </Chart>
808
+ );
809
+ }
810
+ ```
811
+
812
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
813
+
814
+ ##### Several series and gaps
815
+
816
+ ```tsx
817
+ /* Four series, the legend on top, one dashed series and one series with a gap -
818
+ the line is interrupted, not interpolated. A gap is a hole and never a value
819
+ the chart invents (CONTEXT.md, **Gap**). The one reading inside the gap has
820
+ no neighbour to be joined to; it keeps its marker, whatever the number of
821
+ points. */
822
+
823
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
824
+ import { multiData, type Point } from "../../data";
825
+
826
+ /* The plant this is drawn from stands in the second tab, so that the
827
+ example can be copied whole. */
828
+
829
+ export default function MultiSeries() {
830
+ return (
831
+ <Chart data={multiData} height={300} ariaLabel="Four series with a legend">
832
+ <XAxis accessor={(d: Point) => d.t} label="Index" />
833
+ <YAxis accessor={(d: Point) => d.a} label="Value" />
834
+ <Line accessor={(d: Point) => d.a} name="Series A" />
835
+ <Line accessor={(d: Point) => d.b} name="Series B" />
836
+ <Line accessor={(d: Point) => d.c} name="Series C" dash={[4, 4]} />
837
+ <Line accessor={(d: Point) => d.d} name="Series D (with a gap)" />
838
+ <Legend placement="top" />
839
+ <Tooltip mode="x" />
840
+ </Chart>
841
+ );
842
+ }
843
+ ```
844
+
845
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
846
+
847
+ ##### Step line
848
+
849
+ ```tsx
850
+ /* A set point is not a ramp: it holds until the next entry and jumps there.
851
+ `step` draws a line that way - sample-and-hold - beside the temperature that
852
+ follows it. The set point is logged only when it changes; while no recipe is
853
+ loaded it is a gap, and the hold ends where the gap begins. The tooltip
854
+ reports the entry the hold began with, not the nearer one after it. */
855
+
856
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
857
+ import { corridorData, setPoints, type CorridorPoint, type SetPoint } from "../../data";
858
+
859
+ /* The plant this is drawn from stands in the second tab, so that the
860
+ example can be copied whole. */
861
+
862
+ export default function StepLine() {
863
+ return (
864
+ <Chart data={corridorData} height={260} ariaLabel="Furnace temperature following its set point">
865
+ <XAxis accessor={(d: { t: number }) => d.t} time />
866
+ <YAxis accessor={(d: CorridorPoint) => d.temperature} label="°C" />
867
+ <Line data={setPoints} accessor={(d: SetPoint) => d.setPoint} name="Set point" step strokeWidth={2} />
868
+ <Line accessor={(d: CorridorPoint) => d.temperature} name="Temperature" />
869
+ <Legend />
870
+ <Tooltip mode="x" />
871
+ </Chart>
872
+ );
873
+ }
874
+ ```
875
+
876
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
877
+
878
+ #### API
879
+
880
+ ##### `LineProps<T>`
881
+
882
+ | Prop | Type | Default | Description |
883
+ |---|---|---|---|
884
+ | `accessor` (required) | `Accessor<T>` | — | Y value; null/undefined/NaN/±Infinity means a gap (R-2.5). |
885
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
886
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
887
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
888
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
889
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
890
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it; without one the y axis' `tickFormat`, then the default. |
891
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
892
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. |
893
+ | `strokeWidth` | `number` | — | Line width in CSS pixels. |
894
+ | `dash` | `readonly number[]` | — | Dash pattern as a run of lengths in CSS pixels; without one a solid line. |
895
+ | `markers` | `"auto" \| "always" \| "never"` | — | When the individual points are drawn as marks: `auto` every point up to 60 points and above that only a point between two gaps - it has no line to be seen by - `always`, or `never`, not even that one. |
896
+ | `step` | `boolean` | — | Sample-and-hold: each value holds as a horizontal until the next sample and jumps there - a set point, a digital signal. A gap ends the hold at its x. The tooltip reports the sample the hold began with. |
897
+
898
+ ### Area
899
+
900
+ A filled course, or a corridor between two channels - the baseline is a channel of its own and never a second series.
901
+
902
+ ```ts
903
+ import { Area } from "@umriss-ui/charts";
904
+ ```
905
+
906
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/area
907
+
908
+ #### Examples
909
+
910
+ ##### Filled down to 0
911
+
912
+ ```tsx
913
+ /* One area, filled down to 0: the power draw of a line over the early shift.
914
+
915
+ Without a baseline the foot is 0, and that 0 enters the extent of the y axis -
916
+ a draw has a natural zero, and an axis that started at 30 kW would make the
917
+ break look like a shutdown. Fill and outline are set here explicitly: the fill
918
+ carries the quantity, the outline the course. */
919
+
920
+ import { Area, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
921
+ import { powerData, type PowerPoint } from "../../data";
922
+
923
+ /* The plant this is drawn from stands in the second tab, so that the
924
+ example can be copied whole. */
925
+
926
+ const timeOfDay = (v: number) =>
927
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
928
+
929
+ export default function Filled() {
930
+ return (
931
+ <Chart data={powerData} height={260} ariaLabel="Power draw of one line over the early shift">
932
+ <XAxis accessor={(d: PowerPoint) => d.t} tickFormat={timeOfDay} label="Time" />
933
+ <YAxis accessor={(d: PowerPoint) => d.kw} label="kW" />
934
+ <Area accessor={(d: PowerPoint) => d.kw} name="Power draw" fillOpacity={0.35} strokeWidth={2} />
935
+ <Tooltip mode="x" />
936
+ </Chart>
937
+ );
938
+ }
939
+ ```
940
+
941
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
942
+
943
+ ##### A corridor with a gap
944
+
945
+ ```tsx
946
+ /* A corridor between two channels, and the measured temperature inside it.
947
+
948
+ The lower edge is the `baseline` accessor - a channel of the same series, not a
949
+ second series the area is filled towards. During the recipe change there is
950
+ no corridor: the fill gets a hole there, while the line runs on, because the
951
+ furnace was measured the whole time. */
952
+
953
+ import { Area, Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
954
+ import { corridorData, type CorridorPoint } from "../../data";
955
+
956
+ /* The plant this is drawn from stands in the second tab, so that the
957
+ example can be copied whole. */
958
+
959
+ const timeOfDay = (v: number) =>
960
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
961
+
962
+ export default function Corridor() {
963
+ return (
964
+ <Chart data={corridorData} height={280} ariaLabel="Furnace temperature inside the corridor of its recipe">
965
+ <XAxis accessor={(d: CorridorPoint) => d.t} tickFormat={timeOfDay} label="Time" />
966
+ <YAxis accessor={(d: CorridorPoint) => d.temperature} label="°C" />
967
+ {/* An outline runs along the upper edge only; a corridor has two edges, so
968
+ it gets neither. */}
969
+ <Area
970
+ accessor={(d: CorridorPoint) => d.upper}
971
+ baseline={(d: CorridorPoint) => d.lower}
972
+ name="Recipe corridor"
973
+ strokeWidth={0}
974
+ />
975
+ <Line accessor={(d: CorridorPoint) => d.temperature} name="Furnace temperature" strokeWidth={1.75} />
976
+ <Legend placement="top" />
977
+ <Tooltip mode="x" />
978
+ </Chart>
979
+ );
980
+ }
981
+ ```
982
+
983
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
984
+
985
+ ##### Stacked areas
986
+
987
+ ```tsx
988
+ /* The output of three lines over a day, stacked: the upper edge is the plant's
989
+ output, each band a line's part of it. Every Area with the same `stack`
990
+ stands on the ones registered before it.
991
+
992
+ Line 3 stands at night and reports 0 - its band closes to a line, and the
993
+ plant's edge drops with it. A stacked band carries a quantity, so the fill
994
+ is stronger than a lone area's and the outline thin: the edges are the
995
+ boundaries between parts, not courses of their own. */
996
+
997
+ import { Area, Chart, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
998
+ import { lineOutputData, type LineOutput } from "../../data";
999
+
1000
+ /* The plant this is drawn from stands in the second tab, so that the
1001
+ example can be copied whole. */
1002
+
1003
+ export default function StackedAreas() {
1004
+ return (
1005
+ <Chart data={lineOutputData} height={280} ariaLabel="Output of three lines over a day, stacked">
1006
+ <XAxis accessor={(d: LineOutput) => d.t} time domain="data" />
1007
+ <YAxis accessor={(d: LineOutput) => d.line1} label="Pieces per hour" />
1008
+ <Area accessor={(d: LineOutput) => d.line1} name="Line 1" stack="plant" fillOpacity={0.5} strokeWidth={1} />
1009
+ <Area accessor={(d: LineOutput) => d.line2} name="Line 2" stack="plant" fillOpacity={0.5} strokeWidth={1} />
1010
+ <Area accessor={(d: LineOutput) => d.line3} name="Line 3" stack="plant" fillOpacity={0.5} strokeWidth={1} />
1011
+ <Legend placement="top" />
1012
+ <Tooltip mode="x" />
1013
+ </Chart>
1014
+ );
1015
+ }
1016
+ ```
1017
+
1018
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1019
+
1020
+ #### API
1021
+
1022
+ ##### `AreaProps<T>`
1023
+
1024
+ | Prop | Type | Default | Description |
1025
+ |---|---|---|---|
1026
+ | `accessor` (required) | `Accessor<T>` | — | Upper edge; null/undefined/NaN/±Infinity means a gap (R-2.5). |
1027
+ | `baseline` | `Accessor<T>` | — | Lower edge; without a value the fixed baseline 0. In a `stack` the stack below is the lower edge, and this is not read. |
1028
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1029
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1030
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
1031
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1032
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
1033
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it; without one the y axis' `tickFormat`, then the default. |
1034
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
1035
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it. |
1036
+ | `stack` | `string` | — | The stack this series stands in: every Bar and Area with the same id on the same x and y axis stands on the ones registered before it. A gap stacks as zero; negative values stack downward from zero. The tooltip names each series' own value and the stack's total. |
1037
+ | `normalize` | `boolean` | — | On any member of a stack, every x of the stack sums to 100 %: each value becomes its share, and the y axis reads in percent unless it has a `tickFormat`. The total stays the readings' own sum, written in the series' `format`. |
1038
+ | `fillOpacity` | `number` | — | Opacity of the fill, from 0 to 1; the outline stays fully opaque. |
1039
+ | `strokeWidth` | `number` | — | Width of the outline along the upper edge in CSS pixels; 0 leaves it out. |
1040
+ | `dash` | `readonly number[]` | — | Dash pattern of the outline as a run of lengths in CSS pixels; without one a solid outline. The fill stays whole. |
1041
+
1042
+ #### Why it is like this
1043
+
1044
+ ##### The baseline is a channel, not a second series
1045
+
1046
+ A corridor has two edges, and the obvious way to draw it is two series and a fill between them. This library does not do that: the lower edge is the `baseline` accessor of the same `Area`, materialised into a second channel beside the first. One series has one name, one colour, one legend entry and one gap - where either edge is missing, the fill has a hole, and there is no way for the two edges to disagree about where it is. A fill „towards another series“ would make the order of the JSX a statement about geometry, and hiding one of the two would leave the other filled towards nothing. The kinds share one materialisation shape, and a second channel is how an area fits into it; see `docs/adr/0002-bars-on-a-numeric-x-axis.md`.
1047
+
1048
+ ##### Without a baseline the foot is 0, and 0 is in the extent
1049
+
1050
+ A filled area says „this much“, and a quantity is read from its foot. So the fixed baseline 0 enters the extent of the y axis like the values do - an axis that began at the smallest value would cut the foot off, and a dip would read as a shutdown. A course that has no natural zero is a **Line**, not an area.
1051
+
1052
+ ### Bar
1053
+
1054
+ Bars on the numeric x axis, grouped where several stand side by side (ADR-0002).
1055
+
1056
+ ```ts
1057
+ import { Bar } from "@umriss-ui/charts";
1058
+ ```
1059
+
1060
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/bar
1061
+
1062
+ #### Examples
1063
+
1064
+ ##### One bar per shift
1065
+
1066
+ ```tsx
1067
+ /* Scrap per shift: one bar series, the shifts as positions 0 to 8 on the
1068
+ ordinary numeric x axis, named by the tickFormat (ADR-0002).
1069
+
1070
+ A category is a number the axis places and a function names - no band scale,
1071
+ no category axis. `ticks` puts one tick under every bar; without it the 1-2-5
1072
+ algorithm would pick its own positions and name every second shift. */
1073
+
1074
+ import { Bar, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1075
+ import { scrapData, type ShiftScrap } from "../../data";
1076
+
1077
+ /* The plant this is drawn from stands in the second tab, so that the
1078
+ example can be copied whole. */
1079
+
1080
+ export default function PerShift() {
1081
+ return (
1082
+ <Chart data={scrapData} height={260} ariaLabel="Scrap per shift over three days">
1083
+ <XAxis
1084
+ accessor={(d: ShiftScrap) => d.shift}
1085
+ ticks={scrapData.map((d) => d.shift)}
1086
+ tickFormat={(v) => scrapData[v]?.name ?? ""}
1087
+ />
1088
+ <YAxis accessor={(d: ShiftScrap) => d.scrap} label="Pieces" />
1089
+ <Bar accessor={(d: ShiftScrap) => d.scrap} name="Scrap" />
1090
+ <Tooltip mode="x" />
1091
+ </Chart>
1092
+ );
1093
+ }
1094
+ ```
1095
+
1096
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1097
+
1098
+ ##### Grouped bars
1099
+
1100
+ ```tsx
1101
+ /* Planned and made, side by side: two bar series on the same x axis share the
1102
+ step and stand next to each other, centred on the day as a group.
1103
+
1104
+ `barWidth` is the fraction of the step the whole group takes - so both series
1105
+ carry the same value, and a different one would be a DEV warning. The plan
1106
+ gets a quiet `color` of its own: it is the reference, and the palette colour
1107
+ goes to what was made. A token, so that it follows the colour scheme - the
1108
+ canvas resolves it like the palette. */
1109
+
1110
+ import { Bar, Chart, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1111
+ import { outputData, type DayOutput } from "../../data";
1112
+
1113
+ /* The plant this is drawn from stands in the second tab, so that the
1114
+ example can be copied whole. */
1115
+
1116
+ export default function Grouped() {
1117
+ return (
1118
+ <Chart data={outputData} height={280} ariaLabel="Planned and made output per working day">
1119
+ <XAxis
1120
+ accessor={(d: DayOutput) => d.day}
1121
+ ticks={outputData.map((d) => d.day)}
1122
+ tickFormat={(v) => outputData[v]?.name ?? ""}
1123
+ />
1124
+ <YAxis accessor={(d: DayOutput) => d.actual} label="Pieces" />
1125
+ <Bar accessor={(d: DayOutput) => d.planned} name="Planned" color="var(--uc-color-text)" barWidth={0.7} />
1126
+ <Bar accessor={(d: DayOutput) => d.actual} name="Made" barWidth={0.7} />
1127
+ <Legend placement="top" />
1128
+ <Tooltip mode="x" />
1129
+ </Chart>
1130
+ );
1131
+ }
1132
+ ```
1133
+
1134
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1135
+
1136
+ ##### Above and below plan
1137
+
1138
+ ```tsx
1139
+ /* The deviation from plan, per working day: above plan upwards, below plan
1140
+ downwards, the foot at 0 in the middle of the axis.
1141
+
1142
+ The foot of a bar is 0 and not the bottom of the axis - which is what lets one
1143
+ series carry both signs. The deviation is computed in the accessor, not
1144
+ stored: it is the difference of two numbers the data already has. */
1145
+
1146
+ import { Bar, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1147
+ import { outputData, type DayOutput } from "../../data";
1148
+
1149
+ /* The plant this is drawn from stands in the second tab, so that the
1150
+ example can be copied whole. */
1151
+
1152
+ const signed = (v: number) => (v > 0 ? `+${v.toFixed(0)}` : v.toFixed(0));
1153
+
1154
+ export default function Deviation() {
1155
+ return (
1156
+ <Chart data={outputData} height={280} ariaLabel="Deviation from plan per working day">
1157
+ <XAxis
1158
+ accessor={(d: DayOutput) => d.day}
1159
+ ticks={outputData.map((d) => d.day)}
1160
+ tickFormat={(v) => outputData[v]?.name ?? ""}
1161
+ />
1162
+ <YAxis accessor={(d: DayOutput) => d.actual - d.planned} tickFormat={signed} label="Pieces" />
1163
+ <Bar accessor={(d: DayOutput) => d.actual - d.planned} name="Deviation from plan" />
1164
+ <Tooltip mode="x" />
1165
+ </Chart>
1166
+ );
1167
+ }
1168
+ ```
1169
+
1170
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1171
+
1172
+ ##### Stacked bars
1173
+
1174
+ ```tsx
1175
+ /* Downtime by reason, stacked: each day's bar is its whole downtime, and each
1176
+ reason a part of it. Every Bar with the same `stack` stands on the ones
1177
+ registered before it - the order in the JSX is the order from the foot up.
1178
+
1179
+ The material log was not kept on the first Wednesday. That day's bar says
1180
+ so: its material segment is missing, the breakdown stands directly on the
1181
+ setup - a gap stacks as zero -, and the tooltip names the reasons it has
1182
+ and their total. */
1183
+
1184
+ import { Bar, Chart, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1185
+ import { downtimeData, type DayDowntime } from "../../data";
1186
+
1187
+ /* The plant this is drawn from stands in the second tab, so that the
1188
+ example can be copied whole. */
1189
+
1190
+ export default function Stacked() {
1191
+ return (
1192
+ <Chart data={downtimeData} height={280} ariaLabel="Downtime per working day by reason">
1193
+ <XAxis
1194
+ accessor={(d: DayDowntime) => d.day}
1195
+ ticks={downtimeData.map((d) => d.day)}
1196
+ tickFormat={(v) => downtimeData[v]?.name ?? ""}
1197
+ />
1198
+ <YAxis accessor={(d: DayDowntime) => d.setup} label="Minutes" />
1199
+ <Bar accessor={(d: DayDowntime) => d.setup} name="Setup" stack="downtime" />
1200
+ <Bar accessor={(d: DayDowntime) => d.material} name="Material" stack="downtime" />
1201
+ <Bar accessor={(d: DayDowntime) => d.breakdown} name="Breakdown" stack="downtime" />
1202
+ <Legend placement="top" />
1203
+ <Tooltip mode="x" />
1204
+ </Chart>
1205
+ );
1206
+ }
1207
+ ```
1208
+
1209
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1210
+
1211
+ ##### Shares of a whole
1212
+
1213
+ ```tsx
1214
+ /* The same downtime as shares: `normalize` on a stack makes every day sum to
1215
+ 100 %, so the question moves from "how long" to "of what". The breakdown on
1216
+ the second Thursday is no longer the tallest bar - it is the widest part.
1217
+
1218
+ The y axis reads in percent by itself, and so does the tooltip's row per
1219
+ reason. The total stays in minutes: it is the one number the shares hide,
1220
+ and `format` writes it, as it would write any reading. */
1221
+
1222
+ import { Bar, Chart, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1223
+ import { downtimeData, type DayDowntime } from "../../data";
1224
+
1225
+ /* The plant this is drawn from stands in the second tab, so that the
1226
+ example can be copied whole. */
1227
+
1228
+ const minutes = (v: number) => `${v} min`;
1229
+
1230
+ export default function Percent() {
1231
+ return (
1232
+ <Chart data={downtimeData} height={280} ariaLabel="Share of each reason in the downtime per working day">
1233
+ <XAxis
1234
+ accessor={(d: DayDowntime) => d.day}
1235
+ ticks={downtimeData.map((d) => d.day)}
1236
+ tickFormat={(v) => downtimeData[v]?.name ?? ""}
1237
+ />
1238
+ <YAxis accessor={(d: DayDowntime) => d.setup} />
1239
+ <Bar accessor={(d: DayDowntime) => d.setup} name="Setup" stack="downtime" normalize format={minutes} />
1240
+ <Bar accessor={(d: DayDowntime) => d.material} name="Material" stack="downtime" normalize format={minutes} />
1241
+ <Bar accessor={(d: DayDowntime) => d.breakdown} name="Breakdown" stack="downtime" normalize format={minutes} />
1242
+ <Legend placement="top" />
1243
+ <Tooltip mode="x" />
1244
+ </Chart>
1245
+ );
1246
+ }
1247
+ ```
1248
+
1249
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1250
+
1251
+ #### API
1252
+
1253
+ ##### `BarProps<T>`
1254
+
1255
+ | Prop | Type | Default | Description |
1256
+ |---|---|---|---|
1257
+ | `accessor` (required) | `Accessor<T>` | — | Height; null/undefined/NaN/±Infinity means a gap (R-2.5). |
1258
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1259
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1260
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
1261
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1262
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
1263
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it; without one the y axis' `tickFormat`, then the default. |
1264
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
1265
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it. |
1266
+ | `stack` | `string` | — | The stack this series stands in: every Bar and Area with the same id on the same x and y axis stands on the ones registered before it. A gap stacks as zero; negative values stack downward from zero. The tooltip names each series' own value and the stack's total. |
1267
+ | `normalize` | `boolean` | — | On any member of a stack, every x of the stack sums to 100 %: each value becomes its share, and the y axis reads in percent unless it has a `tickFormat`. The total stays the readings' own sum, written in the series' `format`. |
1268
+ | `barWidth` | `number` | — | Width as a fraction of the step; just under 1, so that neighbours do not touch. Several bar series share this fraction. |
1269
+
1270
+ #### Why it is like this
1271
+
1272
+ ##### Bars sit on a numeric x axis
1273
+
1274
+ Every other chart library draws bars against a band scale over categories. This one does not: a bar has a position and a width in domain units, on the same axis a line uses. That is what lets a bar, a line and a scatter share one chart and one tooltip without translating between two kinds of axis - and it is what makes a Pareto possible, where the bars are categories and the cumulative line is not. Categories are passed as numeric positions with a naming `tickFormat`. See `docs/adr/0002-bars-on-a-numeric-x-axis.md`.
1275
+
1276
+ ##### What it costs, said plainly
1277
+
1278
+ A band scale would place bars automatically and keep their gaps even. Here the width is a number the caller sets, and grouped bars divide it among themselves. The trade was made for the mixed chart, and the mixed chart is the case this library exists for.
1279
+
1280
+ ### Scatter
1281
+
1282
+ Individual points without a connecting line - samples, measurements, anything that is not a course.
1283
+
1284
+ ```ts
1285
+ import { Scatter } from "@umriss-ui/charts";
1286
+ ```
1287
+
1288
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/scatter
1289
+
1290
+ #### Examples
1291
+
1292
+ ##### Individual measurements
1293
+
1294
+ ```tsx
1295
+ /* Wall thickness, sampled by hand whenever the inspector came by: individual
1296
+ measurements, drawn as points and joined by nothing.
1297
+
1298
+ A line between two samples would claim a thickness at every moment in
1299
+ between, and nobody measured one. `radius` is in CSS pixels; a little larger
1300
+ than the default, because each point here is a person's walk to the line. */
1301
+
1302
+ import { Chart, Scatter, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1303
+ import { thicknessData, type ThicknessSample } from "../../data";
1304
+
1305
+ /* The plant this is drawn from stands in the second tab, so that the
1306
+ example can be copied whole. */
1307
+
1308
+ const timeOfDay = (v: number) =>
1309
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1310
+
1311
+ export default function Measurements() {
1312
+ return (
1313
+ <Chart data={thicknessData} height={260} ariaLabel="Wall thickness samples over one shift">
1314
+ <XAxis accessor={(d: ThicknessSample) => d.t} tickFormat={timeOfDay} label="Time" />
1315
+ <YAxis accessor={(d: ThicknessSample) => d.mm} label="mm" />
1316
+ <Scatter accessor={(d: ThicknessSample) => d.mm} name="Wall thickness" radius={4} />
1317
+ <Tooltip mode="nearest" />
1318
+ </Chart>
1319
+ );
1320
+ }
1321
+ ```
1322
+
1323
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1324
+
1325
+ ##### Samples against a set point
1326
+
1327
+ ```tsx
1328
+ /* Fill weights read against their set point: the set point as a line, the
1329
+ samples as points over it, and those out of tolerance with `tone="alarm"`.
1330
+
1331
+ The outliers are a series of their own and not a colour per point - a
1332
+ scatter has one colour, and the tone is a role the theme resolves, so the
1333
+ alarm here is the same red as an alarm limit. The two channels never both
1334
+ carry a value, so no sample is drawn twice. */
1335
+
1336
+ import { Chart, Legend, Line, Scatter, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1337
+ import { weightData, type WeightSample } from "../../data";
1338
+
1339
+ /* The plant this is drawn from stands in the second tab, so that the
1340
+ example can be copied whole. */
1341
+
1342
+ const timeOfDay = (v: number) =>
1343
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1344
+
1345
+ export default function OverACourse() {
1346
+ return (
1347
+ <Chart data={weightData} height={300} ariaLabel="Fill weight samples against their set point">
1348
+ <XAxis accessor={(d: WeightSample) => d.t} tickFormat={timeOfDay} label="Time" />
1349
+ <YAxis accessor={(d: WeightSample) => d.setPoint} label="g" />
1350
+ <Line accessor={(d: WeightSample) => d.setPoint} name="Set point" dash={[4, 4]} />
1351
+ <Scatter accessor={(d: WeightSample) => d.sample} name="Sample" radius={2.5} />
1352
+ <Scatter accessor={(d: WeightSample) => d.outlier} name="Out of tolerance" tone="alarm" radius={4} />
1353
+ <Legend placement="top" />
1354
+ <Tooltip mode="nearest" />
1355
+ </Chart>
1356
+ );
1357
+ }
1358
+ ```
1359
+
1360
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1361
+
1362
+ #### API
1363
+
1364
+ ##### `ScatterProps<T>`
1365
+
1366
+ | Prop | Type | Default | Description |
1367
+ |---|---|---|---|
1368
+ | `accessor` (required) | `Accessor<T>` | — | Y value; null/undefined/NaN/±Infinity means a gap (R-2.5). |
1369
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1370
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1371
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
1372
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1373
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
1374
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it; without one the y axis' `tickFormat`, then the default. |
1375
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
1376
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. |
1377
+ | `radius` | `number` | — | Point radius in CSS pixels. |
1378
+
1379
+ ### StateBand
1380
+
1381
+ What a machine was doing, as a partition of the time axis: every segment ends where the next begins, and a state is a number the state list names (ADR-0007).
1382
+
1383
+ ```ts
1384
+ import { StateBand } from "@umriss-ui/charts";
1385
+ ```
1386
+
1387
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/stateband
1388
+
1389
+ #### Examples
1390
+
1391
+ ##### One machine over a shift
1392
+
1393
+ ```tsx
1394
+ /* What one machine did over a shift, as a band: production, setup, fault,
1395
+ maintenance - every segment ends where the next begins.
1396
+
1397
+ No `laneFrom`, no `laneTo`: without them a band fills the whole domain of its
1398
+ y axis, and a chart of one machine needs nothing else. The y axis carries a
1399
+ single tick in the middle of the band, and the tickFormat names the machine
1400
+ there. The legend names the states, not the series - a band is read by its
1401
+ colours. */
1402
+
1403
+ import { Chart, Legend, StateBand, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1404
+ import { PLANT_STATES, shiftData, type StatePoint } from "../../data";
1405
+
1406
+ /* The plant this is drawn from stands in the second tab, so that the
1407
+ example can be copied whole. */
1408
+
1409
+ const timeOfDay = (v: number) =>
1410
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1411
+
1412
+ export default function Shift() {
1413
+ return (
1414
+ <Chart data={shiftData} height={150} ariaLabel="States of Furnace 1 over one shift">
1415
+ <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" />
1416
+ <YAxis accessor={() => 0} domain={[0, 1]} ticks={[0.5]} tickFormat={() => "Furnace 1"} grid={false} />
1417
+ <StateBand accessor={(d: StatePoint) => d.m1} states={PLANT_STATES} name="Furnace 1" />
1418
+ <Legend placement="top" />
1419
+ <Tooltip mode="x" />
1420
+ </Chart>
1421
+ );
1422
+ }
1423
+ ```
1424
+
1425
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1426
+
1427
+ ##### A state under a course
1428
+
1429
+ ```tsx
1430
+ /* The furnace temperature, and under it the furnace's state in a lane of its
1431
+ own: where the band turns to fault, the curve climbs.
1432
+
1433
+ The lane stands on a second y axis, in its domain units - the bottom unit of
1434
+ five, the rest is air for the curve. Two charts one above the other would
1435
+ need their x axes kept in step by hand; one chart has one x axis, and the
1436
+ cause lies exactly under its effect. */
1437
+
1438
+ import { Chart, Legend, Line, StateBand, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1439
+ import { PLANT_STATES, shiftData, type StatePoint } from "../../data";
1440
+
1441
+ /* The plant this is drawn from stands in the second tab, so that the
1442
+ example can be copied whole. */
1443
+
1444
+ const timeOfDay = (v: number) =>
1445
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1446
+
1447
+ export default function UnderACourse() {
1448
+ return (
1449
+ <Chart data={shiftData} height={320} ariaLabel="Furnace temperature above the states of the furnace">
1450
+ <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" />
1451
+ <YAxis accessor={(d: StatePoint) => d.temperature} domain={[720, 900]} label="°C" />
1452
+ <YAxis
1453
+ id="lane"
1454
+ position="right"
1455
+ accessor={() => 0}
1456
+ domain={[0, 5]}
1457
+ ticks={[0.4]}
1458
+ tickFormat={() => "State"}
1459
+ />
1460
+ <StateBand
1461
+ accessor={(d: StatePoint) => d.m1}
1462
+ states={PLANT_STATES}
1463
+ yAxisId="lane"
1464
+ laneFrom={0}
1465
+ laneTo={0.8}
1466
+ name="Furnace 1"
1467
+ />
1468
+ <Line accessor={(d: StatePoint) => d.temperature} name="Furnace temperature" strokeWidth={1.75} />
1469
+ <Legend placement="top" />
1470
+ <Tooltip mode="x" />
1471
+ </Chart>
1472
+ );
1473
+ }
1474
+ ```
1475
+
1476
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1477
+
1478
+ #### API
1479
+
1480
+ ##### `StateBandProps<T>`
1481
+
1482
+ | Prop | Type | Default | Description |
1483
+ |---|---|---|---|
1484
+ | `accessor` (required) | `Accessor<T>` | — | Index of the state in `states`; null/undefined/NaN/±Infinity is a gap - and a gap stays a hole, it gets no colour for "unknown". A colour would be a claim about the interval. |
1485
+ | `states` (required) | `readonly StateEntry[]` | — | The closed set of states, in the order of their codes. |
1486
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1487
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1488
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
1489
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1490
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
1491
+ | `laneFrom` | `number` | — | Lower edge of the lane in domain units of the y axis. |
1492
+ | `laneTo` | `number` | — | Upper edge of the lane in domain units of the y axis. |
1493
+
1494
+ #### Why it is like this
1495
+
1496
+ ##### A state is a number
1497
+
1498
+ The accessor of a state band returns the **index** of a state in the state list, not a string and not a colour. The list is the code: it fixes the order, the name and the colour in one place, so that two charts of the same plant cannot disagree about what yellow means. See `docs/adr/0007-a-state-is-a-number.md`.
1499
+
1500
+ ##### A band is a partition, and a hole is not a state
1501
+
1502
+ Every segment ends where the next begins - that is what makes it a band. Where a machine reports nothing, a hole stays: there is no colour for „unknown“, because a fifth colour in the legend would claim a state that was never measured. Work with an explicit end - idle time between jobs, two jobs on one machine - is occupancy, and it belongs to `@umriss-ui/schedule`.
1503
+
1504
+ ### Matrix
1505
+
1506
+ One value per cell across two discrete axes - coloured by assessment or across a gradient, with a hole where nothing was measured.
1507
+
1508
+ ```ts
1509
+ import { Matrix, DEFAULT_GRADIENT } from "@umriss-ui/charts";
1510
+ ```
1511
+
1512
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/matrix
1513
+
1514
+ #### Examples
1515
+
1516
+ ##### Matrix
1517
+
1518
+ ```tsx
1519
+ /* Eight machines across twenty-four hours at a glance. Above by assessment - the
1520
+ same limits that colour a tile - below across a gradient.
1521
+
1522
+ A bad HOUR everywhere looks different from a bad MACHINE, and exactly that
1523
+ difference is the reason for the form. Where nothing was measured, a hole
1524
+ stays. */
1525
+
1526
+ import { Chart, Matrix, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1527
+ import { MACHINES, matrixData, type CellPoint } from "../../data";
1528
+
1529
+ /* The plant this is drawn from stands in the second tab, so that the
1530
+ example can be copied whole. */
1531
+
1532
+ export default function MatrixExample() {
1533
+ return (
1534
+ <div className="side-by-side">
1535
+ <Chart data={matrixData} height={260} ariaLabel="OEE per machine and hour, by assessment">
1536
+ <XAxis
1537
+ accessor={(d: CellPoint) => d.hour}
1538
+ ticks={[0, 6, 12, 18]}
1539
+ tickFormat={(v) => `${v}:00`}
1540
+ label="Hour"
1541
+ />
1542
+ <YAxis
1543
+ accessor={(d: CellPoint) => d.machine}
1544
+ ticks={MACHINES.map((_, i) => i)}
1545
+ tickFormat={(v) => MACHINES[v] ?? ""}
1546
+ />
1547
+ <Matrix
1548
+ accessor={(d: CellPoint) => d.machine}
1549
+ value={(d: CellPoint) => d.oee}
1550
+ coloring={{
1551
+ kind: "assessment",
1552
+ limits: {
1553
+ limits: [
1554
+ { value: 70, side: "lower", severity: "warning" },
1555
+ { value: 40, side: "lower", severity: "alarm" },
1556
+ ],
1557
+ },
1558
+ }}
1559
+ name="OEE"
1560
+ />
1561
+ <Tooltip mode="nearest" />
1562
+ </Chart>
1563
+ <Chart data={matrixData} height={260} ariaLabel="OEE per machine and hour, as a gradient">
1564
+ <XAxis
1565
+ accessor={(d: CellPoint) => d.hour}
1566
+ ticks={[0, 6, 12, 18]}
1567
+ tickFormat={(v) => `${v}:00`}
1568
+ label="Hour"
1569
+ />
1570
+ <YAxis
1571
+ accessor={(d: CellPoint) => d.machine}
1572
+ ticks={MACHINES.map((_, i) => i)}
1573
+ tickFormat={(v) => MACHINES[v] ?? ""}
1574
+ />
1575
+ <Matrix accessor={(d: CellPoint) => d.machine} value={(d: CellPoint) => d.oee} name="OEE" />
1576
+ <Tooltip mode="nearest" />
1577
+ </Chart>
1578
+ </div>
1579
+ );
1580
+ }
1581
+ ```
1582
+
1583
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1584
+
1585
+ #### API
1586
+
1587
+ ##### `MatrixProps<T>`
1588
+
1589
+ | Prop | Type | Default | Description |
1590
+ |---|---|---|---|
1591
+ | `accessor` (required) | `Accessor<T>` | — | Row position on the y axis. |
1592
+ | `value` (required) | `Accessor<T>` | — | The value that decides the colour. |
1593
+ | `coloring` | `MatrixColoring` | — | Without a value the default gradient across the data range. |
1594
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1595
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1596
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
1597
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1598
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent - a fixed `domain` keeps the axis still. Its legend entry stays, drawn back. Controlled: the caller sets it, typically from `Legend onToggle`. |
1599
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it; without one the default. The y axis' `tickFormat` writes the row, not the value. |
1600
+
1601
+ ## Monitoring
1602
+
1603
+ A value read against its limits - and its shape over time.
1604
+
1605
+ ### LimitLine
1606
+
1607
+ The limits a curve is read against: a line as a landmark above the series, a band as ground below them - the same limit model the components of @umriss-ui/core use (ADR-0006).
1608
+
1609
+ ```ts
1610
+ import { LimitLine, LimitBand } from "@umriss-ui/charts";
1611
+ ```
1612
+
1613
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/limitline
1614
+
1615
+ #### Examples
1616
+
1617
+ ##### Limits and state band
1618
+
1619
+ ```tsx
1620
+ /* The core of a plant screen: a curve, the limits it is read against, and below
1621
+ it the state of three machines.
1622
+
1623
+ Everything in ONE chart on ONE x axis - which is exactly why the fault at
1624
+ 12:15 lies visibly below the temperature jump. A band lies below every series,
1625
+ a limit line above them: the one is ground, the other a landmark. Machine 3
1626
+ reports nothing for a while; a hole stays there, no colour for "unknown". */
1627
+
1628
+ import { Chart, Legend, LimitBand, LimitLine, Line, StateBand, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1629
+ import { PLANT_STATES, shiftData, type StatePoint } from "../../data";
1630
+
1631
+ /* The plant this is drawn from stands in the second tab, so that the
1632
+ example can be copied whole. */
1633
+
1634
+ const timeOfDay = (v: number) =>
1635
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1636
+
1637
+ /* The lanes of a stack: one domain unit per machine. The labelling goes through
1638
+ the axis' tickFormat - there is no lane concept of its own. */
1639
+ const LANE_TICKS = [0.45, 1.45, 2.45];
1640
+ /* Lane 0 lies at the bottom - the list therefore runs from the bottom upwards. */
1641
+ const LANE_NAMES = ["Mill 3", "Press 2", "Furnace 1"];
1642
+
1643
+ export default function LimitsAndState() {
1644
+ return (
1645
+ <Chart
1646
+ data={shiftData}
1647
+ height={340}
1648
+ ariaLabel="Furnace temperature with a warning and an alarm limit above the states of three machines"
1649
+ >
1650
+ <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" />
1651
+ <YAxis accessor={(d: StatePoint) => d.temperature} domain={[740, 900]} label="°C" />
1652
+ {/* The lane axis: one unit per machine, the rest is air for the curve
1653
+ above it. Not a pixel in this file. */}
1654
+ <YAxis
1655
+ id="lanes"
1656
+ position="right"
1657
+ accessor={() => 0}
1658
+ domain={[0, 8]}
1659
+ ticks={LANE_TICKS}
1660
+ tickFormat={(v) => LANE_NAMES[Math.floor(v)] ?? ""}
1661
+ />
1662
+ <LimitBand from={780} to={820} severity="warning" label="Tolerance" />
1663
+ <LimitLine value={820} severity="warning" label="Warning limit" />
1664
+ <LimitLine value={860} severity="alarm" label="Alarm limit" />
1665
+ <StateBand
1666
+ accessor={(d: StatePoint) => d.m1}
1667
+ states={PLANT_STATES}
1668
+ yAxisId="lanes"
1669
+ laneFrom={2}
1670
+ laneTo={2.9}
1671
+ name="Furnace 1"
1672
+ />
1673
+ <StateBand
1674
+ accessor={(d: StatePoint) => d.m2}
1675
+ states={PLANT_STATES}
1676
+ yAxisId="lanes"
1677
+ laneFrom={1}
1678
+ laneTo={1.9}
1679
+ name="Press 2"
1680
+ />
1681
+ <StateBand
1682
+ accessor={(d: StatePoint) => d.m3}
1683
+ states={PLANT_STATES}
1684
+ yAxisId="lanes"
1685
+ laneFrom={0}
1686
+ laneTo={0.9}
1687
+ name="Mill 3"
1688
+ />
1689
+ <Line accessor={(d: StatePoint) => d.temperature} name="Furnace temperature" strokeWidth={1.75} />
1690
+ <Legend placement="top" />
1691
+ <Tooltip mode="x" />
1692
+ </Chart>
1693
+ );
1694
+ }
1695
+ ```
1696
+
1697
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1698
+
1699
+ #### API
1700
+
1701
+ ##### `LimitLineProps`
1702
+
1703
+ | Prop | Type | Default | Description |
1704
+ |---|---|---|---|
1705
+ | `value` (required) | `number` | — | Where the line lies, in domain units of its axis. |
1706
+ | `axisId` | `string` | — | Axis on which the value lies. Without one, the first axis of the orientation. |
1707
+ | `orientation` | `"x" \| "y"` | — | Whether the value lies on the x or on the y axis. Without one, y: a limit is nearly always a value and not a moment. |
1708
+ | `severity` | `Severity` | — | Two severities, not five: five urgencies are five colours, and five colours on a plant screen are noise. |
1709
+ | `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. |
1710
+ | `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. |
1711
+ | `inExtent` | `boolean` | — | Whether the value widens the axis extent, so that the limit is certainly visible. Default true; the head of this file says why. |
1712
+ | `role` | `LimitRole` | — | A chosen specification limit (the default) or a calculated control limit. They look different because they mean different things (ADR-0008). |
1713
+
1714
+ ##### `LimitBandProps`
1715
+
1716
+ | Prop | Type | Default | Description |
1717
+ |---|---|---|---|
1718
+ | `from` (required) | `number` | — | Lower edge of the band, in domain units of its axis. |
1719
+ | `to` (required) | `number` | — | Upper edge, in domain units of its axis. A band drawn backwards (`from` above `to`) is drawn between the two all the same - the pair is a range, not a direction. |
1720
+ | `axisId` | `string` | — | Axis on which the value lies. Without one, the first axis of the orientation. |
1721
+ | `orientation` | `"x" \| "y"` | — | Whether the value lies on the x or on the y axis. Without one, y: a limit is nearly always a value and not a moment. |
1722
+ | `severity` | `Severity` | — | Two severities, not five: five urgencies are five colours, and five colours on a plant screen are noise. |
1723
+ | `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. |
1724
+ | `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. |
1725
+ | `inExtent` | `boolean` | — | Whether the value widens the axis extent, so that the limit is certainly visible. Default true; the head of this file says why. |
1726
+ | `role` | `LimitRole` | — | A chosen specification limit (the default) or a calculated control limit. They look different because they mean different things (ADR-0008). |
1727
+
1728
+ #### Why it is like this
1729
+
1730
+ ##### The limit model lives in both packages, on purpose
1731
+
1732
+ A limit is a value, a side and a severity; a verdict is what a number *is* given those limits. The same model stands in `@umriss-ui/core` and here - duplicated deliberately, because the alternative was a dependency between two packages that are meant to be usable apart. What holds the two copies together is not discipline but a conformance test that runs both versions from one case table. See `docs/adr/0006-the-limit-model-lives-in-both-packages.md`.
1733
+
1734
+ ##### A line is a landmark, a band is ground
1735
+
1736
+ The band lies below every series, the line above them. That is not a taste in layering: a tolerance band is the area a value should be in, and a curve has to be readable on top of it, while a limit is the thing a reader looks for and must not be covered by a stroke that happens to run along it.
1737
+
1738
+ ##### The colour follows the severity, and nothing else
1739
+
1740
+ A limit carries `severity`, and the tone is derived from it. Handing a colour in instead would put the rule in the application and the presentation in the library, and the two would drift apart the moment a bound moves.
1741
+
1742
+ ### ControlChart
1743
+
1744
+ Center line, control limits and the rule violations of a process - out of a named reference window, never out of what happens to be visible (ADR-0008).
1745
+
1746
+ ```ts
1747
+ import { ControlChart, controlLimits, violations } from "@umriss-ui/charts";
1748
+ ```
1749
+
1750
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/controlchart
1751
+
1752
+ #### Examples
1753
+
1754
+ ##### Control chart
1755
+
1756
+ ```tsx
1757
+ /* Two kinds of limit, and they look different because they mean different
1758
+ things.
1759
+
1760
+ The SPECIFICATION limits (dashed, coloured) are chosen: what the customer
1761
+ assumes. The CONTROL limits (solid, neutral) are calculated: what this process
1762
+ normally does - and out of a named reference window, never out of what happens
1763
+ to be visible (ADR-0008). From value 62 the process drifts; the run rule fires
1764
+ long before a point leaves the specification. */
1765
+
1766
+ import { Chart, ControlChart, LimitLine, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1767
+ import { measurementData, type Measurement } from "../../data";
1768
+
1769
+ /* The plant this is drawn from stands in the second tab, so that the
1770
+ example can be copied whole. */
1771
+
1772
+ /* The control limits come out of the first 60 per cent of the series - the
1773
+ period in which the process demonstrably ran in control. */
1774
+ const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 54 } as const;
1775
+
1776
+ export default function ControlChartExample() {
1777
+ return (
1778
+ <Chart data={measurementData} height={300} ariaLabel="Control chart of a feature inspection">
1779
+ <XAxis accessor={(d: Measurement) => d.n} label="Inspection" />
1780
+ <YAxis accessor={(d: Measurement) => d.value} label="mm" />
1781
+ <LimitLine value={13.2} severity="alarm" label="USL" />
1782
+ <LimitLine value={11.8} severity="alarm" label="LSL" />
1783
+ <ControlChart
1784
+ accessor={(d: Measurement) => d.value}
1785
+ data={measurementData}
1786
+ origin={REFERENCE_WINDOW}
1787
+ name="Feature Ø"
1788
+ labelUpper="UCL"
1789
+ labelLower="LCL"
1790
+ violationName="Feature Ø - rule violation"
1791
+ />
1792
+ <Tooltip mode="x" />
1793
+ </Chart>
1794
+ );
1795
+ }
1796
+ ```
1797
+
1798
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1799
+
1800
+ #### API
1801
+
1802
+ ##### `ControlChartProps<T>`
1803
+
1804
+ | Prop | Type | Default | Description |
1805
+ |---|---|---|---|
1806
+ | `accessor` (required) | `Accessor<T>` | — | The values of the chart. |
1807
+ | `data` (required) | `readonly T[]` | — | Required: the limits arise out of these values, not out of what happens to be visible. |
1808
+ | `origin` (required) | `ControlLimitOrigin` | — | Given, or from a named reference window - never tacitly from everything on the screen (ADR-0008). |
1809
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
1810
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
1811
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV - an unnamed series is a colour nobody can look up. |
1812
+ | `format` | `(value: number) => string` | — | The value as the tooltip writes it - on the line and on the violations; without one the y axis' `tickFormat`, then the default. |
1813
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
1814
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value for the line; the theme resolves it. The violations are always "alarm". |
1815
+ | `rules` | `Partial<RuleOptions>` | — | Switchable one by one; run lengths are parameters. |
1816
+ | `zoneLines` | `boolean` | — | The faint zone lines at one and two sigma. Rule 4 is about the two-sigma zone; without them a reader cannot check it. |
1817
+ | `labelUpper` | `string` | — | Labels of the control limits in the axis band. Without a value, none: the package brings no text along, not even "UCL" and "LCL". |
1818
+ | `labelLower` | `string` | — | The same for the lower control limit. Named separately because a chart may well want one of the two labelled and not the other. |
1819
+ | `violationName` | `string` | — | Name of the scatter of violations in the legend and the tooltip. Without a value, none: a legend then carries it as "Series n" and warns once in DEV. Before this, a German suffix stood on the name of the chart. |
1820
+ | `onViolations` | `(found: readonly Violation[]) => void` | — | The violations as data - they can be listed beside the chart as well as marked on it. |
1821
+
1822
+ #### Why it is like this
1823
+
1824
+ ##### A control limit is not a specification limit
1825
+
1826
+ Two numbers on a control chart look alike and mean opposite things. A **specification limit** is chosen: what the customer assumes, what the drawing says. A **control limit** is calculated: what this process actually does when nothing is wrong with it. A process can sit comfortably inside the specification and be out of control, and that is the case the chart exists to show. See `docs/adr/0008-control-limits-are-not-specification-limits.md`.
1827
+
1828
+ ##### Never computed from what happens to be visible
1829
+
1830
+ The limits come from a named **reference window** - a period in which the process demonstrably ran in control - and never from the data the chart is currently showing. Limits recomputed over a drifting process drift with it, and the chart then certifies every state as normal, most confidently at the moment it stops being so.
1831
+
1832
+ ##### The rules fire before a point leaves the specification
1833
+
1834
+ Outlier, run, trend and two-of-three: four rules over the zones, and the point of them is the warning that comes early. They are exported as pure functions, so an application that draws something else can still ask the same question.
1835
+
1836
+ ### Pareto
1837
+
1838
+ Sorted descending with a cumulative line: the pure module that sorts, accumulates and collects the tail - the composition stays the caller's.
1839
+
1840
+ ```ts
1841
+ import { pareto } from "@umriss-ui/charts";
1842
+ ```
1843
+
1844
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/pareto
1845
+
1846
+ #### Examples
1847
+
1848
+ ##### Pareto
1849
+
1850
+ ```tsx
1851
+ /* The first question after a bad week. Bars sorted descending, the cumulative
1852
+ line on a second y axis on the right, categories as indices on the ordinary
1853
+ numeric x axis (ADR-0002).
1854
+
1855
+ The long tail is collected into "Other" and stands LAST - independently of its
1856
+ value, because otherwise the chart would claim there is a downtime reason
1857
+ called "Other" in third place.
1858
+
1859
+ `pareto()` is the pure module: sort, accumulate, collect. The composition
1860
+ below it is four lines and belongs to the caller - which is why there is no
1861
+ <Pareto> that would co-own the second axis. */
1862
+
1863
+ import { useMemo } from "react";
1864
+ import { Bar, Chart, Legend, LimitLine, Line, Tooltip, XAxis, YAxis, pareto } from "@umriss-ui/charts";
1865
+ import type { ParetoEntry } from "@umriss-ui/charts";
1866
+ import { DOWNTIME_REASONS } from "../../data";
1867
+
1868
+ /* The plant this is drawn from stands in the second tab, so that the
1869
+ example can be copied whole. */
1870
+
1871
+ export default function ParetoExample() {
1872
+ const result = useMemo(() => pareto(DOWNTIME_REASONS, { collectRank: 6, remainderName: "Other" }), []);
1873
+ const entries = result.entries;
1874
+
1875
+ return (
1876
+ <Chart data={entries as ParetoEntry[]} height={300} ariaLabel="Downtime reasons by frequency">
1877
+ <XAxis
1878
+ accessor={(d: ParetoEntry) => d.index}
1879
+ ticks={entries.map((e) => e.index)}
1880
+ tickFormat={(v) => entries[v]?.name ?? ""}
1881
+ />
1882
+ <YAxis accessor={(d: ParetoEntry) => d.value} label="Minutes" />
1883
+ <YAxis
1884
+ id="share"
1885
+ position="right"
1886
+ accessor={(d: ParetoEntry) => d.cumulative * 100}
1887
+ domain={[0, 100]}
1888
+ tickFormat={(v) => `${v.toFixed(0)} %`}
1889
+ label="cumulative"
1890
+ />
1891
+ <Bar accessor={(d: ParetoEntry) => d.value} name="Downtime" barWidth={0.72} />
1892
+ <Line
1893
+ accessor={(d: ParetoEntry) => d.cumulative * 100}
1894
+ yAxisId="share"
1895
+ name="cumulative"
1896
+ markers="always"
1897
+ strokeWidth={1.75}
1898
+ />
1899
+ <LimitLine value={80} axisId="share" severity="warning" label="80 %" inExtent={false} />
1900
+ <Legend placement="top" />
1901
+ <Tooltip mode="x" />
1902
+ </Chart>
1903
+ );
1904
+ }
1905
+ ```
1906
+
1907
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1908
+
1909
+ ## Around the chart
1910
+
1911
+ What stands beside the series: what a hover says, what the colours mean - and what the whole thing costs.
1912
+
1913
+ ### Tooltip & Legend
1914
+
1915
+ What a hover reports and what the colours mean: the hit as a whole x position or as the nearest point, the legend above or below the plot.
1916
+
1917
+ ```ts
1918
+ import { Tooltip, Legend, DataTable } from "@umriss-ui/charts";
1919
+ ```
1920
+
1921
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/tooltip
1922
+
1923
+ #### Examples
1924
+
1925
+ ##### Mode x or nearest
1926
+
1927
+ ```tsx
1928
+ /* The same two furnaces twice: on the left the tooltip reports every series at
1929
+ the x position, on the right only the point nearest the pointer.
1930
+
1931
+ `"x"` answers "what was everything at 08:40"; `"nearest"` answers "which
1932
+ curve is this". The hit is searched per series in its own axis space and
1933
+ compared in pixels, so the crosshair snaps to a data point and never stands
1934
+ between two. Hover to see the difference - a picture of the page cannot. */
1935
+
1936
+ import { Chart, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1937
+ import { furnaceData, type FurnacePoint } from "../../data";
1938
+
1939
+ /* The plant this is drawn from stands in the second tab, so that the
1940
+ example can be copied whole. */
1941
+
1942
+ const timeOfDay = (v: number) =>
1943
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1944
+
1945
+ function Furnaces({ mode }: { mode: "x" | "nearest" }) {
1946
+ return (
1947
+ <Chart data={furnaceData} height={220} ariaLabel={`Two furnace temperatures, tooltip mode ${mode}`}>
1948
+ <XAxis accessor={(d: FurnacePoint) => d.t} tickFormat={timeOfDay} tickCount={4} />
1949
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
1950
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" />
1951
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" />
1952
+ <Tooltip mode={mode} />
1953
+ </Chart>
1954
+ );
1955
+ }
1956
+
1957
+ export default function XOrNearest() {
1958
+ return (
1959
+ <div className="pair">
1960
+ <div>
1961
+ <p className="pair-caption">mode="x" - every series at the x position</p>
1962
+ <Furnaces mode="x" />
1963
+ </div>
1964
+ <div>
1965
+ <p className="pair-caption">mode="nearest" - only the nearest point</p>
1966
+ <Furnaces mode="nearest" />
1967
+ </div>
1968
+ </div>
1969
+ );
1970
+ }
1971
+ ```
1972
+
1973
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1974
+
1975
+ ##### Content of one's own
1976
+
1977
+ ```tsx
1978
+ /* A tooltip in the plant's language: "Furnace 1 - 812 °C, 4 °C above set
1979
+ point" instead of a name and a number.
1980
+
1981
+ The render prop receives the hit - the x value and one entry per series, each
1982
+ with its datum - and returns what stands in the box. The set point is not a
1983
+ series here but a field of the datum, which is exactly what the render prop
1984
+ can read and the built-in tooltip cannot know. */
1985
+
1986
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1987
+ import type { TooltipHit } from "@umriss-ui/charts";
1988
+ import { furnaceData, type FurnacePoint } from "../../data";
1989
+
1990
+ /* The plant this is drawn from stands in the second tab, so that the
1991
+ example can be copied whole. */
1992
+
1993
+ const timeOfDay = (v: number) =>
1994
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1995
+
1996
+ function PlantTooltip({ hit }: { hit: TooltipHit<FurnacePoint> }) {
1997
+ return (
1998
+ <div className="custom-tooltip">
1999
+ <strong>{timeOfDay(hit.xValue)}</strong>
2000
+ {hit.points.map((point) => {
2001
+ const off = point.yValue - point.datum.setPoint;
2002
+ return (
2003
+ <span key={point.seriesName}>
2004
+ {point.seriesName} - {point.yValue.toFixed(0)} °C, {Math.abs(off).toFixed(0)} °C{" "}
2005
+ {off >= 0 ? "above" : "below"} set point
2006
+ </span>
2007
+ );
2008
+ })}
2009
+ </div>
2010
+ );
2011
+ }
2012
+
2013
+ export default function OwnContent() {
2014
+ return (
2015
+ <Chart data={furnaceData} height={280} ariaLabel="Two furnace temperatures with a tooltip in plant language">
2016
+ <XAxis accessor={(d: FurnacePoint) => d.t} tickFormat={timeOfDay} label="Time" />
2017
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
2018
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" />
2019
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" />
2020
+ <Legend placement="top" />
2021
+ <Tooltip<FurnacePoint> mode="x" render={(hit) => <PlantTooltip hit={hit} />} />
2022
+ </Chart>
2023
+ );
2024
+ }
2025
+ ```
2026
+
2027
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2028
+
2029
+ ##### Legend above or below
2030
+
2031
+ ```tsx
2032
+ /* The legend above the plot and below it. Above is the default: a chart without
2033
+ `placement` looks like the left one, and `placement="top"` says the same thing
2034
+ out loud.
2035
+
2036
+ A legend stands beside the plot area, never inside it - it takes its height
2037
+ from the chart, not from the curves. Hovering an entry lifts its series and
2038
+ draws the others faint. */
2039
+
2040
+ import { Chart, Legend, Line, XAxis, YAxis } from "@umriss-ui/charts";
2041
+ import { furnaceData, type FurnacePoint } from "../../data";
2042
+
2043
+ /* The plant this is drawn from stands in the second tab, so that the
2044
+ example can be copied whole. */
2045
+
2046
+ const timeOfDay = (v: number) =>
2047
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2048
+
2049
+ export default function LegendPlacement() {
2050
+ return (
2051
+ <div className="pair">
2052
+ <div>
2053
+ <p className="pair-caption">no placement - above, the default</p>
2054
+ <Chart data={furnaceData} height={220} ariaLabel="Legend above the plot">
2055
+ <XAxis accessor={(d: FurnacePoint) => d.t} tickFormat={timeOfDay} tickCount={4} />
2056
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
2057
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" />
2058
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" />
2059
+ <Legend />
2060
+ </Chart>
2061
+ </div>
2062
+ <div>
2063
+ <p className="pair-caption">placement="bottom"</p>
2064
+ <Chart data={furnaceData} height={220} ariaLabel="Legend below the plot">
2065
+ <XAxis accessor={(d: FurnacePoint) => d.t} tickFormat={timeOfDay} tickCount={4} />
2066
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
2067
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" />
2068
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" />
2069
+ <Legend placement="bottom" />
2070
+ </Chart>
2071
+ </div>
2072
+ </div>
2073
+ );
2074
+ }
2075
+ ```
2076
+
2077
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2078
+
2079
+ ##### Value format per series
2080
+
2081
+ ```tsx
2082
+ /* A furnace and the line it heats, on two y axes: each series writes its value
2083
+ in its own unit, through `format`. Without it the tooltip would take the y
2084
+ axis' `tickFormat`, and without that the default - a bare number beside
2085
+ another bare number. No render prop is needed for "°C". Hover to read it. */
2086
+
2087
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2088
+ import { corridorData, powerData, type CorridorPoint, type PowerPoint } from "../../data";
2089
+
2090
+ /* The plant this is drawn from stands in the second tab, so that the
2091
+ example can be copied whole. */
2092
+
2093
+ const celsius = (v: number) => `${v.toFixed(1)} °C`;
2094
+ const kilowatts = (v: number) => `${v.toFixed(0)} kW`;
2095
+
2096
+ export default function ValueFormat() {
2097
+ return (
2098
+ <Chart data={corridorData} height={260} ariaLabel="Furnace temperature and power draw with their units">
2099
+ <XAxis accessor={(d: { t: number }) => d.t} time />
2100
+ <YAxis accessor={(d: CorridorPoint) => d.temperature} label="°C" />
2101
+ <YAxis id="power" position="right" accessor={(d: PowerPoint) => d.kw} label="kW" />
2102
+ <Line accessor={(d: CorridorPoint) => d.temperature} name="Furnace" format={celsius} />
2103
+ <Line data={powerData} accessor={(d: PowerPoint) => d.kw} yAxisId="power" name="Power draw" format={kilowatts} />
2104
+ <Legend />
2105
+ <Tooltip mode="x" />
2106
+ </Chart>
2107
+ );
2108
+ }
2109
+ ```
2110
+
2111
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2112
+
2113
+ ##### Legend that hides a series
2114
+
2115
+ ```tsx
2116
+ /* The legend as a switch. `hidden` is the caller's state and `onToggle` hands
2117
+ it the name of the entry clicked - the chart keeps nothing of its own. A
2118
+ hidden series is not drawn and gives up its say in the extent, so the y axis
2119
+ closes in on what is left; its entry stays, struck through, to be clicked
2120
+ back. Without `onToggle` a legend is not clickable. */
2121
+
2122
+ import { useState } from "react";
2123
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2124
+ import { furnaceData, type FurnacePoint } from "../../data";
2125
+
2126
+ /* The plant this is drawn from stands in the second tab, so that the
2127
+ example can be copied whole. */
2128
+
2129
+ export default function TogglingLegend() {
2130
+ const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set(["Set point"]));
2131
+ const toggle = (name: string) =>
2132
+ setHidden((previous) => {
2133
+ const next = new Set(previous);
2134
+ if (!next.delete(name)) next.add(name);
2135
+ return next;
2136
+ });
2137
+
2138
+ return (
2139
+ <Chart data={furnaceData} height={260} ariaLabel="Two furnaces and their set point, one series hidden">
2140
+ <XAxis accessor={(d: FurnacePoint) => d.t} time />
2141
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
2142
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" hidden={hidden.has("Furnace 1")} />
2143
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" hidden={hidden.has("Furnace 2")} />
2144
+ <Line
2145
+ accessor={(d: FurnacePoint) => d.setPoint}
2146
+ name="Set point"
2147
+ dash={[4, 4]}
2148
+ hidden={hidden.has("Set point")}
2149
+ />
2150
+ <Legend onToggle={toggle} />
2151
+ <Tooltip mode="x" />
2152
+ </Chart>
2153
+ );
2154
+ }
2155
+ ```
2156
+
2157
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2158
+
2159
+ ##### The values as a table
2160
+
2161
+ ```tsx
2162
+ /* The chart's values as a table, for a reader who wants them all at once
2163
+ rather than walked. `<DataTable />` puts a key at the end of the legend;
2164
+ "Show data" lays a plain table over the plot - the time in the first column,
2165
+ then one column per visible series, every value in the format the tooltip
2166
+ writes it in - and "Hide data" gives the picture back.
2167
+
2168
+ The table lists what the chart shows: hide a series in the legend, or zoom
2169
+ the axis, and the table follows. */
2170
+
2171
+ import { useState } from "react";
2172
+ import { Chart, DataTable, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2173
+ import { furnaceData, type FurnacePoint } from "../../data";
2174
+
2175
+ /* The plant this is drawn from stands in the second tab, so that the
2176
+ example can be copied whole. */
2177
+
2178
+ const timeOfDay = (v: number) =>
2179
+ new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2180
+
2181
+ const celsius = (v: number) => `${v.toFixed(1)} °C`;
2182
+
2183
+ export default function ValuesAsTable() {
2184
+ const [hidden, setHidden] = useState<readonly string[]>([]);
2185
+ const toggle = (name: string) =>
2186
+ setHidden((h) => (h.includes(name) ? h.filter((n) => n !== name) : [...h, name]));
2187
+ return (
2188
+ <Chart data={furnaceData} height={260} ariaLabel="Two furnaces over the early shift">
2189
+ <XAxis accessor={(d: FurnacePoint) => d.t} tickFormat={timeOfDay} label="Time" />
2190
+ <YAxis accessor={(d: FurnacePoint) => d.f1} label="°C" />
2191
+ <Line accessor={(d: FurnacePoint) => d.f1} name="Furnace 1" format={celsius} hidden={hidden.includes("Furnace 1")} />
2192
+ <Line accessor={(d: FurnacePoint) => d.f2} name="Furnace 2" format={celsius} hidden={hidden.includes("Furnace 2")} />
2193
+ <Legend onToggle={toggle} />
2194
+ <Tooltip mode="x" />
2195
+ <DataTable />
2196
+ </Chart>
2197
+ );
2198
+ }
2199
+ ```
2200
+
2201
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2202
+
2203
+ ##### A week as a table
2204
+
2205
+ ```tsx
2206
+ /* A week of a kiln, a reading a minute: 10,080 rows, and no reader wants
2207
+ them all. Above 500 rows the table lists the course the way the line is
2208
+ drawn - the first, lowest, highest and last value of each stretch of the
2209
+ week - and its caption says how many readings the rows stand for. Zoom in
2210
+ (Ctrl or ⌘ with the wheel) and the table lists every reading again.
2211
+
2212
+ Without a legend the key stands on a line of its own. */
2213
+
2214
+ import { useState } from "react";
2215
+ import { Chart, DataTable, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2216
+ import { kilnData, type KilnPoint } from "../../data";
2217
+
2218
+ /* The plant this is drawn from stands in the second tab, so that the
2219
+ example can be copied whole. */
2220
+
2221
+ const celsius = (v: number) => `${v.toFixed(0)} °C`;
2222
+
2223
+ export default function WeekAsTable() {
2224
+ const [domain, setDomain] = useState<"data" | readonly [number, number]>("data");
2225
+ return (
2226
+ <Chart data={kilnData} height={240} ariaLabel="Kiln temperature across a week">
2227
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} label="Time" />
2228
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" />
2229
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Kiln" format={celsius} />
2230
+ <Tooltip mode="x" />
2231
+ <DataTable />
2232
+ </Chart>
2233
+ );
2234
+ }
2235
+ ```
2236
+
2237
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2238
+
2239
+ #### API
2240
+
2241
+ ##### `TooltipProps<T>`
2242
+
2243
+ | Prop | Type | Default | Description |
2244
+ |---|---|---|---|
2245
+ | `mode` | `"x" \| "nearest"` | — | "x": every series at the x position; "nearest": only the nearest point. |
2246
+ | `render` | `(hit: TooltipHit<T>) => ReactNode` | — | Render prop for content of one's own; without it the built-in tooltip applies. Compared by its source text, like `tickFormat` - with the same closure limit. |
2247
+
2248
+ ##### `LegendProps`
2249
+
2250
+ | Prop | Type | Default | Description |
2251
+ |---|---|---|---|
2252
+ | `placement` | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on. Without a value, above it. |
2253
+ | `onToggle` | `(name: string) => void` | — | Called with an entry's name when it is clicked - the series' name, or a state's label. The caller flips `hidden` on the series it means. Without it the legend is not clickable. |
2254
+
2255
+ #### Why it is like this
2256
+
2257
+ ##### Every series is searched in its own axis space
2258
+
2259
+ A chart here can hold several x axes, and a series knows only its own. So the hit is not one search over „the data“ but one binary search per series, over its own materialised x values, in the domain units of its own axis. The candidates are then compared in pixels - the one space all axes share. Comparing domain values across two axes would be comparing hours with pieces.
2260
+
2261
+ ##### The crosshair snaps to a point
2262
+
2263
+ The crosshair stands on the data point that was hit, not under the pointer. A line between two samples is drawn, but nothing was measured there, and a crosshair between two points would invite reading off a value nobody has. In mode `"x"` the tooltip lists every series at that position; in mode `"nearest"` only the one point nearest the pointer, which is the question to ask when curves cross.
2264
+
2265
+ ### Benchmark
2266
+
2267
+ Millions of points, measured instead of claimed - and the proof that a hover draws the overlay layer and nothing else.
2268
+
2269
+ ```ts
2270
+ import { Chart } from "@umriss-ui/charts";
2271
+ ```
2272
+
2273
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/#/benchmark
2274
+
2275
+ #### Examples
2276
+
2277
+ ##### Benchmark
2278
+
2279
+ ```tsx
2280
+ /* Benchmark example (R-5.1 to R-5.3).
2281
+
2282
+ Three series; lines and the area downsample on their own above two points
2283
+ per pixel column (charts-long-series 03). A hover draws the overlay layer
2284
+ exclusively - the FPS display proves it. Switchable between three lines and a
2285
+ mixed set of a bar, an area and a line.
2286
+
2287
+ Excluded from the screenshot comparisons (R-5.1) - it measures the moment it
2288
+ exists, so a picture of it would compare two different runs.
2289
+ Shows: the time of materialisation, the time of the last series draw and an FPS
2290
+ counter that runs while the mouse moves over the plot area.
2291
+ Live mode appends 10 points/s and shifts the data window along - the axis width
2292
+ has to stay calm through the hysteresis (R-5.3). */
2293
+
2294
+ import { useCallback, useEffect, useRef, useState } from "react";
2295
+ import { Area, Bar, Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2296
+ import type { ChartPerf } from "@umriss-ui/charts";
2297
+ import { load, random, type LoadPoint } from "../../data";
2298
+
2299
+ /* The plant this is drawn from stands in the second tab, so that the
2300
+ example can be copied whole. */
2301
+
2302
+ const SIZES = [1_000, 100_000, 1_000_000] as const;
2303
+ const WINDOW = 400;
2304
+
2305
+ /** Series kinds of the measuring run. A filled mark and a rectangle per point are
2306
+ different work from a stroked path - the measurements in the capability record
2307
+ must not hold for lines only. */
2308
+ type Mode = "lines" | "mixed";
2309
+
2310
+ export default function Benchmark() {
2311
+ const [data, setData] = useState<readonly LoadPoint[]>(() => load(5, 1_000));
2312
+ const [perf, setPerf] = useState<ChartPerf>({
2313
+ materializeMs: 0,
2314
+ seriesDrawMs: 0,
2315
+ points: 0,
2316
+ });
2317
+ const [fps, setFps] = useState<number | null>(null);
2318
+ const [live, setLive] = useState(false);
2319
+ const [mode, setMode] = useState<Mode>("lines");
2320
+
2321
+ const onPerf = useCallback((p: ChartPerf) => setPerf(p), []);
2322
+
2323
+ /* FPS measurement: runs only while the pointer is over the plot area. What is
2324
+ measured is the actual frame rate of the page - if it drops, the series layer
2325
+ is drawing along (which it must not do on a hover). */
2326
+ const measuring = useRef<number | null>(null);
2327
+ const startMeasuring = useCallback(() => {
2328
+ if (measuring.current !== null) return;
2329
+ let frames = 0;
2330
+ let start = performance.now();
2331
+ const step = () => {
2332
+ frames++;
2333
+ const now = performance.now();
2334
+ if (now - start >= 400) {
2335
+ setFps(Math.round((frames * 1000) / (now - start)));
2336
+ frames = 0;
2337
+ start = now;
2338
+ }
2339
+ measuring.current = requestAnimationFrame(step);
2340
+ };
2341
+ measuring.current = requestAnimationFrame(step);
2342
+ }, []);
2343
+ const stopMeasuring = useCallback(() => {
2344
+ if (measuring.current !== null) cancelAnimationFrame(measuring.current);
2345
+ measuring.current = null;
2346
+ setFps(null);
2347
+ }, []);
2348
+ useEffect(() => stopMeasuring, [stopMeasuring]);
2349
+
2350
+ /* Streaming test (R-5.3): 10 points/s, a travelling window. */
2351
+ useEffect(() => {
2352
+ if (!live) return;
2353
+ const r = random(4711);
2354
+ const timer = window.setInterval(() => {
2355
+ setData((previous) => {
2356
+ const last = previous[previous.length - 1];
2357
+ if (last === undefined) return previous;
2358
+ const next: LoadPoint = {
2359
+ t: last.t + 1,
2360
+ s1: last.s1 + (r() - 0.5) * 3,
2361
+ s2: last.s2 + (r() - 0.5) * 2.4,
2362
+ s3: last.s3 + (r() - 0.5) * 3.6,
2363
+ };
2364
+ const grown = [...previous, next];
2365
+ return grown.length > WINDOW ? grown.slice(grown.length - WINDOW) : grown;
2366
+ });
2367
+ }, 100);
2368
+ return () => window.clearInterval(timer);
2369
+ }, [live]);
2370
+
2371
+ const loadPoints = (n: number) => {
2372
+ setLive(false);
2373
+ setData(load(5, n));
2374
+ };
2375
+
2376
+ return (
2377
+ <>
2378
+ <div className="demo-actions">
2379
+ {SIZES.map((n) => (
2380
+ <button key={n} className="demo-button" onClick={() => loadPoints(n)}>
2381
+ {n.toLocaleString("en-US")} points
2382
+ </button>
2383
+ ))}
2384
+ <button className="demo-button" onClick={() => setLive((v) => !v)}>{live ? "Stop live" : "Live"}</button>
2385
+ <button
2386
+ className="demo-button"
2387
+ data-role="mode"
2388
+ onClick={() => setMode((m) => (m === "lines" ? "mixed" : "lines"))}
2389
+ >
2390
+ {mode === "lines" ? "Mixed" : "Lines only"}
2391
+ </button>
2392
+ </div>
2393
+ <dl className="metrics">
2394
+ <div>
2395
+ <dt>Points</dt>
2396
+ <dd>{perf.points.toLocaleString("en-US")}</dd>
2397
+ </div>
2398
+ <div>
2399
+ <dt>Materialisation</dt>
2400
+ <dd>{perf.materializeMs.toFixed(1)} ms</dd>
2401
+ </div>
2402
+ <div>
2403
+ <dt>Series draw</dt>
2404
+ <dd>{perf.seriesDrawMs.toFixed(1)} ms</dd>
2405
+ </div>
2406
+ <div>
2407
+ <dt>FPS (hover)</dt>
2408
+ <dd>{fps === null ? "–" : fps}</dd>
2409
+ </div>
2410
+ </dl>
2411
+ <div onPointerEnter={startMeasuring} onPointerLeave={stopMeasuring}>
2412
+ <Chart
2413
+ data={data}
2414
+ height={320}
2415
+ ariaLabel="Benchmark with three series"
2416
+ onPerf={onPerf}
2417
+ >
2418
+ <XAxis accessor={(d: LoadPoint) => d.t} label="Index" />
2419
+ <YAxis accessor={(d: LoadPoint) => d.s1} />
2420
+ {mode === "lines" ? (
2421
+ <Line accessor={(d: LoadPoint) => d.s1} name="S1" />
2422
+ ) : (
2423
+ <Bar accessor={(d: LoadPoint) => d.s1} name="S1" />
2424
+ )}
2425
+ {mode === "lines" ? (
2426
+ <Line accessor={(d: LoadPoint) => d.s2} name="S2" />
2427
+ ) : (
2428
+ <Area accessor={(d: LoadPoint) => d.s2} name="S2" />
2429
+ )}
2430
+ <Line accessor={(d: LoadPoint) => d.s3} name="S3" />
2431
+ <Legend placement="top" />
2432
+ <Tooltip mode="x" />
2433
+ </Chart>
2434
+ </div>
2435
+ </>
2436
+ );
2437
+ }
2438
+ ```
2439
+
2440
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2441
+
2442
+ ##### A week of seconds
2443
+
2444
+ ```tsx
2445
+ /* A week of a kiln at one reading a second: 604,800 points per series, two
2446
+ series. Lines and areas downsample on their own above two points per pixel
2447
+ column: each column draws where the course entered it, its lowest and its
2448
+ highest reading and where it left - a spike stays a spike -, so the week
2449
+ costs a path of a few thousand points instead of 1.2 million. Zoom in with
2450
+ Ctrl or ⌘ and the wheel: from about half an hour on, the chart draws every
2451
+ reading again. The tooltip always reads the raw data.
2452
+
2453
+ It measures, like the benchmark above it - the time of the last series
2454
+ draw -, and is therefore not photographed either (R-5.1). */
2455
+
2456
+ import { useCallback, useState } from "react";
2457
+ import { Chart, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2458
+ import type { ChartPerf } from "@umriss-ui/charts";
2459
+ import { kiln, type KilnPoint } from "../../data";
2460
+
2461
+ /* The plant this is drawn from stands in the second tab, so that the
2462
+ example can be copied whole. */
2463
+
2464
+ export default function WeekOfSeconds() {
2465
+ // Made on first render, not on import: 604,800 readings are no page's
2466
+ // business until this one is open.
2467
+ const [data] = useState(() => kiln(2024, 1000));
2468
+ const [domain, setDomain] = useState<"data" | readonly [number, number]>("data");
2469
+ const [drawMs, setDrawMs] = useState(0);
2470
+ const onPerf = useCallback((p: ChartPerf) => setDrawMs(p.seriesDrawMs), []);
2471
+ return (
2472
+ <>
2473
+ <dl className="metrics">
2474
+ <div>
2475
+ <dt>Points</dt>
2476
+ <dd>{(2 * data.length).toLocaleString("en-US")}</dd>
2477
+ </div>
2478
+ <div>
2479
+ <dt>Series draw</dt>
2480
+ <dd>{drawMs.toFixed(1)} ms</dd>
2481
+ </div>
2482
+ </dl>
2483
+ <Chart data={data} height={300} ariaLabel="Kiln temperature and gas flow across a week, one reading a second" onPerf={onPerf}>
2484
+ <XAxis accessor={(d: KilnPoint) => d.t} time domain={domain} onDomainChange={setDomain} />
2485
+ <YAxis accessor={(d: KilnPoint) => d.temperature} label="°C" domain="visible" />
2486
+ <YAxis id="gas" position="right" accessor={(d: KilnPoint) => d.gas} label="m³/h" domain="visible" />
2487
+ <Line accessor={(d: KilnPoint) => d.temperature} name="Temperature" />
2488
+ <Line accessor={(d: KilnPoint) => d.gas} yAxisId="gas" name="Gas" />
2489
+ <Legend placement="top" />
2490
+ <Tooltip mode="x" />
2491
+ </Chart>
2492
+ </>
2493
+ );
2494
+ }
2495
+ ```
2496
+
2497
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2498
+
2499
+ ## The rest of the API
2500
+
2501
+ Exported as well, and named on no page above: the pure modules behind the components, and the types they are made of. Each with its declaration as the package's `.d.ts` carries it.
2502
+
2503
+ ### `invalidateTheme`
2504
+
2505
+ ```ts
2506
+ /** Explicit invalidation: devalue the cache, every living chart reads anew and
2507
+ redraws. */
2508
+ function invalidateTheme(): void;
2509
+ ```
2510
+
2511
+ ### `ResolvedTheme`
2512
+
2513
+ ```ts
2514
+ interface ResolvedTheme {
2515
+ colorAxis: string;
2516
+ colorGrid: string;
2517
+ colorText: string;
2518
+ colorBg: string;
2519
+ /** Colours of the two limit severities. A limit carries the colour of its
2520
+ severity, not that of its series - it belongs to none. */
2521
+ colorWarning: string;
2522
+ colorAlarm: string;
2523
+ /** The third outcome of an assessment. "unknown" deliberately has no
2524
+ colour: a missing value becomes a hole like every gap in this library, and
2525
+ a hole carries no colour, because a colour would be a claim. */
2526
+ colorOk: string;
2527
+ font: string;
2528
+ fontMono: string;
2529
+ series: readonly string[];
2530
+ /** The page is in forced colours: every colour above is a system colour, and
2531
+ the chart encodes by marks whatever its `encoding`. */
2532
+ forced: boolean;
2533
+ }
2534
+ ```
2535
+
2536
+ ### `LinearScale`
2537
+
2538
+ ```ts
2539
+ class LinearScale implements Scale {
2540
+ readonly domain: readonly [number, number];
2541
+ readonly range: readonly [number, number];
2542
+ /** Pre-computed line equation px = v·m + b - the draw loop uses it directly. */
2543
+ readonly m: number;
2544
+ readonly b: number;
2545
+ constructor(domain: readonly [number, number], range: readonly [number, number]);
2546
+ toPx(v: number): number;
2547
+ fromPx(px: number): number;
2548
+ ticks(n: number): number[];
2549
+ }
2550
+ ```
2551
+
2552
+ ### `assess`
2553
+
2554
+ ```ts
2555
+ /** What the value is, given the limit set. A pure function of the two.
2556
+
2557
+ A missing or non-finite value yields `unknown` - and nothing else: the
2558
+ deviation of a value nobody has is unknown to everybody too.
2559
+
2560
+ A bound is violated when the value lies beyond it, not when it lies on it. A
2561
+ value exactly on the upper bound is in order. That is decided once here
2562
+ (ADR-0006), so that two building blocks on the same screen do not disagree
2563
+ about the edge.
2564
+
2565
+ Where several bounds are violated the most severe wins; among equally severe
2566
+ ones the one the value lies furthest beyond. Under a tie in both, the one
2567
+ named first.
2568
+
2569
+ No hysteresis: that would need the previous state, and a function of one
2570
+ value has none. Exactly that is the property which makes this module safe to
2571
+ duplicate. */
2572
+ function assess(value: number | null | undefined, set?: LimitSet): Assessment;
2573
+ ```
2574
+
2575
+ ### `verdictWeight`
2576
+
2577
+ ```ts
2578
+ /** How heavily a verdict weighs, as a number - for sorting and comparing, as
2579
+ when a matrix is to bring its worst cell to the top. It stands here so that
2580
+ the order is not reinvented at every call site. */
2581
+ function verdictWeight(verdict: Verdict): number;
2582
+ ```
2583
+
2584
+ ### `Assessment`
2585
+
2586
+ ```ts
2587
+ /** The verdict and what led to it. */
2588
+ interface Assessment {
2589
+ verdict: Verdict;
2590
+ /** The violated limit - the same object the caller passed in, so that it can
2591
+ be recognised against the caller's own data. Absent where none is
2592
+ violated. */
2593
+ limit?: Limit;
2594
+ /** The amount beyond the bound, always positive. Absent where none is
2595
+ violated. */
2596
+ excess?: number;
2597
+ /** The signed distance from the target. Absent where none is set - a missing
2598
+ target yields no deviation of zero.
2599
+
2600
+ Two fields and not one: excess and deviation are two numbers, and a single
2601
+ field meaning the one thing when a bound is violated and the other
2602
+ otherwise reads well and is wrong at three in the morning. */
2603
+ deviation?: number;
2604
+ }
2605
+ ```
2606
+
2607
+ ### `Limit`
2608
+
2609
+ ```ts
2610
+ /** A bound: a value, a side and a severity. No more - a tolerance band is
2611
+ derived and never an input (two limits of equal severity on opposite sides
2612
+ enclose one). That keeps the one-sided case, which is the more common one,
2613
+ free of a `null` on the other edge. */
2614
+ interface Limit {
2615
+ value: number;
2616
+ side: Side;
2617
+ severity: Severity;
2618
+ }
2619
+ ```
2620
+
2621
+ ### `LimitSet`
2622
+
2623
+ ```ts
2624
+ /** The set a value is read against: its bounds and, where there is one, its
2625
+ target.
2626
+
2627
+ The target is not a limit. It is missed, never violated, and it is never the
2628
+ edge of a region. Conflating the two is how a tile turns red for being above
2629
+ target. */
2630
+ interface LimitSet {
2631
+ /** In any order; none, one or several. */
2632
+ limits?: readonly Limit[];
2633
+ target?: number;
2634
+ }
2635
+ ```
2636
+
2637
+ ### `Side`
2638
+
2639
+ ```ts
2640
+ /** Whether a limit is an upper or a lower bound. */
2641
+ type Side = "upper" | "lower";
2642
+ ```
2643
+
2644
+ ### `Verdict`
2645
+
2646
+ ```ts
2647
+ /** What a value is. Four outcomes, not three.
2648
+
2649
+ `unknown` is an outcome of its own and not a `null` return: a `null`
2650
+ invites `?? "ok"` at the call site, and that very expression is the defect
2651
+ this module exists to prevent. A value nobody has is a reason to look and
2652
+ not a reason to relax. */
2653
+ type Verdict = "ok" | "unknown" | "warning" | "alarm";
2654
+ ```
2655
+
2656
+ ### `sigmaFromLimit`
2657
+
2658
+ ```ts
2659
+ /** Sigma calculated back from a limit that stands on paper. For callers whose
2660
+ baseline has been handed down as a centre line and a control limit. */
2661
+ function sigmaFromLimit(center: number, upper: number): number;
2662
+ ```
2663
+
2664
+ ### `zones`
2665
+
2666
+ ```ts
2667
+ /** The faintly drawn zone lines. Rule 4 is about the two-sigma zone, and a
2668
+ reader cannot check it against a chart that does not show it. */
2669
+ function zones(limits: ControlLimits, multiples?: readonly number[]): Zone[];
2670
+ ```
2671
+
2672
+ ### `ruleOutlier`
2673
+
2674
+ ```ts
2675
+ /** Rule 1 (Nelson 1, Western Electric 1): one point beyond three sigma.
2676
+ Beyond, not on it - a point exactly on the limit violates nothing. */
2677
+ function ruleOutlier(values: readonly number[], limits: ControlLimits): number[];
2678
+ ```
2679
+
2680
+ ### `ruleRun`
2681
+
2682
+ ```ts
2683
+ /** Rule 2 (Nelson 2): a run of points on one side of the centre line. The
2684
+ default length is nine - Nelson. Western Electric counts eight; that is why
2685
+ the length is a parameter and not a literal, so that a works with a rule of
2686
+ its own need not argue with the library. */
2687
+ function ruleRun(values: readonly number[], limits: ControlLimits, length?: number): number[];
2688
+ ```
2689
+
2690
+ ### `ruleTrend`
2691
+
2692
+ ```ts
2693
+ /** Rule 3 (Nelson 3): a run of nothing but rises or nothing but falls. The
2694
+ default length is six points, so five steps - Nelson. Two equal values are
2695
+ neither a rise nor a fall and break the run. */
2696
+ function ruleTrend(values: readonly number[], length?: number): number[];
2697
+ ```
2698
+
2699
+ ### `ruleTwoOfThree`
2700
+
2701
+ ```ts
2702
+ /** Rule 4 (Nelson 5): two of three consecutive points beyond two sigma on the
2703
+ same side. Reported are the points beyond the zone, not the one in between -
2704
+ that one is unremarkable and, marked, would look like a measurement error. */
2705
+ function ruleTwoOfThree(values: readonly number[], limits: ControlLimits): number[];
2706
+ ```
2707
+
2708
+ ### `violatedIndices`
2709
+
2710
+ ```ts
2711
+ /** All violated indices, ascending and free of duplicates - the scatter drawn
2712
+ beside the line. */
2713
+ function violatedIndices(found: readonly Violation[]): number[];
2714
+ ```
2715
+
2716
+ ### `defaultRules`
2717
+
2718
+ ```ts
2719
+ /** All four rules, with Nelson's run lengths: nine points on one side, six
2720
+ points in succession rising or falling. Western Electric counts eight; both
2721
+ lengths are therefore parameters. */
2722
+ const defaultRules: RuleOptions;
2723
+ ```
2724
+
2725
+ ### `ControlLimits`
2726
+
2727
+ ```ts
2728
+ /** Centre line, estimated spread and the control limits at three sigma. */
2729
+ interface ControlLimits {
2730
+ readonly center: number;
2731
+ /** Estimated spread of a single value, not the distance to the limit. */
2732
+ readonly sigma: number;
2733
+ readonly upper: number;
2734
+ readonly lower: number;
2735
+ }
2736
+ ```
2737
+
2738
+ ### `RuleName`
2739
+
2740
+ ```ts
2741
+ /** Name of a rule, the way it is switched on and off in `violations`. */
2742
+ type RuleName = "outlier" | "run" | "trend" | "twoOfThree";
2743
+ ```
2744
+
2745
+ ### `Zone`
2746
+
2747
+ ```ts
2748
+ /** A zone line: k times sigma above and below the centre line. */
2749
+ interface Zone {
2750
+ readonly k: number;
2751
+ readonly upper: number;
2752
+ readonly lower: number;
2753
+ }
2754
+ ```
2755
+
2756
+ ### `paretoDefaults`
2757
+
2758
+ ```ts
2759
+ const defaultOptions: Omit<ParetoOptions, "remainderName">;
2760
+ // exported as paretoDefaults
2761
+ ```
2762
+
2763
+ ### `ParetoSettings`
2764
+
2765
+ ```ts
2766
+ /** What a call may specify: a collect rank only together with the name of the
2767
+ remainder it creates. */
2768
+ type ParetoSettings = {
2769
+ readonly cutoff?: number;
2770
+ } & ({
2771
+ readonly collectRank?: undefined;
2772
+ readonly remainderName?: undefined;
2773
+ } | {
2774
+ readonly collectRank: number;
2775
+ readonly remainderName: string;
2776
+ });
2777
+ ```
2778
+
2779
+ ### `ParetoItem`
2780
+
2781
+ ```ts
2782
+ /** An item, the way the caller counts it. */
2783
+ interface ParetoItem {
2784
+ readonly name: string;
2785
+ readonly value: number;
2786
+ }
2787
+ ```
2788
+
2789
+ ### `ParetoOptions`
2790
+
2791
+ ```ts
2792
+ interface ParetoOptions {
2793
+ /** The share whose crossing is sought. Whoever lands exactly on it counts as
2794
+ the crossing: the question is "which reasons make up eighty per cent", and
2795
+ whoever reaches eighty exactly belongs to them. */
2796
+ readonly cutoff: number;
2797
+ /** How many items stay on their own. Everything behind that becomes a single
2798
+ entry. Beyond the number of items no remainder arises at all - not an empty
2799
+ one. Default: infinity, so no collecting. */
2800
+ readonly collectRank: number;
2801
+ /** Name of the collected remainder. `@umriss-ui/charts` has no wording module
2802
+ - every string it draws comes from the caller - and no module is introduced
2803
+ for a single label. Hence no default either: until library-audit 03 the
2804
+ default here was "Sonstige", the one German word the package brought along
2805
+ itself. */
2806
+ readonly remainderName?: string;
2807
+ }
2808
+ ```
2809
+
2810
+ ### `ParetoResult`
2811
+
2812
+ ```ts
2813
+ interface ParetoResult {
2814
+ readonly entries: readonly ParetoEntry[];
2815
+ /** Total of the drawn entries - after collecting, the same as before. */
2816
+ readonly total: number;
2817
+ /** Index of the first entry whose cumulative share reaches the cutoff; -1
2818
+ when it is never reached. */
2819
+ readonly cutoffIndex: number;
2820
+ }
2821
+ ```
2822
+
2823
+ ### `calendarFrom`
2824
+
2825
+ ```ts
2826
+ /** Calendar from an input - a built calendar is passed straight through, a raw
2827
+ list is built once and remembered. */
2828
+ function calendarFrom(input: CalendarInput): OperatingCalendar;
2829
+ ```
2830
+
2831
+ ### `toOperatingTime`
2832
+
2833
+ ```ts
2834
+ /** Wall clock time → operating time.
2835
+
2836
+ A point in a removed interval is a gap and becomes `NaN` - the encoding every
2837
+ gap of this library already uses, and the drawing loop breaks the mark at it
2838
+ of its own accord. Not the edge of the interval: a measurement from a time
2839
+ which, according to the calendar, did not exist could not be laid there
2840
+ honestly; it would pile up on what really does lie there. The same holds
2841
+ before the first and after the last interval.
2842
+
2843
+ The end of one interval and the beginning of the next fall on the same value:
2844
+ in operating time they are neighbours, and the line between two points on
2845
+ either side is drawn. That is not a lie but the whole purpose of the axis; the
2846
+ mark in the axis band tells the reader that time was taken out there. */
2847
+ function toOperatingTime(wallClock: number, input: CalendarInput): number;
2848
+ ```
2849
+
2850
+ ### `toWallClock`
2851
+
2852
+ ```ts
2853
+ /** Operating time → wall clock time; outside [0, total] there is none.
2854
+
2855
+ At a seam - the end of an interval which is at the same time the beginning of
2856
+ the next - the beginning of the next comes out: from there time counts again,
2857
+ and a pointer aiming there aims at the coming shift. Only the end of the last
2858
+ interval has no next and comes back as itself. */
2859
+ function toWallClock(operatingTime: number, input: CalendarInput): number;
2860
+ ```
2861
+
2862
+ ### `mapSeries`
2863
+
2864
+ ```ts
2865
+ /** Map a whole series inwards - the route materialisation takes when it wants to
2866
+ go it in one piece. Only the first n values are touched. */
2867
+ function mapSeries(input: CalendarInput, source: Float64Array, n: number, target: Float64Array): void;
2868
+ ```
2869
+
2870
+ ### `removedIntervals`
2871
+
2872
+ ```ts
2873
+ /** The removed spans between the operating intervals, for the mark in the axis
2874
+ band. A chart that takes a weekend out and does not say so claims a
2875
+ continuity it does not have. Before the first and after the last interval
2876
+ there is nothing to mark: that lies outside the axis' extent. */
2877
+ function removedIntervals(input: CalendarInput): RemovedSpan[];
2878
+ ```
2879
+
2880
+ ### `breaks`
2881
+
2882
+ ```ts
2883
+ /** The breaks in operating time that lie within the extent [from, to] - what the
2884
+ axis must mark, in domain units. */
2885
+ function breaks(input: CalendarInput, from: number, to: number): number[];
2886
+ ```
2887
+
2888
+ ### `operatingTicks`
2889
+
2890
+ ```ts
2891
+ /** Tick values for an axis extent that already stands in OPERATING TIME - the
2892
+ route the axis layout takes.
2893
+
2894
+ The extent is trimmed to the operating time that exists (an extent widened to
2895
+ nice boundaries reaches beyond it), calculated back into wall clock time, and
2896
+ the candidates arise there. The step comes from the span in operating time and
2897
+ not from the one in wall clock time: roughly one candidate per step of
2898
+ operating time survives, and calculated from the wall clock span, four out of
2899
+ ten wanted ticks would be left. */
2900
+ function operatingTicks(input: CalendarInput, from: number, to: number, count: number, offset?: number): number[];
2901
+ ```
2902
+
2903
+ ### `operatingTimeTicks`
2904
+
2905
+ ```ts
2906
+ /** Ticks for an operating time axis, from an extent in WALL CLOCK TIME.
2907
+
2908
+ The candidates arise in wall clock time on multiples of the step - hours,
2909
+ shift changes, days - whatever lies in removed time falls away, the rest is
2910
+ mapped. Never the other way round: ticks generated in operating time land in
2911
+ the middle of a shift and yield an axis labelled 14.5 and 29.0 which nobody
2912
+ can read.
2913
+
2914
+ The offset shifts the grid; with it a local time hits its own midnight without
2915
+ this module having to know about time zones.
2916
+
2917
+ If two candidates fall on the same operating time - the end of one shift and
2918
+ the beginning of the next - the earlier one stays: two labels on the same
2919
+ pixel would be one too many.
2920
+
2921
+ Both times come back, because the axis needs both: the operating time to
2922
+ place the tick, the wall clock time to label it. */
2923
+ function operatingTimeTicks(input: CalendarInput, from: number, to: number, step: number, offset?: number): OperatingTimeTick[];
2924
+ ```
2925
+
2926
+ ### `timeStep`
2927
+
2928
+ ```ts
2929
+ /** Step for tick candidates in wall clock time, at an intended count. Rounded up
2930
+ to the next readable step, so that no more ticks arise than wanted. Above the
2931
+ day steps it continues in whole days. */
2932
+ function timeStep(span: number, count: number): number;
2933
+ ```
2934
+
2935
+ ### `SECOND`
2936
+
2937
+ ```ts
2938
+ const SECOND = 1000;
2939
+ ```
2940
+
2941
+ ### `MINUTE`
2942
+
2943
+ ```ts
2944
+ const MINUTE: number;
2945
+ ```
2946
+
2947
+ ### `DAY`
2948
+
2949
+ ```ts
2950
+ const DAY: number;
2951
+ ```
2952
+
2953
+ ### `CalendarInput`
2954
+
2955
+ ```ts
2956
+ /** Every function of this module takes either: the raw list as it stands on the
2957
+ axis, or the calendar built from it. */
2958
+ type CalendarInput = OperatingCalendar | readonly OperatingInterval[];
2959
+ ```
2960
+
2961
+ ### `OperatingCalendar`
2962
+
2963
+ ```ts
2964
+ /** An operating calendar: sorted, merged intervals with the operating time
2965
+ accumulated before each one begins. An empty calendar is no calendar: it maps
2966
+ unchanged.
2967
+
2968
+ Build once, then pass around: the mapping runs once per point, and were it to
2969
+ sort and accumulate every time, the calendar would be the most expensive
2970
+ thing about the frame. */
2971
+ interface OperatingCalendar {
2972
+ readonly intervals: readonly OperatingInterval[];
2973
+ /** Operating time accumulated before the respective interval begins. */
2974
+ readonly offsets: readonly number[];
2975
+ /** The whole operating time - the axis' extent is [0, total]. */
2976
+ readonly total: number;
2977
+ }
2978
+ ```
2979
+
2980
+ ### `OperatingTimeTick`
2981
+
2982
+ ```ts
2983
+ /** A tick: where it stands on the clock and where it lies on the axis. */
2984
+ interface OperatingTimeTick {
2985
+ readonly wallClock: number;
2986
+ readonly operatingTime: number;
2987
+ }
2988
+ ```
2989
+
2990
+ ### `RemovedSpan`
2991
+
2992
+ ```ts
2993
+ /** A removed span and the place it collapses onto. */
2994
+ interface RemovedSpan {
2995
+ readonly from: number;
2996
+ readonly to: number;
2997
+ /** Operating time on which the whole span lies - a point, not a stretch. */
2998
+ readonly operatingTime: number;
2999
+ }
3000
+ ```
3001
+
3002
+ ### `AreaSeriesConfig`
3003
+
3004
+ ```ts
3005
+ interface AreaSeriesConfig<T = unknown> extends SeriesBase<T>, Stackable {
3006
+ kind: "area";
3007
+ /** Lower edge; without a value the fixed baseline 0. */
3008
+ baseline?: Accessor<T>;
3009
+ /** Opacity of the fill; the outline stays fully opaque. */
3010
+ fillOpacity: number;
3011
+ strokeWidth: number;
3012
+ /** Dash pattern of the outline. */
3013
+ dash?: readonly number[];
3014
+ }
3015
+ ```
3016
+
3017
+ ### `LimitBandConfig`
3018
+
3019
+ ```ts
3020
+ interface LimitBandConfig extends LimitBase {
3021
+ kind: "band";
3022
+ from: number;
3023
+ to: number;
3024
+ }
3025
+ ```
3026
+
3027
+ ### `LimitConfig`
3028
+
3029
+ ```ts
3030
+ type LimitConfig = LimitLineConfig | LimitBandConfig;
3031
+ ```
3032
+
3033
+ ### `LimitLineConfig`
3034
+
3035
+ ```ts
3036
+ interface LimitLineConfig extends LimitBase {
3037
+ kind: "line";
3038
+ value: number;
3039
+ }
3040
+ ```
3041
+
3042
+ ### `MatrixSeriesConfig`
3043
+
3044
+ ```ts
3045
+ interface MatrixSeriesConfig<T = unknown> extends SeriesBase<T> {
3046
+ kind: "matrix";
3047
+ /** The value that decides the colour - the third channel (ADR-0011).
3048
+ The base accessor yields the row position, not this value. */
3049
+ value: Accessor<T>;
3050
+ coloring: MatrixColoring;
3051
+ }
3052
+ ```
3053
+
3054
+ ### `StateSeriesConfig`
3055
+
3056
+ ```ts
3057
+ interface StateSeriesConfig<T = unknown> extends SeriesBase<T> {
3058
+ kind: "state";
3059
+ /** The closed set of states. The accessor yields the index into it - a number,
3060
+ not a key (ADR-0007). */
3061
+ states: readonly StateEntry[];
3062
+ /** Lower edge of the lane in domain units of the y axis; without a value the
3063
+ beginning of the axis domain. */
3064
+ laneFrom?: number;
3065
+ /** Upper edge of the lane; without a value the end of the axis domain. */
3066
+ laneTo?: number;
3067
+ }
3068
+ ```
3069
+
3070
+ ### `AxisConfig`
3071
+
3072
+ ```ts
3073
+ interface AxisConfig<T = unknown> {
3074
+ id: string;
3075
+ orientation: AxisOrientation;
3076
+ position: AxisPosition;
3077
+ accessor: (d: T, index: number) => number;
3078
+ label?: string;
3079
+ tickCount?: number;
3080
+ tickFormat?: (v: number) => string;
3081
+ /** "visible" is a y axis' only: the extent of what its series show inside
3082
+ their x axis' domain. */
3083
+ domain: "nice" | "data" | "visible" | readonly [number, number];
3084
+ /** undefined = the default per R-4.15 (only the first registered axis per
3085
+ orientation). */
3086
+ grid?: boolean;
3087
+ /** Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are
3088
+ not numbers but places: the lanes of a state stack, the categories of a
3089
+ Pareto. */
3090
+ ticks?: readonly number[];
3091
+ /** The values are instants in milliseconds: ticks on local boundaries,
3092
+ labels by level. A calendar implies it. */
3093
+ time?: boolean;
3094
+ /** Operating calendar: the intervals in which time counts. With it,
3095
+ materialisation maps wall clock time onto operating time before the scale
3096
+ calculates - the scale stays affine (ADR-0001). */
3097
+ calendar?: readonly OperatingInterval[];
3098
+ /** y axes only: this axis' ticks on the first y axis' grid. */
3099
+ alignTicks?: boolean;
3100
+ /** x axes only: where wheel, drag, pinch and double click propose a
3101
+ domain. Without it the axis does not zoom. */
3102
+ onDomainChange?: (domain: [number, number]) => void;
3103
+ }
3104
+ ```
3105
+
3106
+ ### `AxisOrientation`
3107
+
3108
+ ```ts
3109
+ type AxisOrientation = "x" | "y";
3110
+ ```
3111
+
3112
+ ### `AxisPosition`
3113
+
3114
+ ```ts
3115
+ type AxisPosition = "bottom" | "top" | "left" | "right";
3116
+ ```
3117
+
3118
+ ### `BarSeriesConfig`
3119
+
3120
+ ```ts
3121
+ interface BarSeriesConfig<T = unknown> extends SeriesBase<T>, Stackable {
3122
+ kind: "bar";
3123
+ /** Width as a fraction of the step; the group shares this fraction. */
3124
+ barWidth: number;
3125
+ }
3126
+ ```
3127
+
3128
+ ### `LegendConfig`
3129
+
3130
+ ```ts
3131
+ interface LegendConfig {
3132
+ placement: "top" | "bottom";
3133
+ }
3134
+ ```
3135
+
3136
+ ### `LineSeriesConfig`
3137
+
3138
+ ```ts
3139
+ interface LineSeriesConfig<T = unknown> extends SeriesBase<T> {
3140
+ kind: "line";
3141
+ strokeWidth: number;
3142
+ dash?: readonly number[];
3143
+ markers: "auto" | "always" | "never";
3144
+ /** Sample-and-hold: each value holds until the next sample. */
3145
+ step?: boolean;
3146
+ }
3147
+ ```
3148
+
3149
+ ### `MaterializedSeries`
3150
+
3151
+ ```ts
3152
+ /** Materialised series: the draw loop works only on this (R-2.7).
3153
+ Gaps (null/undefined/NaN/±Infinity out of the accessor) are encoded as NaN
3154
+ (R-2.5). */
3155
+ interface MaterializedSeries {
3156
+ x: Float64Array;
3157
+ y: Float64Array;
3158
+ /** Lower edge, where the baseline is an accessor of its own; otherwise null.
3159
+ A fixed baseline needs no channel - it is a number (R-2.7). */
3160
+ y0: Float64Array | null;
3161
+ /** Value channel: the third value per point, which today only the matrix
3162
+ needs. Named rather than overloading y0 - a property that only some kinds
3163
+ carry does not belong in the shared type with a comment (ADR-0011). null
3164
+ for every kind that does not use it. */
3165
+ w: Float64Array | null;
3166
+ length: number;
3167
+ }
3168
+ ```
3169
+
3170
+ ### `Rect`
3171
+
3172
+ ```ts
3173
+ interface Rect {
3174
+ x: number;
3175
+ y: number;
3176
+ width: number;
3177
+ height: number;
3178
+ }
3179
+ ```
3180
+
3181
+ ### `Scale`
3182
+
3183
+ ```ts
3184
+ interface Scale {
3185
+ domain: readonly [number, number];
3186
+ range: readonly [number, number];
3187
+ /** Affine coefficients of the mapping px = v·m + b.
3188
+
3189
+ They stand in the interface because the drawing code reads them and
3190
+ computes inline in the point loop instead of calling toPx() per point -
3191
+ that is what carries 60 fps at millions of points. Every scale of this
3192
+ library is affine; see docs/adr/0001-affine-scale-contract.md, which also
3193
+ says what a later log scale would cost. */
3194
+ readonly m: number;
3195
+ readonly b: number;
3196
+ toPx(v: number): number;
3197
+ fromPx(px: number): number;
3198
+ ticks(n: number): number[];
3199
+ }
3200
+ ```
3201
+
3202
+ ### `ScatterSeriesConfig`
3203
+
3204
+ ```ts
3205
+ interface ScatterSeriesConfig<T = unknown> extends SeriesBase<T> {
3206
+ kind: "scatter";
3207
+ /** Point radius in CSS pixels. */
3208
+ radius: number;
3209
+ }
3210
+ ```
3211
+
3212
+ ### `SeriesBase`
3213
+
3214
+ ```ts
3215
+ interface SeriesBase<T = unknown> {
3216
+ /** Y value; null/undefined/NaN/±Infinity means a gap (R-2.5). */
3217
+ accessor: Accessor<T>;
3218
+ /** Series-own data; overrides the container data (R-2.4). */
3219
+ data?: readonly T[];
3220
+ xAxisId: string;
3221
+ yAxisId: string;
3222
+ name?: string;
3223
+ /** The value in the tooltip; before the y axis' `tickFormat`. Compared by its
3224
+ source text, as an accessor is. */
3225
+ format?: (value: number) => string;
3226
+ color?: string;
3227
+ /** A role instead of a colour value: the theme resolves it, in both themes.
3228
+ For series that mean something rather than just being the next one - the
3229
+ violating points of a control chart, say. `color` beats it. */
3230
+ tone?: "ok" | "warning" | "alarm";
3231
+ /** Not drawn, not hit, not in its axes' extent; its legend entry stays. */
3232
+ hidden?: boolean;
3233
+ }
3234
+ ```
3235
+
3236
+ ### `SeriesConfig`
3237
+
3238
+ ```ts
3239
+ type SeriesConfig<T = unknown> = LineSeriesConfig<T> | AreaSeriesConfig<T> | BarSeriesConfig<T> | ScatterSeriesConfig<T> | StateSeriesConfig<T> | MatrixSeriesConfig<T>;
3240
+ ```
3241
+
3242
+ ### `SeriesKind`
3243
+
3244
+ ```ts
3245
+ type SeriesKind = SeriesConfig["kind"];
3246
+ ```
3247
+
3248
+ ### `TooltipConfig`
3249
+
3250
+ ```ts
3251
+ interface TooltipConfig<T = unknown> {
3252
+ mode: "x" | "nearest";
3253
+ render?: (hit: TooltipHit<T>) => ReactNode;
3254
+ }
3255
+ ```
3256
+
3257
+ ### `TooltipPoint`
3258
+
3259
+ ```ts
3260
+ /** One hit per series in the tooltip (R-4.4). */
3261
+ interface TooltipPoint<T = unknown> {
3262
+ seriesName: string;
3263
+ color: string;
3264
+ /** The point's x value on its own x axis - with several x axes it can
3265
+ differ from the hit's. */
3266
+ xValue: number;
3267
+ /** The point's y value; in a stack its own value, not its top - in a
3268
+ normalised stack its share in percent. */
3269
+ yValue: number;
3270
+ datum: T;
3271
+ index: number;
3272
+ /** Only for the matrix: the value out of the value channel. It stands here and
3273
+ not in yValue, because yValue is the position on the y axis - for a matrix
3274
+ therefore the row. Putting both into one field would be exactly the
3275
+ overloading ADR-0011 is written against. */
3276
+ value?: number;
3277
+ /** Only for state series: the section being pointed at.
3278
+ A duration does not stand here - it is the difference of two numbers the
3279
+ entry already carries, and formatting it would presuppose knowing what the
3280
+ x axis means. The caller knows that, not this library. */
3281
+ segment?: {
3282
+ from: number;
3283
+ to: number;
3284
+ label: string;
3285
+ };
3286
+ }
3287
+ ```
3288
+
3289
+ ### `resolveColours`
3290
+
3291
+ ```ts
3292
+ /** Any number of CSS colours - a token, `light-dark(…)`, `var(--…)`, a
3293
+ literal - resolved to values a canvas can draw, in the scheme that applies
3294
+ at `root`. The same probe `resolveTheme` uses, for a canvas of a
3295
+ neighbouring package (ADR-0022): the schedule draws its subtasks with the
3296
+ colours of @umriss-ui/core and of its caller, not with the chart palette.
3297
+
3298
+ Not cached: the caller keeps the result and reads anew when
3299
+ `subscribeTheme` tells it to. Where no resolution comes back (a DOM
3300
+ without a style engine), a colour is given back as it stands. */
3301
+ function resolveColours<K extends string>(root: Element, colours: Readonly<Record<K, string>>): Record<K, string>;
3302
+ ```
3303
+
3304
+ ### `subscribeTheme`
3305
+
3306
+ ```ts
3307
+ /** Subscribe to theme invalidations - a switch of `color-scheme` on the
3308
+ document, the system's preference, or `invalidateTheme()`. The scenes of
3309
+ the charts use it, and so does any canvas that resolved its colours with
3310
+ `resolveColours`. Returns the unsubscribe. */
3311
+ function subscribeTheme(notify: () => void): () => void;
3312
+ ```
3313
+
3314
+ ### `toOperatingTimeClamped`
3315
+
3316
+ ```ts
3317
+ /** Wall clock time → operating time, but without NaN: whatever lies in removed
3318
+ time falls onto the seam that this span collapses onto anyway; whatever lies
3319
+ before the first or after the last interval onto 0 or onto the whole
3320
+ operating time respectively.
3321
+
3322
+ Why this exists: the materialised x values must stay ascending. Binary search
3323
+ - for a hit as for a segment boundary - assumes that, and every comparison
3324
+ with NaN is false, so that a search over a block of NaN silently lands on the
3325
+ wrong point. The clamped value is a POSITION, not a statement: the point
3326
+ itself is carried as a gap and is never drawn and never hit. */
3327
+ function toOperatingTimeClamped(wallClock: number, input: CalendarInput): number;
3328
+ ```
3329
+
3330
+ ### `localOffset`
3331
+
3332
+ ```ts
3333
+ /** The local offset at an instant, as the shift of a grid that is to lie on
3334
+ local time: `wallClock = offset + k · step`. Taken at one instant, it holds
3335
+ for a grid up to the next clock change. */
3336
+ function localOffset(instant: number): number;
3337
+ ```
3338
+
3339
+ ### `DEFAULT_CHARTS_WORDING`
3340
+
3341
+ ```ts
3342
+ const DEFAULT_CHARTS_WORDING: ChartsWording;
3343
+ ```
3344
+
3345
+ ### `DataTableGroup`
3346
+
3347
+ ```ts
3348
+ /** One table of the data table: the series of one x axis, or one matrix. Every
3349
+ cell is text already, in the format the tooltip writes. */
3350
+ interface DataTableGroup {
3351
+ key: string;
3352
+ caption: string;
3353
+ columns: readonly string[];
3354
+ /** The first cell of each row is its x - the row's heading. */
3355
+ rows: readonly (readonly string[])[];
3356
+ }
3357
+ ```
3358
+
3359
+ ## Files the examples show
3360
+
3361
+ The demo's own data, which some examples import from beside themselves. Copied with the example, it runs.
3362
+
3363
+ ### `data.ts`
3364
+
3365
+ ```ts
3366
+ /* Deterministic, generic number series (R-6.2).
3367
+ Seed-based, so that screenshots are stable; deliberately without any subject
3368
+ matter. */
3369
+
3370
+ export interface Point {
3371
+ t: number;
3372
+ a: number;
3373
+ b: number;
3374
+ c: number;
3375
+ d: number | null;
3376
+ }
3377
+
3378
+ /** A small LCG - reproducible across runs and platforms. */
3379
+ export function random(seed: number): () => number {
3380
+ let s = seed >>> 0;
3381
+ return () => {
3382
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
3383
+ return s / 4294967296;
3384
+ };
3385
+ }
3386
+
3387
+ /** A series with four channels; channel d contains a deliberate gap (R-2.5),
3388
+ and one reading in the middle of it - a point between two gaps. */
3389
+ export function series(seed: number, n: number, gap = false): Point[] {
3390
+ const r = random(seed);
3391
+ const points: Point[] = new Array<Point>(n);
3392
+ let a = 50;
3393
+ let b = 30;
3394
+ let c = 70;
3395
+ let d = 45;
3396
+ const gapFrom = Math.floor(n * 0.42);
3397
+ const gapTo = Math.floor(n * 0.55);
3398
+ const lone = Math.floor((gapFrom + gapTo) / 2);
3399
+ for (let i = 0; i < n; i++) {
3400
+ a += (r() - 0.5) * 6;
3401
+ b += (r() - 0.48) * 4;
3402
+ c += (r() - 0.52) * 5;
3403
+ d += (r() - 0.5) * 7;
3404
+ points[i] = {
3405
+ t: i,
3406
+ a,
3407
+ b,
3408
+ c,
3409
+ d: gap && i >= gapFrom && i <= gapTo && i !== lone ? null : d,
3410
+ };
3411
+ }
3412
+ return points;
3413
+ }
3414
+
3415
+ export interface DualPoint {
3416
+ t: number;
3417
+ small: number;
3418
+ large: number;
3419
+ medium: number;
3420
+ }
3421
+
3422
+ /** Two to three series with clearly different extents. */
3423
+ export function dualSeries(seed: number, n: number): DualPoint[] {
3424
+ const r = random(seed);
3425
+ const points: DualPoint[] = new Array<DualPoint>(n);
3426
+ let small = 21;
3427
+ let large = 128000;
3428
+ let medium = 640;
3429
+ for (let i = 0; i < n; i++) {
3430
+ small += (r() - 0.5) * 0.9;
3431
+ large += (r() - 0.5) * 9000;
3432
+ medium += (r() - 0.5) * 40;
3433
+ points[i] = { t: 2000 + i, small, large, medium };
3434
+ }
3435
+ return points;
3436
+ }
3437
+
3438
+ export interface LoadPoint {
3439
+ t: number;
3440
+ s1: number;
3441
+ s2: number;
3442
+ s3: number;
3443
+ }
3444
+
3445
+ /** Data set for the benchmark example: three series of any length. */
3446
+ export function load(seed: number, n: number): LoadPoint[] {
3447
+ const r = random(seed);
3448
+ const points: LoadPoint[] = new Array<LoadPoint>(n);
3449
+ let s1 = 100;
3450
+ let s2 = 60;
3451
+ let s3 = 140;
3452
+ for (let i = 0; i < n; i++) {
3453
+ s1 += r() - 0.5;
3454
+ s2 += (r() - 0.5) * 0.8;
3455
+ s3 += (r() - 0.5) * 1.2;
3456
+ points[i] = { t: i, s1, s2, s3 };
3457
+ }
3458
+ return points;
3459
+ }
3460
+
3461
+ export interface MixedPoint {
3462
+ t: number;
3463
+ inflow: number;
3464
+ outflow: number;
3465
+ stock: number;
3466
+ /** A corridor with a deliberate gap - the fill gets a hole there. */
3467
+ corridorLower: number | null;
3468
+ corridorUpper: number | null;
3469
+ sample: number;
3470
+ }
3471
+
3472
+ /** Data set of the mixed example: two bar series, a corridor as a band area with a
3473
+ gap, a line above it and samples as a scatter. Few periods, so that individual
3474
+ bars stay visible. */
3475
+ export function mixed(seed: number, n: number): MixedPoint[] {
3476
+ const r = random(seed);
3477
+ const points: MixedPoint[] = new Array<MixedPoint>(n);
3478
+ let stock = 62;
3479
+ const gapFrom = Math.floor(n * 0.45);
3480
+ const gapTo = Math.floor(n * 0.62);
3481
+ for (let i = 0; i < n; i++) {
3482
+ const inflow = 30 + r() * 25;
3483
+ const outflow = 22 + r() * 20;
3484
+ stock += (inflow - outflow) * 0.35;
3485
+ const inGap = i >= gapFrom && i <= gapTo;
3486
+ points[i] = {
3487
+ t: i + 1,
3488
+ inflow,
3489
+ outflow,
3490
+ stock,
3491
+ corridorLower: inGap ? null : stock - 9 - r() * 3,
3492
+ corridorUpper: inGap ? null : stock + 9 + r() * 3,
3493
+ sample: stock + (r() - 0.5) * 26,
3494
+ };
3495
+ }
3496
+ return points;
3497
+ }
3498
+
3499
+ /* ---------------------------------------------------------------------------
3500
+ Data of the operation instruments.
3501
+
3502
+ Up to here every series in this file is deliberately without subject matter
3503
+ (R-6.2): a curve is a curve, and "Series A" says enough. From here on that no
3504
+ longer works. A state band without states, a Pareto without fault reasons and a
3505
+ control chart without a measurement series show nothing - with these instruments
3506
+ the meaning IS the subject. What remains is the other half of the rule, and that
3507
+ is the important one: everything here is seed-based and therefore identical
3508
+ across runs and platforms.
3509
+ --------------------------------------------------------------------------- */
3510
+
3511
+ export const HOUR_MS = 3_600_000;
3512
+ export const DAY_MS = 24 * HOUR_MS;
3513
+
3514
+ /** Monday, 16 March 2026, 00:00 local time - the anchor of every time series
3515
+ below. */
3516
+ export const WEEK_START = new Date("2026-03-16T00:00:00").getTime();
3517
+
3518
+ export interface StatePoint {
3519
+ t: number;
3520
+ /** A code in PLANT_STATES. */
3521
+ m1: number;
3522
+ m2: number;
3523
+ /** A machine without a report: a gap stands here. */
3524
+ m3: number | null;
3525
+ temperature: number;
3526
+ }
3527
+
3528
+ /** The closed set of states of the demo. The order is the code. */
3529
+ export const PLANT_STATES = [
3530
+ { label: "Production", color: "#2f7d51" },
3531
+ { label: "Setup", color: "#c08a2e" },
3532
+ { label: "Fault", color: "#b4483f" },
3533
+ { label: "Maintenance", color: "#5b6b8c" },
3534
+ ] as const;
3535
+
3536
+ /** One shift of three machines: states per quarter of an hour, plus a temperature
3537
+ curve on the same x axis - that is what shows why the band sits in the same
3538
+ chart and not beside it. */
3539
+ export function shift(seed: number, n: number): StatePoint[] {
3540
+ const r = random(seed);
3541
+ const points: StatePoint[] = new Array<StatePoint>(n);
3542
+ const start = WEEK_START + 6 * HOUR_MS;
3543
+ let m1 = 0;
3544
+ let m2 = 0;
3545
+ let m3: number | null = 0;
3546
+ let temperature = 806;
3547
+ for (let i = 0; i < n; i++) {
3548
+ if (r() < 0.16) m1 = Math.floor(r() * 4);
3549
+ if (r() < 0.12) m2 = Math.floor(r() * 3);
3550
+ // From a point on, machine 3 reports nothing any more: the band gets a hole,
3551
+ // and a hole is not a colour for "unknown".
3552
+ if (i > n * 0.62 && i < n * 0.78) m3 = null;
3553
+ else if (r() < 0.1) m3 = Math.floor(r() * 3);
3554
+ temperature += (r() - 0.5) * 9 + (m1 === 2 ? 6 : -0.6);
3555
+ points[i] = {
3556
+ t: start + i * 15 * 60_000,
3557
+ m1,
3558
+ m2,
3559
+ m3,
3560
+ temperature,
3561
+ };
3562
+ }
3563
+ return points;
3564
+ }
3565
+
3566
+ export interface Measurement {
3567
+ n: number;
3568
+ value: number;
3569
+ }
3570
+
3571
+ /** Individual values of a feature inspection. The process runs cleanly and then
3572
+ begins to drift - in such a way that the run rule fires and not only the
3573
+ outlier rule. */
3574
+ export function measurements(seed: number, n: number): Measurement[] {
3575
+ const r = random(seed);
3576
+ const points: Measurement[] = new Array<Measurement>(n);
3577
+ for (let i = 0; i < n; i++) {
3578
+ const drift = i > n * 0.68 ? (i - n * 0.68) * 0.05 : 0;
3579
+ const outlier = i === Math.floor(n * 0.42) ? 0.9 : 0;
3580
+ points[i] = { n: i, value: 12.5 + (r() - 0.5) * 0.34 + drift + outlier };
3581
+ }
3582
+ return points;
3583
+ }
3584
+
3585
+ export interface DowntimeReason {
3586
+ name: string;
3587
+ value: number;
3588
+ }
3589
+
3590
+ /** Downtime reasons of one week, in an arbitrary input order - the sorting is the
3591
+ job of the Pareto module, not of the data. */
3592
+ export const DOWNTIME_REASONS: readonly DowntimeReason[] = [
3593
+ { name: "Tool breakage", value: 34 },
3594
+ { name: "Material shortage", value: 128 },
3595
+ { name: "Setup over plan", value: 96 },
3596
+ { name: "Sensor fault", value: 21 },
3597
+ { name: "Coolant", value: 12 },
3598
+ { name: "Operator error", value: 47 },
3599
+ { name: "Power dip", value: 8 },
3600
+ { name: "Labeller", value: 6 },
3601
+ { name: "Conveyor", value: 5 },
3602
+ { name: "Small part", value: 3 },
3603
+ ];
3604
+
3605
+ export interface CellPoint {
3606
+ /** Hour of the day. */
3607
+ hour: number;
3608
+ /** The row: the machine. */
3609
+ machine: number;
3610
+ /** Overall equipment effectiveness in per cent; null = no measurement. */
3611
+ oee: number | null;
3612
+ }
3613
+
3614
+ export const MACHINES = [
3615
+ "Press 1",
3616
+ "Press 2",
3617
+ "Mill 3",
3618
+ "Mill 4",
3619
+ "Furnace 1",
3620
+ "Furnace 2",
3621
+ "Assembly A",
3622
+ "Assembly B",
3623
+ ] as const;
3624
+
3625
+ /** Machine × hour. One machine has a bad hour, one whole hour is bad everywhere -
3626
+ only the difference makes the matrix worth reading. And a few cells are
3627
+ missing: a hole, not a zero. */
3628
+ export function utilizationMatrix(seed: number): CellPoint[] {
3629
+ const r = random(seed);
3630
+ const cells: CellPoint[] = [];
3631
+ for (let m = 0; m < MACHINES.length; m++) {
3632
+ for (let h = 0; h < 24; h++) {
3633
+ let oee: number | null = 62 + r() * 32;
3634
+ if (m === 3 && h > 9 && h < 15) oee = 18 + r() * 12; // one machine
3635
+ if (h === 21) oee = 24 + r() * 10; // one hour, everywhere
3636
+ if (m === 6 && h > 1 && h < 5) oee = null; // no measurement
3637
+ cells.push({ hour: h, machine: m, oee });
3638
+ }
3639
+ }
3640
+ return cells;
3641
+ }
3642
+
3643
+ export interface WeekPoint {
3644
+ t: number;
3645
+ output: number;
3646
+ }
3647
+
3648
+ /** A working week: values only during the shifts. On a wall clock axis this chart
3649
+ would consist, for a good forty per cent, of flat lines over an empty hall. */
3650
+ export function week(seed: number): WeekPoint[] {
3651
+ const r = random(seed);
3652
+ const points: WeekPoint[] = [];
3653
+ let value = 420;
3654
+ for (let day = 0; day < 5; day++) {
3655
+ for (let quarter = 0; quarter < 4 * 16; quarter++) {
3656
+ const hour = 6 + quarter / 4;
3657
+ value += (r() - 0.5) * 26;
3658
+ points.push({
3659
+ t: WEEK_START + day * DAY_MS + hour * HOUR_MS,
3660
+ output: value,
3661
+ });
3662
+ }
3663
+ }
3664
+ return points;
3665
+ }
3666
+
3667
+ /** The operating calendar to go with it: Monday to Friday, 6 to 22 o'clock. */
3668
+ export const WEEK_CALENDAR = Array.from({ length: 5 }, (_, day) => ({
3669
+ from: WEEK_START + day * DAY_MS + 6 * HOUR_MS,
3670
+ to: WEEK_START + day * DAY_MS + 22 * HOUR_MS,
3671
+ }));
3672
+
3673
+ /* ---------------------------------------------------------------------------
3674
+ Data of the kind pages.
3675
+
3676
+ Area, Bar, Scatter and the tooltip each get a page with examples of their
3677
+ own, and each example shows one property. The data is plant data like the
3678
+ instruments' above, and for the same reason: a filled area of "Series A"
3679
+ does not say why it is filled down to 0, a power draw does.
3680
+ --------------------------------------------------------------------------- */
3681
+
3682
+ export interface PowerPoint {
3683
+ t: number;
3684
+ /** Power draw of the line in kW. */
3685
+ kw: number;
3686
+ }
3687
+
3688
+ /** The power draw of one line over the early shift, every five minutes: the
3689
+ start-up, production, the break at half past nine and a setup towards the
3690
+ end. A draw has a natural 0, which is why the area is filled down to it. */
3691
+ export function powerDraw(seed: number): PowerPoint[] {
3692
+ const r = random(seed);
3693
+ const start = WEEK_START + 6 * HOUR_MS;
3694
+ const points: PowerPoint[] = [];
3695
+ for (let i = 0; i <= 8 * 12; i++) {
3696
+ const hour = 6 + i / 12;
3697
+ let base = 182;
3698
+ if (hour < 6.5) base = 30 + (hour - 6) * 300; // start-up
3699
+ else if (hour >= 9.5 && hour < 10) base = 42; // break
3700
+ else if (hour >= 12 && hour < 12.75) base = 75; // setup
3701
+ points.push({ t: start + i * 5 * 60_000, kw: base + (r() - 0.5) * 18 });
3702
+ }
3703
+ return points;
3704
+ }
3705
+
3706
+ export interface CorridorPoint {
3707
+ t: number;
3708
+ /** The corridor the recipe permits; null during the recipe change. */
3709
+ lower: number | null;
3710
+ upper: number | null;
3711
+ /** The measured temperature - always there, also where no corridor is. */
3712
+ temperature: number;
3713
+ }
3714
+
3715
+ /** A hardening furnace over one shift. The corridor comes from the recipe;
3716
+ during the change from one recipe to the next there is none - a gap, not a
3717
+ corridor of zero width - and the temperature climbs to the new one. */
3718
+ export function corridor(seed: number): CorridorPoint[] {
3719
+ const r = random(seed);
3720
+ const start = WEEK_START + 6 * HOUR_MS;
3721
+ const points: CorridorPoint[] = [];
3722
+ let temperature = 842;
3723
+ for (let i = 0; i <= 8 * 12; i++) {
3724
+ const hour = 6 + i / 12;
3725
+ const change = hour >= 10 && hour < 11;
3726
+ const target = hour < 10 ? 840 : 880;
3727
+ temperature += (target - temperature) * 0.12 + (r() - 0.5) * 6;
3728
+ points.push({
3729
+ t: start + i * 5 * 60_000,
3730
+ lower: change ? null : target - 12,
3731
+ upper: change ? null : target + 12,
3732
+ temperature,
3733
+ });
3734
+ }
3735
+ return points;
3736
+ }
3737
+
3738
+ export interface ShiftScrap {
3739
+ /** The position on the x axis: the shift's index, named by the tickFormat. */
3740
+ shift: number;
3741
+ name: string;
3742
+ /** Scrap in pieces. */
3743
+ scrap: number;
3744
+ }
3745
+
3746
+ /** Scrap per shift over three days. The night shift runs on fewer hands and
3747
+ scraps more - that is the one thing a reader is to find. */
3748
+ export function scrapPerShift(seed: number): ShiftScrap[] {
3749
+ const r = random(seed);
3750
+ const days = ["Mon", "Tue", "Wed"];
3751
+ const shifts = ["early", "late", "night"];
3752
+ return days.flatMap((day, d) =>
3753
+ shifts.map((shift, s) => ({
3754
+ shift: d * 3 + s,
3755
+ name: `${day} ${shift}`,
3756
+ scrap: Math.round((s === 2 ? 38 : 18) + r() * 14),
3757
+ })),
3758
+ );
3759
+ }
3760
+
3761
+ export interface DayOutput {
3762
+ /** The position on the x axis: the working day's index. */
3763
+ day: number;
3764
+ name: string;
3765
+ /** Pieces planned and made. */
3766
+ planned: number;
3767
+ actual: number;
3768
+ }
3769
+
3770
+ /** Two working weeks of one line, planned against made. Some days above plan,
3771
+ some below - the deviation has both signs. */
3772
+ export function planAndActual(seed: number): DayOutput[] {
3773
+ const r = random(seed);
3774
+ const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
3775
+ const points: DayOutput[] = [];
3776
+ for (let day = 0; day < 10; day++) {
3777
+ const planned = day % 5 === 4 ? 1600 : 2000;
3778
+ const date = new Date(WEEK_START + (day + 2 * Math.floor(day / 5)) * DAY_MS).getDate();
3779
+ points.push({
3780
+ day,
3781
+ name: `${names[day % 5]} ${date}`,
3782
+ planned,
3783
+ actual: Math.round(planned + (r() - 0.55) * 520),
3784
+ });
3785
+ }
3786
+ return points;
3787
+ }
3788
+
3789
+ export interface DayDowntime {
3790
+ /** The position on the x axis: the working day's index. */
3791
+ day: number;
3792
+ name: string;
3793
+ /** Minutes stopped, per reason; null where the reason was not logged. */
3794
+ setup: number;
3795
+ material: number | null;
3796
+ breakdown: number;
3797
+ }
3798
+
3799
+ /** Two working weeks of downtime by reason. On one Wednesday the material log
3800
+ was not kept - a gap, not a zero - and on the second Thursday a breakdown
3801
+ takes the day. */
3802
+ export function downtimePerDay(seed: number): DayDowntime[] {
3803
+ const r = random(seed);
3804
+ const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
3805
+ return Array.from({ length: 10 }, (_, day) => ({
3806
+ day,
3807
+ name: `${names[day % 5]} ${new Date(WEEK_START + (day + 2 * Math.floor(day / 5)) * DAY_MS).getDate()}`,
3808
+ setup: Math.round(40 + r() * 30),
3809
+ material: day === 2 ? null : Math.round(10 + r() * 45),
3810
+ breakdown: Math.round((day === 8 ? 150 : 5) + r() * 25),
3811
+ }));
3812
+ }
3813
+
3814
+ export interface LineOutput {
3815
+ t: number;
3816
+ /** Pieces per hour of each of three lines. */
3817
+ line1: number;
3818
+ line2: number;
3819
+ line3: number;
3820
+ }
3821
+
3822
+ /** A day of three lines, hour by hour from six to six. Line 3 runs the early
3823
+ and late shift only - at night it stands, and says 0, not nothing. */
3824
+ export function outputPerLine(seed: number): LineOutput[] {
3825
+ const r = random(seed);
3826
+ return Array.from({ length: 25 }, (_, i) => {
3827
+ const hour = (6 + i) % 24;
3828
+ const night = hour >= 22 || hour < 6;
3829
+ return {
3830
+ t: WEEK_START + (6 + i) * HOUR_MS,
3831
+ line1: Math.round(120 + r() * 30),
3832
+ line2: Math.round((night ? 60 : 90) + r() * 25),
3833
+ line3: night ? 0 : Math.round(70 + r() * 30),
3834
+ };
3835
+ });
3836
+ }
3837
+
3838
+ export interface SetPoint {
3839
+ t: number;
3840
+ /** The set point in °C; null while no recipe is loaded. */
3841
+ setPoint: number | null;
3842
+ }
3843
+
3844
+ /** The set point of the hardening furnace above, logged only when it changes -
3845
+ a raise at 08:30, the recipe change from 10 to 11 o'clock without one, and a
3846
+ last entry at the end of the shift that closes the hold. */
3847
+ export const setPoints: readonly SetPoint[] = [
3848
+ { t: WEEK_START + 6 * HOUR_MS, setPoint: 840 },
3849
+ { t: WEEK_START + 8.5 * HOUR_MS, setPoint: 845 },
3850
+ { t: WEEK_START + 10 * HOUR_MS, setPoint: null },
3851
+ { t: WEEK_START + 11 * HOUR_MS, setPoint: 880 },
3852
+ { t: WEEK_START + 14 * HOUR_MS, setPoint: 880 },
3853
+ ];
3854
+
3855
+ export interface ThicknessSample {
3856
+ t: number;
3857
+ /** Wall thickness in mm. */
3858
+ mm: number;
3859
+ }
3860
+
3861
+ /** Wall thickness of a pipe, sampled by hand over a shift - whenever the
3862
+ inspector came by, not on a clock. Individual measurements, not a course:
3863
+ nothing was measured between two of them. */
3864
+ export function wallThickness(seed: number, n: number): ThicknessSample[] {
3865
+ const r = random(seed);
3866
+ const points: ThicknessSample[] = [];
3867
+ let t = WEEK_START + 6 * HOUR_MS;
3868
+ for (let i = 0; i < n; i++) {
3869
+ t += (4 + r() * 16) * 60_000;
3870
+ points.push({ t, mm: 3.2 + (r() - 0.5) * 0.24 + (r() < 0.08 ? 0.18 : 0) });
3871
+ }
3872
+ return points;
3873
+ }
3874
+
3875
+ export interface WeightSample {
3876
+ t: number;
3877
+ /** The set point of the fill weight in g. */
3878
+ setPoint: number;
3879
+ /** A sample within tolerance; null where it is an outlier. */
3880
+ sample: number | null;
3881
+ /** A sample outside tolerance; null where it is none. */
3882
+ outlier: number | null;
3883
+ }
3884
+
3885
+ /** Fill weights of a filling line against their set point, which changes with
3886
+ the product at eleven. A sample further than the tolerance from its set
3887
+ point is an outlier - the two channels never both carry a value. */
3888
+ export function fillWeights(seed: number, n: number, tolerance: number): WeightSample[] {
3889
+ const r = random(seed);
3890
+ const points: WeightSample[] = [];
3891
+ const start = WEEK_START + 6 * HOUR_MS;
3892
+ for (let i = 0; i < n; i++) {
3893
+ const t = start + (i * 8 * HOUR_MS) / (n - 1);
3894
+ const setPoint = t < WEEK_START + 11 * HOUR_MS ? 500 : 525;
3895
+ const value = setPoint + (r() - 0.5) * 16 + (r() < 0.06 ? (r() < 0.5 ? -1 : 1) * 14 : 0);
3896
+ const out = Math.abs(value - setPoint) > tolerance;
3897
+ points.push({ t, setPoint, sample: out ? null : value, outlier: out ? value : null });
3898
+ }
3899
+ return points;
3900
+ }
3901
+
3902
+ export interface FurnacePoint {
3903
+ t: number;
3904
+ /** Temperatures of the two furnaces in °C. */
3905
+ f1: number;
3906
+ f2: number;
3907
+ /** The set point both run at. */
3908
+ setPoint: number;
3909
+ }
3910
+
3911
+ /** Two furnaces at one set point over the early shift, every ten minutes -
3912
+ close enough that "which one is it" is a question the tooltip answers. */
3913
+ export function furnaces(seed: number): FurnacePoint[] {
3914
+ const r = random(seed);
3915
+ const start = WEEK_START + 6 * HOUR_MS;
3916
+ const points: FurnacePoint[] = [];
3917
+ let f1 = 806;
3918
+ let f2 = 800;
3919
+ for (let i = 0; i <= 8 * 6; i++) {
3920
+ f1 += (808 - f1) * 0.15 + (r() - 0.45) * 5;
3921
+ f2 += (808 - f2) * 0.1 + (r() - 0.55) * 5;
3922
+ points.push({ t: start + i * 10 * 60_000, f1, f2, setPoint: 808 });
3923
+ }
3924
+ return points;
3925
+ }
3926
+
3927
+ export interface HallPoint {
3928
+ t: number;
3929
+ /** Hall temperature in °C. */
3930
+ temperature: number;
3931
+ }
3932
+
3933
+ /** A day of hall temperature from Monday noon, every quarter hour - warm while
3934
+ the ovens run, cooling through the night. Crosses midnight on purpose. */
3935
+ export function hallTemperature(seed: number): HallPoint[] {
3936
+ const r = random(seed);
3937
+ const start = WEEK_START + 12 * HOUR_MS;
3938
+ const points: HallPoint[] = [];
3939
+ let temperature = 24;
3940
+ for (let i = 0; i <= 24 * 4; i++) {
3941
+ const t = start + i * 15 * 60_000;
3942
+ const hour = new Date(t).getHours();
3943
+ const target = hour >= 6 && hour < 22 ? 25 : 18;
3944
+ temperature += (target - temperature) * 0.08 + (r() - 0.5) * 0.4;
3945
+ points.push({ t, temperature });
3946
+ }
3947
+ return points;
3948
+ }
3949
+
3950
+ export interface KilnPoint {
3951
+ t: number;
3952
+ /** Kiln temperature in °C. */
3953
+ temperature: number;
3954
+ /** Gas flow in m³/h. */
3955
+ gas: number;
3956
+ /** Draught in Pa - the kiln runs below the hall's pressure. */
3957
+ draught: number;
3958
+ /** Flue gas temperature in °C: the harder the kiln fires, the hotter it
3959
+ leaves. */
3960
+ flue: number;
3961
+ }
3962
+
3963
+ /** A week of a kiln from Monday midnight, one reading every `step`
3964
+ milliseconds: 850 °C while the shifts run on weekdays, held at 600 °C at
3965
+ night and over the weekend, the gas following the heat it has to make. On
3966
+ Wednesday at 14:00 the burner trips for twenty minutes. The week has no
3967
+ clock change, so the hour is counted rather than asked of a Date - a week
3968
+ of seconds is 604,800 of them. */
3969
+ export function kiln(seed: number, step: number): KilnPoint[] {
3970
+ const r = random(seed);
3971
+ const n = Math.floor((7 * DAY_MS) / step);
3972
+ const points = new Array<KilnPoint>(n);
3973
+ // Ten minutes to answer: the same kiln at any step.
3974
+ const k = Math.min(1, step / 600_000);
3975
+ let temperature = 600;
3976
+ let gas = 22;
3977
+ for (let i = 0; i < n; i++) {
3978
+ const offset = i * step;
3979
+ const day = Math.floor(offset / DAY_MS);
3980
+ const minute = Math.floor((offset % DAY_MS) / 60_000);
3981
+ const running = day < 5 && minute >= 6 * 60 && minute < 22 * 60;
3982
+ const tripped = day === 2 && minute >= 14 * 60 && minute < 14 * 60 + 20;
3983
+ const target = running ? 850 : 600;
3984
+ // What holds the temperature; above it the kiln heats, below it cools.
3985
+ const holding = running ? 38 : 22;
3986
+ const demand = tripped ? 0 : Math.max(0, holding + (target - temperature) * 0.3);
3987
+ gas += (demand - gas) * Math.min(1, k * 4);
3988
+ temperature += (gas - holding) * k;
3989
+ const shown = temperature + (r() - 0.5) * 4;
3990
+ const reading = Math.max(0, gas + (r() - 0.5) * 1.5);
3991
+ points[i] = {
3992
+ t: WEEK_START + offset,
3993
+ temperature: shown,
3994
+ gas: reading,
3995
+ draught: -8 - gas * 0.12 + (r() - 0.5) * 1.2,
3996
+ flue: 250 + reading * 3,
3997
+ };
3998
+ }
3999
+ return points;
4000
+ }
4001
+
4002
+ /* ---------------------------------------------------------------------------
4003
+ The series the examples show.
4004
+
4005
+ They stand here and not in the example files because several examples share
4006
+ one of them - `basicData` is the course in three examples - and because R-6.2
4007
+ wants one seed per series, not one per place it is drawn. An example imports
4008
+ what it needs; nobody inlines a second generator.
4009
+ --------------------------------------------------------------------------- */
4010
+
4011
+ export const basicData = series(42, 120);
4012
+ export const multiData = series(7, 90, true);
4013
+ export const configData = series(1312, 24);
4014
+ export const axesData = dualSeries(99, 60);
4015
+ export const mixedData = mixed(2026, 14);
4016
+ export const shiftData = shift(4711, 64);
4017
+ export const measurementData = measurements(815, 90);
4018
+ export const matrixData = utilizationMatrix(23);
4019
+ export const weekData = week(1963);
4020
+ export const powerData = powerDraw(612);
4021
+ export const corridorData = corridor(1400);
4022
+ export const scrapData = scrapPerShift(333);
4023
+ export const outputData = planAndActual(1017);
4024
+ export const thicknessData = wallThickness(88, 36);
4025
+ export const weightData = fillWeights(4040, 97, 9);
4026
+ export const furnaceData = furnaces(2718);
4027
+ export const hallData = hallTemperature(1603);
4028
+ export const kilnData = kiln(2024, 60_000);
4029
+ export const downtimeData = downtimePerDay(1204);
4030
+ export const lineOutputData = outputPerLine(906);
4031
+ ```