@umriss-ui/charts 0.8.1 → 0.9.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,6 +1,6 @@
1
- # @umriss-ui/charts 0.8.1
1
+ # @umriss-ui/charts 0.9.0
2
2
 
3
- > React canvas chart library for data-dense dashboards - line, area, bar, scatter, control and Pareto charts. Few chart kinds, drawn well, fast.
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
 
5
5
  Install with `pnpm add @umriss-ui/charts`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/charts/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and what it deliberately does not do. The pages index stands in https://romanhaendler.github.io/umriss-ui/charts/llms.txt.
6
6
 
@@ -877,14 +877,11 @@ import { SERVICES, SUCCESS_BY_HOUR, type SuccessCell } from "./operations";
877
877
  const BATTERY = batteryDay("v2");
878
878
  const STATES = vehicleDay("v2");
879
879
 
880
- const timeOfDay = (v: number) =>
881
- new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
882
-
883
880
  export default function BandsLimitsAndCells() {
884
881
  return (
885
882
  <div className="side-by-side">
886
883
  <Chart data={BATTERY} height={280} ariaLabel="An e-van's battery against its reserve, above its states" encoding="marks">
887
- <XAxis accessor={(d: BatteryPoint) => d.t} tickFormat={timeOfDay} label="Time" tickCount={4} />
884
+ <XAxis accessor={(d: BatteryPoint) => d.t} time label="Time" tickCount={4} />
888
885
  <YAxis accessor={(d: BatteryPoint) => d.charge ?? 0} domain={[-40, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="%" />
889
886
  <YAxis id="lane" position="right" accessor={() => 0} domain={[0, 5]} ticks={[0.45]} tickFormat={() => "FP 377 K"} />
890
887
  <LimitBand from={10} to={20} severity="warning" label="Reserve" />
@@ -1216,7 +1213,7 @@ It imports `operations.ts` from beside itself; the file stands once, under "File
1216
1213
 
1217
1214
  | Prop | Type | Default | Description |
1218
1215
  |---|---|---|---|
1219
- | `domain` | `"nice" \| "data" \| readonly [number, number]` | — | `"nice"` widens the data's extent to ticks, `"data"` keeps it, a pair is fixed - the one a zoom passes back. |
1216
+ | `domain` | `"nice" \| "data" \| readonly [number, number]` | — | `"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 - the one a zoom passes back. |
1220
1217
  | `position` | `"bottom" \| "top"` | — | Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own. |
1221
1218
  | `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. |
1222
1219
  | `calendar` | `readonly 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). |
@@ -1720,6 +1717,448 @@ It imports `logistics.ts` from beside itself; the file stands once, under "Files
1720
1717
 
1721
1718
  What umriss deliberately does not build, and why: [ADR-0032](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0032-what-umriss-is-not.md).
1722
1719
 
1720
+ ### BoxPlot
1721
+
1722
+ A distribution per position (a box plot, box-and-whisker): quartiles, median and whiskers, per service, per group or per hour. Reach for it to compare spreads, not single values.
1723
+
1724
+ ```ts
1725
+ import { BoxPlot } from "@umriss-ui/charts";
1726
+ ```
1727
+
1728
+ Demo page: https://romanhaendler.github.io/umriss-ui/charts/boxplot/
1729
+
1730
+ 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.
1731
+
1732
+ 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). A missing median is a gap - no box is drawn there.
1733
+
1734
+ #### Examples
1735
+
1736
+ ##### One box per service
1737
+
1738
+ Five numbers per row, computed wherever the caller likes. Services are positions 0, 1, 2 on the numeric x axis; `tickFormat` names them.
1739
+
1740
+ ```tsx
1741
+ import { BoxPlot, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1742
+
1743
+ interface ResponseTime {
1744
+ service: string;
1745
+ low: number;
1746
+ q1: number;
1747
+ median: number;
1748
+ q3: number;
1749
+ high: number;
1750
+ }
1751
+
1752
+ /* Milliseconds per request over the last hour, aggregated by the database. */
1753
+ const RESPONSE_TIMES: ResponseTime[] = [
1754
+ { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
1755
+ { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
1756
+ { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
1757
+ ];
1758
+
1759
+ export default function Minimal() {
1760
+ return (
1761
+ <Chart data={RESPONSE_TIMES} height={260} ariaLabel="Response time per service over the last hour">
1762
+ <XAxis accessor={(_: ResponseTime, i) => i} ticks={[0, 1, 2]} tickFormat={(v) => RESPONSE_TIMES[v]?.service ?? ""} />
1763
+ <YAxis accessor={(d: ResponseTime) => d.median} tickFormat={(v) => `${v} ms`} />
1764
+ <BoxPlot
1765
+ name="Response time"
1766
+ median={(d: ResponseTime) => d.median}
1767
+ lowerQuartile={(d: ResponseTime) => d.q1}
1768
+ upperQuartile={(d: ResponseTime) => d.q3}
1769
+ lowerWhisker={(d: ResponseTime) => d.low}
1770
+ upperWhisker={(d: ResponseTime) => d.high}
1771
+ />
1772
+ <Tooltip />
1773
+ </Chart>
1774
+ );
1775
+ }
1776
+ ```
1777
+
1778
+ ##### A box per hour on a time axis
1779
+
1780
+ Checkout's median latency, every five minutes, gathered into one box per hour. The quartiles and the whisker rule are the caller's - here the last value within 1.5 IQR - and the library draws what it is given.
1781
+
1782
+ ```tsx
1783
+ import { BoxPlot, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1784
+ import { metrics } from "./operations";
1785
+
1786
+ const HOUR = 3_600_000;
1787
+
1788
+ interface HourBox {
1789
+ hour: number;
1790
+ low: number;
1791
+ q1: number;
1792
+ median: number;
1793
+ q3: number;
1794
+ high: number;
1795
+ }
1796
+
1797
+ /** The value at share p of the sorted values, between neighbours. */
1798
+ function quantile(sorted: readonly number[], p: number): number {
1799
+ const at = (sorted.length - 1) * p;
1800
+ const lo = Math.floor(at);
1801
+ return (sorted[lo] as number) + ((sorted[Math.ceil(at)] as number) - (sorted[lo] as number)) * (at - lo);
1802
+ }
1803
+
1804
+ function hourBoxes(): HourBox[] {
1805
+ const byHour = new Map<number, number[]>();
1806
+ for (const point of metrics("checkout")) {
1807
+ const hour = Math.floor(point.t / HOUR) * HOUR;
1808
+ byHour.set(hour, [...(byHour.get(hour) ?? []), point.p50]);
1809
+ }
1810
+ return [...byHour].map(([hour, values]) => {
1811
+ const sorted = values.sort((a, b) => a - b);
1812
+ const q1 = quantile(sorted, 0.25);
1813
+ const q3 = quantile(sorted, 0.75);
1814
+ const reach = 1.5 * (q3 - q1);
1815
+ const inside = sorted.filter((v) => v >= q1 - reach && v <= q3 + reach);
1816
+ return { hour, low: inside[0] as number, q1, median: quantile(sorted, 0.5), q3, high: inside[inside.length - 1] as number };
1817
+ });
1818
+ }
1819
+
1820
+ const BOXES = hourBoxes();
1821
+
1822
+ export default function OverTime() {
1823
+ return (
1824
+ <Chart data={BOXES} height={280} ariaLabel="Checkout median latency per hour today">
1825
+ <XAxis accessor={(d: HourBox) => d.hour} time />
1826
+ <YAxis accessor={(d: HourBox) => d.median} tickFormat={(v) => `${v} ms`} />
1827
+ <BoxPlot
1828
+ name="Checkout p50"
1829
+ median={(d: HourBox) => d.median}
1830
+ lowerQuartile={(d: HourBox) => d.q1}
1831
+ upperQuartile={(d: HourBox) => d.q3}
1832
+ lowerWhisker={(d: HourBox) => d.low}
1833
+ upperWhisker={(d: HourBox) => d.high}
1834
+ />
1835
+ <Tooltip />
1836
+ </Chart>
1837
+ );
1838
+ }
1839
+ ```
1840
+
1841
+ It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1842
+
1843
+ ##### With outliers
1844
+
1845
+ `outliers` returns the values beyond the whiskers, a list per box. They are drawn with their box and read in its tooltip, the first five written out; one more than three IQR beyond its box is a ring.
1846
+
1847
+ ```tsx
1848
+ import { BoxPlot, Chart, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1849
+
1850
+ interface ResponseTime {
1851
+ service: string;
1852
+ low: number;
1853
+ q1: number;
1854
+ median: number;
1855
+ q3: number;
1856
+ high: number;
1857
+ outliers: number[];
1858
+ }
1859
+
1860
+ /* Milliseconds per request over the last hour. The whiskers end at the last
1861
+ value within 1.5 IQR; the database returned everything beyond as outliers. */
1862
+ const RESPONSE_TIMES: ResponseTime[] = [
1863
+ { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195, outliers: [224, 98] },
1864
+ { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238, outliers: [281, 395] },
1865
+ { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181, outliers: [189, 214, 104] },
1866
+ { service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186, outliers: [193, 198, 204, 210, 222, 235, 259, 71] },
1867
+ ];
1868
+
1869
+ export default function WithOutliers() {
1870
+ return (
1871
+ <Chart data={RESPONSE_TIMES} height={300} ariaLabel="Response time per service over the last hour, with outliers">
1872
+ <XAxis accessor={(_: ResponseTime, i) => i} ticks={[0, 1, 2, 3]} tickFormat={(v) => RESPONSE_TIMES[v]?.service ?? ""} />
1873
+ <YAxis accessor={(d: ResponseTime) => d.median} tickFormat={(v) => `${v} ms`} />
1874
+ <BoxPlot
1875
+ name="Response time"
1876
+ median={(d: ResponseTime) => d.median}
1877
+ lowerQuartile={(d: ResponseTime) => d.q1}
1878
+ upperQuartile={(d: ResponseTime) => d.q3}
1879
+ lowerWhisker={(d: ResponseTime) => d.low}
1880
+ upperWhisker={(d: ResponseTime) => d.high}
1881
+ outliers={(d: ResponseTime) => d.outliers}
1882
+ />
1883
+ <Tooltip />
1884
+ </Chart>
1885
+ );
1886
+ }
1887
+ ```
1888
+
1889
+ ##### Mean and notches: do two medians differ?
1890
+
1891
+ Where two boxes' notches do not overlap, their medians differ. The bounds are yours - here the usual median ± 1.57 · IQR / √n. `mean` draws a small ×: beside the median it shows a skew. `count` is read, not drawn.
1892
+
1893
+ ```tsx
1894
+ import { BoxPlot, Chart, DataTable, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1895
+
1896
+ interface Batch {
1897
+ supplier: string;
1898
+ low: number;
1899
+ q1: number;
1900
+ median: number;
1901
+ q3: number;
1902
+ high: number;
1903
+ mean: number;
1904
+ n: number;
1905
+ }
1906
+
1907
+ /* Breaking strength of tiles, in newtons, per clay supplier. */
1908
+ const BATCHES: Batch[] = [
1909
+ { supplier: "Hollen", low: 1310, q1: 1395, median: 1430, q3: 1468, high: 1540, mean: 1431, n: 120 },
1910
+ { supplier: "Marsk", low: 1290, q1: 1380, median: 1418, q3: 1462, high: 1555, mean: 1426, n: 64 },
1911
+ { supplier: "Velde", low: 1240, q1: 1322, median: 1352, q3: 1398, high: 1490, mean: 1371, n: 96 },
1912
+ ];
1913
+
1914
+ const notch = (d: Batch) => (1.57 * (d.q3 - d.q1)) / Math.sqrt(d.n);
1915
+
1916
+ export default function MeanAndNotches() {
1917
+ return (
1918
+ <Chart data={BATCHES} height={300} ariaLabel="Breaking strength per clay supplier, with mean and notches">
1919
+ <XAxis accessor={(_: Batch, i) => i} ticks={[0, 1, 2]} tickFormat={(v) => BATCHES[v]?.supplier ?? ""} />
1920
+ <YAxis accessor={(d: Batch) => d.median} tickFormat={(v) => `${v} N`} />
1921
+ <BoxPlot
1922
+ name="Breaking strength"
1923
+ median={(d: Batch) => d.median}
1924
+ lowerQuartile={(d: Batch) => d.q1}
1925
+ upperQuartile={(d: Batch) => d.q3}
1926
+ lowerWhisker={(d: Batch) => d.low}
1927
+ upperWhisker={(d: Batch) => d.high}
1928
+ mean={(d: Batch) => d.mean}
1929
+ notchLower={(d: Batch) => d.median - notch(d)}
1930
+ notchUpper={(d: Batch) => d.median + notch(d)}
1931
+ count={(d: Batch) => d.n}
1932
+ format={(v) => `${Math.round(v)} N`}
1933
+ boxWidth={0.5}
1934
+ />
1935
+ <Legend />
1936
+ <Tooltip />
1937
+ <DataTable />
1938
+ </Chart>
1939
+ );
1940
+ }
1941
+ ```
1942
+
1943
+ ##### Before and after, side by side
1944
+
1945
+ Box series on one x axis stand beside each other, as bars do. `hidden` is your state and `onToggle` hands you the clicked entry; a hidden series leaves the drawing and the y extent.
1946
+
1947
+ ```tsx
1948
+ import { useState } from "react";
1949
+ import { BoxPlot, Chart, Legend, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
1950
+
1951
+ interface Spread {
1952
+ service: number;
1953
+ low: number;
1954
+ q1: number;
1955
+ median: number;
1956
+ q3: number;
1957
+ high: number;
1958
+ }
1959
+
1960
+ const SERVICES = ["Checkout", "Billing", "Search", "Images"];
1961
+
1962
+ /* Milliseconds per request, the week before and the week after the release. */
1963
+ const BEFORE: Spread[] = [
1964
+ { service: 0, low: 112, q1: 138, median: 149, q3: 161, high: 195 },
1965
+ { service: 1, low: 85, q1: 121, median: 140, q3: 173, high: 238 },
1966
+ { service: 2, low: 140, q1: 152, median: 158, q3: 165, high: 181 },
1967
+ { service: 3, low: 99, q1: 126, median: 137, q3: 150, high: 186 },
1968
+ ];
1969
+ const AFTER: Spread[] = [
1970
+ { service: 0, low: 118, q1: 135, median: 143, q3: 152, high: 176 },
1971
+ { service: 1, low: 101, q1: 119, median: 128, q3: 139, high: 164 },
1972
+ { service: 2, low: 142, q1: 151, median: 157, q3: 164, high: 179 },
1973
+ { service: 3, low: 103, q1: 124, median: 135, q3: 146, high: 172 },
1974
+ ];
1975
+
1976
+ export default function Grouped() {
1977
+ const [hidden, setHidden] = useState<readonly string[]>([]);
1978
+ const toggle = (name: string) => setHidden((h) => (h.includes(name) ? h.filter((n) => n !== name) : [...h, name]));
1979
+ return (
1980
+ <Chart data={BEFORE} height={280} ariaLabel="Response time per service before and after the release">
1981
+ <XAxis accessor={(d: Spread) => d.service} ticks={[0, 1, 2, 3]} tickFormat={(v) => SERVICES[v] ?? ""} />
1982
+ <YAxis accessor={(d: Spread) => d.median} tickFormat={(v) => `${v} ms`} />
1983
+ {[
1984
+ { name: "Before", data: BEFORE },
1985
+ { name: "After", data: AFTER },
1986
+ ].map((one) => (
1987
+ <BoxPlot
1988
+ key={one.name}
1989
+ name={one.name}
1990
+ data={one.data}
1991
+ hidden={hidden.includes(one.name)}
1992
+ median={(d: Spread) => d.median}
1993
+ lowerQuartile={(d: Spread) => d.q1}
1994
+ upperQuartile={(d: Spread) => d.q3}
1995
+ lowerWhisker={(d: Spread) => d.low}
1996
+ upperWhisker={(d: Spread) => d.high}
1997
+ />
1998
+ ))}
1999
+ <Legend onToggle={toggle} />
2000
+ <Tooltip />
2001
+ </Chart>
2002
+ );
2003
+ }
2004
+ ```
2005
+
2006
+ ##### Against the objective
2007
+
2008
+ A `LimitLine` shows what was promised. Whether a box breaks it is your judgement, not the library's: this week's whiskers reach past the objective in every region, so its series carries `tone="alarm"`.
2009
+
2010
+ ```tsx
2011
+ import { BoxPlot, Chart, Legend, LimitLine, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2012
+
2013
+ interface Spread {
2014
+ region: number;
2015
+ low: number;
2016
+ q1: number;
2017
+ median: number;
2018
+ q3: number;
2019
+ high: number;
2020
+ }
2021
+
2022
+ const REGIONS = ["Europe", "Americas", "Asia"];
2023
+
2024
+ /* Checkout's 95th percentile per five minutes, in ms, per region and week. */
2025
+ const LAST_WEEK: Spread[] = [
2026
+ { region: 0, low: 182, q1: 204, median: 216, q3: 229, high: 262 },
2027
+ { region: 1, low: 191, q1: 213, median: 226, q3: 240, high: 276 },
2028
+ { region: 2, low: 176, q1: 199, median: 211, q3: 224, high: 258 },
2029
+ ];
2030
+ const THIS_WEEK: Spread[] = [
2031
+ { region: 0, low: 196, q1: 231, median: 249, q3: 268, high: 318 },
2032
+ { region: 1, low: 205, q1: 242, median: 262, q3: 283, high: 339 },
2033
+ { region: 2, low: 190, q1: 226, median: 244, q3: 263, high: 311 },
2034
+ ];
2035
+
2036
+ export default function AgainstTheObjective() {
2037
+ return (
2038
+ <Chart data={LAST_WEEK} height={280} ariaLabel="Checkout 95th percentile per region and week against its objective">
2039
+ <XAxis accessor={(d: Spread) => d.region} ticks={[0, 1, 2]} tickFormat={(v) => REGIONS[v] ?? ""} />
2040
+ <YAxis accessor={(d: Spread) => d.median} tickFormat={(v) => `${v} ms`} />
2041
+ <BoxPlot
2042
+ name="Last week"
2043
+ median={(d: Spread) => d.median}
2044
+ lowerQuartile={(d: Spread) => d.q1}
2045
+ upperQuartile={(d: Spread) => d.q3}
2046
+ lowerWhisker={(d: Spread) => d.low}
2047
+ upperWhisker={(d: Spread) => d.high}
2048
+ />
2049
+ <BoxPlot
2050
+ name="This week"
2051
+ data={THIS_WEEK}
2052
+ tone="alarm"
2053
+ median={(d: Spread) => d.median}
2054
+ lowerQuartile={(d: Spread) => d.q1}
2055
+ upperQuartile={(d: Spread) => d.q3}
2056
+ lowerWhisker={(d: Spread) => d.low}
2057
+ upperWhisker={(d: Spread) => d.high}
2058
+ />
2059
+ <LimitLine value={300} severity="alarm" label="Objective" />
2060
+ <Legend />
2061
+ <Tooltip />
2062
+ </Chart>
2063
+ );
2064
+ }
2065
+ ```
2066
+
2067
+ ##### A box per shift, its medians as a line
2068
+
2069
+ The tiles measured in each of six shifts, one box each, and a `Line` through the medians to read the drift. The tooltip reads box and line together; `DataTable` lists every number as text.
2070
+
2071
+ ```tsx
2072
+ import { BoxPlot, Chart, DataTable, Legend, Line, Tooltip, XAxis, YAxis } from "@umriss-ui/charts";
2073
+ import { plant } from "./plant";
2074
+
2075
+ const SHIFTS = ["Mon early", "Mon late", "Tue early", "Tue late", "Wed early", "Wed late"];
2076
+
2077
+ interface ShiftBox {
2078
+ shift: number;
2079
+ low: number;
2080
+ q1: number;
2081
+ median: number;
2082
+ q3: number;
2083
+ high: number;
2084
+ }
2085
+
2086
+ /** The value at share p of the sorted values, between neighbours. */
2087
+ function quantile(sorted: readonly number[], p: number): number {
2088
+ const at = (sorted.length - 1) * p;
2089
+ const lo = Math.floor(at);
2090
+ return (sorted[lo] as number) + ((sorted[Math.ceil(at)] as number) - (sorted[lo] as number)) * (at - lo);
2091
+ }
2092
+
2093
+ /* One shift per seed; the whiskers end at the last length within 1.5 IQR. */
2094
+ const BOXES: ShiftBox[] = SHIFTS.map((_, shift) => {
2095
+ const sorted = plant(shift + 1).samples.map((s) => s.length).sort((a, b) => a - b);
2096
+ const q1 = quantile(sorted, 0.25);
2097
+ const q3 = quantile(sorted, 0.75);
2098
+ const reach = 1.5 * (q3 - q1);
2099
+ const inside = sorted.filter((v) => v >= q1 - reach && v <= q3 + reach);
2100
+ return { shift, low: inside[0] as number, q1, median: quantile(sorted, 0.5), q3, high: inside[inside.length - 1] as number };
2101
+ });
2102
+
2103
+ const mm = (v: number) => `${v.toFixed(2)} mm`;
2104
+
2105
+ export default function Detailed() {
2106
+ return (
2107
+ <Chart data={BOXES} height={320} ariaLabel="Tile length per shift, with the medians as a line">
2108
+ <XAxis accessor={(d: ShiftBox) => d.shift} ticks={SHIFTS.map((_, i) => i)} tickFormat={(v) => SHIFTS[v] ?? ""} label="Shift" />
2109
+ <YAxis accessor={(d: ShiftBox) => d.median} tickFormat={mm} />
2110
+ <BoxPlot
2111
+ name="Tile length"
2112
+ median={(d: ShiftBox) => d.median}
2113
+ lowerQuartile={(d: ShiftBox) => d.q1}
2114
+ upperQuartile={(d: ShiftBox) => d.q3}
2115
+ lowerWhisker={(d: ShiftBox) => d.low}
2116
+ upperWhisker={(d: ShiftBox) => d.high}
2117
+ boxWidth={0.5}
2118
+ />
2119
+ <Line accessor={(d: ShiftBox) => d.median} name="Median" markers="always" />
2120
+ <Legend />
2121
+ <Tooltip />
2122
+ <DataTable />
2123
+ </Chart>
2124
+ );
2125
+ }
2126
+ ```
2127
+
2128
+ It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
2129
+
2130
+ #### API
2131
+
2132
+ ##### `BoxPlotProps<T>`
2133
+
2134
+ | Prop | Type | Default | Description |
2135
+ |---|---|---|---|
2136
+ | `median` (required) | `Accessor<T>` | — | The line across the box; null/undefined/NaN/±Infinity means a gap - no box is drawn there, whatever the other numbers say (R-2.5). |
2137
+ | `lowerQuartile` (required) | `Accessor<T>` | — | The box's lower edge. |
2138
+ | `upperQuartile` (required) | `Accessor<T>` | — | The box's upper edge. |
2139
+ | `lowerWhisker` (required) | `Accessor<T>` | — | Where the lower whisker ends - the caller's rule, not the minimum. |
2140
+ | `upperWhisker` (required) | `Accessor<T>` | — | Where the upper whisker ends - the caller's rule, not the maximum. |
2141
+ | `outliers` | `ListAccessor<T>` | — | The values beyond the whiskers, per box: an array, empty or missing where there are none. Drawn with their box in its colour - one beyond three IQR of the box's own quartiles as a ring -, read in its tooltip and table row, never hit on their own (ADR-0040). |
2142
+ | `mean` | `Accessor<T>` | — | The mean, drawn as a small ×: beside the median it shows a skew. |
2143
+ | `notchLower` | `Accessor<T>` | — | The notch's lower bound - usually of a confidence interval of the median. Both bounds or neither: one alone warns in DEV and is not drawn. |
2144
+ | `notchUpper` | `Accessor<T>` | — | The notch's upper bound; see `notchLower`. |
2145
+ | `count` | `Accessor<T>` | — | How many values stand behind the box; read in the tooltip and the table, not drawn. |
2146
+ | `xAxisId` | `string` | — | Binding to an x axis (R-4.12). |
2147
+ | `yAxisId` | `string` | — | Binding to a y axis (R-4.12). |
2148
+ | `data` | `readonly T[]` | — | Series-own data; overrides the container data (R-2.4). |
2149
+ | `name` | `string` | — | The name in legend and tooltip. Without one the series is called "Series n" and a warning stands in DEV. |
2150
+ | `hidden` | `boolean` | — | Not drawn, not hit and not counted for its axes' extent. Its legend entry stays, drawn back. Controlled, typically from `Legend onToggle`. |
2151
+ | `format` | `(value: number) => string` | — | Every number of the box as the tooltip and the table write it; without one the y axis' `tickFormat`, then the default. |
2152
+ | `color` | `string` | — | Any CSS colour value; without one the palette --uc-series-N. |
2153
+ | `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it. Set by the caller: a box out of its specification is the caller's judgement, not the library's. |
2154
+ | `boxWidth` | `number` | — | Width as a fraction of the step, as a bar's: boxes and bars on one x axis share this fraction and stand side by side. |
2155
+
2156
+ #### Known limits
2157
+
2158
+ - No horizontal boxes, no violin or jittered points, no box width by count: a density is smoothing, a jitter a picture that does not repeat.
2159
+
2160
+ What umriss deliberately does not build, and why: [ADR-0032](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0032-what-umriss-is-not.md).
2161
+
1723
2162
  ### Scatter
1724
2163
 
1725
2164
  Single readings as points, joined by nothing (a scatter plot, dots). Reach for it for samples, checks and events - anything that is not a course.
@@ -1835,13 +2274,10 @@ import { VEHICLE_STATES, vehicleDay, type StatePoint } from "./logistics";
1835
2274
 
1836
2275
  const DAY = vehicleDay("v1");
1837
2276
 
1838
- const timeOfDay = (v: number) =>
1839
- new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1840
-
1841
2277
  export default function OneVehicle() {
1842
2278
  return (
1843
2279
  <Chart data={DAY} height={150} ariaLabel="States of van FP 214 K through the day">
1844
- <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" />
2280
+ <XAxis accessor={(d: StatePoint) => d.t} time label="Time" />
1845
2281
  {/* Without a lane the band fills its y axis; one tick names it. */}
1846
2282
  <YAxis accessor={() => 0} domain={[0, 1]} ticks={[0.5]} tickFormat={() => "FP 214 K"} grid={false} />
1847
2283
  <StateBand accessor={(d: StatePoint) => d.state} states={VEHICLE_STATES} name="FP 214 K" />
@@ -1865,13 +2301,10 @@ import { VEHICLE_STATES, batteryDay, vehicleDay, type BatteryPoint, type StatePo
1865
2301
  const BATTERY = batteryDay("v2");
1866
2302
  const STATES = vehicleDay("v2");
1867
2303
 
1868
- const timeOfDay = (v: number) =>
1869
- new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1870
-
1871
2304
  export default function UnderACourse() {
1872
2305
  return (
1873
2306
  <Chart data={BATTERY} height={320} ariaLabel="An e-van's charge above what it was doing">
1874
- <XAxis accessor={(d: BatteryPoint) => d.t} tickFormat={timeOfDay} label="Time" />
2307
+ <XAxis accessor={(d: BatteryPoint) => d.t} time label="Time" />
1875
2308
  <YAxis accessor={(d: BatteryPoint) => d.charge ?? 0} domain={[-40, 100]} ticks={[0, 25, 50, 75, 100]} label="Charge %" />
1876
2309
  {/* The lane: the bottom unit of five, the rest is room for the course. */}
1877
2310
  <YAxis id="lane" position="right" accessor={() => 0} domain={[0, 5]} ticks={[0.4]} tickFormat={() => "State"} />
@@ -1896,13 +2329,10 @@ import { VEHICLES, VEHICLE_STATES, vehicleDay, type StatePoint } from "./logisti
1896
2329
 
1897
2330
  const FLEET = VEHICLES.map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
1898
2331
 
1899
- const timeOfDay = (v: number) =>
1900
- new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1901
-
1902
2332
  export default function OneLaneEach() {
1903
2333
  return (
1904
2334
  <Chart data={FLEET[0]!.day} height={300} ariaLabel="The states of all eight vehicles through the day">
1905
- <XAxis accessor={(d: StatePoint) => d.t} tickFormat={timeOfDay} label="Time" />
2335
+ <XAxis accessor={(d: StatePoint) => d.t} time label="Time" />
1906
2336
  {/* Lane 0 lies at the bottom, so the first vehicle takes the top lane. */}
1907
2337
  <YAxis
1908
2338
  accessor={() => 0}
@@ -2119,9 +2549,6 @@ import { VEHICLES, VEHICLE_STATES, batteryDay, vehicleDay, type BatteryPoint, ty
2119
2549
  const NORTH = VEHICLES.filter((vehicle) => vehicle.depot === "north");
2120
2550
  const BATTERY = batteryDay("v2");
2121
2551
 
2122
- const timeOfDay = (v: number) =>
2123
- new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
2124
-
2125
2552
  /* The lanes: one domain unit per vehicle, the rest is room for the course.
2126
2553
  Lane 0 lies at the bottom, so the list runs from the bottom up. */
2127
2554
  const LANES = [...NORTH].reverse().map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
@@ -2129,7 +2556,7 @@ const LANES = [...NORTH].reverse().map((vehicle) => ({ vehicle, day: vehicleDay(
2129
2556
  export default function LimitsAndState() {
2130
2557
  return (
2131
2558
  <Chart data={BATTERY} height={340} ariaLabel="An e-van's charge against its limits, above the states of the North depot's vehicles">
2132
- <XAxis accessor={(d: BatteryPoint) => d.t} tickFormat={timeOfDay} label="Time" />
2559
+ <XAxis accessor={(d: BatteryPoint) => d.t} time label="Time" />
2133
2560
  <YAxis accessor={(d: BatteryPoint) => d.charge ?? 0} domain={[-100, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="Charge %" />
2134
2561
  <YAxis
2135
2562
  id="lanes"
@@ -2547,7 +2974,7 @@ const month = (row: LedgerRow) => Number(row.month.slice(5)) - 1;
2547
2974
  function Marketing({ placement }: { placement?: "bottom" }) {
2548
2975
  return (
2549
2976
  <Chart data={MARKETING} height={220} ariaLabel={`Marketing's budget and forecast, legend ${placement ?? "above"}`}>
2550
- <XAxis accessor={month} tickFormat={(v) => MONTH_NAMES[v] ?? ""} tickCount={4} />
2977
+ <XAxis accessor={month} ticks={MONTH_NAMES.map((_, i) => i)} tickFormat={(v) => MONTH_NAMES[v] ?? ""} />
2551
2978
  <YAxis accessor={(d: LedgerRow) => d.forecast} label="€" />
2552
2979
  <Line accessor={(d: LedgerRow) => d.budget} name="Budget" />
2553
2980
  <Line accessor={(d: LedgerRow) => d.forecast} name="Forecast" />
@@ -3597,6 +4024,9 @@ interface MaterializedSeries {
3597
4024
  carry does not belong in the shared type with a comment (ADR-0011). null
3598
4025
  for every kind that does not use it. */
3599
4026
  w: Float64Array | null;
4027
+ /** A box's further numbers beside its median in y; null for every other
4028
+ kind (ADR-0011). */
4029
+ box: BoxChannels | null;
3600
4030
  length: number;
3601
4031
  }
3602
4032
  ```
@@ -3670,7 +4100,7 @@ interface SeriesBase<T = unknown> {
3670
4100
  ### `SeriesConfig`
3671
4101
 
3672
4102
  ```ts
3673
- type SeriesConfig<T = unknown> = LineSeriesConfig<T> | AreaSeriesConfig<T> | BarSeriesConfig<T> | ScatterSeriesConfig<T> | StateSeriesConfig<T> | MatrixSeriesConfig<T>;
4103
+ type SeriesConfig<T = unknown> = LineSeriesConfig<T> | AreaSeriesConfig<T> | BarSeriesConfig<T> | BoxSeriesConfig<T> | ScatterSeriesConfig<T> | StateSeriesConfig<T> | MatrixSeriesConfig<T>;
3674
4104
  ```
3675
4105
 
3676
4106
  ### `SeriesKind`
@@ -3717,6 +4147,12 @@ interface TooltipPoint<T = unknown> {
3717
4147
  to: number;
3718
4148
  label: string;
3719
4149
  };
4150
+ /** Only for a box: its further numbers; yValue is its median. Fields of
4151
+ their own for the reason `value` has one (ADR-0011). */
4152
+ box?: BoxNumbers & Partial<BoxExtras> & {
4153
+ /** As the caller gave them; only where the series has `outliers`. */
4154
+ outliers?: readonly number[];
4155
+ };
3720
4156
  }
3721
4157
  ```
3722
4158
 
@@ -3790,6 +4226,75 @@ interface DataTableGroup {
3790
4226
  }
3791
4227
  ```
3792
4228
 
4229
+ ### `BoxSeriesConfig`
4230
+
4231
+ ```ts
4232
+ /** A box plot (CONTEXT.md: Box). The base accessor is the median; the four
4233
+ further numbers are the caller's, computed by whatever method the caller
4234
+ chose - the library draws them and computes none. */
4235
+ interface BoxSeriesConfig<T = unknown> extends SeriesBase<T> {
4236
+ kind: "box";
4237
+ lowerQuartile: Accessor<T>;
4238
+ upperQuartile: Accessor<T>;
4239
+ lowerWhisker: Accessor<T>;
4240
+ upperWhisker: Accessor<T>;
4241
+ /** The values beyond the whiskers, per box (ADR-0040). */
4242
+ outliers?: ListAccessor<T>;
4243
+ mean?: Accessor<T>;
4244
+ /** Both bounds or neither (CONTEXT.md: Notch). */
4245
+ notchLower?: Accessor<T>;
4246
+ notchUpper?: Accessor<T>;
4247
+ /** How many values stand behind the box; no y value. */
4248
+ count?: Accessor<T>;
4249
+ /** Width as a fraction of the step; boxes and bars on one x axis share it. */
4250
+ boxWidth: number;
4251
+ }
4252
+ ```
4253
+
4254
+ ### `BoxChannels`
4255
+
4256
+ ```ts
4257
+ /** The named channels of a box: one number per point, NaN where the median
4258
+ is a gap; and its outliers (ADR-0040). */
4259
+ interface BoxChannels extends BoxNumbers<Float64Array> {
4260
+ /** Every box's outliers, flat, box after box; null without `outliers`. */
4261
+ outliers: Float64Array | null;
4262
+ /** Where each box's outliers begin, and one past the last: box i's stand
4263
+ from outlierOffsets[i] to outlierOffsets[i + 1]. null without
4264
+ `outliers`. */
4265
+ outlierOffsets: Uint32Array | null;
4266
+ /** The optional numbers, each null where not given. */
4267
+ mean: Float64Array | null;
4268
+ notchLower: Float64Array | null;
4269
+ notchUpper: Float64Array | null;
4270
+ count: Float64Array | null;
4271
+ }
4272
+ ```
4273
+
4274
+ ### `BoxNumbers`
4275
+
4276
+ ```ts
4277
+ /** A box's further numbers beside its median, by name. */
4278
+ interface BoxNumbers<V = number> {
4279
+ lowerQuartile: V;
4280
+ upperQuartile: V;
4281
+ lowerWhisker: V;
4282
+ upperWhisker: V;
4283
+ }
4284
+ ```
4285
+
4286
+ ### `BoxExtras`
4287
+
4288
+ ```ts
4289
+ /** The optional numbers of a box (box-plot 04), by name. */
4290
+ interface BoxExtras<V = number> {
4291
+ mean: V;
4292
+ notchLower: V;
4293
+ notchUpper: V;
4294
+ count: V;
4295
+ }
4296
+ ```
4297
+
3793
4298
  ## Files the examples show
3794
4299
 
3795
4300
  The demos' shared data, which some examples import from beside themselves. Copied with the example, it runs.
@@ -3807,7 +4312,10 @@ The demos' shared data, which some examples import from beside themselves. Copie
3807
4312
  * where the month is closed (January and February), the forecast for every
3808
4313
  * month; Marketing overspends, IT runs under;
3809
4314
  * - `INVOICES` - incoming invoices with their lines (quantity, unit price,
3810
- * discount, VAT rate), and `APPROVALS`, who signed which, or has yet to.
4315
+ * discount, VAT rate), and `APPROVALS`, who signed which, or has yet to;
4316
+ * - `TEAMS` - the teams of each cost centre with their headcount, full-time
4317
+ * equivalents and monthly staff cost, and `BUSINESS_LINES`, which cost
4318
+ * centres each of the three business lines holds.
3811
4319
  *
3812
4320
  * Amounts in euros. "Now" is Tuesday, 17 March 2026. Plain data and small pure
3813
4321
  * functions, no imports: copy the file beside an example and it runs.
@@ -3959,6 +4467,58 @@ export const APPROVALS: readonly Approval[] = [
3959
4467
  { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
3960
4468
  { invoice: "INV-26-0221", step: "cost centre", approver: "Anika Sørensen", decision: "rejected", at: on(2, 24), comment: "Not ordered - the review was cancelled in January." },
3961
4469
  ];
4470
+
4471
+ export interface Team {
4472
+ /** The cost centre the team books to. */
4473
+ costCentre: string;
4474
+ name: string;
4475
+ /** People employed, whatever their hours. */
4476
+ headcount: number;
4477
+ /** Full-time equivalents: contracted hours over 40 a week. */
4478
+ fte: number;
4479
+ /** Staff cost of a month, in thousand euros. */
4480
+ monthlyCost: number;
4481
+ }
4482
+
4483
+ /** Every team, on 17 March: 102 people, 91.5 full-time equivalents. */
4484
+ export const TEAMS: readonly Team[] = [
4485
+ { costCentre: "CC-1100", name: "Field sales North", headcount: 9, fte: 8.6, monthlyCost: 61.2 },
4486
+ { costCentre: "CC-1100", name: "Field sales South", headcount: 8, fte: 7.5, monthlyCost: 53.9 },
4487
+ { costCentre: "CC-1100", name: "Inside sales", headcount: 6, fte: 5.2, monthlyCost: 29.8 },
4488
+ { costCentre: "CC-1100", name: "Key accounts", headcount: 4, fte: 4.0, monthlyCost: 33.6 },
4489
+ { costCentre: "CC-1200", name: "Brand", headcount: 3, fte: 2.8, monthlyCost: 19.4 },
4490
+ { costCentre: "CC-1200", name: "Online marketing", headcount: 4, fte: 3.5, monthlyCost: 24.1 },
4491
+ { costCentre: "CC-1200", name: "Trade fairs", headcount: 2, fte: 1.5, monthlyCost: 10.2 },
4492
+ { costCentre: "CC-3100", name: "Service desk", headcount: 7, fte: 5.9, monthlyCost: 31.3 },
4493
+ { costCentre: "CC-3100", name: "Returns", headcount: 3, fte: 2.5, monthlyCost: 12.6 },
4494
+ { costCentre: "CC-2100", name: "Seating", headcount: 6, fte: 5.8, monthlyCost: 43.5 },
4495
+ { costCentre: "CC-2100", name: "Desks", headcount: 7, fte: 6.5, monthlyCost: 48.1 },
4496
+ { costCentre: "CC-2100", name: "Storage", headcount: 5, fte: 4.6, monthlyCost: 33.9 },
4497
+ { costCentre: "CC-2100", name: "Test lab", headcount: 3, fte: 2.5, monthlyCost: 16.8 },
4498
+ { costCentre: "CC-2200", name: "Industrial design", headcount: 4, fte: 3.6, monthlyCost: 27.2 },
4499
+ { costCentre: "CC-2200", name: "Colour and material", headcount: 2, fte: 1.8, monthlyCost: 12.9 },
4500
+ { costCentre: "CC-4100", name: "Accounting", headcount: 5, fte: 4.4, monthlyCost: 27.5 },
4501
+ { costCentre: "CC-4100", name: "Controlling", headcount: 3, fte: 3.0, monthlyCost: 21.3 },
4502
+ { costCentre: "CC-4200", name: "Recruiting", headcount: 2, fte: 1.8, monthlyCost: 12.4 },
4503
+ { costCentre: "CC-4200", name: "Payroll", headcount: 2, fte: 1.5, monthlyCost: 9.6 },
4504
+ { costCentre: "CC-4300", name: "Workplace", headcount: 4, fte: 3.8, monthlyCost: 24.2 },
4505
+ { costCentre: "CC-4300", name: "Applications", headcount: 5, fte: 4.5, monthlyCost: 34.8 },
4506
+ { costCentre: "CC-4400", name: "Buildings", headcount: 3, fte: 3.0, monthlyCost: 16.9 },
4507
+ { costCentre: "CC-4400", name: "Canteen", headcount: 5, fte: 3.2, monthlyCost: 14.7 },
4508
+ ];
4509
+
4510
+ export interface BusinessLine {
4511
+ name: string;
4512
+ /** Its cost centres, in the order a report lists them. */
4513
+ costCentres: readonly string[];
4514
+ }
4515
+
4516
+ /** How the company reports: three business lines over the nine cost centres. */
4517
+ export const BUSINESS_LINES: readonly BusinessLine[] = [
4518
+ { name: "Commercial", costCentres: ["CC-1100", "CC-1200", "CC-3100"] },
4519
+ { name: "Product", costCentres: ["CC-2100", "CC-2200"] },
4520
+ { name: "Corporate services", costCentres: ["CC-4100", "CC-4200", "CC-4300", "CC-4400"] },
4521
+ ];
3962
4522
  ```
3963
4523
 
3964
4524
  ### `logistics.ts`
@@ -4495,7 +5055,7 @@ export interface AlertType {
4495
5055
 
4496
5056
  export const ALERT_TYPES: readonly AlertType[] = [
4497
5057
  { id: "checkout-latency", label: "Checkout · p95 latency above 300 ms", priority: "high" },
4498
- { id: "checkout-errors", label: "Checkout · error rate above 2 %", priority: "high" },
5058
+ { id: "checkout-errors", label: "Checkout · error rate above 2\u00a0%", priority: "high" },
4499
5059
  { id: "webhooks-queue", label: "Webhooks · queue older than 5 min", priority: "medium" },
4500
5060
  { id: "search-latency", label: "Search · p95 latency above 250 ms", priority: "medium" },
4501
5061
  { id: "images-disk", label: "Image service · disk 85 % full", priority: "low" },