@micha.bigler/ui-core-micha 2.40.0 → 2.41.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 CHANGED
@@ -22,6 +22,7 @@ These principles are the spec a **prototype** encodes and the implementation mat
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
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.
25
+ 8b. **Scatter: neutral by default; colour only when it encodes a real dimension.** An undifferentiated point cloud is one neutral mass (the role register's neutral, not a series/KPI-identity hue) — colour is earned by a genuine data dimension (a category, a continuous measure), never applied just to look intentional. `ScatterChart` (THEME-10, drawn from three real consumers — hram's allocation-performance, access-gap, and optimization-results panels) supports three modes: neutral (default, single series, no colour set), categorical (multiple series, one colour per series, discrete legend — draw the colours from the app's own family/role register, not a hand-indexed array), and continuous (the caller resolves a per-point colour from its own scale and applies it via `getPointStyle`; this package does not ship a colour-scale/legend component). Bubble sizing (`sizeAccessor`) always draws largest-first — an unordered bubble chart with no defined draw order is silently wrong, not a stylistic choice. Individually marked points (a hollow status-quo point against filled candidates) go through the same `getPointStyle` hook. Reference geometry (a computed curve, a labelled reference line) is composed as `children`, reading the chart's own live scale (`ScatterReferenceCurve`/`ScatterReferenceLine`) rather than a second, hand-rolled pixel mapping. **Not this preset:** row-based dot plots (a forest plot, a tornado) — categorical y-axis, 150–220px of row labels, row-density as the layout problem — are a different chart family with their own preset, not a scatter with a tall left margin.
25
26
  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.**
26
27
 
27
28
  ## Craft
package/WORK_ORDERS.md CHANGED
@@ -83,3 +83,5 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
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
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). |
86
+ | THEME-10 | Ein Scatter-Preset, entworfen an drei realen Konsumenten | hram zeichnet **drei** Scatter vollständig von Hand — eigene Achsenlinien, Gitter, Ticks, Tick-Beschriftungen und gedrehte Achsentitel auf fest verdrahteten Leinwänden; aus diesem Paket kommt nur `ChartFrame`. `AllocationPerformancePanel` (720×420, Wolke + Hüllkurve + drei markierte Punkte, kontinuierliche Färbung), `AccessGapScatterPanel` (Bubble-Wolke mit datengetriebenem Radius + beschriftete y=x-Diagonale, **kategoriale** Färbung mit diskreter Legende, beide Achsen fix 0–1) und `OptimizationResultsPanel` (Wolke + gestrichelte Referenzlinie, Status quo als hohler Marker). Das ist keine Nachlässigkeit — **es gibt hier kein Scatter-Preset**, also gab es nichts zu greifen; jede Verbesserung dieses Kits (Achsengrössen, Tick-Formatierung, Label-Platzierung, Rollenpalette) geht an allen dreien vorbei. Der Operator hat diesen Weg bewusst gewählt, statt die Apps MUI X direkt nutzen zu lassen (hram `FIX-15`, Gabelung b). Voller Auftrag [`work-orders/THEME-10.md`](work-orders/THEME-10.md) | 2026-08-14 | done (npm publish 2.41.0, gebündelt mit THEME-11; Konsumenten-Pin-Bumps offen) | ff67fc7 | Tier 3 (Shared-Core, neue öffentliche API). Codex bereits `unavailable` für heute — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **`reviewer` R1 (P1, behoben):** `sizeYAxisForContent` erwartet rohe Zahlen in `series[].data`, Scatter liefert `{x,y,z,id}`-Objekte — der Fallback fand still NULL Kandidaten, solange kein `yAxis.min/max` gesetzt war (stiller Rückfall auf MUIs Flat-Default). Behoben per Adapter am Call-Site (`yValueSeries`), NICHT am geteilten Helper — BarChart/LineChart bleiben unangetastet. **R2 (P2, behoben):** `dataIndex`-Semantik im Bubble-Modus (post-Sort-Index, nicht Original-Array) war nicht dokumentiert — jetzt im Docstring. **`ui_reviewer` U1 (P2, aufgeklärt statt Code geändert):** `shape:'square'` schien nicht auf die drei Panels rückführbar — tatsächlich rückführbar auf Allocation-Panels `isTargeted`-Punkte (`ShapeGlyph`), im WO nur nicht explizit zitiert; WO-Text nachgezogen. **U2 (P2, behoben):** Optimization-Form war live nicht eigens gerendert — dritte Dev-Harness-Fixture ergänzt und live verifiziert (genau diese Kombination — kein `yAxis.min/max`, keine explizite Palette — deckte R1 auf, die beiden anderen Fixtures umgingen den Fehler zufällig). **U3 (P3, behoben):** Test ergänzt, dass eine Einzelserie mit eigener `.color` nicht als neutral behandelt wird. **U5 (P3, dokumentiert):** WO-Text stellte klar, dass Allocations eigener Status-quo-Punkt ebenfalls hohl ist, nicht nur Optimizations. **U4 (P3, akzeptierter Rest):** fehlende Nicht-visuelle Kennzeichnung von hohl/Form — vorbestehende Lücke im ganzen Chart-Kit, nicht neu eingeführt, nicht in dieser WO behoben. Alle drei benannten Konsumenten-Formen live im eigenen Dev-Harness gerendert und DOM-vermessen (Mark-Counts 6/6, 7/7, 4/4 — keine stillen Drops); ein zweiter echter Bug (Bubble-Radius pro Serie statt Chart-weit) beim Live-Rendern gefunden und vor Commit behoben, Regressionstest ergänzt. Affected-Set grün (78 Tests). **Version noch nicht gebumpt, Publish zurückgehalten** — Operator kündigte THEME-11 an, Version-Bump + Publish sollen gebündelt werden. **Das ursprünglich benannte Einzelkonsumenten-Risiko ist weitgehend erledigt.** Der erste Entwurf (2026-08-14) warnte, eine Abstraktion aus einem einzigen Beispiel friere die falsche Form ein; danach nannte der Operator das Access-Panel, und eine Suche fand ein drittes. Drei unabhängige Konsumenten unterscheiden eine echte Variationsachse von einer Vermutung — und **zwei der Achsen wurden gefunden, nicht entworfen**: kategoriale Färbung mit diskreter Legende und datengetriebener Bubble-Radius mit Z-Order kommen im Allocation-Panel überhaupt nicht vor. Aus einem Panel gebaut hätte das Preset beide verfehlt. Ersetzt wird das Risiko durch die engere Disziplin: **jede Option muss auf eines der drei Panels zurückführbar sein**, sonst kommt sie nicht rein; wächst die API während der Umsetzung, stop and report. **Neue Hauptgefahr ist die Übergeneralisierung:** dieselbe Suche fand zwei weitere handgemalte Panels, die ebenfalls Kreise zeichnen, aber **ein anderes Diagramm sind** — `CalibrationPanel` (Forest-Plot, `MARGIN.left=150`, Punktschätzer + Intervall je Zeile) und `SensitivityAnalysisPanel` (Tornado, `MARGIN.left=220`, `<rect>`-Balken), dazu die Divergenz-Ansicht in `OptimizationResultsPanel`. Kategoriale y-Achse, 150–220 px Zeilenlabels, Zeilendichte als Layoutproblem — **ausdrücklich ausgeschlossen**, eigenes Preset, eigener WO. **Wichtigster Default bleibt:** die undifferenzierte Wolke ist neutral (Rollenregister), NICHT eine KPI-Identitätsfarbe — genau das macht das Allocation-Panel falsch. Das Access-Panel färbt dagegen **zu Recht** kategorial (Division/Settlement sind echte Datendimensionen und kommen bereits aus dem eingefrorenen Familienregister); neutral ist der Default für „keine Dimension kodiert“, kein Farbverbot. Muss THEME-9s Helfer wiederverwenden (`sizeYAxisForContent`, `defaultNumericTickFormatter`, `withAxisDefaults`, `spaceForRotatedTicks`); ein Preset mit eigener Achsenlogik würde genau das wieder aufreissen, was THEME-9 geschlossen hat. Konkrete Regression, die es stoppt: das Access-Panel verdrahtet sechs Grautöne fest (`#F0F0F0`, `#D0D0D0`, `#999`, `#666`, `#555`, `#B0B0B0`), die keine Theme-Änderung erreicht. Nicht am Publish abschliessen: hram `FIX-15` ist der erste Konsument und blockiert darauf; Access und Optimization sind bekannte Konsumenten **ohne eigenen WO** — separat zu planen, nicht hier einzufalten. Pin-Bump zusammen mit den bereits wartenden THEME-8 und THEME-9 fahren. |
87
+ | THEME-11 | Das Kit hat keine Meinung zu Chart-Rändern, also erbt jedes Diagramm im Estate die von MUI | hrams vier KPI-Karten wurden pixelweise zerlegt: Chart 270 px hoch, davon **202 px Plotfläche** — die restlichen 167 px sind Beiwerk, und die Bilanz geht auf die beobachteten ~370 px Kartenhöhe exakt auf. Der Grund für 20 px auf allen vier Seiten: **dieses Paket bildet nie eine Meinung**. `spaceForRotatedTicks` gibt `margin` unverändert zurück, solange die x-Ticks nicht rotiert sind — reicht der Aufrufer nichts, geht `margin: undefined` an `MuiBarChart` und `DEFAULT_MARGINS = {20,20,20,20}` greift. THEME-9 hat dem Kit Achsen*grössen* gegeben; Ränder waren nie in seinem Scope. Neu: `top: 10`, `bottom: 8`, `left: 8`, `right: 16`, plus `ChartFrame`s Titel-`mb: 2` → `mb: 1`. Voller Auftrag [`work-orders/THEME-11.md`](work-orders/THEME-11.md) | 2026-08-14 | done (npm publish 2.41.0, gebündelt mit THEME-10; Konsumenten-Pin-Bumps offen) | bb7794d | Tier 3 (Shared-Core). Codex bereits `unavailable` — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **Beide Fallen sauber vermieden** (`reviewer`, an beiden Dateien einzeln verifiziert, keine Befunde): `spaceForRotatedTicks` läuft weiterhin auf dem unveränderten Aufrufer-`margin`, `withMarginDefaults` erst danach gemerged — der rotierte Pfad verliert seinen Zusatzplatz nicht. `MUI_CHART_MARGIN_BOTTOM` (20→8) mitgezogen. **`ui_reviewer` U1-U4 (alle P2/P3, dokumentiert statt Code geändert):** die live gemessene Fast-Kollision (gruppierte Zahl "1,000" bei nur ~1px Abstand vom neuen `right:16`) stammt aus MUIs eigener Standard-Tick-Formatierung, nicht aus THEME-9s Formatter (Fehlzuschreibung korrigiert); **`reviewer` verifizierte zusätzlich**, dass KEIN aktueller hram-Konsument diesen Pfad überhaupt trifft (alle nutzen `scaleType:'band'`; `AllocationPerformancePanel` — der ursprünglich befürchtete Fall — importiert nur `ChartFrame`, zeichnet eigenes SVG, geht gar nicht über `BarChart`/`LineChart`) — "kein Konsument hat das" trifft also exakt zu, nicht nur plausibel. Restrisiko dokumentiert für den ersten künftigen linearen Konsumenten. `ChartFrame`s `mb`-Trim bewusst unbedingt (nicht an Chart-Preset gekoppelt) — Titelzeile und Plot-Rand sind unabhängiges Möbel. **Scope-Lücke benannt, nicht eingefaltet:** `ScatterChart` (THEME-10) bekommt noch keinen `withMarginDefaults` — Folge-WO. Affected-Set grün (91 Tests). Gerendert live verifiziert: rotierter Tick-Chart ohne Overlap (15px Abstand zum Achsentitel), linearer x-Achsen-Chart ohne echtes Clipping (schmal: 5px Abstand; breit: MUIs eigene Kürzung greift bei ~1px, nie Overflow). **Warum die Werte pro Seite verschieden sind — das ist der Kern, kein Feinschliff:** `margin.left` liegt **ausserhalb** der y-Achsenbreite (`drawingArea.left = margin.left + axisSizeLeft`, Kommentar des Pakets selbst), ist also reines Polster; `margin.bottom` liegt **unter** dem x-Achsenblock, dessen `DEFAULT_AXIS_SIZE_HEIGHT` das Tick-Label bereits enthält; `margin.top` trägt dagegen echten Inhalt — das oberste y-Tick-Label ist auf seiner Gitterlinie zentriert und ragt eine halbe Caption-Zeilenhöhe (~9 px) über die Plotfläche, **deshalb 10 und nicht 8**. `right` wird bewusst **weniger** getrimmt (16): bei einer Bandachse sitzen die Ticks mittig, bei einer **linearen** x-Achse sitzt das letzte Label auf der Kante und ragt eine halbe Labelbreite (~13 px) hinaus. Nicht vom Skalentyp abhängig machen — das wäre abgeleitetes Verhalten ohne benannten Konsumenten. **Zwei benannte Fallen.** (1) `spaceForRotatedTicks` erkennt „Aufrufer hat gesetzt“ an `margin?.bottom != null`; wird ein Paket-Default **vor** dieser Prüfung injiziert, sehen rotierte Charts ihn als aufrufergesetzt und verlieren ihren Extraplatz — hrams `RatioMetricsPanel` fährt Labels bei −45° und −90° und würde sie beschneiden. (2) `MUI_CHART_MARGIN_BOTTOM = 20` in `chartDefaults.js` ist eine hartkodierte Kopie des MUI-Defaults und Basis für den rotierten Pfad — muss mitwandern. **Per-Seite mergen, nicht das ganze Objekt ersetzen:** Panels, die schon von Hand tunen (`{left:60, right:8, top:20, …}`), müssen auf ihren gesetzten Seiten unberührt bleiben. `CHART_FRAME_ROOT_SX = { p: 2 }` bleibt bewusst stehen — `ChartFrame` umschliesst auch Nicht-Chart-Inhalte, das ist ein eigener Vorschlag mit anderer Streuweite, kein Anhängsel. **Streuweite:** trifft jedes Diagramm in jeder konsumierenden App, und zwar beim Pin-Bump, nicht beim Publish — ein falscher Seitenwert ist damit eine estate-weite Regression. Der gerenderte Check muss deshalb **einen rotierten** und **einen linearen** x-Achsen-Chart enthalten, nicht nur die KPI-Karten. Pin-Bump zusammen mit THEME-8/9/10 fahren. |
package/dev/entries.jsx CHANGED
@@ -40,6 +40,11 @@ import { BarChart } from '../src/components/charts/BarChart';
40
40
  import { ChartFrame } from '../src/components/charts/ChartFrame';
