@micha.bigler/ui-core-micha 2.30.0 → 2.31.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 +2 -2
- package/dev/entries.jsx +58 -0
- package/dist/components/charts/BarChart.js +8 -6
- package/dist/components/charts/LineChart.js +23 -6
- package/dist/components/charts/TimeSeriesChart.js +12 -3
- package/dist/components/charts/chartDefaults.js +85 -0
- package/dist/components/charts/chartLabels.js +31 -0
- package/dist/components/charts/yearTickInterval.js +7 -0
- package/dist/index.js +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/themeCompleteness.js +18 -2
- package/dist/theme/tokens.js +1 -1
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +38 -9
- package/src/components/charts/LineChart.jsx +62 -9
- package/src/components/charts/TimeSeriesChart.jsx +21 -0
- package/src/components/charts/chartDefaults.js +120 -0
- package/src/components/charts/chartLabels.js +36 -0
- package/src/components/charts/yearTickInterval.js +7 -0
- package/src/index.js +7 -0
- package/src/theme/index.js +7 -0
- package/src/theme/themeCompleteness.js +16 -0
- package/src/theme/tokens.js +1 -1
- package/tests/BarChart.test.jsx +4 -1
- package/tests/LineChart.test.jsx +5 -2
- package/tests/chartDefaults.test.js +208 -0
- package/tests/chartLabels.test.js +50 -0
- package/tests/createAppTheme.test.js +9 -2
- package/tests/themeCompleteness.test.js +31 -0
- package/tests/timeSeriesLegend.test.js +99 -0
- package/work-orders/CHART-6.md +199 -1
- package/work-orders/THEME-2.md +339 -0
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
|
-
import {
|
|
3
|
+
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import {
|
|
5
|
+
LineChart as MuiLineChart,
|
|
6
|
+
MarkElement as MuiMarkElement,
|
|
7
|
+
} from '@mui/x-charts/LineChart';
|
|
4
8
|
import { useNeutralChartPalette } from './palette';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_LEGEND_POSITION,
|
|
11
|
+
spaceForRotatedTicks,
|
|
12
|
+
withAxisDefaults,
|
|
13
|
+
withChartSlotDefaults,
|
|
14
|
+
withGridDefaults,
|
|
15
|
+
} from './chartDefaults';
|
|
5
16
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
18
|
+
// `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
|
|
19
|
+
// override in src/theme/tokens.js as the preferred mechanism, with a
|
|
20
|
+
// per-wrapper slots/sx override as the documented fallback. This uses the
|
|
21
|
+
// fallback deliberately, not because the theme route was infeasible: a
|
|
22
|
+
// wrapper-level fix applies to every consumer immediately on this version
|
|
23
|
+
// bump, including the 14 apps that haven't adopted `createAppTheme` yet
|
|
24
|
+
// (THEME-1 is a separate, optional pin bump) -- a theme-token override would
|
|
25
|
+
// only take effect for apps on the new baseline theme. Composes correctly
|
|
26
|
+
// with a caller's own `slots.mark` (spread after this default, so it wins).
|
|
27
|
+
function FilledMarkElement({ color, style, ...props }) {
|
|
28
|
+
return <MuiMarkElement {...props} color={color} style={{ fill: color, ...style }} />;
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
/**
|
|
@@ -21,23 +41,56 @@ export function LineChart({
|
|
|
21
41
|
palette,
|
|
22
42
|
minHeight,
|
|
23
43
|
aspect,
|
|
44
|
+
grid,
|
|
45
|
+
hideLegend = series.length <= 1,
|
|
46
|
+
legendPosition = DEFAULT_LEGEND_POSITION,
|
|
47
|
+
margin,
|
|
48
|
+
slots,
|
|
49
|
+
slotProps,
|
|
24
50
|
...chartProps
|
|
25
51
|
}) {
|
|
52
|
+
const theme = useTheme();
|
|
26
53
|
const neutralPalette = useNeutralChartPalette();
|
|
27
54
|
if (!xAxisLabel || !yAxisLabel) {
|
|
28
55
|
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
29
56
|
}
|
|
30
57
|
|
|
58
|
+
const labelledXAxis = withAxisDefaults(
|
|
59
|
+
xAxis,
|
|
60
|
+
xAxisLabel,
|
|
61
|
+
{ scaleType: 'point', data: [] },
|
|
62
|
+
theme.typography.caption.fontSize,
|
|
63
|
+
);
|
|
64
|
+
const labelledYAxis = withAxisDefaults(
|
|
65
|
+
yAxis,
|
|
66
|
+
yAxisLabel,
|
|
67
|
+
{ scaleType: 'linear' },
|
|
68
|
+
theme.typography.caption.fontSize,
|
|
69
|
+
);
|
|
70
|
+
const rotatedTickSpace = spaceForRotatedTicks(
|
|
71
|
+
labelledXAxis,
|
|
72
|
+
margin,
|
|
73
|
+
theme.typography.caption.lineHeight,
|
|
74
|
+
);
|
|
75
|
+
const defaultedSeries = series.map((item) => ({
|
|
76
|
+
showMark: false,
|
|
77
|
+
labelMarkType: 'square',
|
|
78
|
+
...item,
|
|
79
|
+
}));
|
|
80
|
+
|
|
31
81
|
return (
|
|
32
82
|
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
33
83
|
<MuiLineChart
|
|
34
84
|
{...chartProps}
|
|
35
|
-
series={
|
|
36
|
-
xAxis={
|
|
37
|
-
yAxis={
|
|
85
|
+
series={defaultedSeries}
|
|
86
|
+
xAxis={rotatedTickSpace.xAxis}
|
|
87
|
+
yAxis={labelledYAxis}
|
|
38
88
|
colors={palette || neutralPalette.categorical}
|
|
39
|
-
|
|
40
|
-
|
|
89
|
+
grid={withGridDefaults(grid)}
|
|
90
|
+
hideLegend={hideLegend}
|
|
91
|
+
margin={rotatedTickSpace.margin}
|
|
92
|
+
slots={{ mark: FilledMarkElement, ...slots }}
|
|
93
|
+
slotProps={withChartSlotDefaults(slotProps, legendPosition)}
|
|
41
94
|
/>
|
|
42
95
|
</Box>
|
|
43
96
|
);
|
|
@@ -80,6 +80,22 @@ const RANGE_OPTIONS = [
|
|
|
80
80
|
{ key: '1y', granularity: 'month', labelKey: 'TimeSeriesChart.RANGE_1_YEAR' },
|
|
81
81
|
];
|
|
82
82
|
|
|
83
|
+
function SeriesToggleDot({ color, active }) {
|
|
84
|
+
return (
|
|
85
|
+
<Box
|
|
86
|
+
component="span"
|
|
87
|
+
data-testid="series-colour-dot"
|
|
88
|
+
sx={{
|
|
89
|
+
width: (theme) => theme.spacing(1.5),
|
|
90
|
+
height: (theme) => theme.spacing(1.5),
|
|
91
|
+
borderRadius: '50%',
|
|
92
|
+
bgcolor: color,
|
|
93
|
+
opacity: active ? 1 : 0.35,
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/**
|
|
84
100
|
* A time-series preset on ChartFrame + BarChart: a range picker (emits the
|
|
85
101
|
* selected range AND its mapped granularity to the host) and series toggle
|
|
@@ -106,6 +122,7 @@ export function TimeSeriesChart({
|
|
|
106
122
|
error = false,
|
|
107
123
|
onRangeChange,
|
|
108
124
|
defaultRange = '1w',
|
|
125
|
+
skipAnimation,
|
|
109
126
|
}) {
|
|
110
127
|
const { t } = useTranslation();
|
|
111
128
|
const palette = useNeutralChartPalette().categorical;
|
|
@@ -221,6 +238,8 @@ export function TimeSeriesChart({
|
|
|
221
238
|
size="small"
|
|
222
239
|
checked={visibleKeys.has(series.key)}
|
|
223
240
|
onChange={() => toggleSeries(series.key)}
|
|
241
|
+
icon={<SeriesToggleDot color={palette[index % palette.length]} active={false} />}
|
|
242
|
+
checkedIcon={<SeriesToggleDot color={palette[index % palette.length]} active />}
|
|
224
243
|
sx={{
|
|
225
244
|
color: palette[index % palette.length],
|
|
226
245
|
'&.Mui-checked': { color: palette[index % palette.length] },
|
|
@@ -261,6 +280,8 @@ export function TimeSeriesChart({
|
|
|
261
280
|
}))}
|
|
262
281
|
palette={visiblePalette}
|
|
263
282
|
height={CHART_HEIGHT}
|
|
283
|
+
hideLegend={seriesConfig.length > 0}
|
|
284
|
+
skipAnimation={skipAnimation}
|
|
264
285
|
/>
|
|
265
286
|
)}
|
|
266
287
|
</ChartFrame>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const DEFAULT_LEGEND_POSITION = {
|
|
2
|
+
vertical: 'bottom',
|
|
3
|
+
horizontal: 'start',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function withGridDefaults(grid) {
|
|
7
|
+
return { horizontal: true, ...grid };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
11
|
+
const values = axes?.length ? axes : [defaults];
|
|
12
|
+
return values.map((axis) => ({
|
|
13
|
+
// `defaults.scaleType` (and any other scale-defining default) must
|
|
14
|
+
// survive even when the caller passes their OWN xAxis/yAxis array --
|
|
15
|
+
// confirmed live in the dev harness: a caller-supplied axis with `data`
|
|
16
|
+
// but no `scaleType` previously lost 'point'/'band' entirely (only
|
|
17
|
+
// applied when the whole array was absent), leaving MUI's scale
|
|
18
|
+
// undefined and every point position NaN (`<path> attribute d:
|
|
19
|
+
// Expected number, "MNaN,..."` in the real rendered SVG -- a mocked
|
|
20
|
+
// prop-assertion test cannot catch this, it only reproduces on an
|
|
21
|
+
// actual MUI scale computation).
|
|
22
|
+
...defaults,
|
|
23
|
+
...axis,
|
|
24
|
+
tickLabelStyle: {
|
|
25
|
+
fontSize: tickFontSize,
|
|
26
|
+
...axis.tickLabelStyle,
|
|
27
|
+
},
|
|
28
|
+
label: axis.label || label,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cssLengthToPixels(value) {
|
|
33
|
+
if (typeof value === 'number') return value;
|
|
34
|
+
if (typeof value !== 'string') return 0;
|
|
35
|
+
|
|
36
|
+
const parsed = Number.parseFloat(value);
|
|
37
|
+
if (!Number.isFinite(parsed)) return 0;
|
|
38
|
+
if (value.endsWith('rem') || value.endsWith('em')) return parsed * 16;
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
43
|
+
const MUI_CHART_MARGIN_BOTTOM = 20;
|
|
44
|
+
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
45
|
+
|
|
46
|
+
function formattedTickText(axis, value) {
|
|
47
|
+
if (!axis.valueFormatter) return String(value);
|
|
48
|
+
try {
|
|
49
|
+
return String(axis.valueFormatter(value, { location: 'tick' }));
|
|
50
|
+
} catch {
|
|
51
|
+
// Some formatters require MUI's complete scale context. Falling back to
|
|
52
|
+
// the raw value is conservative: it reserves at least as much space.
|
|
53
|
+
return String(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rotatedTickMetrics(axis, defaultLineHeight) {
|
|
58
|
+
const angle = Number(axis.tickLabelStyle?.angle) || 0;
|
|
59
|
+
if (angle === 0) return null;
|
|
60
|
+
|
|
61
|
+
const fontSize = cssLengthToPixels(axis.tickLabelStyle?.fontSize);
|
|
62
|
+
if (fontSize === 0) return null;
|
|
63
|
+
|
|
64
|
+
const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
|
|
65
|
+
const longestLabel = (axis.data || []).reduce((longest, value) => {
|
|
66
|
+
const text = formattedTickText(axis, value);
|
|
67
|
+
return text.length > longest.length ? text : longest;
|
|
68
|
+
}, '');
|
|
69
|
+
const textWidth = Math.max(fontSize, longestLabel.length * fontSize * AVERAGE_GLYPH_WIDTH_EM);
|
|
70
|
+
const radians = (angle * Math.PI) / 180;
|
|
71
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
72
|
+
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
73
|
+
const extraHeight = Math.max(1, Math.ceil(projectedHeight - fontSize));
|
|
74
|
+
|
|
75
|
+
return { extraHeight };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rotated labels project both their line box and text width vertically.
|
|
80
|
+
* X-Charts uses xAxis.height when deciding whether a label fits, while margin
|
|
81
|
+
* only separates the axis from the SVG edge, so both surfaces must grow.
|
|
82
|
+
* Explicit caller height and bottom-margin values always remain untouched.
|
|
83
|
+
*/
|
|
84
|
+
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
85
|
+
let largestExtraHeight = 0;
|
|
86
|
+
const spacedXAxis = xAxis.map((axis) => {
|
|
87
|
+
const metrics = rotatedTickMetrics(axis, defaultLineHeight);
|
|
88
|
+
if (!metrics) return axis;
|
|
89
|
+
|
|
90
|
+
largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
|
|
91
|
+
if (axis.height != null) return axis;
|
|
92
|
+
return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const callerSetBottom = typeof margin === 'number' || margin?.bottom != null;
|
|
96
|
+
const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
|
|
97
|
+
? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
|
|
98
|
+
: margin;
|
|
99
|
+
|
|
100
|
+
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
104
|
+
const position = {
|
|
105
|
+
...DEFAULT_LEGEND_POSITION,
|
|
106
|
+
...legendPosition,
|
|
107
|
+
...slotProps?.legend?.position,
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
...slotProps,
|
|
111
|
+
tooltip: {
|
|
112
|
+
trigger: 'axis',
|
|
113
|
+
...slotProps?.tooltip,
|
|
114
|
+
},
|
|
115
|
+
legend: {
|
|
116
|
+
...slotProps?.legend,
|
|
117
|
+
position,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Short, granularity-aware x-axis tick labels.
|
|
2
|
+
//
|
|
3
|
+
// Not a style choice -- MUI X-Charts has a known, unfixed bug (mui-x#18768,
|
|
4
|
+
// duplicate of #18399): when the measured space for a tick label gets
|
|
5
|
+
// tight, its ellipsize() logic doesn't truncate the text, it collapses it
|
|
6
|
+
// to an empty string (the <tspan> stays in the DOM with no content). It
|
|
7
|
+
// only reproduces with long label text ("08/05, 10:00 AM"-style, ~16
|
|
8
|
+
// characters) combined with this app's actual font metrics (DM Sans) --
|
|
9
|
+
// short labels like "05.08." or "10:00" leave enough margin that the bug
|
|
10
|
+
// never triggers. Same fix already proven in jg-ferien's ActivitySection.
|
|
11
|
+
|
|
12
|
+
// `locale` defaults to "de-CH" only as a fallback for callers that don't
|
|
13
|
+
// pass the app's active i18n language (found in review: an earlier version
|
|
14
|
+
// hardcoded "de-CH" always, showing German month names to en/fr users).
|
|
15
|
+
|
|
16
|
+
export function formatShortTime(date, locale = 'de-CH') {
|
|
17
|
+
// hour12 forced false regardless of locale -- an English 12-hour label
|
|
18
|
+
// ("10:00 AM") is exactly the longer style that triggered the original
|
|
19
|
+
// collapse bug; staying short is the point, not locale fidelity here.
|
|
20
|
+
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatShortDate(date, locale = 'de-CH') {
|
|
24
|
+
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatShortMonth(date, locale = 'de-CH') {
|
|
28
|
+
return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "All" spans years -- day/month labels repeat (e.g. every "01.04." shows up
|
|
32
|
+
// once per year) and read as meaningless noise; a bare year number is the
|
|
33
|
+
// only granularity dense multi-year history can show cleanly.
|
|
34
|
+
export function formatShortYear(date, locale = 'de-CH') {
|
|
35
|
+
return date.toLocaleDateString(locale, { year: 'numeric' });
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Return one January tick for every calendar year represented in dates. */
|
|
2
|
+
export function yearTickInterval(dates) {
|
|
3
|
+
const years = new Set(dates.map((date) => date.getFullYear()));
|
|
4
|
+
return [...years]
|
|
5
|
+
.sort((first, second) => first - second)
|
|
6
|
+
.map((year) => new Date(year, 0, 1));
|
|
7
|
+
}
|
package/src/index.js
CHANGED
|
@@ -55,6 +55,13 @@ export { BarChart } from './components/charts/BarChart';
|
|
|
55
55
|
export { LineChart } from './components/charts/LineChart';
|
|
56
56
|
export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
|
|
57
57
|
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
58
|
+
export {
|
|
59
|
+
formatShortTime,
|
|
60
|
+
formatShortDate,
|
|
61
|
+
formatShortMonth,
|
|
62
|
+
formatShortYear,
|
|
63
|
+
} from './components/charts/chartLabels';
|
|
64
|
+
export { yearTickInterval } from './components/charts/yearTickInterval';
|
|
58
65
|
export {
|
|
59
66
|
formatPercentage,
|
|
60
67
|
formatCompact,
|
package/src/theme/index.js
CHANGED
|
@@ -5,3 +5,10 @@ export {
|
|
|
5
5
|
reportThemeAdoption,
|
|
6
6
|
THEME_COMPLETENESS_SURFACES,
|
|
7
7
|
} from './themeCompleteness';
|
|
8
|
+
export {
|
|
9
|
+
formatShortTime,
|
|
10
|
+
formatShortDate,
|
|
11
|
+
formatShortMonth,
|
|
12
|
+
formatShortYear,
|
|
13
|
+
} from '../components/charts/chartLabels';
|
|
14
|
+
export { yearTickInterval } from '../components/charts/yearTickInterval';
|
|
@@ -275,6 +275,22 @@ function contrastFindings(theme) {
|
|
|
275
275
|
white: '#FFFFFF',
|
|
276
276
|
page: theme.palette?.background?.default,
|
|
277
277
|
};
|
|
278
|
+
for (const status of STATUS_KEYS) {
|
|
279
|
+
const main = theme.palette?.[status]?.main;
|
|
280
|
+
if (!main) continue;
|
|
281
|
+
for (const [surface, background] of Object.entries(backgrounds)) {
|
|
282
|
+
const ratio = calculateContrastRatio(main, background);
|
|
283
|
+
if (!Number.isFinite(ratio) || ratio < 4.5) {
|
|
284
|
+
findings.push({
|
|
285
|
+
surface: `contrast.${status}.main-on-${surface}`,
|
|
286
|
+
reason: Number.isFinite(ratio)
|
|
287
|
+
? `Expected at least 4.5:1; received ${ratio.toFixed(2)}:1.`
|
|
288
|
+
: 'Contrast could not be calculated from the declared colours.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
278
294
|
for (const state of ['main', 'hover', 'focus', 'error']) {
|
|
279
295
|
const colour = theme.palette?.controlBorder?.[state];
|
|
280
296
|
if (!colour) continue;
|
package/src/theme/tokens.js
CHANGED
package/tests/BarChart.test.jsx
CHANGED
|
@@ -26,7 +26,10 @@ describe('BarChart preset', () => {
|
|
|
26
26
|
|
|
27
27
|
expect(props.xAxis[0].label).toBe('Year');
|
|
28
28
|
expect(props.yAxis[0].label).toBe('Value (kg)');
|
|
29
|
-
expect(props.slotProps).toEqual({
|
|
29
|
+
expect(props.slotProps).toEqual({
|
|
30
|
+
tooltip: { trigger: 'axis' },
|
|
31
|
+
legend: { position: { vertical: 'bottom', horizontal: 'start' } },
|
|
32
|
+
});
|
|
30
33
|
expect(props.hideLegend).toBe(true);
|
|
31
34
|
expect(screen.getByTestId('bar-chart-container').getAttribute('style') || '').not.toContain('px');
|
|
32
35
|
});
|
package/tests/LineChart.test.jsx
CHANGED
|
@@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react';
|
|
|
5
5
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
6
|
|
|
7
7
|
const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
|
|
8
|
-
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy }));
|
|
8
|
+
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy, MarkElement: 'path' }));
|
|
9
9
|
|
|
10
10
|
import { LineChart } from '../src/components/charts/LineChart';
|
|
11
11
|
|
|
@@ -28,6 +28,9 @@ describe('LineChart preset', () => {
|
|
|
28
28
|
expect(props.xAxis[0].label).toBe('Date');
|
|
29
29
|
expect(props.yAxis[0].label).toBe('Rate (%)');
|
|
30
30
|
expect(props.hideLegend).toBe(false);
|
|
31
|
-
expect(props.slotProps).toEqual({
|
|
31
|
+
expect(props.slotProps).toEqual({
|
|
32
|
+
tooltip: { trigger: 'axis' },
|
|
33
|
+
legend: { position: { vertical: 'bottom', horizontal: 'start' } },
|
|
34
|
+
});
|
|
32
35
|
});
|
|
33
36
|
});
|
|
@@ -0,0 +1,208 @@
|
|
|
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 barSpy = vi.hoisted(() => vi.fn(() => null));
|
|
10
|
+
const lineSpy = vi.hoisted(() => vi.fn(() => null));
|
|
11
|
+
vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: barSpy }));
|
|
12
|
+
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'path' }));
|
|
13
|
+
|
|
14
|
+
import { BarChart } from '../src/components/charts/BarChart';
|
|
15
|
+
import { LineChart } from '../src/components/charts/LineChart';
|
|
16
|
+
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
17
|
+
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
18
|
+
|
|
19
|
+
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
20
|
+
const i18n = i18next.createInstance();
|
|
21
|
+
i18n.init({
|
|
22
|
+
lng: 'en',
|
|
23
|
+
resources: {
|
|
24
|
+
en: {
|
|
25
|
+
translation: {
|
|
26
|
+
'TimeSeriesChart.RANGE_LABEL': 'Range',
|
|
27
|
+
'TimeSeriesChart.RANGE_1_DAY': '1 day',
|
|
28
|
+
'TimeSeriesChart.RANGE_1_WEEK': '1 week',
|
|
29
|
+
'TimeSeriesChart.RANGE_1_MONTH': '1 month',
|
|
30
|
+
'TimeSeriesChart.RANGE_1_YEAR': '1 year',
|
|
31
|
+
'TimeSeriesChart.SERIES_LABEL': 'Series',
|
|
32
|
+
'ChartFrame.EMPTY_DEFAULT': 'No data available.',
|
|
33
|
+
'ChartFrame.LOADING': 'Loading chart.',
|
|
34
|
+
'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
interpolation: { escapeValue: false },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function withProviders(children) {
|
|
42
|
+
return React.createElement(
|
|
43
|
+
ThemeProvider,
|
|
44
|
+
{ theme },
|
|
45
|
+
React.createElement(I18nextProvider, { i18n }, children),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderBar(props = {}) {
|
|
50
|
+
return render(withProviders(
|
|
51
|
+
React.createElement(BarChart, {
|
|
52
|
+
xAxisLabel: 'Month',
|
|
53
|
+
yAxisLabel: 'Cases',
|
|
54
|
+
series: [{ label: 'Cases', data: [1, 2] }],
|
|
55
|
+
...props,
|
|
56
|
+
}),
|
|
57
|
+
));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderLine(props = {}) {
|
|
61
|
+
return render(withProviders(
|
|
62
|
+
React.createElement(LineChart, {
|
|
63
|
+
xAxisLabel: 'Month',
|
|
64
|
+
yAxisLabel: 'Cases',
|
|
65
|
+
series: [{ label: 'Cases', data: [1, 2] }],
|
|
66
|
+
...props,
|
|
67
|
+
}),
|
|
68
|
+
));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('chart chrome defaults', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('gives minimal BarChart, LineChart, and TimeSeriesChart calls the baseline ticks and horizontal grid, with line marks off', () => {
|
|
78
|
+
renderBar();
|
|
79
|
+
const barProps = barSpy.mock.calls.at(-1)[0];
|
|
80
|
+
expect(barProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
81
|
+
expect(barProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
82
|
+
expect(barProps.grid).toEqual({ horizontal: true });
|
|
83
|
+
|
|
84
|
+
renderLine();
|
|
85
|
+
const lineProps = lineSpy.mock.calls.at(-1)[0];
|
|
86
|
+
expect(lineProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
87
|
+
expect(lineProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
88
|
+
expect(lineProps.grid).toEqual({ horizontal: true });
|
|
89
|
+
expect(lineProps.series[0].showMark).toBe(false);
|
|
90
|
+
|
|
91
|
+
render(withProviders(React.createElement(TimeSeriesChart, {
|
|
92
|
+
title: 'Activity',
|
|
93
|
+
xAxisLabel: 'Day',
|
|
94
|
+
yAxisLabel: 'Cases',
|
|
95
|
+
data: {
|
|
96
|
+
xLabels: ['Mon', 'Tue'],
|
|
97
|
+
series: [{ key: 'cases', label: 'Cases', data: [1, 2] }],
|
|
98
|
+
},
|
|
99
|
+
skipAnimation: true,
|
|
100
|
+
})));
|
|
101
|
+
const timeSeriesProps = barSpy.mock.calls.at(-1)[0];
|
|
102
|
+
expect(timeSeriesProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
103
|
+
expect(timeSeriesProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
104
|
+
expect(timeSeriesProps.grid).toEqual({ horizontal: true });
|
|
105
|
+
expect(timeSeriesProps.skipAnimation).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps caller tick font, grid, showMark, and legendPosition values instead of the defaults', () => {
|
|
109
|
+
const legendPosition = { vertical: 'top', horizontal: 'end' };
|
|
110
|
+
renderLine({
|
|
111
|
+
xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { fontSize: 17 } }],
|
|
112
|
+
grid: { horizontal: false, vertical: true },
|
|
113
|
+
series: [{ label: 'Cases', data: [1, 2], showMark: true }],
|
|
114
|
+
legendPosition,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const props = lineSpy.mock.calls.at(-1)[0];
|
|
118
|
+
expect(props.xAxis[0].tickLabelStyle.fontSize).toBe(17);
|
|
119
|
+
expect(props.grid).toEqual({ horizontal: false, vertical: true });
|
|
120
|
+
expect(props.series[0].showMark).toBe(true);
|
|
121
|
+
expect(props.slotProps.legend.position).toEqual(legendPosition);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('adds a caller-requested vertical grid to the default horizontal grid', () => {
|
|
125
|
+
renderLine({ grid: { vertical: true } });
|
|
126
|
+
|
|
127
|
+
expect(lineSpy.mock.calls.at(-1)[0].grid).toEqual({
|
|
128
|
+
horizontal: true,
|
|
129
|
+
vertical: true,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('lets a caller switch off both grid directions explicitly', () => {
|
|
134
|
+
renderBar({ grid: { horizontal: false, vertical: false } });
|
|
135
|
+
|
|
136
|
+
expect(barSpy.mock.calls.at(-1)[0].grid).toEqual({
|
|
137
|
+
horizontal: false,
|
|
138
|
+
vertical: false,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Found live in the dev harness (not by this mocked test, which cannot
|
|
143
|
+
// reproduce a real MUI scale computation): a caller-supplied xAxis with
|
|
144
|
+
// `data` but no `scaleType` lost the wrapper's default scaleType entirely,
|
|
145
|
+
// leaving MUI's point/band scale undefined and every rendered path NaN.
|
|
146
|
+
// This asserts the prop-level contract; the harness's "Chart defaults" /
|
|
147
|
+
// "Caller overrides" LineChart specimens are the real-render proof.
|
|
148
|
+
it('keeps the default scaleType on a caller-supplied axis that does not set one', () => {
|
|
149
|
+
renderLine({ xAxis: [{ data: ['Jan', 'Feb'] }] });
|
|
150
|
+
expect(lineSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('point');
|
|
151
|
+
|
|
152
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'] }] });
|
|
153
|
+
expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('adds a font-and-angle-derived bottom margin only for rotated tick labels', () => {
|
|
157
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
|
|
158
|
+
const rotatedProps = barSpy.mock.calls.at(-1)[0];
|
|
159
|
+
expect(rotatedProps.margin.bottom).toBeGreaterThan(20);
|
|
160
|
+
expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
|
|
161
|
+
|
|
162
|
+
cleanup();
|
|
163
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
|
|
164
|
+
expect(barSpy.mock.calls.at(-1)[0].margin).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('uses the series colour as the fill when a caller opts line markers back in', () => {
|
|
168
|
+
renderLine({ series: [{ label: 'Cases', data: [1, 2], showMark: true }] });
|
|
169
|
+
const props = lineSpy.mock.calls.at(-1)[0];
|
|
170
|
+
const mark = props.slots.mark({
|
|
171
|
+
color: '#3D5A99',
|
|
172
|
+
id: 'cases',
|
|
173
|
+
dataIndex: 0,
|
|
174
|
+
shape: 'circle',
|
|
175
|
+
x: 10,
|
|
176
|
+
y: 10,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(mark.props.style.fill).toBe('#3D5A99');
|
|
180
|
+
expect(mark.props.style.fill).not.toBe(theme.palette.background.paper);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('defaults legendPosition to bottom-start and lets a caller choose another MUI position', () => {
|
|
184
|
+
renderBar({ series: [{ data: [1] }, { data: [2] }] });
|
|
185
|
+
expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
|
|
186
|
+
vertical: 'bottom',
|
|
187
|
+
horizontal: 'start',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
cleanup();
|
|
191
|
+
const requested = { vertical: 'middle' };
|
|
192
|
+
renderBar({ series: [{ data: [1] }, { data: [2] }], legendPosition: requested });
|
|
193
|
+
expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
|
|
194
|
+
vertical: 'middle',
|
|
195
|
+
horizontal: 'start',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
cleanup();
|
|
199
|
+
renderBar({
|
|
200
|
+
series: [{ data: [1] }, { data: [2] }],
|
|
201
|
+
slotProps: { legend: { position: { horizontal: 'end' } } },
|
|
202
|
+
});
|
|
203
|
+
expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
|
|
204
|
+
vertical: 'bottom',
|
|
205
|
+
horizontal: 'end',
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatShortDate,
|
|
5
|
+
formatShortMonth,
|
|
6
|
+
formatShortTime,
|
|
7
|
+
formatShortYear,
|
|
8
|
+
} from '../src/components/charts/chartLabels';
|
|
9
|
+
import { yearTickInterval } from '../src/components/charts/yearTickInterval';
|
|
10
|
+
|
|
11
|
+
// 2026-08-05 14:07, a fixed instant so assertions aren't flaky around
|
|
12
|
+
// midnight/year boundaries.
|
|
13
|
+
const FIXED = new Date(2026, 7, 5, 14, 7, 0);
|
|
14
|
+
|
|
15
|
+
describe('chart label helpers', () => {
|
|
16
|
+
it('ports fitness-monitor short-label behaviour, including locale fallback and forced 24-hour time', () => {
|
|
17
|
+
expect(formatShortDate(FIXED, 'de-CH')).toBe('05.08.');
|
|
18
|
+
expect(formatShortDate(FIXED, 'de-CH').length).toBeLessThan(10);
|
|
19
|
+
expect(formatShortTime(FIXED, 'de-CH')).toBe('14:07');
|
|
20
|
+
expect(formatShortTime(FIXED, 'en-US')).toBe('14:07');
|
|
21
|
+
expect(formatShortMonth(FIXED, 'de-CH')).toMatch(/26$/);
|
|
22
|
+
expect(formatShortMonth(FIXED, 'de-CH').length).toBeLessThan(10);
|
|
23
|
+
expect(formatShortYear(FIXED, 'de-CH')).toBe('2026');
|
|
24
|
+
expect(formatShortYear(FIXED, 'en-US')).toBe('2026');
|
|
25
|
+
expect(formatShortDate(FIXED, 'en-US')).toBe('08/05');
|
|
26
|
+
expect(formatShortDate(FIXED)).toBe(formatShortDate(FIXED, 'de-CH'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns one tick per year and proves dense unfiltered dates produce duplicate year labels', () => {
|
|
30
|
+
const denseDates = [];
|
|
31
|
+
for (let year = 2022; year <= 2025; year += 1) {
|
|
32
|
+
for (let month = 0; month < 12; month += 1) {
|
|
33
|
+
denseDates.push(new Date(year, month, 1));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const unfilteredLabels = denseDates.map((date) => formatShortYear(date, 'de-CH'));
|
|
38
|
+
expect(new Set(unfilteredLabels).size).toBeLessThan(unfilteredLabels.length);
|
|
39
|
+
|
|
40
|
+
const ticks = yearTickInterval(denseDates);
|
|
41
|
+
expect(ticks).toHaveLength(4);
|
|
42
|
+
expect(ticks.every((date) => date.getMonth() === 0 && date.getDate() === 1)).toBe(true);
|
|
43
|
+
expect(ticks.map((date) => formatShortYear(date, 'de-CH'))).toEqual([
|
|
44
|
+
'2022',
|
|
45
|
+
'2023',
|
|
46
|
+
'2024',
|
|
47
|
+
'2025',
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
});
|