@micha.bigler/ui-core-micha 2.42.3 → 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.
@@ -139,104 +139,20 @@ function measureTickTextWidthPx(text, fontSizePx) {
139
139
  // less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
140
140
  // overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
141
141
  // making this conditional on scale type would be inferred behaviour with no named consumer.
142
+ //
143
+ // UCM-CHART-12 Rule 3: no caller-supplied `margin` exists any more (the model owns spacing
144
+ // completely, `resolveChartLayout` builds the final margin object directly) -- this is now only
145
+ // the base every side starts from before `resolveChartLayout` adds the legend band to one side.
142
146
  export const PACKAGE_DEFAULT_MARGIN = {
143
147
  top: 10, bottom: 8, left: 8, right: 16,
144
148
  };
145
149
 
146
- /**
147
- * Applies the package's own margin default, per side -- a caller's own value on any side always
148
- * wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
149
- * therefore keeps that 60 on the left and gets the package defaults on the other three sides,
150
- * never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
151
- * rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
152
- */
153
- export function withMarginDefaults(margin) {
154
- const explicit = typeof margin === 'number'
155
- ? { top: margin, bottom: margin, left: margin, right: margin }
156
- : margin;
157
- return { ...PACKAGE_DEFAULT_MARGIN, ...explicit };
158
- }
159
-
160
150
  // CHART-11: small clearance between the rotated text block's own projected extent and the
161
151
  // bottom of the axis's reserved band -- the same order of magnitude as the package's own
162
152
  // PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
163
153
  // as clipped even when nothing is actually cut off).
164
154
  const ROTATED_TICK_CLEARANCE_PX = 8;
165
155
 
166
- /**
167
- * CHART-11: `measureTextWidth` is injectable (defaults to the real `measureTickTextWidthPx`) so
168
- * tests can stub it for a deterministic result instead of depending on jsdom's SVG text layout
169
- * (unimplemented) or a real browser's font rendering.
170
- *
171
- * CHART-11 also corrects how the projection combines with `MUI_LABELLED_X_AXIS_HEIGHT`. The old
172
- * formula (`extraHeight = projectedHeight - fontSize`, added unconditionally on top of the full
173
- * 45px baseline) double-counts: the baseline already budgets tick+gap+one-line-of-text for the
174
- * NORMAL case, and adding the rotated projection's own full height on top of it, minus only a
175
- * bare `fontSize`, keeps far more of that baseline than a rotated label actually needs from it.
176
- * Measured live against hram real data: with an accurate measured `textWidth`, the OLD combine
177
- * step alone still left ~38px unused (axis.height 118 vs a real rendered tick-group height of
178
- * ~79px) -- the width term was not the only source of over-reservation. The axis height is now
179
- * the larger of the un-rotated baseline (a floor -- a barely-rotated label still gets the normal
180
- * allowance) and the rotated projection itself plus a small clearance -- verified live to close
181
- * the gap to single digits with no label clipping at 1280px or 375px (register note, CHART-11).
182
- */
183
- function rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth = measureTickTextWidthPx) {
184
- const angle = Number(axis.tickLabelStyle?.angle) || 0;
185
- if (angle === 0) return null;
186
-
187
- const fontSize = cssLengthToPixels(axis.tickLabelStyle?.fontSize);
188
- if (fontSize === 0) return null;
189
-
190
- const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
191
- const longestLabel = longestFormattedTick(axis.data || [], axis.valueFormatter);
192
- const measuredWidth = measureTextWidth(longestLabel, fontSize);
193
- const textWidth = Math.max(
194
- fontSize,
195
- Number.isFinite(measuredWidth) && measuredWidth > 0
196
- ? measuredWidth
197
- : estimateTextWidthPx(longestLabel, fontSize),
198
- );
199
- const radians = (angle * Math.PI) / 180;
200
- const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
201
- + Math.abs(Math.sin(radians)) * textWidth;
202
- const neededAxisHeight = Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX;
203
- // `max(0, …)`, not `max(1, …)`: axis.height (= MUI_LABELLED_X_AXIS_HEIGHT + extraHeight) must be
204
- // able to land exactly ON the baseline (45) when a barely-rotated label needs less than it --
205
- // a leftover `max(1, …)` here would silently make 46 the true floor while every doc comment and
206
- // test says 45 (caught in review: harmless in direction, but the two must actually agree).
207
- const extraHeight = Math.max(0, neededAxisHeight - MUI_LABELLED_X_AXIS_HEIGHT);
208
-
209
- return { extraHeight };
210
- }
211
-
212
- /**
213
- * CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
214
- * uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
215
- * `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
216
- * label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
217
- * allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
218
- * `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
219
- * to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
220
- * `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
221
- * downstream applies the package's ordinary bottom default in every case, rotated or not -- a
222
- * caller-set `margin.bottom` was never touched by this function even before this fix, and still
223
- * isn't.
224
- *
225
- * CHART-11: `measureTextWidth` is an optional injection point (defaults to a real DOM
226
- * measurement) that only exists for deterministic tests -- neither preset caller passes it, so
227
- * neither needs to change.
228
- */
229
- export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1, measureTextWidth) {
230
- const spacedXAxis = xAxis.map((axis) => {
231
- const metrics = rotatedTickMetrics(axis, defaultLineHeight, measureTextWidth);
232
- if (!metrics) return axis;
233
- if (axis.height != null) return axis;
234
- return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
235
- });
236
-
237
- return { xAxis: spacedXAxis, margin };
238
- }
239
-
240
156
  // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
