@micha.bigler/ui-core-micha 2.42.2 → 3.0.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 +3 -1
- package/dev/entries.jsx +6 -0
- package/dist/components/charts/BarChart.js +23 -12
- package/dist/components/charts/ChartFrame.js +23 -5
- package/dist/components/charts/LineChart.js +23 -12
- package/dist/components/charts/ScatterChart.js +29 -22
- package/dist/components/charts/TimeSeriesChart.js +6 -1
- package/dist/components/charts/chartDefaults.js +290 -110
- package/docs/CHART-LAYOUT.md +129 -0
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +30 -28
- package/src/components/charts/ChartFrame.jsx +24 -5
- package/src/components/charts/LineChart.jsx +31 -29
- package/src/components/charts/ScatterChart.jsx +37 -29
- package/src/components/charts/TimeSeriesChart.jsx +6 -1
- package/src/components/charts/chartDefaults.js +320 -111
- package/tests/BarChart.test.jsx +27 -39
- package/tests/LineChart.test.jsx +21 -24
- package/tests/ScatterChart.test.jsx +21 -24
- package/tests/chartDefaults.test.js +313 -186
- package/work-orders/UCM-CHART-11.md +132 -0
- package/work-orders/UCM-CHART-12.md +348 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
1
12
|
export const DEFAULT_LEGEND_POSITION = {
|
|
2
13
|
vertical: 'bottom',
|
|
3
14
|
horizontal: 'start',
|
|
@@ -33,7 +44,85 @@ function cssLengthToPixels(value) {
|
|
|
33
44
|
return parsed;
|
|
34
45
|
}
|
|
35
46
|
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
47
|
+
// Still used by `sizeYAxisForContent` (THEME-9, y-axis width) below -- unrelated to CHART-11,
|
|
48
|
+
// which only replaces how the ROTATED x-axis path (`rotatedTickMetrics`) estimates width.
|
|
36
49
|
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
50
|
+
// CHART-11: per-character width table (em units), the ESTIMATE fallback used only when no DOM is
|
|
51
|
+
// available to actually measure a tick label's rendered extent (SSR, or a test environment where
|
|
52
|
+
// `measureTickTextWidthPx` returns null -- jsdom does not implement real SVG text layout). A
|
|
53
|
+
// single flat multiplier (the pre-CHART-11 constant, `AVERAGE_GLYPH_WIDTH_EM = 0.6`) systematically
|
|
54
|
+
// overestimated real ward-name-shaped strings: they mix spaces, apostrophes and narrow lowercase
|
|
55
|
+
// letters ("Mang'ula", "Msolwa Station") with a few wide/uppercase ones, and a flat multiplier
|
|
56
|
+
// prices every character at the wide end. Three tiers, not a full per-glyph font metrics table --
|
|
57
|
+
// proportionate to "an estimate", not a replacement for real measurement.
|
|
58
|
+
const NARROW_GLYPH_WIDTH_EM = 0.28; // i l j I ' . , : ; ! | and space
|
|
59
|
+
const WIDE_GLYPH_WIDTH_EM = 0.78; // uppercase (except I), plus lowercase m/w
|
|
60
|
+
const DEFAULT_GLYPH_WIDTH_EM = 0.52; // everything else -- most lowercase letters and digits
|
|
61
|
+
const NARROW_GLYPH_CHARS = new Set(["i", "l", "j", "I", "'", ".", ",", ":", ";", "!", "|", " "]);
|
|
62
|
+
const WIDE_GLYPH_CHARS = new Set(["m", "w"]);
|
|
63
|
+
function glyphWidthEm(char) {
|
|
64
|
+
if (NARROW_GLYPH_CHARS.has(char))
|
|
65
|
+
return NARROW_GLYPH_WIDTH_EM;
|
|
66
|
+
if (WIDE_GLYPH_CHARS.has(char) || (char >= 'A' && char <= 'Z'))
|
|
67
|
+
return WIDE_GLYPH_WIDTH_EM;
|
|
68
|
+
return DEFAULT_GLYPH_WIDTH_EM;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* CHART-11 ESTIMATE fallback (declared explicitly, not dressed up as a measurement): sums a
|
|
72
|
+
* per-character width table instead of applying one flat multiplier to the whole string. Only
|
|
73
|
+
* reached when `measureTickTextWidthPx` returns null.
|
|
74
|
+
*/
|
|
75
|
+
function estimateTextWidthPx(text, fontSizePx) {
|
|
76
|
+
let widthEm = 0;
|
|
77
|
+
for (const char of text)
|
|
78
|
+
widthEm += glyphWidthEm(char);
|
|
79
|
+
return widthEm * fontSizePx;
|
|
80
|
+
}
|
|
81
|
+
let hiddenMeasurementTextNode;
|
|
82
|
+
function getHiddenMeasurementTextNode() {
|
|
83
|
+
if (typeof document === 'undefined')
|
|
84
|
+
return null;
|
|
85
|
+
if (hiddenMeasurementTextNode && hiddenMeasurementTextNode.isConnected) {
|
|
86
|
+
return hiddenMeasurementTextNode;
|
|
87
|
+
}
|
|
88
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
89
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
90
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
91
|
+
svg.style.position = 'absolute';
|
|
92
|
+
svg.style.width = '0';
|
|
93
|
+
svg.style.height = '0';
|
|
94
|
+
svg.style.overflow = 'hidden';
|
|
95
|
+
svg.style.pointerEvents = 'none';
|
|
96
|
+
const text = document.createElementNS(svgNS, 'text');
|
|
97
|
+
svg.appendChild(text);
|
|
98
|
+
(document.body || document.documentElement).appendChild(svg);
|
|
99
|
+
hiddenMeasurementTextNode = text;
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* CHART-11: measures a tick label's real rendered width via a transient, invisible, shared SVG
|
|
104
|
+
* `<text>` node -- the same element type MUI's own axis ticks render as, so glyph metrics match
|
|
105
|
+
* whatever font actually cascades to it (this package has no access to the consumer's theme font
|
|
106
|
+
* at this call site; the transient node inherits the page's own cascade, same as a real tick
|
|
107
|
+
* would). Returns `null` (never throws) when no DOM is available (SSR) or when
|
|
108
|
+
* `getComputedTextLength` is unsupported/unreliable there (jsdom does not implement real SVG text
|
|
109
|
+
* layout) -- callers fall back to `estimateTextWidthPx` in that case, and tests inject their own
|
|
110
|
+
* stub via `spaceForRotatedTicks`'s optional `measureTextWidth` parameter for determinism.
|
|
111
|
+
*/
|
|
112
|
+
function measureTickTextWidthPx(text, fontSizePx) {
|
|
113
|
+
try {
|
|
114
|
+
const node = getHiddenMeasurementTextNode();
|
|
115
|
+
if (!node)
|
|
116
|
+
return null;
|
|
117
|
+
node.setAttribute('font-size', String(fontSizePx));
|
|
118
|
+
node.textContent = text;
|
|
119
|
+
const length = node.getComputedTextLength();
|
|
120
|
+
return Number.isFinite(length) && length > 0 ? length : null;
|
|
121
|
+
}
|
|
122
|
+
catch (_a) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
37
126
|
// THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
|
|
38
127
|
// MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
|
|
39
128
|
// of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
|
|
@@ -43,78 +132,18 @@ const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
|
43
132
|
// less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
|
|
44
133
|
// overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
|
|
45
134
|
// making this conditional on scale type would be inferred behaviour with no named consumer.
|
|
135
|
+
//
|
|
136
|
+
// UCM-CHART-12 Rule 3: no caller-supplied `margin` exists any more (the model owns spacing
|
|
137
|
+
// completely, `resolveChartLayout` builds the final margin object directly) -- this is now only
|
|
138
|
+
// the base every side starts from before `resolveChartLayout` adds the legend band to one side.
|
|
46
139
|
export const PACKAGE_DEFAULT_MARGIN = {
|
|
47
140
|
top: 10, bottom: 8, left: 8, right: 16,
|
|
48
141
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
|
|
55
|
-
*/
|
|
56
|
-
export function withMarginDefaults(margin) {
|
|
57
|
-
const explicit = typeof margin === 'number'
|
|
58
|
-
? { top: margin, bottom: margin, left: margin, right: margin }
|
|
59
|
-
: margin;
|
|
60
|
-
return Object.assign(Object.assign({}, PACKAGE_DEFAULT_MARGIN), explicit);
|
|
61
|
-
}
|
|
62
|
-
function formattedTickText(axis, value) {
|
|
63
|
-
if (!axis.valueFormatter)
|
|
64
|
-
return String(value);
|
|
65
|
-
try {
|
|
66
|
-
return String(axis.valueFormatter(value, { location: 'tick' }));
|
|
67
|
-
}
|
|
68
|
-
catch (_a) {
|
|
69
|
-
// Some formatters require MUI's complete scale context. Falling back to
|
|
70
|
-
// the raw value is conservative: it reserves at least as much space.
|
|
71
|
-
return String(value);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
function rotatedTickMetrics(axis, defaultLineHeight) {
|
|
75
|
-
var _a, _b, _c;
|
|
76
|
-
const angle = Number((_a = axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.angle) || 0;
|
|
77
|
-
if (angle === 0)
|
|
78
|
-
return null;
|
|
79
|
-
const fontSize = cssLengthToPixels((_b = axis.tickLabelStyle) === null || _b === void 0 ? void 0 : _b.fontSize);
|
|
80
|
-
if (fontSize === 0)
|
|
81
|
-
return null;
|
|
82
|
-
const lineHeight = Number((_c = axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
|
|
83
|
-
const longestLabel = (axis.data || []).reduce((longest, value) => {
|
|
84
|
-
const text = formattedTickText(axis, value);
|
|
85
|
-
return text.length > longest.length ? text : longest;
|
|
86
|
-
}, '');
|
|
87
|
-
const textWidth = Math.max(fontSize, longestLabel.length * fontSize * AVERAGE_GLYPH_WIDTH_EM);
|
|
88
|
-
const radians = (angle * Math.PI) / 180;
|
|
89
|
-
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
90
|
-
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
91
|
-
const extraHeight = Math.max(1, Math.ceil(projectedHeight - fontSize));
|
|
92
|
-
return { extraHeight };
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
|
|
96
|
-
* uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
|
|
97
|
-
* `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
|
|
98
|
-
* label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
|
|
99
|
-
* allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
|
|
100
|
-
* `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
|
|
101
|
-
* to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
|
|
102
|
-
* `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
|
|
103
|
-
* downstream applies the package's ordinary bottom default in every case, rotated or not -- a
|
|
104
|
-
* caller-set `margin.bottom` was never touched by this function even before this fix, and still
|
|
105
|
-
* isn't.
|
|
106
|
-
*/
|
|
107
|
-
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
108
|
-
const spacedXAxis = xAxis.map((axis) => {
|
|
109
|
-
const metrics = rotatedTickMetrics(axis, defaultLineHeight);
|
|
110
|
-
if (!metrics)
|
|
111
|
-
return axis;
|
|
112
|
-
if (axis.height != null)
|
|
113
|
-
return axis;
|
|
114
|
-
return Object.assign(Object.assign({}, axis), { height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight });
|
|
115
|
-
});
|
|
116
|
-
return { xAxis: spacedXAxis, margin };
|
|
117
|
-
}
|
|
142
|
+
// CHART-11: small clearance between the rotated text block's own projected extent and the
|
|
143
|
+
// bottom of the axis's reserved band -- the same order of magnitude as the package's own
|
|
144
|
+
// PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
|
|
145
|
+
// as clipped even when nothing is actually cut off).
|
|
146
|
+
const ROTATED_TICK_CLEARANCE_PX = 8;
|
|
118
147
|
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
119
148
|
// 20px (label, if present) = 65px regardless of what the ticks actually say
|
|
120
149
|
// -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
|
|
@@ -242,56 +271,207 @@ export function defaultNumericTickFormatter(locale) {
|
|
|
242
271
|
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
243
272
|
: new Intl.NumberFormat(locale).format(value));
|
|
244
273
|
}
|
|
274
|
+
// UCM-CHART-12: named size tokens, resolved through the theme's 8px spacing unit
|
|
275
|
+
// (`src/theme/tokens.js`'s `spacing: 8`). `standard` is pinned to the pre-existing deployed
|
|
276
|
+
// default (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) so this migration does not also
|
|
277
|
+
// silently redraw every already-shipped default-sized chart; `compact`/`tall` are proportional.
|
|
278
|
+
// Documented plainly in `docs/CHART-LAYOUT.md` -- these are visible product decisions, not
|
|
279
|
+
// internal plumbing.
|
|
280
|
+
export const CHART_SIZE_SPACING_UNITS = { compact: 30, standard: 40, tall: 50 };
|
|
281
|
+
// UCM-CHART-12 Rule 2: every band is its own measured content, or zero -- no band is ever a
|
|
282
|
+
// constant taken on faith. These two ARE constants, but only for the "content is present" case;
|
|
283
|
+
// each one collapses to 0 the moment its content is absent (`resolveXAxisGeometry`,
|
|
284
|
+
// `resolveChartLayout` below).
|
|
285
|
+
//
|
|
286
|
+
// ui_reviewer finding U1: before this WO, a plain (unrotated, untitled) axis got NO explicit
|
|
287
|
+
// `axis.height` at all -- MUI computed its own default internally. This resolver now always sets
|
|
288
|
+
// `axis.height` explicitly (Rule 1: nothing outside the resolver does chart arithmetic), which
|
|
289
|
+
// looked like a silent behaviour change until checked against MUI's own source
|
|
290
|
+
// (`@mui/x-charts/constants`): `DEFAULT_AXIS_SIZE_HEIGHT = 25` and `AXIS_LABEL_DEFAULT_HEIGHT =
|
|
291
|
+
// 20` -- i.e. `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` below are not an approximation, they are
|
|
292
|
+
// MUI's own constants, confirmed pixel-for-pixel. Making them explicit changes nothing about what
|
|
293
|
+
// renders for that common case; `TICK_BAND_BASE_PX + AXIS_TITLE_BAND_PX` = 45 is also exactly the
|
|
294
|
+
// pre-CHART-12 `MUI_LABELLED_X_AXIS_HEIGHT`, decomposed into the two bands Rule 2 requires instead
|
|
295
|
+
// of one opaque "labelled axis height" figure.
|
|
296
|
+
const TICK_BAND_BASE_PX = 25;
|
|
297
|
+
const AXIS_TITLE_BAND_PX = MUI_LABELLED_X_AXIS_HEIGHT - TICK_BAND_BASE_PX;
|
|
298
|
+
// One row of legend chips + gap -- MUI does not reserve this space itself (confirmed: nothing in
|
|
299
|
+
// this package's pre-CHART-12 margin/axis math ever accounted for the legend), so a visible
|
|
300
|
+
// legend growing the SVG uncounted was the sixth defect this WO's Rule 2 also closes.
|
|
301
|
+
const LEGEND_BAND_PX = 32;
|
|
302
|
+
// UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
|
|
303
|
+
// render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
|
|
304
|
+
// pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
|
|
305
|
+
// once there are enough categories AND the longest one is long enough that a horizontal row would
|
|
306
|
+
// plausibly collide. The mandatory browser-level check (WO Part C) is what actually verifies fit
|
|
307
|
+
// against real rendered geometry; this only picks a reasonable default ahead of that.
|
|
308
|
+
const AUTO_ROTATE_TICK_COUNT = 6;
|
|
309
|
+
const AUTO_ROTATE_LABEL_CHARS = 4;
|
|
310
|
+
function xAxisTickContentText(axis) {
|
|
311
|
+
const data = axis === null || axis === void 0 ? void 0 : axis.data;
|
|
312
|
+
if (!Array.isArray(data))
|
|
313
|
+
return null; // no data array -- can't tell (e.g. a numeric scale); assume content
|
|
314
|
+
return longestFormattedTick(data, axis.valueFormatter);
|
|
315
|
+
}
|
|
316
|
+
// The Access-scatter case (WO Part A): an axis whose tick labels are all blank/absent must
|
|
317
|
+
// collapse its reserved band to zero, not keep the package's floor. `null` (no data array at
|
|
318
|
+
// all -- a linear numeric axis, whose ticks come from the scale, not a caller-supplied list)
|
|
319
|
+
// is treated as "assume content", since there is nothing here to measure as empty.
|
|
320
|
+
function xAxisHasVisibleTicks(axis) {
|
|
321
|
+
const text = xAxisTickContentText(axis);
|
|
322
|
+
return text === null || text.length > 0;
|
|
323
|
+
}
|
|
245
324
|
/**
|
|
246
|
-
* CHART-
|
|
247
|
-
*
|
|
248
|
-
* `
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
|
|
253
|
-
* derives its height from its width, and `minHeight` stays exactly what its name says, a floor
|
|
254
|
-
* stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
|
|
255
|
-
* that would remove the responsive behaviour four live call sites rely on.
|
|
256
|
-
* - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
|
|
257
|
-
* information to go on, and a caller in this shape meant the chart to have that height, not
|
|
258
|
-
* merely a wrapper floor around an unsized chart.
|
|
325
|
+
* UCM-CHART-12: decides the x-axis tick band's height and rotation angle. Replaces CHART-10/11's
|
|
326
|
+
* `spaceForRotatedTicks`/`rotatedTickMetrics` -- same measured-text-width mechanism
|
|
327
|
+
* (`measureTickTextWidthPx`, with `estimateTextWidthPx` as the no-DOM fallback and
|
|
328
|
+
* `measureTextWidth` as the test injection point), but now driven by `xLabels`
|
|
329
|
+
* ("auto"|"horizontal"|"angled") instead of a caller-supplied `tickLabelStyle.angle`, and
|
|
330
|
+
* returning 0 outright when the axis has no visible tick content (Rule 2).
|
|
259
331
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return {
|
|
332
|
+
function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextWidth, fallbackTickFontSize) {
|
|
333
|
+
var _a, _b, _c;
|
|
334
|
+
if (!xAxisHasVisibleTicks(axis))
|
|
335
|
+
return { tickBand: 0, angle: 0 };
|
|
336
|
+
const fontSize = cssLengthToPixels((_a = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.fontSize) || cssLengthToPixels(fallbackTickFontSize);
|
|
337
|
+
const longestLabel = xAxisTickContentText(axis) || '';
|
|
338
|
+
const dataCount = Array.isArray(axis === null || axis === void 0 ? void 0 : axis.data) ? axis.data.length : 0;
|
|
339
|
+
let angle = 0;
|
|
340
|
+
if (xLabelsMode === 'angled') {
|
|
341
|
+
angle = Number((_b = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _b === void 0 ? void 0 : _b.angle) || -45;
|
|
264
342
|
}
|
|
265
|
-
if (
|
|
266
|
-
|
|
343
|
+
else if (xLabelsMode === 'auto') {
|
|
344
|
+
const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && longestLabel.length > AUTO_ROTATE_LABEL_CHARS;
|
|
345
|
+
angle = needsRotation ? -45 : 0;
|
|
346
|
+
} // "horizontal" always stays at angle 0
|
|
347
|
+
if (angle === 0)
|
|
348
|
+
return { tickBand: TICK_BAND_BASE_PX, angle: 0 };
|
|
349
|
+
// Rotation is a visual instruction independent of whether a font size is knowable here -- an
|
|
350
|
+
// "angled" axis still rotates even when `fontSize` can't be resolved (no theme fallback given),
|
|
351
|
+
// it just can't project a tighter band than the floor without one.
|
|
352
|
+
if (fontSize === 0)
|
|
353
|
+
return { tickBand: TICK_BAND_BASE_PX, angle };
|
|
354
|
+
const lineHeight = Number((_c = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
|
|
355
|
+
const measure = measureTextWidth || measureTickTextWidthPx;
|
|
356
|
+
const measuredWidth = measure(longestLabel, fontSize);
|
|
357
|
+
const textWidth = Math.max(fontSize, Number.isFinite(measuredWidth) && measuredWidth > 0
|
|
358
|
+
? measuredWidth
|
|
359
|
+
: estimateTextWidthPx(longestLabel, fontSize));
|
|
360
|
+
const radians = (angle * Math.PI) / 180;
|
|
361
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
362
|
+
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
363
|
+
const tickBand = Math.max(TICK_BAND_BASE_PX, Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX);
|
|
364
|
+
return { tickBand, angle };
|
|
365
|
+
}
|
|
366
|
+
function resolveChartHeightPx(size, height, spacing) {
|
|
367
|
+
if (height != null)
|
|
368
|
+
return height;
|
|
369
|
+
const units = CHART_SIZE_SPACING_UNITS[size];
|
|
370
|
+
if (units == null) {
|
|
371
|
+
throw new Error(`[ui-core-micha] Unknown chart size="${size}". Use "compact" | "standard" | "tall", `
|
|
372
|
+
+ 'or pass height (in px) for the documented escape.');
|
|
267
373
|
}
|
|
268
|
-
|
|
374
|
+
// MUI's `theme.spacing(n)` returns a CSS length STRING ("240px"), not a number -- the chart
|
|
375
|
+
// height feeds straight into MUI X-Charts' own numeric `height` prop, so it must be unwrapped
|
|
376
|
+
// back to a px number via the same `cssLengthToPixels` the rest of this file already uses.
|
|
377
|
+
return typeof spacing === 'function' ? cssLengthToPixels(spacing(units)) : units * 8;
|
|
269
378
|
}
|
|
270
379
|
/**
|
|
271
|
-
* CHART-
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
380
|
+
* UCM-CHART-12: the one function that owns a chart's geometry (Rule 1-3), replacing
|
|
381
|
+
* `resolveChartHeight` + `spaceForRotatedTicks` + `warnOnHeightMismatch`. Returns the COMPLETE
|
|
382
|
+
* geometry a preset needs -- wrapper `sx`, chart height, margins, and both resolved axis arrays --
|
|
383
|
+
* plus the named `bands` the composition invariant is asserted against:
|
|
384
|
+
*
|
|
385
|
+
* chartHeight === bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand
|
|
386
|
+
* containerWidth === bands.yAxisBand + bands.plotWidth + bands.rightPad (when `containerWidth` is given)
|
|
387
|
+
*
|
|
388
|
+
* `plotHeight`/`plotWidth` are the RESIDUAL of `chartHeight`/`containerWidth` minus the OTHER
|
|
389
|
+
* fields this function itself returns (`margin.top/bottom`, `xAxis[0].height`, `yAxisBand`) --
|
|
390
|
+
* by construction, summing them back up can never fail to balance, so that arithmetic identity
|
|
391
|
+
* ALONE proves nothing about a `UCM-CHART-10`-shaped bug (a term double-counted between
|
|
392
|
+
* `xAxis.height` and `margin.bottom`). What actually catches that class of bug is asserting each
|
|
393
|
+
* individual band lands in exactly one place downstream, independently of the others:
|
|
394
|
+
* `xAxis[0].height === xAxisBand + xTitleBand` (nothing else may also carry the tick/title
|
|
395
|
+
* allowance) and `margin.top + margin.bottom - PACKAGE_DEFAULT_MARGIN.top -
|
|
396
|
+
* PACKAGE_DEFAULT_MARGIN.bottom === legendBand` (the ONLY thing margin grows for, over its own
|
|
397
|
+
* package baseline, is the legend). The test suite asserts both forms side by side.
|
|
275
398
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
399
|
+
* `plotHeight` deliberately does NOT subtract the base `PACKAGE_DEFAULT_MARGIN` breathing room --
|
|
400
|
+
* that margin is intentional spacing around the plot (THEME-11), not one of the three itemized
|
|
401
|
+
* "furniture" bands (tick/title/legend) this WO exists to zero out, so it is treated as part of
|
|
402
|
+
* "the rest of the chart", the same as `plotHeight` itself.
|
|
403
|
+
*
|
|
404
|
+
* `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
|
|
405
|
+
* presets themselves stay responsive (no explicit width) and never pass it.
|
|
281
406
|
*/
|
|
282
|
-
export function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
407
|
+
export function resolveChartLayout({ size = 'standard', height, xAxis, yAxis, series = [], xLabels = 'auto', hideLegend = false, legendPosition = DEFAULT_LEGEND_POSITION, tickFontSize, spacing, defaultLineHeight = 1, measureTextWidth, containerWidth, } = {}) {
|
|
408
|
+
var _a, _b, _c;
|
|
409
|
+
const chartHeight = resolveChartHeightPx(size, height, spacing);
|
|
410
|
+
const xAxisList = (xAxis === null || xAxis === void 0 ? void 0 : xAxis.length) ? xAxis : [{}];
|
|
411
|
+
const primaryXAxis = xAxisList[0];
|
|
412
|
+
const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize);
|
|
413
|
+
const xTitleBand = (primaryXAxis === null || primaryXAxis === void 0 ? void 0 : primaryXAxis.label) ? AXIS_TITLE_BAND_PX : 0;
|
|
414
|
+
// A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
|
|
415
|
+
// finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
|
|
416
|
+
// over-reserving height and under-reserving width for a side-placed legend. This WO's width
|
|
417
|
+
// invariant does not itemize a legend-width band, so a side legend simply gets none of the
|
|
418
|
+
// height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
|
|
419
|
+
// legend is off or horizontal").
|
|
420
|
+
const legendVertical = (_a = legendPosition === null || legendPosition === void 0 ? void 0 : legendPosition.vertical) !== null && _a !== void 0 ? _a : DEFAULT_LEGEND_POSITION.vertical;
|
|
421
|
+
const legendIsSidePlaced = legendVertical === 'middle';
|
|
422
|
+
const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
|
|
423
|
+
const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
|
|
424
|
+
const sizedYAxis = sizeYAxisForContent((yAxis === null || yAxis === void 0 ? void 0 : yAxis.length) ? yAxis : [{}], series, tickFontSize);
|
|
425
|
+
const yAxisBand = (_c = (_b = sizedYAxis[0]) === null || _b === void 0 ? void 0 : _b.width) !== null && _c !== void 0 ? _c : 0;
|
|
426
|
+
const rightPad = PACKAGE_DEFAULT_MARGIN.right;
|
|
427
|
+
const resolvedXAxis = xAxisList.map((axisEntry, index) => {
|
|
428
|
+
if (index !== 0 || axisEntry.height != null)
|
|
429
|
+
return axisEntry;
|
|
430
|
+
// `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
|
|
431
|
+
// explicitly here, even when 0, rather than only overriding a truthy value. Leaving a stale
|
|
432
|
+
// caller-supplied `tickLabelStyle.angle` in place when `xLabels` resolved to no rotation would
|
|
433
|
+
// let MUI still render a rotated label whose band this resolver reserved as if it were flat --
|
|
434
|
+
// exactly the space/render mismatch this WO exists to close, just inverted (under-reservation
|
|
435
|
+
// instead of CHART-10's over-reservation).
|
|
436
|
+
const _a = axisEntry.tickLabelStyle || {}, { angle: _callerAngle } = _a, restTickLabelStyle = __rest(_a, ["angle"]);
|
|
437
|
+
return Object.assign(Object.assign({}, axisEntry), { height: xAxisBand + xTitleBand, tickLabelStyle: Object.assign(Object.assign({}, restTickLabelStyle), { angle }) });
|
|
438
|
+
});
|
|
439
|
+
const margin = {
|
|
440
|
+
top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
|
|
441
|
+
bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
|
|
442
|
+
left: PACKAGE_DEFAULT_MARGIN.left,
|
|
443
|
+
right: rightPad,
|
|
444
|
+
};
|
|
445
|
+
// `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
|
|
446
|
+
// furniture bands (see this function's own docblock for why this alone does not prove the
|
|
447
|
+
// absence of a double-counted term, and what does).
|
|
448
|
+
const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
|
|
449
|
+
const plotWidth = containerWidth != null ? containerWidth - yAxisBand - rightPad : undefined;
|
|
450
|
+
return {
|
|
451
|
+
chartHeight,
|
|
452
|
+
sx: { width: '100%', height: chartHeight },
|
|
453
|
+
margin,
|
|
454
|
+
xAxis: resolvedXAxis,
|
|
455
|
+
yAxis: sizedYAxis,
|
|
456
|
+
bands: Object.assign({ plotHeight,
|
|
457
|
+
xAxisBand,
|
|
458
|
+
xTitleBand, legendBand: legendHeightBand, yAxisBand,
|
|
459
|
+
rightPad }, (containerWidth != null ? { plotWidth, containerWidth } : {})),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* UCM-CHART-12 Rule 1/3: `minHeight`, `aspect`, and `margin` are gone from the four chart presets
|
|
464
|
+
* -- the model owns sizing and spacing completely. Passing one is a dev-mode error naming its
|
|
465
|
+
* replacement (never a silent ignore, so the 38 existing call sites this breaks get a clear
|
|
466
|
+
* migration path instead of a layout that quietly stops matching what the prop says) -- gated on
|
|
467
|
+
* `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
|
|
468
|
+
* cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
|
|
469
|
+
*/
|
|
470
|
+
export function assertRemovedChartProp(componentName, propName, value, replacement) {
|
|
471
|
+
if (value === undefined || process.env.NODE_ENV === 'production')
|
|
286
472
|
return;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
+ 'chart. Pass only height, or drop minHeight if it should just track height.'
|
|
290
|
-
: 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
|
|
291
|
-
+ 'Pass only minHeight, or drop height if it was meant to size the whole card.';
|
|
292
|
-
// eslint-disable-next-line no-console
|
|
293
|
-
console.warn(`[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
|
|
294
|
-
+ resolution);
|
|
473
|
+
throw new Error(`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
|
|
474
|
+
+ `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`);
|
|
295
475
|
}
|
|
296
476
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
297
477
|
var _a;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Chart layout model (UCM-CHART-12)
|
|
2
|
+
|
|
3
|
+
One function, `resolveChartLayout` (`src/components/charts/chartDefaults.js`), owns the geometry
|
|
4
|
+
of every chart preset (`BarChart`, `LineChart`, `ScatterChart`, and indirectly `TimeSeriesChart`,
|
|
5
|
+
which wraps `BarChart`). It replaces `resolveChartHeight`, `spaceForRotatedTicks`, and
|
|
6
|
+
`warnOnHeightMismatch` — all three deleted in this version. This is a **breaking major (`3.0.0`)**.
|
|
7
|
+
|
|
8
|
+
## The model
|
|
9
|
+
|
|
10
|
+
Every reserved band is derived from the content it holds and collapses to zero when that content
|
|
11
|
+
is empty. Consumers express intent, never pixels.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
chartHeight === plotHeight + xAxisBand + xTitleBand + legendBand
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `xAxisBand` — the tick row. Zero when the axis has no visible tick content (an empty `data`
|
|
18
|
+
array, or every formatted tick is a blank string). Non-zero otherwise: a flat baseline for
|
|
19
|
+
horizontal labels, or a measured/estimated projection for rotated ones (same measurement
|
|
20
|
+
mechanism as before — a real DOM measurement when available, a per-glyph estimate otherwise).
|
|
21
|
+
The flat baseline (25px) and the title addition (20px) are MUI X-Charts' own
|
|
22
|
+
`DEFAULT_AXIS_SIZE_HEIGHT`/`AXIS_LABEL_DEFAULT_HEIGHT` constants, not a re-estimate — the plain
|
|
23
|
+
unrotated/untitled case renders pixel-identical to before this WO, even though the resolver now
|
|
24
|
+
sets `axis.height` explicitly where MUI used to fill it in implicitly.
|
|
25
|
+
- `xTitleBand` — the axis title. Zero unless the x-axis carries a `label`.
|
|
26
|
+
- `legendBand` — one row for the legend. Zero when `hideLegend` is set.
|
|
27
|
+
- `plotHeight` is the residual: `chartHeight` minus the three bands above.
|
|
28
|
+
|
|
29
|
+
Width follows the same shape (`yAxisBand + plotWidth + rightPad`), but the presets stay
|
|
30
|
+
responsive — they never fix a pixel width — so `plotWidth`/`containerWidth` are only meaningful
|
|
31
|
+
when a caller explicitly supplies `containerWidth` (tests, or the browser-level check); the
|
|
32
|
+
presets themselves never pass it.
|
|
33
|
+
|
|
34
|
+
## Removed props, and their replacement
|
|
35
|
+
|
|
36
|
+
| Removed prop | Replacement | Why |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `minHeight` | `size` (see below) | A floor was a *card* concern, not a chart one — `ChartFrame` still has its own `minHeight`, unchanged. |
|
|
39
|
+
| `aspect` | `size` | One size source, system-wide (operator decision). A chart's height no longer tracks its width. |
|
|
40
|
+
| `margin` | *(none)* | The model owns margins completely — spacing between a chart and its neighbours is the card's job. |
|
|
41
|
+
| `xAxisAngle` / a caller-set `tickLabelStyle.angle` | `xLabels: "auto" \| "horizontal" \| "angled"` | Under `"auto"` the model decides from the tick count and label length whether rotation is needed — a consumer no longer states a rotation and separately pays for its geometry. |
|
|
42
|
+
|
|
43
|
+
Passing a removed prop throws in development (`assertRemovedChartProp`), naming the replacement.
|
|
44
|
+
It is inert in production — a shared package must not crash a consumer's page over a layout prop.
|
|
45
|
+
This is deliberate, not a silent ignore: every call site that still passes `minHeight`/`aspect`/
|
|
46
|
+
`margin` will see it immediately in dev.
|
|
47
|
+
|
|
48
|
+
## `size` — before / after
|
|
49
|
+
|
|
50
|
+
```diff
|
|
51
|
+
- <BarChart minHeight={320} series={series} />
|
|
52
|
+
+ <BarChart size="standard" series={series} />
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```diff
|
|
56
|
+
- <BarChart aspect={1.8} minHeight={280} series={series} />
|
|
57
|
+
+ <BarChart size="tall" series={series} />
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Tokens resolve through the theme's spacing scale (`theme.spacing`, MUI's 8px unit):
|
|
61
|
+
|
|
62
|
+
| Token | Spacing units | Height |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `compact` | 30 | 240px |
|
|
65
|
+
| `standard` | 40 | 320px |
|
|
66
|
+
| `tall` | 50 | 400px |
|
|
67
|
+
|
|
68
|
+
`standard` is pinned to the exact number every current consumer already renders at today
|
|
69
|
+
(`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) — this migration does not silently redraw an
|
|
70
|
+
existing default-sized chart. `height` (a raw px number) survives as a **documented escape** for a
|
|
71
|
+
justified special case; prefer `size`.
|
|
72
|
+
|
|
73
|
+
**Four call sites lose responsive height with `aspect` gone**: `hram/AccessGapScatterPanel.jsx`
|
|
74
|
+
and three in `fitness-monitor/BodyHistoryPage.jsx` (its `960/380` ratios were a design decision,
|
|
75
|
+
not an accident — operator accepted this trade 2026-08-22). Each converts to a size token as a
|
|
76
|
+
named, visible decision in its own migration WO — charts still reflow horizontally (`width: 100%`
|
|
77
|
+
on the wrapper), only the height stops tracking width.
|
|
78
|
+
|
|
79
|
+
## `xLabels` — before / after
|
|
80
|
+
|
|
81
|
+
```diff
|
|
82
|
+
- <BarChart xAxis={[{ data: wardNames, tickLabelStyle: { angle: -45 } }]} />
|
|
83
|
+
+ <BarChart xAxis={[{ data: wardNames }]} xLabels="angled" />
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```diff
|
|
87
|
+
- <BarChart xAxis={[{ data: months }]} /> {/* horizontal by default, unrotated */}
|
|
88
|
+
+ <BarChart xAxis={[{ data: months }]} xLabels="auto" /> {/* the new default; equivalent here */}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`"auto"` (the default) is a **heuristic**, not a true fit computation — the presets stay
|
|
92
|
+
responsive, so the resolver has no real container width to measure against before render. It
|
|
93
|
+
rotates when there are more than 6 categories *and* the longest formatted label is longer than 4
|
|
94
|
+
characters; otherwise it stays horizontal. `"horizontal"` always stays flat; `"angled"` always
|
|
95
|
+
rotates (at the caller's own `tickLabelStyle.angle`, or -45° if unset). Verify a specific chart's
|
|
96
|
+
real rendered fit with the browser-level check described below — a unit test cannot see real
|
|
97
|
+
container width or real font metrics either.
|
|
98
|
+
|
|
99
|
+
## `margin` — no replacement
|
|
100
|
+
|
|
101
|
+
There is no margin prop any more. Spacing between a chart card and its neighbours is the card's
|
|
102
|
+
job (a wrapping `Box`/`Stack`'s own `gap`/`p`), not the chart's.
|
|
103
|
+
|
|
104
|
+
```diff
|
|
105
|
+
- <Chart series={series} margin={{ left: 220 }} /> {/* hand-tuned gutter for a long label */}
|
|
106
|
+
+ <Box sx={{ pl: '220px' }}>
|
|
107
|
+
+ <Chart series={series} />
|
|
108
|
+
+ </Box>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If the extra space was compensating for a wide y-axis label the resolver now sizes itself
|
|
112
|
+
(`sizeYAxisForContent`), try dropping the override entirely first — the model may already reserve
|
|
113
|
+
the right amount.
|
|
114
|
+
|
|
115
|
+
## What stays unchanged
|
|
116
|
+
|
|
117
|
+
- `ChartFrame`'s own `minHeight`/`height`/`aspect` — it is a whole-card floor (title, toolbar,
|
|
118
|
+
chart, legend, footnotes, export links), not a single chart's size, and was never in scope here
|
|
119
|
+
(`UCM-CHART-9` established this distinction).
|
|
120
|
+
- Palettes, series colours, tooltips, legend content, interaction.
|
|
121
|
+
- `sizeYAxisForContent` (y-axis width from tick content) — reused unchanged inside the resolver.
|
|
122
|
+
|
|
123
|
+
## Verifying a migrated chart
|
|
124
|
+
|
|
125
|
+
The invariant is arithmetic, but only against **real rendered geometry** — jsdom cannot measure
|
|
126
|
+
SVG text (`getComputedTextLength` is unimplemented there), so the unit suite injects a stubbed
|
|
127
|
+
measurement. Before shipping a chart that leans on rotated or auto-detected labels, render it in
|
|
128
|
+
a real browser at a realistic width and confirm no tick label is clipped and no band is reserved
|
|
129
|
+
with nothing in it (the Access-scatter defect this WO exists to close).
|