@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/CHANGELOG.md +62 -0
- package/README.md +3 -3
- package/dist/charts.css +1 -1
- package/dist/charts.js +2204 -1825
- package/dist/de.d.ts +16 -0
- package/dist/index.d.ts +143 -3
- package/dist/wording/de.js +17 -6
- package/docs/llms-full.md +587 -27
- package/package.json +4 -3
package/docs/llms-full.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# @umriss-ui/charts 0.
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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] ?? ""}
|
|
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
|
|
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" },
|