@micha.bigler/ui-core-micha 2.42.3 → 3.0.1

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.
@@ -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',
@@ -121,97 +132,18 @@ function measureTickTextWidthPx(text, fontSizePx) {
121
132
  // less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
122
133
  // overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
123
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.
124
139
  export const PACKAGE_DEFAULT_MARGIN = {
125
140
  top: 10, bottom: 8, left: 8, right: 16,
126
141
  };
127
- /**
128
- * Applies the package's own margin default, per side -- a caller's own value on any side always
129
- * wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
130
- * therefore keeps that 60 on the left and gets the package defaults on the other three sides,
131
- * never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
132
- * rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
133
- */
134
- export function withMarginDefaults(margin) {
135
- const explicit = typeof margin === 'number'
136
- ? { top: margin, bottom: margin, left: margin, right: margin }
137
- : margin;
138
- return Object.assign(Object.assign({}, PACKAGE_DEFAULT_MARGIN), explicit);
139
- }
140
142
  // CHART-11: small clearance between the rotated text block's own projected extent and the
141
143
  // bottom of the axis's reserved band -- the same order of magnitude as the package's own
142
144
  // PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
143
145
  // as clipped even when nothing is actually cut off).
144
146
  const ROTATED_TICK_CLEARANCE_PX = 8;
145
- /**
146
- * CHART-11: `measureTextWidth` is injectable (defaults to the real `measureTickTextWidthPx`) so
147
- * tests can stub it for a deterministic result instead of depending on jsdom's SVG text layout
148
- * (unimplemented) or a real browser's font rendering.
149
- *
150
- * CHART-11 also corrects how the projection combines with `MUI_LABELLED_X_AXIS_HEIGHT`. The old
151
- * formula (`extraHeight = projectedHeight - fontSize`, added unconditionally on top of the full
152
- * 45px baseline) double-counts: the baseline already budgets tick+gap+one-line-of-text for the
153
- * NORMAL case, and adding the rotated projection's own full height on top of it, minus only a
154
- * bare `fontSize`, keeps far more of that baseline than a rotated label actually needs from it.
155
- * Measured live against hram real data: with an accurate measured `textWidth`, the OLD combine
156
- * step alone still left ~38px unused (axis.height 118 vs a real rendered tick-group height of
157
- * ~79px) -- the width term was not the only source of over-reservation. The axis height is now
158
- * the larger of the un-rotated baseline (a floor -- a barely-rotated label still gets the normal
159
- * allowance) and the rotated projection itself plus a small clearance -- verified live to close
160
- * the gap to single digits with no label clipping at 1280px or 375px (register note, CHART-11).
161
- */
162
- function rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth = measureTickTextWidthPx) {
163
- var _a, _b, _c;
164
- const angle = Number((_a = axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.angle) || 0;
165
- if (angle === 0)
166
- return null;
167
- const fontSize = cssLengthToPixels((_b = axis.tickLabelStyle) === null || _b === void 0 ? void 0 : _b.fontSize);
168
- if (fontSize === 0)
169
- return null;
170
- const lineHeight = Number((_c = axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
171
- const longestLabel = longestFormattedTick(axis.data || [], axis.valueFormatter);
172
- const measuredWidth = measureTextWidth(longestLabel, fontSize);
173
- const textWidth = Math.max(fontSize, Number.isFinite(measuredWidth) && measuredWidth > 0
174
- ? measuredWidth
175
- : estimateTextWidthPx(longestLabel, fontSize));
176
- const radians = (angle * Math.PI) / 180;
177
- const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
178
- + Math.abs(Math.sin(radians)) * textWidth;
179
- const neededAxisHeight = Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX;
180
- // `max(0, …)`, not `max(1, …)`: axis.height (= MUI_LABELLED_X_AXIS_HEIGHT + extraHeight) must be
181
- // able to land exactly ON the baseline (45) when a barely-rotated label needs less than it --
182
- // a leftover `max(1, …)` here would silently make 46 the true floor while every doc comment and
183
- // test says 45 (caught in review: harmless in direction, but the two must actually agree).
184
- const extraHeight = Math.max(0, neededAxisHeight - MUI_LABELLED_X_AXIS_HEIGHT);
185
- return { extraHeight };
186
- }
187
- /**
188
- * CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
189
- * uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
190
- * `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
191
- * label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
192
- * allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
193
- * `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
194
- * to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
195
- * `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
196
- * downstream applies the package's ordinary bottom default in every case, rotated or not -- a
197
- * caller-set `margin.bottom` was never touched by this function even before this fix, and still
198
- * isn't.
199
- *
200
- * CHART-11: `measureTextWidth` is an optional injection point (defaults to a real DOM
201
- * measurement) that only exists for deterministic tests -- neither preset caller passes it, so
202
- * neither needs to change.
203
- */
204
- export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1, measureTextWidth) {
205
- const spacedXAxis = xAxis.map((axis) => {
206
- const metrics = rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth);
207
- if (!metrics)
208
- return axis;
209
- if (axis.height != null)
210
- return axis;
211
- return Object.assign(Object.assign({}, axis), { height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight });
212
- });
213
- return { xAxis: spacedXAxis, margin };
214
- }
215
147
  // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
216
148
  // 20px (label, if present) = 65px regardless of what the ticks actually say
217
149
  // -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
@@ -311,7 +243,16 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
311
243
  if (candidates.length === 0)
312
244
  return axis;
313
245
  const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
314
- const tickTextWidth = Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
246
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
247
+ // `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
248
+ // of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
249
+ // from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
250
+ // untouched): here there WAS content, it just renders as nothing. With no axis title either,
251
+ // the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
252
+ // text-width term floored, matching the x-axis band's own all-or-nothing zero.
253
+ if (longestTick.length === 0 && !axis.label)
254
+ return Object.assign(Object.assign({}, axis), { width: 0 });
255
+ const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
315
256
  const labelThickness = axis.label
316
257
  ? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
317
258
  : 0;
@@ -339,59 +280,254 @@ export function defaultNumericTickFormatter(locale) {
339
280
  ? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
340
281
  : new Intl.NumberFormat(locale).format(value));
341
282
  }
