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.
Files changed (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agent/rules/basalt-charts.md +28 -9
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  4. package/dist/charts/index.d.ts +1 -1
  5. package/dist/charts/index.d.ts.map +1 -1
  6. package/dist/charts/kinds/Bars.d.ts +21 -8
  7. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  8. package/dist/charts/kinds/Bars.js +6 -3
  9. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  10. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  11. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  12. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  13. package/dist/charts/kinds/DualPanel.js +55 -8
  14. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  15. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  16. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  17. package/dist/charts/kinds/MultiLine.js +35 -25
  18. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  19. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  20. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  21. package/dist/charts/kinds/ZonedLine.js +1 -0
  22. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  23. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  24. package/dist/charts/primitives/CartesianChart.d.ts +22 -4
  25. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  26. package/dist/charts/primitives/CartesianChart.js +18 -8
  27. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  28. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  29. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  30. package/dist/charts/primitives/ChartLegend.js +4 -0
  31. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  32. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  33. package/dist/charts/primitives/XZoneRects.js +39 -2
  34. package/dist/charts/series.d.ts +12 -2
  35. package/dist/charts/series.d.ts.map +1 -1
  36. package/dist/charts/series.js +2 -1
  37. package/llms.txt +2 -2
  38. package/package.json +1 -1
  39. package/src/charts/index.ts +1 -0
  40. package/src/charts/kinds/Bars.test.tsx +98 -0
  41. package/src/charts/kinds/Bars.tsx +28 -12
  42. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  43. package/src/charts/kinds/DualPanel.tsx +81 -9
  44. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  45. package/src/charts/kinds/MultiLine.tsx +45 -28
  46. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  47. package/src/charts/kinds/ZonedLine.tsx +1 -0
  48. package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
  49. package/src/charts/primitives/CartesianChart.tsx +44 -12
  50. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  51. package/src/charts/primitives/ChartLegend.tsx +7 -0
  52. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  53. package/src/charts/primitives/XZoneRects.tsx +75 -10
  54. package/src/charts/series.test.ts +51 -0
  55. 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({ domain: leftDomain, range: [yMax, 0] }),
187
- [leftDomain, yMax]
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({ domain: rightDomain, range: [yMax, 0] }),
191
- [rightDomain, yMax]
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.ts"],"names":[],"mappings":""}
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;AA4ND;;;;;;;;;;;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"}
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
- * An omitted `from` resolves to `0` (start of the plot area); an omitted `to` resolves to
21
- * `xScale.range()[1]` (the plot's right edge). A present key resolves to `xScale(key)` — the
22
- * POINT CENTER, so a band reads as "from this sample to that sample", not edge-to-edge.
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. Clamping would silently paint a band across the whole
26
- * chart for a typo'd or stale key, which is the exact class of quiet lie the repo's "nothing to
27
- * draw is two different states" doctrine rejects: an unknown key must read as "not drawn", not
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;CACb,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;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,+BAsBA"}
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 xFrom = z.from === void 0 ? rangeStart : xScale(z.from);
10
- const xTo = z.to === void 0 ? rangeEnd : xScale(z.to);
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(
@@ -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
- formatValue?: (v: number) => string;
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":"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"}
1
+ {"version":3,"file":"series.d.ts","sourceRoot":"","sources":["../../src/charts/series.ts"],"names":[],"mappings":"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"}
@@ -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.15.0
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.15.0
22
+ Version: 1.16.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -57,6 +57,7 @@ export {
57
57
  resolveAxisDomain,
58
58
  type CartesianChartProps,
59
59
  type CartesianTooltipConfig,
60
+ type CartesianTooltipRowContext,
60
61
  type AxisConfig,
61
62
  type PlotContext,
62
63
  } from './primitives/CartesianChart'
@@ -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
- export type BarsBar = {
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
- formatValue?: (v: number) => string
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
+ })