@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.
@@ -0,0 +1,277 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Box } from '@mui/material';
3
+ import { useTheme } from '@mui/material/styles';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { ScatterChart as MuiScatterChart } from '@mui/x-charts/ScatterChart';
6
+ import { useXScale, useYScale } from '@mui/x-charts/hooks';
7
+ import { useNeutralChartPalette } from './palette';
8
+ import {
9
+ DEFAULT_LEGEND_POSITION,
10
+ defaultNumericTickFormatter,
11
+ sizeYAxisForContent,
12
+ withAxisDefaults,
13
+ withChartSlotDefaults,
14
+ withGridDefaults,
15
+ } from './chartDefaults';
16
+
17
+ // Small enough that hram's largest cloud (~300 points, the tested magnitude -- state it, don't
18
+ // imply it scales past that) doesn't fuse into a solid mass at the default (non-bubble) size.
19
+ const DEFAULT_MARKER_RADIUS = 5;
20
+ const BUBBLE_MIN_RADIUS = 3;
21
+ const BUBBLE_RADIUS_SCALE = 10;
22
+
23
+ /**
24
+ * value -> radius, `max(3, 10 * sqrt(value / maxValue))` -- the formula the access-gap panel
25
+ * already hand-rolls (THEME-10 scope 5). Exported so a consumer can size a legend swatch or a
26
+ * standalone marker the same way the chart does.
27
+ */
28
+ export function scaleBubbleRadius(value, maxValue, { minRadius = BUBBLE_MIN_RADIUS, scale = BUBBLE_RADIUS_SCALE } = {}) {
29
+ if (!Number.isFinite(value) || !Number.isFinite(maxValue) || maxValue <= 0) return minRadius;
30
+ return Math.max(minRadius, scale * Math.sqrt(Math.max(0, value) / maxValue));
31
+ }
32
+
33
+ // A custom MUI marker slot -- the only way to vary radius/colour/shape PER POINT: MUI's own
34
+ // Scatter passes a single `markerSize` per whole series (confirmed against
35
+ // @mui/x-charts/ScatterChart/Scatter.js -- `size: series.markerSize`, never derived from a
36
+ // point's own `z`), so bubble sizing and per-point overrides (a hollow status-quo marker, a
37
+ // continuous-scale colour) can only be expressed by replacing the marker itself.
38
+ function makeMarkerSlot(seriesById, { sizeAccessor, chartMax, getPointStyle }) {
39
+ return function PresetScatterMarker({
40
+ seriesId, dataIndex, color, x, y, isHighlighted, isFaded, size, onClick, ...rest
41
+ }) {
42
+ const point = seriesById[seriesId]?.data?.[dataIndex];
43
+ const style = point && getPointStyle ? (getPointStyle(point, { seriesId, dataIndex }) || {}) : {};
44
+ const radius = style.radius
45
+ ?? (sizeAccessor && point ? scaleBubbleRadius(sizeAccessor(point), chartMax) : size);
46
+ const r = (isHighlighted ? 1.15 : 1) * radius;
47
+ const fill = style.hollow ? 'none' : (style.color ?? color);
48
+ const stroke = style.color ?? color;
49
+ const shared = {
50
+ onClick,
51
+ opacity: isFaded ? 0.3 : 1,
52
+ stroke,
53
+ strokeWidth: style.hollow ? 2 : (style.strokeWidth ?? 1),
54
+ style: { cursor: onClick ? 'pointer' : 'unset' },
55
+ ...rest,
56
+ };
57
+ if (style.shape === 'square') {
58
+ return <rect x={x - r} y={y - r} width={r * 2} height={r * 2} fill={fill} {...shared} />;
59
+ }
60
+ if (style.shape === 'diamond') {
61
+ return <polygon points={`${x},${y - r} ${x + r},${y} ${x},${y + r} ${x - r},${y}`} fill={fill} {...shared} />;
62
+ }
63
+ return <circle cx={x} cy={y} r={r} fill={fill} {...shared} />;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * A computed curve (allocation's Pareto envelope) drawn inside the chart's own live coordinate
69
+ * space. Render as a `ScatterChart` child -- `useXScale`/`useYScale` only resolve inside the
70
+ * chart's own context, so this can never drift into a second, hand-rolled pixel mapping the way
71
+ * the hand-drawn panels this preset replaces each built their own.
72
+ */
73
+ export function ScatterReferenceCurve({ points, color, dashed, strokeWidth = 2 }) {
74
+ const xScale = useXScale();
75
+ const yScale = useYScale();
76
+ if (!points || points.length < 2) return null;
77
+ const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(p.x)},${yScale(p.y)}`).join(' ');
78
+ return <path d={d} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={dashed ? '6 3' : undefined} />;
79
+ }
80
+
81
+ /**
82
+ * A straight reference line with an optional inline label -- the access panel's labelled y=x
83
+ * "no gap" diagonal, or optimization's dashed threshold line. Same live-scale rule as
84
+ * `ScatterReferenceCurve`.
85
+ */
86
+ export function ScatterReferenceLine({
87
+ from, to, color, dashed, strokeWidth = 1.5, label, labelAt, labelAngle = 0,
88
+ }) {
89
+ const xScale = useXScale();
90
+ const yScale = useYScale();
91
+ const labelX = labelAt ? xScale(labelAt.x) : null;
92
+ const labelY = labelAt ? yScale(labelAt.y) : null;
93
+ return (
94
+ <g>
95
+ <line
96
+ x1={xScale(from.x)}
97
+ y1={yScale(from.y)}
98
+ x2={xScale(to.x)}
99
+ y2={yScale(to.y)}
100
+ stroke={color}
101
+ strokeWidth={strokeWidth}
102
+ strokeDasharray={dashed ? '5 3' : undefined}
103
+ />
104
+ {label && labelAt && (
105
+ <text
106
+ x={labelX}
107
+ y={labelY}
108
+ fontSize={10}
109
+ fill={color}
110
+ textAnchor="start"
111
+ transform={labelAngle ? `rotate(${labelAngle}, ${labelX}, ${labelY})` : undefined}
112
+ >
113
+ {label}
114
+ </text>
115
+ )}
116
+ </g>
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Responsive MUI X-Charts scatter preset, drawn from three real consumers (THEME-10): a
122
+ * continuously-coloured cost/outcome cloud with an envelope curve and marked points
123
+ * (allocation performance), a bubble cloud with a labelled y=x reference diagonal and
124
+ * categorical per-point colouring (access gap), and a cloud with a dashed reference line and a
125
+ * hollow status-quo marker (optimization results). Reuses BarChart/LineChart's own axis
126
+ * machinery (THEME-9) rather than sizing its own -- a scatter axis is a numeric axis like any
127
+ * other, and a preset that re-derives axis sizing would re-open exactly what THEME-9 closed.
128
+ *
129
+ * `series` follows MUI's own scatter shape (`{id, label, data: [{x,y,z?,id?}], color?}`).
130
+ * Multiple series render as a categorical cloud with a discrete legend, one colour per series
131
+ * -- draw those colours from the app's own family/role register via `palette`; this package
132
+ * does not invent one. A single series with no explicit `color` renders as one neutral mass:
133
+ * undifferentiated data encodes no dimension, so it gets the role register's neutral tone
134
+ * (`palette.js`'s `neutral`), never a series/KPI identity hue.
135
+ *
136
+ * `sizeAccessor(point) => number` turns on bubble mode: marker radius follows
137
+ * `scaleBubbleRadius`, and every series' points are drawn largest-first so a small bubble is
138
+ * never hidden under a large one -- the z-order is the whole correctness of a bubble chart, not
139
+ * a decoration.
140
+ *
141
+ * `getPointStyle(point, {seriesId, dataIndex}) => {hollow?, color?, shape?, radius?,
142
+ * strokeWidth?}` marks individual points -- a status-quo point drawn hollow against filled
143
+ * candidates (allocation, optimization), a `'square'` shape for a source category the data
144
+ * itself marks (allocation's "targeted" points), or a per-point colour from a continuous scale
145
+ * the caller computed (this package does not ship a continuous colour-scale/legend component;
146
+ * the caller already owns that logic and only needs a way to apply the resolved colour per
147
+ * point, which this is it). Read the `point` object itself, not `dataIndex`, to correlate a
148
+ * style back to your own data -- in bubble mode (`sizeAccessor` set) `dataIndex` indexes the
149
+ * chart's internal z-ordered copy of your series, not your original array's order.
150
+ *
151
+ * Reference geometry (a computed curve, a straight line, either with an optional label) is
152
+ * composed as `children` using `ScatterReferenceCurve`/`ScatterReferenceLine` above.
153
+ *
154
+ * Tested magnitude: a few hundred marks (hram's largest cloud is ~300 points). Not verified,
155
+ * and not implied to hold, at thousands+.
156
+ *
157
+ * THEME-10 was commissioned before its requirement set closed -- a forthcoming "paired point"
158
+ * shape (optimization's MILP-vs-simulated pair, two marks joined by a connector) is named but
159
+ * explicitly NOT built here. Nothing below assumes a datum produces exactly one mark: a point
160
+ * object may carry arbitrary extra fields beyond `{x,y,z,id}` (as `getPointStyle`'s own status-
161
+ * quo/bubble-radius usage already does), `getPointStyle`/`sizeAccessor` key off that datum, not
162
+ * off a fixed mark count, and reference geometry is composed via `children`, not baked into the
163
+ * per-point contract. A pair variant can therefore layer on later -- extra series entries plus a
164
+ * connector drawn through `children` -- without reshaping `series` or either callback's contract.
165
+ */
166
+ export function ScatterChart({
167
+ series = [],
168
+ xAxisLabel,
169
+ yAxisLabel,
170
+ xAxis,
171
+ yAxis,
172
+ palette,
173
+ minHeight,
174
+ aspect,
175
+ grid,
176
+ hideLegend = series.length <= 1,
177
+ legendPosition = DEFAULT_LEGEND_POSITION,
178
+ margin,
179
+ sizeAccessor,
180
+ getPointStyle,
181
+ markerSize = DEFAULT_MARKER_RADIUS,
182
+ slotProps,
183
+ children,
184
+ ...chartProps
185
+ }) {
186
+ const theme = useTheme();
187
+ const { i18n } = useTranslation();
188
+ const neutralPalette = useNeutralChartPalette();
189
+
190
+ const labelledXAxis = withAxisDefaults(
191
+ xAxis,
192
+ xAxisLabel,
193
+ { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
194
+ theme.typography.caption.fontSize,
195
+ );
196
+ const labelledYAxis = withAxisDefaults(
197
+ yAxis,
198
+ yAxisLabel,
199
+ { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) },
200
+ theme.typography.caption.fontSize,
201
+ );
202
+ // Only the Y axis is width-sized (sizeYAxisForContent reserves horizontal space for a
203
+ // vertical axis's ticks+label) -- a scatter's numeric X axis needs no analogous treatment
204
+ // beyond what MUI already sizes for a horizontal tick row.
205
+ //
206
+ // sizeYAxisForContent's series-data fallback (used when the caller sets no axis min/max)
207
+ // expects `series[i].data` to be a plain array of numbers -- true for BarChart/LineChart, but
208
+ // a scatter's data is `{x,y,z,id}` points, so `typeof value === 'number'` never matched and
209
+ // the fallback silently found zero candidates (reviewer finding, live-caught: it "looked fine"
210
+ // only because the untested case happened to fall back to MUI's flat default at a magnitude
211
+ // that didn't visibly overlap). Adapt AT THIS CALL SITE, not inside the shared helper --
212
+ // BarChart/LineChart's own contract must not change for this.
213
+ const yValueSeries = useMemo(
214
+ () => series.map((item) => ({ ...item, data: (item.data || []).map((point) => point?.y) })),
215
+ [series],
216
+ );
217
+ const sizedYAxis = sizeYAxisForContent(labelledYAxis, yValueSeries, theme.typography.caption.fontSize);
218
+
219
+ const isNeutral = series.length <= 1 && !series[0]?.color;
220
+ const resolvedPalette = palette || (isNeutral ? [neutralPalette.neutral] : neutralPalette.categorical);
221
+
222
+ // A single chart-wide max, NOT one per series: population (or whatever sizeAccessor reads) is
223
+ // one comparable scale across the whole cloud (matches the access-gap panel's own
224
+ // `maxPop = Math.max(1, ...points.map(p => p.population))`, computed over every point
225
+ // regardless of division/settlement) -- a per-series max would let each series' own largest
226
+ // point always render at full radius, making cross-series bubble size meaningless.
227
+ const chartMax = useMemo(() => {
228
+ if (!sizeAccessor) return 1;
229
+ return Math.max(
230
+ 1,
231
+ ...series.flatMap((item) => (item.data || []).map((point) => sizeAccessor(point))).filter(Number.isFinite),
232
+ );
233
+ }, [series, sizeAccessor]);
234
+
235
+ // THEME-10 scope 5: explicit z-order -- largest bubbles drawn first so smaller ones are never
236
+ // hidden underneath (SVG paints later elements on top). A no-op outside bubble mode.
237
+ const orderedSeries = useMemo(() => (
238
+ sizeAccessor
239
+ ? series.map((item) => ({
240
+ ...item,
241
+ data: [...(item.data || [])].sort((a, b) => sizeAccessor(b) - sizeAccessor(a)),
242
+ }))
243
+ : series
244
+ ), [series, sizeAccessor]);
245
+
246
+ // The marker's `dataIndex` refers to a position in whatever was actually handed to MUI --
247
+ // `orderedSeries` (post z-order sort), not the caller's original `series` -- so the lookup
248
+ // table the marker closes over must be built from that SAME array, or a sorted bubble series
249
+ // would resolve every point's style/radius against the wrong (pre-sort) index.
250
+ const orderedSeriesById = useMemo(
251
+ () => Object.fromEntries(orderedSeries.map((item) => [item.id, item])),
252
+ [orderedSeries],
253
+ );
254
+ const markerSlot = useMemo(
255
+ () => makeMarkerSlot(orderedSeriesById, { sizeAccessor, chartMax, getPointStyle }),
256
+ [orderedSeriesById, sizeAccessor, chartMax, getPointStyle],
257
+ );
258
+
259
+ return (
260
+ <Box data-testid="scatter-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
261
+ <MuiScatterChart
262
+ {...chartProps}
263
+ series={orderedSeries.map((item) => ({ markerSize, ...item }))}
264
+ xAxis={labelledXAxis}
265
+ yAxis={sizedYAxis}
266
+ colors={resolvedPalette}
267
+ grid={withGridDefaults(grid)}
268
+ hideLegend={hideLegend}
269
+ margin={margin}
270
+ slots={{ marker: markerSlot }}
271
+ slotProps={withChartSlotDefaults(slotProps, legendPosition)}
272
+ >
273
+ {children}
274
+ </MuiScatterChart>
275
+ </Box>
276
+ );
277
+ }
@@ -9,7 +9,7 @@ export function withGridDefaults(grid) {
9
9
 
10
10
  export function withAxisDefaults(axes, label, defaults, tickFontSize) {
11
11
  const values = axes?.length ? axes : [defaults];
12
- return values.map((axis) => ({
12
+ return values.map((axis, index) => ({
13
13
  // `defaults.scaleType` (and any other scale-defining default) must
14
14
  // survive even when the caller passes their OWN xAxis/yAxis array --
15
15
  // confirmed live in the dev harness: a caller-supplied axis with `data`
@@ -25,7 +25,17 @@ export function withAxisDefaults(axes, label, defaults, tickFontSize) {
25
25
  fontSize: tickFontSize,
26
26
  ...axis.tickLabelStyle,
27
27
  },
28
- label: axis.label || label,
28
+ // THEME-9: the single `label` argument is the caller's ONE string for
29
+ // this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
30
+ // fall back onto ONE axis. Applying it via .map() to every entry
31
+ // broadcast the identical label onto every axis in a multi-axis array
32
+ // (measured live: hram's dual-axis chart carried the same 43-character
33
+ // label on both y-axes). Only the first entry -- the common single-axis
34
+ // case is index 0 -- gets the fallback; a caller building a genuine
35
+ // multi-axis array must label each entry itself (as TimeSeriesChart's
36
+ // own secondary-axis support already does), exactly like MUI's own
37
+ // default `position: 'left'` (only the first entry, MUI docs).
38
+ label: axis.label || (index === 0 ? label : undefined),
29
39
  }));
30
40
  }
31
41
 
@@ -40,9 +50,40 @@ function cssLengthToPixels(value) {
40
50
  }
41
51
 
42
52
  const MUI_LABELLED_X_AXIS_HEIGHT = 45;
43
- const MUI_CHART_MARGIN_BOTTOM = 20;
53
+ // THEME-11: this used to mirror MUI's own DEFAULT_MARGINS.bottom (20) -- the "resting" bottom
54
+ // margin the rotated-tick path adds its extra height on top of. Now that PACKAGE_DEFAULT_MARGIN
55
+ // below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
56
+ // MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
57
+ // every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
58
+ const MUI_CHART_MARGIN_BOTTOM = 8;
44
59
  const AVERAGE_GLYPH_WIDTH_EM = 0.6;
45
60
 
61
+ // THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
62
+ // MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
63
+ // of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
64
+ // something different: `left`/`bottom` are pure padding (the tick labels already live inside
65
+ // axisSizeLeft / DEFAULT_AXIS_SIZE_HEIGHT), `top` keeps room for the topmost y-tick label's own
66
+ // ~9px overhang past the plot (it is centred on its gridline, not below it), and `right` stays
67
+ // less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
68
+ // overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
69
+ // making this conditional on scale type would be inferred behaviour with no named consumer.
70
+ export const PACKAGE_DEFAULT_MARGIN = {
71
+ top: 10, bottom: 8, left: 8, right: 16,
72
+ };
73
+
74
+ /**
75
+ * Applies the package's own margin default, per side -- a caller's own value on any side (or
76
+ * the rotated-tick path's own computed `bottom`) always wins on that side; only an UNSET side
77
+ * gets the package default. A caller passing `{ left: 60 }` therefore keeps that 60 on the
78
+ * left and gets the package defaults on the other three sides, never MUI's wider ones.
79
+ */
80
+ export function withMarginDefaults(margin) {
81
+ const explicit = typeof margin === 'number'
82
+ ? { top: margin, bottom: margin, left: margin, right: margin }
83
+ : margin;
84
+ return { ...PACKAGE_DEFAULT_MARGIN, ...explicit };
85
+ }
86
+
46
87
  function formattedTickText(axis, value) {
47
88
  if (!axis.valueFormatter) return String(value);
48
89
  try {
@@ -100,6 +141,136 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
100
141
  return { xAxis: spacedXAxis, margin: spacedMargin };
101
142
  }
102
143
 
144
+ // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
145
+ // 20px (label, if present) = 65px regardless of what the ticks actually say
146
+ // -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
147
+ // AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
148
+ // (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
149
+ // are ADDITIVE, not one containing the other). That flat default is both too
150
+ // generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
151
+ // ("50.0 min"), which is exactly the measured overlap on the Accessibility
152
+ // y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
153
+ const Y_AXIS_TICK_SIZE = 6;
154
+ const Y_AXIS_TICK_LABEL_GAP = 2;
155
+ const Y_AXIS_LABEL_TICK_GAP = 2;
156
+ // A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
157
+ // WIDTH once rotated -- runs a bit past its raw font size once ascender/
158
+ // descender room is included; matches the ~18px measured live for a 14px
159
+ // label (14 * 1.3 ~= 18).
160
+ const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
161
+ // A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
162
+ // note applies here too -- a too-tight cut recreates the overlap it was
163
+ // meant to fix, and glyph-width heuristics are approximate by nature.
164
+ const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
165
+ // MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
166
+ // (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
167
+ // -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
168
+ // regardless of what body1 actually resolves to). Matching that literal
169
+ // constant here -- rather than reading it off `theme.typography.body1` --
170
+ // keeps this sizing correct even if this package's body1 size ever changes;
171
+ // coupling it to body1 was only ever coincidentally right because body1
172
+ // happens to be 14px today.
173
+ const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
174
+
175
+ function longestFormattedTick(values, valueFormatter) {
176
+ return values.reduce((longest, value) => {
177
+ let text;
178
+ try {
179
+ text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
180
+ } catch {
181
+ // Some formatters require MUI's complete scale context; falling back
182
+ // to the raw value is conservative -- it reserves at least as much
183
+ // space as the unformatted number would need.
184
+ text = String(value);
185
+ }
186
+ return text.length > longest.length ? text : longest;
187
+ }, '');
188
+ }
189
+
190
+ /**
191
+ * Sizes a linear y-axis to its actual tick content instead of MUI's flat
192
+ * default. Only touches axes the caller left unsized (`axis.width == null`)
193
+ * and only linear/numeric ones -- a categorical axis's ticks are names, not
194
+ * numbers, and sizing those is not this function's job. Candidate values come
195
+ * from the axis' own `min`/`max` when the caller set them (as every current
196
+ * consumer does), falling back to the series actually plotted against this
197
+ * axis; if neither yields a candidate, the axis is left untouched rather than
198
+ * guessed at.
199
+ *
200
+ * `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
201
+ * (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
202
+ * read first, so a caller-tuned tick font is sized correctly rather than
203
+ * against the package-wide default. The axis LABEL's font is not a caller
204
+ * knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
205
+ *
206
+ * The series-to-axis match for the fallback deliberately mirrors MUI's own
207
+ * default-assignment rule, not a naive `undefined === undefined`: a series
208
+ * with no explicit `yAxisId` is assigned to the FIRST axis in the array
209
+ * (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
210
+ * `undefined === undefined` left the primary axis of a real dual-axis chart
211
+ * (id `'primary'`, its series never set `yAxisId`) with zero matched series
212
+ * and therefore MUI's untouched flat default -- a silent no-op on exactly
213
+ * the dual-axis case this WO was measured against.
214
+ */
215
+ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
216
+ const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
217
+ const defaultAxisId = yAxis[0]?.id;
218
+ return yAxis.map((axis) => {
219
+ if (axis.width != null) return axis;
220
+ if (axis.scaleType && axis.scaleType !== 'linear') return axis;
221
+
222
+ const tickFontSizePx = cssLengthToPixels(axis.tickLabelStyle?.fontSize) || fallbackTickFontSizePx;
223
+ if (tickFontSizePx === 0) return axis;
224
+
225
+ const candidates = [];
226
+ if (typeof axis.min === 'number') candidates.push(axis.min);
227
+ if (typeof axis.max === 'number') candidates.push(axis.max);
228
+ if (candidates.length === 0) {
229
+ (series || [])
230
+ .filter((item) => (item.yAxisId ?? defaultAxisId) === axis.id)
231
+ .forEach((item) => (item.data || []).forEach((value) => {
232
+ if (typeof value === 'number') candidates.push(value);
233
+ }));
234
+ }
235
+ if (candidates.length === 0) return axis;
236
+
237
+ const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
238
+ const tickTextWidth = Math.max(
239
+ tickFontSizePx,
240
+ longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
241
+ );
242
+ const labelThickness = axis.label
243
+ ? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
244
+ : 0;
245
+ const width = Math.ceil(
246
+ (tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
247
+ * Y_AXIS_WIDTH_SAFETY_FACTOR,
248
+ );
249
+
250
+ return { ...axis, width };
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Locale-correct, unit-free tick numbers as the default -- BarChart set none
256
+ * at all (raw numbers straight from the domain), and a hand-rolled `Intl`
257
+ * compact-notation default was measured to be actively wrong: German does
258
+ * not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
259
+ * a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
260
+ * does not solve the width problem and worsens it in one locale. Plain
261
+ * grouped formatting (no `notation: 'compact'`) is safe across all four
262
+ * locales this package's consumers use. Ticks get a short, rounded form;
263
+ * tooltip/legend keep full precision -- teaching screens must not round away
264
+ * a meaningful difference. A caller's own `valueFormatter` always wins (this
265
+ * is only ever used as a default, per `withAxisDefaults`' `{...defaults,
266
+ * ...axis}` merge).
267
+ */
268
+ export function defaultNumericTickFormatter(locale) {
269
+ return (value, context) => (context?.location === 'tick'
270
+ ? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
271
+ : new Intl.NumberFormat(locale).format(value));
272
+ }
273
+
103
274
  export function withChartSlotDefaults(slotProps, legendPosition) {
104
275
  const position = {
105
276
  ...DEFAULT_LEGEND_POSITION,
@@ -18,6 +18,12 @@ export function getNeutralChartPalette(theme) {
18
18
  sequential: isDark
19
19
  ? [palette.action.disabled, palette.text.secondary, palette.primary.light, palette.primary.main, palette.primary.dark]
20
20
  : [palette.action.disabled, palette.divider, palette.text.secondary, palette.primary.light, palette.primary.main],
21
+ // THEME-10: an undifferentiated mass (e.g. a scatter cloud with no colour
22
+ // dimension encoded) is ink-neutral, not a series hue -- a KPI identity
23
+ // colour there implies a meaning the cloud does not carry. `text.secondary`
24
+ // reads as neutral ink in both palette modes without competing with an
25
+ // actual categorical/sequential encoding drawn on top of it.
26
+ neutral: palette.text.secondary,
21
27
  };
22
28
  }
23
29
 
package/src/index.js CHANGED
@@ -71,6 +71,12 @@ export { ChartFrame } from './components/charts/ChartFrame';
71
71
  export { BarChart } from './components/charts/BarChart';
72
72
  export { LineChart } from './components/charts/LineChart';
73
73
  export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
74
+ export {
75
+ ScatterChart,
76
+ ScatterReferenceCurve,
77
+ ScatterReferenceLine,
78
+ scaleBubbleRadius,
79
+ } from './components/charts/ScatterChart';
74
80
  export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
75
81
  export {
76
82
  formatShortTime,
@@ -50,9 +50,25 @@ describe('BarChart preset', () => {
50
50
  expect(chartSpy.mock.calls.at(-1)[0].colors).toEqual(['var(--application-chart-colour)']);
51
51
  });
52
52
 
53
- it('requires labels for both axes', () => {
54
- expect(() => render(
55
- <ThemeProvider theme={createTheme()}><BarChart series={[]} xAxisLabel="Year" /></ThemeProvider>,
56
- )).toThrow('BarChart requires xAxisLabel and yAxisLabel.');
53
+ // THEME-9: DESIGN.md #8a makes an axis title the caller's per-axis
54
+ // decision (categorical axes default to none) -- a hard "both required"
55
+ // throw made that default unachievable through this preset. Omitting a
56
+ // label now renders no title for that axis rather than crashing.
57
+ it('renders without a title on an axis whose label was omitted', () => {
58
+ render(
59
+ <ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} xAxisLabel="Year" /></ThemeProvider>,
60
+ );
61
+ const props = chartSpy.mock.calls.at(-1)[0];
62
+ expect(props.xAxis[0].label).toBe('Year');
63
+ expect(props.yAxis[0].label).toBeUndefined();
64
+ });
65
+
66
+ it('renders with no title on either axis when both labels are omitted', () => {
67
+ render(
68
+ <ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} /></ThemeProvider>,
69
+ );
70
+ const props = chartSpy.mock.calls.at(-1)[0];
71
+ expect(props.xAxis[0].label).toBeUndefined();
72
+ expect(props.yAxis[0].label).toBeUndefined();
57
73
  });
58
74
  });