@micha.bigler/ui-core-micha 2.29.2 → 2.31.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 +5 -5
- package/WORK_ORDERS.md +6 -0
- package/dev/entries.jsx +167 -1
- package/dev/main.jsx +10 -3
- 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 +80 -0
- package/dist/components/charts/chartLabels.js +31 -0
- package/dist/components/charts/palette.js +7 -3
- package/dist/components/charts/yearTickInterval.js +7 -0
- package/dist/index.js +4 -0
- package/dist/theme/createAppTheme.js +168 -0
- package/dist/theme/index.js +4 -0
- package/dist/theme/themeCompleteness.js +320 -0
- package/dist/theme/tokens.js +325 -0
- package/package.json +4 -1
- package/src/components/charts/BarChart.jsx +37 -9
- package/src/components/charts/LineChart.jsx +61 -9
- package/src/components/charts/TimeSeriesChart.jsx +21 -0
- package/src/components/charts/chartDefaults.js +111 -0
- package/src/components/charts/chartLabels.js +36 -0
- package/src/components/charts/palette.js +6 -3
- package/src/components/charts/yearTickInterval.js +7 -0
- package/src/index.js +16 -0
- package/src/theme/createAppTheme.js +212 -0
- package/src/theme/index.js +14 -0
- package/src/theme/themeCompleteness.js +355 -0
- package/src/theme/tokens.js +376 -0
- package/tests/BarChart.test.jsx +4 -1
- package/tests/LineChart.test.jsx +5 -2
- package/tests/chartDefaults.test.js +177 -0
- package/tests/chartLabels.test.js +50 -0
- package/tests/chartsPalette.test.js +40 -0
- package/tests/createAppTheme.test.js +103 -0
- package/tests/themeCompleteness.test.js +63 -0
- package/tests/timeSeriesLegend.test.js +99 -0
- package/work-orders/CHART-6.md +428 -0
- package/work-orders/SHELL-2.md +197 -0
- package/work-orders/THEME-1.md +587 -0
- package/work-orders/assets/SHELL-2-account-navigation.html +1159 -0
- package/work-orders/assets/THEME-1-baseline-reference-sheet.html +1352 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { darken, lighten } from '@mui/material/styles';
|
|
2
|
+
// MUI's palette augmentation (createPalette -> augmentColor) only runs on the
|
|
3
|
+
// FIRST argument passed to createTheme; every later argument -- which is how
|
|
4
|
+
// createAppTheme layers BASELINE_PALETTE on top -- is deep-merged as a raw
|
|
5
|
+
// object with no augmentation. A status entry missing `main`/`contrastText`
|
|
6
|
+
// therefore silently keeps MUI's stock hue for `color="success"` etc. even
|
|
7
|
+
// though `text`/`fill`/`bg` are set correctly, so every status channel MUI
|
|
8
|
+
// recognises (success/warning/error/info) must carry its own explicit
|
|
9
|
+
// main/light/dark/contrastText, computed the same way augmentColor would.
|
|
10
|
+
const withMainShades = (main, contrastText) => ({
|
|
11
|
+
main,
|
|
12
|
+
light: lighten(main, 0.2),
|
|
13
|
+
dark: darken(main, 0.3),
|
|
14
|
+
contrastText,
|
|
15
|
+
});
|
|
16
|
+
export const SERIES_COLOURS = [
|
|
17
|
+
'#3D5A99',
|
|
18
|
+
'#3E80B8',
|
|
19
|
+
'#2E8F8A',
|
|
20
|
+
'#7A5FA8',
|
|
21
|
+
'#9C4F86',
|
|
22
|
+
'#8A7355',
|
|
23
|
+
];
|
|
24
|
+
export const STATUS_KEYS = ['success', 'warning', 'error', 'info', 'stale'];
|
|
25
|
+
export const TYPOGRAPHY_VARIANTS = [
|
|
26
|
+
'h1',
|
|
27
|
+
'h2',
|
|
28
|
+
'h3',
|
|
29
|
+
'h4',
|
|
30
|
+
'h5',
|
|
31
|
+
'h6',
|
|
32
|
+
'subtitle1',
|
|
33
|
+
'subtitle2',
|
|
34
|
+
'body1',
|
|
35
|
+
'body2',
|
|
36
|
+
'button',
|
|
37
|
+
'caption',
|
|
38
|
+
'overline',
|
|
39
|
+
];
|
|
40
|
+
const typeVariant = (fontSize, fontWeight, lineHeight, extra = {}) => (Object.assign({ fontSize,
|
|
41
|
+
fontWeight,
|
|
42
|
+
lineHeight, letterSpacing: 0 }, extra));
|
|
43
|
+
export const BASELINE_PALETTE = {
|
|
44
|
+
ink: {
|
|
45
|
+
primary: '#212529',
|
|
46
|
+
secondary: '#5B6670',
|
|
47
|
+
muted: '#6A7178',
|
|
48
|
+
},
|
|
49
|
+
text: {
|
|
50
|
+
primary: '#212529',
|
|
51
|
+
secondary: '#5B6670',
|
|
52
|
+
disabled: '#6A7178',
|
|
53
|
+
},
|
|
54
|
+
background: {
|
|
55
|
+
default: '#FAFAFA',
|
|
56
|
+
paper: '#FFFFFF',
|
|
57
|
+
},
|
|
58
|
+
divider: 'rgba(33,37,41,.10)',
|
|
59
|
+
controlBorder: {
|
|
60
|
+
main: 'rgba(33,37,41,.50)',
|
|
61
|
+
hover: 'rgba(33,37,41,.65)',
|
|
62
|
+
error: '#BF3227',
|
|
63
|
+
},
|
|
64
|
+
success: Object.assign(Object.assign({}, withMainShades('#1B8038', '#FFFFFF')), { text: '#35794A', fill: '#1B8038', fillText: '#FFFFFF', bg: '#E5F4E9' }),
|
|
65
|
+
warning: Object.assign(Object.assign({}, withMainShades('#C08A2C', '#212529')), { text: '#976100', fill: '#C08A2C', fillText: '#212529', bg: '#FBF0DC' }),
|
|
66
|
+
error: Object.assign(Object.assign({}, withMainShades('#BF3227', '#FFFFFF')), { text: '#BF3227', fill: '#BF3227', fillText: '#FFFFFF', bg: '#FBEAE8' }),
|
|
67
|
+
// The canonical sheet introduces no new info hue. These values reuse
|
|
68
|
+
// MUI's own info.dark/main family while adding the shared status shape.
|
|
69
|
+
info: Object.assign(Object.assign({}, withMainShades('#01579B', '#FFFFFF')), { text: '#01579B', fill: '#01579B', fillText: '#FFFFFF', bg: '#E5F6FD' }),
|
|
70
|
+
stale: {
|
|
71
|
+
text: '#5B6B7D',
|
|
72
|
+
fill: '#5B6B7D',
|
|
73
|
+
fillText: '#FFFFFF',
|
|
74
|
+
bg: '#EAEDF1',
|
|
75
|
+
},
|
|
76
|
+
dataSeries: {
|
|
77
|
+
categorical: SERIES_COLOURS,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export const OVERLAY_SHADOW = '0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
81
|
+
export const MOTION = {
|
|
82
|
+
duration: {
|
|
83
|
+
fast: 120,
|
|
84
|
+
base: 180,
|
|
85
|
+
overlayEnter: 220,
|
|
86
|
+
overlayExit: 180,
|
|
87
|
+
chart: 300,
|
|
88
|
+
},
|
|
89
|
+
easing: {
|
|
90
|
+
enter: 'cubic-bezier(0.0, 0, 0.2, 1)',
|
|
91
|
+
exit: 'cubic-bezier(0.4, 0, 1, 1)',
|
|
92
|
+
state: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
93
|
+
},
|
|
94
|
+
reduced: '0.01ms',
|
|
95
|
+
};
|
|
96
|
+
const coarseHitArea = {
|
|
97
|
+
'@media (any-pointer: coarse)': {
|
|
98
|
+
minHeight: 44,
|
|
99
|
+
minWidth: 44,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const autofill = {
|
|
103
|
+
'&:-webkit-autofill': {
|
|
104
|
+
WebkitBoxShadow: '0 0 0 100px #FFFFFF inset',
|
|
105
|
+
WebkitTextFillColor: '#212529',
|
|
106
|
+
caretColor: '#212529',
|
|
107
|
+
},
|
|
108
|
+
'&:autofill': {
|
|
109
|
+
boxShadow: '0 0 0 100px #FFFFFF inset',
|
|
110
|
+
WebkitTextFillColor: '#212529',
|
|
111
|
+
caretColor: '#212529',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
export const BASELINE_STATIC = {
|
|
115
|
+
typography: {
|
|
116
|
+
fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
117
|
+
h1: typeVariant('32px', 600, 1.2),
|
|
118
|
+
h2: typeVariant('28px', 600, 1.22),
|
|
119
|
+
h3: typeVariant('24px', 600, 1.25),
|
|
120
|
+
h4: typeVariant('20px', 600, 1.3),
|
|
121
|
+
h5: typeVariant('18px', 600, 1.3),
|
|
122
|
+
h6: typeVariant('16px', 600, 1.35),
|
|
123
|
+
subtitle1: typeVariant('15px', 600, 1.4),
|
|
124
|
+
subtitle2: typeVariant('13px', 500, 1.4),
|
|
125
|
+
body1: typeVariant('14px', 400, 1.55),
|
|
126
|
+
body2: typeVariant('13px', 400, 1.55),
|
|
127
|
+
button: typeVariant('14px', 500, 1.4, { textTransform: 'none' }),
|
|
128
|
+
caption: typeVariant('12px', 400, 1.4),
|
|
129
|
+
overline: typeVariant('11px', 600, 1.4, {
|
|
130
|
+
textTransform: 'uppercase',
|
|
131
|
+
letterSpacing: '0.4px',
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
shape: {
|
|
135
|
+
borderRadius: 3,
|
|
136
|
+
},
|
|
137
|
+
radius: {
|
|
138
|
+
control: 3,
|
|
139
|
+
card: 8,
|
|
140
|
+
},
|
|
141
|
+
spacing: 8,
|
|
142
|
+
breakpoints: {
|
|
143
|
+
values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
|
|
144
|
+
},
|
|
145
|
+
density: {
|
|
146
|
+
controlHeight: 40,
|
|
147
|
+
touchTarget: 44,
|
|
148
|
+
tableCellPadding: '10px 16px',
|
|
149
|
+
chip: { height: 32, radius: 16, fontSize: 13 },
|
|
150
|
+
containerGutter: { default: 24, wide: 32 },
|
|
151
|
+
},
|
|
152
|
+
fontLoading: {
|
|
153
|
+
family: 'DM Sans',
|
|
154
|
+
weights: [400, 500, 600],
|
|
155
|
+
source: '@fontsource/dm-sans',
|
|
156
|
+
},
|
|
157
|
+
shadow: {
|
|
158
|
+
rest: 'none',
|
|
159
|
+
overlay: OVERLAY_SHADOW,
|
|
160
|
+
},
|
|
161
|
+
motion: MOTION,
|
|
162
|
+
transitions: {
|
|
163
|
+
duration: {
|
|
164
|
+
fast: MOTION.duration.fast,
|
|
165
|
+
base: MOTION.duration.base,
|
|
166
|
+
overlayEnter: MOTION.duration.overlayEnter,
|
|
167
|
+
overlayExit: MOTION.duration.overlayExit,
|
|
168
|
+
chart: MOTION.duration.chart,
|
|
169
|
+
},
|
|
170
|
+
easing: {
|
|
171
|
+
enter: MOTION.easing.enter,
|
|
172
|
+
exit: MOTION.easing.exit,
|
|
173
|
+
state: MOTION.easing.state,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
// MUI's 25-entry shadows array is intentionally retained intact. Resting
|
|
177
|
+
// surfaces are made shadowless via the Paper/Card defaults below, while
|
|
178
|
+
// named transient surfaces receive shadow.overlay explicitly.
|
|
179
|
+
components: {
|
|
180
|
+
MuiCssBaseline: {
|
|
181
|
+
styleOverrides: {
|
|
182
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
183
|
+
'*, *::before, *::after': {
|
|
184
|
+
animationDuration: '0.01ms !important',
|
|
185
|
+
animationIterationCount: '1 !important',
|
|
186
|
+
transitionDuration: '0.01ms !important',
|
|
187
|
+
scrollBehavior: 'auto !important',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
MuiButton: {
|
|
193
|
+
defaultProps: { disableElevation: true },
|
|
194
|
+
styleOverrides: {
|
|
195
|
+
root: Object.assign({ minHeight: 40, borderRadius: 3, textTransform: 'none', transitionDuration: '120ms' }, coarseHitArea),
|
|
196
|
+
contained: { boxShadow: 'none', '&:hover': { boxShadow: 'none' } },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
MuiTableCell: {
|
|
200
|
+
styleOverrides: { root: { padding: '10px 16px' } },
|
|
201
|
+
},
|
|
202
|
+
MuiIconButton: {
|
|
203
|
+
styleOverrides: { root: Object.assign({ minHeight: 40, minWidth: 40 }, coarseHitArea) },
|
|
204
|
+
},
|
|
205
|
+
MuiDivider: {
|
|
206
|
+
styleOverrides: { root: { borderColor: 'rgba(33,37,41,.10)' } },
|
|
207
|
+
},
|
|
208
|
+
MuiSelect: {
|
|
209
|
+
defaultProps: { variant: 'outlined' },
|
|
210
|
+
styleOverrides: {
|
|
211
|
+
select: {
|
|
212
|
+
minHeight: 40,
|
|
213
|
+
boxSizing: 'border-box',
|
|
214
|
+
'@media (any-pointer: coarse)': { minHeight: 44 },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
MuiCheckbox: {
|
|
219
|
+
styleOverrides: { root: Object.assign({ minHeight: 40, minWidth: 40 }, coarseHitArea) },
|
|
220
|
+
},
|
|
221
|
+
MuiFormControlLabel: {
|
|
222
|
+
styleOverrides: {
|
|
223
|
+
root: Object.assign({ minHeight: 40 }, coarseHitArea),
|
|
224
|
+
label: { fontSize: '14px', lineHeight: 1.55 },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
MuiContainer: {
|
|
228
|
+
styleOverrides: {
|
|
229
|
+
root: {
|
|
230
|
+
paddingLeft: 24,
|
|
231
|
+
paddingRight: 24,
|
|
232
|
+
'@media (min-width:1200px)': { paddingLeft: 32, paddingRight: 32 },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
MuiPaper: {
|
|
237
|
+
defaultProps: { elevation: 0, variant: 'outlined' },
|
|
238
|
+
styleOverrides: {
|
|
239
|
+
root: { borderRadius: 8, boxShadow: 'none' },
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
MuiCard: {
|
|
243
|
+
defaultProps: { elevation: 0, variant: 'outlined' },
|
|
244
|
+
styleOverrides: { root: { borderRadius: 8, boxShadow: 'none' } },
|
|
245
|
+
},
|
|
246
|
+
MuiChip: {
|
|
247
|
+
styleOverrides: {
|
|
248
|
+
root: {
|
|
249
|
+
height: 32,
|
|
250
|
+
borderRadius: 16,
|
|
251
|
+
fontSize: '13px',
|
|
252
|
+
position: 'relative',
|
|
253
|
+
'@media (any-pointer: coarse)': {
|
|
254
|
+
'&::after': {
|
|
255
|
+
content: '""',
|
|
256
|
+
position: 'absolute',
|
|
257
|
+
inset: '-6px',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
MuiTextField: {
|
|
264
|
+
defaultProps: { variant: 'outlined' },
|
|
265
|
+
styleOverrides: { root: Object.assign({ minHeight: 40 }, coarseHitArea) },
|
|
266
|
+
},
|
|
267
|
+
MuiOutlinedInput: {
|
|
268
|
+
styleOverrides: {
|
|
269
|
+
root: Object.assign({ minHeight: 40, borderRadius: 3 }, coarseHitArea),
|
|
270
|
+
input: autofill,
|
|
271
|
+
},
|
|
272
|
+
// `variants` is a sibling of `styleOverrides`, not nested inside it -
|
|
273
|
+
// MUI matches these by `props` and applies `style` independently of the
|
|
274
|
+
// styleOverrides merge, so this is the correct placement (a `variants`
|
|
275
|
+
// array embedded inside styleOverrides.root would just be inert CSS-in-JS).
|
|
276
|
+
variants: [
|
|
277
|
+
{ props: { multiline: false }, style: { height: 40 } },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
MuiFilledInput: {
|
|
281
|
+
styleOverrides: { input: autofill },
|
|
282
|
+
},
|
|
283
|
+
MuiInput: {
|
|
284
|
+
styleOverrides: { input: autofill },
|
|
285
|
+
},
|
|
286
|
+
MuiDialog: {
|
|
287
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
288
|
+
styleOverrides: { paper: { borderRadius: 8, boxShadow: OVERLAY_SHADOW } },
|
|
289
|
+
},
|
|
290
|
+
MuiDrawer: {
|
|
291
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
292
|
+
styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
|
|
293
|
+
},
|
|
294
|
+
MuiMenu: {
|
|
295
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
296
|
+
styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
|
|
297
|
+
},
|
|
298
|
+
MuiTooltip: {
|
|
299
|
+
defaultProps: {
|
|
300
|
+
slotProps: { transition: { timeout: { enter: 220, exit: 180 } } },
|
|
301
|
+
},
|
|
302
|
+
styleOverrides: {
|
|
303
|
+
tooltip: { boxShadow: OVERLAY_SHADOW, transitionDuration: '220ms' },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
309
|
+
{
|
|
310
|
+
surface: 'palette.background.paper',
|
|
311
|
+
reason: 'The canonical surface is deliberately MUI white.',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
surface: 'spacing.unit',
|
|
315
|
+
reason: 'The canonical spacing rhythm deliberately retains MUI\'s 8px unit.',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
surface: 'breakpoints.values',
|
|
319
|
+
reason: 'The canonical breakpoint values deliberately retain MUI\'s defaults.',
|
|
320
|
+
},
|
|
321
|
+
...['subtitle2', 'body1', 'body2', 'button', 'caption'].map((variant) => ({
|
|
322
|
+
surface: `typography.${variant}.fontWeight`,
|
|
323
|
+
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
324
|
+
})),
|
|
325
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"url": "https://github.com/bigler-webapps/ui-core-micha"
|
|
9
9
|
},
|
|
10
10
|
"private": false,
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fontsource/dm-sans": "^5.2.8"
|
|
13
|
+
},
|
|
11
14
|
"peerDependencies": {
|
|
12
15
|
"@emotion/react": "^11.14.0",
|
|
13
16
|
"@emotion/styled": "^11.14.1",
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
|
+
import { useTheme } from '@mui/material/styles';
|
|
3
4
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
4
5
|
import { useNeutralChartPalette } from './palette';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_LEGEND_POSITION,
|
|
8
|
+
spaceForRotatedTicks,
|
|
9
|
+
withAxisDefaults,
|
|
10
|
+
withChartSlotDefaults,
|
|
11
|
+
} from './chartDefaults';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Responsive MUI X-Charts bar preset. xAxisLabel and yAxisLabel (including units) are required.
|
|
@@ -21,23 +23,49 @@ export function BarChart({
|
|
|
21
23
|
palette,
|
|
22
24
|
minHeight,
|
|
23
25
|
aspect,
|
|
26
|
+
grid = { horizontal: true },
|
|
27
|
+
hideLegend = series.length <= 1,
|
|
28
|
+
legendPosition = DEFAULT_LEGEND_POSITION,
|
|
29
|
+
margin,
|
|
30
|
+
slotProps,
|
|
24
31
|
...chartProps
|
|
25
32
|
}) {
|
|
33
|
+
const theme = useTheme();
|
|
26
34
|
const neutralPalette = useNeutralChartPalette();
|
|
27
35
|
if (!xAxisLabel || !yAxisLabel) {
|
|
28
36
|
throw new Error('BarChart requires xAxisLabel and yAxisLabel.');
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
const labelledXAxis = withAxisDefaults(
|
|
40
|
+
xAxis,
|
|
41
|
+
xAxisLabel,
|
|
42
|
+
{ scaleType: 'band', data: [] },
|
|
43
|
+
theme.typography.caption.fontSize,
|
|
44
|
+
);
|
|
45
|
+
const labelledYAxis = withAxisDefaults(
|
|
46
|
+
yAxis,
|
|
47
|
+
yAxisLabel,
|
|
48
|
+
{ scaleType: 'linear' },
|
|
49
|
+
theme.typography.caption.fontSize,
|
|
50
|
+
);
|
|
51
|
+
const rotatedTickSpace = spaceForRotatedTicks(
|
|
52
|
+
labelledXAxis,
|
|
53
|
+
margin,
|
|
54
|
+
theme.typography.caption.lineHeight,
|
|
55
|
+
);
|
|
56
|
+
|
|
31
57
|
return (
|
|
32
58
|
<Box data-testid="bar-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
33
59
|
<MuiBarChart
|
|
34
60
|
{...chartProps}
|
|
35
61
|
series={series}
|
|
36
|
-
xAxis={
|
|
37
|
-
yAxis={
|
|
62
|
+
xAxis={rotatedTickSpace.xAxis}
|
|
63
|
+
yAxis={labelledYAxis}
|
|
38
64
|
colors={palette || neutralPalette.categorical}
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
grid={grid}
|
|
66
|
+
hideLegend={hideLegend}
|
|
67
|
+
margin={rotatedTickSpace.margin}
|
|
68
|
+
slotProps={withChartSlotDefaults(slotProps, legendPosition)}
|
|
41
69
|
/>
|
|
42
70
|
</Box>
|
|
43
71
|
);
|
|
@@ -1,11 +1,30 @@
|
|
|
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
|
+
} from './chartDefaults';
|
|
5
15
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
17
|
+
// `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
|
|
18
|
+
// override in src/theme/tokens.js as the preferred mechanism, with a
|
|
19
|
+
// per-wrapper slots/sx override as the documented fallback. This uses the
|
|
20
|
+
// fallback deliberately, not because the theme route was infeasible: a
|
|
21
|
+
// wrapper-level fix applies to every consumer immediately on this version
|
|
22
|
+
// bump, including the 14 apps that haven't adopted `createAppTheme` yet
|
|
23
|
+
// (THEME-1 is a separate, optional pin bump) -- a theme-token override would
|
|
24
|
+
// only take effect for apps on the new baseline theme. Composes correctly
|
|
25
|
+
// with a caller's own `slots.mark` (spread after this default, so it wins).
|
|
26
|
+
function FilledMarkElement({ color, style, ...props }) {
|
|
27
|
+
return <MuiMarkElement {...props} color={color} style={{ fill: color, ...style }} />;
|
|
9
28
|
}
|
|
10
29
|
|
|
11
30
|
/**
|
|
@@ -21,23 +40,56 @@ export function LineChart({
|
|
|
21
40
|
palette,
|
|
22
41
|
minHeight,
|
|
23
42
|
aspect,
|
|
43
|
+
grid = { horizontal: true },
|
|
44
|
+
hideLegend = series.length <= 1,
|
|
45
|
+
legendPosition = DEFAULT_LEGEND_POSITION,
|
|
46
|
+
margin,
|
|
47
|
+
slots,
|
|
48
|
+
slotProps,
|
|
24
49
|
...chartProps
|
|
25
50
|
}) {
|
|
51
|
+
const theme = useTheme();
|
|
26
52
|
const neutralPalette = useNeutralChartPalette();
|
|
27
53
|
if (!xAxisLabel || !yAxisLabel) {
|
|
28
54
|
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
29
55
|
}
|
|
30
56
|
|
|
57
|
+
const labelledXAxis = withAxisDefaults(
|
|
58
|
+
xAxis,
|
|
59
|
+
xAxisLabel,
|
|
60
|
+
{ scaleType: 'point', data: [] },
|
|
61
|
+
theme.typography.caption.fontSize,
|
|
62
|
+
);
|
|
63
|
+
const labelledYAxis = withAxisDefaults(
|
|
64
|
+
yAxis,
|
|
65
|
+
yAxisLabel,
|
|
66
|
+
{ scaleType: 'linear' },
|
|
67
|
+
theme.typography.caption.fontSize,
|
|
68
|
+
);
|
|
69
|
+
const rotatedTickSpace = spaceForRotatedTicks(
|
|
70
|
+
labelledXAxis,
|
|
71
|
+
margin,
|
|
72
|
+
theme.typography.caption.lineHeight,
|
|
73
|
+
);
|
|
74
|
+
const defaultedSeries = series.map((item) => ({
|
|
75
|
+
showMark: false,
|
|
76
|
+
labelMarkType: 'square',
|
|
77
|
+
...item,
|
|
78
|
+
}));
|
|
79
|
+
|
|
31
80
|
return (
|
|
32
81
|
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
33
82
|
<MuiLineChart
|
|
34
83
|
{...chartProps}
|
|
35
|
-
series={
|
|
36
|
-
xAxis={
|
|
37
|
-
yAxis={
|
|
84
|
+
series={defaultedSeries}
|
|
85
|
+
xAxis={rotatedTickSpace.xAxis}
|
|
86
|
+
yAxis={labelledYAxis}
|
|
38
87
|
colors={palette || neutralPalette.categorical}
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
grid={grid}
|
|
89
|
+
hideLegend={hideLegend}
|
|
90
|
+
margin={rotatedTickSpace.margin}
|
|
91
|
+
slots={{ mark: FilledMarkElement, ...slots }}
|
|
92
|
+
slotProps={withChartSlotDefaults(slotProps, legendPosition)}
|
|
41
93
|
/>
|
|
42
94
|
</Box>
|
|
43
95
|
);
|
|
@@ -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,111 @@
|
|
|
1
|
+
export const DEFAULT_LEGEND_POSITION = {
|
|
2
|
+
vertical: 'bottom',
|
|
3
|
+
horizontal: 'start',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
7
|
+
const values = axes?.length ? axes : [defaults];
|
|
8
|
+
return values.map((axis) => ({
|
|
9
|
+
// `defaults.scaleType` (and any other scale-defining default) must
|
|
10
|
+
// survive even when the caller passes their OWN xAxis/yAxis array --
|
|
11
|
+
// confirmed live in the dev harness: a caller-supplied axis with `data`
|
|
12
|
+
// but no `scaleType` previously lost 'point'/'band' entirely (only
|
|
13
|
+
// applied when the whole array was absent), leaving MUI's scale
|
|
14
|
+
// undefined and every point position NaN (`<path> attribute d:
|
|
15
|
+
// Expected number, "MNaN,..."` in the real rendered SVG -- a mocked
|
|
16
|
+
// prop-assertion test cannot catch this, it only reproduces on an
|
|
17
|
+
// actual MUI scale computation).
|
|
18
|
+
...defaults,
|
|
19
|
+
...axis,
|
|
20
|
+
tickLabelStyle: {
|
|
21
|
+
fontSize: tickFontSize,
|
|
22
|
+
...axis.tickLabelStyle,
|
|
23
|
+
},
|
|
24
|
+
label: axis.label || label,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cssLengthToPixels(value) {
|
|
29
|
+
if (typeof value === 'number') return value;
|
|
30
|
+
if (typeof value !== 'string') return 0;
|
|
31
|
+
|
|
32
|
+
const parsed = Number.parseFloat(value);
|
|
33
|
+
if (!Number.isFinite(parsed)) return 0;
|
|
34
|
+
if (value.endsWith('rem') || value.endsWith('em')) return parsed * 16;
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
39
|
+
const MUI_CHART_MARGIN_BOTTOM = 20;
|
|
40
|
+
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
41
|
+
|
|
42
|
+
function formattedTickText(axis, value) {
|
|
43
|
+
if (!axis.valueFormatter) return String(value);
|
|
44
|
+
try {
|
|
45
|
+
return String(axis.valueFormatter(value, { location: 'tick' }));
|
|
46
|
+
} catch {
|
|
47
|
+
// Some formatters require MUI's complete scale context. Falling back to
|
|
48
|
+
// the raw value is conservative: it reserves at least as much space.
|
|
49
|
+
return String(value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rotatedTickMetrics(axis, defaultLineHeight) {
|
|
54
|
+
const angle = Number(axis.tickLabelStyle?.angle) || 0;
|
|
55
|
+
if (angle === 0) return null;
|
|
56
|
+
|
|
57
|
+
const fontSize = cssLengthToPixels(axis.tickLabelStyle?.fontSize);
|
|
58
|
+
if (fontSize === 0) return null;
|
|
59
|
+
|
|
60
|
+
const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
|
|
61
|
+
const longestLabel = (axis.data || []).reduce((longest, value) => {
|
|
62
|
+
const text = formattedTickText(axis, value);
|
|
63
|
+
return text.length > longest.length ? text : longest;
|
|
64
|
+
}, '');
|
|
65
|
+
const textWidth = Math.max(fontSize, longestLabel.length * fontSize * AVERAGE_GLYPH_WIDTH_EM);
|
|
66
|
+
const radians = (angle * Math.PI) / 180;
|
|
67
|
+
const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
|
|
68
|
+
+ Math.abs(Math.sin(radians)) * textWidth;
|
|
69
|
+
const extraHeight = Math.max(1, Math.ceil(projectedHeight - fontSize));
|
|
70
|
+
|
|
71
|
+
return { extraHeight };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Rotated labels project both their line box and text width vertically.
|
|
76
|
+
* X-Charts uses xAxis.height when deciding whether a label fits, while margin
|
|
77
|
+
* only separates the axis from the SVG edge, so both surfaces must grow.
|
|
78
|
+
* Explicit caller height and bottom-margin values always remain untouched.
|
|
79
|
+
*/
|
|
80
|
+
export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
81
|
+
let largestExtraHeight = 0;
|
|
82
|
+
const spacedXAxis = xAxis.map((axis) => {
|
|
83
|
+
const metrics = rotatedTickMetrics(axis, defaultLineHeight);
|
|
84
|
+
if (!metrics) return axis;
|
|
85
|
+
|
|
86
|
+
largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
|
|
87
|
+
if (axis.height != null) return axis;
|
|
88
|
+
return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const callerSetBottom = typeof margin === 'number' || margin?.bottom != null;
|
|
92
|
+
const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
|
|
93
|
+
? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
|
|
94
|
+
: margin;
|
|
95
|
+
|
|
96
|
+
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
100
|
+
return {
|
|
101
|
+
...slotProps,
|
|
102
|
+
tooltip: {
|
|
103
|
+
trigger: 'axis',
|
|
104
|
+
...slotProps?.tooltip,
|
|
105
|
+
},
|
|
106
|
+
legend: {
|
|
107
|
+
position: legendPosition,
|
|
108
|
+
...slotProps?.legend,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|