@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
|
@@ -7,7 +7,18 @@ export function withGridDefaults(grid) {
|
|
|
7
7
|
}
|
|
8
8
|
export function withAxisDefaults(axes, label, defaults, tickFontSize) {
|
|
9
9
|
const values = (axes === null || axes === void 0 ? void 0 : axes.length) ? axes : [defaults];
|
|
10
|
-
return values.map((axis) => (Object.assign(Object.assign(Object.assign({}, defaults), axis), { tickLabelStyle: Object.assign({ fontSize: tickFontSize }, axis.tickLabelStyle),
|
|
10
|
+
return values.map((axis, index) => (Object.assign(Object.assign(Object.assign({}, defaults), axis), { tickLabelStyle: Object.assign({ fontSize: tickFontSize }, axis.tickLabelStyle),
|
|
11
|
+
// THEME-9: the single `label` argument is the caller's ONE string for
|
|
12
|
+
// this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
|
|
13
|
+
// fall back onto ONE axis. Applying it via .map() to every entry
|
|
14
|
+
// broadcast the identical label onto every axis in a multi-axis array
|
|
15
|
+
// (measured live: hram's dual-axis chart carried the same 43-character
|
|
16
|
+
// label on both y-axes). Only the first entry -- the common single-axis
|
|
17
|
+
// case is index 0 -- gets the fallback; a caller building a genuine
|
|
18
|
+
// multi-axis array must label each entry itself (as TimeSeriesChart's
|
|
19
|
+
// own secondary-axis support already does), exactly like MUI's own
|
|
20
|
+
// default `position: 'left'` (only the first entry, MUI docs).
|
|
21
|
+
label: axis.label || (index === 0 ? label : undefined) })));
|
|
11
22
|
}
|
|
12
23
|
function cssLengthToPixels(value) {
|
|
13
24
|
if (typeof value === 'number')
|
|
@@ -22,8 +33,37 @@ function cssLengthToPixels(value) {
|
|
|
22
33
|
return parsed;
|
|
23
34
|
}
|
|
24
35
|
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
25
|
-
|
|
36
|
+
// THEME-11: this used to mirror MUI's own DEFAULT_MARGINS.bottom (20) -- the "resting" bottom
|
|
37
|
+
// margin the rotated-tick path adds its extra height on top of. Now that PACKAGE_DEFAULT_MARGIN
|
|
38
|
+
// below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
|
|
39
|
+
// MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
|
|
40
|
+
// every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
|
|
41
|
+
const MUI_CHART_MARGIN_BOTTOM = 8;
|
|
26
42
|
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
43
|
+
// THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
|
|
44
|
+
// MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
|
|
45
|
+
// of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
|
|
46
|
+
// something different: `left`/`bottom` are pure padding (the tick labels already live inside
|
|
47
|
+
// axisSizeLeft / DEFAULT_AXIS_SIZE_HEIGHT), `top` keeps room for the topmost y-tick label's own
|
|
48
|
+
// ~9px overhang past the plot (it is centred on its gridline, not below it), and `right` stays
|
|
49
|
+
// less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
|
|
50
|
+
// overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
|
|
51
|
+
// making this conditional on scale type would be inferred behaviour with no named consumer.
|
|
52
|
+
export const PACKAGE_DEFAULT_MARGIN = {
|
|
53
|
+
top: 10, bottom: 8, left: 8, right: 16,
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Applies the package's own margin default, per side -- a caller's own value on any side (or
|
|
57
|
+
* the rotated-tick path's own computed `bottom`) always wins on that side; only an UNSET side
|
|
58
|
+
* gets the package default. A caller passing `{ left: 60 }` therefore keeps that 60 on the
|
|
59
|
+
* left and gets the package defaults on the other three sides, never MUI's wider ones.
|
|
60
|
+
*/
|
|
61
|
+
export function withMarginDefaults(margin) {
|
|
62
|
+
const explicit = typeof margin === 'number'
|
|
63
|
+
? { top: margin, bottom: margin, left: margin, right: margin }
|
|
64
|
+
: margin;
|
|
65
|
+
return Object.assign(Object.assign({}, PACKAGE_DEFAULT_MARGIN), explicit);
|
|
66
|
+
}
|
|
27
67
|
function formattedTickText(axis, value) {
|
|
28
68
|
if (!axis.valueFormatter)
|
|
29
69
|
return String(value);
|
|
@@ -78,6 +118,133 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
|
|
|
78
118
|
? Object.assign(Object.assign({}, margin), { bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }) : margin;
|
|
79
119
|
return { xAxis: spacedXAxis, margin: spacedMargin };
|
|
80
120
|
}
|
|
121
|
+
// THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
|
|
122
|
+
// 20px (label, if present) = 65px regardless of what the ticks actually say
|
|
123
|
+
// -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
|
|
124
|
+
// AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
|
|
125
|
+
// (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
|
|
126
|
+
// are ADDITIVE, not one containing the other). That flat default is both too
|
|
127
|
+
// generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
|
|
128
|
+
// ("50.0 min"), which is exactly the measured overlap on the Accessibility
|
|
129
|
+
// y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
|
|
130
|
+
const Y_AXIS_TICK_SIZE = 6;
|
|
131
|
+
const Y_AXIS_TICK_LABEL_GAP = 2;
|
|
132
|
+
const Y_AXIS_LABEL_TICK_GAP = 2;
|
|
133
|
+
// A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
|
|
134
|
+
// WIDTH once rotated -- runs a bit past its raw font size once ascender/
|
|
135
|
+
// descender room is included; matches the ~18px measured live for a 14px
|
|
136
|
+
// label (14 * 1.3 ~= 18).
|
|
137
|
+
const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
|
|
138
|
+
// A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
|
|
139
|
+
// note applies here too -- a too-tight cut recreates the overlap it was
|
|
140
|
+
// meant to fix, and glyph-width heuristics are approximate by nature.
|
|
141
|
+
const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
|
|
142
|
+
// MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
|
|
143
|
+
// (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
|
|
144
|
+
// -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
|
|
145
|
+
// regardless of what body1 actually resolves to). Matching that literal
|
|
146
|
+
// constant here -- rather than reading it off `theme.typography.body1` --
|
|
147
|
+
// keeps this sizing correct even if this package's body1 size ever changes;
|
|
148
|
+
// coupling it to body1 was only ever coincidentally right because body1
|
|
149
|
+
// happens to be 14px today.
|
|
150
|
+
const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
|
|
151
|
+
function longestFormattedTick(values, valueFormatter) {
|
|
152
|
+
return values.reduce((longest, value) => {
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
|
|
156
|
+
}
|
|
157
|
+
catch (_a) {
|
|
158
|
+
// Some formatters require MUI's complete scale context; falling back
|
|
159
|
+
// to the raw value is conservative -- it reserves at least as much
|
|
160
|
+
// space as the unformatted number would need.
|
|
161
|
+
text = String(value);
|
|
162
|
+
}
|
|
163
|
+
return text.length > longest.length ? text : longest;
|
|
164
|
+
}, '');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Sizes a linear y-axis to its actual tick content instead of MUI's flat
|
|
168
|
+
* default. Only touches axes the caller left unsized (`axis.width == null`)
|
|
169
|
+
* and only linear/numeric ones -- a categorical axis's ticks are names, not
|
|
170
|
+
* numbers, and sizing those is not this function's job. Candidate values come
|
|
171
|
+
* from the axis' own `min`/`max` when the caller set them (as every current
|
|
172
|
+
* consumer does), falling back to the series actually plotted against this
|
|
173
|
+
* axis; if neither yields a candidate, the axis is left untouched rather than
|
|
174
|
+
* guessed at.
|
|
175
|
+
*
|
|
176
|
+
* `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
|
|
177
|
+
* (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
|
|
178
|
+
* read first, so a caller-tuned tick font is sized correctly rather than
|
|
179
|
+
* against the package-wide default. The axis LABEL's font is not a caller
|
|
180
|
+
* knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
|
|
181
|
+
*
|
|
182
|
+
* The series-to-axis match for the fallback deliberately mirrors MUI's own
|
|
183
|
+
* default-assignment rule, not a naive `undefined === undefined`: a series
|
|
184
|
+
* with no explicit `yAxisId` is assigned to the FIRST axis in the array
|
|
185
|
+
* (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
|
|
186
|
+
* `undefined === undefined` left the primary axis of a real dual-axis chart
|
|
187
|
+
* (id `'primary'`, its series never set `yAxisId`) with zero matched series
|
|
188
|
+
* and therefore MUI's untouched flat default -- a silent no-op on exactly
|
|
189
|
+
* the dual-axis case this WO was measured against.
|
|
190
|
+
*/
|
|
191
|
+
export function sizeYAxisForContent(yAxis, series, tickFontSize) {
|
|
192
|
+
var _a;
|
|
193
|
+
const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
|
|
194
|
+
const defaultAxisId = (_a = yAxis[0]) === null || _a === void 0 ? void 0 : _a.id;
|
|
195
|
+
return yAxis.map((axis) => {
|
|
196
|
+
var _a;
|
|
197
|
+
if (axis.width != null)
|
|
198
|
+
return axis;
|
|
199
|
+
if (axis.scaleType && axis.scaleType !== 'linear')
|
|
200
|
+
return axis;
|
|
201
|
+
const tickFontSizePx = cssLengthToPixels((_a = axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.fontSize) || fallbackTickFontSizePx;
|
|
202
|
+
if (tickFontSizePx === 0)
|
|
203
|
+
return axis;
|
|
204
|
+
const candidates = [];
|
|
205
|
+
if (typeof axis.min === 'number')
|
|
206
|
+
candidates.push(axis.min);
|
|
207
|
+
if (typeof axis.max === 'number')
|
|
208
|
+
candidates.push(axis.max);
|
|
209
|
+
if (candidates.length === 0) {
|
|
210
|
+
(series || [])
|
|
211
|
+
.filter((item) => { var _a; return ((_a = item.yAxisId) !== null && _a !== void 0 ? _a : defaultAxisId) === axis.id; })
|
|
212
|
+
.forEach((item) => (item.data || []).forEach((value) => {
|
|
213
|
+
if (typeof value === 'number')
|
|
214
|
+
candidates.push(value);
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
if (candidates.length === 0)
|
|
218
|
+
return axis;
|
|
219
|
+
const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
|
|
220
|
+
const tickTextWidth = Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
|
|
221
|
+
const labelThickness = axis.label
|
|
222
|
+
? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
|
|
223
|
+
: 0;
|
|
224
|
+
const width = Math.ceil((tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
|
|
225
|
+
* Y_AXIS_WIDTH_SAFETY_FACTOR);
|
|
226
|
+
return Object.assign(Object.assign({}, axis), { width });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Locale-correct, unit-free tick numbers as the default -- BarChart set none
|
|
231
|
+
* at all (raw numbers straight from the domain), and a hand-rolled `Intl`
|
|
232
|
+
* compact-notation default was measured to be actively wrong: German does
|
|
233
|
+
* not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
|
|
234
|
+
* a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
|
|
235
|
+
* does not solve the width problem and worsens it in one locale. Plain
|
|
236
|
+
* grouped formatting (no `notation: 'compact'`) is safe across all four
|
|
237
|
+
* locales this package's consumers use. Ticks get a short, rounded form;
|
|
238
|
+
* tooltip/legend keep full precision -- teaching screens must not round away
|
|
239
|
+
* a meaningful difference. A caller's own `valueFormatter` always wins (this
|
|
240
|
+
* is only ever used as a default, per `withAxisDefaults`' `{...defaults,
|
|
241
|
+
* ...axis}` merge).
|
|
242
|
+
*/
|
|
243
|
+
export function defaultNumericTickFormatter(locale) {
|
|
244
|
+
return (value, context) => ((context === null || context === void 0 ? void 0 : context.location) === 'tick'
|
|
245
|
+
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
246
|
+
: new Intl.NumberFormat(locale).format(value));
|
|
247
|
+
}
|
|
81
248
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
82
249
|
var _a;
|
|
83
250
|
const position = Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
|
|
@@ -17,6 +17,12 @@ export function getNeutralChartPalette(theme) {
|
|
|
17
17
|
sequential: isDark
|
|
18
18
|
? [palette.action.disabled, palette.text.secondary, palette.primary.light, palette.primary.main, palette.primary.dark]
|
|
19
19
|
: [palette.action.disabled, palette.divider, palette.text.secondary, palette.primary.light, palette.primary.main],
|
|
20
|
+
// THEME-10: an undifferentiated mass (e.g. a scatter cloud with no colour
|
|
21
|
+
// dimension encoded) is ink-neutral, not a series hue -- a KPI identity
|
|
22
|
+
// colour there implies a meaning the cloud does not carry. `text.secondary`
|
|
23
|
+
// reads as neutral ink in both palette modes without competing with an
|
|
24
|
+
// actual categorical/sequential encoding drawn on top of it.
|
|
25
|
+
neutral: palette.text.secondary,
|
|
20
26
|
};
|
|
21
27
|
}
|
|
22
28
|
/** Resolves the kit's neutral palette from the nearest MUI ThemeProvider. */
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,7 @@ export { ChartFrame } from './components/charts/ChartFrame';
|
|
|
46
46
|
export { BarChart } from './components/charts/BarChart';
|
|
47
47
|
export { LineChart } from './components/charts/LineChart';
|
|
48
48
|
export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
|
|
49
|
+
export { ScatterChart, ScatterReferenceCurve, ScatterReferenceLine, scaleBubbleRadius, } from './components/charts/ScatterChart';
|
|
49
50
|
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
50
51
|
export { formatShortTime, formatShortDate, formatShortMonth, formatShortYear, } from './components/charts/chartLabels';
|
|
51
52
|
export { yearTickInterval } from './components/charts/yearTickInterval';
|
package/package.json
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
4
5
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
5
6
|
import { useNeutralChartPalette } from './palette';
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_LEGEND_POSITION,
|
|
9
|
+
defaultNumericTickFormatter,
|
|
10
|
+
sizeYAxisForContent,
|
|
8
11
|
spaceForRotatedTicks,
|
|
9
12
|
withAxisDefaults,
|
|
10
13
|
withChartSlotDefaults,
|
|
11
14
|
withGridDefaults,
|
|
15
|
+
withMarginDefaults,
|
|
12
16
|
} from './chartDefaults';
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
* Responsive MUI X-Charts bar preset.
|
|
16
|
-
*
|
|
19
|
+
* Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
|
|
20
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
21
|
+
* optional, per DESIGN.md #8a: a categorical axis (BarChart's default x-axis
|
|
22
|
+
* shape) needs no title by default -- its ticks are names -- and a numeric
|
|
23
|
+
* axis needs one only unless its unit is already visible in the ticks and
|
|
24
|
+
* the panel heading already names the quantity. Omitting one never suppresses
|
|
25
|
+
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
26
|
+
* an axis without its own `label`).
|
|
17
27
|
*/
|
|
18
28
|
export function BarChart({
|
|
19
29
|
series = [],
|
|
@@ -32,10 +42,8 @@ export function BarChart({
|
|
|
32
42
|
...chartProps
|
|
33
43
|
}) {
|
|
34
44
|
const theme = useTheme();
|
|
45
|
+
const { i18n } = useTranslation();
|
|
35
46
|
const neutralPalette = useNeutralChartPalette();
|
|
36
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
37
|
-
throw new Error('BarChart requires xAxisLabel and yAxisLabel.');
|
|
38
|
-
}
|
|
39
47
|
|
|
40
48
|
const labelledXAxis = withAxisDefaults(
|
|
41
49
|
xAxis,
|
|
@@ -46,7 +54,12 @@ export function BarChart({
|
|
|
46
54
|
const labelledYAxis = withAxisDefaults(
|
|
47
55
|
yAxis,
|
|
48
56
|
yAxisLabel,
|
|
49
|
-
{ scaleType: 'linear' },
|
|
57
|
+
{ scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
|
|
58
|
+
theme.typography.caption.fontSize,
|
|
59
|
+
);
|
|
60
|
+
const sizedYAxis = sizeYAxisForContent(
|
|
61
|
+
labelledYAxis,
|
|
62
|
+
series,
|
|
50
63
|
theme.typography.caption.fontSize,
|
|
51
64
|
);
|
|
52
65
|
const rotatedTickSpace = spaceForRotatedTicks(
|
|
@@ -61,11 +74,11 @@ export function BarChart({
|
|
|
61
74
|
{...chartProps}
|
|
62
75
|
series={series}
|
|
63
76
|
xAxis={rotatedTickSpace.xAxis}
|
|
64
|
-
yAxis={
|
|
77
|
+
yAxis={sizedYAxis}
|
|
65
78
|
colors={palette || neutralPalette.categorical}
|
|
66
79
|
grid={withGridDefaults(grid)}
|
|
67
80
|
hideLegend={hideLegend}
|
|
68
|
-
margin={rotatedTickSpace.margin}
|
|
81
|
+
margin={withMarginDefaults(rotatedTickSpace.margin)}
|
|
69
82
|
slotProps={withChartSlotDefaults(slotProps, legendPosition)}
|
|
70
83
|
/>
|
|
71
84
|
</Box>
|
|
@@ -80,7 +80,9 @@ export function ChartFrame({
|
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
82
|
<Paper variant={variant} sx={[CHART_FRAME_ROOT_SX, ...callerSx]}>
|
|
83
|
-
|
|
83
|
+
{/* THEME-11: mb: 2 -> mb: 1 (16 -> 8px) -- one line item in the estate-wide chart-furniture
|
|
84
|
+
trim; the controls row below keeps its own mb: 2, not named in that WO's scope. */}
|
|
85
|
+
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1 }}>
|
|
84
86
|
<Box>
|
|
85
87
|
<Typography id={titleId} variant={titleVariant}>{title}</Typography>
|
|
86
88
|
{subtitle && <Typography variant="body2" color="text.secondary">{subtitle}</Typography>}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box } from '@mui/material';
|
|
3
3
|
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
4
5
|
import {
|
|
5
6
|
LineChart as MuiLineChart,
|
|
6
7
|
MarkElement as MuiMarkElement,
|
|
@@ -8,10 +9,13 @@ import {
|
|
|
8
9
|
import { useNeutralChartPalette } from './palette';
|
|
9
10
|
import {
|
|
10
11
|
DEFAULT_LEGEND_POSITION,
|
|
12
|
+
defaultNumericTickFormatter,
|
|
13
|
+
sizeYAxisForContent,
|
|
11
14
|
spaceForRotatedTicks,
|
|
12
15
|
withAxisDefaults,
|
|
13
16
|
withChartSlotDefaults,
|
|
14
17
|
withGridDefaults,
|
|
18
|
+
withMarginDefaults,
|
|
15
19
|
} from './chartDefaults';
|
|
16
20
|
|
|
17
21
|
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
@@ -29,8 +33,14 @@ function FilledMarkElement({ color, style, ...props }) {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* Responsive MUI X-Charts line preset.
|
|
33
|
-
*
|
|
36
|
+
* Responsive MUI X-Charts line preset. It supplies labelled axes, tooltips,
|
|
37
|
+
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
38
|
+
* optional, per DESIGN.md #8a: a categorical axis needs no title by default
|
|
39
|
+
* -- its ticks are names -- and a numeric axis needs one only unless its
|
|
40
|
+
* unit is already visible in the ticks and the panel heading already names
|
|
41
|
+
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
42
|
+
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
43
|
+
* `label`).
|
|
34
44
|
*/
|
|
35
45
|
export function LineChart({
|
|
36
46
|
series = [],
|
|
@@ -50,10 +60,8 @@ export function LineChart({
|
|
|
50
60
|
...chartProps
|
|
51
61
|
}) {
|
|
52
62
|
const theme = useTheme();
|
|
63
|
+
const { i18n } = useTranslation();
|
|
53
64
|
const neutralPalette = useNeutralChartPalette();
|
|
54
|
-
if (!xAxisLabel || !yAxisLabel) {
|
|
55
|
-
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
56
|
-
}
|
|
57
65
|
|
|
58
66
|
const labelledXAxis = withAxisDefaults(
|
|
59
67
|
xAxis,
|
|
@@ -64,19 +72,24 @@ export function LineChart({
|
|
|
64
72
|
const labelledYAxis = withAxisDefaults(
|
|
65
73
|
yAxis,
|
|
66
74
|
yAxisLabel,
|
|
67
|
-
{ scaleType: 'linear' },
|
|
75
|
+
{ scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
|
|
68
76
|
theme.typography.caption.fontSize,
|
|
69
77
|
);
|
|
70
|
-
const rotatedTickSpace = spaceForRotatedTicks(
|
|
71
|
-
labelledXAxis,
|
|
72
|
-
margin,
|
|
73
|
-
theme.typography.caption.lineHeight,
|
|
74
|
-
);
|
|
75
78
|
const defaultedSeries = series.map((item) => ({
|
|
76
79
|
showMark: false,
|
|
77
80
|
labelMarkType: 'square',
|
|
78
81
|
...item,
|
|
79
82
|
}));
|
|
83
|
+
const sizedYAxis = sizeYAxisForContent(
|
|
84
|
+
labelledYAxis,
|
|
85
|
+
defaultedSeries,
|
|
86
|
+
theme.typography.caption.fontSize,
|
|
87
|
+
);
|
|
88
|
+
const rotatedTickSpace = spaceForRotatedTicks(
|
|
89
|
+
labelledXAxis,
|
|
90
|
+
margin,
|
|
91
|
+
theme.typography.caption.lineHeight,
|
|
92
|
+
);
|
|
80
93
|
|
|
81
94
|
return (
|
|
82
95
|
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
@@ -84,11 +97,11 @@ export function LineChart({
|
|
|
84
97
|
{...chartProps}
|
|
85
98
|
series={defaultedSeries}
|
|
86
99
|
xAxis={rotatedTickSpace.xAxis}
|
|
87
|
-
yAxis={
|
|
100
|
+
yAxis={sizedYAxis}
|
|
88
101
|
colors={palette || neutralPalette.categorical}
|
|
89
102
|
grid={withGridDefaults(grid)}
|
|
90
103
|
hideLegend={hideLegend}
|
|
91
|
-
margin={rotatedTickSpace.margin}
|
|
104
|
+
margin={withMarginDefaults(rotatedTickSpace.margin)}
|
|
92
105
|
slots={{ mark: FilledMarkElement, ...slots }}
|
|
93
106
|
slotProps={withChartSlotDefaults(slotProps, legendPosition)}
|
|
94
107
|
/>
|