@umriss-ui/charts 0.12.0 → 0.14.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.
package/docs/llms-full.md CHANGED
@@ -1,4 +1,4 @@
1
- # @umriss-ui/charts 0.12.0
1
+ # @umriss-ui/charts 0.14.0
2
2
 
3
3
  > React canvas chart library for data-dense dashboards - line, area, bar, box plot, scatter, control and Pareto charts. Few chart kinds, drawn well, fast.
4
4
 
@@ -400,9 +400,6 @@ const FLEET = VEHICLES.map((vehicle) => {
400
400
  return { vehicle, points: [...points, { t: NOW, state: null }], idle, driving: points.at(-1)?.state === DRIVING };
401
401
  });
402
402
 
403
- /* Lane 0 lies at the bottom, so the first vehicle takes the top lane. */
404
- const lane = (index: number) => FLEET.length - 1 - index;
405
- const plateOnLane = (v: number) => FLEET[FLEET.length - 1 - Math.floor(v)]?.vehicle.plate ?? "";
406
403
  const depotOf = (id: string) => DEPOTS.find((one) => one.id === id)?.name ?? id;
407
404
 
408
405
  const IDLE_TODAY = FLEET.reduce((sum, one) => sum + one.idle, 0) / 60;
@@ -413,20 +410,14 @@ function FleetLanes() {
413
410
  return (
414
411
  <Chart height={320} ariaLabel="What each vehicle has done today, lane by lane">
415
412
  <XAxis value="t" time domain={DAY} />
416
- <YAxis
417
- domain={[0, FLEET.length]}
418
- ticks={FLEET.map((_, i) => i + 0.5)}
419
- tickFormat={plateOnLane}
420
- grid={false}
421
- />
422
- {FLEET.map((one, i) => (
413
+ <YAxis grid={false} />
414
+ {FLEET.map((one) => (
423
415
  <StateBand
424
416
  key={one.vehicle.id}
425
417
  data={one.points}
426
418
  value="state"
427
419
  states={VEHICLE_STATES}
428
- laneFrom={lane(i) + 0.12}
429
- laneTo={lane(i) + 0.88}
420
+ lane={one.vehicle.plate}
430
421
  name={`${one.vehicle.plate} (${one.vehicle.type}, ${depotOf(one.vehicle.depot)})`}
431
422
  />
432
423
  ))}
@@ -440,11 +431,7 @@ function IdleMinutes() {
440
431
  const { Chart, XAxis, YAxis, Bar } = useChart(FLEET);
441
432
  return (
442
433
  <Chart height={200} ariaLabel="Idle minutes per vehicle so far today">
443
- <XAxis
444
- value={(_d, i) => i}
445
- ticks={FLEET.map((_, i) => i)}
446
- tickFormat={(v) => FLEET[v]?.vehicle.plate ?? ""}
447
- />
434
+ <XAxis value={(d) => d.vehicle.plate} />
448
435
  <YAxis label="min" tickCount={4} />
449
436
  <Bar value="idle" name="Idle" color={VEHICLE_STATES[IDLE]!.color} barWidth={0.6} />
450
437
  <Tooltip mode="x" />
@@ -493,86 +480,23 @@ import { Card, CardBody, CardHeader, Grid, Stack, Stat } from "@umriss-ui/core";
493
480
  import type { LimitSet } from "@umriss-ui/core";
494
481
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
495
482
 
496
- /* Data from the controlling world, written out here so the example runs on its own. */
497
- /** A small LCG - the same numbers on every computer. */
498
- function random(seed: number): () => number {
499
- let s = seed >>> 0;
500
- return () => {
501
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
502
- return s / 4294967296;
503
- };
504
- }
505
-
506
- interface CostCentre {
507
- id: string;
508
- name: string;
509
- owner: string;
510
- /** The budget of one month. */
511
- monthlyBudget: number;
512
- }
513
-
514
- const COST_CENTRES: readonly CostCentre[] = [
515
- { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
516
- { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
517
- { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
518
- { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
519
- { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
520
- { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
521
- { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
522
- { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
523
- { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
524
- ];
525
-
526
- /** The year's months, as `"2026-01"` … `"2026-12"`. */
527
- const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
528
-
529
- /** The months whose books are closed: they have an actual. */
530
- const CLOSED_MONTHS = 2;
531
-
532
- interface LedgerRow {
533
- costCentre: string;
534
- month: string;
535
- budget: number;
536
- /** `null` while the month is open. */
537
- actual: number | null;
538
- forecast: number;
539
- }
540
-
541
- /* Where a cost centre lands against its budget, as a factor: Marketing's
542
- spring campaign overspends, IT's delayed licences underspend. */
543
- const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
544
-
545
- /** Budget, actual and forecast of every cost centre in every month. */
546
- const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
547
- const r = random(310 + c);
548
- const tendency = TENDENCY[centre.id] ?? 1;
549
- return MONTHS.map((month, m) => {
550
- /* December pays the bonuses, August is the quiet month. */
551
- const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
552
- const budget = Math.round(centre.monthlyBudget * season);
553
- const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
554
- const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
555
- return { costCentre: centre.id, month, budget, actual, forecast };
556
- });
557
- });
558
-
559
483
  const thousands = (euros: number) => euros / 1000;
560
- const outlook = (row: LedgerRow) => row.actual ?? row.forecast;
561
484
  const euros = (v: number) => `${v.toLocaleString("en-GB", { maximumFractionDigits: 1 })} k€`;
562
485
 
563
- interface CentreYear {
564
- name: string;
565
- budget: number;
566
- outlook: number;
567
- gap: number;
568
- }
569
-
570
- const YEAR: readonly CentreYear[] = COST_CENTRES.map((centre) => {
571
- const rows = LEDGER.filter((row) => row.costCentre === centre.id);
572
- const budget = rows.reduce((sum, row) => sum + row.budget, 0);
573
- const expected = rows.reduce((sum, row) => sum + outlook(row), 0);
574
- return { name: centre.name, budget, outlook: expected, gap: expected - budget };
575
- });
486
+ /* The year per cost centre, in euros, as the ledger sums it: the budget, and
487
+ the outlook - closed months at their actual, open months at their forecast. */
488
+ const YEAR = [
489
+ { name: "Sales", budget: 1_706_840, outlook: 1_701_784 },
490
+ { name: "Marketing", budget: 817_360, outlook: 934_111 },
491
+ { name: "Engineering", budget: 2_259_760, outlook: 2_258_122 },
492
+ { name: "Design", budget: 649_080, outlook: 649_484 },
493
+ { name: "Customer service", budget: 733_220, outlook: 734_416 },
494
+ { name: "Finance", budget: 564_940, outlook: 563_659 },
495
+ { name: "People", budget: 468_780, outlook: 468_251 },
496
+ { name: "IT", budget: 997_660, outlook: 881_804 },
497
+ { name: "Facilities", budget: 865_440, outlook: 860_691 },
498
+ ];
499
+ const gap = (one: { budget: number; outlook: number }) => one.outlook - one.budget;
576
500
 
577
501
  const BUDGET = YEAR.reduce((sum, one) => sum + one.budget, 0);
578
502
  const OUTLOOK = YEAR.reduce((sum, one) => sum + one.outlook, 0);
@@ -586,24 +510,37 @@ const GAP_LIMITS: LimitSet = {
586
510
  ],
587
511
  };
588
512
 
589
- const MARKETING = COST_CENTRES.find((one) => one.name === "Marketing")!;
590
- const MONTHLY = LEDGER.filter((row) => row.costCentre === MARKETING.id);
591
- const MONTH_NAMES = MONTHS.map((month) => new Date(`${month}-01T00:00`).toLocaleString("en-GB", { month: "short" }));
513
+ const MARKETING = { id: "CC-1200", owner: "Rafael Ortiz" };
514
+
515
+ /* Marketing's ledger month by month, in euros; `actual` is `null` while the
516
+ month's books are open. */
517
+ const MONTHLY = [
518
+ { month: "2026-01", budget: 68_000, actual: 76_823, forecast: 76_900 },
519
+ { month: "2026-02", budget: 68_000, actual: 76_588, forecast: 78_600 },
520
+ { month: "2026-03", budget: 68_000, actual: null, forecast: 78_200 },
521
+ { month: "2026-04", budget: 68_000, actual: null, forecast: 76_100 },
522
+ { month: "2026-05", budget: 68_000, actual: null, forecast: 77_100 },
523
+ { month: "2026-06", budget: 68_000, actual: null, forecast: 77_800 },
524
+ { month: "2026-07", budget: 68_000, actual: null, forecast: 78_400 },
525
+ { month: "2026-08", budget: 61_200, actual: null, forecast: 69_600 },
526
+ { month: "2026-09", budget: 68_000, actual: null, forecast: 79_200 },
527
+ { month: "2026-10", budget: 68_000, actual: null, forecast: 78_400 },
528
+ { month: "2026-11", budget: 68_000, actual: null, forecast: 77_200 },
529
+ { month: "2026-12", budget: 76_160, actual: null, forecast: 88_700 },
530
+ ];
531
+ /** `"2026-03"` as the axis writes it. */
532
+ const monthName = (month: string) => new Date(`${month}-01T00:00`).toLocaleString("en-GB", { month: "short" });
592
533
 
593
534
  function GapPerCentre() {
594
535
  const { Chart, XAxis, YAxis, Bar } = useChart(YEAR);
595
536
  return (
596
537
  <Chart height={240} ariaLabel="Outlook minus budget for the year, per cost centre">
597
- <XAxis
598
- value={(_d, i) => i}
599
- ticks={YEAR.map((_, i) => i)}
600
- tickFormat={(v) => YEAR[v]?.name ?? ""}
601
- />
538
+ <XAxis value="name" />
602
539
  <YAxis label="k€" tickFormat={euros} />
603
540
  {/* One series per sign, stacked on one another so that each bar stands centred on its cost centre. */}
604
- <Bar value={(d) => (d.gap > 0 ? thousands(d.gap) : null)} name="Over budget" tone="alarm" stack="gap" format={euros} />
541
+ <Bar value={(d) => (gap(d) > 0 ? thousands(gap(d)) : null)} name="Over budget" tone="alarm" stack="gap" format={euros} />
605
542
  <Bar
606
- value={(d) => (d.gap <= 0 ? thousands(d.gap) : null)}
543
+ value={(d) => (gap(d) <= 0 ? thousands(gap(d)) : null)}
607
544
  name="Under budget"
608
545
  color="var(--uc-color-text)"
609
546
  stack="gap"
@@ -619,11 +556,7 @@ function MarketingByMonth() {
619
556
  const { Chart, XAxis, YAxis, Bar, Line } = useChart(MONTHLY);
620
557
  return (
621
558
  <Chart height={260} ariaLabel="Marketing's budget, actual and forecast per month">
622
- <XAxis
623
- value={(_d, i) => i}
624
- ticks={MONTHLY.map((_, i) => i)}
625
- tickFormat={(v) => MONTH_NAMES[v] ?? ""}
626
- />
559
+ <XAxis value="month" categoryLabel={monthName} />
627
560
  <YAxis label="k€" tickFormat={euros} />
628
561
  <Bar value={(d) => thousands(d.budget)} name="Budget" color="var(--uc-color-text)" barWidth={0.7} format={euros} />
629
562
  <Bar value={(d) => (d.actual === null ? null : thousands(d.actual))} name="Actual" barWidth={0.7} format={euros} />
@@ -784,8 +717,8 @@ const BURNT = BURNDOWN[0]!.remaining! - LEFT;
784
717
  const DAYS_DONE = BOOKED.length - 1;
785
718
  const DAYS_LEFT = BURNDOWN.length - BOOKED.length;
786
719
 
787
- const DAY_NAMES = BURNDOWN.map((one) => new Date(one.t).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" }));
788
- const day = (i: number) => DAY_NAMES[i] ?? "";
720
+ /** A working day as the axis names it: a category, so the weekend takes no room. */
721
+ const dayOf = (one: { t: number }) => new Date(one.t).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });
789
722
 
790
723
  interface Holder {
791
724
  name: string;
@@ -801,12 +734,7 @@ function SprintBurnDown() {
801
734
  const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
802
735
  return (
803
736
  <Chart height={280} ariaLabel={`${SPRINT.name}: hours left per working day against an even pace`}>
804
- <XAxis
805
- value={(_d, i) => i}
806
- ticks={BURNDOWN.map((_, i) => i)}
807
- tickFormat={day}
808
- label="Working day"
809
- />
737
+ <XAxis value={dayOf} label="Working day" />
810
738
  <YAxis label="Hours left" domain={[0, BURNDOWN[0]!.ideal]} />
811
739
  <Line value="ideal" name="Even pace" color="var(--uc-color-text)" dash={[4, 4]} strokeWidth={1} />
812
740
  <Line value="remaining" name="Left" markers="always" strokeWidth={2} />
@@ -820,11 +748,7 @@ function OpenPerPerson() {
820
748
  const { Chart, XAxis, YAxis, Bar } = useChart(HOLDERS);
821
749
  return (
822
750
  <Chart height={200} ariaLabel="Estimated hours of unfinished items per person">
823
- <XAxis
824
- value={(_d, i) => i}
825
- ticks={HOLDERS.map((_, i) => i)}
826
- tickFormat={(v) => HOLDERS[v]?.name ?? ""}
827
- />
751
+ <XAxis value="name" />
828
752
  <YAxis label="h" tickCount={4} />
829
753
  <Bar value="open" name="Estimate" barWidth={0.6} />
830
754
  <Tooltip mode="x" />
@@ -846,7 +770,7 @@ export default function BurnDown() {
846
770
  <Stack gap={2}>
847
771
  <SprintBurnDown />
848
772
  <Text size="sm" tone="muted">
849
- Ten working days, {day(0)} to {day(BURNDOWN.length - 1)}; the weekend between them is left out.
773
+ Ten working days, {dayOf(BURNDOWN[0]!)} to {dayOf(BURNDOWN.at(-1)!)}; the weekend between them is left out.
850
774
  </Text>
851
775
  </Stack>
852
776
  </CardBody>
@@ -1137,7 +1061,7 @@ The command installs the package alone: React 18 or 19 is its only peer and stay
1137
1061
 
1138
1062
  A chart takes its container's width and follows it; give it a `height` (300 px without one). `useChart(rows)` hands out `Chart`, its axes and its series, typed at the row; each reads its value from a row through `value`, a field name or a function - a value that is `null`, `undefined` or not finite is a gap, never a zero.
1139
1063
 
1140
- Time is a number: milliseconds since 1970, as `Date.getTime()` gives them. With `time` on the x axis the ticks stand on the viewer's local clock, so a day begins at local midnight. The words are English; German comes from `@umriss-ui/charts/wording/de`.
1064
+ An instant is a `Date`, milliseconds since 1970, or - where the x axis says `time` - the ISO string an API sends ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). On a time axis the ticks stand on the viewer's local clock, so a day begins at local midnight. The words are English; German comes from `@umriss-ui/charts/wording/de`.
1141
1065
 
1142
1066
  The charts take German per chart: `wording={GERMAN_CHARTS_WORDING}` from `@umriss-ui/charts/wording/de`, as [the keyboard and screen reader example](#/chart/keyboard-and-screen-reader) shows. They read no language provider, so this demo has no EN/DE switch in its header.
1143
1067
 
@@ -1145,31 +1069,24 @@ The charts take German per chart: `wording={GERMAN_CHARTS_WORDING}` from `@umris
1145
1069
 
1146
1070
  ##### Draw a first chart
1147
1071
 
1148
- Hand [`useChart`](#/api/useChart) the rows, then give its `Chart` an axis per direction and a series; the x axis and the series read a row through `value`.
1072
+ Hand [`useChart`](#/api/useChart) the rows as they come, then give its `Chart` an axis per direction and a series; the x axis and the series read a row through `value`. With `time` the axis reads the instants an API sends as text.
1149
1073
 
1150
1074
  ```tsx
1151
1075
  import { Tooltip, useChart } from "@umriss-ui/charts";
1152
1076
 
1153
- interface Hour {
1154
- /** The hour's start, in milliseconds since 1970. */
1155
- t: number;
1156
- signIns: number;
1157
- }
1158
-
1159
- const at = (hour: number) => new Date(2026, 2, 17, hour).getTime();
1160
-
1161
- const SIGN_INS: Hour[] = [
1162
- { t: at(0), signIns: 310 },
1163
- { t: at(1), signIns: 220 },
1164
- { t: at(2), signIns: 180 },
1165
- { t: at(3), signIns: 170 },
1166
- { t: at(4), signIns: 205 },
1167
- { t: at(5), signIns: 340 },
1168
- { t: at(6), signIns: 820 },
1169
- { t: at(7), signIns: 1460 },
1170
- { t: at(8), signIns: 2130 },
1171
- { t: at(9), signIns: 2480 },
1172
- { t: at(10), signIns: 2390 },
1077
+ /* Sign-ins per hour, as the API sends them: the hour's start in local time. */
1078
+ const SIGN_INS = [
1079
+ { t: "2026-03-17T00:00", signIns: 310 },
1080
+ { t: "2026-03-17T01:00", signIns: 220 },
1081
+ { t: "2026-03-17T02:00", signIns: 180 },
1082
+ { t: "2026-03-17T03:00", signIns: 170 },
1083
+ { t: "2026-03-17T04:00", signIns: 205 },
1084
+ { t: "2026-03-17T05:00", signIns: 340 },
1085
+ { t: "2026-03-17T06:00", signIns: 820 },
1086
+ { t: "2026-03-17T07:00", signIns: 1460 },
1087
+ { t: "2026-03-17T08:00", signIns: 2130 },
1088
+ { t: "2026-03-17T09:00", signIns: 2480 },
1089
+ { t: "2026-03-17T10:00", signIns: 2390 },
1173
1090
  ];
1174
1091
 
1175
1092
  export default function FirstChart() {
@@ -1324,7 +1241,7 @@ From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
1324
1241
  | Prop | Type | Default | Description |
1325
1242
  |---|---|---|---|
1326
1243
  | [`width`](#/chart/ChartProps-width) | `number \| "100%"` | `"100%"` | Width in CSS pixels, or `"100%"` for the host's: the chart measures its host and follows it through a ResizeObserver. |
1327
- | [`height`](#/chart/ChartProps-height) | `number` | `300` | Height in CSS pixels. Unlike the width, the chart does not take its host's. |
1244
+ | [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
1328
1245
  | [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
1329
1246
 
1330
1247
  ###### Accessibility
@@ -1337,8 +1254,8 @@ From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
1337
1254
 
1338
1255
  | Prop | Type | Default | Description |
1339
1256
  |---|---|---|---|
1340
- | [`value`](#/axis/XAxisProps-value) *required* | [`NumberField`](#/axis/type-NumberField)`<T> \| ((d: T, index: number) => number)` | — | Where a row lies on this axis: a number field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
1341
- | [`time`](#/axis/XAxisProps-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. |
1257
+ | [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
1258
+ | [`time`](#/axis/XAxisProps-time) | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. 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 in milliseconds. `calendar` implies it. |
1342
1259
  | [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
1343
1260
 
1344
1261
  From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
@@ -1701,14 +1618,15 @@ const CENTRES = [
1701
1618
  { id: "CC-4400", name: "Facilities" },
1702
1619
  ];
1703
1620
 
1704
- const month = (row: LedgerRow) => Number(row.month.slice(5)) - 1;
1705
1621
  const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1622
+ /** `"2026-03"` as the axis writes it. */
1623
+ const monthName = (month: string) => MONTH_NAMES[Number(month.slice(5)) - 1] ?? month;
1706
1624
 
1707
1625
  function Forecasts({ encoding }: { encoding?: "marks" }) {
1708
1626
  const { Chart, XAxis, YAxis, Line } = useChart(LEDGER);
1709
1627
  return (
1710
1628
  <Chart height={240} ariaLabel="Forecast of four cost centres per month" encoding={encoding}>
1711
- <XAxis value={month} tickFormat={(v) => MONTH_NAMES[v] ?? ""} tickCount={6} />
1629
+ <XAxis value="month" categoryLabel={monthName} />
1712
1630
  <YAxis label="€" />
1713
1631
  {CENTRES.map((c) => (
1714
1632
  <Line
@@ -2022,8 +1940,8 @@ const SERVICES: readonly Service[] = [
2022
1940
 
2023
1941
  interface SuccessCell {
2024
1942
  hour: number;
2025
- /** The row: the service's place in `SERVICES`. */
2026
- service: number;
1943
+ /** The row: the service's name. */
1944
+ service: string;
2027
1945
  /** Requests that succeeded, in per cent; `null` where there were none. */
2028
1946
  success: number | null;
2029
1947
  }
@@ -2034,13 +1952,13 @@ interface SuccessCell {
2034
1952
  const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
2035
1953
  const r = random(23);
2036
1954
  const cells: SuccessCell[] = [];
2037
- SERVICES.forEach((one, service) => {
1955
+ SERVICES.forEach((one) => {
2038
1956
  for (let hour = 0; hour < 24; hour++) {
2039
1957
  let success: number | null = 99.2 + r() * 0.8;
2040
1958
  if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
2041
1959
  if (hour === 3) success = 97.4 + r() * 1;
2042
1960
  if (one.id === "reports" && hour < 6) success = null;
2043
- cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
1961
+ cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
2044
1962
  }
2045
1963
  });
2046
1964
  return cells;
@@ -2055,10 +1973,11 @@ function BatteryAndStates() {
2055
1973
  <Chart height={280} ariaLabel="An e-van's battery against its reserve, above its states" encoding="marks">
2056
1974
  <XAxis value="t" time label="Time" tickCount={4} />
2057
1975
  <YAxis domain={[-40, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="%" />
2058
- <YAxis id="lane" position="right" domain={[0, 5]} ticks={[0.45]} tickFormat={() => "FP 377 K"} />
1976
+ {/* One lane, and four places of room above it for the course. */}
1977
+ <YAxis id="lane" position="right" domain={[-0.5, 4.5]} />
2059
1978
  <LimitBand from={10} to={20} severity="warning" label="Reserve" />
2060
1979
  <LimitLine value={10} severity="alarm" label="Empty soon" />
2061
- <StateBand data={STATES} value="state" states={VEHICLE_STATES} yAxisId="lane" laneFrom={0} laneTo={0.9} name="FP 377 K" />
1980
+ <StateBand data={STATES} value="state" states={VEHICLE_STATES} yAxisId="lane" lane="FP 377 K" name="FP 377 K" />
2062
1981
  <Line value="charge" name="Charge" strokeWidth={1.75} />
2063
1982
  <Legend placement="top" />
2064
1983
  <Tooltip mode="x" />
@@ -2071,7 +1990,7 @@ function SuccessCells() {
2071
1990
  return (
2072
1991
  <Chart height={280} ariaLabel="Successful requests per service and hour, by limits and by marks" encoding="marks">
2073
1992
  <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
2074
- <YAxis ticks={SERVICES.map((_, i) => i)} tickFormat={(v) => SERVICES[v]?.name ?? ""} />
1993
+ <YAxis />
2075
1994
  <Matrix
2076
1995
  value="service"
2077
1996
  level="success"
@@ -2175,6 +2094,58 @@ export default function Loading() {
2175
2094
  }
2176
2095
  ```
2177
2096
 
2097
+ ##### Draw a series per group of long rows
2098
+
2099
+ A chart draws one series per element. Rows that come one per service and hour are grouped before it - a `Set` names the groups, a `filter` picks each one's rows - and each group is a series with its own `data`. Which rows belong together is yours to say.
2100
+
2101
+ ```tsx
2102
+ import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
2103
+
2104
+ /* Failed requests in per cent, per service and hour - long rows, as a query
2105
+ returns them. */
2106
+ const ERRORS = [
2107
+ { t: "2026-03-17T06:00", service: "Checkout", errors: 0.12 },
2108
+ { t: "2026-03-17T06:00", service: "Billing", errors: 0.21 },
2109
+ { t: "2026-03-17T06:00", service: "Search", errors: 0.08 },
2110
+ { t: "2026-03-17T07:00", service: "Checkout", errors: 0.15 },
2111
+ { t: "2026-03-17T07:00", service: "Billing", errors: 0.19 },
2112
+ { t: "2026-03-17T07:00", service: "Search", errors: 0.09 },
2113
+ { t: "2026-03-17T08:00", service: "Checkout", errors: 0.14 },
2114
+ { t: "2026-03-17T08:00", service: "Billing", errors: 0.24 },
2115
+ { t: "2026-03-17T08:00", service: "Search", errors: 0.11 },
2116
+ { t: "2026-03-17T09:00", service: "Checkout", errors: 1.86 },
2117
+ { t: "2026-03-17T09:00", service: "Billing", errors: 0.92 },
2118
+ { t: "2026-03-17T09:00", service: "Search", errors: 0.1 },
2119
+ { t: "2026-03-17T10:00", service: "Checkout", errors: 2.41 },
2120
+ { t: "2026-03-17T10:00", service: "Billing", errors: 1.13 },
2121
+ { t: "2026-03-17T10:00", service: "Search", errors: 0.12 },
2122
+ { t: "2026-03-17T11:00", service: "Checkout", errors: 0.18 },
2123
+ { t: "2026-03-17T11:00", service: "Billing", errors: 0.22 },
2124
+ { t: "2026-03-17T11:00", service: "Search", errors: 0.09 },
2125
+ ];
2126
+
2127
+ /* Once, outside the component: a series' `data` is compared by reference. */
2128
+ const BY_SERVICE = [...new Set(ERRORS.map((row) => row.service))].map((service) => ({
2129
+ service,
2130
+ rows: ERRORS.filter((row) => row.service === service),
2131
+ }));
2132
+
2133
+ export default function SeriesFromLongRows() {
2134
+ const { Chart, XAxis, YAxis, Line } = useChart(ERRORS);
2135
+ return (
2136
+ <Chart height={260} ariaLabel="Failed requests per service and hour this morning">
2137
+ <XAxis value="t" time />
2138
+ <YAxis label="Errors %" />
2139
+ {BY_SERVICE.map(({ service, rows }) => (
2140
+ <Line key={service} data={rows} value="errors" name={service} markers="always" />
2141
+ ))}
2142
+ <Legend placement="top" />
2143
+ <Tooltip mode="x" />
2144
+ </Chart>
2145
+ );
2146
+ }
2147
+ ```
2148
+
2178
2149
  #### When to use something else
2179
2150
 
2180
2151
  - A single number with a small trend beside it → `Sparkline` or `Stat` from @umriss-ui/core
@@ -2209,14 +2180,14 @@ Under forced colours the chart draws in the system colours and tells its series
2209
2180
  | Prop | Type | Default | Description |
2210
2181
  |---|---|---|---|
2211
2182
  | `width` | `number \| "100%"` | `"100%"` | Width in CSS pixels, or `"100%"` for the host's: the chart measures its host and follows it through a ResizeObserver.<br>Shown in: [Size a chart by its container (Installation)](#/installation/in-its-container) |
2212
- | `height` | `number` | `300` | Height in CSS pixels. Unlike the width, the chart does not take its host's.<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 68 more |
2183
+ | `height` | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)).<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 70 more |
2213
2184
  | `padding` | `number \| Partial<`[`Padding`](#type-Padding)`>` | — | Outer spacing of the plot area in CSS pixels.<br>Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
2214
2185
  | `empty` | `ReactNode` | the wording's `empty` | 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. Not while `loading`.<br>Shown in: [Say there is nothing to show](#/chart/empty) |
2215
2186
  | `loading` | `boolean` | `false` | The rows are on their way, as the table's `loading` says ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). A course already drawn stays: it dims after a moment and takes no pointer until the answer is in. Where there is nothing to show, the empty message waits. The plot is `aria-busy` meanwhile.<br>Shown in: [Show that rows are on their way](#/chart/loading) |
2216
2187
  | `wording` | `Partial<`[`ChartsWording`](#type-ChartsWording)`>` | `DEFAULT_CHARTS_WORDING` | The chart's words ([ADR-0031](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0031-the-charts-carry-their-own-wording.md)); entries left out fall back to English. German: `GERMAN_CHARTS_WORDING` from `@umriss-ui/charts/wording/de`.<br>Shown in: [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) |
2217
2188
  | `encoding` | `"color" \| "marks"` | `"color"` | How series are told apart. `"color"`: 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.<br>Shown in: [Tell series apart without colour](#/chart/told-apart-without-colour), [Mark every kind](#/chart/marks-on-every-kind), [Mark bands, limits and cells](#/chart/bands-limits-and-cells) |
2218
2189
  | `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 this way: charts zoom together by handing each other their view (`useChart`'s `initialView` and `onViewChange`).<br>Shown in: [Keep charts in step (Zoom and pan)](#/zoom-and-pan/cursor-sync), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
2219
- | `children` | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order.<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 68 more |
2190
+ | `children` | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order.<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 70 more |
2220
2191
 
2221
2192
  ###### Events
2222
2193
 
@@ -2228,14 +2199,14 @@ Under forced colours the chart draws in the system colours and tells its series
2228
2199
 
2229
2200
  | Prop | Type | Default | Description |
2230
2201
  |---|---|---|---|
2231
- | `ariaLabel` | `string` | — | Required in DEV through a warning.<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 68 more |
2202
+ | `ariaLabel` | `string` | — | Required in DEV through a warning.<br>Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 70 more |
2232
2203
 
2233
2204
  ###### Styling
2234
2205
 
2235
2206
  | Prop | Type | Default | Description |
2236
2207
  |---|---|---|---|
2237
2208
  | `className` | `string` | — | Goes to the root element, as it does everywhere in this workspace. |
2238
- | `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. |
2209
+ | `style` | `CSSProperties` | — | Goes to the root element, after the chart's own styles: a `height` or `minHeight` set here wins. The plot area is observed in both directions and the scene follows it. |
2239
2210
 
2240
2211
  ##### Types on this page
2241
2212
 
@@ -2305,7 +2276,7 @@ What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendl
2305
2276
 
2306
2277
  ### Axis
2307
2278
 
2308
- What a value is measured against: extent, ticks and labels, several axes per side, time on the local clock, and a working-time axis that leaves out the hours nobody works (a scale).
2279
+ What a value is measured against: extent, ticks and labels, categories, several axes per side, time on the local clock, and a working-time axis that leaves out the hours nobody works (a scale).
2309
2280
 
2310
2281
  ```ts
2311
2282
  import { useChart, workingCalendar } from "@umriss-ui/charts";
@@ -2313,7 +2284,7 @@ import { useChart, workingCalendar } from "@umriss-ui/charts";
2313
2284
 
2314
2285
  Demo page: https://romanhaendler.github.io/umriss-ui/charts/axis/
2315
2286
 
2316
- Every scale is linear. Categories are numbers 0, 1, 2 … with a `tickFormat` that names them ([ADR-0002](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0002-bars-on-a-numeric-x-axis.md)); a working calendar maps time before it is drawn and marks every seam it took out ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).
2287
+ Every scale is linear. An axis reads what its rows hold: a number is a position, a string a category, which gets a place, a tick and its name ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); a working calendar maps time before it is drawn and marks every seam it took out ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).
2317
2288
 
2318
2289
  An x axis zooms where it is `zoomable`; `domain` is then where it starts. That has a page of its own, [Zoom and pan](#/zoom-and-pan).
2319
2290
 
@@ -2513,7 +2484,7 @@ export default function WorkingTime() {
2513
2484
 
2514
2485
  ##### Plot instants on a time axis
2515
2486
 
2516
- With `time` the values are instants: ticks stand on the local clock, labelled by hour, with the date where a day begins.
2487
+ Rows that hold a `Date` put the axis in time; `time` does the same for milliseconds and for ISO strings. Ticks stand on the local clock, labelled by hour, with the date where a day begins.
2517
2488
 
2518
2489
  ```tsx
2519
2490
  import { Tooltip, useChart } from "@umriss-ui/charts";
@@ -2560,7 +2531,7 @@ const SERVICES: readonly Service[] = [
2560
2531
  ];
2561
2532
 
2562
2533
  interface MetricPoint {
2563
- t: number;
2534
+ t: Date;
2564
2535
  /** Median and 95th percentile latency, in ms. */
2565
2536
  p50: number;
2566
2537
  p95: number;
@@ -2591,7 +2562,7 @@ function metrics(serviceId: string): MetricPoint[] {
2591
2562
  const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
2592
2563
  const p95 = Math.round(base * surge * (1 + r() * 0.08));
2593
2564
  points.push({
2594
- t,
2565
+ t: new Date(t),
2595
2566
  p50: Math.round(p95 * (0.42 + r() * 0.06)),
2596
2567
  p95,
2597
2568
  errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
@@ -2607,7 +2578,7 @@ export default function TimeAxis() {
2607
2578
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
2608
2579
  return (
2609
2580
  <Chart height={260} ariaLabel="Checkout's error rate today">
2610
- <XAxis value="t" time />
2581
+ <XAxis value="t" />
2611
2582
  <YAxis label="Errors %" />
2612
2583
  <Line value="errorRate" name="Error rate" />
2613
2584
  <Tooltip mode="x" />
@@ -2933,6 +2904,38 @@ export default function EveryKind() {
2933
2904
  }
2934
2905
  ```
2935
2906
 
2907
+ ##### Fix the order and keep an empty place
2908
+
2909
+ `categories` is the order and the extent: a day without a row keeps its place, and the rows may come as they like. `categoryLabel` writes a name where the rows hold a key.
2910
+
2911
+ ```tsx
2912
+ import { Tooltip, useChart } from "@umriss-ui/charts";
2913
+
2914
+ /* Minutes late per working day, as the tour log sends them: only the days
2915
+ that had a delay, the worst first. */
2916
+ const LATE = [
2917
+ { day: "thu", minutes: 164 },
2918
+ { day: "mon", minutes: 71 },
2919
+ { day: "fri", minutes: 58 },
2920
+ { day: "tue", minutes: 43 },
2921
+ ];
2922
+
2923
+ const WEEK = ["mon", "tue", "wed", "thu", "fri"];
2924
+ const NAMES: Record<string, string> = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday" };
2925
+
2926
+ export default function Categories() {
2927
+ const { Chart, XAxis, YAxis, Bar } = useChart(LATE);
2928
+ return (
2929
+ <Chart height={260} ariaLabel="Minutes late per working day">
2930
+ <XAxis value="day" categories={WEEK} categoryLabel={(day) => NAMES[day] ?? day} />
2931
+ <YAxis label="Minutes late" />
2932
+ <Bar value="minutes" name="Late" />
2933
+ <Tooltip mode="x" />
2934
+ </Chart>
2935
+ );
2936
+ }
2937
+ ```
2938
+
2936
2939
  #### Keyboard
2937
2940
 
2938
2941
  The keys of [Chart](#/chart/keyboard-chart) apply here.
@@ -2943,48 +2946,67 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
2943
2946
 
2944
2947
  | Prop | Type | Default | Description |
2945
2948
  |---|---|---|---|
2946
- | `value` *required* | [`NumberField`](#type-NumberField)`<T> \| ((d: T, index: number) => number)` | — | Where a row lies on this axis: a number field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 68 more |
2947
- | `domain` | `"nice" \| "data" \| readonly [number, number]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](#/zoom-and-pan/gestures-and-keys) and 10 more |
2949
+ | `value` *required* | [`AxisField`](#type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](#type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 70 more |
2950
+ | `domain` | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](#/zoom-and-pan/gestures-and-keys) and 10 more |
2948
2951
  | `position` | `"bottom" \| "top"` | `"bottom"` | 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.<br>Shown in: [Add axes for other magnitudes](#/axis/axes), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) |
2949
- | `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.<br>Shown in: [Plot instants on a time axis](#/axis/time), [Align a second axis to the grid](#/axis/aligned-ticks), [Draw a first chart (Installation)](#/installation/first-chart) and 36 more |
2952
+ | `time` | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. 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 in milliseconds. `calendar` implies it.<br>Shown in: [Align a second axis to the grid](#/axis/aligned-ticks), [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container) and 36 more |
2950
2953
  | `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).<br>Shown in: [Leave out the hours nobody works](#/axis/working-time) |
2951
2954
  | `zoomable` | `boolean` | `false` | Zoom and pan: 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 goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setDomain`). The plain wheel always scrolls the page.<br>Shown in: [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](#/zoom-and-pan/gestures-and-keys), [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) and 5 more |
2952
2955
  | `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent or this axis' own `domain`, whichever is wider, at least three data steps | The narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis.<br>Shown in: [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) |
2953
2956
  | `id` | `string` | `"x"` | Axis id, through which series bind themselves.<br>Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
2954
- | `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 60 more |
2955
- | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 9 more |
2956
- | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 29 more |
2957
+ | `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 62 more |
2958
+ | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 8 more |
2959
+ | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 19 more |
2957
2960
  | `grid` | `boolean` | only on the first axis of its orientation | Grid lines.<br>Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
2958
- | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are places and not numbers: the lanes of a state stack, the categories of a Pareto.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 24 more |
2961
+ | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
2962
+ | `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).<br>Shown in: [Fix the order and keep an empty place](#/axis/categories) |
2963
+ | `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is.<br>Shown in: [Fix the order and keep an empty place](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
2959
2964
 
2960
2965
  ##### `YAxisProps`
2961
2966
 
2962
2967
  | Prop | Type | Default | Description |
2963
2968
  |---|---|---|---|
2964
- | `domain` | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | 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.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 12 more |
2969
+ | `domain` | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | 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.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 10 more |
2965
2970
  | `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.<br>Shown in: [Align a second axis to the grid](#/axis/aligned-ticks) |
2966
2971
  | `position` | `"left" \| "right"` | `"left"` | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable.<br>Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
2967
2972
  | `id` | `string` | `"y"` | Axis id, through which series bind themselves.<br>Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
2968
- | `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 60 more |
2969
- | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 9 more |
2970
- | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 29 more |
2973
+ | `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 62 more |
2974
+ | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 8 more |
2975
+ | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 19 more |
2971
2976
  | `grid` | `boolean` | only on the first axis of its orientation | Grid lines.<br>Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
2972
- | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are places and not numbers: the lanes of a state stack, the categories of a Pareto.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 24 more |
2977
+ | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.<br>Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
2978
+ | `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).<br>Shown in: [Fix the order and keep an empty place](#/axis/categories) |
2979
+ | `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is.<br>Shown in: [Fix the order and keep an empty place](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
2973
2980
 
2974
2981
  ##### Types on this page
2975
2982
 
2976
- ###### `NumberField<T>`
2983
+ ###### `AxisField<T, V = AxisValue>`
2977
2984
 
2978
- The fields of a row whose value is a number, or absent.
2985
+ The fields of a row an axis can read - or, with `V`, those that hold only
2986
+ some of what an axis reads: a matrix' row is a position or a category,
2987
+ never an instant.
2979
2988
 
2980
2989
  ```ts
2981
- type NumberField<T> = NoInfer<
2990
+ type AxisField<T, V = AxisValue> = NoInfer<
2982
2991
  {
2983
- [K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
2992
+ [K in keyof T & string]-?: T[K] extends V ? K : never;
2984
2993
  }[keyof T & string]
2985
2994
  >;
2986
2995
  ```
2987
2996
 
2997
+ ###### `AxisValue`
2998
+
2999
+ What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
3000
+ instant on a time axis -, a string, which is a category, or a `Date`,
3001
+ which is an instant. The type of the value decides what kind the axis is,
3002
+ never its content: `"2024"` is a category, `2024` a number. On an axis
3003
+ that says `time` a string is an ISO 8601 instant. null/undefined is no
3004
+ place.
3005
+
3006
+ ```ts
3007
+ type AxisValue = number | string | Date | null | undefined;
3008
+ ```
3009
+
2988
3010
  ###### `WorkingInterval`
2989
3011
 
2990
3012
  An interval in which time counts; thought of as half-open, but mappable from
@@ -3007,7 +3029,7 @@ schedule's shape.
3007
3029
 
3008
3030
  #### Known limits
3009
3031
 
3010
- - No logarithmic and no category scale: compute the logarithm in `value`, and use positions for categories ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)).
3032
+ - No logarithmic and no band scale: compute the logarithm in `value`; a category is a place on the linear scale ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/), [ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).
3011
3033
 
3012
3034
  What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
3013
3035
 
@@ -3541,19 +3563,21 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
3541
3563
 
3542
3564
  | Prop | Type | Default | Description |
3543
3565
  |---|---|---|---|
3544
- | `value` *required* | [`NumberField`](#type-NumberField)`<T> \| ((d: T, index: number) => number)` | — | Where a row lies on this axis: a number field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 68 more |
3545
- | `domain` | `"nice" \| "data" \| readonly [number, number]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 10 more |
3566
+ | `value` *required* | [`AxisField`](#type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](#type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 70 more |
3567
+ | `domain` | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 10 more |
3546
3568
  | `position` | `"bottom" \| "top"` | `"bottom"` | 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.<br>Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
3547
- | `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.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 36 more |
3569
+ | `time` | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. 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 in milliseconds. `calendar` implies it.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 36 more |
3548
3570
  | `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).<br>Shown in: [Leave out the hours nobody works (Axis)](#/axis/working-time) |
3549
3571
  | `zoomable` | `boolean` | `false` | Zoom and pan: 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 goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setDomain`). The plain wheel always scrolls the page.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 5 more |
3550
3572
  | `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent or this axis' own `domain`, whichever is wider, at least three data steps | The narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis.<br>Shown in: [Limit the zoom](#/zoom-and-pan/zoom-limits) |
3551
3573
  | `id` | `string` | `"x"` | Axis id, through which series bind themselves.<br>Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Align a second axis to the grid (Axis)](#/axis/aligned-ticks), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 7 more |
3552
- | `label` | `string` | — | Axis title.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 60 more |
3553
- | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Keep charts in step](#/zoom-and-pan/cursor-sync), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time) and 9 more |
3554
- | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 29 more |
3574
+ | `label` | `string` | — | Axis title.<br>Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 62 more |
3575
+ | `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Keep charts in step](#/zoom-and-pan/cursor-sync), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time) and 8 more |
3576
+ | `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 19 more |
3555
3577
  | `grid` | `boolean` | only on the first axis of its orientation | Grid lines.<br>Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
3556
- | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are places and not numbers: the lanes of a state stack, the categories of a Pareto.<br>Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 24 more |
3578
+ | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.<br>Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
3579
+ | `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).<br>Shown in: [Fix the order and keep an empty place (Axis)](#/axis/categories) |
3580
+ | `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is.<br>Shown in: [Fix the order and keep an empty place (Axis)](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
3557
3581
 
3558
3582
  ##### `ChartOptions`
3559
3583
 
@@ -3569,18 +3593,33 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
3569
3593
 
3570
3594
  ##### Types on this page
3571
3595
 
3572
- ###### `NumberField<T>`
3596
+ ###### `AxisField<T, V = AxisValue>`
3573
3597
 
3574
- The fields of a row whose value is a number, or absent.
3598
+ The fields of a row an axis can read - or, with `V`, those that hold only
3599
+ some of what an axis reads: a matrix' row is a position or a category,
3600
+ never an instant.
3575
3601
 
3576
3602
  ```ts
3577
- type NumberField<T> = NoInfer<
3603
+ type AxisField<T, V = AxisValue> = NoInfer<
3578
3604
  {
3579
- [K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
3605
+ [K in keyof T & string]-?: T[K] extends V ? K : never;
3580
3606
  }[keyof T & string]
3581
3607
  >;
3582
3608
  ```
3583
3609
 
3610
+ ###### `AxisValue`
3611
+
3612
+ What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
3613
+ instant on a time axis -, a string, which is a category, or a `Date`,
3614
+ which is an instant. The type of the value decides what kind the axis is,
3615
+ never its content: `"2024"` is a category, `2024` a number. On an axis
3616
+ that says `time` a string is an ISO 8601 instant. null/undefined is no
3617
+ place.
3618
+
3619
+ ```ts
3620
+ type AxisValue = number | string | Date | null | undefined;
3621
+ ```
3622
+
3584
3623
  ###### `WorkingInterval`
3585
3624
 
3586
3625
  An interval in which time counts; thought of as half-open, but mappable from
@@ -4294,17 +4333,17 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
4294
4333
 
4295
4334
  | Prop | Type | Default | Description |
4296
4335
  |---|---|---|---|
4297
- | `value` *required* | [`Value`](#type-Value)`<T>` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.<br>Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 44 more |
4336
+ | `value` *required* | [`Value`](#type-Value)`<T>` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.<br>Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 45 more |
4298
4337
  | `xAxisId` | `string` | `"x"` | Binding to an x axis.<br>Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
4299
4338
  | `yAxisId` | `string` | `"y"` | Binding to a y axis.<br>Shown in: [Hold a value until it changes](#/line/step), [Add axes for other magnitudes (Axis)](#/axis/axes), [Align a second axis to the grid (Axis)](#/axis/aligned-ticks) and 4 more |
4300
- | `data` | `readonly T[]` | — | Series-own data; overrides the container data.<br>Shown in: [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour) and 5 more |
4301
- | `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.<br>Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 44 more |
4339
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data.<br>Shown in: [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour) and 6 more |
4340
+ | `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.<br>Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 45 more |
4302
4341
  | `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it.<br>Shown in: [Write each value in its unit (Tooltip)](#/tooltip/value-format), [Show the values as a table (Tooltip)](#/tooltip/data-table), [Table a long series (Tooltip)](#/tooltip/a-week-as-a-table) and 1 more |
4303
4342
  | `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value.<br>Shown in: [Compare progress with a plan](#/line/burn-down), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective) and 1 more |
4304
4343
  | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it.<br>Shown in: [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
4305
4344
  | `strokeWidth` | `number` | `1.5` | Line width in CSS pixels.<br>Shown in: [Hold a value until it changes](#/line/step), [Compare progress with a plan](#/line/burn-down), [Mix series kinds (Chart)](#/chart/mixed) and 9 more |
4306
4345
  | `dash` | `readonly number[]` | a solid line | Dash pattern as a run of lengths in CSS pixels.<br>Shown in: [Several series and a gap](#/line/multi-series), [Compare progress with a plan](#/line/burn-down), [Add axes for other magnitudes (Axis)](#/axis/axes) and 1 more |
4307
- | `markers` | `"auto" \| "always" \| "never"` | `"auto"` | 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.<br>Shown in: [Compare progress with a plan](#/line/burn-down), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [A box per shift, its medians as a line (BoxPlot)](#/boxplot/detailed) and 3 more |
4346
+ | `markers` | `"auto" \| "always" \| "never"` | `"auto"` | 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.<br>Shown in: [Compare progress with a plan](#/line/burn-down), [Draw a series per group of long rows (Chart)](#/chart/series-from-long-rows), [Fix the domain and name the ticks (Axis)](#/axis/configuration) and 4 more |
4308
4347
  | `step` | `boolean` | — | Sample-and-hold: each value holds as a horizontal until the next sample and jumps there - a target, a digital signal. A gap ends the hold at its x. The tooltip reports the sample the hold began with.<br>Shown in: [Hold a value until it changes](#/line/step) |
4309
4348
 
4310
4349
  ##### Types on this page
@@ -4804,7 +4843,7 @@ type ValueFunction<T> = (d: T, index: number) => number | null | undefined;
4804
4843
 
4805
4844
  ### Bar
4806
4845
 
4807
- Bars on the numeric x axis (a bar or column chart): one per category, grouped side by side, stacked, or as shares of a whole.
4846
+ Bars along the x axis (a bar or column chart): one per category, grouped side by side, stacked, or as shares of a whole.
4808
4847
 
4809
4848
  ```ts
4810
4849
  import { useChart } from "@umriss-ui/charts";
@@ -4812,89 +4851,35 @@ import { useChart } from "@umriss-ui/charts";
4812
4851
 
4813
4852
  Demo page: https://romanhaendler.github.io/umriss-ui/charts/bar/
4814
4853
 
4815
- A category is a position the axis places and a `tickFormat` names ([ADR-0002](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0002-bars-on-a-numeric-x-axis.md)). A bar's foot is 0, so one series carries both signs.
4854
+ The x axis reads the category from the row - a cost centre, a weekday - and gives each a place, a tick and its name ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); a number or an instant places a bar as well. A bar's foot is 0, so one series carries both signs.
4816
4855
 
4817
4856
  #### Examples
4818
4857
 
4819
4858
  ##### One bar per category
4820
4859
 
4821
- Categories are positions 0, 1, 2 … on the numeric x axis: `ticks` puts a tick under every bar, `tickFormat` names it ([ADR-0002](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0002-bars-on-a-numeric-x-axis.md)).
4860
+ Give the x axis the field that holds the category: each one gets a place, a tick and its name, in the order of the rows ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).
4822
4861
 
4823
4862
  ```tsx
4824
4863
  import { Tooltip, useChart } from "@umriss-ui/charts";
4825
4864
 
4826
- /* Data from the controlling world, written out here so the example runs on its own. */
4827
-
4828
- /** A small LCG - the same numbers on every computer. */
4829
- function random(seed: number): () => number {
4830
- let s = seed >>> 0;
4831
- return () => {
4832
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
4833
- return s / 4294967296;
4834
- };
4835
- }
4836
-
4837
- interface CostCentre {
4838
- id: string;
4839
- name: string;
4840
- owner: string;
4841
- /** The budget of one month. */
4842
- monthlyBudget: number;
4843
- }
4844
-
4845
- const COST_CENTRES: readonly CostCentre[] = [
4846
- { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
4847
- { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
4848
- { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
4849
- { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
4850
- { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
4851
- { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
4852
- { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
4853
- { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
4854
- { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
4865
+ /* February's spend per cost centre, as the ledger sends it. */
4866
+ const SPEND = [
4867
+ { costCentre: "Sales", actual: 134_447 },
4868
+ { costCentre: "Marketing", actual: 76_588 },
4869
+ { costCentre: "Engineering", actual: 183_157 },
4870
+ { costCentre: "Design", actual: 51_761 },
4871
+ { costCentre: "Customer service", actual: 61_160 },
4872
+ { costCentre: "Finance", actual: 46_353 },
4873
+ { costCentre: "People", actual: 37_886 },
4874
+ { costCentre: "IT", actual: 74_313 },
4875
+ { costCentre: "Facilities", actual: 71_848 },
4855
4876
  ];
4856
4877
 
4857
- /** The year's months, as `"2026-01"` … `"2026-12"`. */
4858
- const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
4859
-
4860
- /** The months whose books are closed: they have an actual. */
4861
- const CLOSED_MONTHS = 2;
4862
-
4863
- interface LedgerRow {
4864
- costCentre: string;
4865
- month: string;
4866
- budget: number;
4867
- /** `null` while the month is open. */
4868
- actual: number | null;
4869
- forecast: number;
4870
- }
4871
-
4872
- /* Where a cost centre lands against its budget, as a factor: Marketing's
4873
- spring campaign overspends, IT's delayed licences underspend. */
4874
- const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
4875
-
4876
- /** Budget, actual and forecast of every cost centre in every month. */
4877
- const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
4878
- const r = random(310 + c);
4879
- const tendency = TENDENCY[centre.id] ?? 1;
4880
- return MONTHS.map((month, m) => {
4881
- /* December pays the bonuses, August is the quiet month. */
4882
- const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
4883
- const budget = Math.round(centre.monthlyBudget * season);
4884
- const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
4885
- const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
4886
- return { costCentre: centre.id, month, budget, actual, forecast };
4887
- });
4888
- });
4889
-
4890
- const FEBRUARY = LEDGER.filter((row) => row.month === "2026-02");
4891
- const place = (row: LedgerRow) => COST_CENTRES.findIndex((c) => c.id === row.costCentre);
4892
-
4893
4878
  export default function PerCategory() {
4894
- const { Chart, XAxis, YAxis, Bar } = useChart(FEBRUARY);
4879
+ const { Chart, XAxis, YAxis, Bar } = useChart(SPEND);
4895
4880
  return (
4896
4881
  <Chart height={260} ariaLabel="Actual spend per cost centre in February">
4897
- <XAxis value={place} ticks={COST_CENTRES.map((_, i) => i)} tickFormat={(v) => COST_CENTRES[v]?.name ?? ""} />
4882
+ <XAxis value="costCentre" />
4898
4883
  <YAxis label="€" />
4899
4884
  <Bar value="actual" name="Actual" />
4900
4885
  <Tooltip mode="x" />
@@ -4910,170 +4895,55 @@ Bar series on one x axis stand beside each other; give each the same `barWidth`,
4910
4895
  ```tsx
4911
4896
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
4912
4897
 
4913
- /* Data from the controlling world, written out here so the example runs on its own. */
4914
-
4915
- /** A small LCG - the same numbers on every computer. */
4916
- function random(seed: number): () => number {
4917
- let s = seed >>> 0;
4918
- return () => {
4919
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
4920
- return s / 4294967296;
4921
- };
4922
- }
4923
-
4924
- interface CostCentre {
4925
- id: string;
4926
- name: string;
4927
- owner: string;
4928
- /** The budget of one month. */
4929
- monthlyBudget: number;
4930
- }
4931
-
4932
- const COST_CENTRES: readonly CostCentre[] = [
4933
- { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
4934
- { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
4935
- { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
4936
- { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
4937
- { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
4938
- { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
4939
- { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
4940
- { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
4941
- { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
4898
+ /* February's budget and spend per cost centre, as the ledger sends them. */
4899
+ const FEBRUARY = [
4900
+ { costCentre: "Sales", budget: 142_000, actual: 134_447 },
4901
+ { costCentre: "Marketing", budget: 68_000, actual: 76_588 },
4902
+ { costCentre: "Engineering", budget: 188_000, actual: 183_157 },
4903
+ { costCentre: "Design", budget: 54_000, actual: 51_761 },
4904
+ { costCentre: "Customer service", budget: 61_000, actual: 61_160 },
4905
+ { costCentre: "Finance", budget: 47_000, actual: 46_353 },
4906
+ { costCentre: "People", budget: 39_000, actual: 37_886 },
4907
+ { costCentre: "IT", budget: 83_000, actual: 74_313 },
4908
+ { costCentre: "Facilities", budget: 72_000, actual: 71_848 },
4942
4909
  ];
4943
4910
 
4944
- /** The year's months, as `"2026-01"` … `"2026-12"`. */
4945
- const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
4946
-
4947
- /** The months whose books are closed: they have an actual. */
4948
- const CLOSED_MONTHS = 2;
4949
-
4950
- interface LedgerRow {
4951
- costCentre: string;
4952
- month: string;
4953
- budget: number;
4954
- /** `null` while the month is open. */
4955
- actual: number | null;
4956
- forecast: number;
4957
- }
4958
-
4959
- /* Where a cost centre lands against its budget, as a factor: Marketing's
4960
- spring campaign overspends, IT's delayed licences underspend. */
4961
- const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
4962
-
4963
- /** Budget, actual and forecast of every cost centre in every month. */
4964
- const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
4965
- const r = random(310 + c);
4966
- const tendency = TENDENCY[centre.id] ?? 1;
4967
- return MONTHS.map((month, m) => {
4968
- /* December pays the bonuses, August is the quiet month. */
4969
- const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
4970
- const budget = Math.round(centre.monthlyBudget * season);
4971
- const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
4972
- const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
4973
- return { costCentre: centre.id, month, budget, actual, forecast };
4974
- });
4975
- });
4976
-
4977
- const FEBRUARY = LEDGER.filter((row) => row.month === "2026-02");
4978
- const place = (row: LedgerRow) => COST_CENTRES.findIndex((c) => c.id === row.costCentre);
4979
-
4980
4911
  export default function Grouped() {
4981
4912
  const { Chart, XAxis, YAxis, Bar } = useChart(FEBRUARY);
4982
4913
  return (
4983
4914
  <Chart height={280} ariaLabel="Budget and actual per cost centre in February">
4984
- <XAxis value={place} ticks={COST_CENTRES.map((_, i) => i)} tickFormat={(v) => COST_CENTRES[v]?.name ?? ""} />
4915
+ <XAxis value="costCentre" />
4985
4916
  <YAxis label="€" />
4986
- {/* A token, so that it follows the colour scheme. */}
4987
- <Bar value="budget" name="Budget" color="var(--uc-color-text)" barWidth={0.7} />
4988
- <Bar value="actual" name="Actual" barWidth={0.7} />
4989
- <Legend placement="top" />
4990
- <Tooltip mode="x" />
4991
- </Chart>
4992
- );
4993
- }
4994
- ```
4995
-
4996
- ##### Show bars above and below zero
4997
-
4998
- A bar's foot is 0, not the bottom of the axis, so one series carries both signs - here the year's forecast against budget.
4999
-
5000
- ```tsx
5001
- import { Tooltip, useChart } from "@umriss-ui/charts";
5002
-
5003
- /* Data from the controlling world, written out here so the example runs on its own. */
5004
-
5005
- /** A small LCG - the same numbers on every computer. */
5006
- function random(seed: number): () => number {
5007
- let s = seed >>> 0;
5008
- return () => {
5009
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
5010
- return s / 4294967296;
5011
- };
5012
- }
5013
-
5014
- interface CostCentre {
5015
- id: string;
5016
- name: string;
5017
- owner: string;
5018
- /** The budget of one month. */
5019
- monthlyBudget: number;
5020
- }
5021
-
5022
- const COST_CENTRES: readonly CostCentre[] = [
5023
- { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
5024
- { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
5025
- { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
5026
- { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
5027
- { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
5028
- { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
5029
- { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
5030
- { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
5031
- { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
5032
- ];
5033
-
5034
- /** The year's months, as `"2026-01"` … `"2026-12"`. */
5035
- const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
5036
-
5037
- /** The months whose books are closed: they have an actual. */
5038
- const CLOSED_MONTHS = 2;
5039
-
5040
- interface LedgerRow {
5041
- costCentre: string;
5042
- month: string;
5043
- budget: number;
5044
- /** `null` while the month is open. */
5045
- actual: number | null;
5046
- forecast: number;
5047
- }
5048
-
5049
- /* Where a cost centre lands against its budget, as a factor: Marketing's
5050
- spring campaign overspends, IT's delayed licences underspend. */
5051
- const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
5052
-
5053
- /** Budget, actual and forecast of every cost centre in every month. */
5054
- const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
5055
- const r = random(310 + c);
5056
- const tendency = TENDENCY[centre.id] ?? 1;
5057
- return MONTHS.map((month, m) => {
5058
- /* December pays the bonuses, August is the quiet month. */
5059
- const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
5060
- const budget = Math.round(centre.monthlyBudget * season);
5061
- const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
5062
- const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
5063
- return { costCentre: centre.id, month, budget, actual, forecast };
5064
- });
5065
- });
5066
-
5067
- interface Deviation {
5068
- place: number;
5069
- euros: number;
4917
+ {/* A token, so that it follows the colour scheme. */}
4918
+ <Bar value="budget" name="Budget" color="var(--uc-color-text)" barWidth={0.7} />
4919
+ <Bar value="actual" name="Actual" barWidth={0.7} />
4920
+ <Legend placement="top" />
4921
+ <Tooltip mode="x" />
4922
+ </Chart>
4923
+ );
5070
4924
  }
4925
+ ```
5071
4926
 
5072
- const DEVIATION: Deviation[] = COST_CENTRES.map((centre, place) => {
5073
- const rows = LEDGER.filter((row) => row.costCentre === centre.id);
5074
- const sum = (pick: (row: (typeof rows)[number]) => number) => rows.reduce((total, row) => total + pick(row), 0);
5075
- return { place, euros: sum((row) => row.forecast) - sum((row) => row.budget) };
5076
- });
4927
+ ##### Show bars above and below zero
4928
+
4929
+ A bar's foot is 0, not the bottom of the axis, so one series carries both signs - here the year's forecast against budget.
4930
+
4931
+ ```tsx
4932
+ import { Tooltip, useChart } from "@umriss-ui/charts";
4933
+
4934
+ /* The year's forecast less its budget per cost centre, in euros - summed
4935
+ where the ledger lives, before the chart. */
4936
+ const DEVIATION = [
4937
+ { costCentre: "Sales", euros: 60 },
4938
+ { costCentre: "Marketing", euros: 118_840 },
4939
+ { costCentre: "Engineering", euros: 1_440 },
4940
+ { costCentre: "Design", euros: 720 },
4941
+ { costCentre: "Customer service", euros: 980 },
4942
+ { costCentre: "Finance", euros: -1_940 },
4943
+ { costCentre: "People", euros: -1_480 },
4944
+ { costCentre: "IT", euros: -112_560 },
4945
+ { costCentre: "Facilities", euros: -2_240 },
4946
+ ];
5077
4947
 
5078
4948
  const signed = (v: number) => (v > 0 ? `+${(v / 1000).toFixed(0)}k` : `${(v / 1000).toFixed(0)}k`);
5079
4949
 
@@ -5081,7 +4951,7 @@ export default function Deviation() {
5081
4951
  const { Chart, XAxis, YAxis, Bar } = useChart(DEVIATION);
5082
4952
  return (
5083
4953
  <Chart height={280} ariaLabel="Forecast against budget for the year, per cost centre">
5084
- <XAxis value="place" ticks={COST_CENTRES.map((_, i) => i)} tickFormat={(v) => COST_CENTRES[v]?.name ?? ""} />
4954
+ <XAxis value="costCentre" />
5085
4955
  <YAxis tickFormat={signed} label="€" />
5086
4956
  <Bar value="euros" name="Over budget" />
5087
4957
  <Tooltip mode="x" />
@@ -5097,48 +4967,28 @@ Bars with the same `stack` stand on the ones before them; a missing part stacks
5097
4967
  ```tsx
5098
4968
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
5099
4969
 
5100
- /* Data from the logistics world, written out here so the example runs on its own. */
5101
-
5102
- /** A small LCG - the same numbers on every computer. */
5103
- function random(seed: number): () => number {
5104
- let s = seed >>> 0;
5105
- return () => {
5106
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
5107
- return s / 4294967296;
5108
- };
5109
- }
5110
-
5111
- interface DayDelays {
5112
- /** The position on the x axis: the working day's index. */
5113
- day: number;
5114
- name: string;
5115
- /** Minutes late across all tours, by cause; `null` where it was not logged. */
5116
- traffic: number;
5117
- loading: number | null;
5118
- access: number;
5119
- }
5120
-
5121
- /** Two working weeks, 2 to 13 March. On the first Wednesday the loading log
5122
- was not kept - a gap, not a zero - and on the second Thursday a closed
5123
- bridge takes the day. */
5124
- const DELAYS: readonly DayDelays[] = (() => {
5125
- const r = random(1204);
5126
- const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
5127
- return Array.from({ length: 10 }, (_, day) => ({
5128
- day,
5129
- name: `${names[day % 5]} ${2 + day + 2 * Math.floor(day / 5)}`,
5130
- traffic: Math.round((day === 8 ? 150 : 40) + r() * 30),
5131
- loading: day === 2 ? null : Math.round(10 + r() * 45),
5132
- access: Math.round(5 + r() * 25),
5133
- }));
5134
- })();
4970
+ /* Minutes late across all tours per working day, by cause - two weeks, 2 to
4971
+ 13 March. On the first Wednesday the loading log was not kept: a gap, not
4972
+ a zero. On the second Thursday a closed bridge takes the day. */
4973
+ const DELAYS = [
4974
+ { day: "Mon 2", traffic: 61, loading: 32, access: 10 },
4975
+ { day: "Tue 3", traffic: 47, loading: 46, access: 27 },
4976
+ { day: "Wed 4", traffic: 49, loading: null, access: 22 },
4977
+ { day: "Thu 5", traffic: 40, loading: 44, access: 7 },
4978
+ { day: "Fri 6", traffic: 48, loading: 44, access: 27 },
4979
+ { day: "Mon 9", traffic: 67, loading: 41, access: 8 },
4980
+ { day: "Tue 10", traffic: 58, loading: 50, access: 13 },
4981
+ { day: "Wed 11", traffic: 58, loading: 18, access: 14 },
4982
+ { day: "Thu 12", traffic: 168, loading: 26, access: 10 },
4983
+ { day: "Fri 13", traffic: 57, loading: 18, access: 18 },
4984
+ ];
5135
4985
 
5136
4986
  export default function Stacked() {
5137
4987
  const { Chart, XAxis, YAxis, Bar } = useChart(DELAYS);
5138
4988
 
5139
4989
  return (
5140
4990
  <Chart height={280} ariaLabel="Minutes late per working day, by cause">
5141
- <XAxis value="day" ticks={DELAYS.map((d) => d.day)} tickFormat={(v) => DELAYS[v]?.name ?? ""} />
4991
+ <XAxis value="day" />
5142
4992
  <YAxis label="Minutes late" />
5143
4993
  <Bar value="traffic" name="Traffic" stack="delay" />
5144
4994
  <Bar value="loading" name="Loading" stack="delay" />
@@ -5157,41 +5007,21 @@ export default function Stacked() {
5157
5007
  ```tsx
5158
5008
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
5159
5009
 
5160
- /* Data from the logistics world, written out here so the example runs on its own. */
5161
-
5162
- /** A small LCG - the same numbers on every computer. */
5163
- function random(seed: number): () => number {
5164
- let s = seed >>> 0;
5165
- return () => {
5166
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
5167
- return s / 4294967296;
5168
- };
5169
- }
5170
-
5171
- interface DayDelays {
5172
- /** The position on the x axis: the working day's index. */
5173
- day: number;
5174
- name: string;
5175
- /** Minutes late across all tours, by cause; `null` where it was not logged. */
5176
- traffic: number;
5177
- loading: number | null;
5178
- access: number;
5179
- }
5180
-
5181
- /** Two working weeks, 2 to 13 March. On the first Wednesday the loading log
5182
- was not kept - a gap, not a zero - and on the second Thursday a closed
5183
- bridge takes the day. */
5184
- const DELAYS: readonly DayDelays[] = (() => {
5185
- const r = random(1204);
5186
- const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
5187
- return Array.from({ length: 10 }, (_, day) => ({
5188
- day,
5189
- name: `${names[day % 5]} ${2 + day + 2 * Math.floor(day / 5)}`,
5190
- traffic: Math.round((day === 8 ? 150 : 40) + r() * 30),
5191
- loading: day === 2 ? null : Math.round(10 + r() * 45),
5192
- access: Math.round(5 + r() * 25),
5193
- }));
5194
- })();
5010
+ /* Minutes late across all tours per working day, by cause - two weeks, 2 to
5011
+ 13 March. On the first Wednesday the loading log was not kept: a gap, not
5012
+ a zero. On the second Thursday a closed bridge takes the day. */
5013
+ const DELAYS = [
5014
+ { day: "Mon 2", traffic: 61, loading: 32, access: 10 },
5015
+ { day: "Tue 3", traffic: 47, loading: 46, access: 27 },
5016
+ { day: "Wed 4", traffic: 49, loading: null, access: 22 },
5017
+ { day: "Thu 5", traffic: 40, loading: 44, access: 7 },
5018
+ { day: "Fri 6", traffic: 48, loading: 44, access: 27 },
5019
+ { day: "Mon 9", traffic: 67, loading: 41, access: 8 },
5020
+ { day: "Tue 10", traffic: 58, loading: 50, access: 13 },
5021
+ { day: "Wed 11", traffic: 58, loading: 18, access: 14 },
5022
+ { day: "Thu 12", traffic: 168, loading: 26, access: 10 },
5023
+ { day: "Fri 13", traffic: 57, loading: 18, access: 18 },
5024
+ ];
5195
5025
 
5196
5026
  const minutes = (v: number) => `${v} min`;
5197
5027
 
@@ -5199,7 +5029,7 @@ export default function Percent() {
5199
5029
  const { Chart, XAxis, YAxis, Bar } = useChart(DELAYS);
5200
5030
  return (
5201
5031
  <Chart height={280} ariaLabel="Share of each cause in the minutes late per working day">
5202
- <XAxis value="day" ticks={DELAYS.map((d) => d.day)} tickFormat={(v) => DELAYS[v]?.name ?? ""} />
5032
+ <XAxis value="day" />
5203
5033
  <YAxis />
5204
5034
  <Bar value="traffic" name="Traffic" stack="delay" normalize format={minutes} />
5205
5035
  <Bar value="loading" name="Loading" stack="delay" normalize format={minutes} />
@@ -5225,11 +5055,11 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
5225
5055
 
5226
5056
  | Prop | Type | Default | Description |
5227
5057
  |---|---|---|---|
5228
- | `value` *required* | [`Value`](#type-Value)`<T>` | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.<br>Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 10 more |
5058
+ | `value` *required* | [`Value`](#type-Value)`<T>` | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.<br>Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 11 more |
5229
5059
  | `xAxisId` | `string` | `"x"` | Binding to an x axis.<br>Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
5230
5060
  | `yAxisId` | `string` | `"y"` | Binding to a y axis.<br>Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
5231
5061
  | `data` | `readonly T[]` | — | Series-own data; overrides the container data.<br>Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
5232
- | `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.<br>Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 10 more |
5062
+ | `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.<br>Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 11 more |
5233
5063
  | `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it.<br>Shown in: [Show shares of a whole](#/bar/percent), [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
5234
5064
  | `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value.<br>Shown in: [Group bars side by side](#/bar/grouped), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle), [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
5235
5065
  | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it.<br>Shown in: [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
@@ -5595,13 +5425,13 @@ Demo page: https://romanhaendler.github.io/umriss-ui/charts/boxplot/
5595
5425
 
5596
5426
  Every number is the caller's: `median`, the two quartiles and where each whisker ends. The library computes none of them and names no quartile method or whisker rule, so a box aggregated in a database draws as well as one computed in the browser.
5597
5427
 
5598
- A box stands on the numeric x axis as a bar does: a group is a position a `tickFormat` names, and a time axis carries a box per hour ([ADR-0002](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0002-bars-on-a-numeric-x-axis.md)). A missing median is a gap - no box is drawn there.
5428
+ A box stands on the x axis as a bar does: a group is a category the axis reads from the row, and a time axis carries a box per hour ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). A missing median is a gap - no box is drawn there.
5599
5429
 
5600
5430
  #### Examples
5601
5431
 
5602
5432
  ##### One box per service
5603
5433
 
5604
- Five numbers per row, computed wherever the caller likes. Services are positions 0, 1, 2 on the numeric x axis; `tickFormat` names them.
5434
+ Five numbers per row, computed wherever the caller likes. The x axis reads the service's name: each one is a category with a place and a tick of its own.
5605
5435
 
5606
5436
  ```tsx
5607
5437
  import { Tooltip, useChart } from "@umriss-ui/charts";
@@ -5626,7 +5456,7 @@ export default function Minimal() {
5626
5456
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
5627
5457
  return (
5628
5458
  <Chart height={260} ariaLabel="Response time per service over the last hour">
5629
- <XAxis value={(_, i) => i} ticks={[0, 1, 2]} tickFormat={(v) => RESPONSE_TIMES[v]?.service ?? ""} />
5459
+ <XAxis value="service" />
5630
5460
  <YAxis tickFormat={(v) => `${v} ms`} />
5631
5461
  <BoxPlot
5632
5462
  name="Response time"
@@ -5818,7 +5648,7 @@ export default function WithOutliers() {
5818
5648
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
5819
5649
  return (
5820
5650
  <Chart height={300} ariaLabel="Response time per service over the last hour, with outliers">
5821
- <XAxis value={(_, i) => i} ticks={[0, 1, 2, 3]} tickFormat={(v) => RESPONSE_TIMES[v]?.service ?? ""} />
5651
+ <XAxis value="service" />
5822
5652
  <YAxis tickFormat={(v) => `${v} ms`} />
5823
5653
  <BoxPlot
5824
5654
  name="Response time"
@@ -5866,7 +5696,7 @@ export default function MeanAndNotches() {
5866
5696
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(BATCHES);
5867
5697
  return (
5868
5698
  <Chart height={300} ariaLabel="Breaking strength per clay supplier, with mean and notches">
5869
- <XAxis value={(_, i) => i} ticks={[0, 1, 2]} tickFormat={(v) => BATCHES[v]?.supplier ?? ""} />
5699
+ <XAxis value="supplier" />
5870
5700
  <YAxis tickFormat={(v) => `${v} N`} />
5871
5701
  <BoxPlot
5872
5702
  name="Breaking strength"
@@ -5898,7 +5728,7 @@ Box series on one x axis stand beside each other, as bars do. A click in the leg
5898
5728
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
5899
5729
 
5900
5730
  interface Spread {
5901
- service: number;
5731
+ service: string;
5902
5732
  low: number;
5903
5733
  q1: number;
5904
5734
  median: number;
@@ -5906,27 +5736,25 @@ interface Spread {
5906
5736
  high: number;
5907
5737
  }
5908
5738
 
5909
- const SERVICES = ["Checkout", "Billing", "Search", "Images"];
5910
-
5911
5739
  /* Milliseconds per request, the week before and the week after the release. */
5912
5740
  const BEFORE: Spread[] = [
5913
- { service: 0, low: 112, q1: 138, median: 149, q3: 161, high: 195 },
5914
- { service: 1, low: 85, q1: 121, median: 140, q3: 173, high: 238 },
5915
- { service: 2, low: 140, q1: 152, median: 158, q3: 165, high: 181 },
5916
- { service: 3, low: 99, q1: 126, median: 137, q3: 150, high: 186 },
5741
+ { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
5742
+ { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
5743
+ { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
5744
+ { service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186 },
5917
5745
  ];
5918
5746
  const AFTER: Spread[] = [
5919
- { service: 0, low: 118, q1: 135, median: 143, q3: 152, high: 176 },
5920
- { service: 1, low: 101, q1: 119, median: 128, q3: 139, high: 164 },
5921
- { service: 2, low: 142, q1: 151, median: 157, q3: 164, high: 179 },
5922
- { service: 3, low: 103, q1: 124, median: 135, q3: 146, high: 172 },
5747
+ { service: "Checkout", low: 118, q1: 135, median: 143, q3: 152, high: 176 },
5748
+ { service: "Billing", low: 101, q1: 119, median: 128, q3: 139, high: 164 },
5749
+ { service: "Search", low: 142, q1: 151, median: 157, q3: 164, high: 179 },
5750
+ { service: "Images", low: 103, q1: 124, median: 135, q3: 146, high: 172 },
5923
5751
  ];
5924
5752
 
5925
5753
  export default function Grouped() {
5926
5754
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(BEFORE);
5927
5755
  return (
5928
5756
  <Chart height={280} ariaLabel="Response time per service before and after the release">
5929
- <XAxis value="service" ticks={[0, 1, 2, 3]} tickFormat={(v) => SERVICES[v] ?? ""} />
5757
+ <XAxis value="service" />
5930
5758
  <YAxis tickFormat={(v) => `${v} ms`} />
5931
5759
  {[
5932
5760
  { name: "Before", data: BEFORE },
@@ -5958,7 +5786,7 @@ A `LimitLine` shows what was promised. Whether a box breaks it is your judgement
5958
5786
  import { Legend, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
5959
5787
 
5960
5788
  interface Spread {
5961
- region: number;
5789
+ region: string;
5962
5790
  low: number;
5963
5791
  q1: number;
5964
5792
  median: number;
@@ -5966,25 +5794,23 @@ interface Spread {
5966
5794
  high: number;
5967
5795
  }
5968
5796
 
5969
- const REGIONS = ["Europe", "Americas", "Asia"];
5970
-
5971
5797
  /* Checkout's 95th percentile per five minutes, in ms, per region and week. */
5972
5798
  const LAST_WEEK: Spread[] = [
5973
- { region: 0, low: 182, q1: 204, median: 216, q3: 229, high: 262 },
5974
- { region: 1, low: 191, q1: 213, median: 226, q3: 240, high: 276 },
5975
- { region: 2, low: 176, q1: 199, median: 211, q3: 224, high: 258 },
5799
+ { region: "Europe", low: 182, q1: 204, median: 216, q3: 229, high: 262 },
5800
+ { region: "Americas", low: 191, q1: 213, median: 226, q3: 240, high: 276 },
5801
+ { region: "Asia", low: 176, q1: 199, median: 211, q3: 224, high: 258 },
5976
5802
  ];
5977
5803
  const THIS_WEEK: Spread[] = [
5978
- { region: 0, low: 196, q1: 231, median: 249, q3: 268, high: 318 },
5979
- { region: 1, low: 205, q1: 242, median: 262, q3: 283, high: 339 },
5980
- { region: 2, low: 190, q1: 226, median: 244, q3: 263, high: 311 },
5804
+ { region: "Europe", low: 196, q1: 231, median: 249, q3: 268, high: 318 },
5805
+ { region: "Americas", low: 205, q1: 242, median: 262, q3: 283, high: 339 },
5806
+ { region: "Asia", low: 190, q1: 226, median: 244, q3: 263, high: 311 },
5981
5807
  ];
5982
5808
 
5983
5809
  export default function AgainstTheObjective() {
5984
5810
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(LAST_WEEK);
5985
5811
  return (
5986
5812
  <Chart height={280} ariaLabel="Checkout 95th percentile per region and week against its objective">
5987
- <XAxis value="region" ticks={[0, 1, 2]} tickFormat={(v) => REGIONS[v] ?? ""} />
5813
+ <XAxis value="region" />
5988
5814
  <YAxis tickFormat={(v) => `${v} ms`} />
5989
5815
  <BoxPlot
5990
5816
  name="Last week"
@@ -6019,167 +5845,17 @@ The tiles measured in each of six shifts, one box each, and a `Line` through the
6019
5845
  ```tsx
6020
5846
  import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";
6021
5847
 
6022
- /* Data from the plant world, written out here so the example runs on its own. */
6023
-
6024
- /** A small LCG - reproducible across runs and platforms. */
6025
- function random(seed: number): () => number {
6026
- let s = seed >>> 0;
6027
- return () => {
6028
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
6029
- return s / 4294967296;
6030
- };
6031
- }
6032
-
6033
- /** The early shift, 06:00 to 14:00. */
6034
- const SHIFT_MINUTES = 480;
6035
-
6036
- /** The kiln's zone 3, in °C: the numbers every part reads it against, once.
6037
- Between the two warnings lies the tolerance - a tile fired outside it is
6038
- scrap; `returnTo` is the alarm's dead band. */
6039
- const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
6040
-
6041
- /** The kiln fires one tile every quarter of a minute when nothing holds it up -
6042
- the ideal cycle time of the OEE's performance. */
6043
- const IDEAL_CYCLE_MINUTES = 0.25;
6044
-
6045
- /** One minute of the line. */
6046
- interface Reading {
6047
- /** Minutes since the start of the shift. */
6048
- minute: number;
6049
- /** Zone 3 of the kiln, in °C. */
6050
- kiln: number;
6051
- /** The exit pyrometer, in °C - `null` while it sends nothing. */
6052
- exit: number | null;
6053
- /** Whether the belt runs. */
6054
- running: boolean;
6055
- /** Tiles out of the kiln in this minute. */
6056
- fired: number;
6057
- /** Of them, the ones fired inside the tolerance. */
6058
- good: number;
6059
- }
6060
-
6061
- /** A tile taken off the belt and measured - one every ten minutes while it runs. */
6062
- interface Sample {
6063
- minute: number;
6064
- /** The tile's length after firing, in mm (nominal 600). */
6065
- length: number;
6066
- }
6067
-
6068
- /** A batch of tiles through the three stations. */
6069
- interface Batch {
6070
- id: string;
6071
- name: string;
6072
- /** How many tiles the batch is planned for. */
6073
- tiles: number;
6074
- /** Minutes since the start of the shift, per station. */
6075
- press: readonly [number, number];
6076
- dryer: readonly [number, number];
6077
- kiln: readonly [number, number];
6078
- }
6079
-
6080
- interface Plant {
6081
- readings: readonly Reading[];
6082
- samples: readonly Sample[];
6083
- batches: readonly Batch[];
6084
- }
6085
-
6086
- /* When the exit pyrometer goes silent, and for how long. Fixed rather than
6087
- seeded, so that a running control room shows its reading turn stale, then
6088
- lost, and come back. */
6089
- const SILENT_FROM = 288;
6090
-
6091
- const SILENT_FOR = 50;
6092
-
6093
- /** The shift, from a seed. */
6094
- function plant(seed: number): Plant {
6095
- const r = random(seed);
6096
-
6097
- /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
6098
- into the shift, for half an hour, peaking near 1242 °C. The operator stops
6099
- the feed twenty-two minutes in, for eighteen minutes. The noise around it
6100
- is too small to reach a warning on its own, so this is the only crossing -
6101
- which is what the tests hold. */
6102
- const surgeFrom = 150 + Math.floor(r() * 60);
6103
- const surgeFor = 30;
6104
- const stopFrom = surgeFrom + 22;
6105
- const stopFor = 18;
6106
-
6107
- /* The plan: batches through the kiln one after another, each pressed an
6108
- hour and dried half an hour before - the first ones during the night
6109
- shift. Tiles come out of the kiln only while a batch is in it, so the
6110
- OEE's count and the batches' counts are the same tiles. The plan is a
6111
- plan: a stop of the belt does not move it. */
6112
- const batches: Batch[] = [];
6113
- for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
6114
- const length = 70 + Math.floor(r() * 25);
6115
- batches.push({
6116
- id: `b-${4121 + i}`,
6117
- name: `B-${4121 + i}`,
6118
- tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
6119
- press: [at - 60, at - 60 + length],
6120
- dryer: [at - 30, at - 30 + length],
6121
- kiln: [at, at + length],
6122
- });
6123
- at += length + 5;
6124
- }
6125
-
6126
- const readings: Reading[] = [];
6127
- const samples: Sample[] = [];
6128
- let drift = 0;
6129
- for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
6130
- drift = 0.9 * drift + (r() - 0.5) * 3;
6131
- const into = minute - surgeFrom;
6132
- const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
6133
- const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
6134
-
6135
- const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
6136
- const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
6137
- const jam = r() < 0.12;
6138
- const fired = running && loaded ? (jam ? 3 : 4) : 0;
6139
- const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
6140
- const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
6141
- const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
6142
-
6143
- readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
6144
-
6145
- if (running && minute % 10 === 0) {
6146
- /* Hotter shrinks more: the length follows the kiln, so the control
6147
- chart sees the excursion the trend sees. */
6148
- const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
6149
- samples.push({ minute, length: Math.round(length * 100) / 100 });
6150
- }
6151
- }
6152
-
6153
- return { readings, samples, batches };
6154
- }
6155
-
6156
- const SHIFTS = ["Mon early", "Mon late", "Tue early", "Tue late", "Wed early", "Wed late"];
6157
-
6158
- interface ShiftBox {
6159
- shift: number;
6160
- low: number;
6161
- q1: number;
6162
- median: number;
6163
- q3: number;
6164
- high: number;
6165
- }
6166
-
6167
- /** The value at share p of the sorted values, between neighbours. */
6168
- function quantile(sorted: readonly number[], p: number): number {
6169
- const at = (sorted.length - 1) * p;
6170
- const lo = Math.floor(at);
6171
- return (sorted[lo] as number) + ((sorted[Math.ceil(at)] as number) - (sorted[lo] as number)) * (at - lo);
6172
- }
6173
-
6174
- /* One shift per seed; the whiskers end at the last length within 1.5 IQR. */
6175
- const BOXES: ShiftBox[] = SHIFTS.map((_, shift) => {
6176
- const sorted = plant(shift + 1).samples.map((s) => s.length).sort((a, b) => a - b);
6177
- const q1 = quantile(sorted, 0.25);
6178
- const q3 = quantile(sorted, 0.75);
6179
- const reach = 1.5 * (q3 - q1);
6180
- const inside = sorted.filter((v) => v >= q1 - reach && v <= q3 + reach);
6181
- return { shift, low: inside[0] as number, q1, median: quantile(sorted, 0.5), q3, high: inside[inside.length - 1] as number };
6182
- });
5848
+ /* Data from the plant world: tile length per shift in millimetres, as the
5849
+ quality database sends it - quartiles and median, the whiskers at the last
5850
+ length within 1.5 IQR. */
5851
+ const BOXES = [
5852
+ { shift: "Mon early", low: 599.72, q1: 599.855, median: 599.97, q3: 600.1575, high: 600.4 },
5853
+ { shift: "Mon late", low: 599.53, q1: 599.875, median: 600.025, q3: 600.15, high: 600.38 },
5854
+ { shift: "Tue early", low: 599.6, q1: 599.88, median: 600.015, q3: 600.1925, high: 600.39 },
5855
+ { shift: "Tue late", low: 599.59, q1: 599.89, median: 599.975, q3: 600.1475, high: 600.36 },
5856
+ { shift: "Wed early", low: 599.61, q1: 599.8175, median: 600.01, q3: 600.1775, high: 600.45 },
5857
+ { shift: "Wed late", low: 599.68, q1: 599.8575, median: 600.035, q3: 600.16, high: 600.53 },
5858
+ ];
6183
5859
 
6184
5860
  const mm = (v: number) => `${v.toFixed(2)} mm`;
6185
5861
 
@@ -6187,7 +5863,7 @@ export default function Detailed() {
6187
5863
  const { Chart, XAxis, YAxis, BoxPlot, Line } = useChart(BOXES);
6188
5864
  return (
6189
5865
  <Chart height={320} ariaLabel="Tile length per shift, with the medians as a line">
6190
- <XAxis value="shift" ticks={SHIFTS.map((_, i) => i)} tickFormat={(v) => SHIFTS[v] ?? ""} label="Shift" />
5866
+ <XAxis value="shift" label="Shift" />
6191
5867
  <YAxis tickFormat={mm} />
6192
5868
  <BoxPlot
6193
5869
  name="Tile length"
@@ -6319,7 +5995,7 @@ Demo page: https://romanhaendler.github.io/umriss-ui/charts/stateband/
6319
5995
 
6320
5996
  `value` gives a state's place in `states`, which fixes the order, the name and the colour in one place ([ADR-0007](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0007-a-state-is-a-number.md)). Each segment runs to the next reading; a `null` leaves a hole, never a colour for unknown.
6321
5997
 
6322
- Several bands share one axis as lanes: a y axis with one unit per lane, and `laneFrom` and `laneTo` on each band.
5998
+ Several bands share one axis as lanes: each names its `lane`, and the y axis lists one per name, the first at the top ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). On a numeric y axis `laneFrom` and `laneTo` give a lane its edges instead.
6323
5999
 
6324
6000
  #### Examples
6325
6001
 
@@ -6703,7 +6379,7 @@ export default function UnderACourse() {
6703
6379
 
6704
6380
  ##### Give each one a lane
6705
6381
 
6706
- A y axis with one unit per vehicle and a band per lane: the legend explains every state once, however many lanes share them.
6382
+ Each band names its `lane`, and the y axis lists one per name, the first at the top: the legend explains every state once, however many lanes share them.
6707
6383
 
6708
6384
  ```tsx
6709
6385
  import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
@@ -6869,23 +6545,9 @@ export default function OneLaneEach() {
6869
6545
  return (
6870
6546
  <Chart height={300} ariaLabel="The states of all eight vehicles through the day">
6871
6547
  <XAxis value="t" time label="Time" />
6872
- {/* Lane 0 lies at the bottom, so the first vehicle takes the top lane. */}
6873
- <YAxis
6874
- domain={[0, FLEET.length]}
6875
- ticks={FLEET.map((_, i) => i + 0.45)}
6876
- tickFormat={(v) => FLEET[FLEET.length - 1 - Math.floor(v)]?.vehicle.plate ?? ""}
6877
- grid={false}
6878
- />
6879
- {FLEET.map(({ vehicle, day }, i) => (
6880
- <StateBand
6881
- key={vehicle.id}
6882
- data={day}
6883
- value="state"
6884
- states={VEHICLE_STATES}
6885
- laneFrom={FLEET.length - 1 - i}
6886
- laneTo={FLEET.length - 1 - i + 0.9}
6887
- name={vehicle.plate}
6888
- />
6548
+ <YAxis grid={false} />
6549
+ {FLEET.map(({ vehicle, day }) => (
6550
+ <StateBand key={vehicle.id} data={day} value="state" states={VEHICLE_STATES} lane={vehicle.plate} name={vehicle.plate} />
6889
6551
  ))}
6890
6552
  <Legend placement="top" />
6891
6553
  <Tooltip mode="x" />
@@ -6910,12 +6572,13 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
6910
6572
  |---|---|---|---|
6911
6573
  | `value` *required* | [`Value`](#type-Value)`<T>` | — | Index of the state in `states` - a number field of the row or a function of it; 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.<br>Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 3 more |
6912
6574
  | `states` *required* | `readonly `[`StateEntry`](#type-StateEntry)`[]` | — | The closed set of states, in the order of their codes.<br>Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 3 more |
6913
- | `xAxisId` | `string` | `"x"` | Binding to an x axis. |
6914
- | `yAxisId` | `string` | `"y"` | Binding to a y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Read limits above the states (LimitLine)](#/limitline/limits-and-state) |
6575
+ | `xAxisId` | `string` | — | Binding to an x axis. |
6576
+ | `yAxisId` | `string` | — | Binding to a y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Read limits above the states (LimitLine)](#/limitline/limits-and-state) |
6915
6577
  | `data` | `readonly T[]` | — | Series-own data; overrides the container data.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 2 more |
6916
6578
  | `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.<br>Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 3 more |
6917
- | `laneFrom` | `number` | — | Lower edge of the lane in domain units of the y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 2 more |
6918
- | `laneTo` | `number` | — | Upper edge of the lane in domain units of the y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 2 more |
6579
+ | `lane` | `string` | — | The lane by its name: a category of the y axis, whose place the band takes, as wide as a bar is. The y axis then lists a lane per name, the first at the top, in the bands' order or its own `categories` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Not together with `laneFrom` or `laneTo`.<br>Shown in: [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Read limits above the states (LimitLine)](#/limitline/limits-and-state) and 1 more |
6580
+ | `laneFrom` | `number` | the beginning of the axis' domain | Lower edge of the lane in domain units of a numeric y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course) |
6581
+ | `laneTo` | `number` | the end of the axis' domain | Upper edge of the lane in domain units of a numeric y axis.<br>Shown in: [Lay a state under a course](#/stateband/under-a-course) |
6919
6582
 
6920
6583
  ##### Types on this page
6921
6584
 
@@ -6971,13 +6634,13 @@ import { useChart, DEFAULT_GRADIENT } from "@umriss-ui/charts";
6971
6634
 
6972
6635
  Demo page: https://romanhaendler.github.io/umriss-ui/charts/matrix/
6973
6636
 
6974
- `value` places a cell's row, as it places every series along y; `level` decides its colour. A cell without a level stays a hole, not a zero. The tooltip carries the level as text, since colour alone carries no number.
6637
+ `value` places a cell's row, as it places every series along y - a string names the row, and the y axis lists one per name, the first at the top ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); `level` decides its colour. A cell without a level stays a hole, not a zero. The tooltip carries the level as text, since colour alone carries no number.
6975
6638
 
6976
6639
  #### Examples
6977
6640
 
6978
6641
  ##### Colour cells by their limits
6979
6642
 
6980
- With `coloring` of kind `assessment` a cell takes the tone its limits give it: a bad hour everywhere reads differently from one bad service. `format` writes the value in the tooltip, while the y axis writes the row.
6643
+ With `coloring` of kind `assessment` a cell takes the tone its limits give it: a bad hour everywhere reads differently from one bad service. `value` names the row - a string is a category, and the y axis lists one per service, the first at the top; `format` writes the level in the tooltip.
6981
6644
 
6982
6645
  ```tsx
6983
6646
  import { Tooltip, useChart } from "@umriss-ui/charts";
@@ -7017,8 +6680,8 @@ const SERVICES: readonly Service[] = [
7017
6680
 
7018
6681
  interface SuccessCell {
7019
6682
  hour: number;
7020
- /** The row: the service's place in `SERVICES`. */
7021
- service: number;
6683
+ /** The row: the service's name. */
6684
+ service: string;
7022
6685
  /** Requests that succeeded, in per cent; `null` where there were none. */
7023
6686
  success: number | null;
7024
6687
  }
@@ -7029,13 +6692,13 @@ interface SuccessCell {
7029
6692
  const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
7030
6693
  const r = random(23);
7031
6694
  const cells: SuccessCell[] = [];
7032
- SERVICES.forEach((one, service) => {
6695
+ SERVICES.forEach((one) => {
7033
6696
  for (let hour = 0; hour < 24; hour++) {
7034
6697
  let success: number | null = 99.2 + r() * 0.8;
7035
6698
  if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
7036
6699
  if (hour === 3) success = 97.4 + r() * 1;
7037
6700
  if (one.id === "reports" && hour < 6) success = null;
7038
- cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
6701
+ cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
7039
6702
  }
7040
6703
  });
7041
6704
  return cells;
@@ -7046,7 +6709,7 @@ export default function ByLimits() {
7046
6709
  return (
7047
6710
  <Chart height={280} ariaLabel="Successful requests per service and hour yesterday, by limits">
7048
6711
  <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
7049
- <YAxis ticks={SERVICES.map((_, i) => i)} tickFormat={(v) => SERVICES[v]?.name ?? ""} />
6712
+ <YAxis />
7050
6713
  <Matrix
7051
6714
  value="service"
7052
6715
  level="success"
@@ -7110,8 +6773,8 @@ const SERVICES: readonly Service[] = [
7110
6773
 
7111
6774
  interface SuccessCell {
7112
6775
  hour: number;
7113
- /** The row: the service's place in `SERVICES`. */
7114
- service: number;
6776
+ /** The row: the service's name. */
6777
+ service: string;
7115
6778
  /** Requests that succeeded, in per cent; `null` where there were none. */
7116
6779
  success: number | null;
7117
6780
  }
@@ -7122,13 +6785,13 @@ interface SuccessCell {
7122
6785
  const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
7123
6786
  const r = random(23);
7124
6787
  const cells: SuccessCell[] = [];
7125
- SERVICES.forEach((one, service) => {
6788
+ SERVICES.forEach((one) => {
7126
6789
  for (let hour = 0; hour < 24; hour++) {
7127
6790
  let success: number | null = 99.2 + r() * 0.8;
7128
6791
  if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
7129
6792
  if (hour === 3) success = 97.4 + r() * 1;
7130
6793
  if (one.id === "reports" && hour < 6) success = null;
7131
- cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
6794
+ cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
7132
6795
  }
7133
6796
  });
7134
6797
  return cells;
@@ -7139,7 +6802,7 @@ export default function Gradient() {
7139
6802
  return (
7140
6803
  <Chart height={280} ariaLabel="Successful requests per service and hour yesterday, as a gradient">
7141
6804
  <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
7142
- <YAxis ticks={SERVICES.map((_, i) => i)} tickFormat={(v) => SERVICES[v]?.name ?? ""} />
6805
+ <YAxis />
7143
6806
  <Matrix value="service" level="success" name="Success rate" />
7144
6807
  <Tooltip mode="nearest" />
7145
6808
  </Chart>
@@ -7157,7 +6820,7 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
7157
6820
 
7158
6821
  | Prop | Type | Default | Description |
7159
6822
  |---|---|---|---|
7160
- | `value` *required* | [`Value`](#type-Value)`<T>` | — | Row position on the y axis - a number field of the row or a function of it, as every series' y position.<br>Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
6823
+ | `value` *required* | [`AxisField`](#type-AxisField)`<T, number \| string \| null \| undefined> \| ((d: T, index: number) => number \| string \| null \| undefined)` | — | The row on the y axis - a field of the data's row or a function of it. A number is a position, as every series' y is; a string is a category: the y axis then has a named row per distinct one, the first at the top, in the order the data bring them or the y axis' `categories` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Never an instant: a `Date` is refused.<br>Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
7161
6824
  | `level` *required* | [`Value`](#type-Value)`<T>` | — | The level that decides the colour - a number field of the row or a function of it. A missing level is a hole, not a zero.<br>Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
7162
6825
  | `coloring` | [`MatrixColoring`](#type-MatrixColoring) | `{ kind: "gradient", stops: DEFAULT_GRADIENT }` | How the level colours a cell: by assessment against limits, or across a gradient.<br>Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
7163
6826
  | `xAxisId` | `string` | `"x"` | Binding to an x axis. |
@@ -7168,6 +6831,20 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
7168
6831
 
7169
6832
  ##### Types on this page
7170
6833
 
6834
+ ###### `AxisField<T, V = AxisValue>`
6835
+
6836
+ The fields of a row an axis can read - or, with `V`, those that hold only
6837
+ some of what an axis reads: a matrix' row is a position or a category,
6838
+ never an instant.
6839
+
6840
+ ```ts
6841
+ type AxisField<T, V = AxisValue> = NoInfer<
6842
+ {
6843
+ [K in keyof T & string]-?: T[K] extends V ? K : never;
6844
+ }[keyof T & string]
6845
+ >;
6846
+ ```
6847
+
7171
6848
  ###### `Value<T>`
7172
6849
 
7173
6850
  A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
@@ -7199,6 +6876,19 @@ type MatrixColoring =
7199
6876
  };
7200
6877
  ```
7201
6878
 
6879
+ ###### `AxisValue`
6880
+
6881
+ What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
6882
+ instant on a time axis -, a string, which is a category, or a `Date`,
6883
+ which is an instant. The type of the value decides what kind the axis is,
6884
+ never its content: `"2024"` is a category, `2024` a number. On an axis
6885
+ that says `time` a string is an ISO 8601 instant. null/undefined is no
6886
+ place.
6887
+
6888
+ ```ts
6889
+ type AxisValue = number | string | Date | null | undefined;
6890
+ ```
6891
+
7202
6892
  ###### `NumberField<T>`
7203
6893
 
7204
6894
  The fields of a row whose value is a number, or absent.
@@ -7682,9 +7372,8 @@ function batteryDay(vehicleId: string): BatteryPoint[] {
7682
7372
  const NORTH = VEHICLES.filter((vehicle) => vehicle.depot === "north");
7683
7373
  const BATTERY = batteryDay("v2");
7684
7374
 
7685
- /* The lanes: one domain unit per vehicle, the rest is room for the course.
7686
- Lane 0 lies at the bottom, so the list runs from the bottom up. */
7687
- const LANES = [...NORTH].reverse().map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
7375
+ /* The lanes: one per vehicle, named by its plate. */
7376
+ const LANES = NORTH.map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
7688
7377
 
7689
7378
  export default function LimitsAndState() {
7690
7379
  const { Chart, XAxis, YAxis, StateBand, Line } = useChart(BATTERY);
@@ -7692,27 +7381,13 @@ export default function LimitsAndState() {
7692
7381
  <Chart height={340} ariaLabel="An e-van's charge against its limits, above the states of the North depot's vehicles">
7693
7382
  <XAxis value="t" time label="Time" />
7694
7383
  <YAxis domain={[-100, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="Charge %" />
7695
- <YAxis
7696
- id="lanes"
7697
- position="right"
7698
- domain={[0, 8]}
7699
- ticks={LANES.map((_, i) => i + 0.45)}
7700
- tickFormat={(v) => LANES[Math.floor(v)]?.vehicle.plate ?? ""}
7701
- />
7384
+ {/* The lanes take the lower places; the domain leaves the rest as room for the course. */}
7385
+ <YAxis id="lanes" position="right" domain={[-0.5, 7.5]} />
7702
7386
  <LimitBand from={10} to={20} severity="warning" label="Reserve" />
7703
7387
  <LimitLine value={20} severity="warning" label="Charge soon" />
7704
7388
  <LimitLine value={10} severity="alarm" label="Return now" />
7705
- {LANES.map(({ vehicle, day }, i) => (
7706
- <StateBand
7707
- key={vehicle.id}
7708
- data={day}
7709
- value="state"
7710
- states={VEHICLE_STATES}
7711
- yAxisId="lanes"
7712
- laneFrom={i}
7713
- laneTo={i + 0.9}
7714
- name={vehicle.plate}
7715
- />
7389
+ {LANES.map(({ vehicle, day }) => (
7390
+ <StateBand key={vehicle.id} data={day} value="state" states={VEHICLE_STATES} yAxisId="lanes" lane={vehicle.plate} name={vehicle.plate} />
7716
7391
  ))}
7717
7392
  <Line value="charge" name="Charge" strokeWidth={1.75} />
7718
7393
  <Legend placement="top" />
@@ -7883,7 +7558,7 @@ export default function KilnZone() {
7883
7558
 
7884
7559
  ##### Mark moments and windows along time
7885
7560
 
7886
- With `orientation="x"` a limit lies on the time axis: a line for a moment, a band for a window. Neither carries a severity, so each takes a `color` of its own.
7561
+ With `orientation="x"` a limit lies on the time axis: a line for a moment, a band for a window, each named as a `Date` or in milliseconds. Neither carries a severity, so each takes a `color` of its own.
7887
7562
 
7888
7563
  ```tsx
7889
7564
  import { LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
@@ -7977,8 +7652,8 @@ export default function AlongTime() {
7977
7652
  <Chart height={260} ariaLabel="Checkout's latency across a maintenance window and the start of a release freeze">
7978
7653
  <XAxis value="t" time />
7979
7654
  <YAxis label="ms" />
7980
- <LimitBand orientation="x" from={at(17, 2)} to={at(17, 3)} color="#5b7c99" label="Maintenance" />
7981
- <LimitLine orientation="x" value={at(17, 8, 30)} color="#7b61c9" label="Release freeze" />
7655
+ <LimitBand orientation="x" from={new Date(2026, 2, 17, 2)} to={new Date(2026, 2, 17, 3)} color="#5b7c99" label="Maintenance" />
7656
+ <LimitLine orientation="x" value={new Date(2026, 2, 17, 8, 30)} color="#7b61c9" label="Release freeze" />
7982
7657
  <Line value="p95" name="p95" />
7983
7658
  <Tooltip mode="x" />
7984
7659
  </Chart>
@@ -8165,7 +7840,7 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
8165
7840
 
8166
7841
  | Prop | Type | Default | Description |
8167
7842
  |---|---|---|---|
8168
- | `value` *required* | `number` | — | Where the line lies, in domain units of its axis.<br>Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 10 more |
7843
+ | `value` *required* | `number \| Date` | — | Where the line lies, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`.<br>Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 10 more |
8169
7844
  | `axisId` | `string` | the first axis of the orientation | Axis on which the value lies.<br>Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
8170
7845
  | `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment.<br>Shown in: [Mark moments and windows along time](#/limitline/along-time) |
8171
7846
  | `severity` | [`Severity`](#type-Severity)<br>`"warning" \| "alarm"` | `"alarm"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise.<br>Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 9 more |
@@ -8183,8 +7858,8 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
8183
7858
 
8184
7859
  | Prop | Type | Default | Description |
8185
7860
  |---|---|---|---|
8186
- | `from` *required* | `number` | — | Lower edge of the band, in domain units of its axis.<br>Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 4 more |
8187
- | `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.<br>Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 4 more |
7861
+ | `from` *required* | `number \| Date` | — | Lower edge of the band, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`.<br>Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 4 more |
7862
+ | `to` *required* | `number \| Date` | — | 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.<br>Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 4 more |
8188
7863
  | `axisId` | `string` | the first axis of the orientation | Axis on which the value lies.<br>Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
8189
7864
  | `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment.<br>Shown in: [Mark moments and windows along time](#/limitline/along-time) |
8190
7865
  | `severity` | [`Severity`](#type-Severity)<br>`"warning" \| "alarm"` | `"warning"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise.<br>Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 9 more |
@@ -8927,7 +8602,7 @@ Demo page: https://romanhaendler.github.io/umriss-ui/charts/pareto/
8927
8602
 
8928
8603
  ##### Sort causes and collect the tail
8929
8604
 
8930
- `pareto()` sorts descending, adds up and folds everything past `collectRank` into one entry that stands last; the bars and the line are yours.
8605
+ `pareto()` sorts descending, adds up and folds everything past `collectRank` into one entry that stands last; the bars and the line are yours, and the x axis reads each entry's `name`.
8931
8606
 
8932
8607
  ```tsx
8933
8608
  import { useMemo } from "react";
@@ -8952,7 +8627,7 @@ export default function ParetoExample() {
8952
8627
  const { Chart, XAxis, YAxis, Bar, Line } = useChart(entries);
8953
8628
  return (
8954
8629
  <Chart height={300} ariaLabel="Failed deliveries by cause, sorted, with the cumulative share">
8955
- <XAxis value="index" ticks={entries.map((e) => e.index)} tickFormat={(v) => entries[v]?.name ?? ""} />
8630
+ <XAxis value="name" />
8956
8631
  <YAxis label="Failed deliveries" />
8957
8632
  <YAxis
8958
8633
  id="share"
@@ -8981,7 +8656,7 @@ From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
8981
8656
 
8982
8657
  | Prop | Type | Default | Description |
8983
8658
  |---|---|---|---|
8984
- | [`height`](#/chart/ChartProps-height) | `number` | `300` | Height in CSS pixels. Unlike the width, the chart does not take its host's. |
8659
+ | [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
8985
8660
  | [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
8986
8661
 
8987
8662
  ###### Accessibility
@@ -8994,11 +8669,10 @@ From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
8994
8669
 
8995
8670
  | Prop | Type | Default | Description |
8996
8671
  |---|---|---|---|
8997
- | [`value`](#/axis/XAxisProps-value) *required* | [`NumberField`](#/axis/type-NumberField)`<T> \| ((d: T, index: number) => number)` | — | Where a row lies on this axis: a number field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
8672
+ | [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
8998
8673
  | [`id`](#/axis/XAxisProps-id) | `string` | `"x"` | Axis id, through which series bind themselves. |
8999
8674
  | [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
9000
8675
  | [`tickFormat`](#/axis/XAxisProps-tickFormat) | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. |
9001
- | [`ticks`](#/axis/XAxisProps-ticks) | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are places and not numbers: the lanes of a state stack, the categories of a Pareto. |
9002
8676
 
9003
8677
  From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
9004
8678
 
@@ -9009,7 +8683,6 @@ From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
9009
8683
  | [`id`](#/axis/YAxisProps-id) | `string` | `"y"` | Axis id, through which series bind themselves. |
9010
8684
  | [`label`](#/axis/YAxisProps-label) | `string` | — | Axis title. |
9011
8685
  | [`tickFormat`](#/axis/YAxisProps-tickFormat) | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. |
9012
- | [`ticks`](#/axis/YAxisProps-ticks) | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are places and not numbers: the lanes of a state stack, the categories of a Pareto. |
9013
8686
 
9014
8687
  From `LineProps` — the full table stands on [Line](#/line/type-LineProps).
9015
8688
 
@@ -9033,7 +8706,7 @@ From `LimitLineProps` — the full table stands on [LimitLine](#/limitline/type-
9033
8706
 
9034
8707
  | Prop | Type | Default | Description |
9035
8708
  |---|---|---|---|
9036
- | [`value`](#/limitline/LimitLineProps-value) *required* | `number` | — | Where the line lies, in domain units of its axis. |
8709
+ | [`value`](#/limitline/LimitLineProps-value) *required* | `number \| Date` | — | Where the line lies, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`. |
9037
8710
  | [`axisId`](#/limitline/LimitLineProps-axisId) | `string` | the first axis of the orientation | Axis on which the value lies. |
9038
8711
  | [`severity`](#/limitline/LimitLineProps-severity) | [`Severity`](#/limitline/type-Severity)<br>`"warning" \| "alarm"` | `"alarm"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise. |
9039
8712
  | [`label`](#/limitline/LimitLineProps-label) | `string` | — | What the value means. A line at 90 does not say what 90 is. |
@@ -9653,7 +9326,7 @@ The keys of [Chart](#/chart/keyboard-chart) apply here.
9653
9326
 
9654
9327
  | Prop | Type | Default | Description |
9655
9328
  |---|---|---|---|
9656
- | `mode` | `"x" \| "nearest"` | `"x"` | "x": every series at the x position; "nearest": only the nearest point.<br>Shown in: [Report every series or the nearest](#/tooltip/x-or-nearest), [Write the tooltip yourself](#/tooltip/own-content), [Write each value in its unit](#/tooltip/value-format) and 55 more |
9329
+ | `mode` | `"x" \| "nearest"` | `"x"` | "x": every series at the x position; "nearest": only the nearest point.<br>Shown in: [Report every series or the nearest](#/tooltip/x-or-nearest), [Write the tooltip yourself](#/tooltip/own-content), [Write each value in its unit](#/tooltip/value-format) and 57 more |
9657
9330
  | `render` | `(hit: `[`TooltipHit`](#type-TooltipHit)`<T>) => ReactNode` | the built-in tooltip | Render prop for content of one's own. Compared by its source text, like `tickFormat` - with the same closure limit.<br>Shown in: [Write the tooltip yourself](#/tooltip/own-content), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
9658
9331
 
9659
9332
  ##### Types on this page
@@ -9923,13 +9596,14 @@ const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
9923
9596
 
9924
9597
  const MARKETING = LEDGER.filter((row) => row.costCentre === "CC-1200");
9925
9598
  const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
9926
- const month = (row: LedgerRow) => Number(row.month.slice(5)) - 1;
9599
+ /** `"2026-03"` as the axis writes it. */
9600
+ const monthName = (month: string) => MONTH_NAMES[Number(month.slice(5)) - 1] ?? month;
9927
9601
 
9928
9602
  function Marketing({ placement }: { placement?: "bottom" }) {
9929
9603
  const { Chart, XAxis, YAxis, Line } = useChart(MARKETING);
9930
9604
  return (
9931
9605
  <Chart height={220} ariaLabel={`Marketing's budget and forecast, legend ${placement ?? "above"}`}>
9932
- <XAxis value={month} ticks={MONTH_NAMES.map((_, i) => i)} tickFormat={(v) => MONTH_NAMES[v] ?? ""} />
9606
+ <XAxis value="month" categoryLabel={monthName} />
9933
9607
  <YAxis label="€" />
9934
9608
  <Line value="budget" name="Budget" />
9935
9609
  <Line value="forecast" name="Forecast" />
@@ -9973,7 +9647,7 @@ Every entry of a named series is a button whose `aria-pressed` says whether its
9973
9647
 
9974
9648
  | Prop | Type | Default | Description |
9975
9649
  |---|---|---|---|
9976
- | `placement` | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on.<br>Shown in: [Legend above or below](#/legend/above-or-below), [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind) and 26 more |
9650
+ | `placement` | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on.<br>Shown in: [Legend above or below](#/legend/above-or-below), [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind) and 27 more |
9977
9651
 
9978
9652
  ### Benchmark
9979
9653
 
@@ -10324,7 +9998,7 @@ From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
10324
9998
 
10325
9999
  | Prop | Type | Default | Description |
10326
10000
  |---|---|---|---|
10327
- | [`height`](#/chart/ChartProps-height) | `number` | `300` | Height in CSS pixels. Unlike the width, the chart does not take its host's. |
10001
+ | [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
10328
10002
  | [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
10329
10003
 
10330
10004
  ###### Events
@@ -10343,9 +10017,9 @@ From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
10343
10017
 
10344
10018
  | Prop | Type | Default | Description |
10345
10019
  |---|---|---|---|
10346
- | [`value`](#/axis/XAxisProps-value) *required* | [`NumberField`](#/axis/type-NumberField)`<T> \| ((d: T, index: number) => number)` | — | Where a row lies on this axis: a number field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
10347
- | [`domain`](#/axis/XAxisProps-domain) | `"nice" \| "data" \| readonly [number, number]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it. |
10348
- | [`time`](#/axis/XAxisProps-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. |
10020
+ | [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`. |
10021
+ | [`domain`](#/axis/XAxisProps-domain) | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it. |
10022
+ | [`time`](#/axis/XAxisProps-time) | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. 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 in milliseconds. `calendar` implies it. |
10349
10023
  | [`zoomable`](#/axis/XAxisProps-zoomable) | `boolean` | `false` | Zoom and pan: 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 goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setDomain`). The plain wheel always scrolls the page. |
10350
10024
  | [`id`](#/axis/XAxisProps-id) | `string` | `"x"` | Axis id, through which series bind themselves. |
10351
10025
  | [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
@@ -10990,19 +10664,35 @@ An `XAxis` or `YAxis` as the chart holds it once registered.
10990
10664
  | `id` *required* | `string` | — | |
10991
10665
  | `orientation` *required* | [`AxisOrientation`](#type-AxisOrientation)<br>`"x" \| "y"` | — | |
10992
10666
  | `position` *required* | [`AxisPosition`](#type-AxisPosition)<br>`"bottom" \| "top" \| "left" \| "right"` | — | |
10993
- | `accessor` | `(d: T, index: number) => number` | — | Where a row lies along the axis; an x axis' only - along y the series place their rows. |
10667
+ | `accessor` | `(d: T, index: number) => `[`AxisValue`](#type-AxisValue) | — | Where a row lies along the axis; an x axis' only - along y the series place their rows. A number is a position or an instant, a string a category, a `Date` an instant ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
10994
10668
  | `label` | `string` | — | |
10995
10669
  | `tickCount` | `number` | — | |
10996
10670
  | `tickFormat` | `(v: number) => string` | — | |
10997
10671
  | `domain` *required* | `"nice" \| "data" \| "visible" \| readonly [number, number]` | — | "visible" is a y axis' only: the extent of what its series show inside their x axis' domain. |
10998
10672
  | `grid` | `boolean` | only the first registered axis per orientation | Whether the axis draws its grid lines. |
10999
- | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm. For axes whose values are not numbers but places: the lanes of a state stack, the categories of a Pareto. |
11000
- | `time` | `boolean` | — | The values are instants in milliseconds: ticks on local boundaries, labels by level. A calendar implies it. |
10673
+ | `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. |
10674
+ | `categories` | `readonly string[]` | — | The categories of the axis, in their order: the list is the order and the extent, and makes the axis a category axis ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
10675
+ | `categoryLabel` | `(category: string) => string` | — | What is written for a category, wherever it is written. |
10676
+ | `time` | `boolean` | — | The values are instants - milliseconds, and where this is said ISO 8601 strings as well: ticks on local boundaries, labels by level. A calendar implies it, and so do rows that bring a `Date` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
11001
10677
  | `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it, materialisation maps wall clock time onto working time before the scale calculates - the scale stays affine ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)). |
11002
10678
  | `alignTicks` | `boolean` | — | y axes only: this axis' ticks on the first y axis' grid. |
11003
10679
  | `zoomable` | `boolean` | — | x axes only: wheel, drag, pinch, double click and the zoom keys move the span the chart's view holds for this axis ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)). |
11004
10680
  | `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent, at least three data steps | x axes only: the narrowest and the widest span zoom may reach. |
11005
10681
 
10682
+ ##### `AxisField<T, V = AxisValue>`
10683
+
10684
+ The fields of a row an axis can read - or, with `V`, those that hold only
10685
+ some of what an axis reads: a matrix' row is a position or a category,
10686
+ never an instant.
10687
+
10688
+ ```ts
10689
+ type AxisField<T, V = AxisValue> = NoInfer<
10690
+ {
10691
+ [K in keyof T & string]-?: T[K] extends V ? K : never;
10692
+ }[keyof T & string]
10693
+ >;
10694
+ ```
10695
+
11006
10696
  ##### `AxisOrientation`
11007
10697
 
11008
10698
  Whether an axis or a limit runs along x or along y.
@@ -11028,6 +10718,19 @@ The span each zoomable x axis shows, by axis `id`, in the axis' units.
11028
10718
  type AxisSpans = Readonly<Record<string, readonly [number, number]>>;
11029
10719
  ```
11030
10720
 
10721
+ ##### `AxisValue`
10722
+
10723
+ What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
10724
+ instant on a time axis -, a string, which is a category, or a `Date`,
10725
+ which is an instant. The type of the value decides what kind the axis is,
10726
+ never its content: `"2024"` is a category, `2024` a number. On an axis
10727
+ that says `time` a string is an ISO 8601 instant. null/undefined is no
10728
+ place.
10729
+
10730
+ ```ts
10731
+ type AxisValue = number | string | Date | null | undefined;
10732
+ ```
10733
+
11031
10734
  ##### `BarProps`
11032
10735
 
11033
10736
  Its table stands on [Bar](#/bar/type-BarProps).
@@ -11144,14 +10847,14 @@ is typed by that `data` instead.
11144
10847
 
11145
10848
  | Prop | Type | Default | Description |
11146
10849
  |---|---|---|---|
11147
- | `Chart` *required* | `(props: `[`ChartProps`](#/chart/type-ChartProps)`) => ReactNode` | — | The chart over the hook's rows. One per `useChart`: it holds the view, and a second chart is a second call.<br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 68 more |
11148
- | `XAxis` *required* | `<T = Z>(props: `[`XAxisProps`](#/axis/type-XAxisProps)`<T>) => null` | — | An x axis reads the rows of every series bound to it: the hook's, or with `<XAxis<Row>>` those the series bring as their own `data`.<br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 68 more |
11149
- | `YAxis` *required* | `(props: `[`YAxisProps`](#/axis/type-YAxisProps)`) => null` | — | <br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 68 more |
11150
- | `Line` *required* | `<T = Z>(props: `[`LineProps`](#/line/type-LineProps)`<T>) => null` | — | <br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 44 more |
10850
+ | `Chart` *required* | `(props: `[`ChartProps`](#/chart/type-ChartProps)`) => ReactNode` | — | The chart over the hook's rows. One per `useChart`: it holds the view, and a second chart is a second call.<br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 70 more |
10851
+ | `XAxis` *required* | `<T = Z>(props: `[`XAxisProps`](#/axis/type-XAxisProps)`<T>) => null` | — | An x axis reads the rows of every series bound to it: the hook's, or with `<XAxis<Row>>` those the series bring as their own `data`.<br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 70 more |
10852
+ | `YAxis` *required* | `(props: `[`YAxisProps`](#/axis/type-YAxisProps)`) => null` | — | <br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 70 more |
10853
+ | `Line` *required* | `<T = Z>(props: `[`LineProps`](#/line/type-LineProps)`<T>) => null` | — | <br>Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 45 more |
11151
10854
  | `Area` *required* | `<T = Z>(props: `[`AreaProps`](#/area/type-AreaProps)`<T>) => null` | — | <br>Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 5 more |
11152
- | `Bar` *required* | `<T = Z>(props: `[`BarProps`](#/bar/type-BarProps)`<T>) => null` | — | <br>Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 10 more |
10855
+ | `Bar` *required* | `<T = Z>(props: `[`BarProps`](#/bar/type-BarProps)`<T>) => null` | — | <br>Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 11 more |
11153
10856
  | `Scatter` *required* | `<T = Z>(props: `[`ScatterProps`](#/scatter/type-ScatterProps)`<T>) => null` | — | <br>Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 2 more |
11154
- | `StateBand` *required* | `<T = Z>(props: `[`StateBandProps`](#/stateband/type-StateBandProps)`<T>) => null` | — | <br>Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Lay a state under a course (StateBand)](#/stateband/under-a-course) and 3 more |
10857
+ | `StateBand` *required* | `<T = Z>(props: `[`StateBandProps`](#/stateband/type-StateBandProps)`<T> & `[`OneLane`](#type-OneLane)`) => null` | — | <br>Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Lay a state under a course (StateBand)](#/stateband/under-a-course) and 3 more |
11155
10858
  | `Matrix` *required* | `<T = Z>(props: `[`MatrixProps`](#/matrix/type-MatrixProps)`<T>) => null` | — | <br>Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits), [Colour cells along a gradient (Matrix)](#/matrix/gradient) |
11156
10859
  | `BoxPlot` *required* | `<T = Z>(props: `[`BoxPlotProps`](#/boxplot/type-BoxPlotProps)`<T>) => null` | — | <br>Shown in: [One box per service (BoxPlot)](#/boxplot/minimal), [A box per hour on a time axis (BoxPlot)](#/boxplot/over-time), [With outliers (BoxPlot)](#/boxplot/outliers) and 4 more |
11157
10860
  | `view` *required* | [`ChartView`](#/view/type-ChartView) | — | How the reader is looking at the chart. |
@@ -11471,7 +11174,9 @@ Its table stands on [Matrix](#/matrix/type-MatrixProps).
11471
11174
  ##### `MatrixSeriesConfig<T = unknown>`
11472
11175
 
11473
11176
  A `Matrix` as the chart holds it once registered: its base accessor is the
11474
- row, `level` the colour.
11177
+ row, `level` the colour. The row may be named: where the accessor yields a
11178
+ string, materialisation gives it its category's place ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)) - the one
11179
+ series kind whose base accessor reads more than a number.
11475
11180
 
11476
11181
  | Prop | Type | Default | Description |
11477
11182
  |---|---|---|---|
@@ -11499,6 +11204,16 @@ type NumberField<T> = NoInfer<
11499
11204
  >;
11500
11205
  ```
11501
11206
 
11207
+ ##### `OneLane`
11208
+
11209
+ A lane is named or given by its edges: the compiler refuses both at once.
11210
+ Beside the props and not in them, so that `StateBandProps` stays an
11211
+ interface another can extend.
11212
+
11213
+ ```ts
11214
+ type OneLane = { lane: string; laneFrom?: undefined; laneTo?: undefined } | { lane?: undefined };
11215
+ ```
11216
+
11502
11217
  ##### `Padding`
11503
11218
 
11504
11219
  Space in CSS pixels on each side of a rectangle.
@@ -11516,9 +11231,9 @@ An item, the way it is drawn.
11516
11231
 
11517
11232
  | Prop | Type | Default | Description |
11518
11233
  |---|---|---|---|
11519
- | `name` *required* | `string` | — | <br>Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
11234
+ | `name` *required* | `string` | — | |
11520
11235
  | `value` *required* | `number` | — | |
11521
- | `index` *required* | `number` | — | Category and at the same time x value: category n lies at x = n ([ADR-0002](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0002-bars-on-a-numeric-x-axis.md)).<br>Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
11236
+ | `index` *required* | `number` | — | The entry's rank from 0, in the order drawn - its place on an x axis that reads `name`, and an x value for one that reads numbers. |
11522
11237
  | `share` *required* | `number` | — | Share of the total, 0…1. |
11523
11238
  | `cumulative` *required* | `number` | — | Cumulative share up to and including this entry, 0…1. Exactly 1 at the last entry.<br>Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
11524
11239
  | `remainder` *required* | `boolean` | — | The collected tail. Exactly one entry can be this, and it always stands last. |
@@ -11754,6 +11469,7 @@ an index into `states`.
11754
11469
  |---|---|---|---|
11755
11470
  | `kind` *required* | `"state"` | — | |
11756
11471
  | `states` *required* | `readonly `[`StateEntry`](#type-StateEntry)`[]` | — | The closed set of states. The accessor yields the index into it - a number, not a key ([ADR-0007](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0007-a-state-is-a-number.md)). |
11472
+ | `lane` | `string` | — | The lane by its name: a category of the y axis, whose place the band takes ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
11757
11473
  | `laneFrom` | `number` | — | Lower edge of the lane in domain units of the y axis; without a value the beginning of the axis domain. |
11758
11474
  | `laneTo` | `number` | — | Upper edge of the lane; without a value the end of the axis domain. |
11759
11475
  | `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`<T>` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
@@ -11906,7 +11622,7 @@ A zone line: k times sigma above and below the centre line.
11906
11622
  | `upper` *required* | `number` | — | |
11907
11623
  | `lower` *required* | `number` | — | |
11908
11624
 
11909
- Used on [Scenarios](#/), [Axis](#/axis), [BoxPlot](#/boxplot), [LimitLine](#/limitline) and [ControlChart](#/controlchart).
11625
+ Used on [Scenarios](#/), [Axis](#/axis), [LimitLine](#/limitline) and [ControlChart](#/controlchart).
11910
11626
 
11911
11627
  ##### `ZoomLimits`
11912
11628