@micha.bigler/ui-core-micha 2.40.0 → 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 +1 -0
- package/WORK_ORDERS.md +2 -0
- package/dev/entries.jsx +158 -0
- package/dist/components/charts/BarChart.js +2 -2
- package/dist/components/charts/ChartFrame.js +1 -1
- package/dist/components/charts/LineChart.js +2 -2
- package/dist/components/charts/ScatterChart.js +178 -0
- package/dist/components/charts/chartDefaults.js +30 -1
- 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 +2 -1
- package/src/components/charts/ChartFrame.jsx +3 -1
- package/src/components/charts/LineChart.jsx +2 -1
- package/src/components/charts/ScatterChart.jsx +277 -0
- package/src/components/charts/chartDefaults.js +32 -1
- package/src/components/charts/palette.js +6 -0
- package/src/index.js +6 -0
- package/tests/ScatterChart.test.jsx +234 -0
- package/tests/chartDefaults.test.js +75 -3
- package/tests/chartsPalette.test.js +20 -0
- package/work-orders/THEME-10.md +303 -0
- package/work-orders/THEME-11.md +208 -0
|
@@ -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
|
+
}
|
|
@@ -50,9 +50,40 @@ function cssLengthToPixels(value) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const MUI_LABELLED_X_AXIS_HEIGHT = 45;
|
|
53
|
-
|
|
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;
|
|
54
59
|
const AVERAGE_GLYPH_WIDTH_EM = 0.6;
|
|
55
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
|
+
|
|
56
87
|
function formattedTickText(axis, value) {
|
|
57
88
|
if (!axis.valueFormatter) return String(value);
|
|
58
89
|
try {
|
|
@@ -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,
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, render } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const scatterSpy = vi.hoisted(() => vi.fn(({ children }) => <div data-testid="mui-scatter">{children}</div>));
|
|
10
|
+
vi.mock('@mui/x-charts/ScatterChart', () => ({ ScatterChart: scatterSpy }));
|
|
11
|
+
vi.mock('@mui/x-charts/hooks', () => ({
|
|
12
|
+
useXScale: () => (v) => v * 10,
|
|
13
|
+
useYScale: () => (v) => 100 - v * 10,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ScatterChart,
|
|
18
|
+
ScatterReferenceCurve,
|
|
19
|
+
ScatterReferenceLine,
|
|
20
|
+
scaleBubbleRadius,
|
|
21
|
+
} from '../src/components/charts/ScatterChart';
|
|
22
|
+
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
23
|
+
|
|
24
|
+
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
25
|
+
const i18n = i18next.createInstance();
|
|
26
|
+
i18n.init({ lng: 'en', resources: { en: { translation: {} } }, interpolation: { escapeValue: false } });
|
|
27
|
+
|
|
28
|
+
function withProviders(children) {
|
|
29
|
+
return React.createElement(
|
|
30
|
+
ThemeProvider,
|
|
31
|
+
{ theme },
|
|
32
|
+
React.createElement(I18nextProvider, { i18n }, children),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderScatter(props = {}) {
|
|
37
|
+
return render(withProviders(
|
|
38
|
+
React.createElement(ScatterChart, {
|
|
39
|
+
series: [{ id: 's', data: [{ x: 1, y: 1 }] }],
|
|
40
|
+
...props,
|
|
41
|
+
}),
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// THEME-10 -- the formula the access-gap panel already hand-rolls
|
|
46
|
+
// (`max(3, 10*sqrt(pop/maxPop))`), pinned as a pure function.
|
|
47
|
+
describe('scaleBubbleRadius', () => {
|
|
48
|
+
it('matches the access-panel formula across the domain', () => {
|
|
49
|
+
expect(scaleBubbleRadius(0, 100)).toBe(3);
|
|
50
|
+
expect(scaleBubbleRadius(100, 100)).toBe(10);
|
|
51
|
+
expect(scaleBubbleRadius(25, 100)).toBeCloseTo(5, 5);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('floors at the minimum radius for a degenerate or non-finite input', () => {
|
|
55
|
+
expect(scaleBubbleRadius(5, 0)).toBe(3);
|
|
56
|
+
expect(scaleBubbleRadius(NaN, 100)).toBe(3);
|
|
57
|
+
expect(scaleBubbleRadius(5, NaN)).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('ScatterChart mark count and colour default (THEME-10)', () => {
|
|
62
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
63
|
+
|
|
64
|
+
// The failure mode nobody sees: a scatter that silently drops points.
|
|
65
|
+
it('renders every supplied point, none dropped', () => {
|
|
66
|
+
renderScatter({ series: [{ id: 's', data: [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }] }] });
|
|
67
|
+
expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults a single undifferentiated series to the neutral role colour, not a series hue', () => {
|
|
71
|
+
renderScatter();
|
|
72
|
+
expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual([theme.palette.text.secondary]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('uses the categorical palette, not the neutral tone, once a caller supplies multiple series', () => {
|
|
76
|
+
renderScatter({
|
|
77
|
+
series: [
|
|
78
|
+
{ id: 'a', data: [{ x: 1, y: 1 }] },
|
|
79
|
+
{ id: 'b', data: [{ x: 2, y: 2 }] },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
const { colors } = scatterSpy.mock.calls.at(-1)[0];
|
|
83
|
+
expect(colors).not.toEqual([theme.palette.text.secondary]);
|
|
84
|
+
expect(colors.length).toBeGreaterThan(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('lets a caller override the palette explicitly (e.g. a family/role register)', () => {
|
|
88
|
+
renderScatter({ palette: ['#ABCDEF'] });
|
|
89
|
+
expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual(['#ABCDEF']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// reviewer/ui_reviewer U3: a single series that sets its OWN colour is not "undifferentiated"
|
|
93
|
+
// -- the neutral default only applies when nothing else specifies a colour. This pins that
|
|
94
|
+
// ScatterChart itself never strips or overrides a series' own `.color` (MUI's own resolution
|
|
95
|
+
// of series.color vs. the colors fallback array is MUI's own contract, out of scope here --
|
|
96
|
+
// same boundary BarChart.test.jsx already draws for the identical `colors={palette ||
|
|
97
|
+
// neutralPalette.categorical}` pattern).
|
|
98
|
+
it('leaves a single series\' own explicit colour untouched and does not treat it as neutral', () => {
|
|
99
|
+
renderScatter({ series: [{ id: 's', color: '#123456', data: [{ x: 1, y: 1 }] }] });
|
|
100
|
+
const props = scatterSpy.mock.calls.at(-1)[0];
|
|
101
|
+
expect(props.series[0].color).toBe('#123456');
|
|
102
|
+
expect(props.colors).not.toEqual([theme.palette.text.secondary]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('ScatterChart bubble z-order (THEME-10)', () => {
|
|
107
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
108
|
+
|
|
109
|
+
it('sorts each series largest-first when sizeAccessor is set, so a small bubble is never hidden under a large one', () => {
|
|
110
|
+
renderScatter({
|
|
111
|
+
sizeAccessor: (p) => p.pop,
|
|
112
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, pop: 10 }, { x: 2, y: 2, pop: 500 }, { x: 3, y: 3, pop: 100 }] }],
|
|
113
|
+
});
|
|
114
|
+
const data = scatterSpy.mock.calls.at(-1)[0].series[0].data;
|
|
115
|
+
expect(data.map((p) => p.pop)).toEqual([500, 100, 10]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves point order untouched outside bubble mode', () => {
|
|
119
|
+
const original = [{ x: 1, y: 1 }, { x: 2, y: 2 }];
|
|
120
|
+
renderScatter({ series: [{ id: 's', data: original }] });
|
|
121
|
+
expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toEqual(original);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('ScatterChart per-point marker style (THEME-10)', () => {
|
|
126
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
|
127
|
+
|
|
128
|
+
it('renders a hollow marker (fill: none) only for the point getPointStyle marks hollow', () => {
|
|
129
|
+
renderScatter({
|
|
130
|
+
getPointStyle: (p) => (p.isStatusQuo ? { hollow: true } : {}),
|
|
131
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, isStatusQuo: true }, { x: 2, y: 2, isStatusQuo: false }] }],
|
|
132
|
+
});
|
|
133
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
134
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
135
|
+
|
|
136
|
+
const hollow = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
137
|
+
const filled = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
138
|
+
|
|
139
|
+
expect(hollow.container.querySelector('circle').getAttribute('fill')).toBe('none');
|
|
140
|
+
expect(filled.container.querySelector('circle').getAttribute('fill')).toBe('#111');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('sizes a bubble marker from sizeAccessor via scaleBubbleRadius, resolved against the POST-sort index', () => {
|
|
144
|
+
renderScatter({
|
|
145
|
+
sizeAccessor: (p) => p.pop,
|
|
146
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, pop: 0 }, { x: 2, y: 2, pop: 100 }] }],
|
|
147
|
+
});
|
|
148
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
149
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
150
|
+
|
|
151
|
+
// Data was re-sorted largest-first: index 0 is now pop:100, index 1 is pop:0.
|
|
152
|
+
const big = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
153
|
+
const small = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
154
|
+
|
|
155
|
+
expect(Number(big.container.querySelector('circle').getAttribute('r'))).toBeCloseTo(10, 5);
|
|
156
|
+
expect(Number(small.container.querySelector('circle').getAttribute('r'))).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Live-render caught regression: bubble sizing must share ONE max across every series (a
|
|
160
|
+
// single comparable scale, matching the access-gap panel's own
|
|
161
|
+
// `maxPop = Math.max(1, ...points.map(p => p.population))` over ALL points regardless of
|
|
162
|
+
// division/settlement) -- a per-series max would let each series' own largest point always
|
|
163
|
+
// render at full radius, making cross-series bubble size meaningless.
|
|
164
|
+
it('sizes bubbles against ONE chart-wide max across all series, not a max per series', () => {
|
|
165
|
+
renderScatter({
|
|
166
|
+
sizeAccessor: (p) => p.pop,
|
|
167
|
+
series: [
|
|
168
|
+
{ id: 'small-series', data: [{ x: 1, y: 1, pop: 100 }] },
|
|
169
|
+
{ id: 'big-series', data: [{ x: 2, y: 2, pop: 1000 }] },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const props = scatterSpy.mock.calls.at(-1)[0];
|
|
173
|
+
const Marker = props.slots.marker;
|
|
174
|
+
const markerProps = { color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
175
|
+
|
|
176
|
+
// If sized per-series, "small-series"'s own only point (pop:100) would be its own series
|
|
177
|
+
// max and render at full radius (10) -- wrongly implying it's as large as big-series's point.
|
|
178
|
+
const smallSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'small-series', dataIndex: 0 }));
|
|
179
|
+
const bigSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'big-series', dataIndex: 0 }));
|
|
180
|
+
|
|
181
|
+
const smallR = Number(smallSeriesPoint.container.querySelector('circle').getAttribute('r'));
|
|
182
|
+
const bigR = Number(bigSeriesPoint.container.querySelector('circle').getAttribute('r'));
|
|
183
|
+
expect(bigR).toBeCloseTo(10, 5);
|
|
184
|
+
expect(smallR).toBeCloseTo(10 * Math.sqrt(100 / 1000), 5);
|
|
185
|
+
expect(smallR).toBeLessThan(bigR);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('renders a square/diamond shape when getPointStyle requests one', () => {
|
|
189
|
+
renderScatter({
|
|
190
|
+
getPointStyle: (p) => ({ shape: p.shape }),
|
|
191
|
+
series: [{ id: 's', data: [{ x: 1, y: 1, shape: 'square' }, { x: 2, y: 2, shape: 'diamond' }] }],
|
|
192
|
+
});
|
|
193
|
+
const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
|
|
194
|
+
const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
|
|
195
|
+
|
|
196
|
+
const square = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
|
|
197
|
+
const diamond = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
|
|
198
|
+
|
|
199
|
+
expect(square.container.querySelector('rect')).not.toBeNull();
|
|
200
|
+
expect(diamond.container.querySelector('polygon')).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
|
|
205
|
+
it('draws a path through the curve points using the chart\'s own live scale', () => {
|
|
206
|
+
const { container } = render(React.createElement(ScatterReferenceCurve, {
|
|
207
|
+
points: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
|
208
|
+
color: '#222',
|
|
209
|
+
}));
|
|
210
|
+
expect(container.querySelector('path').getAttribute('d')).toBe('M0,100 L10,90');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('draws a labelled reference line at the given label position', () => {
|
|
214
|
+
const { container } = render(React.createElement(ScatterReferenceLine, {
|
|
215
|
+
from: { x: 0, y: 0 },
|
|
216
|
+
to: { x: 1, y: 1 },
|
|
217
|
+
label: 'No gap',
|
|
218
|
+
labelAt: { x: 0.9, y: 0.9 },
|
|
219
|
+
color: '#222',
|
|
220
|
+
}));
|
|
221
|
+
expect(container.querySelector('line')).not.toBeNull();
|
|
222
|
+
expect(container.textContent).toBe('No gap');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('draws an unlabelled reference line when no label is given', () => {
|
|
226
|
+
const { container } = render(React.createElement(ScatterReferenceLine, {
|
|
227
|
+
from: { x: 0, y: 0 },
|
|
228
|
+
to: { x: 1, y: 1 },
|
|
229
|
+
color: '#222',
|
|
230
|
+
}));
|
|
231
|
+
expect(container.querySelector('line')).not.toBeNull();
|
|
232
|
+
expect(container.querySelector('text')).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
});
|