283
+ // UCM-CHART-12: named size tokens, resolved through the theme's 8px spacing unit
284
+ // (`src/theme/tokens.js`'s `spacing: 8`). `standard` is pinned to the pre-existing deployed
285
+ // default (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) so this migration does not also
286
+ // silently redraw every already-shipped default-sized chart; `compact`/`tall` are proportional.
287
+ // Documented plainly in `docs/CHART-LAYOUT.md` -- these are visible product decisions, not
288
+ // internal plumbing.
289
+ export const CHART_SIZE_SPACING_UNITS = { compact: 30, standard: 40, tall: 50 };
290
+ // UCM-CHART-12 Rule 2: every band is its own measured content, or zero -- no band is ever a
291
+ // constant taken on faith. These two ARE constants, but only for the "content is present" case;
292
+ // each one collapses to 0 the moment its content is absent (`resolveXAxisGeometry`,
293
+ // `resolveChartLayout` below).
294
+ //
295
+ // ui_reviewer finding U1: before this WO, a plain (unrotated, untitled) axis got NO explicit
296
+ // `axis.height` at all -- MUI computed its own default internally. This resolver now always sets
297
+ // `axis.height` explicitly (Rule 1: nothing outside the resolver does chart arithmetic), which
298
+ // looked like a silent behaviour change until checked against MUI's own source
299
+ // (`@mui/x-charts/constants`): `DEFAULT_AXIS_SIZE_HEIGHT = 25` and `AXIS_LABEL_DEFAULT_HEIGHT =
300
+ // 20` -- i.e. `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` below are not an approximation, they are
301
+ // MUI's own constants, confirmed pixel-for-pixel. Making them explicit changes nothing about what
302
+ // renders for that common case; `TICK_BAND_BASE_PX + AXIS_TITLE_BAND_PX` = 45 is also exactly the
303
+ // pre-CHART-12 `MUI_LABELLED_X_AXIS_HEIGHT`, decomposed into the two bands Rule 2 requires instead
304
+ // of one opaque "labelled axis height" figure.
305
+ const TICK_BAND_BASE_PX = 25;
306
+ const AXIS_TITLE_BAND_PX = MUI_LABELLED_X_AXIS_HEIGHT - TICK_BAND_BASE_PX;
307
+ // One row of legend chips + gap -- MUI does not reserve this space itself (confirmed: nothing in
308
+ // this package's pre-CHART-12 margin/axis math ever accounted for the legend), so a visible
309
+ // legend growing the SVG uncounted was the sixth defect this WO's Rule 2 also closes.
310
+ const LEGEND_BAND_PX = 32;
311
+ // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
312
+ // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
313
+ // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
314
+ // once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
315
+ // projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
316
+ // wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
317
+ // the first cut of this heuristic used a raw character-count threshold instead of the measured
318
+ // width this function already has available -- replaced, since "decide from the measured labels"
319
+ // (Rule 3) is achievable even without a real container width to compare against. The count
320
+ // threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
321
+ // spread across a wide chart, so width alone should not trigger rotation either.
322
+ const AUTO_ROTATE_TICK_COUNT = 6;
323
+ const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
324
+ function xAxisTickContentText(axis) {
325
+ const data = axis === null || axis === void 0 ? void 0 : axis.data;
326
+ if (!Array.isArray(data))
327
+ return null; // no data array -- can't tell (e.g. a numeric scale); assume content
328
+ return longestFormattedTick(data, axis.valueFormatter);
329
+ }
330
+ // The Access-scatter case (WO Part A): an axis whose tick labels are all blank/absent must
331
+ // collapse its reserved band to zero, not keep the package's floor. `null` (no data array at
332
+ // all -- a linear numeric axis, whose ticks come from the scale, not a caller-supplied list)
333
+ // is treated as "assume content", since there is nothing here to measure as empty.
334
+ function xAxisHasVisibleTicks(axis) {
335
+ const text = xAxisTickContentText(axis);
336
+ return text === null || text.length > 0;
337
+ }
342
338
  /**
343
- * CHART-8: resolves the `{minHeight, height, aspect}` trio these presets accept into what the
344
- * wrapper reserves and what height (if any) sizes the chart itself. Three-way, not two-way --
345
- * `aspect` changes the answer:
346
- * - `height` set -> it sizes the chart; the wrapper never reserves more than that (a caller-set
347
- * `minHeight` larger than `height` is capped here, closing the dead-space gap this WO exists
348
- * to fix -- equal values, or no `minHeight` at all, pass through unchanged).
349
- * - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
350
- * derives its height from its width, and `minHeight` stays exactly what its name says, a floor
351
- * stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
352
- * that would remove the responsive behaviour four live call sites rely on.
353
- * - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
354
- * information to go on, and a caller in this shape meant the chart to have that height, not
355
- * merely a wrapper floor around an unsized chart.
339
+ * UCM-CHART-12: decides the x-axis tick band's height and rotation angle. Replaces CHART-10/11's
340
+ * `spaceForRotatedTicks`/`rotatedTickMetrics` -- same measured-text-width mechanism
341
+ * (`measureTickTextWidthPx`, with `estimateTextWidthPx` as the no-DOM fallback and
342
+ * `measureTextWidth` as the test injection point), but now driven by `xLabels`
343
+ * ("auto"|"horizontal"|"angled") instead of a caller-supplied `tickLabelStyle.angle`, and
344
+ * returning 0 outright when the axis has no visible tick content (Rule 2).
356
345
  */
