@micha.bigler/ui-core-micha 2.39.1 → 2.40.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/DESIGN.md +1 -0
- package/WORK_ORDERS.md +1 -0
- package/dist/components/charts/BarChart.js +14 -8
- package/dist/components/charts/LineChart.js +15 -9
- package/dist/components/charts/chartDefaults.js +139 -1
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +19 -7
- package/src/components/charts/LineChart.jsx +24 -12
- package/src/components/charts/chartDefaults.js +142 -2
- package/tests/BarChart.test.jsx +20 -4
- package/tests/TimeSeriesChart.test.jsx +19 -6
- package/tests/chartDefaults.test.js +155 -0
- package/work-orders/THEME-9.md +194 -0
package/DESIGN.md
CHANGED
|
@@ -21,6 +21,7 @@ These principles are the spec a **prototype** encodes and the implementation mat
|
|
|
21
21
|
|
|
22
22
|
8. **Every chart earns:** axis labels (with units), a legend only when >1 series, a tooltip, theme-token colours, container-responsive sizing, and empty/loading states. **Single-hue for magnitude; diverging (blue/red around zero) ONLY for signed data.** The concrete data palette is a separate layer with a shared `ui-core-micha` default that apps override when their domain calls for it.
|
|
23
23
|
**Dual-axis is a default-off exception, opt-in only** (CHART-5, operator decision): the shared `TimeSeriesChart` preset supports a second y-axis via a per-series `axis: 'secondary'` field specifically for two series sharing one axis at very different scales/units (e.g. a user count vs. hours) where series-toggles alone leave one series unreadable. Reach for it only under that same condition, not as a general layout choice — a single shared axis (or toggles) stays the default everywhere else.
|
|
24
|
+
8a. **Axis titles: categorical axis gets none by default; numeric axis carries the quantity and unit, once.** A categorical axis' ticks are names (`"Session 1"`, ward names) — a title only restates their type; add one only when the ticks are codes or abbreviations whose kind is not self-evident. A numeric axis is meaningless without a title naming the quantity and its unit — unless the ticks already show the unit (`%`, a currency symbol) and the panel heading already names the quantity, in which case a title repeats both. **The unit belongs in the title once, never in every tick** (`"Accessibility (minutes) (min)"` over ticks `"0.0 min"` was measured live — the unit three times on one axis). This is a decision the caller makes per axis, not something the chart preset silently applies or suppresses (THEME-9) — a label the caller deliberately set is never auto-dropped.
|
|
24
25
|
9. **Ground it in the real subject matter.** Faithful structures from the real panels (hram: Morris-tornado, Pareto/knee, CI-strip, allocation-scatter with shape-encoded sources) — no lorem, no generic charts. Hence: **scout the real subject before building.**
|
|
25
26
|
|
|
26
27
|
## Craft
|
package/WORK_ORDERS.md
CHANGED
|
@@ -82,3 +82,4 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
82
82
|
| THEME-6 | An app-side check for `sx` values the theme already gives | `THEME-4` made shadowing mechanical for the kit's own components; apps have no equivalent — cockpit alone carries **464 `sx` props across 37 files** (measured 2026-08-11). Adds an exported, source-level, **report-only** check an app calls from its own suite, plus the cockpit count. Full spec: [work-orders/THEME-6.md](work-orders/THEME-6.md). | 2026-08-11 | done | 637d262 | DS-18's app-side half. **Codex → Claude fallback**: Codex ran out of workspace credits mid-task (first-hand failure this session, stamped today) — the Orchestrator implemented directly. Author-flip rule applied: independent `reviewer` (Sonnet, mandatory regardless of tier) ran against the Orchestrator's own diff; `ui_reviewer` correctly not spawned, per the WO's own text, since nothing renders. New export `reportRedundantThemeValues(sources, { theme })` in `src/theme/themeCompleteness.js`, reusing existing primitives (`getPath`, `sameValue`, `jsxOpeningTag`, `topLevelSxValue`, `BASELINE_STYLED_MUI_COMPONENTS`, `expandShorthandProperty`) rather than rebuilding them. Attribution by JSX tag name only (root-slot styleOverrides only), literal string/number sx values only — no template literals, identifiers, spreads, or `component={...}` resolution, the bound written open per the WO's instruction. **A real, verified discrepancy in the Envelope's own grounding, corrected here rather than silently chased:** the cited cockpit examples (`borderColor: 'divider'` ×2, `bgcolor: 'background.paper'` ×3, `borderRadius: 1` ×3) all live on `<Box component="button">` in `cockpit/frontend/src/cockpit/ChatView.jsx:75,82,95` — confirmed by direct inspection — which the check's own stated tag-name attribution method cannot catch (`Box` isn't a themed tag, and `component={...}` resolution is explicitly out of scope). The check was NOT widened to chase these numbers. **Run against cockpit's real source** (`cockpit/frontend/src`, 50 `.jsx` files, `theme.js` excluded from the sources passed per the WO's app-side-exclusion instruction — `reportOffPaletteColours`'s own skip pattern left untouched): **27 findings, all `MuiButton.textTransform`, across 4 files** (`BoardView.jsx`, `KnowledgeView.jsx`, `SettingsView.jsx`, one more) — cockpit's buttons redundantly set `sx={{ textTransform: 'none' }}`, matching the baseline's own `MuiButton.styleOverrides.root.textTransform`. This is the honest, real result, reported as a correction to the Envelope's stale grounding, not a match to it. Independent `reviewer` found one real **P2, fixed**: numeric sx values for `borderRadius` and spacing-shorthand aliases were compared raw against the theme's already-resolved CSS value, but MUI actually multiplies a bare number by `theme.shape.borderRadius`/`theme.spacing()` before rendering (e.g. `borderRadius: 8` in sx renders as `8 × shape.borderRadius`, not `8`) — comparing the two raw numbers could produce a **false positive**, exactly the failure mode the WO's own Risks section names as the one to avoid. Fixed by skipping numeric-value comparison entirely for `borderRadius` and every spacing alias (`MUI_NUMERIC_SCALE_PROPERTIES`), erring toward a false negative (the accepted lower bound) rather than a false positive; two regression tests added (the specific false-positive case, plus a corrected version of the "component the theme doesn't style" test, which had used an off-list tag rather than exercising the intended undefined-styleOverride branch — also a reviewer note, fixed). Re-verified: cockpit's 27/27 findings unchanged by the fix (all string-valued `textTransform`, not numeric). Scoped tests 37/37 green, `tsc` clean. Version `2.37.1` → `2.38.0` (minor, new exported API). No rendered gate — static/logic change, no fix shipped anywhere (report-only, per the WO's explicit non-goal). |
|
|
83
83
|
| THEME-7 | Guidance: a series slot that collides with an app's own accent | The baseline ships six `dataSeries.categorical` colours and no guidance for the case where a slot sits too close to something the consuming app owns. Measured case, not hypothesis (hram `IMARA-2`, frozen 2026-08-13, Lab ΔE calibrated against that operator's own rejected pair at ΔE 16.6): series 1 `#3D5A99` vs hram's primary `#2F4F96` = **ΔE 7.2**, and series 3 `#2E8F8A` vs hram's DALYs identity `#1F7A72` = **ΔE 8.3** (same hue, one lightness step). hram resolved both app-side by dropping slot 1 and replacing slot 3 with a navy. This WO adds the missing guidance to `DESIGN.md` and the token docs — how to check an accent against the palette, and why a colliding slot should be DROPPED rather than nudged — plus an explicit decide-and-record on whether to ship anything beyond guidance (ΔE helper / documented extension sequence / nothing). **Explicitly not a defect report: the six shipped values stay unchanged**, since they are correct for any app without a muted-indigo accent or a teal domain identity. Full order [`work-orders/THEME-7.md`](work-orders/THEME-7.md) | 2026-08-13 | planned | | Tier 3 (shared-core surface) though documentation-led; independent `reviewer` mandatory. Raised by the hram/Imara design-language work at the operator's explicit instruction to take the finding upstream rather than keep it app-local. Stated limitation to keep in the text: the guidance derives from ONE app's experience and its threshold is calibrated on one operator's judgement — a starting point, not a standard. |
|
|
84
84
|
| THEME-8 | Touch-Ziel des Selects sitzt am falschen Element und drückt den Text aus der Mitte | `src/theme/tokens.js` setzt die Touch-Mindesthöhe (40, im `@media (any-pointer: coarse)`-Zweig 44) auf `MuiSelect.styleOverrides.**select**` — also auf das INNERE Element, das MUIs variantenabhängiges Padding trägt. Beim `standard`-Select ist dieses Padding oben-verankert (`1px 24px 5px 0`), der Zusatzraum landet komplett unten, der Text reitet oben. **Von den sechs Komponenten mit Touch-Ziel (`MuiButton`, `MuiIconButton`, `MuiSelect`, `MuiCheckbox`, `MuiFormControlLabel`, `MuiTextField`) ist `MuiSelect` die EINZIGE, die einen Nicht-Root-Slot adressiert** — eine Inkonsistenz in der Baseline selbst, keine Designentscheidung. **Live gemessen** (hram, Administration → Users, Select in einer Tabellenzelle): heute 0 px über / 0 px unter dem Text (Inneres auf 44 px gestreckt); nach Verschieben der Regel auf die Wurzel **9 px / 9 px** — exakt zentriert, weil `MuiInputBase-root` bereits `display:flex; align-items:center` ist. Voller Auftrag [`work-orders/THEME-8.md`](work-orders/THEME-8.md) | 2026-08-14 | done (npm publish; consumer pin bumps still open) | ae0745d | Tier 3 (Shared-Core). Codex out of credits (2026-08-14, `.claude/codex-status.md`), Orchestrator implemented directly — authorship flip made `reviewer` + `ui_reviewer` mandatory (already required at Tier 3): both ran, no blocking findings. `reviewer`: config-level test doesn't render/measure the DOM (P3, accepted — Envelope's own proof is the live measurement, already done), no version bump at review time (P2, resolved by 2.39.0→2.39.1 in the same commit). `ui_reviewer`: MuiSelect's coarse branch still hand-writes `{minHeight:44}` instead of the shared `coarseHitArea` helper (P3, pre-existing, not introduced here). Both independently traced MUI source (`Select.js`/`InputBase.js`) and confirmed the root-slot fix reaches the right DOM node and centres via the inherited flex layout. No live browser re-render done (Claude-Code preview-scoping override: small/medium frontend change, code reasoning + tests suffice). **Wert bleibt 40/44 — nur der Anker ist falsch.** Verstärkt auf Touch-Geräten: der `coarse`-Zweig greift auch auf Laptops mit Touchscreen (Operator-Rechner meldet `any-pointer: coarse`), dort 44 statt 40 px — gleicher Fehler, 4 px grösser. **Estate-weite Sichtveränderung**: alle konsumierenden Apps bekommen re-zentrierte Selects; in den Release Notes nennen. **Nicht am npm-Publish abschliessen** — die Pin-Bumps der Konsumenten (zuerst hram, wo es gefunden wurde) gehören dazu. Gefunden beim Messen einer vermeintlichen hram-Regression; die App-seitige Schwester ist hram FIX-11 (anderer Fehler, gleiche Felder). |
|
|
85
|
+
| THEME-9 | Diagramme geben mehr Platz an Achsen-Mobiliar als an Daten | **In einer konsumierenden App (hram) gemessen, nicht geschätzt.** Eine KPI-Karte „Coverage“: Karte 470 × 372 → Chart-Box 436 × **300** (`minHeight:300`, das SVG darin ist 270 hoch → **30 px tot**) → SVG 436 × 270 → **Zeichenfläche 301 × 175**. Die Zeichenfläche ist damit **45 % des SVG und 30 % der Karte**; innerhalb des SVG gehen 135 px Breite und 95 px Höhe verloren. Vom Kartenrand bis zur ersten Tick-Zahl sind es 104 px, davon **60 px blanker Raum zwischen SVG-Rand und Achsentitel**; unter dem Achsentitel liegen 30 px leer im SVG plus 32 px bis zur Karte. **Drei weitere gemessene Befunde:** (1) `withAxisDefaults` endet mit `label: axis.label || label` und setzt damit EIN `yAxisLabel` auf JEDE Achse — in hrams Doppelachsen-Chart tragen beide Y-Achsen dieselbe 43-Zeichen-Zeile, je 65 px eines 206-px-Charts. (2) Der Achsentitel wird IN das Tick-Band gesetzt: auf der Accessibility-Ansicht überlappen **4 von 4** Y-Ticks den Titel, dasselbe passiert mit um 45° gedrehten Ortsnamen über dem Titel „Ward“. (3) Ticks tragen Einheit und Zier-Nachkommastellen (`0.0 min`, `50.0 min`) — dieselbe Einheit steht nochmals doppelt im Titel (`Accessibility (minutes) (min)`); anderswo kürzt MUI die Ticks zu `0.0…`. `BarChart` setzt gar keinen Standard-Formatter, `TimeSeriesChart` schon — also löst es jede App neu. Voller Auftrag [`work-orders/THEME-9.md`](work-orders/THEME-9.md) | 2026-08-14 | done (npm publish; consumer pin bumps still open) | 3fd6097 | Tier 3 (Shared-Core). Codex bereits als `unavailable` für heute vermerkt (`.claude/codex-status.md`, THEME-8) — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **`reviewer` R1 (P1, behoben):** die Serie-zu-Achse-Zuordnung in `sizeYAxisForContent` traf `undefined===undefined` statt MUI's echter Default-Regel (unmarkierte Serie → erste Achse im Array) — machte den Sizer zum stillen No-op genau auf der Primärachse des Doppelachsen-Falls, der diesen WO motiviert hat; Regressionstest ergänzt. **`ui_reviewer` U1 (P1, behoben):** `DESIGN.md` #8a versprach "keine Titel-Pflicht bei kategorialer Achse", aber `BarChart`/`LineChart` erzwangen beide Labels per `throw` — Labels jetzt optional, sonst wäre die Regel für FIX-13 unbrauchbar gewesen. **U3 (P2, behoben):** Sizer nutzte den Theme-Default-Font statt der per Achse gesetzten `tickLabelStyle.fontSize`. **U5 (P3, behoben):** Label-Dicke war an `theme.typography.body1` gekoppelt, obwohl MUI intern hart 14px setzt — jetzt eigene benannte Konstante. **U2 (P2, akzeptierter Rest):** Schätzung kennt MUI's "nice-tick"-Rundung nicht exakt; bestehender 1.1×-Sicherheitsfaktor federt kleine Abweichungen ab, der gerenderte Check ist der eigentliche Beweis. **U4:** Behauptung, `TimeSeriesChart` umgehe `withAxisDefaults`/`sizeYAxisForContent`, am Quellcode widerlegt (rendert über die eigene `<BarChart>`-Komponente). Affected-Set grün (60 Tests). **Live-Nachmessung in hram nicht möglich** — Browser-Pane liefert nur für den vom Operator angezeigten Tab reale Viewport-Masse (neue Tabs 0×0, negative-Width-SVG-Fehler als direkte Folge, kein Codefehler); die "vorher"-Zahlen der Coverage-Karte wurden LIVE aus dem korrekt angezeigten hram-Tab gemessen (siehe WO) und bestätigten exakt die im Envelope genannten 60px/104px — zurückgeführt auf hrams eigenes `margin.left=60`, das sich ADDITIV mit MUIs Achsenbreiten-Reservierung addiert (kein ucm-Fehler). Die "nachher"-Messung folgt mit FIX-13 nach dem Pin-Bump (ohnehin dort vorgesehen). Fünf Posten: Ränder/Achsenreservierung verkleinern · Achsentitel nicht mehr broadcasten (mit Test, genau der Defekt der ausgeliefert wurde) · Titel aus dem Tick-Band halten · Standard-Tick-Formatter · Entscheidungsregel in `DESIGN.md`. **Gemessene Warnung gegen naives `Intl`-Compact:** Deutsch kürzt Tausender GAR NICHT (`12'500` bleibt), Swahili stellt ein Wort voran (`elfu 12.5`, breiter als die Rohzahl) — kompakte Notation allein löst das Breitenproblem also nicht und verschlimmert es in einer Sprache; ein handgeschriebenes `B` wäre auf Deutsch zudem FALSCH (Billion = 10¹², `Intl` sagt korrekt `Mrd.`). **Regel:** kategoriale Achse → kein Titel (Ticks sind Namen); numerische Achse → Titel trägt Grösse und Einheit, ausser die Ticks zeigen die Einheit schon (`%`); Einheit einmal im Titel, nie in jedem Tick. **Ränder kürzen und Überlappung beheben ziehen gegeneinander** — zusammen machen und messen, sonst erzeugt das eine das andere neu. Nicht am Publish abschliessen: App-Pins nachziehen, hram zuerst (dortiger Nachzug = FIX-13). |
|
|
@@ -13,22 +13,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
|
+
import { useTranslation } from 'react-i18next';
|
|
16
17
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
17
18
|
import { useNeutralChartPalette } from './palette';
|
|
18
|
-
import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
19
20
|
/**
|
|
20
|
-
* Responsive MUI X-Charts bar preset.
|
|
21
|
-
*
|
|
21
|
+
* Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
|
|
22
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
23
|
+
* optional, per DESIGN.md #8a: a categorical axis (BarChart's default x-axis
|
|
24
|
+
* shape) needs no title by default -- its ticks are names -- and a numeric
|
|
25
|
+
* axis needs one only unless its unit is already visible in the ticks and
|
|
26
|
+
* the panel heading already names the quantity. Omitting one never suppresses
|
|
27
|
+
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
28
|
+
* an axis without its own `label`).
|
|
22
29
|
*/
|
|
23
30
|
export function BarChart(_a) {
|
|
24
31
|
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slotProps"]);
|
|
25
32
|
const theme = useTheme();
|
|
33
|
+
const { i18n } = useTranslation();
|
|
26
34
|
const neutralPalette = useNeutralChartPalette();
|
|
27
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
28
|
-
throw new Error('BarChart requires xAxisLabel and yAxisLabel.');
|
|
29
|
-
}
|
|
30
35
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'band', data: [] }, theme.typography.caption.fontSize);
|
|
31
|
-
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear' }, theme.typography.caption.fontSize);
|
|
36
|
+
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
37
|
+
const sizedYAxis = sizeYAxisForContent(labelledYAxis, series, theme.typography.caption.fontSize);
|
|
32
38
|
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
33
|
-
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { series: series, xAxis: rotatedTickSpace.xAxis, yAxis:
|
|
39
|
+
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { series: series, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: rotatedTickSpace.margin, slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
34
40
|
}
|
|
@@ -13,9 +13,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
|
+
import { useTranslation } from 'react-i18next';
|
|
16
17
|
import { LineChart as MuiLineChart, MarkElement as MuiMarkElement, } from '@mui/x-charts/LineChart';
|
|
17
18
|
import { useNeutralChartPalette } from './palette';
|
|
18
|
-
import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
19
20
|
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
20
21
|
// `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
|
|
21
22
|
// override in src/theme/tokens.js as the preferred mechanism, with a
|
|
@@ -31,19 +32,24 @@ function FilledMarkElement(_a) {
|
|
|
31
32
|
return _jsx(MuiMarkElement, Object.assign({}, props, { color: color, style: Object.assign({ fill: color }, style) }));
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
|
-
* Responsive MUI X-Charts line preset.
|
|
35
|
-
*
|
|
35
|
+
* Responsive MUI X-Charts line preset. It supplies labelled axes, tooltips,
|
|
36
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
37
|
+
* optional, per DESIGN.md #8a: a categorical axis needs no title by default
|
|
38
|
+
* -- its ticks are names -- and a numeric axis needs one only unless its
|
|
39
|
+
* unit is already visible in the ticks and the panel heading already names
|
|
40
|
+
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
41
|
+
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
42
|
+
* `label`).
|
|
36
43
|
*/
|
|
37
44
|
export function LineChart(_a) {
|
|
38
45
|
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slots, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slots", "slotProps"]);
|
|
39
46
|
const theme = useTheme();
|
|
47
|
+
const { i18n } = useTranslation();
|
|
40
48
|
const neutralPalette = useNeutralChartPalette();
|
|
41
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
42
|
-
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
43
|
-
}
|
|
44
49
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'point', data: [] }, theme.typography.caption.fontSize);
|
|
45
|
-
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear' }, theme.typography.caption.fontSize);
|
|
46
|
-
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
50
|
+
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
47
51
|
const defaultedSeries = series.map((item) => (Object.assign({ showMark: false, labelMarkType: 'square' }, item)));
|
|
48
|
-
|
|
52
|
+
const sizedYAxis = sizeYAxisForContent(labelledYAxis, defaultedSeries, theme.typography.caption.fontSize);
|
|
53
|
+
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
54
|
+
return (_jsx(Box, { "data-testid": "line-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiLineChart, Object.assign({}, chartProps, { series: defaultedSeries, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: rotatedTickSpace.margin, slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
49
55
|
}
|
|
@@ -7,7 +7,18 @@ export function withGridDefaults(grid) {
|
|
|
7
7
|
}
|
|
8
8
|
export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
9
9
|
const values = (axes === null || axes === void 0 ? void 0 : axes.length) ? axes : [defaults];
|
|
10
|
-
return values.map((axis) => (Object.assign(Object.assign(Object.assign({}, defaults), axis), { tickLabelStyle: Object.assign({ fontSize: tickFontSize }, axis.tickLabelStyle),
|
|
10
|
+
return values.map((axis, index) => (Object.assign(Object.assign(Object.assign({}, defaults), axis), { tickLabelStyle: Object.assign({ fontSize: tickFontSize }, axis.tickLabelStyle),
|
|
11
|
+
// THEME-9: the single `label` argument is the caller's ONE string for
|
|
12
|
+
// this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
|
|
13
|
+
// fall back onto ONE axis. Applying it via .map() to every entry
|
|
14
|
+
// broadcast the identical label onto every axis in a multi-axis array
|
|
15
|
+
// (measured live: hram's dual-axis chart carried the same 43-character
|
|
16
|
+
// label on both y-axes). Only the first entry -- the common single-axis
|
|
17
|
+
// case is index 0 -- gets the fallback; a caller building a genuine
|
|
18
|
+
// multi-axis array must label each entry itself (as TimeSeriesChart's
|
|
19
|
+
// own secondary-axis support already does), exactly like MUI's own
|
|
20
|
+
// default `position: 'left'` (only the first entry, MUI docs).
|
|
21
|
+
label: axis.label || (index === 0 ? label : undefined) })));
|
|
11
22
|
}
|
|
12
23
|
function cssLengthToPixels(value) {
|
|
13
24
|
if (typeof value === 'number')
|
|
@@ -78,6 +89,133 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
|
78
89
|
? Object.assign(Object.assign({}, margin), { bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }) : margin;
|
|
79
90
|
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
80
91
|
}
|
|
92
|
+
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
93
|
+
// 20px (label, if present) = 65px regardless of what the ticks actually say
|
|
94
|
+
// -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
|
|
95
|
+
// AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
|
|
96
|
+
// (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
|
|
97
|
+
// are ADDITIVE, not one containing the other). That flat default is both too
|
|
98
|
+
// generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
|
|
99
|
+
// ("50.0 min"), which is exactly the measured overlap on the Accessibility
|
|
100
|
+
// y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
|
|
101
|
+
const Y_AXIS_TICK_SIZE = 6;
|
|
102
|
+
const Y_AXIS_TICK_LABEL_GAP = 2;
|
|
103
|
+
const Y_AXIS_LABEL_TICK_GAP = 2;
|
|
104
|
+
// A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
|
|
105
|
+
// WIDTH once rotated -- runs a bit past its raw font size once ascender/
|
|
106
|
+
// descender room is included; matches the ~18px measured live for a 14px
|
|
107
|
+
// label (14 * 1.3 ~= 18).
|
|
108
|
+
const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
|
|
109
|
+
// A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
|
|
110
|
+
// note applies here too -- a too-tight cut recreates the overlap it was
|
|
111
|
+
// meant to fix, and glyph-width heuristics are approximate by nature.
|
|
112
|
+
const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
|
|
113
|
+
// MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
|
|
114
|
+
// (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
|
|
115
|
+
// -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
|
|
116
|
+
// regardless of what body1 actually resolves to). Matching that literal
|
|
117
|
+
// constant here -- rather than reading it off `theme.typography.body1` --
|
|
118
|
+
// keeps this sizing correct even if this package's body1 size ever changes;
|
|
119
|
+
// coupling it to body1 was only ever coincidentally right because body1
|
|
120
|
+
// happens to be 14px today.
|
|
121
|
+
const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
|
|
122
|
+
function longestFormattedTick(values, valueFormatter) {
|
|
123
|
+
return values.reduce((longest, value) => {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
|
|
127
|
+
}
|
|
128
|
+
catch (_a) {
|
|
129
|
+
// Some formatters require MUI's complete scale context; falling back
|
|
130
|
+
// to the raw value is conservative -- it reserves at least as much
|
|
131
|
+
// space as the unformatted number would need.
|
|
132
|
+
text = String(value);
|
|
133
|
+
}
|
|
134
|
+
return text.length > longest.length ? text : longest;
|
|
135
|
+
}, '');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Sizes a linear y-axis to its actual tick content instead of MUI's flat
|
|
139
|
+
* default. Only touches axes the caller left unsized (`axis.width == null`)
|
|
140
|
+
* and only linear/numeric ones -- a categorical axis's ticks are names, not
|
|
141
|
+
* numbers, and sizing those is not this function's job. Candidate values come
|
|
142
|
+
* from the axis' own `min`/`max` when the caller set them (as every current
|
|
143
|
+
* consumer does), falling back to the series actually plotted against this
|
|
144
|
+
* axis; if neither yields a candidate, the axis is left untouched rather than
|
|
145
|
+
* guessed at.
|
|
146
|
+
*
|
|
147
|
+
* `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
|
|
148
|
+
* (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
|
|
149
|
+
* read first, so a caller-tuned tick font is sized correctly rather than
|
|
150
|
+
* against the package-wide default. The axis LABEL's font is not a caller
|
|
151
|
+
* knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
|
|
152
|
+
*
|
|
153
|
+
* The series-to-axis match for the fallback deliberately mirrors MUI's own
|
|
154
|
+
* default-assignment rule, not a naive `undefined === undefined`: a series
|
|
155
|
+
* with no explicit `yAxisId` is assigned to the FIRST axis in the array
|
|
156
|
+
* (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
|
|
157
|
+
* `undefined === undefined` left the primary axis of a real dual-axis chart
|
|
158
|
+
* (id `'primary'`, its series never set `yAxisId`) with zero matched series
|
|
159
|
+
* and therefore MUI's untouched flat default -- a silent no-op on exactly
|
|
160
|
+
* the dual-axis case this WO was measured against.
|
|
161
|
+
*/
|
|
162
|
+
export function sizeYAxisForContent(yAxis, series, tickFontSize) {
|
|
163
|
+
var _a;
|
|
164
|
+
const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
|
|
165
|
+
const defaultAxisId = (_a = yAxis[0]) === null || _a === void 0 ? void 0 : _a.id;
|
|
166
|
+
return yAxis.map((axis) => {
|
|
167
|
+
var _a;
|
|
168
|
+
if (axis.width != null)
|
|
169
|
+
return axis;
|
|
170
|
+
if (axis.scaleType && axis.scaleType !== 'linear')
|
|
171
|
+
return axis;
|
|
172
|
+
const tickFontSizePx = cssLengthToPixels((_a = axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.fontSize) || fallbackTickFontSizePx;
|
|
173
|
+
if (tickFontSizePx === 0)
|
|
174
|
+
return axis;
|
|
175
|
+
const candidates = [];
|
|
176
|
+
if (typeof axis.min === 'number')
|
|
177
|
+
candidates.push(axis.min);
|
|
178
|
+
if (typeof axis.max === 'number')
|
|
179
|
+
candidates.push(axis.max);
|
|
180
|
+
if (candidates.length === 0) {
|
|
181
|
+
(series || [])
|
|
182
|
+
.filter((item) => { var _a; return ((_a = item.yAxisId) !== null && _a !== void 0 ? _a : defaultAxisId) === axis.id; })
|
|
183
|
+
.forEach((item) => (item.data || []).forEach((value) => {
|
|
184
|
+
if (typeof value === 'number')
|
|
185
|
+
candidates.push(value);
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
if (candidates.length === 0)
|
|
189
|
+
return axis;
|
|
190
|
+
const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
|
|
191
|
+
const tickTextWidth = Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
|
|
192
|
+
const labelThickness = axis.label
|
|
193
|
+
? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
|
|
194
|
+
: 0;
|
|
195
|
+
const width = Math.ceil((tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
|
|
196
|
+
* Y_AXIS_WIDTH_SAFETY_FACTOR);
|
|
197
|
+
return Object.assign(Object.assign({}, axis), { width });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Locale-correct, unit-free tick numbers as the default -- BarChart set none
|
|
202
|
+
* at all (raw numbers straight from the domain), and a hand-rolled `Intl`
|
|
203
|
+
* compact-notation default was measured to be actively wrong: German does
|
|
204
|
+
* not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
|
|
205
|
+
* a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
|
|
206
|
+
* does not solve the width problem and worsens it in one locale. Plain
|
|
207
|
+
* grouped formatting (no `notation: 'compact'`) is safe across all four
|
|
208
|
+
* locales this package's consumers use. Ticks get a short, rounded form;
|
|
209
|
+
* tooltip/legend keep full precision -- teaching screens must not round away
|
|
210
|
+
* a meaningful difference. A caller's own `valueFormatter` always wins (this
|
|
211
|
+
* is only ever used as a default, per `withAxisDefaults`' `{...defaults,
|
|
212
|
+
* ...axis}` merge).
|
|
213
|
+
*/
|
|
214
|
+
export function defaultNumericTickFormatter(locale) {
|
|
215
|
+
return (value, context) => ((context === null || context === void 0 ? void 0 : context.location) === 'tick'
|
|
216
|
+
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
217
|
+
: new Intl.NumberFormat(locale).format(value));
|
|
218
|
+
}
|
|
81
219
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
82
220
|
var _a;
|
|
83
221
|
const position = Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
4
5
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
5
6
|
import { useNeutralChartPalette } from './palette';
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_LEGEND_POSITION,
|
|
9
|
+
defaultNumericTickFormatter,
|
|
10
|
+
sizeYAxisForContent,
|
|
8
11
|
spaceForRotatedTicks,
|
|
9
12
|
withAxisDefaults,
|
|
10
13
|
withChartSlotDefaults,
|
|
@@ -12,8 +15,14 @@ import {
|
|
|
12
15
|
} from './chartDefaults';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
* Responsive MUI X-Charts bar preset.
|
|
16
|
-
*
|
|
18
|
+
* Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
|
|
19
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
20
|
+
* optional, per DESIGN.md #8a: a categorical axis (BarChart's default x-axis
|
|
21
|
+
* shape) needs no title by default -- its ticks are names -- and a numeric
|
|
22
|
+
* axis needs one only unless its unit is already visible in the ticks and
|
|
23
|
+
* the panel heading already names the quantity. Omitting one never suppresses
|
|
24
|
+
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
25
|
+
* an axis without its own `label`).
|
|
17
26
|
*/
|
|
18
27
|
export function BarChart({
|
|
19
28
|
series = [],
|
|
@@ -32,10 +41,8 @@ export function BarChart({
|
|
|
32
41
|
...chartProps
|
|
33
42
|
}) {
|
|
34
43
|
const theme = useTheme();
|
|
44
|
+
const { i18n } = useTranslation();
|
|
35
45
|
const neutralPalette = useNeutralChartPalette();
|
|
36
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
37
|
-
throw new Error('BarChart requires xAxisLabel and yAxisLabel.');
|
|
38
|
-
}
|
|
39
46
|
|
|
40
47
|
const labelledXAxis = withAxisDefaults(
|
|
41
48
|
xAxis,
|
|
@@ -46,7 +53,12 @@ export function BarChart({
|
|
|
46
53
|
const labelledYAxis = withAxisDefaults(
|
|
47
54
|
yAxis,
|
|
48
55
|
yAxisLabel,
|
|
49
|
-
{ scaleType: 'linear' },
|
|
56
|
+
{ scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
|
|
57
|
+
theme.typography.caption.fontSize,
|
|
58
|
+
);
|
|
59
|
+
const sizedYAxis = sizeYAxisForContent(
|
|
60
|
+
labelledYAxis,
|
|
61
|
+
series,
|
|
50
62
|
theme.typography.caption.fontSize,
|
|
51
63
|
);
|
|
52
64
|
const rotatedTickSpace = spaceForRotatedTicks(
|
|
@@ -61,7 +73,7 @@ export function BarChart({
|
|
|
61
73
|
{...chartProps}
|
|
62
74
|
series={series}
|
|
63
75
|
xAxis={rotatedTickSpace.xAxis}
|
|
64
|
-
yAxis={
|
|
76
|
+
yAxis={sizedYAxis}
|
|
65
77
|
colors={palette || neutralPalette.categorical}
|
|
66
78
|
grid={withGridDefaults(grid)}
|
|
67
79
|
hideLegend={hideLegend}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
4
5
|
import {
|
|
5
6
|
LineChart as MuiLineChart,
|
|
6
7
|
MarkElement as MuiMarkElement,
|
|
@@ -8,6 +9,8 @@ import {
|
|
|
8
9
|
import { useNeutralChartPalette } from './palette';
|
|
9
10
|
import {
|
|
10
11
|
DEFAULT_LEGEND_POSITION,
|
|
12
|
+
defaultNumericTickFormatter,
|
|
13
|
+
sizeYAxisForContent,
|
|
11
14
|
spaceForRotatedTicks,
|
|
12
15
|
withAxisDefaults,
|
|
13
16
|
withChartSlotDefaults,
|
|
@@ -29,8 +32,14 @@ function FilledMarkElement({ color, style, ...props }) {
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
|
-
* Responsive MUI X-Charts line preset.
|
|
33
|
-
*
|
|
35
|
+
* Responsive MUI X-Charts line preset. It supplies labelled axes, tooltips,
|
|
36
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
37
|
+
* optional, per DESIGN.md #8a: a categorical axis needs no title by default
|
|
38
|
+
* -- its ticks are names -- and a numeric axis needs one only unless its
|
|
39
|
+
* unit is already visible in the ticks and the panel heading already names
|
|
40
|
+
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
41
|
+
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
42
|
+
* `label`).
|
|
34
43
|
*/
|
|
35
44
|
export function LineChart({
|
|
36
45
|
series = [],
|
|
@@ -50,10 +59,8 @@ export function LineChart({
|
|
|
50
59
|
...chartProps
|
|
51
60
|
}) {
|
|
52
61
|
const theme = useTheme();
|
|
62
|
+
const { i18n } = useTranslation();
|
|
53
63
|
const neutralPalette = useNeutralChartPalette();
|
|
54
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
55
|
-
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
56
|
-
}
|
|
57
64
|
|
|
58
65
|
const labelledXAxis = withAxisDefaults(
|
|
59
66
|
xAxis,
|
|
@@ -64,19 +71,24 @@ export function LineChart({
|
|
|
64
71
|
const labelledYAxis = withAxisDefaults(
|
|
65
72
|
yAxis,
|
|
66
73
|
yAxisLabel,
|
|
67
|
-
{ scaleType: 'linear' },
|
|
74
|
+
{ scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
|
|
68
75
|
theme.typography.caption.fontSize,
|
|
69
76
|
);
|
|
70
|
-
const rotatedTickSpace = spaceForRotatedTicks(
|
|
71
|
-
labelledXAxis,
|
|
72
|
-
margin,
|
|
73
|
-
theme.typography.caption.lineHeight,
|
|
74
|
-
);
|
|
75
77
|
const defaultedSeries = series.map((item) => ({
|
|
76
78
|
showMark: false,
|
|
77
79
|
labelMarkType: 'square',
|
|
78
80
|
...item,
|
|
79
81
|
}));
|
|
82
|
+
const sizedYAxis = sizeYAxisForContent(
|
|
83
|
+
labelledYAxis,
|
|
84
|
+
defaultedSeries,
|
|
85
|
+
theme.typography.caption.fontSize,
|
|
86
|
+
);
|
|
87
|
+
const rotatedTickSpace = spaceForRotatedTicks(
|
|
88
|
+
labelledXAxis,
|
|
89
|
+
margin,
|
|
90
|
+
theme.typography.caption.lineHeight,
|
|
91
|
+
);
|
|
80
92
|
|
|
81
93
|
return (
|
|
82
94
|
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
@@ -84,7 +96,7 @@ export function LineChart({
|
|
|
84
96
|
{...chartProps}
|
|
85
97
|
series={defaultedSeries}
|
|
86
98
|
xAxis={rotatedTickSpace.xAxis}
|
|
87
|
-
yAxis={
|
|
99
|
+
yAxis={sizedYAxis}
|
|
88
100
|
colors={palette || neutralPalette.categorical}
|
|
89
101
|
grid={withGridDefaults(grid)}
|
|
90
102
|
hideLegend={hideLegend}
|
|
@@ -9,7 +9,7 @@ export function withGridDefaults(grid) {
|
|
|
9
9
|
|
|
10
10
|
export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
11
11
|
const values = axes?.length ? axes : [defaults];
|
|
12
|
-
return values.map((axis) => ({
|
|
12
|
+
return values.map((axis, index) => ({
|
|
13
13
|
// `defaults.scaleType` (and any other scale-defining default) must
|
|
14
14
|
// survive even when the caller passes their OWN xAxis/yAxis array --
|
|
15
15
|
// confirmed live in the dev harness: a caller-supplied axis with `data`
|
|
@@ -25,7 +25,17 @@ export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
|
25
25
|
fontSize: tickFontSize,
|
|
26
26
|
...axis.tickLabelStyle,
|
|
27
27
|
},
|
|
28
|
-
|
|
28
|
+
// THEME-9: the single `label` argument is the caller's ONE string for
|
|
29
|
+
// this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
|
|
30
|
+
// fall back onto ONE axis. Applying it via .map() to every entry
|
|
31
|
+
// broadcast the identical label onto every axis in a multi-axis array
|
|
32
|
+
// (measured live: hram's dual-axis chart carried the same 43-character
|
|
33
|
+
// label on both y-axes). Only the first entry -- the common single-axis
|
|
34
|
+
// case is index 0 -- gets the fallback; a caller building a genuine
|
|
35
|
+
// multi-axis array must label each entry itself (as TimeSeriesChart's
|
|
36
|
+
// own secondary-axis support already does), exactly like MUI's own
|
|
37
|
+
// default `position: 'left'` (only the first entry, MUI docs).
|
|
38
|
+
label: axis.label || (index === 0 ? label : undefined),
|
|
29
39
|
}));
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -100,6 +110,136 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
|
100
110
|
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
114
|
+
// 20px (label, if present) = 65px regardless of what the ticks actually say
|
|
115
|
+
// -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
|
|
116
|
+
// AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
|
|
117
|
+
// (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
|
|
118
|
+
// are ADDITIVE, not one containing the other). That flat default is both too
|
|
119
|
+
// generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
|
|
120
|
+
// ("50.0 min"), which is exactly the measured overlap on the Accessibility
|
|
121
|
+
// y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
|
|
122
|
+
const Y_AXIS_TICK_SIZE = 6;
|
|
123
|
+
const Y_AXIS_TICK_LABEL_GAP = 2;
|
|
124
|
+
const Y_AXIS_LABEL_TICK_GAP = 2;
|
|
125
|
+
// A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
|
|
126
|
+
// WIDTH once rotated -- runs a bit past its raw font size once ascender/
|
|
127
|
+
// descender room is included; matches the ~18px measured live for a 14px
|
|
128
|
+
// label (14 * 1.3 ~= 18).
|
|
129
|
+
const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
|
|
130
|
+
// A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
|
|
131
|
+
// note applies here too -- a too-tight cut recreates the overlap it was
|
|
132
|
+
// meant to fix, and glyph-width heuristics are approximate by nature.
|
|
133
|
+
const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
|
|
134
|
+
// MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
|
|
135
|
+
// (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
|
|
136
|
+
// -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
|
|
137
|
+
// regardless of what body1 actually resolves to). Matching that literal
|
|
138
|
+
// constant here -- rather than reading it off `theme.typography.body1` --
|
|
139
|
+
// keeps this sizing correct even if this package's body1 size ever changes;
|
|
140
|
+
// coupling it to body1 was only ever coincidentally right because body1
|
|
141
|
+
// happens to be 14px today.
|
|
142
|
+
const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
|
|
143
|
+
|
|
144
|
+
function longestFormattedTick(values, valueFormatter) {
|
|
145
|
+
return values.reduce((longest, value) => {
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
|
|
149
|
+
} catch {
|
|
150
|
+
// Some formatters require MUI's complete scale context; falling back
|
|
151
|
+
// to the raw value is conservative -- it reserves at least as much
|
|
152
|
+
// space as the unformatted number would need.
|
|
153
|
+
text = String(value);
|
|
154
|
+
}
|
|
155
|
+
return text.length > longest.length ? text : longest;
|
|
156
|
+
}, '');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sizes a linear y-axis to its actual tick content instead of MUI's flat
|
|
161
|
+
* default. Only touches axes the caller left unsized (`axis.width == null`)
|
|
162
|
+
* and only linear/numeric ones -- a categorical axis's ticks are names, not
|
|
163
|
+
* numbers, and sizing those is not this function's job. Candidate values come
|
|
164
|
+
* from the axis' own `min`/`max` when the caller set them (as every current
|
|
165
|
+
* consumer does), falling back to the series actually plotted against this
|
|
166
|
+
* axis; if neither yields a candidate, the axis is left untouched rather than
|
|
167
|
+
* guessed at.
|
|
168
|
+
*
|
|
169
|
+
* `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
|
|
170
|
+
* (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
|
|
171
|
+
* read first, so a caller-tuned tick font is sized correctly rather than
|
|
172
|
+
* against the package-wide default. The axis LABEL's font is not a caller
|
|
173
|
+
* knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
|
|
174
|
+
*
|
|
175
|
+
* The series-to-axis match for the fallback deliberately mirrors MUI's own
|
|
176
|
+
* default-assignment rule, not a naive `undefined === undefined`: a series
|
|
177
|
+
* with no explicit `yAxisId` is assigned to the FIRST axis in the array
|
|
178
|
+
* (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
|
|
179
|
+
* `undefined === undefined` left the primary axis of a real dual-axis chart
|
|
180
|
+
* (id `'primary'`, its series never set `yAxisId`) with zero matched series
|
|
181
|
+
* and therefore MUI's untouched flat default -- a silent no-op on exactly
|
|
182
|
+
* the dual-axis case this WO was measured against.
|
|
183
|
+
*/
|
|
184
|
+
export function sizeYAxisForContent(yAxis, series, tickFontSize) {
|
|
185
|
+
const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
|
|
186
|
+
const defaultAxisId = yAxis[0]?.id;
|
|
187
|
+
return yAxis.map((axis) => {
|
|
188
|
+
if (axis.width != null) return axis;
|
|
189
|
+
if (axis.scaleType && axis.scaleType !== 'linear') return axis;
|
|
190
|
+
|
|
191
|
+
const tickFontSizePx = cssLengthToPixels(axis.tickLabelStyle?.fontSize) || fallbackTickFontSizePx;
|
|
192
|
+
if (tickFontSizePx === 0) return axis;
|
|
193
|
+
|
|
194
|
+
const candidates = [];
|
|
195
|
+
if (typeof axis.min === 'number') candidates.push(axis.min);
|
|
196
|
+
if (typeof axis.max === 'number') candidates.push(axis.max);
|
|
197
|
+
if (candidates.length === 0) {
|
|
198
|
+
(series || [])
|
|
199
|
+
.filter((item) => (item.yAxisId ?? defaultAxisId) === axis.id)
|
|
200
|
+
.forEach((item) => (item.data || []).forEach((value) => {
|
|
201
|
+
if (typeof value === 'number') candidates.push(value);
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
if (candidates.length === 0) return axis;
|
|
205
|
+
|
|
206
|
+
const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
|
|
207
|
+
const tickTextWidth = Math.max(
|
|
208
|
+
tickFontSizePx,
|
|
209
|
+
longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
|
|
210
|
+
);
|
|
211
|
+
const labelThickness = axis.label
|
|
212
|
+
? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
|
|
213
|
+
: 0;
|
|
214
|
+
const width = Math.ceil(
|
|
215
|
+
(tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
|
|
216
|
+
* Y_AXIS_WIDTH_SAFETY_FACTOR,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return { ...axis, width };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Locale-correct, unit-free tick numbers as the default -- BarChart set none
|
|
225
|
+
* at all (raw numbers straight from the domain), and a hand-rolled `Intl`
|
|
226
|
+
* compact-notation default was measured to be actively wrong: German does
|
|
227
|
+
* not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
|
|
228
|
+
* a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
|
|
229
|
+
* does not solve the width problem and worsens it in one locale. Plain
|
|
230
|
+
* grouped formatting (no `notation: 'compact'`) is safe across all four
|
|
231
|
+
* locales this package's consumers use. Ticks get a short, rounded form;
|
|
232
|
+
* tooltip/legend keep full precision -- teaching screens must not round away
|
|
233
|
+
* a meaningful difference. A caller's own `valueFormatter` always wins (this
|
|
234
|
+
* is only ever used as a default, per `withAxisDefaults`' `{...defaults,
|
|
235
|
+
* ...axis}` merge).
|
|
236
|
+
*/
|
|
237
|
+
export function defaultNumericTickFormatter(locale) {
|
|
238
|
+
return (value, context) => (context?.location === 'tick'
|
|
239
|
+
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
240
|
+
: new Intl.NumberFormat(locale).format(value));
|
|
241
|
+
}
|
|
242
|
+
|
|
103
243
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
104
244
|
const position = {
|
|
105
245
|
...DEFAULT_LEGEND_POSITION,
|
package/tests/BarChart.test.jsx
CHANGED
|
@@ -50,9 +50,25 @@ describe('BarChart preset', () => {
|
|
|
50
50
|
expect(chartSpy.mock.calls.at(-1)[0].colors).toEqual(['var(--application-chart-colour)']);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
// THEME-9: DESIGN.md #8a makes an axis title the caller's per-axis
|
|
54
|
+
// decision (categorical axes default to none) -- a hard "both required"
|
|
55
|
+
// throw made that default unachievable through this preset. Omitting a
|
|
56
|
+
// label now renders no title for that axis rather than crashing.
|
|
57
|
+
it('renders without a title on an axis whose label was omitted', () => {
|
|
58
|
+
render(
|
|
59
|
+
<ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} xAxisLabel="Year" /></ThemeProvider>,
|
|
60
|
+
);
|
|
61
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
62
|
+
expect(props.xAxis[0].label).toBe('Year');
|
|
63
|
+
expect(props.yAxis[0].label).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('renders with no title on either axis when both labels are omitted', () => {
|
|
67
|
+
render(
|
|
68
|
+
<ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} /></ThemeProvider>,
|
|
69
|
+
);
|
|
70
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
71
|
+
expect(props.xAxis[0].label).toBeUndefined();
|
|
72
|
+
expect(props.yAxis[0].label).toBeUndefined();
|
|
57
73
|
});
|
|
58
74
|
});
|
|
@@ -216,12 +216,17 @@ describe('TimeSeriesChart', () => {
|
|
|
216
216
|
],
|
|
217
217
|
};
|
|
218
218
|
|
|
219
|
-
it('CHART-5: no custom yAxis when the shared single axis is not all-integer (today\'s real case)', () => {
|
|
219
|
+
it('CHART-5: no custom (integer-blanking) yAxis formatter when the shared single axis is not all-integer (today\'s real case)', () => {
|
|
220
220
|
renderChart({ data: MIXED_DATA });
|
|
221
221
|
|
|
222
222
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
223
223
|
expect(props.yAxis).toHaveLength(1);
|
|
224
|
-
|
|
224
|
+
// THEME-9: BarChart now always supplies a default (locale-grouped, non-
|
|
225
|
+
// blanking) tick formatter, so this is no longer undefined -- but it
|
|
226
|
+
// must still be BarChart's generic default, not CHART-5's own
|
|
227
|
+
// integer-blanking one (which would wrongly blank the 4.5/5.2/6.1 ticks).
|
|
228
|
+
expect(typeof props.yAxis[0].valueFormatter).toBe('function');
|
|
229
|
+
expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
225
230
|
expect(props.yAxis[0].label).toBe('Count');
|
|
226
231
|
});
|
|
227
232
|
|
|
@@ -293,8 +298,13 @@ describe('TimeSeriesChart', () => {
|
|
|
293
298
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
294
299
|
const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
|
|
295
300
|
const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
|
|
296
|
-
expect(
|
|
297
|
-
expect(
|
|
301
|
+
expect(primaryAxis.valueFormatter(2, { location: 'tick' })).toBe('2');
|
|
302
|
+
expect(primaryAxis.valueFormatter(2.5, { location: 'tick' })).toBe('');
|
|
303
|
+
// THEME-9: BarChart's own default formatter now covers the secondary
|
|
304
|
+
// axis too (a function, not undefined) -- distinguished from the
|
|
305
|
+
// integer-blanking one above by NOT blanking a non-integer tick.
|
|
306
|
+
expect(typeof secondaryAxis.valueFormatter).toBe('function');
|
|
307
|
+
expect(secondaryAxis.valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
298
308
|
});
|
|
299
309
|
|
|
300
310
|
it('CHART-5: throws when a series declares axis "secondary" without secondaryYAxisLabel', () => {
|
|
@@ -318,12 +328,15 @@ describe('TimeSeriesChart', () => {
|
|
|
318
328
|
|
|
319
329
|
it('CHART-5: toggling the non-integer series off switches the shared axis to integer-formatted', () => {
|
|
320
330
|
renderChart({ data: MIXED_DATA });
|
|
321
|
-
|
|
331
|
+
// THEME-9: BarChart's default formatter is present from the start now,
|
|
332
|
+
// so the CHART-5-specific signal is that a non-integer tick is NOT
|
|
333
|
+
// blanked yet -- the integer-blanking formatter isn't active.
|
|
334
|
+
expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
322
335
|
|
|
323
336
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
|
|
324
337
|
|
|
325
338
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
326
|
-
expect(
|
|
339
|
+
expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).toBe('');
|
|
327
340
|
});
|
|
328
341
|
|
|
329
342
|
it('CHART-5: x-axis tickLabelInterval guarantees some labels always show, evenly spread', () => {
|
|
@@ -14,6 +14,11 @@ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'pa
|
|
|
14
14
|
import { BarChart } from '../src/components/charts/BarChart';
|
|
15
15
|
import { LineChart } from '../src/components/charts/LineChart';
|
|
16
16
|
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
17
|
+
import {
|
|
18
|
+
defaultNumericTickFormatter,
|
|
19
|
+
sizeYAxisForContent,
|
|
20
|
+
withAxisDefaults,
|
|
21
|
+
} from '../src/components/charts/chartDefaults';
|
|
17
22
|
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
18
23
|
|
|
19
24
|
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
@@ -206,3 +211,153 @@ describe('chart chrome defaults', () => {
|
|
|
206
211
|
});
|
|
207
212
|
});
|
|
208
213
|
});
|
|
214
|
+
|
|
215
|
+
// THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
|
|
216
|
+
// yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
|
|
217
|
+
// Measured live: hram's dual-axis chart carried the identical 43-character
|
|
218
|
+
// label on both y-axes. This is the defect that actually shipped, pinned
|
|
219
|
+
// directly against the exported function rather than only through a
|
|
220
|
+
// rendered BarChart (which never happens to build a multi-axis array).
|
|
221
|
+
describe('withAxisDefaults label broadcast (THEME-9)', () => {
|
|
222
|
+
it('applies the single label argument only to the first axis of a multi-entry array', () => {
|
|
223
|
+
const result = withAxisDefaults(
|
|
224
|
+
[{ id: 'primary' }, { id: 'secondary' }],
|
|
225
|
+
'Shared label',
|
|
226
|
+
{ scaleType: 'linear' },
|
|
227
|
+
'12px',
|
|
228
|
+
);
|
|
229
|
+
expect(result[0].label).toBe('Shared label');
|
|
230
|
+
expect(result[1].label).toBeUndefined();
|
|
231
|
+
// The exact defect: never let one string land on two axes.
|
|
232
|
+
expect(result.filter((axis) => axis.label === 'Shared label')).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('leaves axes that set their own label untouched, whatever position they are in', () => {
|
|
236
|
+
const result = withAxisDefaults(
|
|
237
|
+
[{ label: 'Primary' }, { label: 'Secondary' }],
|
|
238
|
+
'Fallback',
|
|
239
|
+
{ scaleType: 'linear' },
|
|
240
|
+
'12px',
|
|
241
|
+
);
|
|
242
|
+
expect(result[0].label).toBe('Primary');
|
|
243
|
+
expect(result[1].label).toBe('Secondary');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('still labels the common single-axis case unchanged', () => {
|
|
247
|
+
const result = withAxisDefaults([{ id: 'only' }], 'Cases', { scaleType: 'linear' }, '12px');
|
|
248
|
+
expect(result[0].label).toBe('Cases');
|
|
249
|
+
|
|
250
|
+
const synthesized = withAxisDefaults(undefined, 'Cases', { scaleType: 'linear' }, '12px');
|
|
251
|
+
expect(synthesized[0].label).toBe('Cases');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// THEME-9 #4 -- BarChart set no default tick formatter at all (raw domain
|
|
256
|
+
// numbers); a hand-rolled `Intl` compact-notation default was measured to be
|
|
257
|
+
// actively wrong in two of the four locales this package's consumers use:
|
|
258
|
+
// German does not compact thousands at all, and Swahili prepends a word
|
|
259
|
+
// ("elfu") that is WIDER than the raw number. Plain grouped formatting (no
|
|
260
|
+
// `notation: 'compact'`) is pinned here against a live `Intl.NumberFormat`
|
|
261
|
+
// computation rather than hardcoded locale punctuation, so the assertion
|
|
262
|
+
// survives ICU data updates.
|
|
263
|
+
describe('defaultNumericTickFormatter (THEME-9)', () => {
|
|
264
|
+
it.each(['de-CH', 'en', 'fr', 'sw'])('formats %s ticks the same way Intl grouping would, with no compact notation', (locale) => {
|
|
265
|
+
const formatter = defaultNumericTickFormatter(locale);
|
|
266
|
+
for (const value of [12500, 998000, 1000000000]) {
|
|
267
|
+
const expected = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
|
|
268
|
+
expect(formatter(value, { location: 'tick' })).toBe(expected);
|
|
269
|
+
// The exact failure mode measured live: compact notation prepending a
|
|
270
|
+
// wider word in Swahili, or a 'K'/'M'/'B' suffix elsewhere.
|
|
271
|
+
expect(formatter(value, { location: 'tick' })).not.toMatch(/[KMB]|elfu/i);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('keeps full precision outside the tick context (tooltip/legend) rather than rounding away a meaningful difference', () => {
|
|
276
|
+
const formatter = defaultNumericTickFormatter('en');
|
|
277
|
+
expect(formatter(12500.456, { location: 'tooltip' })).toBe('12,500.456');
|
|
278
|
+
expect(formatter(12500.456, { location: 'tick' })).toBe('12,500.5');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// THEME-9 #1/#3 -- MUI's own y-axis width default is a flat 45px (ticks) +
|
|
283
|
+
// 20px (label) regardless of what the ticks actually say (confirmed against
|
|
284
|
+
// @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH/AXIS_LABEL_DEFAULT_HEIGHT and
|
|
285
|
+
// its useChartDimensions selector, where margin and axis size are additive).
|
|
286
|
+
// That is both too generous for short ticks and too stingy for wide
|
|
287
|
+
// unit-suffixed ones -- the measured Accessibility-panel overlap. Margins
|
|
288
|
+
// and the exact rendered overlap are verified live, not by unit test (per
|
|
289
|
+
// the WO); this pins the sizing FUNCTION's own contract.
|
|
290
|
+
describe('sizeYAxisForContent (THEME-9)', () => {
|
|
291
|
+
it('sizes a linear axis from its own caller-supplied min/max', () => {
|
|
292
|
+
const [axis] = sizeYAxisForContent(
|
|
293
|
+
[{ scaleType: 'linear', min: 0, max: 1, label: 'Coverage (%)' }],
|
|
294
|
+
[],
|
|
295
|
+
'12px',
|
|
296
|
+
);
|
|
297
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('reserves more width for a longer formatted tick, all else equal', () => {
|
|
301
|
+
const [shortTick] = sizeYAxisForContent(
|
|
302
|
+
[{ scaleType: 'linear', min: 0, max: 1 }], [], '12px',
|
|
303
|
+
);
|
|
304
|
+
const [wideTick] = sizeYAxisForContent(
|
|
305
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: (v) => `${v} minutes` }],
|
|
306
|
+
[], '12px',
|
|
307
|
+
);
|
|
308
|
+
expect(wideTick.width).toBeGreaterThan(shortTick.width);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('falls back to the plotted series when the caller sets neither min nor max', () => {
|
|
312
|
+
const [axis] = sizeYAxisForContent(
|
|
313
|
+
[{ scaleType: 'linear' }],
|
|
314
|
+
[{ data: [1, 250000] }],
|
|
315
|
+
'12px',
|
|
316
|
+
);
|
|
317
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('leaves the axis untouched when there is nothing to measure against', () => {
|
|
321
|
+
const [axis] = sizeYAxisForContent([{ scaleType: 'linear' }], [], '12px');
|
|
322
|
+
expect(axis.width).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('never overrides a caller-supplied width', () => {
|
|
326
|
+
const [axis] = sizeYAxisForContent(
|
|
327
|
+
[{ scaleType: 'linear', min: 0, max: 1, width: 42 }], [], '12px',
|
|
328
|
+
);
|
|
329
|
+
expect(axis.width).toBe(42);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
|
|
333
|
+
const [axis] = sizeYAxisForContent(
|
|
334
|
+
[{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
|
|
335
|
+
);
|
|
336
|
+
expect(axis.width).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Reviewer-caught regression (THEME-9): a series with no explicit
|
|
340
|
+
// `yAxisId` is NOT "assigned to no axis" -- MUI defaults it to the FIRST
|
|
341
|
+
// axis in the array (`yAxisIds[0]`), whatever that axis' id is. Matching
|
|
342
|
+
// only `undefined === undefined` left the primary axis of a genuine
|
|
343
|
+
// dual-axis chart (id set, series unmarked) with zero matched series and
|
|
344
|
+
// therefore MUI's untouched flat default -- a silent no-op on exactly the
|
|
345
|
+
// dual-axis case this WO was measured against (TimeSeriesChart's own
|
|
346
|
+
// secondary-axis feature, CHART-5).
|
|
347
|
+
it('assigns an unmarked series to the FIRST axis in a dual-axis array, matching MUI\'s own default', () => {
|
|
348
|
+
const dualAxis = [{ scaleType: 'linear', id: 'primary' }, { scaleType: 'linear', id: 'secondary' }];
|
|
349
|
+
const series = [
|
|
350
|
+
{ data: [1, 2, 3] }, // no yAxisId -- MUI assigns this to 'primary'
|
|
351
|
+
{ data: [400000, 500000], yAxisId: 'secondary' },
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
const [primary, secondary] = sizeYAxisForContent(dualAxis, series, '12px');
|
|
355
|
+
expect(primary.width).toBeGreaterThan(0);
|
|
356
|
+
expect(secondary.width).toBeGreaterThan(0);
|
|
357
|
+
// The secondary axis' series format much wider numbers -- it must
|
|
358
|
+
// reserve more width than the primary axis, not the same (which is what
|
|
359
|
+
// "both axes silently untouched" or "both matched the same series" would
|
|
360
|
+
// produce).
|
|
361
|
+
expect(secondary.width).toBeGreaterThan(primary.width);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# THEME-9 — Charts spend more space on furniture than on data
|
|
2
|
+
|
|
3
|
+
## Part A — Envelope
|
|
4
|
+
|
|
5
|
+
**Goal.** Give the chart presets back the space their axis furniture currently reserves and
|
|
6
|
+
does not use, stop broadcasting one axis label onto every axis, keep the axis label out of
|
|
7
|
+
the tick band, and make short, locale-correct tick numbers the default.
|
|
8
|
+
|
|
9
|
+
**Why — measured in a consuming app (hram), not estimated.** One KPI card, "Coverage":
|
|
10
|
+
|
|
11
|
+
| layer | size | owner |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| card (`Paper`, 16 px padding) | 470 × 372 | app |
|
|
14
|
+
| chart wrapper `Box` | 436 × **300** (`minHeight: 300`) | **this package** — the SVG inside is 270 tall, so **30 px are dead** |
|
|
15
|
+
| SVG | 436 × 270 | this package |
|
|
16
|
+
| **plot area** | **301 × 175** | — |
|
|
17
|
+
|
|
18
|
+
**The plot is 45 % of the SVG and 30 % of the card.** 135 px of width and 95 px of height are
|
|
19
|
+
consumed inside the SVG. Walking the left edge outward: 17 px card padding, then **60 px of
|
|
20
|
+
blank between the SVG edge and the axis label**, then 9 px, then the first tick — 104 px before
|
|
21
|
+
a number appears. Below the chart: 30 px of blank inside the SVG under the axis label, then
|
|
22
|
+
another 32 px to the card edge.
|
|
23
|
+
|
|
24
|
+
Three further findings from the same app, all measured:
|
|
25
|
+
|
|
26
|
+
1. **One label is applied to every axis.** `withAxisDefaults` ends with
|
|
27
|
+
`label: axis.label || label`, so a single `yAxisLabel` lands on *all* axes in the array. In
|
|
28
|
+
hram's dual-axis chart both y-axes therefore carry the identical 43-character string
|
|
29
|
+
`"Treated / Untreated / Saved / Avoid. Deaths"`, each reserving 65 px of a 206 px-wide chart.
|
|
30
|
+
2. **The axis label is drawn inside the tick band.** On hram's Accessibility chart **4 of 4**
|
|
31
|
+
y-tick labels overlap the axis label — the rotated title runs straight through
|
|
32
|
+
`"0.0 min"`, `"50.0 min"`, … The same happens on an x-axis whose tick labels are rotated
|
|
33
|
+
(30+ ward names over the label `"Ward"`). The space reserved for rotated ticks does not
|
|
34
|
+
actually keep the label clear of them.
|
|
35
|
+
3. **Ticks carry units and needless decimals.** `"0.0 min"`, `"50.0 min"` — the unit appears in
|
|
36
|
+
every tick *and* twice in the label (`"Accessibility (minutes) (min)"`). Elsewhere MUI
|
|
37
|
+
truncates ticks it cannot fit, rendering `"0.0…"`, `"50.0…"`. `BarChart` sets no default tick
|
|
38
|
+
formatter at all, while `TimeSeriesChart` already applies one conditionally — so every app
|
|
39
|
+
re-solves this, and where nobody remembered, the numbers are wide and truncated.
|
|
40
|
+
|
|
41
|
+
**Scope.**
|
|
42
|
+
|
|
43
|
+
1. **Shrink the default margins and the axis-size reservation.** MUI X sizes axes itself; the
|
|
44
|
+
generous fixed values here predate that. Target the measured waste: ~60 px of blank left of
|
|
45
|
+
the label and ~30 px below it. Re-measure in a consuming app afterwards — the number to beat
|
|
46
|
+
is "plot area = 45 % of the SVG".
|
|
47
|
+
2. **Stop broadcasting the label.** An axis label is set on the axis that needs it, not on every
|
|
48
|
+
axis in the array. Add a test that pins this: one label must never land on two axes. That is
|
|
49
|
+
the exact defect measured above, and nothing currently prevents it.
|
|
50
|
+
3. **Keep the label out of the tick band.** Whatever space rotated or long tick labels occupy
|
|
51
|
+
must be excluded from where the axis label is placed. Verify with the real case — rotated
|
|
52
|
+
45° ward names, and a y-axis whose ticks are wide.
|
|
53
|
+
4. **A default tick formatter for `BarChart`** (and any preset lacking one): short numbers, no
|
|
54
|
+
decorative decimals, no unit in the tick. **Do not simply switch on `Intl` compact
|
|
55
|
+
notation** — measured across the four locales this package's consumers use:
|
|
56
|
+
|
|
57
|
+
| value | de-CH | en | fr | sw |
|
|
58
|
+
|---|---|---|---|---|
|
|
59
|
+
| 12 500 | **`12'500`** | `12.5K` | `12,5 k` | **`elfu 12.5`** |
|
|
60
|
+
| 998 000 | **`998'000`** | `998K` | `998 k` | **`elfu 998`** |
|
|
61
|
+
| 1 000 000 000 | `1 Mrd.` | `1B` | `1 Md` | `1B` |
|
|
62
|
+
|
|
63
|
+
German does not compact thousands **at all** — exactly the range these charts live in — and
|
|
64
|
+
Swahili puts a word in front of the number, which is wider than the raw value. So compact
|
|
65
|
+
notation alone does not solve the width problem and in one locale makes it worse. A
|
|
66
|
+
per-axis unit chosen once (with the unit named in the axis label) does. Also note: a
|
|
67
|
+
hand-rolled `"B"` suffix would be **wrong in German**, where *Billion* is 10¹² — `Intl` gets
|
|
68
|
+
this right (`Mrd.`), hand-rolled suffixes do not.
|
|
69
|
+
5. **Document the decision rule in `DESIGN.md`**, where a developer will find it at the call
|
|
70
|
+
site:
|
|
71
|
+
- **Categorical axis → no title by default.** The tick values are names; a title only
|
|
72
|
+
restates their type. Exception: ticks that are codes or abbreviations whose kind is not
|
|
73
|
+
self-evident.
|
|
74
|
+
- **Numeric axis → title carries the quantity and the unit.** A bare number is meaningless
|
|
75
|
+
without one. Exception: the ticks already show the unit (`%`, a currency symbol).
|
|
76
|
+
- **The unit belongs in the title once — never in every tick.**
|
|
77
|
+
|
|
78
|
+
**Non-goals / do-not-touch.** No new chart types, no colour or typography changes, no change to
|
|
79
|
+
what any chart plots. **Do not auto-suppress axis labels** — make them opt-in, but leave the
|
|
80
|
+
decision with the caller; silently dropping a label someone deliberately set is a worse failure
|
|
81
|
+
than the redundancy we are removing. Do not fix any of this per-app with `sx` overrides.
|
|
82
|
+
|
|
83
|
+
**Tier 3** — shared-core surface. `reviewer` mandatory, `ui_reviewer` too: the visible result is
|
|
84
|
+
a layout change in every consuming app.
|
|
85
|
+
|
|
86
|
+
**Tests to write.** The one-label-per-axis assertion (scope 2) — it is cheap and it is the
|
|
87
|
+
defect that actually shipped. A formatter test with the four locales above as fixtures, so the
|
|
88
|
+
German and Swahili behaviour is pinned rather than rediscovered. Margins and overlap are carried
|
|
89
|
+
by the rendered check, not by unit tests.
|
|
90
|
+
|
|
91
|
+
**Risks.**
|
|
92
|
+
- **Estate-wide visual change**, in apps nobody is currently working on. Name it in the release
|
|
93
|
+
notes; do not let it arrive as a surprise.
|
|
94
|
+
- **Tighter margins can clip** where they previously over-reserved. The overlap fix (scope 3)
|
|
95
|
+
and the margin cut (scope 1) pull in opposite directions — do them together and measure, or
|
|
96
|
+
the second will re-create the first.
|
|
97
|
+
- The tick formatter changes numbers on screens used for teaching. Rounding that hides a
|
|
98
|
+
meaningful difference is worse than a wide label: keep full precision in tooltips and tables,
|
|
99
|
+
compact only where space is scarce.
|
|
100
|
+
|
|
101
|
+
**Delivery is not done at publish.** Consuming apps need their pin bumped and their charts
|
|
102
|
+
looked at — hram first, where all of this was measured, and where the app-side follow-up is
|
|
103
|
+
tracked as `FIX-13`.
|
|
104
|
+
|
|
105
|
+
## Part B — Implementation map — ADDRESSED TO THE IMPLEMENTER
|
|
106
|
+
|
|
107
|
+
Codex was not dispatched: `.claude/codex-status.md` already carried a same-day (2026-08-14)
|
|
108
|
+
`unavailable` line (recorded earlier during `THEME-8`), so per the known-unavailable shortcut
|
|
109
|
+
the Orchestrator implemented directly, which flips authorship — `reviewer` + `ui_reviewer`
|
|
110
|
+
became mandatory (already required at Tier 3).
|
|
111
|
+
|
|
112
|
+
### What landed, and why (measured, not guessed)
|
|
113
|
+
|
|
114
|
+
- **`withAxisDefaults` label broadcast (scope 2).** Fixed: the single `label` argument now falls
|
|
115
|
+
back only onto `index === 0` of a multi-entry axis array — mirrors MUI's own
|
|
116
|
+
`position: 'left'`-only-on-first-entry default. Test: `chartDefaults.test.js`.
|
|
117
|
+
- **`sizeYAxisForContent`, new (scope 1/3).** MUI's own y-axis width default is a flat 45px+20px
|
|
118
|
+
regardless of tick content (verified against `@mui/x-charts`' `DEFAULT_AXIS_SIZE_WIDTH` /
|
|
119
|
+
`AXIS_LABEL_DEFAULT_HEIGHT` and its `useChartDimensions` selector: margin and axis-size are
|
|
120
|
+
ADDITIVE, not nested — confirmed pixel-for-pixel against a live hram render, see below). This
|
|
121
|
+
sizes a linear y-axis from its own `min`/`max` or the plotted series, mirroring
|
|
122
|
+
`spaceForRotatedTicks`'s existing glyph-width heuristic.
|
|
123
|
+
- **`defaultNumericTickFormatter`, new (scope 4).** Locale-grouped, non-compact by default —
|
|
124
|
+
verified live against `Intl.NumberFormat` output for all four locales (de-CH does not compact
|
|
125
|
+
thousands at all; sw's compact form is WIDER than the raw number).
|
|
126
|
+
- **DESIGN.md #8a, new (scope 5).** The categorical/numeric axis-title rule.
|
|
127
|
+
- **Not originally scoped, added because scope 5 required it:** `BarChart`/`LineChart` used to
|
|
128
|
+
hard-`throw` unless BOTH `xAxisLabel` and `yAxisLabel` were supplied — this made #8a's stated
|
|
129
|
+
"categorical axis gets no title by default" unachievable through either preset (`ui_reviewer`
|
|
130
|
+
finding U1). Both labels are now optional; omitting one renders no title for that axis. This is
|
|
131
|
+
the change FIX-13 needs to actually remove a title.
|
|
132
|
+
|
|
133
|
+
### The Coverage-KPI-card measurement itself was NOT a package-level bug
|
|
134
|
+
|
|
135
|
+
Live DOM measurement in the running hram app (`localhost:8000`, Basic Results → Coverage card)
|
|
136
|
+
traced the Envelope's own "60px blank / 30px dead" numbers to their exact source:
|
|
137
|
+
`drawingArea.left = margin.left + axisSizeLeft` (MUI's own `useChartDimensions` selector) — the
|
|
138
|
+
60px is hram's OWN `margin={{left: 60, ...}}` prop on `TotalMetricsPanel.jsx`, stacking
|
|
139
|
+
ADDITIVELY with MUI's separate ~65px axis-width reservation, not one containing the other. That
|
|
140
|
+
duplication is `FIX-13`'s item 6 to fix (trim the app's own margin now that this package sizes
|
|
141
|
+
the axis itself), not something `THEME-9` could fix by itself — packages can right-size their own
|
|
142
|
+
axis reservation, not an app's separately-chosen margin.
|
|
143
|
+
|
|
144
|
+
### Review findings, fixed before commit
|
|
145
|
+
|
|
146
|
+
- **`reviewer` R1 (P1):** `sizeYAxisForContent`'s series-to-axis matching used
|
|
147
|
+
`(item.yAxisId ?? undefined) === (axis.id ?? undefined)`, which only matches when BOTH sides are
|
|
148
|
+
literally `undefined` — but MUI assigns an unmarked series to the FIRST axis in the array
|
|
149
|
+
(`yAxisIds[0]`), not to "no axis". This made the sizer a silent no-op for the PRIMARY axis of
|
|
150
|
+
exactly the dual-axis case (`TimeSeriesChart`'s `CHART-5` feature) that motivated this WO. Fixed:
|
|
151
|
+
match against `yAxis[0]?.id` as the default, not `undefined`. Regression test added.
|
|
152
|
+
- **`ui_reviewer` U1 (P1):** see "not originally scoped" above — labels made optional.
|
|
153
|
+
- **`ui_reviewer` U3 (P2):** the sizer used the theme-wide tick font size even when a caller
|
|
154
|
+
overrode `tickLabelStyle.fontSize` per-axis. Fixed: reads the axis' own (already-merged)
|
|
155
|
+
`tickLabelStyle.fontSize` first, falling back to the theme default only if absent.
|
|
156
|
+
- **`ui_reviewer` U5 (P3):** the label-thickness term was derived from `theme.typography.body1`,
|
|
157
|
+
which only coincidentally matches MUI's own HARDCODED 14px axis-label font
|
|
158
|
+
(`ChartsYAxisImpl.js` sets `fontSize: 14` after spreading `body1`, so it always wins regardless
|
|
159
|
+
of theme). Fixed: a named `MUI_AXIS_LABEL_FONT_SIZE_PX = 14` constant, decoupled from theme.
|
|
160
|
+
- **`ui_reviewer` U2 (P2, accepted as a documented limitation, not fixed):** the width estimate is
|
|
161
|
+
driven by the axis' own `min`/`max`/series values, not MUI's actual "nice-tick" rounding, which
|
|
162
|
+
can occasionally round a domain's extremes outward to a longer string. The existing 1.1x safety
|
|
163
|
+
factor absorbs small overshoots; a tighter fix would require duplicating MUI's own tick
|
|
164
|
+
algorithm, out of proportion for this WO. The rendered check below is the actual proof either way.
|
|
165
|
+
- **`ui_reviewer` U4:** claimed `TimeSeriesChart.jsx` bypasses `withAxisDefaults`/
|
|
166
|
+
`sizeYAxisForContent` entirely. Verified against source: `TimeSeriesChart` renders through this
|
|
167
|
+
package's own `<BarChart>` (not `MuiLineChart` directly), so its `yAxis` array — including the
|
|
168
|
+
dual-axis case — DOES flow through both functions. Not acted on; recorded here so the claim
|
|
169
|
+
isn't silently dropped.
|
|
170
|
+
|
|
171
|
+
### Target repo
|
|
172
|
+
|
|
173
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
174
|
+
|
|
175
|
+
## Part C — Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
|
|
176
|
+
|
|
177
|
+
> **If you are the implementer reading this work order as your own specification: STOP at this
|
|
178
|
+
> line.**
|
|
179
|
+
|
|
180
|
+
- **Execution.** Codex known-unavailable today (see Part B) — implemented directly in Claude.
|
|
181
|
+
- **Review routing.** `reviewer` + `ui_reviewer`, concurrent, one background batch — both ran,
|
|
182
|
+
findings above, all in-scope findings fixed before commit.
|
|
183
|
+
- **Verification.** The package's own affected-set suite (`BarChart.test.jsx`, `LineChart.test.jsx`,
|
|
184
|
+
`TimeSeriesChart.test.jsx`, `timeSeriesLegend.test.js`, `chartDefaults.test.js` — 60 tests,
|
|
185
|
+
green). **The rendered check in hram could NOT be completed live in this session** — the Browser
|
|
186
|
+
pane only carries real viewport dimensions for the tab the operator has actually displayed;
|
|
187
|
+
freshly-opened tabs (both a new hram tab and ui-core-micha's own dev harness) measured 0×0 and
|
|
188
|
+
produced negative-width SVG rect errors as a direct consequence, not a code defect. The BEFORE
|
|
189
|
+
numbers (Coverage KPI card) WERE captured live from the properly-displayed hram tab and are
|
|
190
|
+
quoted above; the AFTER re-measurement is deferred to `FIX-13`, which needs it anyway once
|
|
191
|
+
hram's pin is bumped (its own WO already orders re-measurement after the pin bump, so this
|
|
192
|
+
doesn't lose anything — it was always going to happen there).
|
|
193
|
+
- **Register & commit.** Advance the THEME-9 row with the reviewer verdicts. Track the consumer
|
|
194
|
+
pin bumps (hram = `FIX-13`) — this WO is not done at publish.
|