41
41
  import { LineChart } from '../src/components/charts/LineChart';
42
42
  import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
43
+ import {
44
+ ScatterChart,
45
+ ScatterReferenceCurve,
46
+ ScatterReferenceLine,
47
+ } from '../src/components/charts/ScatterChart';
43
48
  import { NotificationBell } from '../src/notifications/NotificationBell';
44
49
  import { NotificationsProvider } from '../src/notifications/NotificationsProvider';
45
50
  import { useRealtime } from '../src/notifications/realtime';
@@ -201,6 +206,155 @@ function BarChartEntry() {
201
206
  );
202
207
  }
203
208
 
209
+ // THEME-11 rendered-verification fixture -- a genuinely LINEAR (not band/point) x-axis, whose
210
+ // last tick label sits AT the plot edge rather than centred like a band axis. Right margin is
211
+ // the one side deliberately trimmed less (16 vs 8) for exactly this case.
212
+ function LinearXAxisLineChartEntry() {
213
+ return (
214
+ <ChartFrame title="Linear x-axis (shape check)" subtitle="THEME-11: last tick label must not clip against the trimmed right margin" minHeight={360}>
215
+ <LineChart
216
+ xAxis={[{ scaleType: 'linear', data: [0, 250, 500, 750, 1000] }]}
217
+ series={[{ label: 'Value', data: [4, 9, 6, 14, 11] }]}
218
+ xAxisLabel="Budget"
219
+ yAxisLabel="Outcome"
220
+ height={320}
221
+ />
222
+ </ChartFrame>
223
+ );
224
+ }
225
+
226
+ // THEME-10 rendered-verification fixture -- shape 1: allocation-performance's cloud + computed
227
+ // envelope curve + individually marked points, including a hollow status-quo marker and a
228
+ // continuous per-point colour the caller resolves itself (`getPointStyle`).
229
+ const ALLOCATION_POINTS = [
230
+ { id: 'sq', x: 0, y: 0, equity: 0.5, isStatusQuo: true },
231
+ { id: 'p1', x: 100000, y: 400, equity: 0.2 },
232
+ { id: 'p2', x: 250000, y: 900, equity: 0.35 },
233
+ { id: 'p3', x: 400000, y: 1500, equity: 0.55 },
234
+ { id: 'p4', x: 550000, y: 1900, equity: 0.72 },
235
+ { id: 'p5', x: 700000, y: 2100, equity: 0.9, isSelected: true },
236
+ ];
237
+ const ALLOCATION_ENVELOPE = [
238
+ { x: 0, y: 0 }, { x: 250000, y: 900 }, { x: 550000, y: 1900 }, { x: 700000, y: 2100 },
239
+ ];
240
+
241
+ function equityColor(value) {
242
+ // Stand-in continuous scale: the app owns this in reality (getColorScale); the preset only
243
+ // needs to APPLY a resolved colour per point via getPointStyle.
244
+ const hue = 220 - value * 160;
245
+ return `hsl(${hue}, 55%, 45%)`;
246
+ }
247
+
248
+ function AllocationShapeScatterEntry() {
249
+ return (
250
+ <ChartFrame title="Allocation performance (shape check)" subtitle="Cloud + envelope + hollow status-quo + selected diamond" minHeight={420}>
251
+ <ScatterChart
252
+ series={[{ id: 'candidates', data: ALLOCATION_POINTS }]}
253
+ xAxisLabel="Cost"
254
+ yAxisLabel="DALYs averted"
255
+ getPointStyle={(point) => ({
256
+ hollow: point.isStatusQuo,
257
+ shape: point.isSelected ? 'diamond' : 'circle',
258
+ color: point.isStatusQuo ? undefined : equityColor(point.equity),
259
+ radius: point.isStatusQuo || point.isSelected ? 7 : 6,
260
+ })}
261
+ height={360}
262
+ >
263
+ <ScatterReferenceCurve points={ALLOCATION_ENVELOPE} color="#2E7D32" dashed={false} />
264
+ </ScatterChart>
265
+ </ChartFrame>
266
+ );
267
+ }
268
+
269
+ // THEME-10 rendered-verification fixture -- shape 2: access-gap's bubble cloud (data-driven
270
+ // radius, explicit z-order) + labelled y=x reference diagonal + categorical colouring with a
271
+ // discrete legend + both axes pinned 0-1 with a fixed tick set.
272
+ const ACCESS_DIVISION_COLORS = { north: '#0F62FE', south: '#24A148', east: '#FF832B' };
273
+ const ACCESS_POINTS = {
274
+ north: [
275
+ { id: 'n1', x: 0.62, y: 0.7, pop: 12000 },
276
+ { id: 'n2', x: 0.4, y: 0.5, pop: 3000 },
277
+ { id: 'n3', x: 0.8, y: 0.75, pop: 18000 },
278
+ ],
279
+ south: [
280
+ { id: 's1', x: 0.3, y: 0.35, pop: 5000 },
281
+ { id: 's2', x: 0.55, y: 0.5, pop: 9000 },
282
+ ],
283
+ east: [
284
+ { id: 'e1', x: 0.7, y: 0.6, pop: 2000 },
285
+ { id: 'e2', x: 0.45, y: 0.4, pop: 30000 },
286
+ ],
287
+ };
288
+ const ACCESS_TICKS = [0, 0.25, 0.5, 0.75, 1];
289
+ const pctFormatter = (value, context) => (context?.location === 'tick'
290
+ ? `${Math.round(value * 100)}%`
291
+ : `${(value * 100).toFixed(1)}%`);
292
+
293
+ function AccessShapeScatterEntry() {
294
+ return (
295
+ <ChartFrame title="Access gap (shape check)" subtitle="Bubble cloud + labelled y=x diagonal + categorical legend, fixed 0-1 axes" minHeight={420}>
296
+ <ScatterChart
297
+ series={Object.entries(ACCESS_POINTS).map(([division, data]) => ({
298
+ id: division,
299
+ label: division,
300
+ data,
301
+ color: ACCESS_DIVISION_COLORS[division],
302
+ }))}
303
+ xAxisLabel="Coverage"
304
+ yAxisLabel="Care-seeking rate"
305
+ xAxis={[{ min: 0, max: 1, tickInterval: ACCESS_TICKS, valueFormatter: pctFormatter }]}
306
+ yAxis={[{ min: 0, max: 1, tickInterval: ACCESS_TICKS, valueFormatter: pctFormatter }]}
307
+ sizeAccessor={(point) => point.pop}
308
+ height={360}
309
+ >
310
+ <ScatterReferenceLine
311
+ from={{ x: 0, y: 0 }}
312
+ to={{ x: 1, y: 1 }}
313
+ color="#B0B0B0"
314
+ dashed
315
+ label="No gap"
316
+ labelAt={{ x: 0.88, y: 0.93 }}
317
+ labelAngle={-45}
318
+ />
319
+ </ScatterChart>
320
+ </ChartFrame>
321
+ );
322
+ }
323
+
324
+ // THEME-10 rendered-verification fixture -- shape 3: optimization-results' cloud (no bubble
325
+ // sizing, no categorical colour -- the plainest of the three shapes) + a dashed reference
326
+ // threshold line + a hollow status-quo marker, added per ui_reviewer finding U2: the other two
327
+ // fixtures don't exercise "no yAxis min/max set, no explicit palette" together, which is exactly
328
+ // the combination reviewer finding R1 (sizeYAxisForContent's number-vs-point-object mismatch)
329
+ // slipped through on.
330
+ const OPTIMIZATION_POINTS = [
331
+ { id: 'sq', x: 0, y: 0.42, isStatusQuo: true },
332
+ { id: 'c1', x: 120000, y: 0.55 },
333
+ { id: 'c2', x: 260000, y: 0.68 },
334
+ { id: 'c3', x: 400000, y: 0.79 },
335
+ ];
336
+
337
+ function OptimizationShapeScatterEntry() {
338
+ return (
339
+ <ChartFrame title="Optimization results (shape check)" subtitle="Cloud + dashed threshold + hollow status-quo, no bubble/no categorical colour" minHeight={420}>
340
+ <ScatterChart
341
+ series={[{ id: 'candidates', data: OPTIMIZATION_POINTS }]}
342
+ xAxisLabel="Cost"
343
+ yAxisLabel="Coverage"
344
+ getPointStyle={(point) => (point.isStatusQuo ? { hollow: true } : {})}
345
+ height={360}
346
+ >
347
+ <ScatterReferenceLine
348
+ from={{ x: 0, y: 0.6 }}
349
+ to={{ x: 400000, y: 0.6 }}
350
+ color="#0F62FE"
351
+ dashed
352
+ />
353
+ </ScatterChart>
354
+ </ChartFrame>
355
+ );
356
+ }
357
+
204
358
  function StatusChip({ label, tone }) {
205
359
  return (
206
360
  <Chip
@@ -540,6 +694,10 @@ export const entries = [
540
694
  { id: 'section-nav', label: 'Navigation / Section nav', Component: SectionNavEntry },
541
695
  { id: 'notification-bell', label: 'Notifications / Bell', Component: NotificationBellEntry },
542
696
  { id: 'bar-chart', label: 'Charts / BarChart', Component: BarChartEntry },
697
+ { id: 'linear-x-line-chart', label: 'Charts / LineChart (linear x-axis)', Component: LinearXAxisLineChartEntry },
698
+ { id: 'scatter-chart-allocation-shape', label: 'Charts / ScatterChart (allocation shape)', Component: AllocationShapeScatterEntry },
699
+ { id: 'scatter-chart-access-shape', label: 'Charts / ScatterChart (access shape)', Component: AccessShapeScatterEntry },
700
+ { id: 'scatter-chart-optimization-shape', label: 'Charts / ScatterChart (optimization shape)', Component: OptimizationShapeScatterEntry },
543
701
  { id: 'time-series-chart', label: 'Charts / TimeSeriesChart', Component: TimeSeriesChartEntry },
544
702
  { id: 'realtime-adapter', label: 'Transport / Realtime adapter', Component: RealtimeAdapterEntry },
545
703
  { id: 'messaging-provider', label: 'Messaging / Provider state', Component: MessagingProviderEntry },
@@ -16,7 +16,7 @@ import { useTheme } from '@mui/material/styles';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
18
18
  import { useNeutralChartPalette } from './palette';
19
- import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
19
+ import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
20
20
  /**
21
21
  * Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
22
22
  * and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
@@ -36,5 +36,5 @@ export function BarChart(_a) {
36
36
  const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
37
37
  const sizedYAxis = sizeYAxisForContent(labelledYAxis, series, theme.typography.caption.fontSize);
38
38
  const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
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) })) }));
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: withMarginDefaults(rotatedTickSpace.margin), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
40
40
  }
@@ -45,7 +45,7 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
45
45
  else if (isEmpty) {
46
46
  content = _jsx(Alert, { severity: "info", children: emptyMessage || t('ChartFrame.EMPTY_DEFAULT') });
47
47
  }
48
- return (_jsxs(Paper, { variant: variant, sx: [CHART_FRAME_ROOT_SX, ...callerSx], children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: titleVariant, children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), controls && _jsx(Box, { sx: { mb: 2 }, children: controls }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
48
+ return (_jsxs(Paper, { variant: variant, sx: [CHART_FRAME_ROOT_SX, ...callerSx], children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: titleVariant, children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), controls && _jsx(Box, { sx: { mb: 2 }, children: controls }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
49
49
  width: '100%',
50
50
  minHeight,
51
51
  aspectRatio: aspect,
@@ -16,7 +16,7 @@ import { useTheme } from '@mui/material/styles';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { LineChart as MuiLineChart, MarkElement as MuiMarkElement, } from '@mui/x-charts/LineChart';
18
18
  import { useNeutralChartPalette } from './palette';
19
- import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
19
+ import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
20
20
  // Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
21
21
  // `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
22
22
  // override in src/theme/tokens.js as the preferred mechanism, with a
@@ -51,5 +51,5 @@ export function LineChart(_a) {
51
51
  const defaultedSeries = series.map((item) => (Object.assign({ showMark: false, labelMarkType: 'square' }, item)));
52
52
  const sizedYAxis = sizeYAxisForContent(labelledYAxis, defaultedSeries, theme.typography.caption.fontSize);
53
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) })) }));
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: withMarginDefaults(rotatedTickSpace.margin), slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
55
55
  }
@@ -0,0 +1,178 @@
1
+ var __rest = (this && this.__rest) || function (s, e) {
2
+ var t = {};
3
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
+ t[p] = s[p];
5
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
+ t[p[i]] = s[p[i]];
9
+ }
10
+ return t;
11
+ };
12
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ import React, { useMemo } from 'react';
14
+ import { Box } from '@mui/material';
15
+ import { useTheme } from '@mui/material/styles';
16
+ import { useTranslation } from 'react-i18next';
17
+ import { ScatterChart as MuiScatterChart } from '@mui/x-charts/ScatterChart';
18
+ import { useXScale, useYScale } from '@mui/x-charts/hooks';
19
+ import { useNeutralChartPalette } from './palette';
20
+ import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
21
+ // Small enough that hram's largest cloud (~300 points, the tested magnitude -- state it, don't
22
+ // imply it scales past that) doesn't fuse into a solid mass at the default (non-bubble) size.
23
+ const DEFAULT_MARKER_RADIUS = 5;
24
+ const BUBBLE_MIN_RADIUS = 3;
25
+ const BUBBLE_RADIUS_SCALE = 10;
26
+ /**
27
+ * value -> radius, `max(3, 10 * sqrt(value / maxValue))` -- the formula the access-gap panel
28
+ * already hand-rolls (THEME-10 scope 5). Exported so a consumer can size a legend swatch or a
29
+ * standalone marker the same way the chart does.
30
+ */
31
+ export function scaleBubbleRadius(value, maxValue, { minRadius = BUBBLE_MIN_RADIUS, scale = BUBBLE_RADIUS_SCALE } = {}) {
32
+ if (!Number.isFinite(value) || !Number.isFinite(maxValue) || maxValue <= 0)
33
+ return minRadius;
34
+ return Math.max(minRadius, scale * Math.sqrt(Math.max(0, value) / maxValue));
35
+ }
36
+ // A custom MUI marker slot -- the only way to vary radius/colour/shape PER POINT: MUI's own
37
+ // Scatter passes a single `markerSize` per whole series (confirmed against
38
+ // @mui/x-charts/ScatterChart/Scatter.js -- `size: series.markerSize`, never derived from a
39
+ // point's own `z`), so bubble sizing and per-point overrides (a hollow status-quo marker, a
40
+ // continuous-scale colour) can only be expressed by replacing the marker itself.
41
+ function makeMarkerSlot(seriesById, { sizeAccessor, chartMax, getPointStyle }) {
42
+ return function PresetScatterMarker(_a) {
43
+ var _b, _c, _d, _e, _f, _g;
44
+ var { seriesId, dataIndex, color, x, y, isHighlighted, isFaded, size, onClick } = _a, rest = __rest(_a, ["seriesId", "dataIndex", "color", "x", "y", "isHighlighted", "isFaded", "size", "onClick"]);
45
+ const point = (_c = (_b = seriesById[seriesId]) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c[dataIndex];
46
+ const style = point && getPointStyle ? (getPointStyle(point, { seriesId, dataIndex }) || {}) : {};
47
+ const radius = (_d = style.radius) !== null && _d !== void 0 ? _d : (sizeAccessor && point ? scaleBubbleRadius(sizeAccessor(point), chartMax) : size);
48
+ const r = (isHighlighted ? 1.15 : 1) * radius;
49
+ const fill = style.hollow ? 'none' : ((_e = style.color) !== null && _e !== void 0 ? _e : color);
50
+ const stroke = (_f = style.color) !== null && _f !== void 0 ? _f : color;
51
+ const shared = Object.assign({ onClick, opacity: isFaded ? 0.3 : 1, stroke, strokeWidth: style.hollow ? 2 : ((_g = style.strokeWidth) !== null && _g !== void 0 ? _g : 1), style: { cursor: onClick ? 'pointer' : 'unset' } }, rest);
52
+ if (style.shape === 'square') {
53
+ return _jsx("rect", Object.assign({ x: x - r, y: y - r, width: r * 2, height: r * 2, fill: fill }, shared));
54
+ }
55
+ if (style.shape === 'diamond') {
56
+ return _jsx("polygon", Object.assign({ points: `${x},${y - r} ${x + r},${y} ${x},${y + r} ${x - r},${y}`, fill: fill }, shared));
57
+ }
58
+ return _jsx("circle", Object.assign({ cx: x, cy: y, r: r, fill: fill }, shared));
59
+ };
60
+ }
61
+ /**
62
+ * A computed curve (allocation's Pareto envelope) drawn inside the chart's own live coordinate
63
+ * space. Render as a `ScatterChart` child -- `useXScale`/`useYScale` only resolve inside the
64
+ * chart's own context, so this can never drift into a second, hand-rolled pixel mapping the way
65
+ * the hand-drawn panels this preset replaces each built their own.
66
+ */
67
+ export function ScatterReferenceCurve({ points, color, dashed, strokeWidth = 2 }) {
68
+ const xScale = useXScale();
69
+ const yScale = useYScale();
70
+ if (!points || points.length < 2)
71
+ return null;
72
+ const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(p.x)},${yScale(p.y)}`).join(' ');
73
+ return _jsx("path", { d: d, fill: "none", stroke: color, strokeWidth: strokeWidth, strokeDasharray: dashed ? '6 3' : undefined });
74
+ }
75
+ /**
76
+ * A straight reference line with an optional inline label -- the access panel's labelled y=x
77
+ * "no gap" diagonal, or optimization's dashed threshold line. Same live-scale rule as
78
+ * `ScatterReferenceCurve`.
79
+ */
80
+ export function ScatterReferenceLine({ from, to, color, dashed, strokeWidth = 1.5, label, labelAt, labelAngle = 0, }) {
81
+ const xScale = useXScale();
82
+ const yScale = useYScale();
83
+ const labelX = labelAt ? xScale(labelAt.x) : null;
84
+ const labelY = labelAt ? yScale(labelAt.y) : null;
85
+ return (_jsxs("g", { children: [_jsx("line", { x1: xScale(from.x), y1: yScale(from.y), x2: xScale(to.x), y2: yScale(to.y), stroke: color, strokeWidth: strokeWidth, strokeDasharray: dashed ? '5 3' : undefined }), label && labelAt && (_jsx("text", { x: labelX, y: labelY, fontSize: 10, fill: color, textAnchor: "start", transform: labelAngle ? `rotate(${labelAngle}, ${labelX}, ${labelY})` : undefined, children: label }))] }));
86
+ }
87
+ /**
88
+ * Responsive MUI X-Charts scatter preset, drawn from three real consumers (THEME-10): a
89
+ * continuously-coloured cost/outcome cloud with an envelope curve and marked points
90
+ * (allocation performance), a bubble cloud with a labelled y=x reference diagonal and
91
+ * categorical per-point colouring (access gap), and a cloud with a dashed reference line and a
92
+ * hollow status-quo marker (optimization results). Reuses BarChart/LineChart's own axis
93
+ * machinery (THEME-9) rather than sizing its own -- a scatter axis is a numeric axis like any
94
+ * other, and a preset that re-derives axis sizing would re-open exactly what THEME-9 closed.
95
+ *
96
+ * `series` follows MUI's own scatter shape (`{id, label, data: [{x,y,z?,id?}], color?}`).
97
+ * Multiple series render as a categorical cloud with a discrete legend, one colour per series
98
+ * -- draw those colours from the app's own family/role register via `palette`; this package
99
+ * does not invent one. A single series with no explicit `color` renders as one neutral mass:
100
+ * undifferentiated data encodes no dimension, so it gets the role register's neutral tone
101
+ * (`palette.js`'s `neutral`), never a series/KPI identity hue.
102
+ *
103
+ * `sizeAccessor(point) => number` turns on bubble mode: marker radius follows
104
+ * `scaleBubbleRadius`, and every series' points are drawn largest-first so a small bubble is
105
+ * never hidden under a large one -- the z-order is the whole correctness of a bubble chart, not
106
+ * a decoration.
107
+ *
108
+ * `getPointStyle(point, {seriesId, dataIndex}) => {hollow?, color?, shape?, radius?,
109
+ * strokeWidth?}` marks individual points -- a status-quo point drawn hollow against filled
110
+ * candidates (allocation, optimization), a `'square'` shape for a source category the data
111
+ * itself marks (allocation's "targeted" points), or a per-point colour from a continuous scale
112
+ * the caller computed (this package does not ship a continuous colour-scale/legend component;
113
+ * the caller already owns that logic and only needs a way to apply the resolved colour per
114
+ * point, which this is it). Read the `point` object itself, not `dataIndex`, to correlate a
115
+ * style back to your own data -- in bubble mode (`sizeAccessor` set) `dataIndex` indexes the
116
+ * chart's internal z-ordered copy of your series, not your original array's order.
117
+ *
118
+ * Reference geometry (a computed curve, a straight line, either with an optional label) is
119
+ * composed as `children` using `ScatterReferenceCurve`/`ScatterReferenceLine` above.
120
+ *
121
+ * Tested magnitude: a few hundred marks (hram's largest cloud is ~300 points). Not verified,
122
+ * and not implied to hold, at thousands+.
123
+ *
124
+ * THEME-10 was commissioned before its requirement set closed -- a forthcoming "paired point"
125
+ * shape (optimization's MILP-vs-simulated pair, two marks joined by a connector) is named but
126
+ * explicitly NOT built here. Nothing below assumes a datum produces exactly one mark: a point
127
+ * object may carry arbitrary extra fields beyond `{x,y,z,id}` (as `getPointStyle`'s own status-
128
+ * quo/bubble-radius usage already does), `getPointStyle`/`sizeAccessor` key off that datum, not
129
+ * off a fixed mark count, and reference geometry is composed via `children`, not baked into the
130
+ * per-point contract. A pair variant can therefore layer on later -- extra series entries plus a
131
+ * connector drawn through `children` -- without reshaping `series` or either callback's contract.
132
+ */
133
+ export function ScatterChart(_a) {
134
+ var _b;
135
+ var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, sizeAccessor, getPointStyle, markerSize = DEFAULT_MARKER_RADIUS, slotProps, children } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "sizeAccessor", "getPointStyle", "markerSize", "slotProps", "children"]);
136
+ const theme = useTheme();
137
+ const { i18n } = useTranslation();
138
+ const neutralPalette = useNeutralChartPalette();
139
+ const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
140
+ const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
141
+ // Only the Y axis is width-sized (sizeYAxisForContent reserves horizontal space for a
142
+ // vertical axis's ticks+label) -- a scatter's numeric X axis needs no analogous treatment
143
+ // beyond what MUI already sizes for a horizontal tick row.
144
+ //
145
+ // sizeYAxisForContent's series-data fallback (used when the caller sets no axis min/max)
146
+ // expects `series[i].data` to be a plain array of numbers -- true for BarChart/LineChart, but
147
+ // a scatter's data is `{x,y,z,id}` points, so `typeof value === 'number'` never matched and
148
+ // the fallback silently found zero candidates (reviewer finding, live-caught: it "looked fine"
149
+ // only because the untested case happened to fall back to MUI's flat default at a magnitude
150
+ // that didn't visibly overlap). Adapt AT THIS CALL SITE, not inside the shared helper --
151
+ // BarChart/LineChart's own contract must not change for this.
152
+ const yValueSeries = useMemo(() => series.map((item) => (Object.assign(Object.assign({}, item), { data: (item.data || []).map((point) => point === null || point === void 0 ? void 0 : point.y) }))), [series]);
153
+ const sizedYAxis = sizeYAxisForContent(labelledYAxis, yValueSeries, theme.typography.caption.fontSize);
154
+ const isNeutral = series.length <= 1 && !((_b = series[0]) === null || _b === void 0 ? void 0 : _b.color);
155
+ const resolvedPalette = palette || (isNeutral ? [neutralPalette.neutral] : neutralPalette.categorical);
156
+ // A single chart-wide max, NOT one per series: population (or whatever sizeAccessor reads) is
157
+ // one comparable scale across the whole cloud (matches the access-gap panel's own
158
+ // `maxPop = Math.max(1, ...points.map(p => p.population))`, computed over every point
159
+ // regardless of division/settlement) -- a per-series max would let each series' own largest
160
+ // point always render at full radius, making cross-series bubble size meaningless.
161
+ const chartMax = useMemo(() => {
162
+ if (!sizeAccessor)
163
+ return 1;
164
+ return Math.max(1, ...series.flatMap((item) => (item.data || []).map((point) => sizeAccessor(point))).filter(Number.isFinite));
165
+ }, [series, sizeAccessor]);
166
+ // THEME-10 scope 5: explicit z-order -- largest bubbles drawn first so smaller ones are never
167
+ // hidden underneath (SVG paints later elements on top). A no-op outside bubble mode.
168
+ const orderedSeries = useMemo(() => (sizeAccessor
169
+ ? series.map((item) => (Object.assign(Object.assign({}, item), { data: [...(item.data || [])].sort((a, b) => sizeAccessor(b) - sizeAccessor(a)) })))
170
+ : series), [series, sizeAccessor]);
171
+ // The marker's `dataIndex` refers to a position in whatever was actually handed to MUI --
172
+ // `orderedSeries` (post z-order sort), not the caller's original `series` -- so the lookup
173
+ // table the marker closes over must be built from that SAME array, or a sorted bubble series
174
+ // would resolve every point's style/radius against the wrong (pre-sort) index.
175
+ const orderedSeriesById = useMemo(() => Object.fromEntries(orderedSeries.map((item) => [item.id, item])), [orderedSeries]);
176
+ const markerSlot = useMemo(() => makeMarkerSlot(orderedSeriesById, { sizeAccessor, chartMax, getPointStyle }), [orderedSeriesById, sizeAccessor, chartMax, getPointStyle]);
177
+ return (_jsx(Box, { "data-testid": "scatter-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiScatterChart, Object.assign({}, chartProps, { series: orderedSeries.map((item) => (Object.assign({ markerSize }, item))), xAxis: labelledXAxis, yAxis: sizedYAxis, colors: resolvedPalette, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: margin, slots: { marker: markerSlot }, slotProps: withChartSlotDefaults(slotProps, legendPosition), children: children })) }));
178
+ }
@@ -33,8 +33,37 @@ function cssLengthToPixels(value) {
33
33
  return parsed;
34
34
  }
35
35
  const MUI_LABELLED_X_AXIS_HEIGHT = 45;
36
- const MUI_CHART_MARGIN_BOTTOM = 20;
36
+ // THEME-11: this used to mirror MUI's own DEFAULT_MARGINS.bottom (20) -- the "resting" bottom
37
+ // margin the rotated-tick path adds its extra height on top of. Now that PACKAGE_DEFAULT_MARGIN
38
+ // below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
39
+ // MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
40
+ // every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
41
+ const MUI_CHART_MARGIN_BOTTOM = 8;
37
42
  const AVERAGE_GLYPH_WIDTH_EM = 0.6;
43
+ // THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
44
+ // MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
45
+ // of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
46
+ // something different: `left`/`bottom` are pure padding (the tick labels already live inside
47
+ // axisSizeLeft / DEFAULT_AXIS_SIZE_HEIGHT), `top` keeps room for the topmost y-tick label's own
48
+ // ~9px overhang past the plot (it is centred on its gridline, not below it), and `right` stays
49
+ // less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
50
+ // overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
51
+ // making this conditional on scale type would be inferred behaviour with no named consumer.
52
+ export const PACKAGE_DEFAULT_MARGIN = {
53
+ top: 10, bottom: 8, left: 8, right: 16,
54
+ };
55
+ /**
56
+ * Applies the package's own margin default, per side -- a caller's own value on any side (or
57
+ * the rotated-tick path's own computed `bottom`) always wins on that side; only an UNSET side
58
+ * gets the package default. A caller passing `{ left: 60 }` therefore keeps that 60 on the
59
+ * left and gets the package defaults on the other three sides, never MUI's wider ones.
60
+ */
61
+ export function withMarginDefaults(margin) {
62
+ const explicit = typeof margin === 'number'
63
+ ? { top: margin, bottom: margin, left: margin, right: margin }
64
+ : margin;
65
+ return Object.assign(Object.assign({}, PACKAGE_DEFAULT_MARGIN), explicit);
66
+ }
38
67
  function formattedTickText(axis, value) {
39
68
  if (!axis.valueFormatter)
40
69
  return String(value);
@@ -17,6 +17,12 @@ export function getNeutralChartPalette(theme) {
17
17
  sequential: isDark
18
18
  ? [palette.action.disabled, palette.text.secondary, palette.primary.light, palette.primary.main, palette.primary.dark]
19
19
  : [palette.action.disabled, palette.divider, palette.text.secondary, palette.primary.light, palette.primary.main],
20
+ // THEME-10: an undifferentiated mass (e.g. a scatter cloud with no colour
21
+ // dimension encoded) is ink-neutral, not a series hue -- a KPI identity
22
+ // colour there implies a meaning the cloud does not carry. `text.secondary`
23
+ // reads as neutral ink in both palette modes without competing with an
24
+ // actual categorical/sequential encoding drawn on top of it.
25
+ neutral: palette.text.secondary,
20
26
  };
21
27
  }
22
28
  /** Resolves the kit's neutral palette from the nearest MUI ThemeProvider. */
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export { ChartFrame } from './components/charts/ChartFrame';
46
46
  export { BarChart } from './components/charts/BarChart';
47
47
  export { LineChart } from './components/charts/LineChart';
48
48
  export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
49
+ export { ScatterChart, ScatterReferenceCurve, ScatterReferenceLine, scaleBubbleRadius, } from './components/charts/ScatterChart';
49
50
  export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
50
51
  export { formatShortTime, formatShortDate, formatShortMonth, formatShortYear, } from './components/charts/chartLabels';
51
52
  export { yearTickInterval } from './components/charts/yearTickInterval';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -12,6 +12,7 @@ import {
12
12
  withAxisDefaults,
13
13
  withChartSlotDefaults,
14
14
  withGridDefaults,
15
+ withMarginDefaults,
15
16
  } from './chartDefaults';
16
17
 
17
18
  /**
@@ -77,7 +78,7 @@ export function BarChart({
77
78
  colors={palette || neutralPalette.categorical}
78
79
  grid={withGridDefaults(grid)}
79
80
  hideLegend={hideLegend}
80
- margin={rotatedTickSpace.margin}
81
+ margin={withMarginDefaults(rotatedTickSpace.margin)}
81
82
  slotProps={withChartSlotDefaults(slotProps, legendPosition)}
82
83
  />
83
84
  </Box>
@@ -80,7 +80,9 @@ export function ChartFrame({
80
80
 
81
81
  return (
82
82
  <Paper variant={variant} sx={[CHART_FRAME_ROOT_SX, ...callerSx]}>
83
- <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }}>
83
+ {/* THEME-11: mb: 2 -> mb: 1 (16 -> 8px) -- one line item in the estate-wide chart-furniture
84
+ trim; the controls row below keeps its own mb: 2, not named in that WO's scope. */}
85
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1 }}>
84
86
  <Box>
85
87
  <Typography id={titleId} variant={titleVariant}>{title}</Typography>
86
88
  {subtitle && <Typography variant="body2" color="text.secondary">{subtitle}</Typography>}
@@ -15,6 +15,7 @@ import {
15
15
  withAxisDefaults,
16
16
  withChartSlotDefaults,
17
17
  withGridDefaults,
18
+ withMarginDefaults,
18
19
  } from './chartDefaults';
19
20
 
20
21
  // Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
@@ -100,7 +101,7 @@ export function LineChart({
100
101
  colors={palette || neutralPalette.categorical}
101
102
  grid={withGridDefaults(grid)}
102
103
  hideLegend={hideLegend}
103
- margin={rotatedTickSpace.margin}
104
+ margin={withMarginDefaults(rotatedTickSpace.margin)}
104
105
  slots={{ mark: FilledMarkElement, ...slots }}
105
106
  slotProps={withChartSlotDefaults(slotProps, legendPosition)}
106
107
  />