241
157
  // 20px (label, if present) = 65px regardless of what the ticks actually say
242
158
  // -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
@@ -367,56 +283,247 @@ export function defaultNumericTickFormatter(locale) {
367
283
  : new Intl.NumberFormat(locale).format(value));
368
284
  }
369
285
 
286
+ // UCM-CHART-12: named size tokens, resolved through the theme's 8px spacing unit
287
+ // (`src/theme/tokens.js`'s `spacing: 8`). `standard` is pinned to the pre-existing deployed
288
+ // default (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) so this migration does not also
289
+ // silently redraw every already-shipped default-sized chart; `compact`/`tall` are proportional.
290
+ // Documented plainly in `docs/CHART-LAYOUT.md` -- these are visible product decisions, not
291
+ // internal plumbing.
292
+ export const CHART_SIZE_SPACING_UNITS = { compact: 30, standard: 40, tall: 50 };
293
+
294
+ // UCM-CHART-12 Rule 2: every band is its own measured content, or zero -- no band is ever a
295
+ // constant taken on faith. These two ARE constants, but only for the "content is present" case;
296
+ // each one collapses to 0 the moment its content is absent (`resolveXAxisGeometry`,
297
+ // `resolveChartLayout` below).
298
+ //
299
+ // ui_reviewer finding U1: before this WO, a plain (unrotated, untitled) axis got NO explicit
300
+ // `axis.height` at all -- MUI computed its own default internally. This resolver now always sets
301
+ // `axis.height` explicitly (Rule 1: nothing outside the resolver does chart arithmetic), which
302
+ // looked like a silent behaviour change until checked against MUI's own source
303
+ // (`@mui/x-charts/constants`): `DEFAULT_AXIS_SIZE_HEIGHT = 25` and `AXIS_LABEL_DEFAULT_HEIGHT =
304
+ // 20` -- i.e. `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` below are not an approximation, they are
305
+ // MUI's own constants, confirmed pixel-for-pixel. Making them explicit changes nothing about what
306
+ // renders for that common case; `TICK_BAND_BASE_PX + AXIS_TITLE_BAND_PX` = 45 is also exactly the
307
+ // pre-CHART-12 `MUI_LABELLED_X_AXIS_HEIGHT`, decomposed into the two bands Rule 2 requires instead
308
+ // of one opaque "labelled axis height" figure.
309
+ const TICK_BAND_BASE_PX = 25;
310
+ const AXIS_TITLE_BAND_PX = MUI_LABELLED_X_AXIS_HEIGHT - TICK_BAND_BASE_PX;
311
+ // One row of legend chips + gap -- MUI does not reserve this space itself (confirmed: nothing in
312
+ // this package's pre-CHART-12 margin/axis math ever accounted for the legend), so a visible
313
+ // legend growing the SVG uncounted was the sixth defect this WO's Rule 2 also closes.
314
+ const LEGEND_BAND_PX = 32;
315
+
316
+ // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
317
+ // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
318
+ // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
319
+ // once there are enough categories AND the longest one is long enough that a horizontal row would
320
+ // plausibly collide. The mandatory browser-level check (WO Part C) is what actually verifies fit
321
+ // against real rendered geometry; this only picks a reasonable default ahead of that.
322
+ const AUTO_ROTATE_TICK_COUNT = 6;
323
+ const AUTO_ROTATE_LABEL_CHARS = 4;
324
+
325
+ function xAxisTickContentText(axis) {
326
+ const data = axis?.data;
327
+ if (!Array.isArray(data)) return null; // no data array -- can't tell (e.g. a numeric scale); assume content
328
+ return longestFormattedTick(data, axis.valueFormatter);
329
+ }
330
+
331
+ // The Access-scatter case (WO Part A): an axis whose tick labels are all blank/absent must
332
+ // collapse its reserved band to zero, not keep the package's floor. `null` (no data array at
333
+ // all -- a linear numeric axis, whose ticks come from the scale, not a caller-supplied list)
334
+ // is treated as "assume content", since there is nothing here to measure as empty.
335
+ function xAxisHasVisibleTicks(axis) {
336
+ const text = xAxisTickContentText(axis);
337
+ return text === null || text.length > 0;
338
+ }
339
+
370
340
  /**
371
- * CHART-8: resolves the `{minHeight, height, aspect}` trio these presets accept into what the
372
- * wrapper reserves and what height (if any) sizes the chart itself. Three-way, not two-way --
373
- * `aspect` changes the answer:
374
- * - `height` set -> it sizes the chart; the wrapper never reserves more than that (a caller-set
375
- * `minHeight` larger than `height` is capped here, closing the dead-space gap this WO exists
376
- * to fix -- equal values, or no `minHeight` at all, pass through unchanged).
377
- * - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
378
- * derives its height from its width, and `minHeight` stays exactly what its name says, a floor
379
- * stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
380
- * that would remove the responsive behaviour four live call sites rely on.
381
- * - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
382
- * information to go on, and a caller in this shape meant the chart to have that height, not
383
- * merely a wrapper floor around an unsized chart.
341
+ * UCM-CHART-12: decides the x-axis tick band's height and rotation angle. Replaces CHART-10/11's
342
+ * `spaceForRotatedTicks`/`rotatedTickMetrics` -- same measured-text-width mechanism
343
+ * (`measureTickTextWidthPx`, with `estimateTextWidthPx` as the no-DOM fallback and
344
+ * `measureTextWidth` as the test injection point), but now driven by `xLabels`
345
+ * ("auto"|"horizontal"|"angled") instead of a caller-supplied `tickLabelStyle.angle`, and
346
+ * returning 0 outright when the axis has no visible tick content (Rule 2).
384
347
  */
