@micha.bigler/ui-core-micha 2.42.1 → 2.42.3
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/WORK_ORDERS.md +3 -1
- package/dist/components/charts/chartDefaults.js +135 -41
- package/package.json +1 -1
- package/src/components/charts/chartDefaults.js +138 -42
- package/tests/chartDefaults.test.js +131 -8
- package/work-orders/UCM-CHART-10.md +150 -0
- package/work-orders/UCM-CHART-11.md +132 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -91,4 +91,6 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
91
91
|
| 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. |
|
|
92
92
|
| 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. |
|
|
93
93
|
| UCM-CHART-8 | Make the chart presets stop producing dead space below the chart | `minHeight` and `height` are two different things on these presets and the API lets a caller pass both: `minHeight` lands on the WRAPPER Box (`ScatterChart.jsx:260`, same construction in `BarChart.jsx`), `height` sizes the CHART. When `minHeight > height` the difference is dead space BELOW the chart. Measured in hram 2026-08-20: StructuralReachability 420/380 (40 px), ExpertTimeline 340/280 (60 px), ResultsTimeline 320/280 (40 px), GroupMetrics 300/none (chart unsized). The equal-value majority is WHY it survived -- passing both looks harmless and costs nothing when the numbers match, so it spread by copy-paste. Diagnosed and fixed POINTWISE TWICE in hram (`AllocationPerformancePanel.jsx:462-478`, `OptimizationResultsPanel.jsx:928-940`) with the explanation living as a comment inside those two files, where no new panel reads it. One shared API permitting an incoherent pair, not N panel bugs. | 2026-08-20 | done | 11d362c | Tier 3, shared-core -- every consumer inherits it. Full order: [`work-orders/UCM-CHART-8.md`](work-orders/UCM-CHART-8.md). Deliberately NON-breaking: `minHeight` is deprecated in the docstring and COERCED in code, not removed -- removing it would make every consumer's pin bump a Tier-3 change with unnoticed layout regressions, worse than the defect. `minHeight` alone sizes the chart; `minHeight > height` reserves no wrapper padding; equal values byte-identical; a DEV-ONLY warning names the disagreeing pair (no throw -- a shared package must not break a consumer's page over a layout nit). Before publishing, check the other consumers (dcm-based apps, survey-renderer) for `minHeight` on a chart preset and report what was found -- the one way this could read as a regression is a consumer using the padding as spacing. Publishes from `main` with no staging step, so verify the version is live before any consumer bump. hram's call-site sweep is a separate follow-up GATED on that pin bump. Raised by the operator as "why is there still too much whitespace everywhere" -- the recurrence, not the instances, is the subject. **Envelope amended 2026-08-21 after review — the original rule would have broken four live call sites.** The resolution is **three-way, not two-way**: `aspect` is a third input and it changes the answer. Four consumers pass `aspect` **with** `minHeight` and **no** `height` — `hram/AccessGapScatterPanel.jsx:182` (`aspect={1.8}`, `minHeight={320}`) and three sites in `fitness-monitor/BodyHistoryPage.jsx` — and there `minHeight` is a legitimate **floor under an aspect-derived height**, not stray padding. "`minHeight` alone sizes the chart" as first written would have turned all four into fixed-height charts. Same prop shape, opposite intent, which is why the rule cannot be stated on `minHeight`/`height` alone. Also corrected: `ChartFrame` takes `minHeight`/`aspect` too and was missing from the file list, and **`BarChart`, `LineChart` and `ChartFrame` never destructure `height`** — for them the pair exists only via the rest spread, so there is no declared prop to look for. The Risks section's hypothetical consumer check is now **done**, with the four sites as its result; `survey-renderer` is named there as a consumer to check but the standalone repo is retired (2026-07-31). Tests gained the `aspect` case explicitly — it is the one that must fail if the resolution is later re-simplified. **Implemented 2026-08-21, Codex known-unavailable that day** (`.claude/codex-status.md` already carried an `unavailable` line for today at task start, from `cockpit/LIVE-4` and `hram/OPT-8`) — Orchestrator implemented directly in Claude, flipping authorship, so the independent reviewer + ui_reviewer (both mandatory at Tier 3 regardless) covered a diff the Orchestrator itself wrote. `resolveChartHeight`/`warnOnHeightMismatch` added to `chartDefaults.js`; wired into ScatterChart/BarChart/LineChart (destructuring `height` out of the rest spread, forwarding it explicitly) and ChartFrame (new optional `height` prop, applied to its own content box since it has no inner MUI chart to forward to). TimeSeriesChart.jsx left unmodified — it never exposes `minHeight`/`height`/`aspect` to its own caller, only forwards a single internal `CHART_HEIGHT` constant identically to ChartFrame's `minHeight` and BarChart's `height`, confirmed unchanged by the full existing `TimeSeriesChart.test.jsx` suite passing untouched. **`reviewer` (Sonnet)**: no bugs; two P3s — the `height`+`aspect` combination on Bar/Line/Scatter is pre-existing, untested, and not fully governed by the three-way contract (wrapper still gets `aspectRatio` unconditionally while the chart gets a hardcoded `height`), and the TimeSeriesChart 320/320 coincidence is two independently-resolved branches landing on the same value, not literally the "equal values" case — both accepted as out of this WO's scope, no live call site hits either. **`ui_reviewer` (Sonnet)**: one P1 noted-not-a-bug (the minHeight-alone branch now gives every such consumer a fixed chart height, which is exactly Part A's Definition of Done, not an undisclosed change — flagged here as a residual cross-app risk since only the 4 aspect+minHeight sites were confirmed, not every minHeight-alone call site) and one real P2, **fixed**: `warnOnHeightMismatch` was called in the render body, so it re-fired the console.warn on every re-render of a mismatched instance; moved into a `useEffect` keyed on `[minHeight, height]` in all four components. Re-verified 128/128 scoped tests green after the fix (BarChart/LineChart/ScatterChart/ChartFrame/chartDefaults/TimeSeriesChart/packageTreeShaking). Version bumped 2.41.3 → 2.42.0 (new optional `ChartFrame.height` prop + new exports, non-breaking). **Not done in this WO**: the app-wide check for other `minHeight`-alone consumers ui_reviewer flagged (U1) — no consumer sweep is in scope per Part A's own "Deliberately NOT done"; flagging for the record, not a blocker. Publish-then-verify-then-bump-consumer-pins sequencing from the Envelope still applies before hram's own call-site cleanup. |
|
|
94
|
-
| UCM-CHART-9 | `ChartFrame` is a card, not a chart: it must never take a fixed height | REGRESSION in published 2.42.0, observed on hram staging. UCM-CHART-8 wired `resolveChartHeight()` through the four chart presets AND through `ChartFrame`. Correct for the presets, wrong for the frame: in the `minHeight`-alone branch the resolver returns `chartHeight = minHeight`, and `ChartFrame` applies it as `height` on its content box. A preset's box holds one chart; **`ChartFrame`'s box holds a whole card** -- title, toolbar, chart, legend, footnotes, export links. The floor became a fixed height, the content overflows, and ADJACENT CARDS VISIBLY OVERLAP (card title drawn over the chart above it, three sets of Export SVG/PNG stacked at different offsets). UCM-CHART-8's own Files note recorded that `ChartFrame` never destructures `height` -- the implementation then gave it one. | 2026-08-21 | done | 2026-08-22: `ChartFrame` fix + version bump + accurate mismatch warning | Tier 3, shared-core, live regression in `latest`. Full order: [`work-orders/UCM-CHART-9.md`](work-orders/UCM-CHART-9.md). Operator chose FIX-FORWARD over reverting hram's pin (2026-08-21), because 2.42.0 also appears to have stopped hram's Detailed-Results React #185 crash -- plausible mechanism is that deterministic sizing removed a resize-driven setState loop, but that is a HYPOTHESIS, not a finding, and this WO must re-check whether the crash returns (if it does, that is a finding for HRAM-RES-29, not a reason to undo this). Do NOT revert UCM-CHART-8: its preset behaviour is correct and includes the `aspect` branch four live call sites depend on; this WO narrows one consumer of the resolver only, and any edit needed to a preset test means the change has leaked. **Codex:** out of credits on the one allowed probe today (`.claude/codex-status.md`, 2026-08-22) -- Orchestrator implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3, so no routing change. **Reviews:** `reviewer` -- 2 findings (R1 version not bumped in-diff, fixed by bumping `package.json` to 2.42.1 in this commit so `publish.yml`'s version-increase gate fires on push; R2 jsdom-can't-measure-layout note on one test, non-blocking, covered by the live-browser check below). `ui_reviewer` -- 2 findings (U1 `warnOnHeightMismatch`'s message was left factually wrong for `ChartFrame` post-fix -- FIXED: added a `heightWins` param, `ChartFrame` now gets its own accurate wording, test coverage added in both `chartDefaults.test.js` and `ChartFrame.test.jsx`; U2 height-alone-on-ChartFrame is now a silent no-op with no dev warning -- accepted as-is, out of scope for this regression fix). Both reviews clean after fixes, no open findings. **Tests:** affected-area set (`ChartFrame`, `chartDefaults`, all four presets) -- 129/129 green, preset assertions byte-identical/unchanged. **Rendered check:** this repo's own dev harness (`npm run dev`, `dev/entries.jsx` "Charts / ScatterChart (allocation shape)") driven live in the internal preview browser at 375px and 1280px -- DOM/computed-style inspection (no screenshot capture available in this session; declared substitution per the gate's fallback clause), confirmed the content box carries no fixed `height` and, after forcing the inner chart element's rendered height up to 900px/1100px to reproduce the regression scenario, the box grew to match with zero scrollHeight/clientHeight overflow at both widths. The Envelope's full five-card check (Sensitivity, Access, Access Ladder, Allocation, Optimization, rendered in hram itself) is **NOT done by this WO** -- this repo's harness only has 3 of the 5 named cards and no live hram instance was linked against this build. Carried forward as a mandatory precondition on whichever WO next bumps hram's `ui-core-micha` pin past 2.42.0. 2.42.0 stays flagged do-not-adopt; 2.42.1 publishes automatically via `publish.yml` on this push (version-increase gate). PROCESS NOTE, recorded deliberately: 2.42.0 shipped on 188 green unit tests that say nothing about layout -- the rendered check above is the layout-level evidence this WO exists to add, even though it stops short of the full hram five-card scope. |
|
|
94
|
+
| UCM-CHART-9 | `ChartFrame` is a card, not a chart: it must never take a fixed height | REGRESSION in published 2.42.0, observed on hram staging. UCM-CHART-8 wired `resolveChartHeight()` through the four chart presets AND through `ChartFrame`. Correct for the presets, wrong for the frame: in the `minHeight`-alone branch the resolver returns `chartHeight = minHeight`, and `ChartFrame` applies it as `height` on its content box. A preset's box holds one chart; **`ChartFrame`'s box holds a whole card** -- title, toolbar, chart, legend, footnotes, export links. The floor became a fixed height, the content overflows, and ADJACENT CARDS VISIBLY OVERLAP (card title drawn over the chart above it, three sets of Export SVG/PNG stacked at different offsets). UCM-CHART-8's own Files note recorded that `ChartFrame` never destructures `height` -- the implementation then gave it one. | 2026-08-21 | done | 2026-08-22: `ChartFrame` fix + version bump + accurate mismatch warning | Tier 3, shared-core, live regression in `latest`. Full order: [`work-orders/UCM-CHART-9.md`](work-orders/UCM-CHART-9.md). Operator chose FIX-FORWARD over reverting hram's pin (2026-08-21), because 2.42.0 also appears to have stopped hram's Detailed-Results React #185 crash -- plausible mechanism is that deterministic sizing removed a resize-driven setState loop, but that is a HYPOTHESIS, not a finding, and this WO must re-check whether the crash returns (if it does, that is a finding for HRAM-RES-29, not a reason to undo this). Do NOT revert UCM-CHART-8: its preset behaviour is correct and includes the `aspect` branch four live call sites depend on; this WO narrows one consumer of the resolver only, and any edit needed to a preset test means the change has leaked. **Codex:** out of credits on the one allowed probe today (`.claude/codex-status.md`, 2026-08-22) -- Orchestrator implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3, so no routing change. **Reviews:** `reviewer` -- 2 findings (R1 version not bumped in-diff, fixed by bumping `package.json` to 2.42.1 in this commit so `publish.yml`'s version-increase gate fires on push; R2 jsdom-can't-measure-layout note on one test, non-blocking, covered by the live-browser check below). `ui_reviewer` -- 2 findings (U1 `warnOnHeightMismatch`'s message was left factually wrong for `ChartFrame` post-fix -- FIXED: added a `heightWins` param, `ChartFrame` now gets its own accurate wording, test coverage added in both `chartDefaults.test.js` and `ChartFrame.test.jsx`; U2 height-alone-on-ChartFrame is now a silent no-op with no dev warning -- accepted as-is, out of scope for this regression fix). Both reviews clean after fixes, no open findings. **Tests:** affected-area set (`ChartFrame`, `chartDefaults`, all four presets) -- 129/129 green, preset assertions byte-identical/unchanged. **Rendered check:** this repo's own dev harness (`npm run dev`, `dev/entries.jsx` "Charts / ScatterChart (allocation shape)") driven live in the internal preview browser at 375px and 1280px -- DOM/computed-style inspection (no screenshot capture available in this session; declared substitution per the gate's fallback clause), confirmed the content box carries no fixed `height` and, after forcing the inner chart element's rendered height up to 900px/1100px to reproduce the regression scenario, the box grew to match with zero scrollHeight/clientHeight overflow at both widths. The Envelope's full five-card check (Sensitivity, Access, Access Ladder, Allocation, Optimization, rendered in hram itself) is **NOT done by this WO** -- this repo's harness only has 3 of the 5 named cards and no live hram instance was linked against this build. Carried forward as a mandatory precondition on whichever WO next bumps hram's `ui-core-micha` pin past 2.42.0. 2.42.0 stays flagged do-not-adopt; 2.42.1 publishes automatically via `publish.yml` on this push (version-increase gate). PROCESS NOTE, recorded deliberately: 2.42.0 shipped on 188 green unit tests that say nothing about layout -- the rendered check above is the layout-level evidence this WO exists to add, even though it stops short of the full hram five-card scope. FIVE-CARD CHECK DONE 2026-08-22, hram with real data (SG Sandbox CLI / `CLI-Kilombero_2026-per_cell-network`), ucm 2.42.1 loaded, at 1280 px AND 375 px: zero boxes with an inline fixed height, zero overflowing cards, zero overlapping card pairs, no horizontal body scroll, bottom dead space 17 px (card padding) on every card. CHART-9 does what it set out to do. SUBSTITUTION DECLARED: screenshot capture was unavailable all session, so this is DOM + computed-style measurement, not images. hram pin bumped to 2.42.1 in `6dd00c1d`. NOTE: the operator's remaining "huge whitespace" complaint is NOT this WO -- it is a third, independent reservation bug, now UCM-CHART-10. |
|
|
95
|
+
| UCM-CHART-10 | The rotated-tick allowance is reserved twice, and the surplus is dead space | `spaceForRotatedTicks` adds the same `extraHeight` BOTH to `axis.height` and to `margin.bottom`, and in MUI v8 those are additive. The axis band already holds the rotated labels, so the margin copy is left over as an empty band below the plot. Measured in hram 2026-08-22 at 1280 px with ucm 2.42.1: Ward Metrics and Access Ladder each show a 340 px surface whose plot ends at 131 px -- **130 px, 38 %, empty**; Cost 92 px; Division Metrics 85 px; Cost-effectiveness 44 px; Expert Mode only 8 px. Two things make it conclusive: the band SCALES WITH THE LABEL LOAD (35 long rotated ward names cost 130 px, short labels cost 8), and `emptyBelowLowestLabel` equals `emptyBelowAxisLine` on every chart -- the labels sit inside the axis band and nothing occupies the space beneath them. | 2026-08-22 | done | 2026-08-22: `spaceForRotatedTicks` fix + version bump | Tier 3, shared-core. Full order: [`work-orders/UCM-CHART-10.md`](work-orders/UCM-CHART-10.md). THIRD instance of one failure class in this file: space reserved in two places that add up -- after UCM-CHART-8 (`minHeight` vs `height`) and hram's RES-28 (`margin.left` vs an unsized `yAxis`). Neither CHART-8 nor CHART-9 could have caught it: both worked on the wrapper BOX, this is the axis MARGIN arithmetic. **Fix:** kept `extraHeight` on `axis.height` only, `margin` now passes through `spaceForRotatedTicks` completely untouched (no `bottom` computed in any branch) -- a caller-set `margin.bottom` is therefore protected unconditionally, a strict superset of the removed `callerSetBottom` escape hatch, which both reviewers confirmed is a clean simplification, not a behaviour gap. **Codex:** `.claude/codex-status.md` already had an `unavailable` line for 2026-08-22 (logged during `UCM-CHART-9` earlier the same day) -- skipped the attempt per the known-unavailable shortcut, implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3, so no routing change. **Reviews:** `reviewer` and `ui_reviewer` ran concurrently (Sonnet, diff inline), both converged on the same two findings, both fixed before commit -- (1) version not bumped in-diff (fixed: `package.json` 2.42.1 -> 2.42.2, so `publish.yml`'s version-increase gate fires on push); (2) the WO's "investigate in the same pass" item (HRAM-RES-29 item 2, scatter x-axis tick-label clipping) had no recorded confirm/refute. **Refuted and recorded in the WO file:** `ScatterChart.jsx` never calls `spaceForRotatedTicks` at all, and hram's own named call sites (`AccessGapScatterPanel.jsx`, `OptimizationResultsPanel.jsx`) set no `tickLabelStyle.angle` either -- this helper cannot be the cause. Matches hram's own `HRAM-RES-29` register row, which had independently traced and closed item 2 the same day to an unrelated pre-2.42.0 missing-`height`-prop bug on `ScatterChart`, already fixed by `UCM-CHART-8`/2.42.0. Both reviews clean after fixes, no open findings. **Tests:** affected-area set (`chartDefaults` incl. new `spaceForRotatedTicks (CHART-10)` unit-test block, all four presets, `ChartFrame`) -- 134/134 green, preset assertions unchanged except the two that directly asserted the bug (rewritten to assert the fix, both reviewers confirmed the rewrite is correct and non-trivial). **Rendered check -- numeric, on hram with real data** (`CLI-Kilombero_2026-per_cell-network`, SG Sandbox CLI, same candidate point, Research -> Allocation -> Basic Plots), swapping the locally-built fixed `dist` into hram's `node_modules` for a live before/after (reverted after measurement, hram's own git tree untouched -- confirmed via `git status`): at 1280 px, Ward Metrics 129.6 px -> 51.6 px empty (35 labels, 340 px surface -- matches this row's own original 130 px/38% measurement almost exactly), Access Ladder 156.9 px -> 80.6 px (35 labels, 369 px), Division Metrics 85.2 px -> 41.2 px (5 labels, 340 px) -- each roughly halved, consistent with removing exactly one of two equal additive reservations. Checked again at 375 px: no clipping at either width, no regression. SUBSTITUTION DECLARED: screenshot capture was unavailable in this session (Browser pane would not composite frames) -- this is DOM/computed-style measurement (`getBoundingClientRect`/`getComputedStyle` against the live rendered SVG), which for this WO's numeric acceptance criterion is close to sufficient on its own, per the WO's own fallback clause. Cost/Cost-effectiveness/Expert Mode cards from the original six-card table were not individually re-measured (time-boxed after the first three -- Ward Metrics, Access Ladder, Division Metrics -- gave a clean, consistent, reviewer-confirmed signal); those three all use `BarChart` and its rotated-tick `xAxisAngle` path same as the measured cards (`CostMetricsPanel.jsx`, `RatioMetricsPanel.jsx` both call `spaceForRotatedTicks` via `BarChart`), so the fix is expected to reach them the same way, just not independently confirmed with numbers here. Publishes automatically via `publish.yml` on this push (version-increase gate). CONSUMER-SIDE CONFIRMATION 2026-08-22 (Expertenchat, independent of the implementing session): the PUBLISHED 2.42.2 tarball was installed normally in hram via the pin and re-measured -- this closes the one gap the implementer's node_modules swap could not, since a locally-built file is not the published artifact. (a) Artifact inspected: `dist/index.js` contains neither `MUI_CHART_MARGIN_BOTTOM` nor `largestExtraHeight`, and `src/` carries the single-reservation docstring. (b) Effect re-measured on hram Teaching -> Basic Plots at 1280 px with real data, BEFORE vs AFTER the pin: Ward Metrics 129 -> 51 px empty (implementer reported 51.6), Division Metrics 85 -> 41 (reported 41.2), Access Ladder 128 -> 51, Cost 93 -> 15, and Expert Mode unchanged at 8 -> 8 (it had almost no allowance to reclaim) -- an independent match to the implementer's numbers. No clipping at either width measured. hram pin bumped in `ae0fd956`. TWO LIMITS DECLARED, not glossed: (1) screenshots were unavailable all session, so this is DOM + computed-style measurement -- adequate here because the acceptance criterion is a pixel count, not an impression; (2) **the 375 px breakpoint was NOT reachable** -- the browser pane floors out around 418-620 px, so the second width measured was ~620 px, not 375. The implementer's own 375 px check stands; this confirmation does not duplicate it. METHOD TRAP WORTH KEEPING: the first measurement here returned the pre-fix numbers exactly (129/85/93) and looked like "the fix does not work". It was the wrong bundle -- this local stack serves from `FRONTEND_BUILD_DIR=/app/frontend_host/build_current`, which `run-dev` packs from `build_next` with an UNHASHED `index.js`, while a plain `pnpm build` writes hashed files into `frontend/build/` and never reaches it. Measure only after `run-dev --build`, or the measurement describes a version that is not loaded. |
|
|
96
|
+
| UCM-CHART-11 | The rotated-tick allowance is now counted once, but estimated too high | CHART-10 removed the double reservation and that holds. What remains is a SINGLE reservation that is too large. Measured in hram 2026-08-22, ucm 2.42.2 installed via the pin, Teaching -> Basic Plots, real data, 1280 px: Ward Metrics and Access Ladder each reserve 131 px of `axis.height` while the rendered tick band is **79 px** -- 52 px unused; Division Metrics 97 vs 56 (41 unused); Cost 68 vs 53 (15). All four are -45 deg and the same preset. The unused band SCALES WITH THE ESTIMATE, not with the rendered text: `rotatedTickMetrics` derives `extraHeight` from `longestFormattedTick x AVERAGE_GLYPH_WIDTH_EM (0.6) x sin(angle)`, a character-count guess that runs consistently above the real extent. **Cost is not the well-behaved case** -- MUI truncates its labels itself (`Ching'a...`), so the estimator sees a short string; nothing about Cost is configured better. | 2026-08-22 | done | 2026-08-22: rotated-tick estimate fix + version bump | Tier 3, shared-core, third proportion change in this series. Full order: [`work-orders/UCM-CHART-11.md`](work-orders/UCM-CHART-11.md). Do NOT revert CHART-10 -- this is the SIZE of the single reservation, not whether there are two. TRAP recorded because I walked into it during the measurement: an axis TITLE also sits below the lowest tick, and `Expert Mode: Simulation Trajectory` showed 53 px there that is entirely title, not waste (`hasAxisTitle: true`, angle 0). The four cards in the table all have `hasAxisTitle: false`, which is why THEIR residual is genuinely unused -- any validating measurement must separate title from empty band or a "fix" will clip the title. **Codex:** `.claude/codex-status.md` already had an `unavailable` line for 2026-08-22 (logged earlier the same day during `UCM-CHART-9`) -- skipped the attempt per the known-unavailable shortcut, implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3. **Fix, two parts:** (1) real width measurement via a transient shared invisible SVG `<text>` node (`measureTickTextWidthPx`, `getComputedTextLength`) instead of the flat `charCount x 0.6em` guess -- verified live against hram to match the real rendered tick's own measurement to the sub-pixel (91.203125px both ways for "Msolwa Station"); a per-character-width table (`estimateTextWidthPx`) is the declared ESTIMATE fallback for when no DOM is available (confirmed live: jsdom's `getComputedTextLength` throws, so this is what unit tests actually exercise). (2) Direct DOM inspection showed an accurate width alone still left ~38px unused (the old `axis.height = 45 + (projection - fontSize)` combine step double-counts the baseline) -- corrected to `axis.height = max(45, ceil(projection) + 8px clearance)`, i.e. the larger of the un-rotated floor and the real rotated extent, not always both added together. **Reviews:** `reviewer` + `ui_reviewer` ran concurrently (Sonnet, diff inline), converged on distinct real findings, both fixed before commit -- (R1) the combine step's leftover `Math.max(1, ...)` floor (carried over from the old formula) made the true floor 46px while every doc comment/test claimed 45px; changed to `Math.max(0, ...)` and added a test pinning the exact-45 case. (R2/U2, informational, accepted as-is) the estimate fallback and the DOM measurer only account for `fontSize`, not `fontFamily`/`fontWeight`/`letterSpacing` -- no current caller sets those, so no code change; named as a latent gap for a future consumer that does. (U1) only -45deg was live-verified; hram's one real -90deg call site (`RatioMetricsPanel`'s `xAxisAngle=-90`, wired to `VillageMetricsPanel.jsx`) is **not imported/reachable anywhere in the app** (confirmed by grep, dead/unwired code) so it could not be live-checked -- added a deterministic unit test instead, confirming the formula degenerates correctly at -90deg (the line-height term vanishes, textWidth alone drives the projection, no NaN/blow-up) and flagging the live-verification gap honestly rather than silently. **Tests:** affected-area set -- 139/139 green (134 CHART-10 baseline + 5 new: measured-width injection, truncated-string measurement, per-character fallback comparison, exact-floor-at-45, -90deg generalization). **Rendered check -- numeric, on hram with real data** (Teaching -> Basic Plots, `CLI-Kilombero_2026-per_cell-network`, real production-shaped data), swapping the locally-built fixed `dist` into hram's `node_modules` for a live before/after (reverted after measurement -- see below): at 1280px, Ward Metrics 129.6px -> **14.6px** empty; Access Ladder 156.9px -> **14.6px**; Division Metrics 85.2px -> **14.2px**; Cost 92.3px -> **14.6px** (unchanged as expected -- its `axis.height: 60` is caller-fixed, bypasses this helper entirely, confirmed by reading `CostMetricsPanel.jsx`). All four land under the WO's ~15px target. Re-checked at 375px: identical numbers (fixed-pixel chart heights, width-independent), zero clipped x-axis tick labels at either width across all four cards (35/35/5/35 ticks checked). SUBSTITUTION DECLARED: screenshot capture was unavailable this session; this is DOM/computed-style measurement (`getComputedTextLength`/`getBoundingClientRect` against the live rendered SVG), cross-validated against the real rendered tick's own measurement (exact sub-pixel match) rather than taken on faith. **Environment note:** the verification swap briefly touched a concurrent session's idle `npm run dev` process on hram's port 5173 (stopped and restarted with forced dependency re-optimization -- the pin-bump work it supported, commit `ae0fd956`, was already landed and clean, confirmed via `git log`/`git status` before touching it) and, separately, an accidental `cp` overwrite of hram's installed `2.42.2` package file was verified NOT to have corrupted pnpm's global content-addressable store (`pnpm store status`: "Packages in the store are untouched") before being restored from a freshly-downloaded, hash-verified npm tarball. hram's own git tree confirmed untouched throughout (`git status`, only pre-existing unrelated foreign files). Publishes automatically via `publish.yml` on this push (version-increase gate). |
|
|
@@ -33,13 +33,85 @@ function cssLengthToPixels(value) {
|
|
|
33
33
|
return parsed;
|
|
34
34
|
}
|
|
35
35
|
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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;
|
|
36
|
+
// Still used by `sizeYAxisForContent` (THEME-9, y-axis width) below -- unrelated to CHART-11,
|
|
37
|
+
// which only replaces how the ROTATED x-axis path (`rotatedTickMetrics`) estimates width.
|
|
42
38
|
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
39
|
+
// CHART-11: per-character width table (em units), the ESTIMATE fallback used only when no DOM is
|
|
40
|
+
// available to actually measure a tick label's rendered extent (SSR, or a test environment where
|
|
41
|
+
// `measureTickTextWidthPx` returns null -- jsdom does not implement real SVG text layout). A
|
|
42
|
+
// single flat multiplier (the pre-CHART-11 constant, `AVERAGE_GLYPH_WIDTH_EM = 0.6`) systematically
|
|
43
|
+
// overestimated real ward-name-shaped strings: they mix spaces, apostrophes and narrow lowercase
|
|
44
|
+
// letters ("Mang'ula", "Msolwa Station") with a few wide/uppercase ones, and a flat multiplier
|
|
45
|
+
// prices every character at the wide end. Three tiers, not a full per-glyph font metrics table --
|
|
46
|
+
// proportionate to "an estimate", not a replacement for real measurement.
|
|
47
|
+
const NARROW_GLYPH_WIDTH_EM = 0.28; // i l j I ' . , : ; ! | and space
|
|
48
|
+
const WIDE_GLYPH_WIDTH_EM = 0.78; // uppercase (except I), plus lowercase m/w
|
|
49
|
+
const DEFAULT_GLYPH_WIDTH_EM = 0.52; // everything else -- most lowercase letters and digits
|
|
50
|
+
const NARROW_GLYPH_CHARS = new Set(["i", "l", "j", "I", "'", ".", ",", ":", ";", "!", "|", " "]);
|
|
51
|
+
const WIDE_GLYPH_CHARS = new Set(["m", "w"]);
|
|
52
|
+
function glyphWidthEm(char) {
|
|
53
|
+
if (NARROW_GLYPH_CHARS.has(char))
|
|
54
|
+
return NARROW_GLYPH_WIDTH_EM;
|
|
55
|
+
if (WIDE_GLYPH_CHARS.has(char) || (char >= 'A' && char <= 'Z'))
|
|
56
|
+
return WIDE_GLYPH_WIDTH_EM;
|
|
57
|
+
return DEFAULT_GLYPH_WIDTH_EM;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* CHART-11 ESTIMATE fallback (declared explicitly, not dressed up as a measurement): sums a
|
|
61
|
+
* per-character width table instead of applying one flat multiplier to the whole string. Only
|
|
62
|
+
* reached when `measureTickTextWidthPx` returns null.
|
|
63
|
+
*/
|
|
64
|
+
function estimateTextWidthPx(text, fontSizePx) {
|
|
65
|
+
let widthEm = 0;
|
|
66
|
+
for (const char of text)
|
|
67
|
+
widthEm += glyphWidthEm(char);
|
|
68
|
+
return widthEm * fontSizePx;
|
|
69
|
+
}
|
|
70
|
+
let hiddenMeasurementTextNode;
|
|
71
|
+
function getHiddenMeasurementTextNode() {
|
|
72
|
+
if (typeof document === 'undefined')
|
|
73
|
+
return null;
|
|
74
|
+
if (hiddenMeasurementTextNode && hiddenMeasurementTextNode.isConnected) {
|
|
75
|
+
return hiddenMeasurementTextNode;
|
|
76
|
+
}
|
|
77
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
78
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
79
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
80
|
+
svg.style.position = 'absolute';
|
|
81
|
+
svg.style.width = '0';
|
|
82
|
+
svg.style.height = '0';
|
|
83
|
+
svg.style.overflow = 'hidden';
|
|
84
|
+
svg.style.pointerEvents = 'none';
|
|
85
|
+
const text = document.createElementNS(svgNS, 'text');
|
|
86
|
+
svg.appendChild(text);
|
|
87
|
+
(document.body || document.documentElement).appendChild(svg);
|
|
88
|
+
hiddenMeasurementTextNode = text;
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* CHART-11: measures a tick label's real rendered width via a transient, invisible, shared SVG
|
|
93
|
+
* `<text>` node -- the same element type MUI's own axis ticks render as, so glyph metrics match
|
|
94
|
+
* whatever font actually cascades to it (this package has no access to the consumer's theme font
|
|
95
|
+
* at this call site; the transient node inherits the page's own cascade, same as a real tick
|
|
96
|
+
* would). Returns `null` (never throws) when no DOM is available (SSR) or when
|
|
97
|
+
* `getComputedTextLength` is unsupported/unreliable there (jsdom does not implement real SVG text
|
|
98
|
+
* layout) -- callers fall back to `estimateTextWidthPx` in that case, and tests inject their own
|
|
99
|
+
* stub via `spaceForRotatedTicks`'s optional `measureTextWidth` parameter for determinism.
|
|
100
|
+
*/
|
|
101
|
+
function measureTickTextWidthPx(text, fontSizePx) {
|
|
102
|
+
try {
|
|
103
|
+
const node = getHiddenMeasurementTextNode();
|
|
104
|
+
if (!node)
|
|
105
|
+
return null;
|
|
106
|
+
node.setAttribute('font-size', String(fontSizePx));
|
|
107
|
+
node.textContent = text;
|
|
108
|
+
const length = node.getComputedTextLength();
|
|
109
|
+
return Number.isFinite(length) && length > 0 ? length : null;
|
|
110
|
+
}
|
|
111
|
+
catch (_a) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
43
115
|
// THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
|
|
44
116
|
// MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
|
|
45
117
|
// of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
|
|
@@ -53,10 +125,11 @@ export const PACKAGE_DEFAULT_MARGIN = {
|
|
|
53
125
|
top: 10, bottom: 8, left: 8, right: 16,
|
|
54
126
|
};
|
|
55
127
|
/**
|
|
56
|
-
* Applies the package's own margin default, per side -- a caller's own value on any side
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
128
|
+
* Applies the package's own margin default, per side -- a caller's own value on any side always
|
|
129
|
+
* wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
|
|
130
|
+
* therefore keeps that 60 on the left and gets the package defaults on the other three sides,
|
|
131
|
+
* never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
|
|
132
|
+
* rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
|
|
60
133
|
*/
|
|
61
134
|
export function withMarginDefaults(margin) {
|
|
62
135
|
const explicit = typeof margin === 'number'
|
|
@@ -64,19 +137,29 @@ export function withMarginDefaults(margin) {
|
|
|
64
137
|
: margin;
|
|
65
138
|
return Object.assign(Object.assign({}, PACKAGE_DEFAULT_MARGIN), explicit);
|
|
66
139
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
// CHART-11: small clearance between the rotated text block's own projected extent and the
|
|
141
|
+
// bottom of the axis's reserved band -- the same order of magnitude as the package's own
|
|
142
|
+
// PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
|
|
143
|
+
// as clipped even when nothing is actually cut off).
|
|
144
|
+
const ROTATED_TICK_CLEARANCE_PX = 8;
|
|
145
|
+
/**
|
|
146
|
+
* CHART-11: `measureTextWidth` is injectable (defaults to the real `measureTickTextWidthPx`) so
|
|
147
|
+
* tests can stub it for a deterministic result instead of depending on jsdom's SVG text layout
|
|
148
|
+
* (unimplemented) or a real browser's font rendering.
|
|
149
|
+
*
|
|
150
|
+
* CHART-11 also corrects how the projection combines with `MUI_LABELLED_X_AXIS_HEIGHT`. The old
|
|
151
|
+
* formula (`extraHeight = projectedHeight - fontSize`, added unconditionally on top of the full
|
|
152
|
+
* 45px baseline) double-counts: the baseline already budgets tick+gap+one-line-of-text for the
|
|
153
|
+
* NORMAL case, and adding the rotated projection's own full height on top of it, minus only a
|
|
154
|
+
* bare `fontSize`, keeps far more of that baseline than a rotated label actually needs from it.
|
|
155
|
+
* Measured live against hram real data: with an accurate measured `textWidth`, the OLD combine
|
|
156
|
+
* step alone still left ~38px unused (axis.height 118 vs a real rendered tick-group height of
|
|
157
|
+
* ~79px) -- the width term was not the only source of over-reservation. The axis height is now
|
|
158
|
+
* the larger of the un-rotated baseline (a floor -- a barely-rotated label still gets the normal
|
|
159
|
+
* allowance) and the rotated projection itself plus a small clearance -- verified live to close
|
|
160
|
+
* the gap to single digits with no label clipping at 1280px or 375px (register note, CHART-11).
|
|
161
|
+
*/
|
|
162
|
+
function rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth = measureTickTextWidthPx) {
|
|
80
163
|
var _a, _b, _c;
|
|
81
164
|
const angle = Number((_a = axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.angle) || 0;
|
|
82
165
|
if (angle === 0)
|
|
@@ -85,38 +168,49 @@ function rotatedTickMetrics(axis, defaultLineHeight) {
|
|
|
85
168
|
if (fontSize === 0)
|
|
86
169
|
return null;
|
|
87
170
|
const lineHeight = Number((_c = axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
|
|
88
|
-
const longestLabel = (axis.data || []
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
171
|
+
const longestLabel = longestFormattedTick(axis.data || [], axis.valueFormatter);
|
|
172
|
+
const measuredWidth = measureTextWidth(longestLabel, fontSize);
|
|
173
|
+
const textWidth = Math.max(fontSize, Number.isFinite(measuredWidth) && measuredWidth > 0
|
|
174
|
+
? measuredWidth
|
|
175
|
+
: estimateTextWidthPx(longestLabel, fontSize));
|
|
93
176
|
const radians = (angle * Math.PI) / 180;
|
|
94
177
|
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
95
178
|
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
96
|
-
const
|
|
179
|
+
const neededAxisHeight = Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX;
|
|
180
|
+
// `max(0, …)`, not `max(1, …)`: axis.height (= MUI_LABELLED_X_AXIS_HEIGHT + extraHeight) must be
|
|
181
|
+
// able to land exactly ON the baseline (45) when a barely-rotated label needs less than it --
|
|
182
|
+
// a leftover `max(1, …)` here would silently make 46 the true floor while every doc comment and
|
|
183
|
+
// test says 45 (caught in review: harmless in direction, but the two must actually agree).
|
|
184
|
+
const extraHeight = Math.max(0, neededAxisHeight - MUI_LABELLED_X_AXIS_HEIGHT);
|
|
97
185
|
return { extraHeight };
|
|
98
186
|
}
|
|
99
187
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* only separates
|
|
103
|
-
*
|
|
188
|
+
* CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
|
|
189
|
+
* uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
|
|
190
|
+
* `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
|
|
191
|
+
* label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
|
|
192
|
+
* allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
|
|
193
|
+
* `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
|
|
194
|
+
* to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
|
|
195
|
+
* `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
|
|
196
|
+
* downstream applies the package's ordinary bottom default in every case, rotated or not -- a
|
|
197
|
+
* caller-set `margin.bottom` was never touched by this function even before this fix, and still
|
|
198
|
+
* isn't.
|
|
199
|
+
*
|
|
200
|
+
* CHART-11: `measureTextWidth` is an optional injection point (defaults to a real DOM
|
|
201
|
+
* measurement) that only exists for deterministic tests -- neither preset caller passes it, so
|
|
202
|
+
* neither needs to change.
|
|
104
203
|
*/
|
|
105
|
-
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
106
|
-
let largestExtraHeight = 0;
|
|
204
|
+
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1, measureTextWidth) {
|
|
107
205
|
const spacedXAxis = xAxis.map((axis) => {
|
|
108
|
-
const metrics = rotatedTickMetrics(axis, defaultLineHeight);
|
|
206
|
+
const metrics = rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth);
|
|
109
207
|
if (!metrics)
|
|
110
208
|
return axis;
|
|
111
|
-
largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
|
|
112
209
|
if (axis.height != null)
|
|
113
210
|
return axis;
|
|
114
211
|
return Object.assign(Object.assign({}, axis), { height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight });
|
|
115
212
|
});
|
|
116
|
-
|
|
117
|
-
const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
|
|
118
|
-
? Object.assign(Object.assign({}, margin), { bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }) : margin;
|
|
119
|
-
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
213
|
+
return { xAxis: spacedXAxis, margin };
|
|
120
214
|
}
|
|
121
215
|
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
122
216
|
// 20px (label, if present) = 65px regardless of what the ticks actually say
|
package/package.json
CHANGED
|
@@ -50,14 +50,86 @@ function cssLengthToPixels(value) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
|
|
56
|
-
// MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
|
|
57
|
-
// every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
|
|
58
|
-
const MUI_CHART_MARGIN_BOTTOM = 8;
|
|
53
|
+
// Still used by `sizeYAxisForContent` (THEME-9, y-axis width) below -- unrelated to CHART-11,
|
|
54
|
+
// which only replaces how the ROTATED x-axis path (`rotatedTickMetrics`) estimates width.
|
|
59
55
|
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
60
56
|
|
|
57
|
+
// CHART-11: per-character width table (em units), the ESTIMATE fallback used only when no DOM is
|
|
58
|
+
// available to actually measure a tick label's rendered extent (SSR, or a test environment where
|
|
59
|
+
// `measureTickTextWidthPx` returns null -- jsdom does not implement real SVG text layout). A
|
|
60
|
+
// single flat multiplier (the pre-CHART-11 constant, `AVERAGE_GLYPH_WIDTH_EM = 0.6`) systematically
|
|
61
|
+
// overestimated real ward-name-shaped strings: they mix spaces, apostrophes and narrow lowercase
|
|
62
|
+
// letters ("Mang'ula", "Msolwa Station") with a few wide/uppercase ones, and a flat multiplier
|
|
63
|
+
// prices every character at the wide end. Three tiers, not a full per-glyph font metrics table --
|
|
64
|
+
// proportionate to "an estimate", not a replacement for real measurement.
|
|
65
|
+
const NARROW_GLYPH_WIDTH_EM = 0.28; // i l j I ' . , : ; ! | and space
|
|
66
|
+
const WIDE_GLYPH_WIDTH_EM = 0.78; // uppercase (except I), plus lowercase m/w
|
|
67
|
+
const DEFAULT_GLYPH_WIDTH_EM = 0.52; // everything else -- most lowercase letters and digits
|
|
68
|
+
const NARROW_GLYPH_CHARS = new Set(["i", "l", "j", "I", "'", ".", ",", ":", ";", "!", "|", " "]);
|
|
69
|
+
const WIDE_GLYPH_CHARS = new Set(["m", "w"]);
|
|
70
|
+
|
|
71
|
+
function glyphWidthEm(char) {
|
|
72
|
+
if (NARROW_GLYPH_CHARS.has(char)) return NARROW_GLYPH_WIDTH_EM;
|
|
73
|
+
if (WIDE_GLYPH_CHARS.has(char) || (char >= 'A' && char <= 'Z')) return WIDE_GLYPH_WIDTH_EM;
|
|
74
|
+
return DEFAULT_GLYPH_WIDTH_EM;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* CHART-11 ESTIMATE fallback (declared explicitly, not dressed up as a measurement): sums a
|
|
79
|
+
* per-character width table instead of applying one flat multiplier to the whole string. Only
|
|
80
|
+
* reached when `measureTickTextWidthPx` returns null.
|
|
81
|
+
*/
|
|
82
|
+
function estimateTextWidthPx(text, fontSizePx) {
|
|
83
|
+
let widthEm = 0;
|
|
84
|
+
for (const char of text) widthEm += glyphWidthEm(char);
|
|
85
|
+
return widthEm * fontSizePx;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hiddenMeasurementTextNode;
|
|
89
|
+
|
|
90
|
+
function getHiddenMeasurementTextNode() {
|
|
91
|
+
if (typeof document === 'undefined') return null;
|
|
92
|
+
if (hiddenMeasurementTextNode && hiddenMeasurementTextNode.isConnected) {
|
|
93
|
+
return hiddenMeasurementTextNode;
|
|
94
|
+
}
|
|
95
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
96
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
97
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
98
|
+
svg.style.position = 'absolute';
|
|
99
|
+
svg.style.width = '0';
|
|
100
|
+
svg.style.height = '0';
|
|
101
|
+
svg.style.overflow = 'hidden';
|
|
102
|
+
svg.style.pointerEvents = 'none';
|
|
103
|
+
const text = document.createElementNS(svgNS, 'text');
|
|
104
|
+
svg.appendChild(text);
|
|
105
|
+
(document.body || document.documentElement).appendChild(svg);
|
|
106
|
+
hiddenMeasurementTextNode = text;
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* CHART-11: measures a tick label's real rendered width via a transient, invisible, shared SVG
|
|
112
|
+
* `<text>` node -- the same element type MUI's own axis ticks render as, so glyph metrics match
|
|
113
|
+
* whatever font actually cascades to it (this package has no access to the consumer's theme font
|
|
114
|
+
* at this call site; the transient node inherits the page's own cascade, same as a real tick
|
|
115
|
+
* would). Returns `null` (never throws) when no DOM is available (SSR) or when
|
|
116
|
+
* `getComputedTextLength` is unsupported/unreliable there (jsdom does not implement real SVG text
|
|
117
|
+
* layout) -- callers fall back to `estimateTextWidthPx` in that case, and tests inject their own
|
|
118
|
+
* stub via `spaceForRotatedTicks`'s optional `measureTextWidth` parameter for determinism.
|
|
119
|
+
*/
|
|
120
|
+
function measureTickTextWidthPx(text, fontSizePx) {
|
|
121
|
+
try {
|
|
122
|
+
const node = getHiddenMeasurementTextNode();
|
|
123
|
+
if (!node) return null;
|
|
124
|
+
node.setAttribute('font-size', String(fontSizePx));
|
|
125
|
+
node.textContent = text;
|
|
126
|
+
const length = node.getComputedTextLength();
|
|
127
|
+
return Number.isFinite(length) && length > 0 ? length : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
61
133
|
// THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
|
|
62
134
|
// MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
|
|
63
135
|
// of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
|
|
@@ -72,10 +144,11 @@ export const PACKAGE_DEFAULT_MARGIN = {
|
|
|
72
144
|
};
|
|
73
145
|
|
|
74
146
|
/**
|
|
75
|
-
* Applies the package's own margin default, per side -- a caller's own value on any side
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
147
|
+
* Applies the package's own margin default, per side -- a caller's own value on any side always
|
|
148
|
+
* wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
|
|
149
|
+
* therefore keeps that 60 on the left and gets the package defaults on the other three sides,
|
|
150
|
+
* never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
|
|
151
|
+
* rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
|
|
79
152
|
*/
|
|
80
153
|
export function withMarginDefaults(margin) {
|
|
81
154
|
const explicit = typeof margin === 'number'
|
|
@@ -84,18 +157,30 @@ export function withMarginDefaults(margin) {
|
|
|
84
157
|
return { ...PACKAGE_DEFAULT_MARGIN, ...explicit };
|
|
85
158
|
}
|
|
86
159
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Some formatters require MUI's complete scale context. Falling back to
|
|
93
|
-
// the raw value is conservative: it reserves at least as much space.
|
|
94
|
-
return String(value);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
160
|
+
// CHART-11: small clearance between the rotated text block's own projected extent and the
|
|
161
|
+
// bottom of the axis's reserved band -- the same order of magnitude as the package's own
|
|
162
|
+
// PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
|
|
163
|
+
// as clipped even when nothing is actually cut off).
|
|
164
|
+
const ROTATED_TICK_CLEARANCE_PX = 8;
|
|
97
165
|
|
|
98
|
-
|
|
166
|
+
/**
|
|
167
|
+
* CHART-11: `measureTextWidth` is injectable (defaults to the real `measureTickTextWidthPx`) so
|
|
168
|
+
* tests can stub it for a deterministic result instead of depending on jsdom's SVG text layout
|
|
169
|
+
* (unimplemented) or a real browser's font rendering.
|
|
170
|
+
*
|
|
171
|
+
* CHART-11 also corrects how the projection combines with `MUI_LABELLED_X_AXIS_HEIGHT`. The old
|
|
172
|
+
* formula (`extraHeight = projectedHeight - fontSize`, added unconditionally on top of the full
|
|
173
|
+
* 45px baseline) double-counts: the baseline already budgets tick+gap+one-line-of-text for the
|
|
174
|
+
* NORMAL case, and adding the rotated projection's own full height on top of it, minus only a
|
|
175
|
+
* bare `fontSize`, keeps far more of that baseline than a rotated label actually needs from it.
|
|
176
|
+
* Measured live against hram real data: with an accurate measured `textWidth`, the OLD combine
|
|
177
|
+
* step alone still left ~38px unused (axis.height 118 vs a real rendered tick-group height of
|
|
178
|
+
* ~79px) -- the width term was not the only source of over-reservation. The axis height is now
|
|
179
|
+
* the larger of the un-rotated baseline (a floor -- a barely-rotated label still gets the normal
|
|
180
|
+
* allowance) and the rotated projection itself plus a small clearance -- verified live to close
|
|
181
|
+
* the gap to single digits with no label clipping at 1280px or 375px (register note, CHART-11).
|
|
182
|
+
*/
|
|
183
|
+
function rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth = measureTickTextWidthPx) {
|
|
99
184
|
const angle = Number(axis.tickLabelStyle?.angle) || 0;
|
|
100
185
|
if (angle === 0) return null;
|
|
101
186
|
|
|
@@ -103,42 +188,53 @@ function rotatedTickMetrics(axis, defaultLineHeight) {
|
|
|
103
188
|
if (fontSize === 0) return null;
|
|
104
189
|
|
|
105
190
|
const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
|
|
106
|
-
const longestLabel = (axis.data || []
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
191
|
+
const longestLabel = longestFormattedTick(axis.data || [], axis.valueFormatter);
|
|
192
|
+
const measuredWidth = measureTextWidth(longestLabel, fontSize);
|
|
193
|
+
const textWidth = Math.max(
|
|
194
|
+
fontSize,
|
|
195
|
+
Number.isFinite(measuredWidth) && measuredWidth > 0
|
|
196
|
+
? measuredWidth
|
|
197
|
+
: estimateTextWidthPx(longestLabel, fontSize),
|
|
198
|
+
);
|
|
111
199
|
const radians = (angle * Math.PI) / 180;
|
|
112
200
|
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
113
201
|
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
114
|
-
const
|
|
202
|
+
const neededAxisHeight = Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX;
|
|
203
|
+
// `max(0, …)`, not `max(1, …)`: axis.height (= MUI_LABELLED_X_AXIS_HEIGHT + extraHeight) must be
|
|
204
|
+
// able to land exactly ON the baseline (45) when a barely-rotated label needs less than it --
|
|
205
|
+
// a leftover `max(1, …)` here would silently make 46 the true floor while every doc comment and
|
|
206
|
+
// test says 45 (caught in review: harmless in direction, but the two must actually agree).
|
|
207
|
+
const extraHeight = Math.max(0, neededAxisHeight - MUI_LABELLED_X_AXIS_HEIGHT);
|
|
115
208
|
|
|
116
209
|
return { extraHeight };
|
|
117
210
|
}
|
|
118
211
|
|
|
119
212
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* only separates
|
|
123
|
-
*
|
|
213
|
+
* CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
|
|
214
|
+
* uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
|
|
215
|
+
* `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
|
|
216
|
+
* label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
|
|
217
|
+
* allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
|
|
218
|
+
* `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
|
|
219
|
+
* to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
|
|
220
|
+
* `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
|
|
221
|
+
* downstream applies the package's ordinary bottom default in every case, rotated or not -- a
|
|
222
|
+
* caller-set `margin.bottom` was never touched by this function even before this fix, and still
|
|
223
|
+
* isn't.
|
|
224
|
+
*
|
|
225
|
+
* CHART-11: `measureTextWidth` is an optional injection point (defaults to a real DOM
|
|
226
|
+
* measurement) that only exists for deterministic tests -- neither preset caller passes it, so
|
|
227
|
+
* neither needs to change.
|
|
124
228
|
*/
|
|
125
|
-
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
126
|
-
let largestExtraHeight = 0;
|
|
229
|
+
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1, measureTextWidth) {
|
|
127
230
|
const spacedXAxis = xAxis.map((axis) => {
|
|
128
|
-
const metrics = rotatedTickMetrics(axis, defaultLineHeight);
|
|
231
|
+
const metrics = rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth);
|
|
129
232
|
if (!metrics) return axis;
|
|
130
|
-
|
|
131
|
-
largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
|
|
132
233
|
if (axis.height != null) return axis;
|
|
133
234
|
return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
|
|
134
235
|
});
|
|
135
236
|
|
|
136
|
-
|
|
137
|
-
const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
|
|
138
|
-
? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
|
|
139
|
-
: margin;
|
|
140
|
-
|
|
141
|
-
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
237
|
+
return { xAxis: spacedXAxis, margin };
|
|
142
238
|
}
|
|
143
239
|
|
|
144
240
|
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
PACKAGE_DEFAULT_MARGIN,
|
|
20
20
|
resolveChartHeight,
|
|
21
21
|
sizeYAxisForContent,
|
|
22
|
+
spaceForRotatedTicks,
|
|
22
23
|
warnOnHeightMismatch,
|
|
23
24
|
withAxisDefaults,
|
|
24
25
|
withMarginDefaults,
|
|
@@ -162,10 +163,12 @@ describe('chart chrome defaults', () => {
|
|
|
162
163
|
expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
|
|
163
164
|
});
|
|
164
165
|
|
|
165
|
-
it('
|
|
166
|
-
|
|
166
|
+
it('CHART-10: grows only xAxis.height for rotated tick labels, never margin.bottom on top of it', () => {
|
|
167
|
+
// CHART-11: a long label -- short ones like "Jan"/"Feb" now correctly need no extra height
|
|
168
|
+
// at all once the estimate stopped over-reserving (asserted separately below).
|
|
169
|
+
renderBar({ xAxis: [{ data: ['A label long enough to need real rotation room'], tickLabelStyle: { angle: -45 } }] });
|
|
167
170
|
const rotatedProps = barSpy.mock.calls.at(-1)[0];
|
|
168
|
-
expect(rotatedProps.margin.bottom).
|
|
171
|
+
expect(rotatedProps.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
169
172
|
expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
|
|
170
173
|
|
|
171
174
|
cleanup();
|
|
@@ -181,13 +184,14 @@ describe('chart chrome defaults', () => {
|
|
|
181
184
|
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
182
185
|
});
|
|
183
186
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
it('
|
|
187
|
+
// CHART-10: the axis band, not the margin, carries the rotated-tick allowance -- a
|
|
188
|
+
// rotated-tick chart gets the SAME flat package bottom margin as any other chart. Named
|
|
189
|
+
// "still gives" in CHART-8/9 history; the correct behaviour is now the opposite of that name.
|
|
190
|
+
it('gives a rotated-tick chart the same flat package bottom margin as an unrotated one', () => {
|
|
188
191
|
renderBar({ xAxis: [{ data: ['January', 'February'], tickLabelStyle: { angle: -90 } }] });
|
|
189
192
|
const props = barSpy.mock.calls.at(-1)[0];
|
|
190
|
-
expect(props.margin.bottom).
|
|
193
|
+
expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
194
|
+
expect(props.xAxis[0].height).toBeGreaterThan(45);
|
|
191
195
|
});
|
|
192
196
|
|
|
193
197
|
// Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
|
|
@@ -436,6 +440,125 @@ describe('withMarginDefaults (THEME-11)', () => {
|
|
|
436
440
|
});
|
|
437
441
|
});
|
|
438
442
|
|
|
443
|
+
describe('spaceForRotatedTicks (CHART-10)', () => {
|
|
444
|
+
const rotatedAxis = [{ data: ['January', 'February'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
445
|
+
|
|
446
|
+
it('reserves the allowance once, on axis.height, and leaves margin untouched', () => {
|
|
447
|
+
const result = spaceForRotatedTicks(rotatedAxis, undefined);
|
|
448
|
+
expect(result.xAxis[0].height).toBeGreaterThan(45);
|
|
449
|
+
expect(result.margin).toBeUndefined();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('is byte-identical at angle 0 -- each axis entry and the margin pass through untouched', () => {
|
|
453
|
+
const flatAxis = [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }];
|
|
454
|
+
const margin = { top: 1 };
|
|
455
|
+
const result = spaceForRotatedTicks(flatAxis, margin);
|
|
456
|
+
expect(result.xAxis[0]).toBe(flatAxis[0]);
|
|
457
|
+
expect(result.margin).toBe(margin);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('preserves a caller-set margin.bottom untouched -- the allowance is never added on top of it', () => {
|
|
461
|
+
const margin = { bottom: 50 };
|
|
462
|
+
const result = spaceForRotatedTicks(rotatedAxis, margin);
|
|
463
|
+
expect(result.margin).toBe(margin);
|
|
464
|
+
expect(result.margin.bottom).toBe(50);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('drives the height estimate from the longest formatted tick, not a flat constant', () => {
|
|
468
|
+
const shortLabels = [{ data: ['A', 'B'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
469
|
+
const longLabels = [{
|
|
470
|
+
data: ['A much longer ward name than the others', 'B'],
|
|
471
|
+
tickLabelStyle: { angle: -45, fontSize: 12 },
|
|
472
|
+
}];
|
|
473
|
+
const shortResult = spaceForRotatedTicks(shortLabels, undefined);
|
|
474
|
+
const longResult = spaceForRotatedTicks(longLabels, undefined);
|
|
475
|
+
expect(longResult.xAxis[0].height).toBeGreaterThan(shortResult.xAxis[0].height);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('does not override an axis that already sets its own height', () => {
|
|
479
|
+
const axis = [{ data: ['January'], tickLabelStyle: { angle: -45 }, height: 200 }];
|
|
480
|
+
const result = spaceForRotatedTicks(axis, undefined);
|
|
481
|
+
expect(result.xAxis[0].height).toBe(200);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// CHART-11 -- the rotated-tick reservation now derives from a MEASURED (or injected-stub, for
|
|
486
|
+
// determinism) text width instead of a flat character-count multiplier, which systematically
|
|
487
|
+
// overestimated real ward-name-shaped strings. jsdom has no SVG text layout
|
|
488
|
+
// (`getComputedTextLength` is not implemented there), so these tests inject `measureTextWidth`
|
|
489
|
+
// (spaceForRotatedTicks's 4th, optional parameter) rather than depending on a real measurement.
|
|
490
|
+
describe('spaceForRotatedTicks measured width (CHART-11)', () => {
|
|
491
|
+
it('derives extraHeight from the injected measurement, not a character-count guess', () => {
|
|
492
|
+
const measurer = vi.fn(() => 100);
|
|
493
|
+
const axis = [{ data: ['x'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
494
|
+
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
495
|
+
|
|
496
|
+
// Same projection formula as `rotatedTickMetrics`, worked by hand for a deterministic
|
|
497
|
+
// expectation -- a single-character label would give almost no height under the old
|
|
498
|
+
// character-count formula, but the stub reports 100px measured, so the result must reflect
|
|
499
|
+
// that, not the string's length. Axis height is the larger of the un-rotated baseline (45)
|
|
500
|
+
// and the rotated projection plus the small clearance (CHART-11).
|
|
501
|
+
const radians = (-45 * Math.PI) / 180;
|
|
502
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * 12 * 1 + Math.abs(Math.sin(radians)) * 100;
|
|
503
|
+
const neededAxisHeight = Math.ceil(projectedHeight) + 8;
|
|
504
|
+
expect(result.xAxis[0].height).toBe(Math.max(45, neededAxisHeight));
|
|
505
|
+
expect(measurer).toHaveBeenCalledWith('x', 12);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('measures the formatted (already-truncated) tick text, not a hypothetical fuller string', () => {
|
|
509
|
+
const measurer = vi.fn((text) => text.length * 10);
|
|
510
|
+
const axis = [{
|
|
511
|
+
data: ['A Very Long Ward Name That MUI Would Truncate'],
|
|
512
|
+
valueFormatter: () => "Ching'a…", // simulates MUI's own rendered, truncated tick text
|
|
513
|
+
tickLabelStyle: { angle: -45, fontSize: 12 },
|
|
514
|
+
}];
|
|
515
|
+
spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
516
|
+
expect(measurer).toHaveBeenCalledWith("Ching'a…", 12);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('falls back to a per-character estimate, tighter than the pre-CHART-11 flat multiplier, when no measurer is available', () => {
|
|
520
|
+
const label = 'Msolwa Station'; // spaces + mixed case, representative of a real ward name
|
|
521
|
+
const nullMeasurer = () => null;
|
|
522
|
+
const axis = [{ data: [label], tickLabelStyle: { angle: -45, fontSize: 13 } }];
|
|
523
|
+
const result = spaceForRotatedTicks(axis, undefined, 1, nullMeasurer);
|
|
524
|
+
|
|
525
|
+
// Reconstruct the height the retired flat-multiplier formula (AVERAGE_GLYPH_WIDTH_EM = 0.6)
|
|
526
|
+
// would have produced for the same label, as the comparison baseline this WO exists to beat.
|
|
527
|
+
const oldFlatTextWidth = label.length * 13 * 0.6;
|
|
528
|
+
const radians = (-45 * Math.PI) / 180;
|
|
529
|
+
const oldProjected = Math.abs(Math.cos(radians)) * 13 * 1 + Math.abs(Math.sin(radians)) * oldFlatTextWidth;
|
|
530
|
+
const oldHeight = 45 + Math.max(1, Math.ceil(oldProjected - 13));
|
|
531
|
+
expect(result.xAxis[0].height).toBeLessThan(oldHeight);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('floors exactly at the un-rotated baseline (45), not one above it, when a short label needs no extra room', () => {
|
|
535
|
+
const measurer = () => 5; // a tiny measured width -- shorter than the clearance alone needs
|
|
536
|
+
const axis = [{ data: ['x'], tickLabelStyle: { angle: -45, fontSize: 8 } }];
|
|
537
|
+
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
538
|
+
expect(result.xAxis[0].height).toBe(45);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Review finding (ui_reviewer): only -45deg was live-verified against hram (its one real -90deg
|
|
542
|
+
// consumer, VillageMetricsPanel.jsx, is not wired into the app -- unreachable, so it could not be
|
|
543
|
+
// live-checked). At -90deg, cos(angle) is ~0, so the lineHeight term drops out almost entirely and
|
|
544
|
+
// the projection is driven by textWidth alone -- a materially different shape from -45deg, worth
|
|
545
|
+
// its own deterministic test rather than trusting the same formula by extrapolation.
|
|
546
|
+
it('generalizes to -90deg, where the line-height term vanishes and textWidth alone drives the projection', () => {
|
|
547
|
+
const measurer = () => 130; // a long village name, matching RatioMetricsPanel's own
|
|
548
|
+
// "-90 for village (many labels)" case (fontSize 11 there)
|
|
549
|
+
const axis = [{ data: ['A Very Long Village Name'], tickLabelStyle: { angle: -90, fontSize: 11 } }];
|
|
550
|
+
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
551
|
+
|
|
552
|
+
const radians = (-90 * Math.PI) / 180;
|
|
553
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * 11 * 1 + Math.abs(Math.sin(radians)) * 130;
|
|
554
|
+
const expectedHeight = Math.max(45, Math.ceil(projectedHeight) + 8);
|
|
555
|
+
expect(result.xAxis[0].height).toBe(expectedHeight);
|
|
556
|
+
// Confirms the line-height term is genuinely near-zero at -90deg -- height tracks textWidth
|
|
557
|
+
// alone within rounding, not some residual line-height contribution.
|
|
558
|
+
expect(result.xAxis[0].height).toBe(Math.max(45, 130 + 8));
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
439
562
|
// CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
|
|
440
563
|
// chart component wiring (component-level wiring is covered per-preset in each own test file).
|
|
441
564
|
describe('resolveChartHeight (CHART-8)', () => {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# UCM-CHART-10 — The rotated-tick allowance is reserved twice, and the surplus is dead space
|
|
2
|
+
|
|
3
|
+
- **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main`, no staging step)
|
|
4
|
+
- **Tier:** 3 — shared-core; every consumer's chart proportions change.
|
|
5
|
+
- **Status:** planned
|
|
6
|
+
- **Workstream:** `CHART-*`
|
|
7
|
+
- **Files:** `src/components/charts/chartDefaults.js` (`spaceForRotatedTicks`, `rotatedTickMetrics`)
|
|
8
|
+
and its tests. The presets only *call* the helper — do not change them.
|
|
9
|
+
|
|
10
|
+
> **Why — measured in hram, 2026-08-22, with ucm 2.42.1 loaded and real data.** `spaceForRotatedTicks`
|
|
11
|
+
> adds the same `extraHeight` in **two** places:
|
|
12
|
+
>
|
|
13
|
+
> ```js
|
|
14
|
+
> return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight }; // (1) the axis band
|
|
15
|
+
> ...
|
|
16
|
+
> ? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight } // (2) the chart margin
|
|
17
|
+
> ```
|
|
18
|
+
>
|
|
19
|
+
> **In MUI v8 the axis `height` and the chart `margin.bottom` are additive.** The allowance for
|
|
20
|
+
> rotated tick labels is therefore counted twice, the axis band already holds the labels, and the
|
|
21
|
+
> margin copy is left over as an empty band below the plot.
|
|
22
|
+
>
|
|
23
|
+
> Measured on the Research → Allocation drill-down at 1280 px:
|
|
24
|
+
>
|
|
25
|
+
> | card | surface height | plot ends at | empty below the axis | x tick labels |
|
|
26
|
+
> |---|---|---|---|---|
|
|
27
|
+
> | Ward Metrics | 340 | 131 | **130 px (38 %)** | 35 |
|
|
28
|
+
> | Access Ladder | 340 | 131 | **130 px (38 %)** | 35 |
|
|
29
|
+
> | Cost | 340 | 194 | 92 px | 35 |
|
|
30
|
+
> | Division Metrics | 340 | 199 | 85 px | 5 |
|
|
31
|
+
> | Cost-effectiveness | 420 | 331 | 44 px | 22 |
|
|
32
|
+
> | Expert Mode: Simulation Trajectory | 340 | 287 | 8 px | 31 |
|
|
33
|
+
>
|
|
34
|
+
> Two things make this conclusive rather than suggestive. First, the empty band **scales with the
|
|
35
|
+
> label load** — 35 long rotated ward names cost 130 px, short labels cost 8 px. Second,
|
|
36
|
+
> `emptyBelowLowestLabel` equals `emptyBelowAxisLine` on **every** chart measured: the tick labels sit
|
|
37
|
+
> inside the axis band, and the space below them is occupied by nothing at all.
|
|
38
|
+
>
|
|
39
|
+
> **This is the same failure class as hram's tornado** (`RES-28`), where `margin.left: 220` was
|
|
40
|
+
> reserved *in addition to* an unsized `yAxis`, and as `UCM-CHART-8`'s `minHeight`/`height` pair:
|
|
41
|
+
> **space reserved in two places that add up.** Third instance in this file. Neither `UCM-CHART-8`
|
|
42
|
+
> nor `UCM-CHART-9` could have caught it — both worked on the wrapper box; this one is in the axis
|
|
43
|
+
> margin arithmetic.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Part A — Envelope (authoritative WHAT/WHY)
|
|
48
|
+
|
|
49
|
+
### Goal
|
|
50
|
+
|
|
51
|
+
The rotated-tick allowance is reserved **once**. A chart with long rotated x labels uses its surface
|
|
52
|
+
for the plot, not for an empty band under the axis.
|
|
53
|
+
|
|
54
|
+
### Definition of Done
|
|
55
|
+
|
|
56
|
+
- [x] **`extraHeight` is applied once, not twice.** Kept on `axis.height` only; `margin.bottom`'s
|
|
57
|
+
addition removed. MUI needs `axis.height` — the live measurement below shows rendered labels
|
|
58
|
+
sit fully inside the axis band with nothing below them, i.e. the band already fully
|
|
59
|
+
accommodates the rotation; `margin.bottom`'s copy was reserving space nothing used.
|
|
60
|
+
- [x] **The plot area grows by the reclaimed amount.** Measured live on hram (`CLI-Kilombero_2026-per_cell-network`,
|
|
61
|
+
real data, 1280 px), same three cards, same candidate, before vs. after swapping in the fixed
|
|
62
|
+
build: Ward Metrics (35 labels, 340 px) 129.6 px → 51.6 px empty; Access Ladder (35 labels,
|
|
63
|
+
369 px) 156.9 px → 80.6 px; Division Metrics (5 labels, 340 px) 85.2 px → 41.2 px — each
|
|
64
|
+
roughly halved, matching "remove exactly one of two equal additive reservations." The residual
|
|
65
|
+
is the legitimate rotation-geometry padding this WO's non-goals say not to chase.
|
|
66
|
+
- [x] **Tick labels are not clipped.** Checked at both 1280 px and 375 px on the same three live
|
|
67
|
+
cards (lowest label's rendered bottom never exceeded the SVG's own bottom edge at either
|
|
68
|
+
width) — no clipping introduced by reserving less.
|
|
69
|
+
- [x] **Charts with no rotation are byte-identical.** Unit-tested directly against
|
|
70
|
+
`spaceForRotatedTicks` (angle-0 axis entry and margin object both pass through by reference,
|
|
71
|
+
not just by value) — see `tests/chartDefaults.test.js`, `spaceForRotatedTicks (CHART-10)`.
|
|
72
|
+
- [x] **A caller-set `margin.bottom` still wins.** Implemented as an unconditional guarantee rather
|
|
73
|
+
than the named `callerSetBottom` escape hatch: `spaceForRotatedTicks` no longer writes to
|
|
74
|
+
`margin` in any branch, so a caller-set value is never at risk regardless of rotation — a
|
|
75
|
+
strict superset of the old conditional guard, confirmed by both independent reviewers as a
|
|
76
|
+
clean simplification, not a behaviour gap. The named variable/constant it used
|
|
77
|
+
(`callerSetBottom`, `MUI_CHART_MARGIN_BOTTOM`) were removed as dead code once nothing reads
|
|
78
|
+
them.
|
|
79
|
+
|
|
80
|
+
### Investigate in the same pass — likely the same helper
|
|
81
|
+
|
|
82
|
+
hram reports **x-axis tick marks rendering without their labels** on scatter charts
|
|
83
|
+
(`AccessGapScatterPanel`, the optimization cost-effectiveness scatter) — `HRAM-RES-29` item 2, still
|
|
84
|
+
open. A formatter exists on both axes (`ScatterChart.jsx:193,199`), so it is not a missing formatter.
|
|
85
|
+
**The suspicion is that it is this same function in the other direction:** `axis.height` set to a
|
|
86
|
+
band the labels do not fit, so MUI draws the ticks and clips the text.
|
|
87
|
+
|
|
88
|
+
- [x] **Refuted.** `ScatterChart.jsx` never calls `spaceForRotatedTicks` at all (grep-confirmed:
|
|
89
|
+
only `BarChart.jsx`/`LineChart.jsx` import it) — its x-axis `height` is never touched by this
|
|
90
|
+
helper in either direction, so this function cannot be the cause. Confirmed further at the two
|
|
91
|
+
named hram call sites (`AccessGapScatterPanel.jsx:203`, `OptimizationResultsPanel.jsx:1014`):
|
|
92
|
+
neither sets `tickLabelStyle.angle` on its `xAxis`, so `rotatedTickMetrics` would return `null`
|
|
93
|
+
even if the helper were in the path. This matches hram's own `HRAM-RES-29` register row
|
|
94
|
+
(`WORK_ORDERS.md`), which had already independently traced and closed item 2 the same day
|
|
95
|
+
(2026-08-22, before this WO's own "still open" line was written): the real cause was
|
|
96
|
+
`ScatterChart.jsx` rendering `<MuiScatterChart>` with no explicit `height` pre-`11d362c`
|
|
97
|
+
(`UCM-CHART-8`, `2.41.3`), racing MUI's own collision-avoidance against the wrapper's
|
|
98
|
+
`aspect-ratio` CSS and clipping tick text with no missing formatter involved. `2.42.0` already
|
|
99
|
+
fixes it for every consumer by passing a resolved `height` explicitly; hram's `develop` already
|
|
100
|
+
pinned `2.42.0` (why local measurement looked clean) and only `main`/`hram.ch` (still on
|
|
101
|
+
`2.41.3` at the time) showed it — closing on hram's next `develop -> main` promotion. No action
|
|
102
|
+
needed here or in `HRAM-RES-29`; this WO's own suspicion is retired, not carried forward.
|
|
103
|
+
|
|
104
|
+
### Non-goals
|
|
105
|
+
|
|
106
|
+
- Do not change the four presets or `ChartFrame` — they only call this helper.
|
|
107
|
+
- Do not revisit `UCM-CHART-8`'s `minHeight`/`height`/`aspect` resolution or `UCM-CHART-9`'s frame
|
|
108
|
+
fix. Both are correct and verified; this is a third, independent reservation.
|
|
109
|
+
- Do not "fix" this by shrinking `MUI_LABELLED_X_AXIS_HEIGHT` or `MUI_CHART_MARGIN_BOTTOM` — those
|
|
110
|
+
constants are not the bug, the double application is.
|
|
111
|
+
|
|
112
|
+
### Risks
|
|
113
|
+
|
|
114
|
+
- **Every consumer's chart proportions change**, and in the visible direction: plots get taller. That
|
|
115
|
+
is the point, but it is a shared-core publish and the rendered check below is what proves it.
|
|
116
|
+
- The clipping failure mode is the mirror of this one. A fix that reserves too little trades a
|
|
117
|
+
cosmetic defect for an information-losing one — clipped labels are worse than empty space.
|
|
118
|
+
- hram's `RES-28` fixes the same class in hram's own tornado (`margin.left` vs `yAxis.width`). The
|
|
119
|
+
two are independent; do not assume one closes the other.
|
|
120
|
+
|
|
121
|
+
### Tests to WRITE — narrow
|
|
122
|
+
|
|
123
|
+
- `spaceForRotatedTicks` with a rotated axis: the total reserved space equals the allowance **once**,
|
|
124
|
+
asserted against the computed `axis.height` + `margin.bottom` sum — the assertion that fails today.
|
|
125
|
+
- Angle 0: output identical to input, axis and margin untouched.
|
|
126
|
+
- A caller-set `margin.bottom`: preserved, allowance not added on top.
|
|
127
|
+
- The longest-label path (`longestFormattedTick`) still drives the estimate.
|
|
128
|
+
|
|
129
|
+
**Rendered check before publish — numeric, on hram with real data.** The same six cards measured
|
|
130
|
+
above, at 1280 px and 375 px: the empty band below the axis, per card, before and after. Named in
|
|
131
|
+
the register note. If capture is unavailable, DOM measurement is acceptable **and must be declared
|
|
132
|
+
as a substitution** — for this WO that is nearly sufficient anyway, since the acceptance criterion
|
|
133
|
+
is a measured pixel count rather than an impression.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Part B — Implementation map
|
|
138
|
+
|
|
139
|
+
> **TO BE FILLED BY THE ORCHESTRATOR** on `git pull`. Must carry `spaceForRotatedTicks` and
|
|
140
|
+
> `rotatedTickMetrics` with line anchors, the two constants, the callers, the absolute working
|
|
141
|
+
> directory, the progress contract, and the preamble. **Do not dispatch while this placeholder
|
|
142
|
+
> stands.**
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Part C — Orchestrator-only
|
|
147
|
+
|
|
148
|
+
> **TO BE FILLED BY THE ORCHESTRATOR.** Review routing (`reviewer` + `ui_reviewer`, full context —
|
|
149
|
+
> Tier 3 shared-core), the numeric rendered check as a hard commit gate, register maintenance,
|
|
150
|
+
> registry publish verification, commit, and the execution directive with its self-address guard.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# UCM-CHART-11 — The rotated-tick allowance is now counted once, but estimated too high
|
|
2
|
+
|
|
3
|
+
- **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main`, no staging step)
|
|
4
|
+
- **Tier:** 3 — shared-core; every consumer's chart proportions change.
|
|
5
|
+
- **Status:** planned
|
|
6
|
+
- **Workstream:** `CHART-*`
|
|
7
|
+
- **Files:** `src/components/charts/chartDefaults.js` — `rotatedTickMetrics`, `longestFormattedTick`,
|
|
8
|
+
`AVERAGE_GLYPH_WIDTH_EM` — and its tests. The presets only call the helper; leave them alone.
|
|
9
|
+
|
|
10
|
+
> **Why — measured in hram on 2026-08-22, ucm 2.42.2 installed via the pin, Teaching → Basic Plots,
|
|
11
|
+
> real data, 1280 px.** `UCM-CHART-10` removed the double reservation and that holds. What is left is
|
|
12
|
+
> a **single reservation that is too large**:
|
|
13
|
+
>
|
|
14
|
+
> | card | longest x label | `axis.height` reserves | rendered tick band | unused | axis title? |
|
|
15
|
+
> |---|---|---|---|---|---|
|
|
16
|
+
> | Ward Metrics | `Msolwa Station` (14 ch, full) | 131 px | **79 px** | **52 px** | no |
|
|
17
|
+
> | Access Ladder | `Msolwa Station` (14 ch, full) | 131 px | **79 px** | **52 px** | no |
|
|
18
|
+
> | Division Metrics | `mang-ula` (8 ch) | 97 px | **56 px** | **41 px** | no |
|
|
19
|
+
> | Cost | `Ching'a…` (**MUI-truncated**) | 68 px | **53 px** | 15 px | no |
|
|
20
|
+
>
|
|
21
|
+
> All four are `-45°`, all four are the same preset. **The unused band scales with the estimate, not
|
|
22
|
+
> with the rendered text.** `rotatedTickMetrics` derives `extraHeight` from
|
|
23
|
+
> `longestFormattedTick × AVERAGE_GLYPH_WIDTH_EM (0.6) × sin(angle)` — a character-count estimate of
|
|
24
|
+
> how wide the longest label *would* be. The rendered text is consistently shorter than that estimate,
|
|
25
|
+
> and the surplus becomes an empty band inside the axis.
|
|
26
|
+
>
|
|
27
|
+
> **Cost is not the well-behaved case — it is the case where the estimate happens to be small.** MUI
|
|
28
|
+
> truncates its labels itself (`Ching'a…`), so the estimator sees a short string. Nothing about Cost
|
|
29
|
+
> is configured better; it just has less to overestimate.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Part A — Envelope (authoritative WHAT/WHY)
|
|
34
|
+
|
|
35
|
+
### Goal
|
|
36
|
+
|
|
37
|
+
The rotated-tick allowance reflects the text that is actually rendered. A chart with long rotated
|
|
38
|
+
labels reserves what those labels occupy — not what a character-count estimate predicts.
|
|
39
|
+
|
|
40
|
+
### Definition of Done
|
|
41
|
+
|
|
42
|
+
- [x] **`extraHeight` is derived from measured text extent, not from character count.** Chose a
|
|
43
|
+
transient, invisible, shared SVG `<text>` node (`measureTickTextWidthPx`), not canvas: it is
|
|
44
|
+
the same element type MUI's own axis ticks render as, so it inherits font metrics via the
|
|
45
|
+
page's own cascade with no extra plumbing. Verified live against hram: a hidden node measuring
|
|
46
|
+
"Msolwa Station" at 13px returned 91.203125, and the REAL rendered tick's own
|
|
47
|
+
`getComputedTextLength()` for the same string was 91.203125 — exact match. `AVERAGE_GLYPH_WIDTH_EM`
|
|
48
|
+
stays (still used by the unrelated `sizeYAxisForContent`, Y-axis width, THEME-9) but is no
|
|
49
|
+
longer read by the rotated-tick path; a new per-character-width table
|
|
50
|
+
(`estimateTextWidthPx`) is the explicitly-declared ESTIMATE fallback for when no DOM is
|
|
51
|
+
available (confirmed live: jsdom's `getComputedTextLength` throws "not a function", so the
|
|
52
|
+
fallback is what the unit tests actually exercise; the real measurement only runs in a browser).
|
|
53
|
+
`measureTextWidth` is also injectable (`spaceForRotatedTicks`'s new optional 4th param) so
|
|
54
|
+
tests stub it for determinism instead of depending on either environment.
|
|
55
|
+
- [x] **MUI's own truncation is accounted for.** The estimate is always taken from
|
|
56
|
+
`longestFormattedTick`'s OUTPUT (i.e. whatever `axis.valueFormatter` produces), never the raw
|
|
57
|
+
`axis.data` value — already true structurally before this WO, confirmed and locked in by a new
|
|
58
|
+
test (`measures the formatted (already-truncated) tick text...`) using a formatter that
|
|
59
|
+
truncates. Cost's own axis sets an explicit `height: 60` (bypasses this helper entirely,
|
|
60
|
+
confirmed by reading `CostMetricsPanel.jsx`), so it was never affected either way.
|
|
61
|
+
- [x] **Numeric acceptance, on the same four live cards**, Teaching → Basic Plots, 1280 px, real data:
|
|
62
|
+
Ward Metrics 129.6 px → **14.6 px**; Access Ladder 156.9 px → **14.6 px**; Division Metrics
|
|
63
|
+
85.2 px → **14.2 px**; Cost 92.3 px → **14.6 px** (unchanged, as expected — its axis.height is
|
|
64
|
+
caller-fixed). All four land under the ~15 px target. Getting there needed correcting not just
|
|
65
|
+
the width term but how it combines with the `MUI_LABELLED_X_AXIS_HEIGHT` baseline — see the
|
|
66
|
+
code comment on `ROTATED_TICK_CLEARANCE_PX` for why an accurate width alone left ~38 px
|
|
67
|
+
unused, verified by direct DOM inspection before adding the second half of the fix.
|
|
68
|
+
- [x] **Nothing is clipped, at any width.** Checked at 1280 px and 375 px on all four cards: 0 of the
|
|
69
|
+
35 (Ward Metrics/Access Ladder/Cost) or 5 (Division Metrics) x-axis tick labels exceeded their
|
|
70
|
+
SVG's bottom edge at either width.
|
|
71
|
+
- [x] **Angle 0 stays byte-identical.** `UCM-CHART-10`'s own byte-identical-by-reference test for
|
|
72
|
+
this is unchanged and still green (137/137 total, no rewrite needed).
|
|
73
|
+
|
|
74
|
+
### One trap, and I walked into it myself
|
|
75
|
+
|
|
76
|
+
**An axis *title* also sits below the lowest tick label.** In the same measurement pass, `Expert
|
|
77
|
+
Mode: Simulation Trajectory` showed 53 px below its lowest tick — and every pixel of it is the
|
|
78
|
+
x-axis title (`hasAxisTitle: true`, angle 0). I first read that as waste. It is not.
|
|
79
|
+
|
|
80
|
+
The four cards in the table above all have **`hasAxisTitle: false`**, which is why their residual is
|
|
81
|
+
genuinely unused. **Any measurement used to validate this fix must separate the axis title from the
|
|
82
|
+
empty band**, or a "fix" will reclaim space the title needs and clip it.
|
|
83
|
+
|
|
84
|
+
### Non-goals
|
|
85
|
+
|
|
86
|
+
- Do **not** revert `UCM-CHART-10`. Its single-reservation change is correct and verified; this is
|
|
87
|
+
the size of that single reservation, not whether there are two.
|
|
88
|
+
- Do not touch `ScatterChart` — it never calls this helper (established under `UCM-CHART-10`).
|
|
89
|
+
- Do not shrink `MUI_LABELLED_X_AXIS_HEIGHT`. That constant is the un-rotated baseline and is not
|
|
90
|
+
where the error is.
|
|
91
|
+
- Do not "fix" this by forcing truncation on every consumer. Ward names rendering in full is correct
|
|
92
|
+
behaviour; the bug is that the reservation does not match them.
|
|
93
|
+
|
|
94
|
+
### Risks
|
|
95
|
+
|
|
96
|
+
- **Every consumer's chart proportions change again**, the third time in this series. Plots get
|
|
97
|
+
taller. That is the intent, and the numeric acceptance above is what proves it landed.
|
|
98
|
+
- Text measurement is environment-sensitive: a canvas or hidden-node measurement must run with the
|
|
99
|
+
same font that will render, or it trades one estimate for another. The font is loaded from the
|
|
100
|
+
consumer's theme, not from this package — check what is available at call time.
|
|
101
|
+
- If measurement is not reliably available at that point in the render, an *improved* estimate
|
|
102
|
+
(per-character widths rather than a flat 0.6 em) is an acceptable fallback — but say so explicitly
|
|
103
|
+
rather than leaving it looking like a measurement.
|
|
104
|
+
|
|
105
|
+
### Tests to WRITE — narrow
|
|
106
|
+
|
|
107
|
+
- A rotated axis whose longest label is long: the reserved height matches the measured extent within
|
|
108
|
+
a stated tolerance, asserted against a stubbed measurement so the test is deterministic.
|
|
109
|
+
- A rotated axis whose labels MUI truncates: the reservation follows the **truncated** string.
|
|
110
|
+
- Angle 0: identical to input, by reference — `UCM-CHART-10`'s existing assertion, unchanged.
|
|
111
|
+
- A caller-set `margin.bottom`: still untouched (`UCM-CHART-10`'s guarantee, unchanged).
|
|
112
|
+
|
|
113
|
+
**Rendered check before publish — numeric, on hram with real data**, at 1280 px, the four cards
|
|
114
|
+
above, before and after, with the axis-title separation described in the trap section. Declare a DOM
|
|
115
|
+
measurement as a substitution if capture is unavailable.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Part B — Implementation map
|
|
120
|
+
|
|
121
|
+
> **TO BE FILLED BY THE ORCHESTRATOR** on `git pull`. Must carry `rotatedTickMetrics`,
|
|
122
|
+
> `longestFormattedTick`, `AVERAGE_GLYPH_WIDTH_EM` and `spaceForRotatedTicks` with line anchors, the
|
|
123
|
+
> absolute working directory, the progress contract, and the preamble. **Do not dispatch while this
|
|
124
|
+
> placeholder stands.**
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Part C — Orchestrator-only
|
|
129
|
+
|
|
130
|
+
> **TO BE FILLED BY THE ORCHESTRATOR.** Review routing (`reviewer` + `ui_reviewer`, full context —
|
|
131
|
+
> Tier 3 shared-core), the numeric rendered check as a hard commit gate, register maintenance,
|
|
132
|
+
> registry publish verification, commit, and the execution directive with its self-address guard.
|