@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.
@@ -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), label: axis.label || label })));
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
- const MUI_CHART_MARGIN_BOTTOM = 20;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.39.1",
3
+ "version": "2.41.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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. xAxisLabel and yAxisLabel (including units) are required.
16
- * It supplies labelled axes, tooltips, and an automatic multi-series legend.
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={labelledYAxis}
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
- <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }}>
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. xAxisLabel and yAxisLabel (including units) are required.
33
- * It supplies labelled axes, tooltips, and an automatic multi-series legend.
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={labelledYAxis}
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
  />