385
- export function resolveChartHeight({ minHeight, height, aspect }) {
386
- if (height != null) {
387
- const wrapperMinHeight = minHeight != null && minHeight > height ? height : minHeight;
388
- return { wrapperMinHeight, chartHeight: height };
389
- }
390
- if (aspect != null) {
391
- return { wrapperMinHeight: minHeight, chartHeight: undefined };
348
+ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextWidth, fallbackTickFontSize) {
349
+ if (!xAxisHasVisibleTicks(axis)) return { tickBand: 0, angle: 0 };
350
+
351
+ const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
352
+ const longestLabel = xAxisTickContentText(axis) || '';
353
+ const dataCount = Array.isArray(axis?.data) ? axis.data.length : 0;
354
+
355
+ let angle = 0;
356
+ if (xLabelsMode === 'angled') {
357
+ angle = Number(axis?.tickLabelStyle?.angle) || -45;
358
+ } else if (xLabelsMode === 'auto') {
359
+ const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && longestLabel.length > AUTO_ROTATE_LABEL_CHARS;
360
+ angle = needsRotation ? -45 : 0;
361
+ } // "horizontal" always stays at angle 0
362
+
363
+ if (angle === 0) return { tickBand: TICK_BAND_BASE_PX, angle: 0 };
364
+ // Rotation is a visual instruction independent of whether a font size is knowable here -- an
365
+ // "angled" axis still rotates even when `fontSize` can't be resolved (no theme fallback given),
366
+ // it just can't project a tighter band than the floor without one.
367
+ if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
368
+
369
+ const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
370
+ const measure = measureTextWidth || measureTickTextWidthPx;
371
+ const measuredWidth = measure(longestLabel, fontSize);
372
+ const textWidth = Math.max(
373
+ fontSize,
374
+ Number.isFinite(measuredWidth) && measuredWidth > 0
375
+ ? measuredWidth
376
+ : estimateTextWidthPx(longestLabel, fontSize),
377
+ );
378
+ const radians = (angle * Math.PI) / 180;
379
+ const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
380
+ + Math.abs(Math.sin(radians)) * textWidth;
381
+ const tickBand = Math.max(TICK_BAND_BASE_PX, Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX);
382
+ return { tickBand, angle };
383
+ }
384
+
385
+ function resolveChartHeightPx(size, height, spacing) {
386
+ if (height != null) return height;
387
+ const units = CHART_SIZE_SPACING_UNITS[size];
388
+ if (units == null) {
389
+ throw new Error(
390
+ `[ui-core-micha] Unknown chart size="${size}". Use "compact" | "standard" | "tall", `
391
+ + 'or pass height (in px) for the documented escape.',
392
+ );
392
393
  }
393
- 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;
394
398
  }
395
399
 
396
400
  /**
397
- * CHART-8: dev-only warning when a caller passes both `minHeight` and `height` and they disagree
398
- * -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
399
- * combination in production would break a consumer's page over a layout nit. Silent on the
400
- * equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
401
+ * UCM-CHART-12: the one function that owns a chart's geometry (Rule 1-3), replacing
402
+ * `resolveChartHeight` + `spaceForRotatedTicks` + `warnOnHeightMismatch`. Returns the COMPLETE
403
+ * geometry a preset needs -- wrapper `sx`, chart height, margins, and both resolved axis arrays --
404
+ * plus the named `bands` the composition invariant is asserted against:
405
+ *
406
+ * chartHeight === bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand
407
+ * containerWidth === bands.yAxisBand + bands.plotWidth + bands.rightPad (when `containerWidth` is given)
408
+ *
409
+ * `plotHeight`/`plotWidth` are the RESIDUAL of `chartHeight`/`containerWidth` minus the OTHER
410
+ * fields this function itself returns (`margin.top/bottom`, `xAxis[0].height`, `yAxisBand`) --
411
+ * by construction, summing them back up can never fail to balance, so that arithmetic identity
412
+ * ALONE proves nothing about a `UCM-CHART-10`-shaped bug (a term double-counted between
413
+ * `xAxis.height` and `margin.bottom`). What actually catches that class of bug is asserting each
414
+ * individual band lands in exactly one place downstream, independently of the others:
415
+ * `xAxis[0].height === xAxisBand + xTitleBand` (nothing else may also carry the tick/title
416
+ * allowance) and `margin.top + margin.bottom - PACKAGE_DEFAULT_MARGIN.top -
417
+ * PACKAGE_DEFAULT_MARGIN.bottom === legendBand` (the ONLY thing margin grows for, over its own
418
+ * package baseline, is the legend). The test suite asserts both forms side by side.
419
+ *
420
+ * `plotHeight` deliberately does NOT subtract the base `PACKAGE_DEFAULT_MARGIN` breathing room --
421
+ * that margin is intentional spacing around the plot (THEME-11), not one of the three itemized
422
+ * "furniture" bands (tick/title/legend) this WO exists to zero out, so it is treated as part of
423
+ * "the rest of the chart", the same as `plotHeight` itself.
401
424
  *
402
- * CHART-9: `heightWins` distinguishes the two shapes this fires from. The four chart presets
403
- * (default, `heightWins: true`) DO apply `resolveChartHeight`'s `height` to their box, so the
404
- * original "height wins" wording is accurate there. `ChartFrame` (`heightWins: false`) never
405
- * applies `height` to its box at all -- `minHeight` always keeps reserving the floor -- so it gets
406
- * its own accurate wording instead of reusing the presets' story.
425
+ * `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
426
+ * presets themselves stay responsive (no explicit width) and never pass it.
427
+ */
428
+ export function resolveChartLayout({
429
+ size = 'standard',
430
+ height,
431
+ xAxis,
432
+ yAxis,
433
+ series = [],
434
+ xLabels = 'auto',
435
+ hideLegend = false,
436
+ legendPosition = DEFAULT_LEGEND_POSITION,
437
+ tickFontSize,
438
+ spacing,
439
+ defaultLineHeight = 1,
440
+ measureTextWidth,
441
+ containerWidth,
442
+ } = {}) {
443
+ const chartHeight = resolveChartHeightPx(size, height, spacing);
444
+
445
+ const xAxisList = xAxis?.length ? xAxis : [{}];
446
+ const primaryXAxis = xAxisList[0];
447
+ const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(
448
+ primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize,
449
+ );
450
+ const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
451
+
452
+ // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
453
+ // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
454
+ // over-reserving height and under-reserving width for a side-placed legend. This WO's width
455
+ // invariant does not itemize a legend-width band, so a side legend simply gets none of the
456
+ // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
457
+ // legend is off or horizontal").
458
+ const legendVertical = legendPosition?.vertical ?? DEFAULT_LEGEND_POSITION.vertical;
459
+ const legendIsSidePlaced = legendVertical === 'middle';
460
+ const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
461
+ const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
462
+
463
+ const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
464
+ const yAxisBand = sizedYAxis[0]?.width ?? 0;
465
+ const rightPad = PACKAGE_DEFAULT_MARGIN.right;
466
+
467
+ const resolvedXAxis = xAxisList.map((axisEntry, index) => {
468
+ if (index !== 0 || axisEntry.height != null) return axisEntry;
469
+ // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
470
+ // explicitly here, even when 0, rather than only overriding a truthy value. Leaving a stale
471
+ // caller-supplied `tickLabelStyle.angle` in place when `xLabels` resolved to no rotation would
472
+ // let MUI still render a rotated label whose band this resolver reserved as if it were flat --
473
+ // exactly the space/render mismatch this WO exists to close, just inverted (under-reservation
474
+ // instead of CHART-10's over-reservation).
475
+ const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
476
+ return {
477
+ ...axisEntry,
478
+ height: xAxisBand + xTitleBand,
479
+ tickLabelStyle: { ...restTickLabelStyle, angle },
480
+ };
481
+ });
482
+
483
+ const margin = {
484
+ top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
485
+ bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
486
+ left: PACKAGE_DEFAULT_MARGIN.left,
487
+ right: rightPad,
488
+ };
489
+
490
+ // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
491
+ // furniture bands (see this function's own docblock for why this alone does not prove the
492
+ // absence of a double-counted term, and what does).
493
+ const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
494
+ const plotWidth = containerWidth != null ? containerWidth - yAxisBand - rightPad : undefined;
495
+
496
+ return {
497
+ chartHeight,
498
+ sx: { width: '100%', height: chartHeight },
499
+ margin,
500
+ xAxis: resolvedXAxis,
501
+ yAxis: sizedYAxis,
502
+ bands: {
503
+ plotHeight,
504
+ xAxisBand,
505
+ xTitleBand,
506
+ legendBand: legendHeightBand,
507
+ yAxisBand,
508
+ rightPad,
509
+ ...(containerWidth != null ? { plotWidth, containerWidth } : {}),
510
+ },
511
+ };
512
+ }
513
+
514
+ /**
515
+ * UCM-CHART-12 Rule 1/3: `minHeight`, `aspect`, and `margin` are gone from the four chart presets
516
+ * -- the model owns sizing and spacing completely. Passing one is a dev-mode error naming its
517
+ * replacement (never a silent ignore, so the 38 existing call sites this breaks get a clear
518
+ * migration path instead of a layout that quietly stops matching what the prop says) -- gated on
519
+ * `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
520
+ * cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
407
521
  */
408
- export function warnOnHeightMismatch(componentName, { minHeight, height }, { heightWins = true } = {}) {
409
- if (process.env.NODE_ENV === 'production') return;
410
- if (minHeight == null || height == null || minHeight === height) return;
411
- const resolution = heightWins
412
- ? 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
413
- + 'chart. Pass only height, or drop minHeight if it should just track height.'
414
- : 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
415
- + 'Pass only minHeight, or drop height if it was meant to size the whole card.';
416
- // eslint-disable-next-line no-console
417
- console.warn(
418
- `[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
419
- + resolution,
522
+ export function assertRemovedChartProp(componentName, propName, value, replacement) {
523
+ if (value === undefined || process.env.NODE_ENV === 'production') return;
524
+ throw new Error(
525
+ `[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
526
+ + `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`,
420
527
  );
421
528
  }
422
529
 
@@ -20,7 +20,7 @@ function renderChart(props) {
20
20
  describe('BarChart preset', () => {
21
21
  afterEach(cleanup);
22
22
 
23
- it('passes both axis labels, tooltip, responsive sizing, and hides a single-series legend', () => {
23
+ it('passes both axis labels, tooltip, the default "standard" height, and hides a single-series legend', () => {
24
24
  renderChart({ series: [{ data: [1, 2] }] });
25
25
  const props = chartSpy.mock.calls[0][0];
26
26
 
@@ -31,7 +31,10 @@ describe('BarChart preset', () => {
31
31
  legend: { position: { vertical: 'bottom', horizontal: 'start' } },
32
32
  });
33
33
  expect(props.hideLegend).toBe(true);
34
- expect(screen.getByTestId('bar-chart-container').getAttribute('style') || '').not.toContain('px');
34
+ // UCM-CHART-12: size defaults to "standard" (320px, the historical default) -- the wrapper
35
+ // always carries an explicit height now, width stays responsive.
36
+ expect(props.height).toBe(320);
37
+ expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).height).toBe('320px');
35
38
  });
36
39
 
37
40
  it.each([
@@ -72,51 +75,36 @@ describe('BarChart preset', () => {
72
75
  expect(props.yAxis[0].label).toBeUndefined();
73
76
  });
74
77
 
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');
78
+ // UCM-CHART-12: `size`/`height` resolution, wired through the actual component. Replaces the
79
+ // deleted `minHeight`/`aspect` CHART-8 trio -- see the removed-prop assertions below for the
80
+ // migration side of this.
81
+ describe('size/height resolution (UCM-CHART-12)', () => {
82
+ it.each([
83
+ ['compact', 240],
84
+ ['standard', 320],
85
+ ['tall', 400],
86
+ ])('resolves size="%s" to %ipx, on both the chart height and the wrapper', (size, px) => {
87
+ renderChart({ series: [{ data: [1] }], size });
88
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(px);
89
+ expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).height).toBe(`${px}px`);
81
90
  });
82
91
 
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');
92
+ it('lets height override size entirely, as the documented escape', () => {
93
+ renderChart({ series: [{ data: [1] }], size: 'tall', height: 280 });
94
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(280);
95
+ expect(window.getComputedStyle(screen.getByTestId('bar-chart-container')).height).toBe('280px');
89
96
  });
90
97
 
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');
98
+ it('throws in dev when a caller still passes minHeight, naming the replacement', () => {
99
+ expect(() => renderChart({ series: [{ data: [1] }], minHeight: 300 })).toThrow(/size=.*standard.*tall/);
95
100
  });
96
101
 
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');
102
+ it('throws in dev when a caller still passes aspect, naming the removal', () => {
103
+ expect(() => renderChart({ series: [{ data: [1] }], aspect: 1.8 })).toThrow(/aspect/);
101
104
  });
102
105
 
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();
106
+ it('throws in dev when a caller still passes margin, naming the removal', () => {
107
+ expect(() => renderChart({ series: [{ data: [1] }], margin: { top: 1 } })).toThrow(/margin/);
120
108
  });
121
109
  });
122
110
  });
@@ -34,8 +34,9 @@ describe('LineChart preset', () => {
34
34
  });
35
35
  });
36
36
 
37
- // CHART-8: minHeight/height/aspect resolution, wired through the actual component.
38
- describe('minHeight/height resolution (CHART-8)', () => {
37
+ // UCM-CHART-12: `size`/`height` resolution, wired through the actual component. Replaces the
38
+ // deleted `minHeight`/`aspect` CHART-8 trio.
39
+ describe('size/height resolution (UCM-CHART-12)', () => {
39
40
  function renderLine(props) {
40
41
  return render(
41
42
  <ThemeProvider theme={createTheme()}>
@@ -46,36 +47,32 @@ describe('LineChart preset', () => {
46
47
 
47
48
  afterEach(cleanup);
48
49
 
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');
50
+ it.each([
51
+ ['compact', 240],
52
+ ['standard', 320],
53
+ ['tall', 400],
54
+ ])('resolves size="%s" to %ipx, on both the chart height and the wrapper', (size, px) => {
55
+ renderLine({ size });
56
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(px);
57
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).height).toBe(`${px}px`);
53
58
  });
54
59
 
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');
60
+ it('lets height override size entirely, as the documented escape', () => {
61
+ renderLine({ size: 'compact', height: 280 });
62
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(280);
63
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).height).toBe('280px');
61
64
  });
62
65
 
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');
66
+ it('throws in dev when a caller still passes minHeight, naming the replacement', () => {
67
+ expect(() => renderLine({ minHeight: 300 })).toThrow(/size=.*standard.*tall/);
67
68
  });
68
69
 
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');
70
+ it('throws in dev when a caller still passes aspect, naming the removal', () => {
71
+ expect(() => renderLine({ aspect: 1.8 })).toThrow(/aspect/);
73
72
  });
74
73
 
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');
74
+ it('throws in dev when a caller still passes margin, naming the removal', () => {
75
+ expect(() => renderLine({ margin: { top: 1 } })).toThrow(/margin/);
79
76
  });
80
77
  });
81
78
  });
@@ -233,39 +233,36 @@ describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
233
233
  });
