@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
|
@@ -15,14 +15,15 @@ import { BarChart } from '../src/components/charts/BarChart';
|
|
|
15
15
|
import { LineChart } from '../src/components/charts/LineChart';
|
|
16
16
|
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
17
17
|
import {
|
|
18
|
-
|
|
18
|
+
CHART_SIZE_SPACING_UNITS,
|
|
19
19
|
PACKAGE_DEFAULT_MARGIN,
|
|
20
|
-
|
|
20
|
+
assertRemovedChartProp,
|
|
21
|
+
defaultNumericTickFormatter,
|
|
22
|
+
resolveChartLayout,
|
|
23
|
+
resolveLegendPosition,
|
|
21
24
|
sizeYAxisForContent,
|
|
22
|
-
spaceForRotatedTicks,
|
|
23
|
-
warnOnHeightMismatch,
|
|
24
25
|
withAxisDefaults,
|
|
25
|
-
|
|
26
|
+
withChartSlotDefaults,
|
|
26
27
|
} from '../src/components/charts/chartDefaults';
|
|
27
28
|
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
28
29
|
|
|
@@ -113,6 +114,9 @@ describe('chart chrome defaults', () => {
|
|
|
113
114
|
expect(timeSeriesProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
114
115
|
expect(timeSeriesProps.grid).toEqual({ horizontal: true });
|
|
115
116
|
expect(timeSeriesProps.skipAnimation).toBe(true);
|
|
117
|
+
// UCM-CHART-12: TimeSeriesChart's inner BarChart now takes size="standard" instead of a
|
|
118
|
+
// fixed height prop -- the resolved height must still land on the historical 320px default.
|
|
119
|
+
expect(timeSeriesProps.height).toBe(320);
|
|
116
120
|
});
|
|
117
121
|
|
|
118
122
|
it('keeps caller tick font, grid, showMark, and legendPosition values instead of the defaults', () => {
|
|
@@ -163,67 +167,71 @@ describe('chart chrome defaults', () => {
|
|
|
163
167
|
expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
|
|
164
168
|
});
|
|
165
169
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
// UCM-CHART-12: rotation is now an explicit xLabels="angled" request, not an implicit
|
|
171
|
+
// consequence of a caller-supplied tickLabelStyle.angle -- replaces the old CHART-10 regression
|
|
172
|
+
// test (grows only xAxis.height, never margin.bottom on top of it) against the new API.
|
|
173
|
+
it('grows only xAxis.height for xLabels="angled", never margin.bottom on top of it', () => {
|
|
174
|
+
renderBar({
|
|
175
|
+
xAxis: [{ data: ['A label long enough to need real rotation room'] }],
|
|
176
|
+
xLabels: 'angled',
|
|
177
|
+
});
|
|
170
178
|
const rotatedProps = barSpy.mock.calls.at(-1)[0];
|
|
171
179
|
expect(rotatedProps.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
172
180
|
expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
|
|
173
181
|
|
|
174
182
|
cleanup();
|
|
175
|
-
renderBar({ xAxis: [{ data: ['Jan', 'Feb'],
|
|
176
|
-
expect(barSpy.mock.calls.at(-1)[0].margin).
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// THEME-11 -- the package
|
|
180
|
-
// every side
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'] }], xLabels: 'horizontal' });
|
|
184
|
+
expect(barSpy.mock.calls.at(-1)[0].margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// THEME-11 -- the package forms its own opinion on chart margins (was: MUI's own flat 20px on
|
|
188
|
+
// every side). UCM-CHART-12: there is no caller-facing `margin` prop any more (the model owns
|
|
189
|
+
// spacing completely), so these assert the rendered default directly rather than through
|
|
190
|
+
// `withMarginDefaults`, which UCM-CHART-12 removed as dead code once its only caller (the
|
|
191
|
+
// deleted `margin` prop) was gone.
|
|
192
|
+
describe('package margin default (THEME-11 / UCM-CHART-12)', () => {
|
|
193
|
+
it('applies the package default on every side when there is no legend to make room for', () => {
|
|
194
|
+
renderBar({ hideLegend: true });
|
|
184
195
|
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
185
196
|
});
|
|
186
197
|
|
|
187
|
-
// CHART-10: the axis band, not the margin, carries the rotated-tick allowance -- a
|
|
188
|
-
// rotated-tick chart gets the SAME flat package bottom margin as any other chart. Named
|
|
189
|
-
// "still gives" in CHART-8/9 history; the correct behaviour is now the opposite of that name.
|
|
190
198
|
it('gives a rotated-tick chart the same flat package bottom margin as an unrotated one', () => {
|
|
191
|
-
renderBar({
|
|
199
|
+
renderBar({
|
|
200
|
+
xAxis: [{ data: ['January', 'February'] }],
|
|
201
|
+
xLabels: 'angled',
|
|
202
|
+
hideLegend: true,
|
|
203
|
+
});
|
|
192
204
|
const props = barSpy.mock.calls.at(-1)[0];
|
|
193
205
|
expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
194
206
|
expect(props.xAxis[0].height).toBeGreaterThan(45);
|
|
195
207
|
});
|
|
196
208
|
|
|
197
|
-
// Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
|
|
198
|
-
// side it set and gets the package default on the others -- never MUI's wider 20px, and
|
|
199
|
-
// never silently reset to the package default on the side the caller DID set.
|
|
200
|
-
it('merges a caller-supplied partial margin per side, not wholesale', () => {
|
|
201
|
-
renderBar({ margin: { left: 60 } });
|
|
202
|
-
const { margin } = barSpy.mock.calls.at(-1)[0];
|
|
203
|
-
expect(margin.left).toBe(60);
|
|
204
|
-
expect(margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
|
|
205
|
-
expect(margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
206
|
-
expect(margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('keeps every side a caller sets when they supply a full margin object', () => {
|
|
210
|
-
renderBar({ margin: { top: 1, bottom: 2, left: 3, right: 4 } });
|
|
211
|
-
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual({ top: 1, bottom: 2, left: 3, right: 4 });
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// A LINEAR x-axis's last tick label sits AT the plot edge (unlike a band axis's centred
|
|
215
|
-
// ticks) and overhangs by about half its width -- the reason `right` is trimmed less than
|
|
216
|
-
// `left`/`bottom`. Pinned here as a value; the actual no-clip claim is verified by the
|
|
217
|
-
// rendered check (WO Part C), not derivable from a mocked MUI component.
|
|
218
209
|
it('keeps a wider default right margin than left/bottom, for a linear x-axis\'s edge-sitting last tick', () => {
|
|
219
210
|
expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.left);
|
|
220
211
|
expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
221
212
|
});
|
|
222
213
|
|
|
223
214
|
it('applies the SAME package default to LineChart as BarChart', () => {
|
|
224
|
-
renderLine();
|
|
215
|
+
renderLine({ hideLegend: true });
|
|
225
216
|
expect(lineSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
226
217
|
});
|
|
218
|
+
|
|
219
|
+
it('grows margin.bottom by the legend band when a legend renders at the bottom (the default)', () => {
|
|
220
|
+
renderBar({ series: [{ data: [1] }, { data: [2] }] }); // 2 series -> legend shown
|
|
221
|
+
const props = barSpy.mock.calls.at(-1)[0];
|
|
222
|
+
expect(props.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
223
|
+
expect(props.margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('grows margin.top instead when legendPosition.vertical is "top"', () => {
|
|
227
|
+
renderBar({
|
|
228
|
+
series: [{ data: [1] }, { data: [2] }],
|
|
229
|
+
legendPosition: { vertical: 'top' },
|
|
230
|
+
});
|
|
231
|
+
const props = barSpy.mock.calls.at(-1)[0];
|
|
232
|
+
expect(props.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
|
|
233
|
+
expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
234
|
+
});
|
|
227
235
|
});
|
|
228
236
|
|
|
229
237
|
it('uses the series colour as the fill when a caller opts line markers back in', () => {
|
|
@@ -269,6 +277,396 @@ describe('chart chrome defaults', () => {
|
|
|
269
277
|
});
|
|
270
278
|
});
|
|
271
279
|
|
|
280
|
+
// UCM-CHART-12: removed props (`minHeight`, `aspect`, `margin`) throw a dev-mode error naming
|
|
281
|
+
// their replacement instead of being silently ignored -- 38 existing call sites across the estate
|
|
282
|
+
// hit this. Gated on NODE_ENV so a shared package cannot crash a consumer's production page.
|
|
283
|
+
describe('removed chart props throw in dev, naming their replacement (UCM-CHART-12)', () => {
|
|
284
|
+
afterEach(cleanup);
|
|
285
|
+
|
|
286
|
+
it('assertRemovedChartProp is a no-op when the value is unset', () => {
|
|
287
|
+
expect(() => assertRemovedChartProp('BarChart', 'minHeight', undefined, 'x')).not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('assertRemovedChartProp throws in development, naming the component, prop, and replacement', () => {
|
|
291
|
+
expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.'))
|
|
292
|
+
.toThrow(/BarChart.*minHeight.*Use size instead\./s);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('assertRemovedChartProp stays inert in production', () => {
|
|
296
|
+
const original = process.env.NODE_ENV;
|
|
297
|
+
process.env.NODE_ENV = 'production';
|
|
298
|
+
try {
|
|
299
|
+
expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.')).not.toThrow();
|
|
300
|
+
} finally {
|
|
301
|
+
process.env.NODE_ENV = original;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it.each([
|
|
306
|
+
['BarChart', BarChart, 'minHeight', 320],
|
|
307
|
+
['BarChart', BarChart, 'aspect', 1.8],
|
|
308
|
+
['BarChart', BarChart, 'margin', { top: 1 }],
|
|
309
|
+
['LineChart', LineChart, 'minHeight', 320],
|
|
310
|
+
['LineChart', LineChart, 'aspect', 1.8],
|
|
311
|
+
['LineChart', LineChart, 'margin', { top: 1 }],
|
|
312
|
+
])('%s throws when a caller still passes %s', (_name, Component, prop, value) => {
|
|
313
|
+
expect(() => render(withProviders(React.createElement(Component, {
|
|
314
|
+
series: [{ label: 'Cases', data: [1, 2] }],
|
|
315
|
+
[prop]: value,
|
|
316
|
+
})))).toThrow();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// UCM-CHART-12 -- the composition invariant, the actual deliverable of this WO. Two things are
|
|
321
|
+
// asserted per case, and they are NOT the same guarantee (reviewer finding R1):
|
|
322
|
+
// (1) the SUM `plotHeight + xAxisBand + xTitleBand + legendBand === chartHeight` -- true by
|
|
323
|
+
// construction (`plotHeight` is defined as the residual of the other three), so on its own
|
|
324
|
+
// this proves only that `resolveChartLayout`'s return shape is internally consistent, NOT the
|
|
325
|
+
// absence of a double-counted term.
|
|
326
|
+
// (2) the PER-TERM MAPPING -- `xAxis[0].height` carries EXACTLY `xAxisBand + xTitleBand` and
|
|
327
|
+
// nothing else, `margin`'s growth over the package baseline carries EXACTLY `legendBand` and
|
|
328
|
+
// nothing else. THIS is what would have caught a `UCM-CHART-10`-shaped bug (the same
|
|
329
|
+
// allowance reserved twice, once in axis height and again in margin) -- assertion (1) alone
|
|
330
|
+
// would not have, since a term double-counted in both places still sums correctly.
|
|
331
|
+
describe('resolveChartLayout composition invariant (UCM-CHART-12)', () => {
|
|
332
|
+
const bandAxis = (data) => [{ scaleType: 'band', data, label: undefined }];
|
|
333
|
+
|
|
334
|
+
it.each([
|
|
335
|
+
['none', []],
|
|
336
|
+
['short', ['Jan', 'Feb']],
|
|
337
|
+
['long', ['A much longer ward name than the others']],
|
|
338
|
+
['long-and-many', Array.from({ length: 12 }, (_, i) => `Ward number ${i} with a longish name`)],
|
|
339
|
+
['empty-strings', ['', '', '']],
|
|
340
|
+
])('holds the height invariant for the "%s" label load, with no term double-counted', (_label, data) => {
|
|
341
|
+
const layout = resolveChartLayout({
|
|
342
|
+
size: 'standard',
|
|
343
|
+
xAxis: bandAxis(data),
|
|
344
|
+
yAxis: [{ scaleType: 'linear', min: 0, max: 100 }],
|
|
345
|
+
spacing: theme.spacing,
|
|
346
|
+
measureTextWidth: () => null, // force the deterministic estimate fallback
|
|
347
|
+
});
|
|
348
|
+
const { bands } = layout;
|
|
349
|
+
expect(bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand)
|
|
350
|
+
.toBe(layout.chartHeight);
|
|
351
|
+
// The per-term mapping -- the actual double-count guard (see the describe-level comment).
|
|
352
|
+
expect(layout.xAxis[0].height).toBe(bands.xAxisBand + bands.xTitleBand);
|
|
353
|
+
expect(layout.margin.top + layout.margin.bottom - PACKAGE_DEFAULT_MARGIN.top - PACKAGE_DEFAULT_MARGIN.bottom)
|
|
354
|
+
.toBe(bands.legendBand);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('collapses the x-axis band to zero when every tick label is blank (the Access-scatter case)', () => {
|
|
358
|
+
const layout = resolveChartLayout({
|
|
359
|
+
xAxis: [{ scaleType: 'band', data: ['', '', ''] }],
|
|
360
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
361
|
+
spacing: theme.spacing,
|
|
362
|
+
});
|
|
363
|
+
expect(layout.bands.xAxisBand).toBe(0);
|
|
364
|
+
expect(layout.xAxis[0].height).toBe(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('collapses the x-axis band to zero when there is no tick data at all', () => {
|
|
368
|
+
const layout = resolveChartLayout({
|
|
369
|
+
xAxis: [{ scaleType: 'band', data: [] }],
|
|
370
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
371
|
+
spacing: theme.spacing,
|
|
372
|
+
});
|
|
373
|
+
expect(layout.bands.xAxisBand).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('assumes non-empty content when the axis has no data array at all (a numeric scale)', () => {
|
|
377
|
+
const layout = resolveChartLayout({
|
|
378
|
+
xAxis: [{ scaleType: 'linear' }],
|
|
379
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
380
|
+
spacing: theme.spacing,
|
|
381
|
+
});
|
|
382
|
+
expect(layout.bands.xAxisBand).toBeGreaterThan(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('reserves the x-title band only when the x-axis carries a label', () => {
|
|
386
|
+
const withoutTitle = resolveChartLayout({
|
|
387
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
388
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
389
|
+
spacing: theme.spacing,
|
|
390
|
+
});
|
|
391
|
+
expect(withoutTitle.bands.xTitleBand).toBe(0);
|
|
392
|
+
|
|
393
|
+
const withTitle = resolveChartLayout({
|
|
394
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'], label: 'Month' }],
|
|
395
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
396
|
+
spacing: theme.spacing,
|
|
397
|
+
});
|
|
398
|
+
expect(withTitle.bands.xTitleBand).toBeGreaterThan(0);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('reserves the legend band only when the legend is not hidden', () => {
|
|
402
|
+
const hidden = resolveChartLayout({
|
|
403
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
404
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
405
|
+
hideLegend: true,
|
|
406
|
+
spacing: theme.spacing,
|
|
407
|
+
});
|
|
408
|
+
expect(hidden.bands.legendBand).toBe(0);
|
|
409
|
+
|
|
410
|
+
const shown = resolveChartLayout({
|
|
411
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
412
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
413
|
+
hideLegend: false,
|
|
414
|
+
spacing: theme.spacing,
|
|
415
|
+
});
|
|
416
|
+
expect(shown.bands.legendBand).toBeGreaterThan(0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Reviewer finding R2: a `vertical: 'middle'` legend renders on the SIDE (consuming width, not
|
|
420
|
+
// height) -- it must not reserve a height band or grow margin.top/bottom, or the height
|
|
421
|
+
// invariant would over-reserve height for space the legend never occupies there.
|
|
422
|
+
it('reserves no height band for a side-placed ("middle") legend', () => {
|
|
423
|
+
const layout = resolveChartLayout({
|
|
424
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
425
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
426
|
+
hideLegend: false,
|
|
427
|
+
legendPosition: { vertical: 'middle' },
|
|
428
|
+
spacing: theme.spacing,
|
|
429
|
+
});
|
|
430
|
+
expect(layout.bands.legendBand).toBe(0);
|
|
431
|
+
expect(layout.margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
|
|
432
|
+
expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('holds the width invariant when a containerWidth is supplied (test-only -- presets never pass this)', () => {
|
|
436
|
+
const layout = resolveChartLayout({
|
|
437
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
|
|
438
|
+
yAxis: [{ scaleType: 'linear', min: 0, max: 250000 }],
|
|
439
|
+
tickFontSize: 12,
|
|
440
|
+
spacing: theme.spacing,
|
|
441
|
+
containerWidth: 600,
|
|
442
|
+
});
|
|
443
|
+
const { bands } = layout;
|
|
444
|
+
expect(bands.yAxisBand + bands.plotWidth + bands.rightPad).toBe(600);
|
|
445
|
+
expect(bands.yAxisBand).toBeGreaterThan(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's axis: 'secondary'
|
|
449
|
+
// feature) has a SECOND y-axis on the right, which the resolver previously ignored entirely --
|
|
450
|
+
// `sizedYAxis[0]` was the only width ever accounted for. The width invariant must include it.
|
|
451
|
+
it('accounts for a secondary (right-positioned) y-axis in both margin.right and the width invariant', () => {
|
|
452
|
+
const single = resolveChartLayout({
|
|
453
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
|
|
454
|
+
yAxis: [{ scaleType: 'linear', id: 'primary', min: 0, max: 100 }],
|
|
455
|
+
tickFontSize: 12,
|
|
456
|
+
spacing: theme.spacing,
|
|
457
|
+
containerWidth: 600,
|
|
458
|
+
});
|
|
459
|
+
const dual = resolveChartLayout({
|
|
460
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
|
|
461
|
+
yAxis: [
|
|
462
|
+
{ scaleType: 'linear', id: 'primary', position: 'left', min: 0, max: 100 },
|
|
463
|
+
{ scaleType: 'linear', id: 'secondary', position: 'right', min: 0, max: 500000 },
|
|
464
|
+
],
|
|
465
|
+
tickFontSize: 12,
|
|
466
|
+
spacing: theme.spacing,
|
|
467
|
+
containerWidth: 600,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(dual.bands.secondaryYAxisBand).toBeGreaterThan(0);
|
|
471
|
+
// The wider secondary axis (500000 vs 100) must reserve MORE than the primary alone.
|
|
472
|
+
expect(dual.margin.right).toBeGreaterThan(single.margin.right);
|
|
473
|
+
expect(dual.margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right + dual.bands.secondaryYAxisBand);
|
|
474
|
+
// Width invariant, now with the secondary band as a fourth term.
|
|
475
|
+
expect(dual.bands.yAxisBand + dual.bands.plotWidth + dual.bands.secondaryYAxisBand + dual.bands.rightPad)
|
|
476
|
+
.toBe(600);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('an unmarked (no explicit position) second y-axis defaults to the LEFT, matching MUI, and reserves no secondary band', () => {
|
|
480
|
+
const layout = resolveChartLayout({
|
|
481
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
482
|
+
yAxis: [
|
|
483
|
+
{ scaleType: 'linear', id: 'a', min: 0, max: 1 },
|
|
484
|
+
{ scaleType: 'linear', id: 'b', min: 0, max: 1 },
|
|
485
|
+
],
|
|
486
|
+
tickFontSize: 12,
|
|
487
|
+
spacing: theme.spacing,
|
|
488
|
+
});
|
|
489
|
+
expect(layout.bands.secondaryYAxisBand).toBe(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Codex second-pass review finding: a caller-supplied `valueFormatter` that blanks every tick
|
|
493
|
+
// must collapse the y-axis band to zero too (Rule 2's general principle), not just the x-axis
|
|
494
|
+
// case the WO's Definition of Done names explicitly.
|
|
495
|
+
it('collapses the y-axis band to zero when every tick formats to a blank string', () => {
|
|
496
|
+
const layout = resolveChartLayout({
|
|
497
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
498
|
+
yAxis: [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }],
|
|
499
|
+
tickFontSize: 12,
|
|
500
|
+
spacing: theme.spacing,
|
|
501
|
+
});
|
|
502
|
+
expect(layout.bands.yAxisBand).toBe(0);
|
|
503
|
+
expect(layout.yAxis[0].width).toBe(0);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Codex second-pass review finding: `slotProps.legend.position` can override `legendPosition` at
|
|
507
|
+
// render time (`withChartSlotDefaults`) -- the resolver must reserve space for wherever the
|
|
508
|
+
// legend ACTUALLY renders, not just what `legendPosition` alone says.
|
|
509
|
+
it('reserves space for the slotProps.legend.position override, not just legendPosition', () => {
|
|
510
|
+
const layout = resolveChartLayout({
|
|
511
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
512
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
513
|
+
legendPosition: { vertical: 'bottom' },
|
|
514
|
+
slotProps: { legend: { position: { vertical: 'top' } } },
|
|
515
|
+
spacing: theme.spacing,
|
|
516
|
+
});
|
|
517
|
+
expect(layout.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
|
|
518
|
+
expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('resolveLegendPosition matches withChartSlotDefaults\' own merge precedence exactly', () => {
|
|
522
|
+
const legendPosition = { vertical: 'bottom', horizontal: 'start' };
|
|
523
|
+
const slotProps = { legend: { position: { horizontal: 'end' } } };
|
|
524
|
+
const resolved = resolveLegendPosition(legendPosition, slotProps);
|
|
525
|
+
const fromSlotDefaults = withChartSlotDefaults(slotProps, legendPosition).legend.position;
|
|
526
|
+
expect(resolved).toEqual(fromSlotDefaults);
|
|
527
|
+
expect(resolved).toEqual({ vertical: 'bottom', horizontal: 'end' });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Codex second-pass review finding: a caller-set `xAxis[0].height` bypasses the model's SIZE,
|
|
531
|
+
// but rotation must still be governed entirely by `xLabels` -- a stale caller-set angle must not
|
|
532
|
+
// survive under a mode that says "no rotation".
|
|
533
|
+
it('still governs rotation via xLabels even when the caller sets an explicit axis height', () => {
|
|
534
|
+
const layout = resolveChartLayout({
|
|
535
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'], height: 60, tickLabelStyle: { angle: -60 } }],
|
|
536
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
537
|
+
xLabels: 'horizontal',
|
|
538
|
+
spacing: theme.spacing,
|
|
539
|
+
});
|
|
540
|
+
expect(layout.xAxis[0].height).toBe(60); // the caller's own height escape still wins
|
|
541
|
+
expect(layout.xAxis[0].tickLabelStyle.angle).toBeFalsy(); // but the stale rotation does not survive
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe('resolveChartLayout size tokens and height escape (UCM-CHART-12)', () => {
|
|
546
|
+
it.each(Object.entries(CHART_SIZE_SPACING_UNITS))('resolves size="%s" through the theme spacing scale', (size, units) => {
|
|
547
|
+
const layout = resolveChartLayout({ size, spacing: theme.spacing });
|
|
548
|
+
expect(layout.chartHeight).toBe(units * 8);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('pins "standard" to the historical 320px default', () => {
|
|
552
|
+
const layout = resolveChartLayout({ size: 'standard', spacing: theme.spacing });
|
|
553
|
+
expect(layout.chartHeight).toBe(320);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('lets height override the size token entirely', () => {
|
|
557
|
+
const layout = resolveChartLayout({ size: 'tall', height: 111, spacing: theme.spacing });
|
|
558
|
+
expect(layout.chartHeight).toBe(111);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('throws on an unknown size token', () => {
|
|
562
|
+
expect(() => resolveChartLayout({ size: 'huge', spacing: theme.spacing })).toThrow(/Unknown chart size/);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('falls back to a numeric 8px unit when no spacing function is given', () => {
|
|
566
|
+
const layout = resolveChartLayout({ size: 'standard' });
|
|
567
|
+
expect(layout.chartHeight).toBe(320);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
|
|
572
|
+
it('"horizontal" never rotates, regardless of tick count or label length', () => {
|
|
573
|
+
const layout = resolveChartLayout({
|
|
574
|
+
xAxis: [{ scaleType: 'band', data: Array.from({ length: 20 }, (_, i) => `A rather long label ${i}`) }],
|
|
575
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
576
|
+
xLabels: 'horizontal',
|
|
577
|
+
spacing: theme.spacing,
|
|
578
|
+
});
|
|
579
|
+
expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('"angled" always rotates, even for a single short label', () => {
|
|
583
|
+
const layout = resolveChartLayout({
|
|
584
|
+
xAxis: [{ scaleType: 'band', data: ['Jan'] }],
|
|
585
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
586
|
+
xLabels: 'angled',
|
|
587
|
+
spacing: theme.spacing,
|
|
588
|
+
});
|
|
589
|
+
expect(layout.xAxis[0].tickLabelStyle.angle).toBe(-45);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('"auto" stays horizontal for a few short labels, rotates for many long ones (measured width, not char count)', () => {
|
|
593
|
+
// codex second-pass review finding: "auto" must decide from the label's MEASURED width, not a
|
|
594
|
+
// raw character count -- these inject a deterministic measurer instead of relying on the
|
|
595
|
+
// per-glyph estimate, and a `tickFontSize` fallback so the resolver can actually measure.
|
|
596
|
+
const few = resolveChartLayout({
|
|
597
|
+
xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar'] }],
|
|
598
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
599
|
+
xLabels: 'auto',
|
|
600
|
+
tickFontSize: 12,
|
|
601
|
+
spacing: theme.spacing,
|
|
602
|
+
measureTextWidth: () => 20, // narrow -- well under the rotation threshold
|
|
603
|
+
});
|
|
604
|
+
expect(few.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
605
|
+
|
|
606
|
+
const many = resolveChartLayout({
|
|
607
|
+
xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `Ward name ${i}`) }],
|
|
608
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
609
|
+
xLabels: 'auto',
|
|
610
|
+
tickFontSize: 12,
|
|
611
|
+
spacing: theme.spacing,
|
|
612
|
+
measureTextWidth: () => 90, // wide -- over the rotation threshold
|
|
613
|
+
});
|
|
614
|
+
expect(many.xAxis[0].tickLabelStyle.angle).toBe(-45);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('"auto" does not rotate a few WIDE labels -- tick count and measured width must both cross the threshold', () => {
|
|
618
|
+
const layout = resolveChartLayout({
|
|
619
|
+
xAxis: [{ scaleType: 'band', data: ['A very wide single label'] }],
|
|
620
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
621
|
+
xLabels: 'auto',
|
|
622
|
+
tickFontSize: 12,
|
|
623
|
+
spacing: theme.spacing,
|
|
624
|
+
measureTextWidth: () => 200, // wide, but only one category
|
|
625
|
+
});
|
|
626
|
+
expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('"auto" does not rotate many labels that are individually narrow', () => {
|
|
630
|
+
const layout = resolveChartLayout({
|
|
631
|
+
xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `${i}`) }],
|
|
632
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
633
|
+
xLabels: 'auto',
|
|
634
|
+
tickFontSize: 12,
|
|
635
|
+
spacing: theme.spacing,
|
|
636
|
+
measureTextWidth: () => 8, // many categories, but each label is tiny
|
|
637
|
+
});
|
|
638
|
+
expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('drives the rotated tick band from a measured width, not a flat constant', () => {
|
|
642
|
+
const short = resolveChartLayout({
|
|
643
|
+
xAxis: [{ scaleType: 'band', data: ['A'], tickLabelStyle: { fontSize: 12 } }],
|
|
644
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
645
|
+
xLabels: 'angled',
|
|
646
|
+
spacing: theme.spacing,
|
|
647
|
+
measureTextWidth: () => 10,
|
|
648
|
+
});
|
|
649
|
+
const long = resolveChartLayout({
|
|
650
|
+
xAxis: [{ scaleType: 'band', data: ['A much longer label'], tickLabelStyle: { fontSize: 12 } }],
|
|
651
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
652
|
+
xLabels: 'angled',
|
|
653
|
+
spacing: theme.spacing,
|
|
654
|
+
measureTextWidth: () => 140,
|
|
655
|
+
});
|
|
656
|
+
expect(long.bands.xAxisBand).toBeGreaterThan(short.bands.xAxisBand);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('does not override an axis that already sets its own height', () => {
|
|
660
|
+
const layout = resolveChartLayout({
|
|
661
|
+
xAxis: [{ scaleType: 'band', data: ['January'], height: 200 }],
|
|
662
|
+
yAxis: [{ scaleType: 'linear' }],
|
|
663
|
+
xLabels: 'angled',
|
|
664
|
+
spacing: theme.spacing,
|
|
665
|
+
});
|
|
666
|
+
expect(layout.xAxis[0].height).toBe(200);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
272
670
|
// THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
|
|
273
671
|
// yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
|
|
274
672
|
// Measured live: hram's dual-axis chart carried the identical 43-character
|
|
@@ -386,6 +784,24 @@ describe('sizeYAxisForContent (THEME-9)', () => {
|
|
|
386
784
|
expect(axis.width).toBe(42);
|
|
387
785
|
});
|
|
388
786
|
|
|
787
|
+
// UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed (min/max set), but
|
|
788
|
+
// a caller `valueFormatter` reduced every tick to a blank string -- distinct from "nothing to
|
|
789
|
+
// measure at all" (the untouched/undefined case below), this must collapse to a real 0, not the
|
|
790
|
+
// font-size floor a blank string previously still received.
|
|
791
|
+
it('collapses to width 0 when every formatted tick is blank and there is no axis title', () => {
|
|
792
|
+
const [axis] = sizeYAxisForContent(
|
|
793
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }], [], '12px',
|
|
794
|
+
);
|
|
795
|
+
expect(axis.width).toBe(0);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('still reserves width for the axis TITLE when ticks are blank but a label is set', () => {
|
|
799
|
+
const [axis] = sizeYAxisForContent(
|
|
800
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '', label: 'Coverage' }], [], '12px',
|
|
801
|
+
);
|
|
802
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
803
|
+
});
|
|
804
|
+
|
|
389
805
|
it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
|
|
390
806
|
const [axis] = sizeYAxisForContent(
|
|
391
807
|
[{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
|
|
@@ -418,224 +834,3 @@ describe('sizeYAxisForContent (THEME-9)', () => {
|
|
|
418
834
|
expect(secondary.width).toBeGreaterThan(primary.width);
|
|
419
835
|
});
|
|
420
836
|
});
|
|
421
|
-
|
|
422
|
-
// THEME-11 -- pure-function contract of the merge helper itself, independent of any chart
|
|
423
|
-
// component wiring (that's covered above, in 'chart chrome defaults').
|
|
424
|
-
describe('withMarginDefaults (THEME-11)', () => {
|
|
425
|
-
it('returns the package default untouched when the caller sets no margin', () => {
|
|
426
|
-
expect(withMarginDefaults(undefined)).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('merges a partial object per side', () => {
|
|
430
|
-
expect(withMarginDefaults({ top: 40 })).toEqual({ ...PACKAGE_DEFAULT_MARGIN, top: 40 });
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it('expands a numeric shorthand margin to all four sides before merging', () => {
|
|
434
|
-
expect(withMarginDefaults(5)).toEqual({ top: 5, bottom: 5, left: 5, right: 5 });
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
it('leaves a fully-specified caller margin untouched', () => {
|
|
438
|
-
const full = { top: 1, bottom: 2, left: 3, right: 4 };
|
|
439
|
-
expect(withMarginDefaults(full)).toEqual(full);
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
describe('spaceForRotatedTicks (CHART-10)', () => {
|
|
444
|
-
const rotatedAxis = [{ data: ['January', 'February'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
445
|
-
|
|
446
|
-
it('reserves the allowance once, on axis.height, and leaves margin untouched', () => {
|
|
447
|
-
const result = spaceForRotatedTicks(rotatedAxis, undefined);
|
|
448
|
-
expect(result.xAxis[0].height).toBeGreaterThan(45);
|
|
449
|
-
expect(result.margin).toBeUndefined();
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it('is byte-identical at angle 0 -- each axis entry and the margin pass through untouched', () => {
|
|
453
|
-
const flatAxis = [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }];
|
|
454
|
-
const margin = { top: 1 };
|
|
455
|
-
const result = spaceForRotatedTicks(flatAxis, margin);
|
|
456
|
-
expect(result.xAxis[0]).toBe(flatAxis[0]);
|
|
457
|
-
expect(result.margin).toBe(margin);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it('preserves a caller-set margin.bottom untouched -- the allowance is never added on top of it', () => {
|
|
461
|
-
const margin = { bottom: 50 };
|
|
462
|
-
const result = spaceForRotatedTicks(rotatedAxis, margin);
|
|
463
|
-
expect(result.margin).toBe(margin);
|
|
464
|
-
expect(result.margin.bottom).toBe(50);
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
it('drives the height estimate from the longest formatted tick, not a flat constant', () => {
|
|
468
|
-
const shortLabels = [{ data: ['A', 'B'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
469
|
-
const longLabels = [{
|
|
470
|
-
data: ['A much longer ward name than the others', 'B'],
|
|
471
|
-
tickLabelStyle: { angle: -45, fontSize: 12 },
|
|
472
|
-
}];
|
|
473
|
-
const shortResult = spaceForRotatedTicks(shortLabels, undefined);
|
|
474
|
-
const longResult = spaceForRotatedTicks(longLabels, undefined);
|
|
475
|
-
expect(longResult.xAxis[0].height).toBeGreaterThan(shortResult.xAxis[0].height);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it('does not override an axis that already sets its own height', () => {
|
|
479
|
-
const axis = [{ data: ['January'], tickLabelStyle: { angle: -45 }, height: 200 }];
|
|
480
|
-
const result = spaceForRotatedTicks(axis, undefined);
|
|
481
|
-
expect(result.xAxis[0].height).toBe(200);
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
// CHART-11 -- the rotated-tick reservation now derives from a MEASURED (or injected-stub, for
|
|
486
|
-
// determinism) text width instead of a flat character-count multiplier, which systematically
|
|
487
|
-
// overestimated real ward-name-shaped strings. jsdom has no SVG text layout
|
|
488
|
-
// (`getComputedTextLength` is not implemented there), so these tests inject `measureTextWidth`
|
|
489
|
-
// (spaceForRotatedTicks's 4th, optional parameter) rather than depending on a real measurement.
|
|
490
|
-
describe('spaceForRotatedTicks measured width (CHART-11)', () => {
|
|
491
|
-
it('derives extraHeight from the injected measurement, not a character-count guess', () => {
|
|
492
|
-
const measurer = vi.fn(() => 100);
|
|
493
|
-
const axis = [{ data: ['x'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
|
|
494
|
-
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
495
|
-
|
|
496
|
-
// Same projection formula as `rotatedTickMetrics`, worked by hand for a deterministic
|
|
497
|
-
// expectation -- a single-character label would give almost no height under the old
|
|
498
|
-
// character-count formula, but the stub reports 100px measured, so the result must reflect
|
|
499
|
-
// that, not the string's length. Axis height is the larger of the un-rotated baseline (45)
|
|
500
|
-
// and the rotated projection plus the small clearance (CHART-11).
|
|
501
|
-
const radians = (-45 * Math.PI) / 180;
|
|
502
|
-
const projectedHeight = Math.abs(Math.cos(radians)) * 12 * 1 + Math.abs(Math.sin(radians)) * 100;
|
|
503
|
-
const neededAxisHeight = Math.ceil(projectedHeight) + 8;
|
|
504
|
-
expect(result.xAxis[0].height).toBe(Math.max(45, neededAxisHeight));
|
|
505
|
-
expect(measurer).toHaveBeenCalledWith('x', 12);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('measures the formatted (already-truncated) tick text, not a hypothetical fuller string', () => {
|
|
509
|
-
const measurer = vi.fn((text) => text.length * 10);
|
|
510
|
-
const axis = [{
|
|
511
|
-
data: ['A Very Long Ward Name That MUI Would Truncate'],
|
|
512
|
-
valueFormatter: () => "Ching'a…", // simulates MUI's own rendered, truncated tick text
|
|
513
|
-
tickLabelStyle: { angle: -45, fontSize: 12 },
|
|
514
|
-
}];
|
|
515
|
-
spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
516
|
-
expect(measurer).toHaveBeenCalledWith("Ching'a…", 12);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
it('falls back to a per-character estimate, tighter than the pre-CHART-11 flat multiplier, when no measurer is available', () => {
|
|
520
|
-
const label = 'Msolwa Station'; // spaces + mixed case, representative of a real ward name
|
|
521
|
-
const nullMeasurer = () => null;
|
|
522
|
-
const axis = [{ data: [label], tickLabelStyle: { angle: -45, fontSize: 13 } }];
|
|
523
|
-
const result = spaceForRotatedTicks(axis, undefined, 1, nullMeasurer);
|
|
524
|
-
|
|
525
|
-
// Reconstruct the height the retired flat-multiplier formula (AVERAGE_GLYPH_WIDTH_EM = 0.6)
|
|
526
|
-
// would have produced for the same label, as the comparison baseline this WO exists to beat.
|
|
527
|
-
const oldFlatTextWidth = label.length * 13 * 0.6;
|
|
528
|
-
const radians = (-45 * Math.PI) / 180;
|
|
529
|
-
const oldProjected = Math.abs(Math.cos(radians)) * 13 * 1 + Math.abs(Math.sin(radians)) * oldFlatTextWidth;
|
|
530
|
-
const oldHeight = 45 + Math.max(1, Math.ceil(oldProjected - 13));
|
|
531
|
-
expect(result.xAxis[0].height).toBeLessThan(oldHeight);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it('floors exactly at the un-rotated baseline (45), not one above it, when a short label needs no extra room', () => {
|
|
535
|
-
const measurer = () => 5; // a tiny measured width -- shorter than the clearance alone needs
|
|
536
|
-
const axis = [{ data: ['x'], tickLabelStyle: { angle: -45, fontSize: 8 } }];
|
|
537
|
-
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
538
|
-
expect(result.xAxis[0].height).toBe(45);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// Review finding (ui_reviewer): only -45deg was live-verified against hram (its one real -90deg
|
|
542
|
-
// consumer, VillageMetricsPanel.jsx, is not wired into the app -- unreachable, so it could not be
|
|
543
|
-
// live-checked). At -90deg, cos(angle) is ~0, so the lineHeight term drops out almost entirely and
|
|
544
|
-
// the projection is driven by textWidth alone -- a materially different shape from -45deg, worth
|
|
545
|
-
// its own deterministic test rather than trusting the same formula by extrapolation.
|
|
546
|
-
it('generalizes to -90deg, where the line-height term vanishes and textWidth alone drives the projection', () => {
|
|
547
|
-
const measurer = () => 130; // a long village name, matching RatioMetricsPanel's own
|
|
548
|
-
// "-90 for village (many labels)" case (fontSize 11 there)
|
|
549
|
-
const axis = [{ data: ['A Very Long Village Name'], tickLabelStyle: { angle: -90, fontSize: 11 } }];
|
|
550
|
-
const result = spaceForRotatedTicks(axis, undefined, 1, measurer);
|
|
551
|
-
|
|
552
|
-
const radians = (-90 * Math.PI) / 180;
|
|
553
|
-
const projectedHeight = Math.abs(Math.cos(radians)) * 11 * 1 + Math.abs(Math.sin(radians)) * 130;
|
|
554
|
-
const expectedHeight = Math.max(45, Math.ceil(projectedHeight) + 8);
|
|
555
|
-
expect(result.xAxis[0].height).toBe(expectedHeight);
|
|
556
|
-
// Confirms the line-height term is genuinely near-zero at -90deg -- height tracks textWidth
|
|
557
|
-
// alone within rounding, not some residual line-height contribution.
|
|
558
|
-
expect(result.xAxis[0].height).toBe(Math.max(45, 130 + 8));
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
// CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
|
|
563
|
-
// chart component wiring (component-level wiring is covered per-preset in each own test file).
|
|
564
|
-
describe('resolveChartHeight (CHART-8)', () => {
|
|
565
|
-
it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
|
|
566
|
-
expect(resolveChartHeight({ minHeight: 300 })).toEqual({ wrapperMinHeight: 300, chartHeight: 300 });
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
|
|
570
|
-
expect(resolveChartHeight({ minHeight: 320, aspect: 1.8 })).toEqual({
|
|
571
|
-
wrapperMinHeight: 320,
|
|
572
|
-
chartHeight: undefined,
|
|
573
|
-
});
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
|
|
577
|
-
expect(resolveChartHeight({ minHeight: 420, height: 380 })).toEqual({
|
|
578
|
-
wrapperMinHeight: 380,
|
|
579
|
-
chartHeight: 380,
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
it('is byte-identical (both values unchanged) when minHeight equals height', () => {
|
|
584
|
-
expect(resolveChartHeight({ minHeight: 320, height: 320 })).toEqual({
|
|
585
|
-
wrapperMinHeight: 320,
|
|
586
|
-
chartHeight: 320,
|
|
587
|
-
});
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it('leaves the wrapper unset when height is passed alone', () => {
|
|
591
|
-
expect(resolveChartHeight({ height: 280 })).toEqual({ wrapperMinHeight: undefined, chartHeight: 280 });
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it('does not cap the wrapper when minHeight is smaller than height', () => {
|
|
595
|
-
expect(resolveChartHeight({ minHeight: 200, height: 380 })).toEqual({
|
|
596
|
-
wrapperMinHeight: 200,
|
|
597
|
-
chartHeight: 380,
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
describe('warnOnHeightMismatch (CHART-8)', () => {
|
|
603
|
-
afterEach(() => vi.restoreAllMocks());
|
|
604
|
-
|
|
605
|
-
it('warns, naming the component and both values, when minHeight and height disagree', () => {
|
|
606
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
607
|
-
warnOnHeightMismatch('BarChart', { minHeight: 420, height: 380 });
|
|
608
|
-
expect(warn).toHaveBeenCalledOnce();
|
|
609
|
-
expect(warn.mock.calls[0][0]).toContain('BarChart');
|
|
610
|
-
expect(warn.mock.calls[0][0]).toContain('420');
|
|
611
|
-
expect(warn.mock.calls[0][0]).toContain('380');
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
it('does not warn when minHeight equals height', () => {
|
|
615
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
616
|
-
warnOnHeightMismatch('BarChart', { minHeight: 320, height: 320 });
|
|
617
|
-
expect(warn).not.toHaveBeenCalled();
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
it('does not warn on the legitimate minHeight + aspect (no height) combination', () => {
|
|
621
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
622
|
-
warnOnHeightMismatch('BarChart', { minHeight: 320, height: undefined });
|
|
623
|
-
expect(warn).not.toHaveBeenCalled();
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
it('does not warn when only one of the pair is set', () => {
|
|
627
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
628
|
-
warnOnHeightMismatch('BarChart', { minHeight: undefined, height: 280 });
|
|
629
|
-
expect(warn).not.toHaveBeenCalled();
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
it('gives ChartFrame-accurate wording when heightWins is false (CHART-9)', () => {
|
|
633
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
634
|
-
warnOnHeightMismatch('ChartFrame', { minHeight: 420, height: 380 }, { heightWins: false });
|
|
635
|
-
expect(warn).toHaveBeenCalledOnce();
|
|
636
|
-
const message = warn.mock.calls[0][0];
|
|
637
|
-
expect(message).toContain('ChartFrame');
|
|
638
|
-
expect(message).toContain('height is ignored');
|
|
639
|
-
expect(message).not.toContain('height wins');
|
|
640
|
-
});
|
|
641
|
-
});
|