357
- export function resolveChartHeight({ minHeight, height, aspect }) {
358
- if (height != null) {
359
- const wrapperMinHeight = minHeight != null && minHeight > height ? height : minHeight;
360
- return { wrapperMinHeight, chartHeight: height };
346
+ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextWidth, fallbackTickFontSize) {
347
+ var _a, _b, _c;
348
+ if (!xAxisHasVisibleTicks(axis))
349
+ return { tickBand: 0, angle: 0 };
350
+ const fontSize = cssLengthToPixels((_a = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.fontSize) || cssLengthToPixels(fallbackTickFontSize);
351
+ const longestLabel = xAxisTickContentText(axis) || '';
352
+ const dataCount = Array.isArray(axis === null || axis === void 0 ? void 0 : axis.data) ? axis.data.length : 0;
353
+ const measure = measureTextWidth || measureTickTextWidthPx;
354
+ // Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
355
+ // below need the label's real width, and must agree on what "real" means (same measurement,
356
+ // same estimate fallback) or the two could disagree about the same label.
357
+ const labelWidthPx = fontSize > 0
358
+ ? Math.max(fontSize, (() => {
359
+ const measured = measure(longestLabel, fontSize);
360
+ return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
361
+ })())
362
+ : 0;
363
+ let angle = 0;
364
+ if (xLabelsMode === 'angled') {
365
+ angle = Number((_b = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _b === void 0 ? void 0 : _b.angle) || -45;
361
366
  }
362
- if (aspect != null) {
363
- return { wrapperMinHeight: minHeight, chartHeight: undefined };
367
+ else if (xLabelsMode === 'auto') {
368
+ const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
369
+ angle = needsRotation ? -45 : 0;
370
+ } // "horizontal" always stays at angle 0
371
+ if (angle === 0)
372
+ return { tickBand: TICK_BAND_BASE_PX, angle: 0 };
373
+ // Rotation is a visual instruction independent of whether a font size is knowable here -- an
374
+ // "angled" axis still rotates even when `fontSize` can't be resolved (no theme fallback given),
375
+ // it just can't project a tighter band than the floor without one.
376
+ if (fontSize === 0)
377
+ return { tickBand: TICK_BAND_BASE_PX, angle };
378
+ const lineHeight = Number((_c = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
379
+ const textWidth = labelWidthPx;
380
+ const radians = (angle * Math.PI) / 180;
381
+ const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
382
+ + Math.abs(Math.sin(radians)) * textWidth;
383
+ const tickBand = Math.max(TICK_BAND_BASE_PX, Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX);
384
+ return { tickBand, angle };
385
+ }
386
+ function resolveChartHeightPx(size, height, spacing) {
387
+ if (height != null)
388
+ return height;
389
+ const units = CHART_SIZE_SPACING_UNITS[size];
390
+ if (units == null) {
391
+ throw new Error(`[ui-core-micha] Unknown chart size="${size}". Use "compact" | "standard" | "tall", `
392
+ + 'or pass height (in px) for the documented escape.');
364
393
  }
365
- return { wrapperMinHeight: minHeight, chartHeight: minHeight };
394
+ // MUI's `theme.spacing(n)` returns a CSS length STRING ("240px"), not a number -- the chart
395
+ // height feeds straight into MUI X-Charts' own numeric `height` prop, so it must be unwrapped
396
+ // back to a px number via the same `cssLengthToPixels` the rest of this file already uses.
397
+ return typeof spacing === 'function' ? cssLengthToPixels(spacing(units)) : units * 8;
366
398
  }
367
399
  /**
368
- * CHART-8: dev-only warning when a caller passes both `minHeight` and `height` and they disagree
369
- * -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
370
- * combination in production would break a consumer's page over a layout nit. Silent on the
371
- * equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
400
+ * UCM-CHART-12: the one function that owns a chart's geometry (Rule 1-3), replacing
401
+ * `resolveChartHeight` + `spaceForRotatedTicks` + `warnOnHeightMismatch`. Returns the COMPLETE
402
+ * geometry a preset needs -- wrapper `sx`, chart height, margins, and both resolved axis arrays --
403
+ * plus the named `bands` the composition invariant is asserted against:
404
+ *
405
+ * chartHeight === bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand
406
+ * containerWidth === bands.yAxisBand + bands.plotWidth + bands.rightPad (when `containerWidth` is given)
407
+ *
408
+ * `plotHeight`/`plotWidth` are the RESIDUAL of `chartHeight`/`containerWidth` minus the OTHER
409
+ * fields this function itself returns (`margin.top/bottom`, `xAxis[0].height`, `yAxisBand`) --
410
+ * by construction, summing them back up can never fail to balance, so that arithmetic identity
411
+ * ALONE proves nothing about a `UCM-CHART-10`-shaped bug (a term double-counted between
412
+ * `xAxis.height` and `margin.bottom`). What actually catches that class of bug is asserting each
413
+ * individual band lands in exactly one place downstream, independently of the others:
414
+ * `xAxis[0].height === xAxisBand + xTitleBand` (nothing else may also carry the tick/title
415
+ * allowance) and `margin.top + margin.bottom - PACKAGE_DEFAULT_MARGIN.top -
416
+ * PACKAGE_DEFAULT_MARGIN.bottom === legendBand` (the ONLY thing margin grows for, over its own
417
+ * package baseline, is the legend). The test suite asserts both forms side by side.
418
+ *
419
+ * `plotHeight` deliberately does NOT subtract the base `PACKAGE_DEFAULT_MARGIN` breathing room --
420
+ * that margin is intentional spacing around the plot (THEME-11), not one of the three itemized
421
+ * "furniture" bands (tick/title/legend) this WO exists to zero out, so it is treated as part of
422
+ * "the rest of the chart", the same as `plotHeight` itself.
372
423
  *
373
- * CHART-9: `heightWins` distinguishes the two shapes this fires from. The four chart presets
374
- * (default, `heightWins: true`) DO apply `resolveChartHeight`'s `height` to their box, so the
375
- * original "height wins" wording is accurate there. `ChartFrame` (`heightWins: false`) never
376
- * applies `height` to its box at all -- `minHeight` always keeps reserving the floor -- so it gets
377
- * its own accurate wording instead of reusing the presets' story.
424
+ * `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
425
+ * presets themselves stay responsive (no explicit width) and never pass it.
378
426
  */
379
- export function warnOnHeightMismatch(componentName, { minHeight, height }, { heightWins = true } = {}) {
380
- if (process.env.NODE_ENV === 'production')
381
- return;
382
- if (minHeight == null || height == null || minHeight === height)
427
+ export function resolveChartLayout({ size = 'standard', height, xAxis, yAxis, series = [], xLabels = 'auto', hideLegend = false, legendPosition = DEFAULT_LEGEND_POSITION, slotProps, tickFontSize, spacing, defaultLineHeight = 1, measureTextWidth, containerWidth, } = {}) {
428
+ var _a, _b, _c;
429
+ const chartHeight = resolveChartHeightPx(size, height, spacing);
430
+ const xAxisList = (xAxis === null || xAxis === void 0 ? void 0 : xAxis.length) ? xAxis : [{}];
431
+ const primaryXAxis = xAxisList[0];
432
+ const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize);
433
+ const xTitleBand = (primaryXAxis === null || primaryXAxis === void 0 ? void 0 : primaryXAxis.label) ? AXIS_TITLE_BAND_PX : 0;
434
+ // Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
435
+ // `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
436
+ // margin/height this function reserved could disagree with where the legend actually rendered.
437
+ // `resolveLegendPosition` is the one merge both call sites now share.
438
+ const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
439
+ // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
440
+ // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
441
+ // over-reserving height and under-reserving width for a side-placed legend. This WO's width
442
+ // invariant does not itemize a legend-width band, so a side legend simply gets none of the
443
+ // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
444
+ // legend is off or horizontal").
445
+ const legendVertical = effectiveLegendPosition.vertical;
446
+ const legendIsSidePlaced = legendVertical === 'middle';
447
+ const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
448
+ const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
449
+ const sizedYAxis = sizeYAxisForContent((yAxis === null || yAxis === void 0 ? void 0 : yAxis.length) ? yAxis : [{}], series, tickFontSize);
450
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
451
+ // feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
452
+ // only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
453
+ // (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
454
+ // logic above), so only an explicit `'right'` counts as the secondary axis.
455
+ const primaryYAxis = (_a = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right')) !== null && _a !== void 0 ? _a : sizedYAxis[0];
456
+ const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
457
+ const yAxisBand = (_b = primaryYAxis === null || primaryYAxis === void 0 ? void 0 : primaryYAxis.width) !== null && _b !== void 0 ? _b : 0;
458
+ const secondaryYAxisBand = (_c = secondaryYAxisEntry === null || secondaryYAxisEntry === void 0 ? void 0 : secondaryYAxisEntry.width) !== null && _c !== void 0 ? _c : 0;
459
+ const rightPad = PACKAGE_DEFAULT_MARGIN.right;
460
+ const resolvedXAxis = xAxisList.map((axisEntry, index) => {
461
+ var _a;
462
+ if (index !== 0)
463
+ return axisEntry;
464
+ // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
465
+ // explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
466
+ // whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
467
+ // never its ROTATION -- codex second-pass review finding: the two were previously coupled,
468
+ // so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
469
+ // under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
470
+ // resolved to no rotation would let MUI still render a rotated label whose band this resolver
471
+ // reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
472
+ // just inverted (under-reservation instead of CHART-10's over-reservation).
473
+ const _b = axisEntry.tickLabelStyle || {}, { angle: _callerAngle } = _b, restTickLabelStyle = __rest(_b, ["angle"]);
474
+ return Object.assign(Object.assign({}, axisEntry), { height: (_a = axisEntry.height) !== null && _a !== void 0 ? _a : (xAxisBand + xTitleBand), tickLabelStyle: Object.assign(Object.assign({}, restTickLabelStyle), { angle }) });
475
+ });
476
+ const margin = {
477
+ top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
478
+ bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
479
+ left: PACKAGE_DEFAULT_MARGIN.left,
480
+ right: rightPad + secondaryYAxisBand,
481
+ };
482
+ // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
483
+ // furniture bands (see this function's own docblock for why this alone does not prove the
484
+ // absence of a double-counted term, and what does).
485
+ const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
486
+ const plotWidth = containerWidth != null
487
+ ? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
488
+ : undefined;
489
+ return {
490
+ chartHeight,
491
+ sx: { width: '100%', height: chartHeight },
492
+ margin,
493
+ xAxis: resolvedXAxis,
494
+ yAxis: sizedYAxis,
495
+ bands: Object.assign({ plotHeight,
496
+ xAxisBand,
497
+ secondaryYAxisBand,
498
+ xTitleBand, legendBand: legendHeightBand, yAxisBand,
499
+ rightPad }, (containerWidth != null ? { plotWidth, containerWidth } : {})),
500
+ };
501
+ }
502
+ /**
503
+ * UCM-CHART-12 Rule 1/3: `minHeight`, `aspect`, and `margin` are gone from the four chart presets
504
+ * -- the model owns sizing and spacing completely. Passing one is a dev-mode error naming its
505
+ * replacement (never a silent ignore, so the 38 existing call sites this breaks get a clear
506
+ * migration path instead of a layout that quietly stops matching what the prop says) -- gated on
507
+ * `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
508
+ * cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
509
+ */
510
+ export function assertRemovedChartProp(componentName, propName, value, replacement) {
511
+ if (value === undefined || process.env.NODE_ENV === 'production')
383
512
  return;
384
- const resolution = heightWins
385
- ? 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
386
- + 'chart. Pass only height, or drop minHeight if it should just track height.'
387
- : 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
388
- + 'Pass only minHeight, or drop height if it was meant to size the whole card.';
389
- // eslint-disable-next-line no-console
390
- console.warn(`[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
391
- + resolution);
513
+ throw new Error(`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
514
+ + `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`);
392
515
  }
393
- export function withChartSlotDefaults(slotProps, legendPosition) {
516
+ /**
517
+ * UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
518
+ * legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
519
+ * same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
520
+ * only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
521
+ * while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
522
+ * time -- a caller passing both could get a legend rendered top/side while the resolver had
523
+ * reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
524
+ * close. Both call sites now merge through this one function.
525
+ */
526
+ export function resolveLegendPosition(legendPosition, slotProps) {
394
527
  var _a;
395
- 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);
528
+ return 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);
529
+ }
530
+ export function withChartSlotDefaults(slotProps, legendPosition) {
531
+ const position = resolveLegendPosition(legendPosition, slotProps);
396
532
  return Object.assign(Object.assign({}, slotProps), { tooltip: Object.assign({ trigger: 'axis' }, slotProps === null || slotProps === void 0 ? void 0 : slotProps.tooltip), legend: Object.assign(Object.assign({}, slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend), { position }) });
397
533
  }
@@ -0,0 +1,140 @@
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
+ - The y-axis band collapses to zero the same way the x-axis one does: candidates existed (the
29
+ axis has real `min`/`max` or plotted data) but a caller `valueFormatter` blanks every formatted
30
+ tick, and there is no axis title either — the axis has nothing to show, so it reserves nothing.
31
+ Distinct from "nothing to measure at all" (no `min`/`max`/data), where MUI's own default applies
32
+ untouched.
33
+
34
+ Width follows the same shape (`yAxisBand + plotWidth + secondaryYAxisBand + rightPad`), but the
35
+ presets stay responsive — they never fix a pixel width — so `plotWidth`/`containerWidth` are only
36
+ meaningful when a caller explicitly supplies `containerWidth` (tests, or the browser-level check);
37
+ the presets themselves never pass it. A **secondary (right-positioned) y-axis** — `TimeSeriesChart`'s
38
+ `axis: 'secondary'` feature, or any preset given a two-entry `yAxis` array with one `position:
39
+ 'right'` — reserves its own width on the right, added to `margin.right` alongside the fixed
40
+ breathing-room pad; an axis with no explicit `position` defaults to the left, matching MUI's own
41
+ default-assignment rule.
42
+
43
+ ## Removed props, and their replacement
44
+
45
+ | Removed prop | Replacement | Why |
46
+ |---|---|---|
47
+ | `minHeight` | `size` (see below) | A floor was a *card* concern, not a chart one — `ChartFrame` still has its own `minHeight`, unchanged. |
48
+ | `aspect` | `size` | One size source, system-wide (operator decision). A chart's height no longer tracks its width. |
49
+ | `margin` | *(none)* | The model owns margins completely — spacing between a chart and its neighbours is the card's job. |
50
+ | `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. |
51
+
52
+ Passing a removed prop throws in development (`assertRemovedChartProp`), naming the replacement.
53
+ It is inert in production — a shared package must not crash a consumer's page over a layout prop.
54
+ This is deliberate, not a silent ignore: every call site that still passes `minHeight`/`aspect`/
55
+ `margin` will see it immediately in dev.
56
+
57
+ ## `size` — before / after
58
+
59
+ ```diff
60
+ - <BarChart minHeight={320} series={series} />
61
+ + <BarChart size="standard" series={series} />
62
+ ```
63
+
64
+ ```diff
65
+ - <BarChart aspect={1.8} minHeight={280} series={series} />
66
+ + <BarChart size="tall" series={series} />
67
+ ```
68
+
69
+ Tokens resolve through the theme's spacing scale (`theme.spacing`, MUI's 8px unit):
70
+
71
+ | Token | Spacing units | Height |
72
+ |---|---|---|
73
+ | `compact` | 30 | 240px |
74
+ | `standard` | 40 | 320px |
75
+ | `tall` | 50 | 400px |
76
+
77
+ `standard` is pinned to the exact number every current consumer already renders at today
78
+ (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) — this migration does not silently redraw an
79
+ existing default-sized chart. `height` (a raw px number) survives as a **documented escape** for a
80
+ justified special case; prefer `size`.
81
+
82
+ **Four call sites lose responsive height with `aspect` gone**: `hram/AccessGapScatterPanel.jsx`
83
+ and three in `fitness-monitor/BodyHistoryPage.jsx` (its `960/380` ratios were a design decision,
84
+ not an accident — operator accepted this trade 2026-08-22). Each converts to a size token as a
85
+ named, visible decision in its own migration WO — charts still reflow horizontally (`width: 100%`
86
+ on the wrapper), only the height stops tracking width.
87
+
88
+ ## `xLabels` — before / after
89
+
90
+ ```diff
91
+ - <BarChart xAxis={[{ data: wardNames, tickLabelStyle: { angle: -45 } }]} />
92
+ + <BarChart xAxis={[{ data: wardNames }]} xLabels="angled" />
93
+ ```
94
+
95
+ ```diff
96
+ - <BarChart xAxis={[{ data: months }]} /> {/* horizontal by default, unrotated */}
97
+ + <BarChart xAxis={[{ data: months }]} xLabels="auto" /> {/* the new default; equivalent here */}
98
+ ```
99
+
100
+ `"auto"` (the default) is a **heuristic**, not a true fit computation — the presets stay
101
+ responsive, so the resolver has no real container width to measure against before render. It
102
+ rotates when there are more than 6 categories *and* the longest label's real MEASURED width
103
+ (same DOM measurement the rotated-band projection itself uses — a real browser measurement when
104
+ available, a per-glyph estimate otherwise) exceeds 50px; otherwise it stays horizontal.
105
+ `"horizontal"` always stays flat; `"angled"` always rotates (at the caller's own
106
+ `tickLabelStyle.angle`, or -45° if unset). "Measured labels" (Rule 3) is therefore real — what
107
+ remains unmeasured is "available width", since no container width exists before render. Verify a
108
+ specific chart's real rendered fit with the browser-level check described below.
109
+
110
+ ## `margin` — no replacement
111
+
112
+ There is no margin prop any more. Spacing between a chart card and its neighbours is the card's
113
+ job (a wrapping `Box`/`Stack`'s own `gap`/`p`), not the chart's.
114
+
115
+ ```diff
116
+ - <Chart series={series} margin={{ left: 220 }} /> {/* hand-tuned gutter for a long label */}
117
+ + <Box sx={{ pl: '220px' }}>
118
+ + <Chart series={series} />
119
+ + </Box>
120
+ ```
121
+
122
+ If the extra space was compensating for a wide y-axis label the resolver now sizes itself
123
+ (`sizeYAxisForContent`), try dropping the override entirely first — the model may already reserve
124
+ the right amount.
125
+
126
+ ## What stays unchanged
127
+
128
+ - `ChartFrame`'s own `minHeight`/`height`/`aspect` — it is a whole-card floor (title, toolbar,
129
+ chart, legend, footnotes, export links), not a single chart's size, and was never in scope here
130
+ (`UCM-CHART-9` established this distinction).
131
+ - Palettes, series colours, tooltips, legend content, interaction.
132
+ - `sizeYAxisForContent` (y-axis width from tick content) — reused unchanged inside the resolver.
133
+
134
+ ## Verifying a migrated chart
135
+
136
+ The invariant is arithmetic, but only against **real rendered geometry** — jsdom cannot measure
137
+ SVG text (`getComputedTextLength` is unimplemented there), so the unit suite injects a stubbed
138
+ measurement. Before shipping a chart that leans on rotated or auto-detected labels, render it in
139
+ a real browser at a realistic width and confirm no tick label is clipped and no band is reserved
140
+ with nothing in it (the Access-scatter defect this WO exists to close).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.42.3",
3
+ "version": "3.0.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [