@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.
- package/WORK_ORDERS.md +1 -0
- package/dev/entries.jsx +6 -0
- package/dist/components/charts/BarChart.js +24 -12
- package/dist/components/charts/ChartFrame.js +23 -5
- package/dist/components/charts/LineChart.js +24 -12
- package/dist/components/charts/ScatterChart.js +30 -22
- package/dist/components/charts/TimeSeriesChart.js +6 -1
- package/dist/components/charts/chartDefaults.js +263 -127
- package/docs/CHART-LAYOUT.md +140 -0
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +31 -28
- package/src/components/charts/ChartFrame.jsx +24 -5
- package/src/components/charts/LineChart.jsx +32 -29
- package/src/components/charts/ScatterChart.jsx +38 -29
- package/src/components/charts/TimeSeriesChart.jsx +6 -1
- package/src/components/charts/chartDefaults.js +292 -133
- 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 +460 -265
- package/work-orders/UCM-CHART-12.md +348 -0
|
@@ -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 /
|
|
@@ -331,7 +247,15 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
|
|
|
331
247
|
if (candidates.length === 0) return axis;
|
|
332
248
|
|
|
333
249
|
const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
|
|
334
|
-
|
|
250
|
+
// UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
|
|
251
|
+
// `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
|
|
252
|
+
// of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
|
|
253
|
+
// from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
|
|
254
|
+
// untouched): here there WAS content, it just renders as nothing. With no axis title either,
|
|
255
|
+
// the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
|
|
256
|
+
// text-width term floored, matching the x-axis band's own all-or-nothing zero.
|
|
257
|
+
if (longestTick.length === 0 && !axis.label) return { ...axis, width: 0 };
|
|
258
|
+
const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(
|
|
335
259
|
tickFontSizePx,
|
|
336
260
|
longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
|
|
337
261
|
);
|
|
@@ -367,65 +291,300 @@ export function defaultNumericTickFormatter(locale) {
|
|
|
367
291
|
: new Intl.NumberFormat(locale).format(value));
|
|
368
292
|
}
|
|
369
293
|
|
|
294
|
+
// UCM-CHART-12: named size tokens, resolved through the theme's 8px spacing unit
|
|
295
|
+
// (`src/theme/tokens.js`'s `spacing: 8`). `standard` is pinned to the pre-existing deployed
|
|
296
|
+
// default (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) so this migration does not also
|
|
297
|
+
// silently redraw every already-shipped default-sized chart; `compact`/`tall` are proportional.
|
|
298
|
+
// Documented plainly in `docs/CHART-LAYOUT.md` -- these are visible product decisions, not
|
|
299
|
+
// internal plumbing.
|
|
300
|
+
export const CHART_SIZE_SPACING_UNITS = { compact: 30, standard: 40, tall: 50 };
|
|
301
|
+
|
|
302
|
+
// UCM-CHART-12 Rule 2: every band is its own measured content, or zero -- no band is ever a
|
|
303
|
+
// constant taken on faith. These two ARE constants, but only for the "content is present" case;
|
|
304
|
+
// each one collapses to 0 the moment its content is absent (`resolveXAxisGeometry`,
|
|
305
|
+
// `resolveChartLayout` below).
|
|
306
|
+
//
|
|
307
|
+
// ui_reviewer finding U1: before this WO, a plain (unrotated, untitled) axis got NO explicit
|
|
308
|
+
// `axis.height` at all -- MUI computed its own default internally. This resolver now always sets
|
|
309
|
+
// `axis.height` explicitly (Rule 1: nothing outside the resolver does chart arithmetic), which
|
|
310
|
+
// looked like a silent behaviour change until checked against MUI's own source
|
|
311
|
+
// (`@mui/x-charts/constants`): `DEFAULT_AXIS_SIZE_HEIGHT = 25` and `AXIS_LABEL_DEFAULT_HEIGHT =
|
|
312
|
+
// 20` -- i.e. `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` below are not an approximation, they are
|
|
313
|
+
// MUI's own constants, confirmed pixel-for-pixel. Making them explicit changes nothing about what
|
|
314
|
+
// renders for that common case; `TICK_BAND_BASE_PX + AXIS_TITLE_BAND_PX` = 45 is also exactly the
|
|
315
|
+
// pre-CHART-12 `MUI_LABELLED_X_AXIS_HEIGHT`, decomposed into the two bands Rule 2 requires instead
|
|
316
|
+
// of one opaque "labelled axis height" figure.
|
|
317
|
+
const TICK_BAND_BASE_PX = 25;
|
|
318
|
+
const AXIS_TITLE_BAND_PX = MUI_LABELLED_X_AXIS_HEIGHT - TICK_BAND_BASE_PX;
|
|
319
|
+
// One row of legend chips + gap -- MUI does not reserve this space itself (confirmed: nothing in
|
|
320
|
+
// this package's pre-CHART-12 margin/axis math ever accounted for the legend), so a visible
|
|
321
|
+
// legend growing the SVG uncounted was the sixth defect this WO's Rule 2 also closes.
|
|
322
|
+
const LEGEND_BAND_PX = 32;
|
|
323
|
+
|
|
324
|
+
// UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
|
|
325
|
+
// render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
|
|
326
|
+
// pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
|
|
327
|
+
// once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
|
|
328
|
+
// projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
|
|
329
|
+
// wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
|
|
330
|
+
// the first cut of this heuristic used a raw character-count threshold instead of the measured
|
|
331
|
+
// width this function already has available -- replaced, since "decide from the measured labels"
|
|
332
|
+
// (Rule 3) is achievable even without a real container width to compare against. The count
|
|
333
|
+
// threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
|
|
334
|
+
// spread across a wide chart, so width alone should not trigger rotation either.
|
|
335
|
+
const AUTO_ROTATE_TICK_COUNT = 6;
|
|
336
|
+
const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
|
|
337
|
+
|
|
338
|
+
function xAxisTickContentText(axis) {
|
|
339
|
+
const data = axis?.data;
|
|
340
|
+
if (!Array.isArray(data)) return null; // no data array -- can't tell (e.g. a numeric scale); assume content
|
|
341
|
+
return longestFormattedTick(data, axis.valueFormatter);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The Access-scatter case (WO Part A): an axis whose tick labels are all blank/absent must
|
|
345
|
+
// collapse its reserved band to zero, not keep the package's floor. `null` (no data array at
|
|
346
|
+
// all -- a linear numeric axis, whose ticks come from the scale, not a caller-supplied list)
|
|
347
|
+
// is treated as "assume content", since there is nothing here to measure as empty.
|
|
348
|
+
function xAxisHasVisibleTicks(axis) {
|
|
349
|
+
const text = xAxisTickContentText(axis);
|
|
350
|
+
return text === null || text.length > 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
370
353
|
/**
|
|
371
|
-
* CHART-
|
|
372
|
-
*
|
|
373
|
-
* `
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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.
|
|
354
|
+
* UCM-CHART-12: decides the x-axis tick band's height and rotation angle. Replaces CHART-10/11's
|
|
355
|
+
* `spaceForRotatedTicks`/`rotatedTickMetrics` -- same measured-text-width mechanism
|
|
356
|
+
* (`measureTickTextWidthPx`, with `estimateTextWidthPx` as the no-DOM fallback and
|
|
357
|
+
* `measureTextWidth` as the test injection point), but now driven by `xLabels`
|
|
358
|
+
* ("auto"|"horizontal"|"angled") instead of a caller-supplied `tickLabelStyle.angle`, and
|
|
359
|
+
* returning 0 outright when the axis has no visible tick content (Rule 2).
|
|
384
360
|
*/
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
361
|
+
function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextWidth, fallbackTickFontSize) {
|
|
362
|
+
if (!xAxisHasVisibleTicks(axis)) return { tickBand: 0, angle: 0 };
|
|
363
|
+
|
|
364
|
+
const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
|
|
365
|
+
const longestLabel = xAxisTickContentText(axis) || '';
|
|
366
|
+
const dataCount = Array.isArray(axis?.data) ? axis.data.length : 0;
|
|
367
|
+
const measure = measureTextWidth || measureTickTextWidthPx;
|
|
368
|
+
|
|
369
|
+
// Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
|
|
370
|
+
// below need the label's real width, and must agree on what "real" means (same measurement,
|
|
371
|
+
// same estimate fallback) or the two could disagree about the same label.
|
|
372
|
+
const labelWidthPx = fontSize > 0
|
|
373
|
+
? Math.max(fontSize, (() => {
|
|
374
|
+
const measured = measure(longestLabel, fontSize);
|
|
375
|
+
return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
|
|
376
|
+
})())
|
|
377
|
+
: 0;
|
|
378
|
+
|
|
379
|
+
let angle = 0;
|
|
380
|
+
if (xLabelsMode === 'angled') {
|
|
381
|
+
angle = Number(axis?.tickLabelStyle?.angle) || -45;
|
|
382
|
+
} else if (xLabelsMode === 'auto') {
|
|
383
|
+
const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
|
|
384
|
+
angle = needsRotation ? -45 : 0;
|
|
385
|
+
} // "horizontal" always stays at angle 0
|
|
386
|
+
|
|
387
|
+
if (angle === 0) return { tickBand: TICK_BAND_BASE_PX, angle: 0 };
|
|
388
|
+
// Rotation is a visual instruction independent of whether a font size is knowable here -- an
|
|
389
|
+
// "angled" axis still rotates even when `fontSize` can't be resolved (no theme fallback given),
|
|
390
|
+
// it just can't project a tighter band than the floor without one.
|
|
391
|
+
if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
|
|
392
|
+
|
|
393
|
+
const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
|
|
394
|
+
const textWidth = labelWidthPx;
|
|
395
|
+
const radians = (angle * Math.PI) / 180;
|
|
396
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
397
|
+
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
398
|
+
const tickBand = Math.max(TICK_BAND_BASE_PX, Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX);
|
|
399
|
+
return { tickBand, angle };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveChartHeightPx(size, height, spacing) {
|
|
403
|
+
if (height != null) return height;
|
|
404
|
+
const units = CHART_SIZE_SPACING_UNITS[size];
|
|
405
|
+
if (units == null) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`[ui-core-micha] Unknown chart size="${size}". Use "compact" | "standard" | "tall", `
|
|
408
|
+
+ 'or pass height (in px) for the documented escape.',
|
|
409
|
+
);
|
|
392
410
|
}
|
|
393
|
-
|
|
411
|
+
// MUI's `theme.spacing(n)` returns a CSS length STRING ("240px"), not a number -- the chart
|
|
412
|
+
// height feeds straight into MUI X-Charts' own numeric `height` prop, so it must be unwrapped
|
|
413
|
+
// back to a px number via the same `cssLengthToPixels` the rest of this file already uses.
|
|
414
|
+
return typeof spacing === 'function' ? cssLengthToPixels(spacing(units)) : units * 8;
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
/**
|
|
397
|
-
* CHART-
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
418
|
+
* UCM-CHART-12: the one function that owns a chart's geometry (Rule 1-3), replacing
|
|
419
|
+
* `resolveChartHeight` + `spaceForRotatedTicks` + `warnOnHeightMismatch`. Returns the COMPLETE
|
|
420
|
+
* geometry a preset needs -- wrapper `sx`, chart height, margins, and both resolved axis arrays --
|
|
421
|
+
* plus the named `bands` the composition invariant is asserted against:
|
|
401
422
|
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
423
|
+
* chartHeight === bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand
|
|
424
|
+
* containerWidth === bands.yAxisBand + bands.plotWidth + bands.rightPad (when `containerWidth` is given)
|
|
425
|
+
*
|
|
426
|
+
* `plotHeight`/`plotWidth` are the RESIDUAL of `chartHeight`/`containerWidth` minus the OTHER
|
|
427
|
+
* fields this function itself returns (`margin.top/bottom`, `xAxis[0].height`, `yAxisBand`) --
|
|
428
|
+
* by construction, summing them back up can never fail to balance, so that arithmetic identity
|
|
429
|
+
* ALONE proves nothing about a `UCM-CHART-10`-shaped bug (a term double-counted between
|
|
430
|
+
* `xAxis.height` and `margin.bottom`). What actually catches that class of bug is asserting each
|
|
431
|
+
* individual band lands in exactly one place downstream, independently of the others:
|
|
432
|
+
* `xAxis[0].height === xAxisBand + xTitleBand` (nothing else may also carry the tick/title
|
|
433
|
+
* allowance) and `margin.top + margin.bottom - PACKAGE_DEFAULT_MARGIN.top -
|
|
434
|
+
* PACKAGE_DEFAULT_MARGIN.bottom === legendBand` (the ONLY thing margin grows for, over its own
|
|
435
|
+
* package baseline, is the legend). The test suite asserts both forms side by side.
|
|
436
|
+
*
|
|
437
|
+
* `plotHeight` deliberately does NOT subtract the base `PACKAGE_DEFAULT_MARGIN` breathing room --
|
|
438
|
+
* that margin is intentional spacing around the plot (THEME-11), not one of the three itemized
|
|
439
|
+
* "furniture" bands (tick/title/legend) this WO exists to zero out, so it is treated as part of
|
|
440
|
+
* "the rest of the chart", the same as `plotHeight` itself.
|
|
441
|
+
*
|
|
442
|
+
* `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
|
|
443
|
+
* presets themselves stay responsive (no explicit width) and never pass it.
|
|
407
444
|
*/
|
|
408
|
-
export function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
445
|
+
export function resolveChartLayout({
|
|
446
|
+
size = 'standard',
|
|
447
|
+
height,
|
|
448
|
+
xAxis,
|
|
449
|
+
yAxis,
|
|
450
|
+
series = [],
|
|
451
|
+
xLabels = 'auto',
|
|
452
|
+
hideLegend = false,
|
|
453
|
+
legendPosition = DEFAULT_LEGEND_POSITION,
|
|
454
|
+
slotProps,
|
|
455
|
+
tickFontSize,
|
|
456
|
+
spacing,
|
|
457
|
+
defaultLineHeight = 1,
|
|
458
|
+
measureTextWidth,
|
|
459
|
+
containerWidth,
|
|
460
|
+
} = {}) {
|
|
461
|
+
const chartHeight = resolveChartHeightPx(size, height, spacing);
|
|
462
|
+
|
|
463
|
+
const xAxisList = xAxis?.length ? xAxis : [{}];
|
|
464
|
+
const primaryXAxis = xAxisList[0];
|
|
465
|
+
const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(
|
|
466
|
+
primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize,
|
|
420
467
|
);
|
|
468
|
+
const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
|
|
469
|
+
|
|
470
|
+
// Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
|
|
471
|
+
// `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
|
|
472
|
+
// margin/height this function reserved could disagree with where the legend actually rendered.
|
|
473
|
+
// `resolveLegendPosition` is the one merge both call sites now share.
|
|
474
|
+
const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
|
|
475
|
+
|
|
476
|
+
// A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
|
|
477
|
+
// finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
|
|
478
|
+
// over-reserving height and under-reserving width for a side-placed legend. This WO's width
|
|
479
|
+
// invariant does not itemize a legend-width band, so a side legend simply gets none of the
|
|
480
|
+
// height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
|
|
481
|
+
// legend is off or horizontal").
|
|
482
|
+
const legendVertical = effectiveLegendPosition.vertical;
|
|
483
|
+
const legendIsSidePlaced = legendVertical === 'middle';
|
|
484
|
+
const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
|
|
485
|
+
const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
|
|
486
|
+
|
|
487
|
+
const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
|
|
488
|
+
// Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
|
|
489
|
+
// feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
|
|
490
|
+
// only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
|
|
491
|
+
// (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
|
|
492
|
+
// logic above), so only an explicit `'right'` counts as the secondary axis.
|
|
493
|
+
const primaryYAxis = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right') ?? sizedYAxis[0];
|
|
494
|
+
const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
|
|
495
|
+
const yAxisBand = primaryYAxis?.width ?? 0;
|
|
496
|
+
const secondaryYAxisBand = secondaryYAxisEntry?.width ?? 0;
|
|
497
|
+
const rightPad = PACKAGE_DEFAULT_MARGIN.right;
|
|
498
|
+
|
|
499
|
+
const resolvedXAxis = xAxisList.map((axisEntry, index) => {
|
|
500
|
+
if (index !== 0) return axisEntry;
|
|
501
|
+
// `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
|
|
502
|
+
// explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
|
|
503
|
+
// whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
|
|
504
|
+
// never its ROTATION -- codex second-pass review finding: the two were previously coupled,
|
|
505
|
+
// so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
|
|
506
|
+
// under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
|
|
507
|
+
// resolved to no rotation would let MUI still render a rotated label whose band this resolver
|
|
508
|
+
// reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
|
|
509
|
+
// just inverted (under-reservation instead of CHART-10's over-reservation).
|
|
510
|
+
const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
|
|
511
|
+
return {
|
|
512
|
+
...axisEntry,
|
|
513
|
+
height: axisEntry.height ?? (xAxisBand + xTitleBand),
|
|
514
|
+
tickLabelStyle: { ...restTickLabelStyle, angle },
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const margin = {
|
|
519
|
+
top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
|
|
520
|
+
bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
|
|
521
|
+
left: PACKAGE_DEFAULT_MARGIN.left,
|
|
522
|
+
right: rightPad + secondaryYAxisBand,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
|
|
526
|
+
// furniture bands (see this function's own docblock for why this alone does not prove the
|
|
527
|
+
// absence of a double-counted term, and what does).
|
|
528
|
+
const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
|
|
529
|
+
const plotWidth = containerWidth != null
|
|
530
|
+
? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
|
|
531
|
+
: undefined;
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
chartHeight,
|
|
535
|
+
sx: { width: '100%', height: chartHeight },
|
|
536
|
+
margin,
|
|
537
|
+
xAxis: resolvedXAxis,
|
|
538
|
+
yAxis: sizedYAxis,
|
|
539
|
+
bands: {
|
|
540
|
+
plotHeight,
|
|
541
|
+
xAxisBand,
|
|
542
|
+
secondaryYAxisBand,
|
|
543
|
+
xTitleBand,
|
|
544
|
+
legendBand: legendHeightBand,
|
|
545
|
+
yAxisBand,
|
|
546
|
+
rightPad,
|
|
547
|
+
...(containerWidth != null ? { plotWidth, containerWidth } : {}),
|
|
548
|
+
},
|
|
549
|
+
};
|
|
421
550
|
}
|
|
422
551
|
|
|
423
|
-
|
|
424
|
-
|
|
552
|
+
/**
|
|
553
|
+
* UCM-CHART-12 Rule 1/3: `minHeight`, `aspect`, and `margin` are gone from the four chart presets
|
|
554
|
+
* -- the model owns sizing and spacing completely. Passing one is a dev-mode error naming its
|
|
555
|
+
* replacement (never a silent ignore, so the 38 existing call sites this breaks get a clear
|
|
556
|
+
* migration path instead of a layout that quietly stops matching what the prop says) -- gated on
|
|
557
|
+
* `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
|
|
558
|
+
* cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
|
|
559
|
+
*/
|
|
560
|
+
export function assertRemovedChartProp(componentName, propName, value, replacement) {
|
|
561
|
+
if (value === undefined || process.env.NODE_ENV === 'production') return;
|
|
562
|
+
throw new Error(
|
|
563
|
+
`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
|
|
564
|
+
+ `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
|
|
570
|
+
* legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
|
|
571
|
+
* same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
|
|
572
|
+
* only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
|
|
573
|
+
* while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
|
|
574
|
+
* time -- a caller passing both could get a legend rendered top/side while the resolver had
|
|
575
|
+
* reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
|
|
576
|
+
* close. Both call sites now merge through this one function.
|
|
577
|
+
*/
|
|
578
|
+
export function resolveLegendPosition(legendPosition, slotProps) {
|
|
579
|
+
return {
|
|
425
580
|
...DEFAULT_LEGEND_POSITION,
|
|
426
581
|
...legendPosition,
|
|
427
582
|
...slotProps?.legend?.position,
|
|
428
583
|
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
587
|
+
const position = resolveLegendPosition(legendPosition, slotProps);
|
|
429
588
|
return {
|
|
430
589
|
...slotProps,
|
|
431
590
|
tooltip: {
|
package/tests/BarChart.test.jsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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('
|
|
84
|
-
renderChart({ series: [{ data: [1] }],
|
|
85
|
-
expect(chartSpy.mock.calls.at(-1)[0].height).
|
|
86
|
-
|
|
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('
|
|
92
|
-
renderChart({ series: [{ data: [1] }], minHeight:
|
|
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('
|
|
98
|
-
renderChart({ series: [{ data: [1] }],
|
|
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('
|
|
104
|
-
renderChart({ series: [{ data: [1] }],
|
|
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
|
});
|
package/tests/LineChart.test.jsx
CHANGED
|
@@ -34,8 +34,9 @@ describe('LineChart preset', () => {
|
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
// CHART-
|
|
38
|
-
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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('
|
|
56
|
-
renderLine({
|
|
57
|
-
expect(chartSpy.mock.calls.at(-1)[0].height).
|
|
58
|
-
|
|
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('
|
|
64
|
-
renderLine({ minHeight:
|
|
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('
|
|
70
|
-
renderLine({
|
|
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('
|
|
76
|
-
renderLine({
|
|
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
|
});
|