@micha.bigler/ui-core-micha 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +2 -1
- package/dist/components/charts/BarChart.js +1 -0
- package/dist/components/charts/ChartFrame.js +33 -27
- package/dist/components/charts/LineChart.js +1 -0
- package/dist/components/charts/ScatterChart.js +1 -0
- package/dist/components/charts/chartDefaults.js +88 -29
- package/docs/CHART-LAYOUT.md +28 -13
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +1 -0
- package/src/components/charts/ChartFrame.jsx +27 -31
- package/src/components/charts/LineChart.jsx +1 -0
- package/src/components/charts/ScatterChart.jsx +1 -0
- package/src/components/charts/chartDefaults.js +88 -30
- package/tests/ChartFrame.test.jsx +44 -27
- package/tests/chartDefaults.test.js +157 -1
- package/work-orders/UCM-CHART-13.md +122 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useId, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
4
|
Box,
|
|
@@ -11,38 +11,27 @@ import {
|
|
|
11
11
|
import { useTranslation } from 'react-i18next';
|
|
12
12
|
import { useTheme } from '@mui/material/styles';
|
|
13
13
|
import { exportChartPng, exportChartSvg } from './exportChart';
|
|
14
|
+
import { assertRemovedChartProp } from './chartDefaults';
|
|
14
15
|
|
|
15
16
|
export const CHART_FRAME_ROOT_SX = { p: 2 };
|
|
16
17
|
export const CHART_FRAME_ALERT_SX = { mt: 2 };
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* UCM-CHART-12: `warnOnHeightMismatch` (the shared helper this note used to call into
|
|
20
|
-
* `chartDefaults`) was deleted along with `resolveChartHeight` -- both belonged to the
|
|
21
|
-
* `{minHeight, height, aspect}` trio the four chart presets used to accept, which that WO
|
|
22
|
-
* replaced with `resolveChartLayout`'s `size`/`height` model. `ChartFrame` keeps its OWN
|
|
23
|
-
* `minHeight`/`height`/`aspect` props unchanged (CHART-9: it is a card floor, not a chart size),
|
|
24
|
-
* so it keeps this note too -- same text, same condition, just no longer routed through the
|
|
25
|
-
* now-deleted shared function.
|
|
26
|
-
*/
|
|
27
|
-
function warnOnChartFrameHeightMismatch(minHeight, height) {
|
|
28
|
-
if (process.env.NODE_ENV === 'production') return;
|
|
29
|
-
if (minHeight == null || height == null || minHeight === height) return;
|
|
30
|
-
// eslint-disable-next-line no-console
|
|
31
|
-
console.warn(
|
|
32
|
-
`[ui-core-micha] <ChartFrame> received both minHeight={${minHeight}} and height={${height}}; `
|
|
33
|
-
+ 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
|
|
34
|
-
+ 'Pass only minHeight, or drop height if it was meant to size the whole card.',
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
19
|
/**
|
|
39
20
|
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
40
21
|
*
|
|
41
22
|
* `minHeight` (CHART-9): always just a floor on the content box, which holds a whole card --
|
|
42
|
-
* title, toolbar, chart, legend, footnotes, export links -- not a single chart.
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
23
|
+
* title, toolbar, chart, legend, footnotes, export links -- not a single chart.
|
|
24
|
+
*
|
|
25
|
+
* UCM-CHART-13: `height` and `aspect` are GONE. `height` was destructured and only ever fed a
|
|
26
|
+
* dev-mode mismatch warning, never applied to the box -- genuinely dead. `aspect` is a DIFFERENT
|
|
27
|
+
* case (codex review finding, corrected from this WO's own Envelope, which claimed it too was
|
|
28
|
+
* applied nowhere): it WAS wired to `aspectRatio` on the box at 3.0.1, and worked. It is removed
|
|
29
|
+
* anyway, because no consumer across the five apps passes it (measured against 3.0.1) and
|
|
30
|
+
* `ChartFrame` accepting a sizing prop it is the only one of the four+frame components to apply is
|
|
31
|
+
* exactly the kind of inconsistency this WO exists to close -- a working-but-unused prop is still
|
|
32
|
+
* unused surface, just not dead code the way `height` was. Passing either now throws in dev via the
|
|
33
|
+
* same `assertRemovedChartProp` the four chart presets use (`UCM-CHART-12`), naming `minHeight` (a
|
|
34
|
+
* card floor) or the chart's own `size` prop as the replacement.
|
|
46
35
|
*/
|
|
47
36
|
export function ChartFrame({
|
|
48
37
|
title,
|
|
@@ -54,8 +43,6 @@ export function ChartFrame({
|
|
|
54
43
|
isEmpty = false,
|
|
55
44
|
emptyMessage,
|
|
56
45
|
minHeight,
|
|
57
|
-
height,
|
|
58
|
-
aspect,
|
|
59
46
|
exportOptions = false,
|
|
60
47
|
onExportSvg,
|
|
61
48
|
onExportPng,
|
|
@@ -65,6 +52,10 @@ export function ChartFrame({
|
|
|
65
52
|
variant = 'outlined',
|
|
66
53
|
titleVariant = 'h6',
|
|
67
54
|
sx,
|
|
55
|
+
// UCM-CHART-13: `height`/`aspect` are gone from the signature itself (DoD) -- captured via rest,
|
|
56
|
+
// not named parameters, so they read as "detected leftovers", not "accepted props" a reader might
|
|
57
|
+
// assume are still wired to something.
|
|
58
|
+
...legacyProps
|
|
68
59
|
}) {
|
|
69
60
|
const { t } = useTranslation();
|
|
70
61
|
const theme = useTheme();
|
|
@@ -78,9 +69,15 @@ export function ChartFrame({
|
|
|
78
69
|
const chartLabel = ariaLabel || title;
|
|
79
70
|
const callerSx = Array.isArray(sx) ? sx : [sx];
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
assertRemovedChartProp(
|
|
73
|
+
'ChartFrame', 'height', legacyProps.height,
|
|
74
|
+
'Use minHeight for the card floor, or size on the chart inside the frame if you meant the chart\'s own height.',
|
|
75
|
+
'UCM-CHART-13, v3.1.0',
|
|
76
|
+
);
|
|
77
|
+
assertRemovedChartProp(
|
|
78
|
+
'ChartFrame', 'aspect', legacyProps.aspect,
|
|
79
|
+
'Removed -- it was never applied to the frame. Use size on the chart inside the frame if you meant the chart\'s own aspect.',
|
|
80
|
+
'UCM-CHART-13, v3.1.0',
|
|
84
81
|
);
|
|
85
82
|
|
|
86
83
|
const runExport = async (type) => {
|
|
@@ -136,7 +133,6 @@ export function ChartFrame({
|
|
|
136
133
|
sx={{
|
|
137
134
|
width: '100%',
|
|
138
135
|
minHeight,
|
|
139
|
-
aspectRatio: aspect,
|
|
140
136
|
display: 'flex',
|
|
141
137
|
alignItems: 'center',
|
|
142
138
|
justifyContent: 'center',
|
|
@@ -247,7 +247,15 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
|
|
|
247
247
|
if (candidates.length === 0) return axis;
|
|
248
248
|
|
|
249
249
|
const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
|
|
250
|
-
|
|
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(
|
|
251
259
|
tickFontSizePx,
|
|
252
260
|
longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
|
|
253
261
|
);
|
|
@@ -316,11 +324,16 @@ const LEGEND_BAND_PX = 32;
|
|
|
316
324
|
// UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
|
|
317
325
|
// render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
|
|
318
326
|
// 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
|
|
320
|
-
//
|
|
321
|
-
//
|
|
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.
|
|
322
335
|
const AUTO_ROTATE_TICK_COUNT = 6;
|
|
323
|
-
const
|
|
336
|
+
const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
|
|
324
337
|
|
|
325
338
|
function xAxisTickContentText(axis) {
|
|
326
339
|
const data = axis?.data;
|
|
@@ -351,12 +364,23 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
|
|
|
351
364
|
const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
|
|
352
365
|
const longestLabel = xAxisTickContentText(axis) || '';
|
|
353
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;
|
|
354
378
|
|
|
355
379
|
let angle = 0;
|
|
356
380
|
if (xLabelsMode === 'angled') {
|
|
357
381
|
angle = Number(axis?.tickLabelStyle?.angle) || -45;
|
|
358
382
|
} else if (xLabelsMode === 'auto') {
|
|
359
|
-
const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT &&
|
|
383
|
+
const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
|
|
360
384
|
angle = needsRotation ? -45 : 0;
|
|
361
385
|
} // "horizontal" always stays at angle 0
|
|
362
386
|
|
|
@@ -367,14 +391,7 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
|
|
|
367
391
|
if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
|
|
368
392
|
|
|
369
393
|
const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
|
|
370
|
-
const
|
|
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
|
-
);
|
|
394
|
+
const textWidth = labelWidthPx;
|
|
378
395
|
const radians = (angle * Math.PI) / 180;
|
|
379
396
|
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
380
397
|
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
@@ -434,6 +451,7 @@ export function resolveChartLayout({
|
|
|
434
451
|
xLabels = 'auto',
|
|
435
452
|
hideLegend = false,
|
|
436
453
|
legendPosition = DEFAULT_LEGEND_POSITION,
|
|
454
|
+
slotProps,
|
|
437
455
|
tickFontSize,
|
|
438
456
|
spacing,
|
|
439
457
|
defaultLineHeight = 1,
|
|
@@ -449,33 +467,50 @@ export function resolveChartLayout({
|
|
|
449
467
|
);
|
|
450
468
|
const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
|
|
451
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
|
+
|
|
452
476
|
// A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
|
|
453
477
|
// finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
|
|
454
478
|
// over-reserving height and under-reserving width for a side-placed legend. This WO's width
|
|
455
479
|
// invariant does not itemize a legend-width band, so a side legend simply gets none of the
|
|
456
480
|
// height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
|
|
457
481
|
// legend is off or horizontal").
|
|
458
|
-
const legendVertical =
|
|
482
|
+
const legendVertical = effectiveLegendPosition.vertical;
|
|
459
483
|
const legendIsSidePlaced = legendVertical === 'middle';
|
|
460
484
|
const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
|
|
461
485
|
const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
|
|
462
486
|
|
|
463
487
|
const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
|
|
464
|
-
|
|
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;
|
|
465
497
|
const rightPad = PACKAGE_DEFAULT_MARGIN.right;
|
|
466
498
|
|
|
467
499
|
const resolvedXAxis = xAxisList.map((axisEntry, index) => {
|
|
468
|
-
if (index !== 0
|
|
500
|
+
if (index !== 0) return axisEntry;
|
|
469
501
|
// `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
|
|
471
|
-
// caller
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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).
|
|
475
510
|
const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
|
|
476
511
|
return {
|
|
477
512
|
...axisEntry,
|
|
478
|
-
height: xAxisBand + xTitleBand,
|
|
513
|
+
height: axisEntry.height ?? (xAxisBand + xTitleBand),
|
|
479
514
|
tickLabelStyle: { ...restTickLabelStyle, angle },
|
|
480
515
|
};
|
|
481
516
|
});
|
|
@@ -484,14 +519,16 @@ export function resolveChartLayout({
|
|
|
484
519
|
top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
|
|
485
520
|
bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
|
|
486
521
|
left: PACKAGE_DEFAULT_MARGIN.left,
|
|
487
|
-
right: rightPad,
|
|
522
|
+
right: rightPad + secondaryYAxisBand,
|
|
488
523
|
};
|
|
489
524
|
|
|
490
525
|
// `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
|
|
491
526
|
// furniture bands (see this function's own docblock for why this alone does not prove the
|
|
492
527
|
// absence of a double-counted term, and what does).
|
|
493
528
|
const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
|
|
494
|
-
const plotWidth = containerWidth != null
|
|
529
|
+
const plotWidth = containerWidth != null
|
|
530
|
+
? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
|
|
531
|
+
: undefined;
|
|
495
532
|
|
|
496
533
|
return {
|
|
497
534
|
chartHeight,
|
|
@@ -502,6 +539,7 @@ export function resolveChartLayout({
|
|
|
502
539
|
bands: {
|
|
503
540
|
plotHeight,
|
|
504
541
|
xAxisBand,
|
|
542
|
+
secondaryYAxisBand,
|
|
505
543
|
xTitleBand,
|
|
506
544
|
legendBand: legendHeightBand,
|
|
507
545
|
yAxisBand,
|
|
@@ -518,21 +556,41 @@ export function resolveChartLayout({
|
|
|
518
556
|
* migration path instead of a layout that quietly stops matching what the prop says) -- gated on
|
|
519
557
|
* `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
|
|
520
558
|
* cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
|
|
559
|
+
*
|
|
560
|
+
* `removedIn` (codex review finding, UCM-CHART-13): a shared helper hardcoding ONE WO/version
|
|
561
|
+
* string would misattribute the removal for every OTHER call site that reuses it -- `ChartFrame`
|
|
562
|
+
* (UCM-CHART-13, v3.1.0) removes `height`/`aspect` for a different reason, in a different WO, than
|
|
563
|
+
* the four presets' own `minHeight`/`aspect`/`margin` (UCM-CHART-12, v3.0.0). The default keeps
|
|
564
|
+
* every existing call site's message byte-identical; only a NEW call site needs to override it.
|
|
521
565
|
*/
|
|
522
|
-
export function assertRemovedChartProp(componentName, propName, value, replacement) {
|
|
566
|
+
export function assertRemovedChartProp(componentName, propName, value, replacement, removedIn = 'UCM-CHART-12, v3.0.0') {
|
|
523
567
|
if (value === undefined || process.env.NODE_ENV === 'production') return;
|
|
524
568
|
throw new Error(
|
|
525
|
-
`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in
|
|
526
|
-
+
|
|
569
|
+
`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in ${removedIn} `
|
|
570
|
+
+ `-- see docs/CHART-LAYOUT.md). ${replacement}`,
|
|
527
571
|
);
|
|
528
572
|
}
|
|
529
573
|
|
|
530
|
-
|
|
531
|
-
|
|
574
|
+
/**
|
|
575
|
+
* UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
|
|
576
|
+
* legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
|
|
577
|
+
* same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
|
|
578
|
+
* only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
|
|
579
|
+
* while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
|
|
580
|
+
* time -- a caller passing both could get a legend rendered top/side while the resolver had
|
|
581
|
+
* reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
|
|
582
|
+
* close. Both call sites now merge through this one function.
|
|
583
|
+
*/
|
|
584
|
+
export function resolveLegendPosition(legendPosition, slotProps) {
|
|
585
|
+
return {
|
|
532
586
|
...DEFAULT_LEGEND_POSITION,
|
|
533
587
|
...legendPosition,
|
|
534
588
|
...slotProps?.legend?.position,
|
|
535
589
|
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
593
|
+
const position = resolveLegendPosition(legendPosition, slotProps);
|
|
536
594
|
return {
|
|
537
595
|
...slotProps,
|
|
538
596
|
tooltip: {
|
|
@@ -213,10 +213,8 @@ describe('ChartFrame', () => {
|
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
// CHART-9: ChartFrame's content box is a whole CARD (title, toolbar, chart, legend, footnotes,
|
|
216
|
-
// export links), not a single chart
|
|
217
|
-
|
|
218
|
-
// take a fixed `height`; `minHeight` is always just a floor content can exceed.
|
|
219
|
-
describe('minHeight/height resolution (CHART-9)', () => {
|
|
216
|
+
// export links), not a single chart. `minHeight` is always just a floor content can exceed.
|
|
217
|
+
describe('minHeight resolution (CHART-9)', () => {
|
|
220
218
|
function contentBoxStyle() {
|
|
221
219
|
return window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
|
|
222
220
|
}
|
|
@@ -245,37 +243,56 @@ describe('ChartFrame', () => {
|
|
|
245
243
|
expect(style.height).toBe('auto');
|
|
246
244
|
});
|
|
247
245
|
|
|
248
|
-
it('leaves minHeight
|
|
249
|
-
renderFrame(
|
|
246
|
+
it('leaves minHeight unset (auto) when the caller passes none', () => {
|
|
247
|
+
renderFrame();
|
|
250
248
|
const style = contentBoxStyle();
|
|
251
|
-
expect(style.minHeight).toBe('
|
|
249
|
+
expect(style.minHeight).toBe('auto');
|
|
252
250
|
expect(style.height).toBe('auto');
|
|
253
|
-
expect(style.aspectRatio).toBe('1.8 / 1');
|
|
254
251
|
});
|
|
252
|
+
});
|
|
255
253
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
254
|
+
// UCM-CHART-13: `height`/`aspect` were destructured but never applied to the box -- measured
|
|
255
|
+
// against 3.0.1, no consumer passed either. Both are now gone; passing one throws in dev, naming
|
|
256
|
+
// the replacement, the same as the four chart presets' removed props (`UCM-CHART-12`).
|
|
257
|
+
describe('removed height/aspect props throw in dev, naming their replacement (UCM-CHART-13)', () => {
|
|
258
|
+
it('throws when a caller still passes height, naming minHeight AND size as the replacement', () => {
|
|
259
|
+
expect(() => renderFrame({ height: 380 })).toThrow(/ChartFrame.*height.*minHeight.*size/s);
|
|
261
260
|
});
|
|
262
261
|
|
|
263
|
-
it('
|
|
264
|
-
renderFrame({
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
262
|
+
it('throws when a caller still passes aspect, naming size as the replacement', () => {
|
|
263
|
+
expect(() => renderFrame({ aspect: 1.8 })).toThrow(/ChartFrame.*aspect.*size/s);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('attributes the removal to UCM-CHART-13/v3.1.0, not the presets\' own UCM-CHART-12 message', () => {
|
|
267
|
+
expect(() => renderFrame({ height: 380 })).toThrow(/UCM-CHART-13.*v3\.1\.0/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('does not throw when only minHeight is passed', () => {
|
|
271
|
+
expect(() => renderFrame({ minHeight: 320 })).not.toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('stays inert in production even when height/aspect are passed', () => {
|
|
275
|
+
const original = process.env.NODE_ENV;
|
|
276
|
+
process.env.NODE_ENV = 'production';
|
|
277
|
+
try {
|
|
278
|
+
expect(() => renderFrame({ height: 380, aspect: 1.8 })).not.toThrow();
|
|
279
|
+
} finally {
|
|
280
|
+
process.env.NODE_ENV = original;
|
|
281
|
+
}
|
|
268
282
|
});
|
|
269
283
|
|
|
270
|
-
it('
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
284
|
+
it('applies nothing to the box for a still-passed height/aspect (dev-mode error aside) -- confirmed via the production path', () => {
|
|
285
|
+
const original = process.env.NODE_ENV;
|
|
286
|
+
process.env.NODE_ENV = 'production';
|
|
287
|
+
try {
|
|
288
|
+
renderFrame({ minHeight: 320, height: 380, aspect: 1.8 });
|
|
289
|
+
const style = window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
|
|
290
|
+
expect(style.minHeight).toBe('320px');
|
|
291
|
+
expect(style.height).toBe('auto');
|
|
292
|
+
expect(style.aspectRatio).toBe('auto');
|
|
293
|
+
} finally {
|
|
294
|
+
process.env.NODE_ENV = original;
|
|
295
|
+
}
|
|
279
296
|
});
|
|
280
297
|
});
|
|
281
298
|
});
|
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
assertRemovedChartProp,
|
|
21
21
|
defaultNumericTickFormatter,
|
|
22
22
|
resolveChartLayout,
|
|
23
|
+
resolveLegendPosition,
|
|
23
24
|
sizeYAxisForContent,
|
|
24
25
|
withAxisDefaults,
|
|
26
|
+
withChartSlotDefaults,
|
|
25
27
|
} from '../src/components/charts/chartDefaults';
|
|
26
28
|
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
27
29
|
|
|
@@ -290,6 +292,15 @@ describe('removed chart props throw in dev, naming their replacement (UCM-CHART-
|
|
|
290
292
|
.toThrow(/BarChart.*minHeight.*Use size instead\./s);
|
|
291
293
|
});
|
|
292
294
|
|
|
295
|
+
// UCM-CHART-13 (codex review finding): a shared helper hardcoding one WO/version string would
|
|
296
|
+
// misattribute the removal for a different call site's own, different removal.
|
|
297
|
+
it('defaults removedIn to UCM-CHART-12/v3.0.0 (the four presets\' own call sites), but a caller can override it', () => {
|
|
298
|
+
expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.'))
|
|
299
|
+
.toThrow(/UCM-CHART-12, v3\.0\.0/);
|
|
300
|
+
expect(() => assertRemovedChartProp('ChartFrame', 'aspect', 1.8, 'Use size instead.', 'UCM-CHART-13, v3.1.0'))
|
|
301
|
+
.toThrow(/UCM-CHART-13, v3\.1\.0/);
|
|
302
|
+
});
|
|
303
|
+
|
|
293
304
|
it('assertRemovedChartProp stays inert in production', () => {
|
|
294
305
|
const original = process.env.NODE_ENV;
|
|
295
306
|
process.env.NODE_ENV = 'production';
|
|
@@ -442,6 +453,102 @@ describe('resolveChartLayout composition invariant (UCM-CHART-12)', () => {
|
|
|
442
453
|
expect(bands.yAxisBand + bands.plotWidth + bands.rightPad).toBe(600);
|
|
443
454
|
expect(bands.yAxisBand).toBeGreaterThan(0);
|
|
444
455
|
});
|
|
456
|
+
|
|
457
|
+
// Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's axis: 'secondary'
|
|
458
|
+
// feature) has a SECOND y-axis on the right, which the resolver previously ignored entirely --
|
|
459
|
+
// `sizedYAxis[0]` was the only width ever accounted for. The width invariant must include it.
|
|
460
|
+
it('accounts for a secondary (right-positioned) y-axis in both margin.right and the width invariant', () => {
|
|
461
|
+
const single = resolveChartLayout({
|
|
462
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
|
|
463
|
+
yAxis: [{ scaleType: 'linear', id: 'primary', min: 0, max: 100 }],
|
|
464
|
+
tickFontSize: 12,
|
|
465
|
+
spacing: theme.spacing,
|
|
466
|
+
containerWidth: 600,
|
|
467
|
+
});
|
|
468
|
+
const dual = resolveChartLayout({
|
|
469
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
|
|
470
|
+
yAxis: [
|
|
471
|
+
{ scaleType: 'linear', id: 'primary', position: 'left', min: 0, max: 100 },
|
|
472
|
+
{ scaleType: 'linear', id: 'secondary', position: 'right', min: 0, max: 500000 },
|
|
473
|
+
],
|
|
474
|
+
tickFontSize: 12,
|
|
475
|
+
spacing: theme.spacing,
|
|
476
|
+
containerWidth: 600,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
expect(dual.bands.secondaryYAxisBand).toBeGreaterThan(0);
|
|
480
|
+
// The wider secondary axis (500000 vs 100) must reserve MORE than the primary alone.
|
|
481
|
+
expect(dual.margin.right).toBeGreaterThan(single.margin.right);
|
|
482
|
+
expect(dual.margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right + dual.bands.secondaryYAxisBand);
|
|
483
|
+
// Width invariant, now with the secondary band as a fourth term.
|
|
484
|
+
expect(dual.bands.yAxisBand + dual.bands.plotWidth + dual.bands.secondaryYAxisBand + dual.bands.rightPad)
|
|
485
|
+
.toBe(600);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('an unmarked (no explicit position) second y-axis defaults to the LEFT, matching MUI, and reserves no secondary band', () => {
|
|
489
|
+
const layout = resolveChartLayout({
|
|
490
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
491
|
+
yAxis: [
|
|
492
|
+
{ scaleType: 'linear', id: 'a', min: 0, max: 1 },
|
|
493
|
+
{ scaleType: 'linear', id: 'b', min: 0, max: 1 },
|
|
494
|
+
],
|
|
495
|
+
tickFontSize: 12,
|
|
496
|
+
spacing: theme.spacing,
|
|
497
|
+
});
|
|
498
|
+
expect(layout.bands.secondaryYAxisBand).toBe(0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Codex second-pass review finding: a caller-supplied `valueFormatter` that blanks every tick
|
|
502
|
+
// must collapse the y-axis band to zero too (Rule 2's general principle), not just the x-axis
|
|
503
|
+
// case the WO's Definition of Done names explicitly.
|
|
504
|
+
it('collapses the y-axis band to zero when every tick formats to a blank string', () => {
|
|
505
|
+
const layout = resolveChartLayout({
|
|
506
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
507
|
+
yAxis: [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }],
|
|
508
|
+
tickFontSize: 12,
|
|
509
|
+
spacing: theme.spacing,
|
|
510
|
+
});
|
|
511
|
+
expect(layout.bands.yAxisBand).toBe(0);
|
|
512
|
+
expect(layout.yAxis[0].width).toBe(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Codex second-pass review finding: `slotProps.legend.position` can override `legendPosition` at
|
|
516
|
+
// render time (`withChartSlotDefaults`) -- the resolver must reserve space for wherever the
|
|
517
|
+
// legend ACTUALLY renders, not just what `legendPosition` alone says.
|
|
518
|
+
it('reserves space for the slotProps.legend.position override, not just legendPosition', () => {
|
|
519
|
+
const layout = resolveChartLayout({
|
|
520
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
521
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
522
|
+
legendPosition: { vertical: 'bottom' },
|
|
523
|
+
slotProps: { legend: { position: { vertical: 'top' } } },
|
|
524
|
+
spacing: theme.spacing,
|
|
525
|
+
});
|
|
526
|
+
expect(layout.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
|
|
527
|
+
expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('resolveLegendPosition matches withChartSlotDefaults\' own merge precedence exactly', () => {
|
|
531
|
+
const legendPosition = { vertical: 'bottom', horizontal: 'start' };
|
|
532
|
+
const slotProps = { legend: { position: { horizontal: 'end' } } };
|
|
533
|
+
const resolved = resolveLegendPosition(legendPosition, slotProps);
|
|
534
|
+
const fromSlotDefaults = withChartSlotDefaults(slotProps, legendPosition).legend.position;
|
|
535
|
+
expect(resolved).toEqual(fromSlotDefaults);
|
|
536
|
+
expect(resolved).toEqual({ vertical: 'bottom', horizontal: 'end' });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Codex second-pass review finding: a caller-set `xAxis[0].height` bypasses the model's SIZE,
|
|
540
|
+
// but rotation must still be governed entirely by `xLabels` -- a stale caller-set angle must not
|
|
541
|
+
// survive under a mode that says "no rotation".
|
|
542
|
+
it('still governs rotation via xLabels even when the caller sets an explicit axis height', () => {
|
|
543
|
+
const layout = resolveChartLayout({
|
|
544
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'], height: 60, tickLabelStyle: { angle: -60 } }],
|
|
545
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
546
|
+
xLabels: 'horizontal',
|
|
547
|
+
spacing: theme.spacing,
|
|
548
|
+
});
|
|
549
|
+
expect(layout.xAxis[0].height).toBe(60); // the caller's own height escape still wins
|
|
550
|
+
expect(layout.xAxis[0].tickLabelStyle.angle).toBeFalsy(); // but the stale rotation does not survive
|
|
551
|
+
});
|
|
445
552
|
});
|
|
446
553
|
|
|
447
554
|
describe('resolveChartLayout size tokens and height escape (UCM-CHART-12)', () => {
|
|
@@ -491,12 +598,17 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
|
|
|
491
598
|
expect(layout.xAxis[0].tickLabelStyle.angle).toBe(-45);
|
|
492
599
|
});
|
|
493
600
|
|
|
494
|
-
it('"auto" stays horizontal for a few short labels, rotates for many long ones', () => {
|
|
601
|
+
it('"auto" stays horizontal for a few short labels, rotates for many long ones (measured width, not char count)', () => {
|
|
602
|
+
// codex second-pass review finding: "auto" must decide from the label's MEASURED width, not a
|
|
603
|
+
// raw character count -- these inject a deterministic measurer instead of relying on the
|
|
604
|
+
// per-glyph estimate, and a `tickFontSize` fallback so the resolver can actually measure.
|
|
495
605
|
const few = resolveChartLayout({
|
|
496
606
|
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar'] }],
|
|
497
607
|
yAxis: [{ scaleType: 'linear' }],
|
|
498
608
|
xLabels: 'auto',
|
|
609
|
+
tickFontSize: 12,
|
|
499
610
|
spacing: theme.spacing,
|
|
611
|
+
measureTextWidth: () => 20, // narrow -- well under the rotation threshold
|
|
500
612
|
});
|
|
501
613
|
expect(few.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
502
614
|
|
|
@@ -504,11 +616,37 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
|
|
|
504
616
|
xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `Ward name ${i}`) }],
|
|
505
617
|
yAxis: [{ scaleType: 'linear' }],
|
|
506
618
|
xLabels: 'auto',
|
|
619
|
+
tickFontSize: 12,
|
|
507
620
|
spacing: theme.spacing,
|
|
621
|
+
measureTextWidth: () => 90, // wide -- over the rotation threshold
|
|
508
622
|
});
|
|
509
623
|
expect(many.xAxis[0].tickLabelStyle.angle).toBe(-45);
|
|
510
624
|
});
|
|
511
625
|
|
|
626
|
+
it('"auto" does not rotate a few WIDE labels -- tick count and measured width must both cross the threshold', () => {
|
|
627
|
+
const layout = resolveChartLayout({
|
|
628
|
+
xAxis: [{ scaleType: 'band', data: ['A very wide single label'] }],
|
|
629
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
630
|
+
xLabels: 'auto',
|
|
631
|
+
tickFontSize: 12,
|
|
632
|
+
spacing: theme.spacing,
|
|
633
|
+
measureTextWidth: () => 200, // wide, but only one category
|
|
634
|
+
});
|
|
635
|
+
expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('"auto" does not rotate many labels that are individually narrow', () => {
|
|
639
|
+
const layout = resolveChartLayout({
|
|
640
|
+
xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `${i}`) }],
|
|
641
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
642
|
+
xLabels: 'auto',
|
|
643
|
+
tickFontSize: 12,
|
|
644
|
+
spacing: theme.spacing,
|
|
645
|
+
measureTextWidth: () => 8, // many categories, but each label is tiny
|
|
646
|
+
});
|
|
647
|
+
expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
648
|
+
});
|
|
649
|
+
|
|
512
650
|
it('drives the rotated tick band from a measured width, not a flat constant', () => {
|
|
513
651
|
const short = resolveChartLayout({
|
|
514
652
|
xAxis: [{ scaleType: 'band', data: ['A'], tickLabelStyle: { fontSize: 12 } }],
|
|
@@ -655,6 +793,24 @@ describe('sizeYAxisForContent (THEME-9)', () => {
|
|
|
655
793
|
expect(axis.width).toBe(42);
|
|
656
794
|
});
|
|
657
795
|
|
|
796
|
+
// UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed (min/max set), but
|
|
797
|
+
// a caller `valueFormatter` reduced every tick to a blank string -- distinct from "nothing to
|
|
798
|
+
// measure at all" (the untouched/undefined case below), this must collapse to a real 0, not the
|
|
799
|
+
// font-size floor a blank string previously still received.
|
|
800
|
+
it('collapses to width 0 when every formatted tick is blank and there is no axis title', () => {
|
|
801
|
+
const [axis] = sizeYAxisForContent(
|
|
802
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }], [], '12px',
|
|
803
|
+
);
|
|
804
|
+
expect(axis.width).toBe(0);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('still reserves width for the axis TITLE when ticks are blank but a label is set', () => {
|
|
808
|
+
const [axis] = sizeYAxisForContent(
|
|
809
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '', label: 'Coverage' }], [], '12px',
|
|
810
|
+
);
|
|
811
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
812
|
+
});
|
|
813
|
+
|
|
658
814
|
it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
|
|
659
815
|
const [axis] = sizeYAxisForContent(
|
|
660
816
|
[{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
|