@micha.bigler/ui-core-micha 2.41.3 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +1 -0
- package/dist/components/charts/BarChart.js +11 -4
- package/dist/components/charts/ChartFrame.js +12 -3
- package/dist/components/charts/LineChart.js +11 -4
- package/dist/components/charts/ScatterChart.js +11 -4
- package/dist/components/charts/chartDefaults.js +41 -0
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +14 -2
- package/src/components/charts/ChartFrame.jsx +13 -2
- package/src/components/charts/LineChart.jsx +14 -2
- package/src/components/charts/ScatterChart.jsx +14 -2
- package/src/components/charts/chartDefaults.js +43 -0
- package/tests/BarChart.test.jsx +48 -0
- package/tests/ChartFrame.test.jsx +43 -0
- package/tests/LineChart.test.jsx +46 -1
- package/tests/ScatterChart.test.jsx +38 -1
- package/tests/chartDefaults.test.js +73 -0
- package/work-orders/UCM-CHART-8.md +147 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -90,3 +90,4 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
90
90
|
| THEME-9 | Diagramme geben mehr Platz an Achsen-Mobiliar als an Daten | **In einer konsumierenden App (hram) gemessen, nicht geschätzt.** Eine KPI-Karte „Coverage“: Karte 470 × 372 → Chart-Box 436 × **300** (`minHeight:300`, das SVG darin ist 270 hoch → **30 px tot**) → SVG 436 × 270 → **Zeichenfläche 301 × 175**. Die Zeichenfläche ist damit **45 % des SVG und 30 % der Karte**; innerhalb des SVG gehen 135 px Breite und 95 px Höhe verloren. Vom Kartenrand bis zur ersten Tick-Zahl sind es 104 px, davon **60 px blanker Raum zwischen SVG-Rand und Achsentitel**; unter dem Achsentitel liegen 30 px leer im SVG plus 32 px bis zur Karte. **Drei weitere gemessene Befunde:** (1) `withAxisDefaults` endet mit `label: axis.label || label` und setzt damit EIN `yAxisLabel` auf JEDE Achse — in hrams Doppelachsen-Chart tragen beide Y-Achsen dieselbe 43-Zeichen-Zeile, je 65 px eines 206-px-Charts. (2) Der Achsentitel wird IN das Tick-Band gesetzt: auf der Accessibility-Ansicht überlappen **4 von 4** Y-Ticks den Titel, dasselbe passiert mit um 45° gedrehten Ortsnamen über dem Titel „Ward“. (3) Ticks tragen Einheit und Zier-Nachkommastellen (`0.0 min`, `50.0 min`) — dieselbe Einheit steht nochmals doppelt im Titel (`Accessibility (minutes) (min)`); anderswo kürzt MUI die Ticks zu `0.0…`. `BarChart` setzt gar keinen Standard-Formatter, `TimeSeriesChart` schon — also löst es jede App neu. Voller Auftrag [`work-orders/THEME-9.md`](work-orders/THEME-9.md) | 2026-08-14 | done (npm publish; consumer pin bumps still open) | 3fd6097 | Tier 3 (Shared-Core). Codex bereits als `unavailable` für heute vermerkt (`.claude/codex-status.md`, THEME-8) — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **`reviewer` R1 (P1, behoben):** die Serie-zu-Achse-Zuordnung in `sizeYAxisForContent` traf `undefined===undefined` statt MUI's echter Default-Regel (unmarkierte Serie → erste Achse im Array) — machte den Sizer zum stillen No-op genau auf der Primärachse des Doppelachsen-Falls, der diesen WO motiviert hat; Regressionstest ergänzt. **`ui_reviewer` U1 (P1, behoben):** `DESIGN.md` #8a versprach "keine Titel-Pflicht bei kategorialer Achse", aber `BarChart`/`LineChart` erzwangen beide Labels per `throw` — Labels jetzt optional, sonst wäre die Regel für FIX-13 unbrauchbar gewesen. **U3 (P2, behoben):** Sizer nutzte den Theme-Default-Font statt der per Achse gesetzten `tickLabelStyle.fontSize`. **U5 (P3, behoben):** Label-Dicke war an `theme.typography.body1` gekoppelt, obwohl MUI intern hart 14px setzt — jetzt eigene benannte Konstante. **U2 (P2, akzeptierter Rest):** Schätzung kennt MUI's "nice-tick"-Rundung nicht exakt; bestehender 1.1×-Sicherheitsfaktor federt kleine Abweichungen ab, der gerenderte Check ist der eigentliche Beweis. **U4:** Behauptung, `TimeSeriesChart` umgehe `withAxisDefaults`/`sizeYAxisForContent`, am Quellcode widerlegt (rendert über die eigene `<BarChart>`-Komponente). Affected-Set grün (60 Tests). **Live-Nachmessung in hram nicht möglich** — Browser-Pane liefert nur für den vom Operator angezeigten Tab reale Viewport-Masse (neue Tabs 0×0, negative-Width-SVG-Fehler als direkte Folge, kein Codefehler); die "vorher"-Zahlen der Coverage-Karte wurden LIVE aus dem korrekt angezeigten hram-Tab gemessen (siehe WO) und bestätigten exakt die im Envelope genannten 60px/104px — zurückgeführt auf hrams eigenes `margin.left=60`, das sich ADDITIV mit MUIs Achsenbreiten-Reservierung addiert (kein ucm-Fehler). Die "nachher"-Messung folgt mit FIX-13 nach dem Pin-Bump (ohnehin dort vorgesehen). Fünf Posten: Ränder/Achsenreservierung verkleinern · Achsentitel nicht mehr broadcasten (mit Test, genau der Defekt der ausgeliefert wurde) · Titel aus dem Tick-Band halten · Standard-Tick-Formatter · Entscheidungsregel in `DESIGN.md`. **Gemessene Warnung gegen naives `Intl`-Compact:** Deutsch kürzt Tausender GAR NICHT (`12'500` bleibt), Swahili stellt ein Wort voran (`elfu 12.5`, breiter als die Rohzahl) — kompakte Notation allein löst das Breitenproblem also nicht und verschlimmert es in einer Sprache; ein handgeschriebenes `B` wäre auf Deutsch zudem FALSCH (Billion = 10¹², `Intl` sagt korrekt `Mrd.`). **Regel:** kategoriale Achse → kein Titel (Ticks sind Namen); numerische Achse → Titel trägt Grösse und Einheit, ausser die Ticks zeigen die Einheit schon (`%`); Einheit einmal im Titel, nie in jedem Tick. **Ränder kürzen und Überlappung beheben ziehen gegeneinander** — zusammen machen und messen, sonst erzeugt das eine das andere neu. Nicht am Publish abschliessen: App-Pins nachziehen, hram zuerst (dortiger Nachzug = FIX-13). |
|
|
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
|
+
| 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. |
|
|
@@ -10,13 +10,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
|
-
import React from 'react';
|
|
13
|
+
import React, { useEffect } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
18
18
|
import { useNeutralChartPalette } from './palette';
|
|
19
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, spaceForRotatedTicks, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
20
20
|
/**
|
|
21
21
|
* Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
|
|
22
22
|
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
@@ -26,15 +26,22 @@ import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForConte
|
|
|
26
26
|
* the panel heading already names the quantity. Omitting one never suppresses
|
|
27
27
|
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
28
28
|
* an axis without its own `label`).
|
|
29
|
+
*
|
|
30
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
31
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
32
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
33
|
+
* if `minHeight` is also passed and larger.
|
|
29
34
|
*/
|
|
30
35
|
export function BarChart(_a) {
|
|
31
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slotProps"]);
|
|
36
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slotProps"]);
|
|
32
37
|
const theme = useTheme();
|
|
33
38
|
const { i18n } = useTranslation();
|
|
34
39
|
const neutralPalette = useNeutralChartPalette();
|
|
40
|
+
useEffect(() => warnOnHeightMismatch('BarChart', { minHeight, height }), [minHeight, height]);
|
|
41
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
35
42
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'band', data: [] }, theme.typography.caption.fontSize);
|
|
36
43
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
37
44
|
const sizedYAxis = sizeYAxisForContent(labelledYAxis, series, theme.typography.caption.fontSize);
|
|
38
45
|
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
39
|
-
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { series: series, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
46
|
+
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { height: chartHeight, series: series, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
40
47
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useId, useRef, useState } from 'react';
|
|
2
|
+
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
3
3
|
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography, } from '@mui/material';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { useTheme } from '@mui/material/styles';
|
|
6
6
|
import { exportChartPng, exportChartSvg } from './exportChart';
|
|
7
|
+
import { resolveChartHeight, warnOnHeightMismatch } from './chartDefaults';
|
|
7
8
|
export const CHART_FRAME_ROOT_SX = { p: 2 };
|
|
8
9
|
export const CHART_FRAME_ALERT_SX = { mt: 2 };
|
|
9
10
|
/**
|
|
10
11
|
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
12
|
+
*
|
|
13
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
14
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the content box itself.
|
|
15
|
+
* Once `height` is set, `height` sizes the box and it never reserves more than that, even if
|
|
16
|
+
* `minHeight` is also passed and larger.
|
|
11
17
|
*/
|
|
12
|
-
export function ChartFrame({ title, subtitle, toolbar, controls, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, aspect, exportOptions = false, onExportSvg, onExportPng, meta, ariaLabel, children, variant = 'outlined', titleVariant = 'h6', sx, }) {
|
|
18
|
+
export function ChartFrame({ title, subtitle, toolbar, controls, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, height, aspect, exportOptions = false, onExportSvg, onExportPng, meta, ariaLabel, children, variant = 'outlined', titleVariant = 'h6', sx, }) {
|
|
13
19
|
const { t } = useTranslation();
|
|
14
20
|
const theme = useTheme();
|
|
15
21
|
const titleId = useId();
|
|
@@ -21,6 +27,8 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
|
|
|
21
27
|
const hasCustomAriaLabel = Boolean(ariaLabel);
|
|
22
28
|
const chartLabel = ariaLabel || title;
|
|
23
29
|
const callerSx = Array.isArray(sx) ? sx : [sx];
|
|
30
|
+
useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }), [minHeight, height]);
|
|
31
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
24
32
|
const runExport = async (type) => {
|
|
25
33
|
setExportError(false);
|
|
26
34
|
try {
|
|
@@ -47,7 +55,8 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
|
|
|
47
55
|
}
|
|
48
56
|
return (_jsxs(Paper, { variant: variant, sx: [CHART_FRAME_ROOT_SX, ...callerSx], children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: titleVariant, children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), controls && _jsx(Box, { sx: { mb: 2 }, children: controls }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
|
|
49
57
|
width: '100%',
|
|
50
|
-
minHeight,
|
|
58
|
+
minHeight: wrapperMinHeight,
|
|
59
|
+
height: chartHeight,
|
|
51
60
|
aspectRatio: aspect,
|
|
52
61
|
display: 'flex',
|
|
53
62
|
alignItems: 'center',
|
|
@@ -10,13 +10,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
|
-
import React from 'react';
|
|
13
|
+
import React, { useEffect } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { LineChart as MuiLineChart, MarkElement as MuiMarkElement, } from '@mui/x-charts/LineChart';
|
|
18
18
|
import { useNeutralChartPalette } from './palette';
|
|
19
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, spaceForRotatedTicks, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
20
20
|
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
21
21
|
// `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
|
|
22
22
|
// override in src/theme/tokens.js as the preferred mechanism, with a
|
|
@@ -40,16 +40,23 @@ function FilledMarkElement(_a) {
|
|
|
40
40
|
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
41
41
|
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
42
42
|
* `label`).
|
|
43
|
+
*
|
|
44
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
45
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
46
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
47
|
+
* if `minHeight` is also passed and larger.
|
|
43
48
|
*/
|
|
44
49
|
export function LineChart(_a) {
|
|
45
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slots, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slots", "slotProps"]);
|
|
50
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slots, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slots", "slotProps"]);
|
|
46
51
|
const theme = useTheme();
|
|
47
52
|
const { i18n } = useTranslation();
|
|
48
53
|
const neutralPalette = useNeutralChartPalette();
|
|
54
|
+
useEffect(() => warnOnHeightMismatch('LineChart', { minHeight, height }), [minHeight, height]);
|
|
55
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
49
56
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'point', data: [] }, theme.typography.caption.fontSize);
|
|
50
57
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
51
58
|
const defaultedSeries = series.map((item) => (Object.assign({ showMark: false, labelMarkType: 'square' }, item)));
|
|
52
59
|
const sizedYAxis = sizeYAxisForContent(labelledYAxis, defaultedSeries, theme.typography.caption.fontSize);
|
|
53
60
|
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
54
|
-
return (_jsx(Box, { "data-testid": "line-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiLineChart, Object.assign({}, chartProps, { series: defaultedSeries, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
61
|
+
return (_jsx(Box, { "data-testid": "line-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiLineChart, Object.assign({}, chartProps, { height: chartHeight, series: defaultedSeries, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
55
62
|
}
|
|
@@ -10,14 +10,14 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
-
import React, { useMemo } from 'react';
|
|
13
|
+
import React, { useEffect, useMemo } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { ScatterChart as MuiScatterChart } from '@mui/x-charts/ScatterChart';
|
|
18
18
|
import { useXScale, useYScale } from '@mui/x-charts/hooks';
|
|
19
19
|
import { useNeutralChartPalette } from './palette';
|
|
20
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
20
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
21
21
|
// Small enough that hram's largest cloud (~300 points, the tested magnitude -- state it, don't
|
|
22
22
|
// imply it scales past that) doesn't fuse into a solid mass at the default (non-bubble) size.
|
|
23
23
|
const DEFAULT_MARKER_RADIUS = 5;
|
|
@@ -118,6 +118,11 @@ export function ScatterReferenceLine({ from, to, color, dashed, strokeWidth = 1.
|
|
|
118
118
|
* Reference geometry (a computed curve, a straight line, either with an optional label) is
|
|
119
119
|
* composed as `children` using `ScatterReferenceCurve`/`ScatterReferenceLine` above.
|
|
120
120
|
*
|
|
121
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
122
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
123
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
124
|
+
* if `minHeight` is also passed and larger.
|
|
125
|
+
*
|
|
121
126
|
* Tested magnitude: a few hundred marks (hram's largest cloud is ~300 points). Not verified,
|
|
122
127
|
* and not implied to hold, at thousands+.
|
|
123
128
|
*
|
|
@@ -132,10 +137,12 @@ export function ScatterReferenceLine({ from, to, color, dashed, strokeWidth = 1.
|
|
|
132
137
|
*/
|
|
133
138
|
export function ScatterChart(_a) {
|
|
134
139
|
var _b;
|
|
135
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, sizeAccessor, getPointStyle, markerSize = DEFAULT_MARKER_RADIUS, slotProps, children } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "sizeAccessor", "getPointStyle", "markerSize", "slotProps", "children"]);
|
|
140
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, sizeAccessor, getPointStyle, markerSize = DEFAULT_MARKER_RADIUS, slotProps, children } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "sizeAccessor", "getPointStyle", "markerSize", "slotProps", "children"]);
|
|
136
141
|
const theme = useTheme();
|
|
137
142
|
const { i18n } = useTranslation();
|
|
138
143
|
const neutralPalette = useNeutralChartPalette();
|
|
144
|
+
useEffect(() => warnOnHeightMismatch('ScatterChart', { minHeight, height }), [minHeight, height]);
|
|
145
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
139
146
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
140
147
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
141
148
|
// Only the Y axis is width-sized (sizeYAxisForContent reserves horizontal space for a
|
|
@@ -174,5 +181,5 @@ export function ScatterChart(_a) {
|
|
|
174
181
|
// would resolve every point's style/radius against the wrong (pre-sort) index.
|
|
175
182
|
const orderedSeriesById = useMemo(() => Object.fromEntries(orderedSeries.map((item) => [item.id, item])), [orderedSeries]);
|
|
176
183
|
const markerSlot = useMemo(() => makeMarkerSlot(orderedSeriesById, { sizeAccessor, chartMax, getPointStyle }), [orderedSeriesById, sizeAccessor, chartMax, getPointStyle]);
|
|
177
|
-
return (_jsx(Box, { "data-testid": "scatter-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiScatterChart, Object.assign({}, chartProps, { series: orderedSeries.map((item) => (Object.assign({ markerSize }, item))), xAxis: labelledXAxis, yAxis: sizedYAxis, colors: resolvedPalette, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: margin, slots: { marker: markerSlot }, slotProps: withChartSlotDefaults(slotProps, legendPosition), children: children })) }));
|
|
184
|
+
return (_jsx(Box, { "data-testid": "scatter-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiScatterChart, Object.assign({}, chartProps, { height: chartHeight, series: orderedSeries.map((item) => (Object.assign({ markerSize }, item))), xAxis: labelledXAxis, yAxis: sizedYAxis, colors: resolvedPalette, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: margin, slots: { marker: markerSlot }, slotProps: withChartSlotDefaults(slotProps, legendPosition), children: children })) }));
|
|
178
185
|
}
|
|
@@ -245,6 +245,47 @@ export function defaultNumericTickFormatter(locale) {
|
|
|
245
245
|
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
246
246
|
: new Intl.NumberFormat(locale).format(value));
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* CHART-8: resolves the `{minHeight, height, aspect}` trio these presets accept into what the
|
|
250
|
+
* wrapper reserves and what height (if any) sizes the chart itself. Three-way, not two-way --
|
|
251
|
+
* `aspect` changes the answer:
|
|
252
|
+
* - `height` set -> it sizes the chart; the wrapper never reserves more than that (a caller-set
|
|
253
|
+
* `minHeight` larger than `height` is capped here, closing the dead-space gap this WO exists
|
|
254
|
+
* to fix -- equal values, or no `minHeight` at all, pass through unchanged).
|
|
255
|
+
* - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
|
|
256
|
+
* derives its height from its width, and `minHeight` stays exactly what its name says, a floor
|
|
257
|
+
* stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
|
|
258
|
+
* that would remove the responsive behaviour four live call sites rely on.
|
|
259
|
+
* - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
|
|
260
|
+
* information to go on, and a caller in this shape meant the chart to have that height, not
|
|
261
|
+
* merely a wrapper floor around an unsized chart.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveChartHeight({ minHeight, height, aspect }) {
|
|
264
|
+
if (height != null) {
|
|
265
|
+
const wrapperMinHeight = minHeight != null && minHeight > height ? height : minHeight;
|
|
266
|
+
return { wrapperMinHeight, chartHeight: height };
|
|
267
|
+
}
|
|
268
|
+
if (aspect != null) {
|
|
269
|
+
return { wrapperMinHeight: minHeight, chartHeight: undefined };
|
|
270
|
+
}
|
|
271
|
+
return { wrapperMinHeight: minHeight, chartHeight: minHeight };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* CHART-8: dev-only warning when a caller passes both `minHeight` and `height` and they disagree
|
|
275
|
+
* -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
|
|
276
|
+
* combination in production would break a consumer's page over a layout nit. Silent on the
|
|
277
|
+
* equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
|
|
278
|
+
*/
|
|
279
|
+
export function warnOnHeightMismatch(componentName, { minHeight, height }) {
|
|
280
|
+
if (process.env.NODE_ENV === 'production')
|
|
281
|
+
return;
|
|
282
|
+
if (minHeight == null || height == null || minHeight === height)
|
|
283
|
+
return;
|
|
284
|
+
// eslint-disable-next-line no-console
|
|
285
|
+
console.warn(`[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
|
|
286
|
+
+ 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
|
|
287
|
+
+ 'chart. Pass only height, or drop minHeight if it should just track height.');
|
|
288
|
+
}
|
|
248
289
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
249
290
|
var _a;
|
|
250
291
|
const position = Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
@@ -7,8 +7,10 @@ import { useNeutralChartPalette } from './palette';
|
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_LEGEND_POSITION,
|
|
9
9
|
defaultNumericTickFormatter,
|
|
10
|
+
resolveChartHeight,
|
|
10
11
|
sizeYAxisForContent,
|
|
11
12
|
spaceForRotatedTicks,
|
|
13
|
+
warnOnHeightMismatch,
|
|
12
14
|
withAxisDefaults,
|
|
13
15
|
withChartSlotDefaults,
|
|
14
16
|
withGridDefaults,
|
|
@@ -24,6 +26,11 @@ import {
|
|
|
24
26
|
* the panel heading already names the quantity. Omitting one never suppresses
|
|
25
27
|
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
26
28
|
* an axis without its own `label`).
|
|
29
|
+
*
|
|
30
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
31
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
32
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
33
|
+
* if `minHeight` is also passed and larger.
|
|
27
34
|
*/
|
|
28
35
|
export function BarChart({
|
|
29
36
|
series = [],
|
|
@@ -33,6 +40,7 @@ export function BarChart({
|
|
|
33
40
|
yAxis,
|
|
34
41
|
palette,
|
|
35
42
|
minHeight,
|
|
43
|
+
height,
|
|
36
44
|
aspect,
|
|
37
45
|
grid,
|
|
38
46
|
hideLegend = series.length <= 1,
|
|
@@ -45,6 +53,9 @@ export function BarChart({
|
|
|
45
53
|
const { i18n } = useTranslation();
|
|
46
54
|
const neutralPalette = useNeutralChartPalette();
|
|
47
55
|
|
|
56
|
+
useEffect(() => warnOnHeightMismatch('BarChart', { minHeight, height }), [minHeight, height]);
|
|
57
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
58
|
+
|
|
48
59
|
const labelledXAxis = withAxisDefaults(
|
|
49
60
|
xAxis,
|
|
50
61
|
xAxisLabel,
|
|
@@ -69,9 +80,10 @@ export function BarChart({
|
|
|
69
80
|
);
|
|
70
81
|
|
|
71
82
|
return (
|
|
72
|
-
<Box data-testid="bar-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
83
|
+
<Box data-testid="bar-chart-container" sx={{ width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }}>
|
|
73
84
|
<MuiBarChart
|
|
74
85
|
{...chartProps}
|
|
86
|
+
height={chartHeight}
|
|
75
87
|
series={series}
|
|
76
88
|
xAxis={rotatedTickSpace.xAxis}
|
|
77
89
|
yAxis={sizedYAxis}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useId, useRef, useState } from 'react';
|
|
1
|
+
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
4
|
Box,
|
|
@@ -11,12 +11,18 @@ import {
|
|
|
11
11
|
import { useTranslation } from 'react-i18next';
|
|
12
12
|
import { useTheme } from '@mui/material/styles';
|
|
13
13
|
import { exportChartPng, exportChartSvg } from './exportChart';
|
|
14
|
+
import { resolveChartHeight, warnOnHeightMismatch } from './chartDefaults';
|
|
14
15
|
|
|
15
16
|
export const CHART_FRAME_ROOT_SX = { p: 2 };
|
|
16
17
|
export const CHART_FRAME_ALERT_SX = { mt: 2 };
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
21
|
+
*
|
|
22
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
23
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the content box itself.
|
|
24
|
+
* Once `height` is set, `height` sizes the box and it never reserves more than that, even if
|
|
25
|
+
* `minHeight` is also passed and larger.
|
|
20
26
|
*/
|
|
21
27
|
export function ChartFrame({
|
|
22
28
|
title,
|
|
@@ -28,6 +34,7 @@ export function ChartFrame({
|
|
|
28
34
|
isEmpty = false,
|
|
29
35
|
emptyMessage,
|
|
30
36
|
minHeight,
|
|
37
|
+
height,
|
|
31
38
|
aspect,
|
|
32
39
|
exportOptions = false,
|
|
33
40
|
onExportSvg,
|
|
@@ -51,6 +58,9 @@ export function ChartFrame({
|
|
|
51
58
|
const chartLabel = ariaLabel || title;
|
|
52
59
|
const callerSx = Array.isArray(sx) ? sx : [sx];
|
|
53
60
|
|
|
61
|
+
useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }), [minHeight, height]);
|
|
62
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
63
|
+
|
|
54
64
|
const runExport = async (type) => {
|
|
55
65
|
setExportError(false);
|
|
56
66
|
try {
|
|
@@ -103,7 +113,8 @@ export function ChartFrame({
|
|
|
103
113
|
aria-labelledby={hasCustomAriaLabel ? undefined : titleId}
|
|
104
114
|
sx={{
|
|
105
115
|
width: '100%',
|
|
106
|
-
minHeight,
|
|
116
|
+
minHeight: wrapperMinHeight,
|
|
117
|
+
height: chartHeight,
|
|
107
118
|
aspectRatio: aspect,
|
|
108
119
|
display: 'flex',
|
|
109
120
|
alignItems: 'center',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
@@ -10,8 +10,10 @@ import { useNeutralChartPalette } from './palette';
|
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_LEGEND_POSITION,
|
|
12
12
|
defaultNumericTickFormatter,
|
|
13
|
+
resolveChartHeight,
|
|
13
14
|
sizeYAxisForContent,
|
|
14
15
|
spaceForRotatedTicks,
|
|
16
|
+
warnOnHeightMismatch,
|
|
15
17
|
withAxisDefaults,
|
|
16
18
|
withChartSlotDefaults,
|
|
17
19
|
withGridDefaults,
|
|
@@ -41,6 +43,11 @@ function FilledMarkElement({ color, style, ...props }) {
|
|
|
41
43
|
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
42
44
|
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
43
45
|
* `label`).
|
|
46
|
+
*
|
|
47
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
48
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
49
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
50
|
+
* if `minHeight` is also passed and larger.
|
|
44
51
|
*/
|
|
45
52
|
export function LineChart({
|
|
46
53
|
series = [],
|
|
@@ -50,6 +57,7 @@ export function LineChart({
|
|
|
50
57
|
yAxis,
|
|
51
58
|
palette,
|
|
52
59
|
minHeight,
|
|
60
|
+
height,
|
|
53
61
|
aspect,
|
|
54
62
|
grid,
|
|
55
63
|
hideLegend = series.length <= 1,
|
|
@@ -63,6 +71,9 @@ export function LineChart({
|
|
|
63
71
|
const { i18n } = useTranslation();
|
|
64
72
|
const neutralPalette = useNeutralChartPalette();
|
|
65
73
|
|
|
74
|
+
useEffect(() => warnOnHeightMismatch('LineChart', { minHeight, height }), [minHeight, height]);
|
|
75
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
76
|
+
|
|
66
77
|
const labelledXAxis = withAxisDefaults(
|
|
67
78
|
xAxis,
|
|
68
79
|
xAxisLabel,
|
|
@@ -92,9 +103,10 @@ export function LineChart({
|
|
|
92
103
|
);
|
|
93
104
|
|
|
94
105
|
return (
|
|
95
|
-
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
106
|
+
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }}>
|
|
96
107
|
<MuiLineChart
|
|
97
108
|
{...chartProps}
|
|
109
|
+
height={chartHeight}
|
|
98
110
|
series={defaultedSeries}
|
|
99
111
|
xAxis={rotatedTickSpace.xAxis}
|
|
100
112
|
yAxis={sizedYAxis}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useMemo } from 'react';
|
|
1
|
+
import React, { useEffect, useMemo } from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
@@ -8,7 +8,9 @@ import { useNeutralChartPalette } from './palette';
|
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_LEGEND_POSITION,
|
|
10
10
|
defaultNumericTickFormatter,
|
|
11
|
+
resolveChartHeight,
|
|
11
12
|
sizeYAxisForContent,
|
|
13
|
+
warnOnHeightMismatch,
|
|
12
14
|
withAxisDefaults,
|
|
13
15
|
withChartSlotDefaults,
|
|
14
16
|
withGridDefaults,
|
|
@@ -151,6 +153,11 @@ export function ScatterReferenceLine({
|
|
|
151
153
|
* Reference geometry (a computed curve, a straight line, either with an optional label) is
|
|
152
154
|
* composed as `children` using `ScatterReferenceCurve`/`ScatterReferenceLine` above.
|
|
153
155
|
*
|
|
156
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
157
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
158
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
159
|
+
* if `minHeight` is also passed and larger.
|
|
160
|
+
*
|
|
154
161
|
* Tested magnitude: a few hundred marks (hram's largest cloud is ~300 points). Not verified,
|
|
155
162
|
* and not implied to hold, at thousands+.
|
|
156
163
|
*
|
|
@@ -171,6 +178,7 @@ export function ScatterChart({
|
|
|
171
178
|
yAxis,
|
|
172
179
|
palette,
|
|
173
180
|
minHeight,
|
|
181
|
+
height,
|
|
174
182
|
aspect,
|
|
175
183
|
grid,
|
|
176
184
|
hideLegend = series.length <= 1,
|
|
@@ -187,6 +195,9 @@ export function ScatterChart({
|
|
|
187
195
|
const { i18n } = useTranslation();
|
|
188
196
|
const neutralPalette = useNeutralChartPalette();
|
|
189
197
|
|
|
198
|
+
useEffect(() => warnOnHeightMismatch('ScatterChart', { minHeight, height }), [minHeight, height]);
|
|
199
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
200
|
+
|
|
190
201
|
const labelledXAxis = withAxisDefaults(
|
|
191
202
|
xAxis,
|
|
192
203
|
xAxisLabel,
|
|
@@ -257,9 +268,10 @@ export function ScatterChart({
|
|
|
257
268
|
);
|
|
258
269
|
|
|
259
270
|
return (
|
|
260
|
-
<Box data-testid="scatter-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
271
|
+
<Box data-testid="scatter-chart-container" sx={{ width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }}>
|
|
261
272
|
<MuiScatterChart
|
|
262
273
|
{...chartProps}
|
|
274
|
+
height={chartHeight}
|
|
263
275
|
series={orderedSeries.map((item) => ({ markerSize, ...item }))}
|
|
264
276
|
xAxis={labelledXAxis}
|
|
265
277
|
yAxis={sizedYAxis}
|
|
@@ -271,6 +271,49 @@ export function defaultNumericTickFormatter(locale) {
|
|
|
271
271
|
: new Intl.NumberFormat(locale).format(value));
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* CHART-8: resolves the `{minHeight, height, aspect}` trio these presets accept into what the
|
|
276
|
+
* wrapper reserves and what height (if any) sizes the chart itself. Three-way, not two-way --
|
|
277
|
+
* `aspect` changes the answer:
|
|
278
|
+
* - `height` set -> it sizes the chart; the wrapper never reserves more than that (a caller-set
|
|
279
|
+
* `minHeight` larger than `height` is capped here, closing the dead-space gap this WO exists
|
|
280
|
+
* to fix -- equal values, or no `minHeight` at all, pass through unchanged).
|
|
281
|
+
* - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
|
|
282
|
+
* derives its height from its width, and `minHeight` stays exactly what its name says, a floor
|
|
283
|
+
* stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
|
|
284
|
+
* that would remove the responsive behaviour four live call sites rely on.
|
|
285
|
+
* - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
|
|
286
|
+
* information to go on, and a caller in this shape meant the chart to have that height, not
|
|
287
|
+
* merely a wrapper floor around an unsized chart.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveChartHeight({ minHeight, height, aspect }) {
|
|
290
|
+
if (height != null) {
|
|
291
|
+
const wrapperMinHeight = minHeight != null && minHeight > height ? height : minHeight;
|
|
292
|
+
return { wrapperMinHeight, chartHeight: height };
|
|
293
|
+
}
|
|
294
|
+
if (aspect != null) {
|
|
295
|
+
return { wrapperMinHeight: minHeight, chartHeight: undefined };
|
|
296
|
+
}
|
|
297
|
+
return { wrapperMinHeight: minHeight, chartHeight: minHeight };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* CHART-8: dev-only warning when a caller passes both `minHeight` and `height` and they disagree
|
|
302
|
+
* -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
|
|
303
|
+
* combination in production would break a consumer's page over a layout nit. Silent on the
|
|
304
|
+
* equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
|
|
305
|
+
*/
|
|
306
|
+
export function warnOnHeightMismatch(componentName, { minHeight, height }) {
|
|
307
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
308
|
+
if (minHeight == null || height == null || minHeight === height) return;
|
|
309
|
+
// eslint-disable-next-line no-console
|
|
310
|
+
console.warn(
|
|
311
|
+
`[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
|
|
312
|
+
+ 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
|
|
313
|
+
+ 'chart. Pass only height, or drop minHeight if it should just track height.',
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
274
317
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
275
318
|
const position = {
|
|
276
319
|
...DEFAULT_LEGEND_POSITION,
|
package/tests/BarChart.test.jsx
CHANGED
|
@@ -71,4 +71,52 @@ describe('BarChart preset', () => {
|
|
|
71
71
|
expect(props.xAxis[0].label).toBeUndefined();
|
|
72
72
|
expect(props.yAxis[0].label).toBeUndefined();
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
// CHART-8: minHeight/height/aspect resolution, wired through the actual component.
|
|
76
|
+
describe('minHeight/height resolution (CHART-8)', () => {
|
|
77
|
+
it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
|
|
78
|
+
renderChart({ series: [{ data: [1] }], minHeight: 300 });
|
|
79
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(300);
|
|
80
|
+
expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).minHeight).toBe('300px');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
|
|
84
|
+
renderChart({ series: [{ data: [1] }], minHeight: 320, aspect: 1.8 });
|
|
85
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBeUndefined();
|
|
86
|
+
const style = window.getComputedStyle(screen.getByTestId('bar-chart-container'));
|
|
87
|
+
expect(style.minHeight).toBe('320px');
|
|
88
|
+
expect(style.aspectRatio).toBe('1.8 / 1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
92
|
+
renderChart({ series: [{ data: [1] }], minHeight: 420, height: 380 });
|
|
93
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(380);
|
|
94
|
+
expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).minHeight).toBe('380px');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is byte-identical when minHeight equals height', () => {
|
|
98
|
+
renderChart({ series: [{ data: [1] }], minHeight: 320, height: 320 });
|
|
99
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(320);
|
|
100
|
+
expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).minHeight).toBe('320px');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is unchanged when height is passed alone', () => {
|
|
104
|
+
renderChart({ series: [{ data: [1] }], height: 280 });
|
|
105
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(280);
|
|
106
|
+
expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).minHeight).toBe('auto');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('warns only on the disagreeing pair, naming both values, and not on the aspect combination', () => {
|
|
110
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
111
|
+
renderChart({ series: [{ data: [1] }], minHeight: 420, height: 380 });
|
|
112
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
113
|
+
expect(warn.mock.calls[0][0]).toContain('420');
|
|
114
|
+
expect(warn.mock.calls[0][0]).toContain('380');
|
|
115
|
+
warn.mockClear();
|
|
116
|
+
|
|
117
|
+
renderChart({ series: [{ data: [1] }], minHeight: 320, aspect: 1.8 });
|
|
118
|
+
expect(warn).not.toHaveBeenCalled();
|
|
119
|
+
warn.mockRestore();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
74
122
|
});
|
|
@@ -211,4 +211,47 @@ describe('ChartFrame', () => {
|
|
|
211
211
|
renderFrame({ isEmpty: true }, 'fr');
|
|
212
212
|
expect(screen.getByText('Aucune donnée disponible.')).toBeTruthy();
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
// CHART-8: minHeight/height/aspect resolution on ChartFrame's own content box.
|
|
216
|
+
describe('minHeight/height resolution (CHART-8)', () => {
|
|
217
|
+
function contentBoxStyle() {
|
|
218
|
+
return window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
it('sizes the content box from minHeight alone when neither height nor aspect is set', () => {
|
|
222
|
+
renderFrame({ minHeight: 300 });
|
|
223
|
+
const style = contentBoxStyle();
|
|
224
|
+
expect(style.minHeight).toBe('300px');
|
|
225
|
+
expect(style.height).toBe('300px');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('leaves minHeight as a floor and gives no fixed height when aspect is set (no height)', () => {
|
|
229
|
+
renderFrame({ minHeight: 320, aspect: 1.8 });
|
|
230
|
+
const style = contentBoxStyle();
|
|
231
|
+
expect(style.minHeight).toBe('320px');
|
|
232
|
+
expect(style.height).toBe('auto');
|
|
233
|
+
expect(style.aspectRatio).toBe('1.8 / 1');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
237
|
+
renderFrame({ minHeight: 420, height: 380 });
|
|
238
|
+
const style = contentBoxStyle();
|
|
239
|
+
expect(style.minHeight).toBe('380px');
|
|
240
|
+
expect(style.height).toBe('380px');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('is byte-identical when minHeight equals height', () => {
|
|
244
|
+
renderFrame({ minHeight: 320, height: 320 });
|
|
245
|
+
const style = contentBoxStyle();
|
|
246
|
+
expect(style.minHeight).toBe('320px');
|
|
247
|
+
expect(style.height).toBe('320px');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('is unchanged when height is passed alone', () => {
|
|
251
|
+
renderFrame({ height: 280 });
|
|
252
|
+
const style = contentBoxStyle();
|
|
253
|
+
expect(style.minHeight).toBe('auto');
|
|
254
|
+
expect(style.height).toBe('280px');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
214
257
|
});
|
package/tests/LineChart.test.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
5
5
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
6
|
|
|
7
7
|
const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
|
|
@@ -33,4 +33,49 @@ describe('LineChart preset', () => {
|
|
|
33
33
|
legend: { position: { vertical: 'bottom', horizontal: 'start' } },
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// CHART-8: minHeight/height/aspect resolution, wired through the actual component.
|
|
38
|
+
describe('minHeight/height resolution (CHART-8)', () => {
|
|
39
|
+
function renderLine(props) {
|
|
40
|
+
return render(
|
|
41
|
+
<ThemeProvider theme={createTheme()}>
|
|
42
|
+
<LineChart series={[{ data: [1, 2] }]} {...props} />
|
|
43
|
+
</ThemeProvider>,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(cleanup);
|
|
48
|
+
|
|
49
|
+
it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
|
|
50
|
+
renderLine({ minHeight: 300 });
|
|
51
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(300);
|
|
52
|
+
expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('300px');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
|
|
56
|
+
renderLine({ minHeight: 320, aspect: 1.8 });
|
|
57
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBeUndefined();
|
|
58
|
+
const style = window.getComputedStyle(screen.getByTestId('line-chart-container'));
|
|
59
|
+
expect(style.minHeight).toBe('320px');
|
|
60
|
+
expect(style.aspectRatio).toBe('1.8 / 1');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
64
|
+
renderLine({ minHeight: 420, height: 380 });
|
|
65
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(380);
|
|
66
|
+
expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('380px');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is byte-identical when minHeight equals height', () => {
|
|
70
|
+
renderLine({ minHeight: 320, height: 320 });
|
|
71
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(320);
|
|
72
|
+
expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('320px');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is unchanged when height is passed alone', () => {
|
|
76
|
+
renderLine({ height: 280 });
|
|
77
|
+
expect(chartSpy.mock.calls.at(-1)[0].height).toBe(280);
|
|
78
|
+
expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('auto');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
36
81
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import i18next from 'i18next';
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { cleanup, render } from '@testing-library/react';
|
|
5
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
6
6
|
import { I18nextProvider } from 'react-i18next';
|
|
7
7
|
import { ThemeProvider } from '@mui/material/styles';
|
|
8
8
|
|
|
@@ -232,3 +232,40 @@ describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
|
|
|
232
232
|
expect(container.querySelector('text')).toBeNull();
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
// CHART-8: minHeight/height/aspect resolution, wired through the actual component.
|
|
237
|
+
describe('ScatterChart minHeight/height resolution (CHART-8)', () => {
|
|
238
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
239
|
+
|
|
240
|
+
it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
|
|
241
|
+
renderScatter({ minHeight: 300 });
|
|
242
|
+
expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(300);
|
|
243
|
+
expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('300px');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
|
|
247
|
+
renderScatter({ minHeight: 320, aspect: 1.8 });
|
|
248
|
+
expect(scatterSpy.mock.calls.at(-1)[0].height).toBeUndefined();
|
|
249
|
+
const style = window.getComputedStyle(screen.getByTestId('scatter-chart-container'));
|
|
250
|
+
expect(style.minHeight).toBe('320px');
|
|
251
|
+
expect(style.aspectRatio).toBe('1.8 / 1');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
255
|
+
renderScatter({ minHeight: 420, height: 380 });
|
|
256
|
+
expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(380);
|
|
257
|
+
expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('380px');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('is byte-identical when minHeight equals height', () => {
|
|
261
|
+
renderScatter({ minHeight: 320, height: 320 });
|
|
262
|
+
expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(320);
|
|
263
|
+
expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('320px');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('is unchanged when height is passed alone', () => {
|
|
267
|
+
renderScatter({ height: 280 });
|
|
268
|
+
expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(280);
|
|
269
|
+
expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('auto');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -17,7 +17,9 @@ import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
|
17
17
|
import {
|
|
18
18
|
defaultNumericTickFormatter,
|
|
19
19
|
PACKAGE_DEFAULT_MARGIN,
|
|
20
|
+
resolveChartHeight,
|
|
20
21
|
sizeYAxisForContent,
|
|
22
|
+
warnOnHeightMismatch,
|
|
21
23
|
withAxisDefaults,
|
|
22
24
|
withMarginDefaults,
|
|
23
25
|
} from '../src/components/charts/chartDefaults';
|
|
@@ -433,3 +435,74 @@ describe('withMarginDefaults (THEME-11)', () => {
|
|
|
433
435
|
expect(withMarginDefaults(full)).toEqual(full);
|
|
434
436
|
});
|
|
435
437
|
});
|
|
438
|
+
|
|
439
|
+
// CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
|
|
440
|
+
// chart component wiring (component-level wiring is covered per-preset in each own test file).
|
|
441
|
+
describe('resolveChartHeight (CHART-8)', () => {
|
|
442
|
+
it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
|
|
443
|
+
expect(resolveChartHeight({ minHeight: 300 })).toEqual({ wrapperMinHeight: 300, chartHeight: 300 });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
|
|
447
|
+
expect(resolveChartHeight({ minHeight: 320, aspect: 1.8 })).toEqual({
|
|
448
|
+
wrapperMinHeight: 320,
|
|
449
|
+
chartHeight: undefined,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
454
|
+
expect(resolveChartHeight({ minHeight: 420, height: 380 })).toEqual({
|
|
455
|
+
wrapperMinHeight: 380,
|
|
456
|
+
chartHeight: 380,
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('is byte-identical (both values unchanged) when minHeight equals height', () => {
|
|
461
|
+
expect(resolveChartHeight({ minHeight: 320, height: 320 })).toEqual({
|
|
462
|
+
wrapperMinHeight: 320,
|
|
463
|
+
chartHeight: 320,
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('leaves the wrapper unset when height is passed alone', () => {
|
|
468
|
+
expect(resolveChartHeight({ height: 280 })).toEqual({ wrapperMinHeight: undefined, chartHeight: 280 });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('does not cap the wrapper when minHeight is smaller than height', () => {
|
|
472
|
+
expect(resolveChartHeight({ minHeight: 200, height: 380 })).toEqual({
|
|
473
|
+
wrapperMinHeight: 200,
|
|
474
|
+
chartHeight: 380,
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('warnOnHeightMismatch (CHART-8)', () => {
|
|
480
|
+
afterEach(() => vi.restoreAllMocks());
|
|
481
|
+
|
|
482
|
+
it('warns, naming the component and both values, when minHeight and height disagree', () => {
|
|
483
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
484
|
+
warnOnHeightMismatch('BarChart', { minHeight: 420, height: 380 });
|
|
485
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
486
|
+
expect(warn.mock.calls[0][0]).toContain('BarChart');
|
|
487
|
+
expect(warn.mock.calls[0][0]).toContain('420');
|
|
488
|
+
expect(warn.mock.calls[0][0]).toContain('380');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('does not warn when minHeight equals height', () => {
|
|
492
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
493
|
+
warnOnHeightMismatch('BarChart', { minHeight: 320, height: 320 });
|
|
494
|
+
expect(warn).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('does not warn on the legitimate minHeight + aspect (no height) combination', () => {
|
|
498
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
499
|
+
warnOnHeightMismatch('BarChart', { minHeight: 320, height: undefined });
|
|
500
|
+
expect(warn).not.toHaveBeenCalled();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('does not warn when only one of the pair is set', () => {
|
|
504
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
505
|
+
warnOnHeightMismatch('BarChart', { minHeight: undefined, height: 280 });
|
|
506
|
+
expect(warn).not.toHaveBeenCalled();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# UCM-CHART-8 — Make the chart presets stop producing dead space below the chart
|
|
2
|
+
|
|
3
|
+
- **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main` without a staging step)
|
|
4
|
+
- **Tier:** 3 — shared-core. Every consuming app inherits the behaviour change.
|
|
5
|
+
- **Status:** planned
|
|
6
|
+
- **Workstream:** `CHART-*` (chart presets)
|
|
7
|
+
- **Files:** `src/components/charts/ScatterChart.jsx`, `BarChart.jsx`, `LineChart.jsx`,
|
|
8
|
+
`TimeSeriesChart.jsx`, **`ChartFrame.jsx`** — all five destructure `minHeight` **and** `aspect`
|
|
9
|
+
(verified 2026-08-21) — plus their tests. **Note for the implementer: only `ScatterChart` and
|
|
10
|
+
`TimeSeriesChart` reference `height` at all.** `BarChart`, `LineChart` and `ChartFrame` never
|
|
11
|
+
destructure it, so for them the incoherent "pair" exists only via the rest spread into the MUI
|
|
12
|
+
chart. Do not go looking for a declared `height` prop there.
|
|
13
|
+
|
|
14
|
+
> **Why.** `minHeight` and `height` are two different things on these presets, and the API lets a
|
|
15
|
+
> caller pass both. `minHeight` lands on the **wrapper `Box`** —
|
|
16
|
+
> `sx={{ width: '100%', minHeight, aspectRatio: aspect }}` (`ScatterChart.jsx:260`, same
|
|
17
|
+
> construction in `BarChart.jsx`) — while `height` sizes the **chart**. When `minHeight > height`,
|
|
18
|
+
> the difference is dead space **below the chart**.
|
|
19
|
+
>
|
|
20
|
+
> Measured in `hram` on 2026-08-20:
|
|
21
|
+
>
|
|
22
|
+
> | panel | `minHeight` | `height` | dead space |
|
|
23
|
+
> |---|---|---|---|
|
|
24
|
+
> | `StructuralReachabilityPanel` | 420 | 380 | 40 px |
|
|
25
|
+
> | `ExpertTimelinePanel` | 340 | 280 (×2) | 60 px |
|
|
26
|
+
> | `ResultsTimelinePanel` | 320 | 280 | 40 px |
|
|
27
|
+
> | `GroupMetricsPanel` | 300 | **none** | the whole chart is unsized |
|
|
28
|
+
> | Cost, Ratio, Accessibility, Referral, Total | equal | equal | 0 |
|
|
29
|
+
>
|
|
30
|
+
> **The equal-value majority is why this survived.** Passing both looks like harmless
|
|
31
|
+
> belt-and-braces and costs nothing when the numbers match, so the pattern spread by copy-paste and
|
|
32
|
+
> nobody noticed the cases where they diverge. It has been diagnosed and fixed **pointwise twice**
|
|
33
|
+
> in hram (`AllocationPerformancePanel.jsx:462-478`, `OptimizationResultsPanel.jsx:928-940`, both to
|
|
34
|
+
> `height={420}`) — with the explanation written as a comment **inside those two files**, where no
|
|
35
|
+
> new panel will ever read it.
|
|
36
|
+
>
|
|
37
|
+
> This is not N panel bugs. It is one shared API that permits an incoherent pair, and it will keep
|
|
38
|
+
> recurring for as long as it does. Fixing it here fixes every consumer at once, including the ones
|
|
39
|
+
> nobody sweeps.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Part A — Envelope (authoritative WHAT/WHY)
|
|
44
|
+
|
|
45
|
+
### Goal
|
|
46
|
+
|
|
47
|
+
A caller can no longer produce dead space by passing `minHeight`. The presets resolve the pair
|
|
48
|
+
themselves, and the incoherent combination becomes impossible rather than merely discouraged.
|
|
49
|
+
|
|
50
|
+
### Definition of Done
|
|
51
|
+
|
|
52
|
+
- [ ] **The resolution is three-way, not two-way — `aspect` is the third input and it changes the
|
|
53
|
+
answer.** Amended 2026-08-21; the original two-way rule would have broken four live call sites.
|
|
54
|
+
- **`height` present** → it sizes the chart, and the wrapper reserves no more than that.
|
|
55
|
+
- **No `height`, no `aspect`** → **`minHeight` sizes the chart**, not merely the wrapper.
|
|
56
|
+
This is what a caller in that shape meant (`GroupMetricsPanel`: `minHeight={300}`, no
|
|
57
|
+
`height`, no `aspect` — chart unsized today).
|
|
58
|
+
- **No `height`, but `aspect` present** → **today's behaviour is correct and must not change.**
|
|
59
|
+
The wrapper is `width: 100%` with `aspectRatio`, so its height derives from its width, and
|
|
60
|
+
`minHeight` is the floor that stops it collapsing on a narrow viewport. Here `minHeight`
|
|
61
|
+
means exactly what its name says.
|
|
62
|
+
|
|
63
|
+
**Same prop shape, opposite intent** — which is why the rule cannot be stated on
|
|
64
|
+
`minHeight`/`height` alone.
|
|
65
|
+
- [ ] **`minHeight > height` produces no wrapper padding.** The chart is sized by `height` and the
|
|
66
|
+
wrapper does not reserve more than the chart occupies. The extra space was never intentional.
|
|
67
|
+
- [ ] **Equal values behave exactly as today** — that is the majority of call sites and they must
|
|
68
|
+
not shift by a pixel.
|
|
69
|
+
- [ ] **A dev-mode warning names the offending call site** when both are passed and they disagree.
|
|
70
|
+
Not a throw: a shared package that throws on a prop combination in production breaks a
|
|
71
|
+
consumer's page over a layout nit.
|
|
72
|
+
- [ ] **JSDoc on every preset that takes it** states in one line what `minHeight` now does, so the
|
|
73
|
+
next caller does not have to infer it from a comment in a consumer app.
|
|
74
|
+
|
|
75
|
+
### Deliberately NOT done
|
|
76
|
+
|
|
77
|
+
- **`minHeight` is not removed from the API.** Removing it is a breaking change to a shared package,
|
|
78
|
+
which would make every consumer's pin bump a Tier-3 change with a real chance of unnoticed layout
|
|
79
|
+
regressions — a worse outcome than the defect. Deprecate in the docstring, coerce in the code.
|
|
80
|
+
- No change to chart internals, palettes, axis defaults or `aspect`.
|
|
81
|
+
- No consumer-side sweep in this WO. hram's call-site cleanup is its own follow-up, gated on the pin
|
|
82
|
+
bump — see below.
|
|
83
|
+
|
|
84
|
+
### Preconditions and ordering
|
|
85
|
+
|
|
86
|
+
The fix only reaches hram after **(a)** this lands and publishes and **(b)** hram bumps its
|
|
87
|
+
`@micha.bigler/ui-core-micha` pin. Verify the published version is actually live before the consumer
|
|
88
|
+
bump — this package publishes from `main` with no staging step, so a failed publish is silent until
|
|
89
|
+
a consumer installs it.
|
|
90
|
+
|
|
91
|
+
### Risks
|
|
92
|
+
|
|
93
|
+
- **The consumer check is done, and it found something — 2026-08-21.** It was written here as a
|
|
94
|
+
hypothetical ("a consumer may be relying on the padding as spacing"); the real finding is
|
|
95
|
+
different and larger. **Four live call sites pass `aspect` together with `minHeight` and no
|
|
96
|
+
`height`:**
|
|
97
|
+
|
|
98
|
+
| call site | props |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `hram/AccessGapScatterPanel.jsx:182` | `aspect={1.8}` + `minHeight={320}` |
|
|
101
|
+
| `fitness-monitor/BodyHistoryPage.jsx:~328` | `aspect="960 / 380"` + `minHeight={320}` |
|
|
102
|
+
| `fitness-monitor/BodyHistoryPage.jsx:~355` | `aspect="960 / 380"` + `minHeight={320}` |
|
|
103
|
+
| `fitness-monitor/BodyHistoryPage.jsx:~450` | `aspect="400 / 220"` + `minHeight={220}` |
|
|
104
|
+
|
|
105
|
+
These are not relying on padding-as-spacing. They are using `minHeight` **as a floor under an
|
|
106
|
+
aspect-derived height**, which is legitimate and is what the name promises. The original
|
|
107
|
+
Definition of Done would have turned all four into fixed-height charts and removed their
|
|
108
|
+
responsive behaviour. **That is the reason for the three-way rule above**, and it is the case to
|
|
109
|
+
hold onto when reading the rest of this order.
|
|
110
|
+
|
|
111
|
+
Scope note: `survey-renderer` is named above as a consumer to check. **The standalone repo is
|
|
112
|
+
retired** (2026-07-31); the widget now lives at `survey_app/frontend/packages/survey-renderer`,
|
|
113
|
+
and neither it nor `cockpit` or `jg-ferien` passes `aspect` to a chart preset. jg's one `aspect=`
|
|
114
|
+
hit is an `ImageCropDialog`, unrelated.
|
|
115
|
+
- The dev warning must not fire on the equal-value majority, or it becomes noise that trains people
|
|
116
|
+
to ignore it.
|
|
117
|
+
|
|
118
|
+
### Tests to WRITE — narrow
|
|
119
|
+
|
|
120
|
+
- `minHeight` alone, **no `aspect`** → the chart receives that height.
|
|
121
|
+
- **`minHeight` + `aspect`, no `height` → unchanged from today**: the wrapper keeps its
|
|
122
|
+
`aspectRatio` and `minHeight` stays a floor, and the chart is **not** given a fixed height. This is
|
|
123
|
+
the four-call-site case in Risks and the one a two-way rule would have broken — **it is the test
|
|
124
|
+
that has to fail if someone later re-simplifies the resolution.**
|
|
125
|
+
- `minHeight > height` → the chart receives `height` and the wrapper reserves no more than that.
|
|
126
|
+
- `minHeight === height` → byte-identical to the current output.
|
|
127
|
+
- `height` alone → unchanged.
|
|
128
|
+
- The dev warning fires only on the disagreeing pair, and names the values. It must **not** fire on
|
|
129
|
+
`minHeight` + `aspect` without `height`, which is a legitimate combination.
|
|
130
|
+
|
|
131
|
+
No full suite.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Part B — Implementation map
|
|
136
|
+
|
|
137
|
+
> **TO BE FILLED BY THE ORCHESTRATOR** on `git pull`. Must carry the preset files with `path:line`
|
|
138
|
+
> anchors (`ScatterChart.jsx:173-174, :260` and the equivalents), the absolute working directory,
|
|
139
|
+
> the progress contract, and the preamble. **Do not dispatch while this placeholder stands.**
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Part C — Orchestrator-only
|
|
144
|
+
|
|
145
|
+
> **TO BE FILLED BY THE ORCHESTRATOR.** Review routing (`reviewer` + `ui_reviewer`, full context —
|
|
146
|
+
> Tier 3 shared-core), verification, register maintenance, commit, publish check, and the execution
|
|
147
|
+
> directive with its self-address guard.
|