234
234
  });
235
235
 
236
- // CHART-8: minHeight/height/aspect resolution, wired through the actual component.
237
- describe('ScatterChart minHeight/height resolution (CHART-8)', () => {
236
+ // UCM-CHART-12: `size`/`height` resolution, wired through the actual component. Replaces the
237
+ // deleted `minHeight`/`aspect` CHART-8 trio.
238
+ describe('ScatterChart size/height resolution (UCM-CHART-12)', () => {
238
239
  afterEach(() => { cleanup(); vi.clearAllMocks(); });
239
240
 
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');
241
+ it.each([
242
+ ['compact', 240],
243
+ ['standard', 320],
244
+ ['tall', 400],
245
+ ])('resolves size="%s" to %ipx, on both the chart height and the wrapper', (size, px) => {
246
+ renderScatter({ size });
247
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(px);
248
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).height).toBe(`${px}px`);
244
249
  });
245
250
 
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');
251
+ it('lets height override size entirely, as the documented escape', () => {
252
+ renderScatter({ size: 'tall', height: 280 });
253
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(280);
254
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).height).toBe('280px');
252
255
  });
253
256
 
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');
257
+ it('throws in dev when a caller still passes minHeight, naming the replacement', () => {
258
+ expect(() => renderScatter({ minHeight: 300 })).toThrow(/size=.*standard.*tall/);
258
259
  });
259
260
 
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');
261
+ it('throws in dev when a caller still passes aspect, naming the removal', () => {
262
+ expect(() => renderScatter({ aspect: 1.8 })).toThrow(/aspect/);
264
263
  });
265
264
 
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');
265
+ it('throws in dev when a caller still passes margin, naming the removal', () => {
266
+ expect(() => renderScatter({ margin: { top: 1 } })).toThrow(/margin/);
270
267
  });
271
268
  });