@micha.bigler/ui-core-micha 2.39.1 → 2.41.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/DESIGN.md +2 -0
- package/WORK_ORDERS.md +3 -0
- package/dev/entries.jsx +158 -0
- package/dist/components/charts/BarChart.js +14 -8
- package/dist/components/charts/ChartFrame.js +1 -1
- package/dist/components/charts/LineChart.js +15 -9
- package/dist/components/charts/ScatterChart.js +178 -0
- package/dist/components/charts/chartDefaults.js +169 -2
- package/dist/components/charts/palette.js +6 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +21 -8
- package/src/components/charts/ChartFrame.jsx +3 -1
- package/src/components/charts/LineChart.jsx +26 -13
- package/src/components/charts/ScatterChart.jsx +277 -0
- package/src/components/charts/chartDefaults.js +174 -3
- package/src/components/charts/palette.js +6 -0
- package/src/index.js +6 -0
- package/tests/BarChart.test.jsx +20 -4
- package/tests/ScatterChart.test.jsx +234 -0
- package/tests/TimeSeriesChart.test.jsx +19 -6
- package/tests/chartDefaults.test.js +230 -3
- package/tests/chartsPalette.test.js +20 -0
- package/work-orders/THEME-10.md +303 -0
- package/work-orders/THEME-11.md +208 -0
- package/work-orders/THEME-9.md +194 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, render } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const scatterSpy = vi.hoisted(() => vi.fn(({ children }) => <div data-testid="mui-scatter">{children}</div>));
|
|
10
|
+
vi.mock('@mui/x-charts/ScatterChart', () => ({ ScatterChart: scatterSpy }));
|
|
11
|
+
vi.mock('@mui/x-charts/hooks', () => ({
|
|
12
|
+
useXScale: () => (v) => v * 10,
|
|
13
|
+
useYScale: () => (v) => 100 - v * 10,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ScatterChart,
|
|
18
|
+
ScatterReferenceCurve,
|
|
19
|
+
ScatterReferenceLine,
|
|
20
|
+
scaleBubbleRadius,
|
|
21
|
+
} from '../src/components/charts/ScatterChart';
|
|
22
|
+
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
23
|
+
|
|
24
|
+
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
25
|
+
const i18n = i18next.createInstance();
|
|
26
|
+
i18n.init({ lng: 'en', resources: { en: { translation: {} } }, interpolation: { escapeValue: false } });
|
|
27
|
+
|
|
28
|
+
function withProviders(children) {
|
|
29
|
+
return React.createElement(
|
|
30
|
+
ThemeProvider,
|
|
31
|
+
{ theme },
|
|
32
|
+
React.createElement(I18nextProvider, { i18n }, children),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderScatter(props = {}) {
|
|
37
|
+
return render(withProviders(
|
|
38
|
+
React.createElement(ScatterChart, {
|
|
39
|
+
series: [{ id: 's', data: [{ x: 1, y: 1 }] }],
|
|
40
|
+
...props,
|
|
41
|
+
}),
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// THEME-10 -- the formula the access-gap panel already hand-rolls
|
|
46
|
+
// (`max(3, 10*sqrt(pop/maxPop))`), pinned as a pure function.
|
|
47
|
+
describe('scaleBubbleRadius', () => {
|
|
48
|
+
it('matches the access-panel formula across the domain', () => {
|
|
49
|
+
expect(scaleBubbleRadius(0, 100)).toBe(3);
|
|
50
|
+
expect(scaleBubbleRadius(100, 100)).toBe(10);
|
|
51
|
+
expect(scaleBubbleRadius(25, 100)).toBeCloseTo(5, 5);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('floors at the minimum radius for a degenerate or non-finite input', () => {
|
|
55
|
+
expect(scaleBubbleRadius(5, 0)).toBe(3);
|
|
56
|
+
expect(scaleBubbleRadius(NaN, 100)).toBe(3);
|
|
57
|
+
expect(scaleBubbleRadius(5, NaN)).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('ScatterChart mark count and colour default (THEME-10)', () => {
|
|
62
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
63
|
+
|
|
64
|
+
// The failure mode nobody sees: a scatter that silently drops points.
|
|
65
|
+
it('renders every supplied point, none dropped', () => {
|
|
66
|
+
renderScatter({ series: [{ id: 's', data: [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }] }] });
|
|
67
|
+
expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults a single undifferentiated series to the neutral role colour, not a series hue', () => {
|
|
71
|
+
renderScatter();
|
|
72
|
+
expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual([theme.palette.text.secondary]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('uses the categorical palette, not the neutral tone, once a caller supplies multiple series', () => {
|
|
76
|
+
renderScatter({
|
|
77
|
+
series: [
|
|
78
|
+
{ id: 'a', data: [{ x: 1, y: 1 }] },
|
|
79
|
+
{ id: 'b', data: [{ x: 2, y: 2 }] },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
const { colors } = scatterSpy.mock.calls.at(-1)[0];
|
|
83
|
+
expect(colors).not.toEqual([theme.palette.text.secondary]);
|
|
84
|
+
expect(colors.length).toBeGreaterThan(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('lets a caller override the palette explicitly (e.g. a family/role register)', () => {
|
|
88
|
+
renderScatter({ palette: ['#ABCDEF'] });
|
|
89
|
+
expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual(['#ABCDEF']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// reviewer/ui_reviewer U3: a single series that sets its OWN colour is not "undifferentiated"
|
|
93
|
+
// -- the neutral default only applies when nothing else specifies a colour. This pins that
|
|
94
|
+
// ScatterChart itself never strips or overrides a series' own `.color` (MUI's own resolution
|
|
95
|
+
// of series.color vs. the colors fallback array is MUI's own contract, out of scope here --
|
|
96
|
+
// same boundary BarChart.test.jsx already draws for the identical `colors={palette ||
|
|
97
|
+
// neutralPalette.categorical}` pattern).
|
|
98
|
+
it('leaves a single series\' own explicit colour untouched and does not treat it as neutral', () => {
|
|
99
|
+
renderScatter({ series: [{ id: 's', color: '#123456', data: [{ x: 1, y: 1 }] }] });
|
|
100
|
+
const props = scatterSpy.mock.calls.at(-1)[0];
|
|
101
|
+
expect(props.series[0].color).toBe('#123456');
|
|
102
|
+
expect(props.colors).not.toEqual([theme.palette.text.secondary]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('ScatterChart bubble z-order (THEME-10)', () => {
|
|
107
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
108
|
+
|
|
109
|
+
it('sorts each series largest-first when sizeAccessor is set, so a small bubble is never hidden under a large one', () => {
|
|
110
|
+
renderScatter({
|
|
111
|
+
sizeAccessor: (p) => p.pop,
|
|
112
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, pop: 10 }, { x: 2, y: 2, pop: 500 }, { x: 3, y: 3, pop: 100 }] }],
|
|
113
|
+
});
|
|
114
|
+
const data = scatterSpy.mock.calls.at(-1)[0].series[0].data;
|
|
115
|
+
expect(data.map((p) => p.pop)).toEqual([500, 100, 10]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves point order untouched outside bubble mode', () => {
|
|
119
|
+
const original = [{ x: 1, y: 1 }, { x: 2, y: 2 }];
|
|
120
|
+
renderScatter({ series: [{ id: 's', data: original }] });
|
|
121
|
+
expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toEqual(original);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('ScatterChart per-point marker style (THEME-10)', () => {
|
|
126
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
127
|
+
|
|
128
|
+
it('renders a hollow marker (fill: none) only for the point getPointStyle marks hollow', () => {
|
|
129
|
+
renderScatter({
|
|
130
|
+
getPointStyle: (p) => (p.isStatusQuo ? { hollow: true } : {}),
|
|
131
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, isStatusQuo: true }, { x: 2, y: 2, isStatusQuo: false }] }],
|
|
132
|
+
});
|
|
133
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
134
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
135
|
+
|
|
136
|
+
const hollow = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
137
|
+
const filled = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
138
|
+
|
|
139
|
+
expect(hollow.container.querySelector('circle').getAttribute('fill')).toBe('none');
|
|
140
|
+
expect(filled.container.querySelector('circle').getAttribute('fill')).toBe('#111');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('sizes a bubble marker from sizeAccessor via scaleBubbleRadius, resolved against the POST-sort index', () => {
|
|
144
|
+
renderScatter({
|
|
145
|
+
sizeAccessor: (p) => p.pop,
|
|
146
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, pop: 0 }, { x: 2, y: 2, pop: 100 }] }],
|
|
147
|
+
});
|
|
148
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
149
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
150
|
+
|
|
151
|
+
// Data was re-sorted largest-first: index 0 is now pop:100, index 1 is pop:0.
|
|
152
|
+
const big = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
153
|
+
const small = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
154
|
+
|
|
155
|
+
expect(Number(big.container.querySelector('circle').getAttribute('r'))).toBeCloseTo(10, 5);
|
|
156
|
+
expect(Number(small.container.querySelector('circle').getAttribute('r'))).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Live-render caught regression: bubble sizing must share ONE max across every series (a
|
|
160
|
+
// single comparable scale, matching the access-gap panel's own
|
|
161
|
+
// `maxPop = Math.max(1, ...points.map(p => p.population))` over ALL points regardless of
|
|
162
|
+
// division/settlement) -- a per-series max would let each series' own largest point always
|
|
163
|
+
// render at full radius, making cross-series bubble size meaningless.
|
|
164
|
+
it('sizes bubbles against ONE chart-wide max across all series, not a max per series', () => {
|
|
165
|
+
renderScatter({
|
|
166
|
+
sizeAccessor: (p) => p.pop,
|
|
167
|
+
series: [
|
|
168
|
+
{ id: 'small-series', data: [{ x: 1, y: 1, pop: 100 }] },
|
|
169
|
+
{ id: 'big-series', data: [{ x: 2, y: 2, pop: 1000 }] },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const props = scatterSpy.mock.calls.at(-1)[0];
|
|
173
|
+
const Marker = props.slots.marker;
|
|
174
|
+
const markerProps = { color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
175
|
+
|
|
176
|
+
// If sized per-series, "small-series"'s own only point (pop:100) would be its own series
|
|
177
|
+
// max and render at full radius (10) -- wrongly implying it's as large as big-series's point.
|
|
178
|
+
const smallSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'small-series', dataIndex: 0 }));
|
|
179
|
+
const bigSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'big-series', dataIndex: 0 }));
|
|
180
|
+
|
|
181
|
+
const smallR = Number(smallSeriesPoint.container.querySelector('circle').getAttribute('r'));
|
|
182
|
+
const bigR = Number(bigSeriesPoint.container.querySelector('circle').getAttribute('r'));
|
|
183
|
+
expect(bigR).toBeCloseTo(10, 5);
|
|
184
|
+
expect(smallR).toBeCloseTo(10 * Math.sqrt(100 / 1000), 5);
|
|
185
|
+
expect(smallR).toBeLessThan(bigR);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('renders a square/diamond shape when getPointStyle requests one', () => {
|
|
189
|
+
renderScatter({
|
|
190
|
+
getPointStyle: (p) => ({ shape: p.shape }),
|
|
191
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, shape: 'square' }, { x: 2, y: 2, shape: 'diamond' }] }],
|
|
192
|
+
});
|
|
193
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
194
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
195
|
+
|
|
196
|
+
const square = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
197
|
+
const diamond = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
198
|
+
|
|
199
|
+
expect(square.container.querySelector('rect')).not.toBeNull();
|
|
200
|
+
expect(diamond.container.querySelector('polygon')).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
|
|
205
|
+
it('draws a path through the curve points using the chart\'s own live scale', () => {
|
|
206
|
+
const { container } = render(React.createElement(ScatterReferenceCurve, {
|
|
207
|
+
points: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
|
208
|
+
color: '#222',
|
|
209
|
+
}));
|
|
210
|
+
expect(container.querySelector('path').getAttribute('d')).toBe('M0,100 L10,90');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('draws a labelled reference line at the given label position', () => {
|
|
214
|
+
const { container } = render(React.createElement(ScatterReferenceLine, {
|
|
215
|
+
from: { x: 0, y: 0 },
|
|
216
|
+
to: { x: 1, y: 1 },
|
|
217
|
+
label: 'No gap',
|
|
218
|
+
labelAt: { x: 0.9, y: 0.9 },
|
|
219
|
+
color: '#222',
|
|
220
|
+
}));
|
|
221
|
+
expect(container.querySelector('line')).not.toBeNull();
|
|
222
|
+
expect(container.textContent).toBe('No gap');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('draws an unlabelled reference line when no label is given', () => {
|
|
226
|
+
const { container } = render(React.createElement(ScatterReferenceLine, {
|
|
227
|
+
from: { x: 0, y: 0 },
|
|
228
|
+
to: { x: 1, y: 1 },
|
|
229
|
+
color: '#222',
|
|
230
|
+
}));
|
|
231
|
+
expect(container.querySelector('line')).not.toBeNull();
|
|
232
|
+
expect(container.querySelector('text')).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -216,12 +216,17 @@ describe('TimeSeriesChart', () => {
|
|
|
216
216
|
],
|
|
217
217
|
};
|
|
218
218
|
|
|
219
|
-
it('CHART-5: no custom yAxis when the shared single axis is not all-integer (today\'s real case)', () => {
|
|
219
|
+
it('CHART-5: no custom (integer-blanking) yAxis formatter when the shared single axis is not all-integer (today\'s real case)', () => {
|
|
220
220
|
renderChart({ data: MIXED_DATA });
|
|
221
221
|
|
|
222
222
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
223
223
|
expect(props.yAxis).toHaveLength(1);
|
|
224
|
-
|
|
224
|
+
// THEME-9: BarChart now always supplies a default (locale-grouped, non-
|
|
225
|
+
// blanking) tick formatter, so this is no longer undefined -- but it
|
|
226
|
+
// must still be BarChart's generic default, not CHART-5's own
|
|
227
|
+
// integer-blanking one (which would wrongly blank the 4.5/5.2/6.1 ticks).
|
|
228
|
+
expect(typeof props.yAxis[0].valueFormatter).toBe('function');
|
|
229
|
+
expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
225
230
|
expect(props.yAxis[0].label).toBe('Count');
|
|
226
231
|
});
|
|
227
232
|
|
|
@@ -293,8 +298,13 @@ describe('TimeSeriesChart', () => {
|
|
|
293
298
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
294
299
|
const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
|
|
295
300
|
const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
|
|
296
|
-
expect(
|
|
297
|
-
expect(
|
|
301
|
+
expect(primaryAxis.valueFormatter(2, { location: 'tick' })).toBe('2');
|
|
302
|
+
expect(primaryAxis.valueFormatter(2.5, { location: 'tick' })).toBe('');
|
|
303
|
+
// THEME-9: BarChart's own default formatter now covers the secondary
|
|
304
|
+
// axis too (a function, not undefined) -- distinguished from the
|
|
305
|
+
// integer-blanking one above by NOT blanking a non-integer tick.
|
|
306
|
+
expect(typeof secondaryAxis.valueFormatter).toBe('function');
|
|
307
|
+
expect(secondaryAxis.valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
298
308
|
});
|
|
299
309
|
|
|
300
310
|
it('CHART-5: throws when a series declares axis "secondary" without secondaryYAxisLabel', () => {
|
|
@@ -318,12 +328,15 @@ describe('TimeSeriesChart', () => {
|
|
|
318
328
|
|
|
319
329
|
it('CHART-5: toggling the non-integer series off switches the shared axis to integer-formatted', () => {
|
|
320
330
|
renderChart({ data: MIXED_DATA });
|
|
321
|
-
|
|
331
|
+
// THEME-9: BarChart's default formatter is present from the start now,
|
|
332
|
+
// so the CHART-5-specific signal is that a non-integer tick is NOT
|
|
333
|
+
// blanked yet -- the integer-blanking formatter isn't active.
|
|
334
|
+
expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
|
|
322
335
|
|
|
323
336
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
|
|
324
337
|
|
|
325
338
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
326
|
-
expect(
|
|
339
|
+
expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).toBe('');
|
|
327
340
|
});
|
|
328
341
|
|
|
329
342
|
it('CHART-5: x-axis tickLabelInterval guarantees some labels always show, evenly spread', () => {
|
|
@@ -14,6 +14,13 @@ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'pa
|
|
|
14
14
|
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
|
+
import {
|
|
18
|
+
defaultNumericTickFormatter,
|
|
19
|
+
PACKAGE_DEFAULT_MARGIN,
|
|
20
|
+
sizeYAxisForContent,
|
|
21
|
+
withAxisDefaults,
|
|
22
|
+
withMarginDefaults,
|
|
23
|
+
} from '../src/components/charts/chartDefaults';
|
|
17
24
|
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
18
25
|
|
|
19
26
|
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
@@ -153,15 +160,64 @@ describe('chart chrome defaults', () => {
|
|
|
153
160
|
expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
|
|
154
161
|
});
|
|
155
162
|
|
|
156
|
-
it('adds a font-and-angle-derived bottom margin only for rotated tick labels', () => {
|
|
163
|
+
it('adds a font-and-angle-derived EXTRA bottom margin, on top of the package default, only for rotated tick labels', () => {
|
|
157
164
|
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
|
|
158
165
|
const rotatedProps = barSpy.mock.calls.at(-1)[0];
|
|
159
|
-
expect(rotatedProps.margin.bottom).toBeGreaterThan(
|
|
166
|
+
expect(rotatedProps.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
160
167
|
expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
|
|
161
168
|
|
|
162
169
|
cleanup();
|
|
163
170
|
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
|
|
164
|
-
expect(barSpy.mock.calls.at(-1)[0].margin).
|
|
171
|
+
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// THEME-11 -- the package now forms an opinion on chart margins (was: MUI's own flat 20px on
|
|
175
|
+
// every side, unconditionally, because this package never set one).
|
|
176
|
+
describe('withMarginDefaults / package margin default (THEME-11)', () => {
|
|
177
|
+
it('applies the package default on every side when the caller sets no margin', () => {
|
|
178
|
+
renderBar();
|
|
179
|
+
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Trap 1 named in the WO: a package-level default injected BEFORE spaceForRotatedTicks'
|
|
183
|
+
// own `margin?.bottom != null` check would make every rotated-tick chart think the caller
|
|
184
|
+
// set bottom, and lose its extra rotation space entirely.
|
|
185
|
+
it('still gives a rotated-tick chart its enlarged bottom margin, not the flat package default', () => {
|
|
186
|
+
renderBar({ xAxis: [{ data: ['January', 'February'], tickLabelStyle: { angle: -90 } }] });
|
|
187
|
+
const props = barSpy.mock.calls.at(-1)[0];
|
|
188
|
+
expect(props.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom + 10);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
|
|
192
|
+
// side it set and gets the package default on the others -- never MUI's wider 20px, and
|
|
193
|
+
// never silently reset to the package default on the side the caller DID set.
|
|
194
|
+
it('merges a caller-supplied partial margin per side, not wholesale', () => {
|
|
195
|
+
renderBar({ margin: { left: 60 } });
|
|
196
|
+
const { margin } = barSpy.mock.calls.at(-1)[0];
|
|
197
|
+
expect(margin.left).toBe(60);
|
|
198
|
+
expect(margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
|
|
199
|
+
expect(margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
200
|
+
expect(margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('keeps every side a caller sets when they supply a full margin object', () => {
|
|
204
|
+
renderBar({ margin: { top: 1, bottom: 2, left: 3, right: 4 } });
|
|
205
|
+
expect(barSpy.mock.calls.at(-1)[0].margin).toEqual({ top: 1, bottom: 2, left: 3, right: 4 });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// A LINEAR x-axis's last tick label sits AT the plot edge (unlike a band axis's centred
|
|
209
|
+
// ticks) and overhangs by about half its width -- the reason `right` is trimmed less than
|
|
210
|
+
// `left`/`bottom`. Pinned here as a value; the actual no-clip claim is verified by the
|
|
211
|
+
// rendered check (WO Part C), not derivable from a mocked MUI component.
|
|
212
|
+
it('keeps a wider default right margin than left/bottom, for a linear x-axis\'s edge-sitting last tick', () => {
|
|
213
|
+
expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.left);
|
|
214
|
+
expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('applies the SAME package default to LineChart as BarChart', () => {
|
|
218
|
+
renderLine();
|
|
219
|
+
expect(lineSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
220
|
+
});
|
|
165
221
|
});
|
|
166
222
|
|
|
167
223
|
it('uses the series colour as the fill when a caller opts line markers back in', () => {
|
|
@@ -206,3 +262,174 @@ describe('chart chrome defaults', () => {
|
|
|
206
262
|
});
|
|
207
263
|
});
|
|
208
264
|
});
|
|
265
|
+
|
|
266
|
+
// THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
|
|
267
|
+
// yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
|
|
268
|
+
// Measured live: hram's dual-axis chart carried the identical 43-character
|
|
269
|
+
// label on both y-axes. This is the defect that actually shipped, pinned
|
|
270
|
+
// directly against the exported function rather than only through a
|
|
271
|
+
// rendered BarChart (which never happens to build a multi-axis array).
|
|
272
|
+
describe('withAxisDefaults label broadcast (THEME-9)', () => {
|
|
273
|
+
it('applies the single label argument only to the first axis of a multi-entry array', () => {
|
|
274
|
+
const result = withAxisDefaults(
|
|
275
|
+
[{ id: 'primary' }, { id: 'secondary' }],
|
|
276
|
+
'Shared label',
|
|
277
|
+
{ scaleType: 'linear' },
|
|
278
|
+
'12px',
|
|
279
|
+
);
|
|
280
|
+
expect(result[0].label).toBe('Shared label');
|
|
281
|
+
expect(result[1].label).toBeUndefined();
|
|
282
|
+
// The exact defect: never let one string land on two axes.
|
|
283
|
+
expect(result.filter((axis) => axis.label === 'Shared label')).toHaveLength(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('leaves axes that set their own label untouched, whatever position they are in', () => {
|
|
287
|
+
const result = withAxisDefaults(
|
|
288
|
+
[{ label: 'Primary' }, { label: 'Secondary' }],
|
|
289
|
+
'Fallback',
|
|
290
|
+
{ scaleType: 'linear' },
|
|
291
|
+
'12px',
|
|
292
|
+
);
|
|
293
|
+
expect(result[0].label).toBe('Primary');
|
|
294
|
+
expect(result[1].label).toBe('Secondary');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('still labels the common single-axis case unchanged', () => {
|
|
298
|
+
const result = withAxisDefaults([{ id: 'only' }], 'Cases', { scaleType: 'linear' }, '12px');
|
|
299
|
+
expect(result[0].label).toBe('Cases');
|
|
300
|
+
|
|
301
|
+
const synthesized = withAxisDefaults(undefined, 'Cases', { scaleType: 'linear' }, '12px');
|
|
302
|
+
expect(synthesized[0].label).toBe('Cases');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// THEME-9 #4 -- BarChart set no default tick formatter at all (raw domain
|
|
307
|
+
// numbers); a hand-rolled `Intl` compact-notation default was measured to be
|
|
308
|
+
// actively wrong in two of the four locales this package's consumers use:
|
|
309
|
+
// German does not compact thousands at all, and Swahili prepends a word
|
|
310
|
+
// ("elfu") that is WIDER than the raw number. Plain grouped formatting (no
|
|
311
|
+
// `notation: 'compact'`) is pinned here against a live `Intl.NumberFormat`
|
|
312
|
+
// computation rather than hardcoded locale punctuation, so the assertion
|
|
313
|
+
// survives ICU data updates.
|
|
314
|
+
describe('defaultNumericTickFormatter (THEME-9)', () => {
|
|
315
|
+
it.each(['de-CH', 'en', 'fr', 'sw'])('formats %s ticks the same way Intl grouping would, with no compact notation', (locale) => {
|
|
316
|
+
const formatter = defaultNumericTickFormatter(locale);
|
|
317
|
+
for (const value of [12500, 998000, 1000000000]) {
|
|
318
|
+
const expected = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
|
|
319
|
+
expect(formatter(value, { location: 'tick' })).toBe(expected);
|
|
320
|
+
// The exact failure mode measured live: compact notation prepending a
|
|
321
|
+
// wider word in Swahili, or a 'K'/'M'/'B' suffix elsewhere.
|
|
322
|
+
expect(formatter(value, { location: 'tick' })).not.toMatch(/[KMB]|elfu/i);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('keeps full precision outside the tick context (tooltip/legend) rather than rounding away a meaningful difference', () => {
|
|
327
|
+
const formatter = defaultNumericTickFormatter('en');
|
|
328
|
+
expect(formatter(12500.456, { location: 'tooltip' })).toBe('12,500.456');
|
|
329
|
+
expect(formatter(12500.456, { location: 'tick' })).toBe('12,500.5');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// THEME-9 #1/#3 -- MUI's own y-axis width default is a flat 45px (ticks) +
|
|
334
|
+
// 20px (label) regardless of what the ticks actually say (confirmed against
|
|
335
|
+
// @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH/AXIS_LABEL_DEFAULT_HEIGHT and
|
|
336
|
+
// its useChartDimensions selector, where margin and axis size are additive).
|
|
337
|
+
// That is both too generous for short ticks and too stingy for wide
|
|
338
|
+
// unit-suffixed ones -- the measured Accessibility-panel overlap. Margins
|
|
339
|
+
// and the exact rendered overlap are verified live, not by unit test (per
|
|
340
|
+
// the WO); this pins the sizing FUNCTION's own contract.
|
|
341
|
+
describe('sizeYAxisForContent (THEME-9)', () => {
|
|
342
|
+
it('sizes a linear axis from its own caller-supplied min/max', () => {
|
|
343
|
+
const [axis] = sizeYAxisForContent(
|
|
344
|
+
[{ scaleType: 'linear', min: 0, max: 1, label: 'Coverage (%)' }],
|
|
345
|
+
[],
|
|
346
|
+
'12px',
|
|
347
|
+
);
|
|
348
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('reserves more width for a longer formatted tick, all else equal', () => {
|
|
352
|
+
const [shortTick] = sizeYAxisForContent(
|
|
353
|
+
[{ scaleType: 'linear', min: 0, max: 1 }], [], '12px',
|
|
354
|
+
);
|
|
355
|
+
const [wideTick] = sizeYAxisForContent(
|
|
356
|
+
[{ scaleType: 'linear', min: 0, max: 1, valueFormatter: (v) => `${v} minutes` }],
|
|
357
|
+
[], '12px',
|
|
358
|
+
);
|
|
359
|
+
expect(wideTick.width).toBeGreaterThan(shortTick.width);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('falls back to the plotted series when the caller sets neither min nor max', () => {
|
|
363
|
+
const [axis] = sizeYAxisForContent(
|
|
364
|
+
[{ scaleType: 'linear' }],
|
|
365
|
+
[{ data: [1, 250000] }],
|
|
366
|
+
'12px',
|
|
367
|
+
);
|
|
368
|
+
expect(axis.width).toBeGreaterThan(0);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('leaves the axis untouched when there is nothing to measure against', () => {
|
|
372
|
+
const [axis] = sizeYAxisForContent([{ scaleType: 'linear' }], [], '12px');
|
|
373
|
+
expect(axis.width).toBeUndefined();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('never overrides a caller-supplied width', () => {
|
|
377
|
+
const [axis] = sizeYAxisForContent(
|
|
378
|
+
[{ scaleType: 'linear', min: 0, max: 1, width: 42 }], [], '12px',
|
|
379
|
+
);
|
|
380
|
+
expect(axis.width).toBe(42);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
|
|
384
|
+
const [axis] = sizeYAxisForContent(
|
|
385
|
+
[{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
|
|
386
|
+
);
|
|
387
|
+
expect(axis.width).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Reviewer-caught regression (THEME-9): a series with no explicit
|
|
391
|
+
// `yAxisId` is NOT "assigned to no axis" -- MUI defaults it to the FIRST
|
|
392
|
+
// axis in the array (`yAxisIds[0]`), whatever that axis' id is. Matching
|
|
393
|
+
// only `undefined === undefined` left the primary axis of a genuine
|
|
394
|
+
// dual-axis chart (id set, series unmarked) with zero matched series and
|
|
395
|
+
// therefore MUI's untouched flat default -- a silent no-op on exactly the
|
|
396
|
+
// dual-axis case this WO was measured against (TimeSeriesChart's own
|
|
397
|
+
// secondary-axis feature, CHART-5).
|
|
398
|
+
it('assigns an unmarked series to the FIRST axis in a dual-axis array, matching MUI\'s own default', () => {
|
|
399
|
+
const dualAxis = [{ scaleType: 'linear', id: 'primary' }, { scaleType: 'linear', id: 'secondary' }];
|
|
400
|
+
const series = [
|
|
401
|
+
{ data: [1, 2, 3] }, // no yAxisId -- MUI assigns this to 'primary'
|
|
402
|
+
{ data: [400000, 500000], yAxisId: 'secondary' },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const [primary, secondary] = sizeYAxisForContent(dualAxis, series, '12px');
|
|
406
|
+
expect(primary.width).toBeGreaterThan(0);
|
|
407
|
+
expect(secondary.width).toBeGreaterThan(0);
|
|
408
|
+
// The secondary axis' series format much wider numbers -- it must
|
|
409
|
+
// reserve more width than the primary axis, not the same (which is what
|
|
410
|
+
// "both axes silently untouched" or "both matched the same series" would
|
|
411
|
+
// produce).
|
|
412
|
+
expect(secondary.width).toBeGreaterThan(primary.width);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// THEME-11 -- pure-function contract of the merge helper itself, independent of any chart
|
|
417
|
+
// component wiring (that's covered above, in 'chart chrome defaults').
|
|
418
|
+
describe('withMarginDefaults (THEME-11)', () => {
|
|
419
|
+
it('returns the package default untouched when the caller sets no margin', () => {
|
|
420
|
+
expect(withMarginDefaults(undefined)).toEqual(PACKAGE_DEFAULT_MARGIN);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('merges a partial object per side', () => {
|
|
424
|
+
expect(withMarginDefaults({ top: 40 })).toEqual({ ...PACKAGE_DEFAULT_MARGIN, top: 40 });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('expands a numeric shorthand margin to all four sides before merging', () => {
|
|
428
|
+
expect(withMarginDefaults(5)).toEqual({ top: 5, bottom: 5, left: 5, right: 5 });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('leaves a fully-specified caller margin untouched', () => {
|
|
432
|
+
const full = { top: 1, bottom: 2, left: 3, right: 4 };
|
|
433
|
+
expect(withMarginDefaults(full)).toEqual(full);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -37,6 +37,26 @@ describe('chart palette and formatters', () => {
|
|
|
37
37
|
expect(createChartFormatters('fr-FR').ratio(1.5)).toContain('1');
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
// THEME-10: an undifferentiated scatter cloud (no colour dimension encoded) needs a single
|
|
41
|
+
// neutral tone, distinct from the categorical/sequential arrays above.
|
|
42
|
+
it('resolves a single neutral tone from theme.palette.text.secondary', () => {
|
|
43
|
+
const theme = {
|
|
44
|
+
palette: {
|
|
45
|
+
mode: 'light',
|
|
46
|
+
primary: { main: 'primary-main' },
|
|
47
|
+
secondary: { main: 'secondary-main' },
|
|
48
|
+
info: { main: 'info-main' },
|
|
49
|
+
success: { main: 'success-main' },
|
|
50
|
+
warning: { main: 'warning-main' },
|
|
51
|
+
action: { disabled: 'action-disabled' },
|
|
52
|
+
text: { secondary: 'text-secondary' },
|
|
53
|
+
divider: 'divider',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(getNeutralChartPalette(theme).neutral).toBe('text-secondary');
|
|
58
|
+
});
|
|
59
|
+
|
|
40
60
|
it('uses the theme categorical ramp when one is present', () => {
|
|
41
61
|
const ramp = ['series-1', 'series-2', 'series-3'];
|
|
42
62
|
const theme = {
|