basalt-ui 1.15.0 → 1.16.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/CHANGELOG.md +7 -0
- package/agent/rules/basalt-charts.md +28 -9
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +21 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +6 -3
- package/dist/charts/kinds/Bars.test.d.ts +2 -0
- package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
- package/dist/charts/kinds/DualPanel.d.ts +13 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +55 -8
- package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +35 -25
- package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +1 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +22 -4
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +18 -8
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.d.ts +3 -0
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +4 -0
- package/dist/charts/primitives/XZoneRects.d.ts +22 -8
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
- package/dist/charts/primitives/XZoneRects.js +39 -2
- package/dist/charts/series.d.ts +12 -2
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/Bars.test.tsx +98 -0
- package/src/charts/kinds/Bars.tsx +28 -12
- package/src/charts/kinds/DualPanel.test.tsx +131 -0
- package/src/charts/kinds/DualPanel.tsx +81 -9
- package/src/charts/kinds/MultiLine.test.tsx +37 -0
- package/src/charts/kinds/MultiLine.tsx +45 -28
- package/src/charts/kinds/ZonedLine.test.tsx +37 -0
- package/src/charts/kinds/ZonedLine.tsx +1 -0
- package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
- package/src/charts/primitives/CartesianChart.tsx +44 -12
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- package/src/charts/primitives/XZoneRects.test.tsx +60 -0
- package/src/charts/primitives/XZoneRects.tsx +75 -10
- package/src/charts/series.test.ts +51 -0
- package/src/charts/series.ts +15 -4
|
@@ -154,17 +154,19 @@ function CartesianPlot({
|
|
|
154
154
|
() => probeAxisLabels({
|
|
155
155
|
domain: leftDomain,
|
|
156
156
|
ticks: leftTicks,
|
|
157
|
+
nice: y?.nice ?? false,
|
|
157
158
|
...y?.format !== void 0 && { format: y.format }
|
|
158
159
|
}),
|
|
159
|
-
[leftDomain, leftTicks, y?.format]
|
|
160
|
+
[leftDomain, leftTicks, y?.format, y?.nice]
|
|
160
161
|
);
|
|
161
162
|
const { labels: rightLabels, format: rightFormat } = useMemo(
|
|
162
163
|
() => rightDomain === null ? { labels: [], format: (v) => String(v) } : probeAxisLabels({
|
|
163
164
|
domain: rightDomain,
|
|
164
165
|
ticks: rightTicks,
|
|
166
|
+
nice: y2?.nice ?? false,
|
|
165
167
|
...y2?.format !== void 0 && { format: y2.format }
|
|
166
168
|
}),
|
|
167
|
-
[rightDomain, rightTicks, y2?.format]
|
|
169
|
+
[rightDomain, rightTicks, y2?.format, y2?.nice]
|
|
168
170
|
);
|
|
169
171
|
const xLabels = useMemo(() => keys.map(formatX), [keys, formatX]);
|
|
170
172
|
const margin = useMemo(
|
|
@@ -183,12 +185,20 @@ function CartesianPlot({
|
|
|
183
185
|
[keys, xMax]
|
|
184
186
|
);
|
|
185
187
|
const yScale = useMemo(
|
|
186
|
-
() => scaleLinear({
|
|
187
|
-
|
|
188
|
+
() => scaleLinear({
|
|
189
|
+
domain: leftDomain,
|
|
190
|
+
range: [yMax, 0],
|
|
191
|
+
...y?.nice === true && { nice: true }
|
|
192
|
+
}),
|
|
193
|
+
[leftDomain, yMax, y?.nice]
|
|
188
194
|
);
|
|
189
195
|
const y2Scale = useMemo(
|
|
190
|
-
() => rightDomain === null ? null : scaleLinear({
|
|
191
|
-
|
|
196
|
+
() => rightDomain === null ? null : scaleLinear({
|
|
197
|
+
domain: rightDomain,
|
|
198
|
+
range: [yMax, 0],
|
|
199
|
+
...y2?.nice === true && { nice: true }
|
|
200
|
+
}),
|
|
201
|
+
[rightDomain, yMax, y2?.nice]
|
|
192
202
|
);
|
|
193
203
|
const tickValues = useMemo(
|
|
194
204
|
() => xTicks === void 0 ? smartTicks(keys, xMax) : smartTicksEvery(keys, xTicks),
|
|
@@ -325,7 +335,7 @@ function CartesianPlot({
|
|
|
325
335
|
}
|
|
326
336
|
),
|
|
327
337
|
/* @__PURE__ */ jsxs(TooltipBody, { children: [
|
|
328
|
-
tooltipCfg?.prependRows?.(point),
|
|
338
|
+
tooltipCfg?.prependRows?.(point, { visible, hidden }),
|
|
329
339
|
rows.map((row) => /* @__PURE__ */ jsx(
|
|
330
340
|
TooltipRow,
|
|
331
341
|
{
|
|
@@ -338,7 +348,7 @@ function CartesianPlot({
|
|
|
338
348
|
},
|
|
339
349
|
row.key
|
|
340
350
|
)),
|
|
341
|
-
tooltipCfg?.extraRows?.(point)
|
|
351
|
+
tooltipCfg?.extraRows?.(point, { visible, hidden })
|
|
342
352
|
] })
|
|
343
353
|
] })
|
|
344
354
|
] });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CartesianChart.test.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/CartesianChart.test.
|
|
1
|
+
{"version":3,"file":"CartesianChart.test.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/CartesianChart.test.tsx"],"names":[],"mappings":""}
|
|
@@ -10,6 +10,9 @@ export type LegendEntry = {
|
|
|
10
10
|
dashed?: boolean;
|
|
11
11
|
/** bar swatch opacity — honored instead of a hardcoded value, so it cannot lie about the fill. */
|
|
12
12
|
fillOpacity?: number;
|
|
13
|
+
/** line/splitLine swatch stroke opacity — parity with `fillOpacity` above, so a dimmed
|
|
14
|
+
* companion line's swatch cannot lie about its own opacity either. */
|
|
15
|
+
strokeOpacity?: number;
|
|
13
16
|
/** Drives `groups` rendering (series → hairline divider → overlay → reference). */
|
|
14
17
|
role?: SeriesRole;
|
|
15
18
|
/** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChartLegend.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/ChartLegend.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE5D,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,WAAW,CAAA;IAC9C,mFAAmF;IACnF,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,kGAAkG;IAClG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mFAAmF;IACnF,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB;iFAC6E;IAC7E,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;IACxB;gCAC4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;
|
|
1
|
+
{"version":3,"file":"ChartLegend.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/ChartLegend.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE5D,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,WAAW,CAAA;IAC9C,mFAAmF;IACnF,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,kGAAkG;IAClG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;0EACsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mFAAmF;IACnF,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB;iFAC6E;IAC7E,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;IACxB;gCAC4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAgOD;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,WAAkB,EAClB,WAAW,EACX,MAAkB,EAClB,QAAQ,EACR,OAAY,EACZ,SAAoB,EACpB,MAAc,EACd,OAAO,GACR,EAAE;IACD,KAAK,EAAE,WAAW,EAAE,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC1C,kCAAkC;IAClC,MAAM,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAC5B,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,eAAe,CAAA;IAC3B,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,+BAmEA"}
|
|
@@ -70,6 +70,7 @@ function LegendSwatch({ item, idPrefix }) {
|
|
|
70
70
|
y2: 7,
|
|
71
71
|
stroke: item.color,
|
|
72
72
|
strokeWidth: item.strokeWidth ?? 2.5,
|
|
73
|
+
strokeOpacity: item.strokeOpacity ?? 1,
|
|
73
74
|
strokeDasharray: item.dashed ? VX.dashArray : void 0
|
|
74
75
|
}
|
|
75
76
|
),
|
|
@@ -82,6 +83,7 @@ function LegendSwatch({ item, idPrefix }) {
|
|
|
82
83
|
y2: 7,
|
|
83
84
|
stroke: item.secondColor,
|
|
84
85
|
strokeWidth: item.strokeWidth ?? 2.5,
|
|
86
|
+
strokeOpacity: item.strokeOpacity ?? 1,
|
|
85
87
|
strokeDasharray: item.dashed ? VX.dashArray : void 0
|
|
86
88
|
}
|
|
87
89
|
)
|
|
@@ -124,6 +126,7 @@ function LegendSwatch({ item, idPrefix }) {
|
|
|
124
126
|
y2: 3,
|
|
125
127
|
stroke: item.color,
|
|
126
128
|
strokeWidth: item.strokeWidth ?? 3,
|
|
129
|
+
strokeOpacity: item.strokeOpacity ?? 1,
|
|
127
130
|
strokeLinecap: "round",
|
|
128
131
|
strokeDasharray: item.dashed ? VX.dashArray : void 0
|
|
129
132
|
}
|
|
@@ -166,6 +169,7 @@ function LegendChildSwatch({ item }) {
|
|
|
166
169
|
y2: 5,
|
|
167
170
|
stroke: item.color,
|
|
168
171
|
strokeWidth: item.strokeWidth ?? 2,
|
|
172
|
+
strokeOpacity: item.strokeOpacity ?? 1,
|
|
169
173
|
strokeDasharray: item.dashed ? VX.dashArray : void 0
|
|
170
174
|
}
|
|
171
175
|
) });
|
|
@@ -11,24 +11,38 @@ export type XZoneSpec = {
|
|
|
11
11
|
/** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
|
|
12
12
|
to?: string;
|
|
13
13
|
fill: string;
|
|
14
|
+
/**
|
|
15
|
+
* `'center'` (default, unchanged behaviour) resolves a present bound to `xScale(key)` — the
|
|
16
|
+
* point's own center, so a band reads "from this sample to that sample" rather than
|
|
17
|
+
* edge-to-edge. `'edge'` resolves a present `from` to `xScale(from) - step/2` and a present `to`
|
|
18
|
+
* to `xScale(to) + step/2` (`step` read from `xScale.step()`), so the band covers both terminal
|
|
19
|
+
* slots in full — a two-key band widens by exactly one step, and `from === to` renders one step
|
|
20
|
+
* wide instead of being skipped as degenerate. An omitted `from`/`to` still resolves to the plot
|
|
21
|
+
* edge (`xScale.range()[0]`/`[1]`) in both modes, unshifted — omission means "the plot's own
|
|
22
|
+
* edge", not "the first/last sample's edge". A resolved `'edge'` bound is clamped into the plot
|
|
23
|
+
* range so a band anchored at the first or last sample cannot paint outside the plot area.
|
|
24
|
+
*/
|
|
25
|
+
align?: 'center' | 'edge';
|
|
14
26
|
};
|
|
15
27
|
/**
|
|
16
28
|
* Vertical zone backgrounds (x-range overlays — twilight bands, a shaded time window) — one
|
|
17
29
|
* <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
|
|
18
30
|
* `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
|
|
19
31
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
32
|
+
* Bound resolution depends on `XZoneSpec.align` (see its JSDoc for the full `'center'`/`'edge'`
|
|
33
|
+
* contract) — default `'center'` resolves a present key to the point's own center; `'edge'`
|
|
34
|
+
* widens by half a step at each present bound so the band covers the terminal slots in full.
|
|
23
35
|
*
|
|
24
36
|
* A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
|
|
25
|
-
* it is NEVER clamped to a plot edge
|
|
26
|
-
* chart for a typo'd or stale key, which is the exact class of
|
|
27
|
-
* draw is two different states" doctrine rejects: an unknown key
|
|
28
|
-
* as "the whole plot".
|
|
37
|
+
* it is NEVER clamped to a plot edge, in either mode. Clamping an unknown key would silently
|
|
38
|
+
* paint a band across the whole chart for a typo'd or stale key, which is the exact class of
|
|
39
|
+
* quiet lie the repo's "nothing to draw is two different states" doctrine rejects: an unknown key
|
|
40
|
+
* must read as "not drawn", not as "the whole plot".
|
|
29
41
|
*
|
|
30
42
|
* A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
|
|
31
|
-
* also skipped — no negative-width rect.
|
|
43
|
+
* also skipped — no negative-width rect. In `'center'` mode `from === to` is degenerate (skipped,
|
|
44
|
+
* today's behaviour); in `'edge'` mode it is NOT — the half-step widening on each side always
|
|
45
|
+
* produces a positive-width, one-step-wide rect.
|
|
32
46
|
*/
|
|
33
47
|
export declare function XZoneRects({ zones, height, xScale, }: {
|
|
34
48
|
zones: XZoneSpec[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"XZoneRects.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/XZoneRects.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C;;;;GAIG;AACH,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;AAEvD,MAAM,MAAM,SAAS,GAAG;IACtB,iGAAiG;IACjG,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,gGAAgG;IAChG,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"XZoneRects.d.ts","sourceRoot":"","sources":["../../../src/charts/primitives/XZoneRects.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C;;;;GAIG;AACH,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;AAEvD,MAAM,MAAM,SAAS,GAAG;IACtB,iGAAiG;IACjG,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,gGAAgG;IAChG,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;;;;;;OAUG;IACH,KAAK,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAA;CAC1B,CAAA;AAkCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,MAAM,EACN,MAAM,GACP,EAAE;IACD,KAAK,EAAE,SAAS,EAAE,CAAA;IAClB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,UAAU,CAAA;CACnB,+BAyCA"}
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { Fragment, jsx } from "react/jsx-runtime";
|
|
2
|
+
function clamp(value, min, max) {
|
|
3
|
+
return Math.min(Math.max(value, min), max);
|
|
4
|
+
}
|
|
5
|
+
function resolveBound({
|
|
6
|
+
key,
|
|
7
|
+
omittedEdge,
|
|
8
|
+
xScale,
|
|
9
|
+
align,
|
|
10
|
+
edgeSign,
|
|
11
|
+
rangeMin,
|
|
12
|
+
rangeMax
|
|
13
|
+
}) {
|
|
14
|
+
if (key === void 0) return omittedEdge;
|
|
15
|
+
const center = xScale(key);
|
|
16
|
+
if (center === void 0) return void 0;
|
|
17
|
+
if (align === "center") return center;
|
|
18
|
+
return clamp(center + edgeSign * (xScale.step() / 2), rangeMin, rangeMax);
|
|
19
|
+
}
|
|
2
20
|
function XZoneRects({
|
|
3
21
|
zones,
|
|
4
22
|
height,
|
|
@@ -6,8 +24,27 @@ function XZoneRects({
|
|
|
6
24
|
}) {
|
|
7
25
|
return /* @__PURE__ */ jsx(Fragment, { children: zones.map((z, i) => {
|
|
8
26
|
const [rangeStart, rangeEnd] = xScale.range();
|
|
9
|
-
const
|
|
10
|
-
const
|
|
27
|
+
const rangeMin = Math.min(rangeStart, rangeEnd);
|
|
28
|
+
const rangeMax = Math.max(rangeStart, rangeEnd);
|
|
29
|
+
const align = z.align ?? "center";
|
|
30
|
+
const xFrom = resolveBound({
|
|
31
|
+
key: z.from,
|
|
32
|
+
omittedEdge: rangeStart,
|
|
33
|
+
xScale,
|
|
34
|
+
align,
|
|
35
|
+
edgeSign: -1,
|
|
36
|
+
rangeMin,
|
|
37
|
+
rangeMax
|
|
38
|
+
});
|
|
39
|
+
const xTo = resolveBound({
|
|
40
|
+
key: z.to,
|
|
41
|
+
omittedEdge: rangeEnd,
|
|
42
|
+
xScale,
|
|
43
|
+
align,
|
|
44
|
+
edgeSign: 1,
|
|
45
|
+
rangeMin,
|
|
46
|
+
rangeMax
|
|
47
|
+
});
|
|
11
48
|
if (xFrom === void 0 || xTo === void 0) return null;
|
|
12
49
|
if (xTo <= xFrom) return null;
|
|
13
50
|
return /* @__PURE__ */ jsx(
|
package/dist/charts/series.d.ts
CHANGED
|
@@ -52,6 +52,14 @@ export type SeriesStyle = {
|
|
|
52
52
|
strokeWidth?: number;
|
|
53
53
|
/** bar/area: the swatch honors this so it cannot lie. */
|
|
54
54
|
fillOpacity?: number;
|
|
55
|
+
/**
|
|
56
|
+
* A MARK property — dims the plotted stroke, e.g. a faint moving-average companion line. The
|
|
57
|
+
* legend swatch honors it too (parity with `fillOpacity`, which it already honors — a swatch
|
|
58
|
+
* that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
|
|
59
|
+
* the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
|
|
60
|
+
* sub-1 opacity reads as a rendering bug, not as data.
|
|
61
|
+
*/
|
|
62
|
+
strokeOpacity?: number;
|
|
55
63
|
/** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
|
|
56
64
|
* chart dual-axis — `CartesianChart` reads it for the crosshair dots and the tooltip. */
|
|
57
65
|
axis?: 'left' | 'right';
|
|
@@ -75,8 +83,10 @@ export type SeriesStyle = {
|
|
|
75
83
|
export type ChartSeries<T> = SeriesStyle & {
|
|
76
84
|
/** null = line gap + skipped tooltip row. */
|
|
77
85
|
getValue: (d: T) => number | null;
|
|
78
|
-
/** Per-series override of the shared tooltip/value formatter.
|
|
79
|
-
|
|
86
|
+
/** Per-series override of the shared tooltip/value formatter. Receives the hovered datum
|
|
87
|
+
* alongside the value so a row can read fields beyond the plotted number (e.g. `97.5 kg (92.5 ×
|
|
88
|
+
* 3)`). */
|
|
89
|
+
formatValue?: (v: number, d: T) => string;
|
|
80
90
|
/** PR star / status dot. Return null for no marker at that point. */
|
|
81
91
|
getMarker?: (d: T) => {
|
|
82
92
|
color?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"series.d.ts","sourceRoot":"","sources":["../../src/charts/series.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAE3D;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AAE5C,iEAAiE;AACjE,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;AAEhD,0CAA0C;AAC1C,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,QAAQ,CAAA;AAE3C,uCAAuC;AACvC,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,SAAS,GAAG,WAAW,CAAA;AAE3D,6DAA6D;AAC7D,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAA;AAEjE;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,wBAAwB;IACxB,SAAS,CAAC,EAAE,eAAe,CAAA;IAC3B,4DAA4D;IAC5D,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,8FAA8F;IAC9F,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,uGAAuG;AACvG,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,UAAU,CAAA;IAChB,uBAAuB;IACvB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;6FACyF;IACzF,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,wBAAwB;IACxB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;gCAC4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAED,uFAAuF;AACvF,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI,WAAW,GAAG;IACzC,6CAA6C;IAC7C,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,CAAA;IACjC
|
|
1
|
+
{"version":3,"file":"series.d.ts","sourceRoot":"","sources":["../../src/charts/series.ts"],"names":[],"mappings":"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"}
|
package/dist/charts/series.js
CHANGED
|
@@ -9,6 +9,7 @@ function legendEntryFor(s) {
|
|
|
9
9
|
dashed: s.dash === "dashed",
|
|
10
10
|
...s.strokeWidth !== void 0 && { strokeWidth: s.strokeWidth },
|
|
11
11
|
...s.fillOpacity !== void 0 && { fillOpacity: s.fillOpacity },
|
|
12
|
+
...s.strokeOpacity !== void 0 && { strokeOpacity: s.strokeOpacity },
|
|
12
13
|
...s.role !== void 0 && { role: s.role },
|
|
13
14
|
...s.note !== void 0 && { note: s.note }
|
|
14
15
|
};
|
|
@@ -42,7 +43,7 @@ function deriveTooltipRows(series, datum, fallbackFormat) {
|
|
|
42
43
|
key: s.key,
|
|
43
44
|
label: s.label,
|
|
44
45
|
color: s.color,
|
|
45
|
-
value: format(value),
|
|
46
|
+
value: format(value, datum),
|
|
46
47
|
shape: shapeFor(s.mark),
|
|
47
48
|
dashed: s.dash === "dashed",
|
|
48
49
|
...s.strokeWidth !== void 0 && { strokeWidth: s.strokeWidth }
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# basalt-ui v1.
|
|
1
|
+
# basalt-ui v1.16.0
|
|
2
2
|
# Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
|
|
3
3
|
#
|
|
4
4
|
# basalt-ui is an opinionated framework for Mantine-based React apps.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
# ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
|
|
20
20
|
|
|
21
21
|
Package: basalt-ui
|
|
22
|
-
Version: 1.
|
|
22
|
+
Version: 1.16.0
|
|
23
23
|
|
|
24
24
|
## basalt-ui
|
|
25
25
|
Import: import { ... } from 'basalt-ui'
|
package/package.json
CHANGED
package/src/charts/index.ts
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Bars` — the `tooltip: false` seam threaded from `SeriesStyle.tooltip` through `BarsBar`/
|
|
3
|
+
* `BarsLine` (replacing the removed per-kind `hideBarTooltipRows`), plus the `BarsBar<T>`
|
|
4
|
+
* generic-default assignability guarantee. Real DOM harness (`tests/setup/dom.ts`): the cursor
|
|
5
|
+
* only advances via `useChartCursor`'s keyboard path (`ArrowRight` on the overlay's `role="slider"`),
|
|
6
|
+
* which is deterministic regardless of happy-dom's zeroed layout geometry — unlike the pointer path,
|
|
7
|
+
* which needs a real `getBoundingClientRect`.
|
|
8
|
+
*/
|
|
9
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { Bars } from './Bars'
|
|
12
|
+
import type { BarsBar } from './Bars'
|
|
13
|
+
|
|
14
|
+
type Row = { date: string; shown: number; quiet: number; trend: number }
|
|
15
|
+
|
|
16
|
+
const rows: Row[] = [{ date: '2026-08-01', shown: 10, quiet: 5, trend: 7 }]
|
|
17
|
+
|
|
18
|
+
describe('Bars — tooltip: false drops only that row', () => {
|
|
19
|
+
test('a quiet bar is drawn and legended but produces no tooltip row', async () => {
|
|
20
|
+
render(
|
|
21
|
+
<Bars<Row>
|
|
22
|
+
data={rows}
|
|
23
|
+
chartId="bars-tooltip"
|
|
24
|
+
getX={(d) => d.date}
|
|
25
|
+
getValue={(d, key) => d[key as 'shown' | 'quiet']}
|
|
26
|
+
positiveBars={[
|
|
27
|
+
{ key: 'shown', label: 'Shown', color: '#111' },
|
|
28
|
+
{ key: 'quiet', label: 'Quiet', color: '#222', tooltip: false },
|
|
29
|
+
]}
|
|
30
|
+
/>,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Both series are legended regardless of the tooltip flag.
|
|
34
|
+
expect(screen.getByText('Shown')).toBeTruthy()
|
|
35
|
+
expect(screen.getByText('Quiet')).toBeTruthy()
|
|
36
|
+
|
|
37
|
+
const slider = screen.getByRole('slider')
|
|
38
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
39
|
+
|
|
40
|
+
// The tooltip row duplicates 'Shown' (legend + row); 'Quiet' stays legend-only. The tooltip
|
|
41
|
+
// anchor is set through a `requestAnimationFrame` coalesce, so wait for the SECOND 'Shown' to
|
|
42
|
+
// land rather than resolving on the legend's already-present first one.
|
|
43
|
+
await waitFor(() => expect(screen.getAllByText('Shown')).toHaveLength(2))
|
|
44
|
+
expect(screen.getAllByText('Quiet')).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('Bars — lines[].tooltip: false drops only that row', () => {
|
|
49
|
+
test('a quiet line is drawn and legended but produces no tooltip row', async () => {
|
|
50
|
+
render(
|
|
51
|
+
<Bars<Row>
|
|
52
|
+
data={rows}
|
|
53
|
+
chartId="bars-line-tooltip"
|
|
54
|
+
getX={(d) => d.date}
|
|
55
|
+
getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
|
|
56
|
+
positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
|
|
57
|
+
lines={[{ key: 'trend', label: 'Trend', color: '#333', tooltip: false }]}
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// Both series are legended regardless of the tooltip flag.
|
|
62
|
+
expect(screen.getByText('Shown')).toBeTruthy()
|
|
63
|
+
expect(screen.getByText('Trend')).toBeTruthy()
|
|
64
|
+
|
|
65
|
+
const slider = screen.getByRole('slider')
|
|
66
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
67
|
+
|
|
68
|
+
// The tooltip row duplicates 'Shown' (legend + row); 'Trend' stays legend-only.
|
|
69
|
+
await waitFor(() => expect(screen.getAllByText('Shown')).toHaveLength(2))
|
|
70
|
+
expect(screen.getAllByText('Trend')).toHaveLength(1)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('Bars — lines[].strokeOpacity dims the plotted stroke', () => {
|
|
75
|
+
test('the line path carries the configured stroke-opacity attribute', () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<Bars<Row>
|
|
78
|
+
data={rows}
|
|
79
|
+
chartId="bars-line-opacity"
|
|
80
|
+
getX={(d) => d.date}
|
|
81
|
+
getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
|
|
82
|
+
positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
|
|
83
|
+
lines={[{ key: 'trend', label: 'Trend', color: '#333', strokeOpacity: 0.4 }]}
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const path = container.querySelector('path[stroke="#333"]')
|
|
88
|
+
expect(path?.getAttribute('stroke-opacity')).toBe('0.4')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('BarsBar<T> — the `= unknown` default stays assignable', () => {
|
|
93
|
+
test('an existing untyped BarsBar[] array is assignable to BarsBar<Row>[] contravariantly', () => {
|
|
94
|
+
const bars: BarsBar[] = [{ key: 'shown', label: 'Shown', color: '#111' }]
|
|
95
|
+
const typed: BarsBar<Row>[] = bars
|
|
96
|
+
expect(typed).toBe(bars)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -10,7 +10,12 @@ import { LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
|
10
10
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
11
11
|
import { padAutoLower } from '../utils/domain'
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `T = unknown` is load-bearing, not a shortcut: it keeps a consumer's existing
|
|
15
|
+
* `const bars: BarsBar[] = [...]` compiling (and still assignable to `BarsBar<Row>[]`,
|
|
16
|
+
* contravariantly) even though the type is now generic over the datum.
|
|
17
|
+
*/
|
|
18
|
+
export type BarsBar<T = unknown> = {
|
|
14
19
|
/** Field key — `getValue(d, key)` extracts the number (null = skip this slot, not domain hole). */
|
|
15
20
|
key: string
|
|
16
21
|
/** Tooltip + legend label. */
|
|
@@ -18,22 +23,30 @@ export type BarsBar = {
|
|
|
18
23
|
/** Fill color (a resolved CSS color / token ref). Per `docs/DESIGN-SPEC.md` §5, the primary bar
|
|
19
24
|
* series should typically resolve to `VX.accent`, a secondary companion to `VX.faint`. */
|
|
20
25
|
color: string
|
|
21
|
-
/** Per-series tooltip value formatter — overrides the left/right axis's own format.
|
|
22
|
-
|
|
26
|
+
/** Per-series tooltip value formatter — overrides the left/right axis's own format. Receives the
|
|
27
|
+
* hovered datum alongside the value. */
|
|
28
|
+
formatValue?: (v: number, d: T) => string
|
|
23
29
|
/** Y axis to plot against. Honored only in grouped layout. Default 'left'. */
|
|
24
30
|
axisSide?: 'left' | 'right'
|
|
25
31
|
/** Relative width within the group (only honored in grouped layout). Default 1 — all bars equal. */
|
|
26
32
|
weight?: number
|
|
33
|
+
/** Default true; false = drawn and legended, but never listed as a tooltip row — threads
|
|
34
|
+
* `SeriesStyle.tooltip` through `Bars`, replacing the removed per-kind `hideBarTooltipRows`. */
|
|
35
|
+
tooltip?: boolean
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
export type BarsLine = {
|
|
38
|
+
export type BarsLine<T = unknown> = {
|
|
30
39
|
key: string
|
|
31
40
|
label: string
|
|
32
41
|
color: string
|
|
33
42
|
axisSide?: 'left' | 'right'
|
|
34
43
|
strokeWidth?: number
|
|
35
44
|
dashed?: boolean
|
|
36
|
-
formatValue?: (v: number) => string
|
|
45
|
+
formatValue?: (v: number, d: T) => string
|
|
46
|
+
/** Default true; false = drawn and legended, but never listed as a tooltip row. */
|
|
47
|
+
tooltip?: boolean
|
|
48
|
+
/** Dims the plotted stroke AND the legend swatch, never the tooltip row (`SeriesStyle.strokeOpacity`). */
|
|
49
|
+
strokeOpacity?: number
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
/** @deprecated Use ZoneSpec from primitives/ZoneRects. Kept as an alias. */
|
|
@@ -57,12 +70,12 @@ export type BarsProps<T> = {
|
|
|
57
70
|
getValue: (d: T, key: string) => number | null
|
|
58
71
|
|
|
59
72
|
/** 1+ bar series, stacked when ≥2, plotted above baseline (y >= 0). */
|
|
60
|
-
positiveBars: BarsBar[]
|
|
73
|
+
positiveBars: BarsBar<T>[]
|
|
61
74
|
/** Optional bar series stacked below baseline (rendered as flipped negatives). */
|
|
62
|
-
negativeBars?: BarsBar[]
|
|
75
|
+
negativeBars?: BarsBar<T>[]
|
|
63
76
|
|
|
64
77
|
/** 0–2 line overlays, each on left or right axis. */
|
|
65
|
-
lines?: BarsLine[]
|
|
78
|
+
lines?: BarsLine<T>[]
|
|
66
79
|
|
|
67
80
|
/** Horizontal value-range overlays (target zones, optimal bands). */
|
|
68
81
|
zones?: BarsZone[]
|
|
@@ -157,6 +170,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
157
170
|
...(b.axisSide !== undefined && { axis: b.axisSide }),
|
|
158
171
|
getValue: (d: T) => getValue(d, b.key),
|
|
159
172
|
...(b.formatValue !== undefined && { formatValue: b.formatValue }),
|
|
173
|
+
...(b.tooltip !== undefined && { tooltip: b.tooltip }),
|
|
160
174
|
})),
|
|
161
175
|
[positiveBars, negativeBars, getValue],
|
|
162
176
|
)
|
|
@@ -173,6 +187,8 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
173
187
|
...(ln.axisSide !== undefined && { axis: ln.axisSide }),
|
|
174
188
|
getValue: (d: T) => getValue(d, ln.key),
|
|
175
189
|
...(ln.formatValue !== undefined && { formatValue: ln.formatValue }),
|
|
190
|
+
...(ln.tooltip !== undefined && { tooltip: ln.tooltip }),
|
|
191
|
+
...(ln.strokeOpacity !== undefined && { strokeOpacity: ln.strokeOpacity }),
|
|
176
192
|
})),
|
|
177
193
|
[lines, getValue],
|
|
178
194
|
)
|
|
@@ -294,9 +310,9 @@ type LinePt<T> = { __d: T; __y: number }
|
|
|
294
310
|
type BarsMarksProps<T> = {
|
|
295
311
|
getX: (d: T) => string
|
|
296
312
|
getValue: (d: T, key: string) => number | null
|
|
297
|
-
positiveBars: BarsBar[]
|
|
298
|
-
negativeBars: BarsBar[]
|
|
299
|
-
lines: BarsLine[]
|
|
313
|
+
positiveBars: BarsBar<T>[]
|
|
314
|
+
negativeBars: BarsBar<T>[]
|
|
315
|
+
lines: BarsLine<T>[]
|
|
300
316
|
barWidthRatio: number
|
|
301
317
|
barLayout: 'stacked' | 'grouped'
|
|
302
318
|
barOpacity?: (d: T, key: string) => number
|
|
@@ -485,7 +501,7 @@ function BarsMarks<T>({
|
|
|
485
501
|
stroke={ln.color}
|
|
486
502
|
strokeWidth={ln.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
487
503
|
strokeDasharray={ln.dashed ? VX.dashArray : undefined}
|
|
488
|
-
strokeOpacity={dim(ln.key)}
|
|
504
|
+
strokeOpacity={dim(ln.key) * (ln.strokeOpacity ?? 1)}
|
|
489
505
|
curve={curveMonotoneX}
|
|
490
506
|
/>
|
|
491
507
|
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DualPanel` — the bottom-pane domain seam (`bottomYDomain`/`bottomMaxAbsFloor`) and `formatBar`
|
|
3
|
+
* (tooltip-row-only override, `formatBottom` keeps owning the ticks). Same SSR harness as
|
|
4
|
+
* `ChartFrame.test.tsx` for the axis-only assertions (no hover needed); the `formatBar` case needs
|
|
5
|
+
* a live cursor, so it uses the real DOM harness + the overlay's keyboard path (parity with
|
|
6
|
+
* `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
|
|
7
|
+
* keyboard-focusable, so `getByRole('slider')` is unambiguous.
|
|
8
|
+
*/
|
|
9
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
12
|
+
import { DualPanel } from './DualPanel'
|
|
13
|
+
import type { ChartSeries } from '../series'
|
|
14
|
+
|
|
15
|
+
type Row = { date: string; v: number; bar: number }
|
|
16
|
+
|
|
17
|
+
const rows: Row[] = [{ date: '2026-08-01', v: 10, bar: 0.02 }]
|
|
18
|
+
|
|
19
|
+
const series: ChartSeries<Row>[] = [
|
|
20
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function renderStatic(bottomMaxAbsFloor?: number, bottomYDomain?: [number, number]): string {
|
|
24
|
+
return renderToStaticMarkup(
|
|
25
|
+
<DualPanel<Row>
|
|
26
|
+
data={rows}
|
|
27
|
+
chartId="dp-floor"
|
|
28
|
+
getX={(d) => d.date}
|
|
29
|
+
series={series}
|
|
30
|
+
getBar={(d) => d.bar}
|
|
31
|
+
barLabel="Bar"
|
|
32
|
+
barColorPositive="#0a0"
|
|
33
|
+
barColorNegative="#a00"
|
|
34
|
+
formatTop={(v) => String(v)}
|
|
35
|
+
formatBottom={(v) => v.toFixed(2)}
|
|
36
|
+
{...(bottomMaxAbsFloor !== undefined && { bottomMaxAbsFloor })}
|
|
37
|
+
{...(bottomYDomain !== undefined && { bottomYDomain })}
|
|
38
|
+
/>,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('DualPanel — bottomMaxAbsFloor', () => {
|
|
43
|
+
test('floors the symmetric max-abs so a near-zero plateau does not amplify to full height', () => {
|
|
44
|
+
// The raw data's max abs is 0.02 — nowhere near the 0.5 floor, so "0.50" can only appear on
|
|
45
|
+
// the bottom axis once the floor widens the domain.
|
|
46
|
+
expect(renderStatic()).not.toContain('>0.50<')
|
|
47
|
+
expect(renderStatic(0.5)).toContain('>0.50<')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('DualPanel — per-point getMarker rendering', () => {
|
|
52
|
+
test('renders exactly one circle per non-null marker, honouring color/r overrides', () => {
|
|
53
|
+
const markerRows: Row[] = [
|
|
54
|
+
{ date: '2026-08-01', v: 10, bar: 0.02 },
|
|
55
|
+
{ date: '2026-08-02', v: 12, bar: 0.03 },
|
|
56
|
+
{ date: '2026-08-03', v: 9, bar: 0.01 },
|
|
57
|
+
]
|
|
58
|
+
const markerSeries: ChartSeries<Row>[] = [
|
|
59
|
+
{
|
|
60
|
+
key: 'v',
|
|
61
|
+
label: 'V',
|
|
62
|
+
color: '#111',
|
|
63
|
+
mark: 'line',
|
|
64
|
+
getValue: (d) => d.v,
|
|
65
|
+
getMarker: (d) =>
|
|
66
|
+
d.date === '2026-08-01'
|
|
67
|
+
? { color: '#f00', r: 9 }
|
|
68
|
+
: d.date === '2026-08-03'
|
|
69
|
+
? { r: 3 }
|
|
70
|
+
: null,
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<DualPanel<Row>
|
|
76
|
+
data={markerRows}
|
|
77
|
+
chartId="dp-markers"
|
|
78
|
+
getX={(d) => d.date}
|
|
79
|
+
series={markerSeries}
|
|
80
|
+
getBar={(d) => d.bar}
|
|
81
|
+
barLabel="Bar"
|
|
82
|
+
barColorPositive="#0a0"
|
|
83
|
+
barColorNegative="#a00"
|
|
84
|
+
formatTop={(v) => String(v)}
|
|
85
|
+
formatBottom={(v) => v.toFixed(2)}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const circles = container.querySelectorAll('circle')
|
|
90
|
+
expect(circles).toHaveLength(2)
|
|
91
|
+
expect(circles[0]?.getAttribute('fill')).toBe('#f00')
|
|
92
|
+
expect(circles[0]?.getAttribute('r')).toBe('9')
|
|
93
|
+
// No color override on the second marker — falls back to the series color.
|
|
94
|
+
expect(circles[1]?.getAttribute('fill')).toBe('#111')
|
|
95
|
+
expect(circles[1]?.getAttribute('r')).toBe('3')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('DualPanel — fixed bottomYDomain ships verbatim (un-niced)', () => {
|
|
100
|
+
test('bottom axis ticks come from the pinned domain, not the computed max-abs', () => {
|
|
101
|
+
// Data's max abs is 0.02 — an auto domain would never produce a "5.00" tick. Pinning
|
|
102
|
+
// bottomYDomain to [-5, 5] must still paint it.
|
|
103
|
+
expect(renderStatic(undefined, [-5, 5])).toContain('>5.00<')
|
|
104
|
+
expect(renderStatic()).not.toContain('>5.00<')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('DualPanel — formatBar overrides only the tooltip row', () => {
|
|
109
|
+
test('the bar tooltip row reads formatBar, not formatBottom', async () => {
|
|
110
|
+
render(
|
|
111
|
+
<DualPanel<Row>
|
|
112
|
+
data={rows}
|
|
113
|
+
chartId="dp-formatbar"
|
|
114
|
+
getX={(d) => d.date}
|
|
115
|
+
series={series}
|
|
116
|
+
getBar={(d) => d.bar}
|
|
117
|
+
barLabel="Bar"
|
|
118
|
+
barColorPositive="#0a0"
|
|
119
|
+
barColorNegative="#a00"
|
|
120
|
+
formatTop={(v) => String(v)}
|
|
121
|
+
formatBottom={(v) => v.toFixed(2)}
|
|
122
|
+
formatBar={(v, d) => `${v.toFixed(3)} m/s (${d.date})`}
|
|
123
|
+
/>,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const slider = screen.getByRole('slider')
|
|
127
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
128
|
+
|
|
129
|
+
expect(await screen.findByText('0.020 m/s (2026-08-01)')).toBeTruthy()
|
|
130
|
+
})
|
|
131
|
+
})
|