basalt-ui 1.15.0 → 1.17.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 (91) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +74 -13
  3. package/agent/templates/CLAUDE-block.md.tpl +9 -6
  4. package/dist/charts/cursor/resolve.d.ts +29 -5
  5. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  6. package/dist/charts/cursor/resolve.js +25 -4
  7. package/dist/charts/hooks/useChartCursor.d.ts +8 -1
  8. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  9. package/dist/charts/hooks/useChartCursor.js +6 -2
  10. package/dist/charts/index.d.ts +2 -1
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +30 -8
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +10 -3
  15. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  16. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  17. package/dist/charts/kinds/DualPanel.d.ts +30 -0
  18. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  19. package/dist/charts/kinds/DualPanel.js +82 -12
  20. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  21. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  22. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  23. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  24. package/dist/charts/kinds/MultiLine.js +45 -33
  25. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  26. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/StackedArea.js +16 -1
  30. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  31. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  32. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  33. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  34. package/dist/charts/kinds/ZonedLine.js +5 -0
  35. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  36. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  37. package/dist/charts/primitives/CartesianChart.d.ts +45 -6
  38. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  39. package/dist/charts/primitives/CartesianChart.js +29 -12
  40. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
  41. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  42. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  43. package/dist/charts/primitives/ChartFrame.js +1 -1
  44. package/dist/charts/primitives/ChartLegend.d.ts +3 -0
  45. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  46. package/dist/charts/primitives/ChartLegend.js +4 -0
  47. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  48. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  49. package/dist/charts/primitives/ChartTooltip.js +3 -2
  50. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  51. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  52. package/dist/charts/primitives/XZoneRects.d.ts +22 -8
  53. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
  54. package/dist/charts/primitives/XZoneRects.js +39 -2
  55. package/dist/charts/series.d.ts +16 -3
  56. package/dist/charts/series.d.ts.map +1 -1
  57. package/dist/charts/series.js +2 -1
  58. package/dist/charts/utils/domain.d.ts +15 -0
  59. package/dist/charts/utils/domain.d.ts.map +1 -1
  60. package/dist/charts/utils/domain.js +6 -1
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/cursor/resolve.test.ts +62 -0
  64. package/src/charts/cursor/resolve.ts +71 -7
  65. package/src/charts/hooks/useChartCursor.ts +12 -1
  66. package/src/charts/index.ts +2 -0
  67. package/src/charts/kinds/Bars.test.tsx +183 -0
  68. package/src/charts/kinds/Bars.tsx +41 -12
  69. package/src/charts/kinds/DualPanel.test.tsx +441 -0
  70. package/src/charts/kinds/DualPanel.tsx +127 -11
  71. package/src/charts/kinds/MultiLine.test.tsx +218 -0
  72. package/src/charts/kinds/MultiLine.tsx +64 -36
  73. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  74. package/src/charts/kinds/StackedArea.tsx +25 -1
  75. package/src/charts/kinds/ZonedLine.test.tsx +147 -0
  76. package/src/charts/kinds/ZonedLine.tsx +14 -0
  77. package/src/charts/primitives/CartesianChart.test.tsx +269 -0
  78. package/src/charts/primitives/CartesianChart.tsx +79 -19
  79. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  80. package/src/charts/primitives/ChartFrame.tsx +9 -2
  81. package/src/charts/primitives/ChartLegend.test.tsx +30 -0
  82. package/src/charts/primitives/ChartLegend.tsx +7 -0
  83. package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
  84. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  85. package/src/charts/primitives/XZoneRects.test.tsx +60 -0
  86. package/src/charts/primitives/XZoneRects.tsx +75 -10
  87. package/src/charts/series.test.ts +51 -0
  88. package/src/charts/series.ts +18 -6
  89. package/src/charts/utils/domain.test.ts +31 -1
  90. package/src/charts/utils/domain.ts +19 -0
  91. package/src/charts/primitives/CartesianChart.test.ts +0 -99
@@ -60,6 +60,14 @@ export type SeriesStyle = {
60
60
  strokeWidth?: number
61
61
  /** bar/area: the swatch honors this so it cannot lie. */
62
62
  fillOpacity?: number
63
+ /**
64
+ * A MARK property — dims the plotted stroke, e.g. a faint moving-average companion line. The
65
+ * legend swatch honors it too (parity with `fillOpacity`, which it already honors — a swatch
66
+ * that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
67
+ * the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
68
+ * sub-1 opacity reads as a rendering bug, not as data.
69
+ */
70
+ strokeOpacity?: number
63
71
  /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
64
72
  * chart dual-axis — `CartesianChart` reads it for the crosshair dots and the tooltip. */
65
73
  axis?: 'left' | 'right'
@@ -84,10 +92,13 @@ export type SeriesStyle = {
84
92
  export type ChartSeries<T> = SeriesStyle & {
85
93
  /** null = line gap + skipped tooltip row. */
86
94
  getValue: (d: T) => number | null
87
- /** Per-series override of the shared tooltip/value formatter. */
88
- formatValue?: (v: number) => string
89
- /** PR star / status dot. Return null for no marker at that point. */
90
- getMarker?: (d: T) => { color?: string; r?: number } | null
95
+ /** Per-series override of the shared tooltip/value formatter. Receives the hovered datum
96
+ * alongside the value so a row can read fields beyond the plotted number (e.g. `97.5 kg (92.5 ×
97
+ * 3)`). */
98
+ formatValue?: (v: number, d: T) => string
99
+ /** PR star / status dot. Return null for no marker at that point. `ring` defaults true (today's
100
+ * punched-out stroke, unchanged); `false` omits the stroke entirely. `fillOpacity` defaults 1. */
101
+ getMarker?: (d: T) => { color?: string; r?: number; fillOpacity?: number; ring?: boolean } | null
91
102
  }
92
103
 
93
104
  /** A single derived tooltip row, ready for `TooltipRow`. */
@@ -112,6 +123,7 @@ function legendEntryFor(s: SeriesStyle): LegendEntry {
112
123
  dashed: s.dash === 'dashed',
113
124
  ...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
114
125
  ...(s.fillOpacity !== undefined && { fillOpacity: s.fillOpacity }),
126
+ ...(s.strokeOpacity !== undefined && { strokeOpacity: s.strokeOpacity }),
115
127
  ...(s.role !== undefined && { role: s.role }),
116
128
  ...(s.note !== undefined && { note: s.note }),
117
129
  }
@@ -165,12 +177,12 @@ export function deriveTooltipRows<T>(
165
177
  if (s.tooltip === false) continue
166
178
  const value = s.getValue(datum)
167
179
  if (value === null) continue
168
- const format = s.formatValue ?? fallbackFormat
180
+ const format: (v: number, d: T) => string = s.formatValue ?? fallbackFormat
169
181
  rows.push({
170
182
  key: s.key,
171
183
  label: s.label,
172
184
  color: s.color,
173
- value: format(value),
185
+ value: format(value, datum),
174
186
  shape: shapeFor(s.mark),
175
187
  dashed: s.dash === 'dashed',
176
188
  ...(s.strokeWidth !== undefined && { strokeWidth: s.strokeWidth }),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { padAutoLower } from './domain'
2
+ import { padAutoLower, padAutoUpper } from './domain'
3
3
 
4
4
  describe('padAutoLower', () => {
5
5
  test('stays at 0 for the default zero-baseline case (never goes negative)', () => {
@@ -22,3 +22,33 @@ describe('padAutoLower', () => {
22
22
  }
23
23
  })
24
24
  })
25
+
26
+ describe('padAutoUpper', () => {
27
+ test('stays at 0 (never pushed positive)', () => {
28
+ expect(padAutoUpper(0, 1.1)).toBe(0)
29
+ })
30
+
31
+ test('pads UP above a positive candidate', () => {
32
+ expect(padAutoUpper(78, 1.1)).toBeCloseTo(85.8, 5)
33
+ })
34
+
35
+ test('pads further up (toward and past zero) for a negative candidate', () => {
36
+ expect(padAutoUpper(-5, 1.1)).toBeCloseTo(-4.5454, 3)
37
+ })
38
+
39
+ test('upper never lands below the candidate it was derived from', () => {
40
+ for (const candidate of [-10, -0.5, 0, 0.5, 10, 78]) {
41
+ expect(padAutoUpper(candidate, 1.1)).toBeGreaterThanOrEqual(candidate)
42
+ }
43
+ })
44
+
45
+ test('the autoMaxFloor case: dataMax 3.2, pad 1.1, floor 6 -> 6.6', () => {
46
+ expect(padAutoUpper(Math.max(3.2, 6), 1.1)).toBeCloseTo(6.6, 5)
47
+ })
48
+
49
+ test('is the exact mirror of padAutoLower — same magnitude, opposite direction', () => {
50
+ for (const candidate of [-10, -0.5, 0.5, 10, 78]) {
51
+ expect(padAutoUpper(candidate, 1.1)).toBeCloseTo(-padAutoLower(-candidate, 1.1), 5)
52
+ }
53
+ })
54
+ })
@@ -20,3 +20,22 @@ export function padAutoLower(candidate: number, pad: number): number {
20
20
  if (candidate === 0) return 0
21
21
  return candidate > 0 ? candidate / pad : candidate * pad
22
22
  }
23
+
24
+ /**
25
+ * Auto y-domain upper-bound padding — the exact mirror of {@link padAutoLower}, sharing its
26
+ * sign-safety: pad AWAY from zero (`upper >= candidate`), never toward it. `candidate` is the
27
+ * caller's `Math.max(dataMax, floor)` — the `autoMaxFloor` clamp has already been applied, so
28
+ * padding runs AFTER the floor, not before it. Applying the floor post-pad (the pre-existing bug)
29
+ * lands a floored axis top exactly on the floor value with zero headroom — a target line pinned to
30
+ * that floor then sits glued to the plot edge instead of getting the same breathing room every
31
+ * other bound gets.
32
+ *
33
+ * - `candidate === 0` stays exactly 0 — never pushed positive.
34
+ * - `candidate > 0` multiplies by `pad`, moving the ceiling further UP (away from zero).
35
+ * - `candidate < 0` divides by `pad`, moving the ceiling UP toward (and past) zero — the mirror of
36
+ * `padAutoLower`'s `candidate > 0` branch.
37
+ */
38
+ export function padAutoUpper(candidate: number, pad: number): number {
39
+ if (candidate === 0) return 0
40
+ return candidate > 0 ? candidate * pad : candidate / pad
41
+ }
@@ -1,99 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import type { ChartSeries } from '../series'
3
- import { resolveAxisDomain } from './CartesianChart'
4
-
5
- type Row = { date: string; a: number; b: number }
6
-
7
- const rows: Row[] = [
8
- { date: '2026-08-01', a: 10, b: 4 },
9
- { date: '2026-08-02', a: 40, b: 6 },
10
- { date: '2026-08-03', a: 25, b: 5 },
11
- ]
12
-
13
- const seriesFor = (key: 'a' | 'b'): ChartSeries<Row> => ({
14
- key,
15
- label: key,
16
- color: '#000',
17
- mark: 'line',
18
- getValue: (d) => d[key],
19
- })
20
-
21
- const both = [seriesFor('a'), seriesFor('b')]
22
-
23
- describe('resolveAxisDomain', () => {
24
- test('a fixed tuple passes through untouched', () => {
25
- expect(resolveAxisDomain({ domain: [0, 100] }, rows, both)).toEqual([0, 100])
26
- })
27
-
28
- test('auto pads the upper bound away from zero and keeps the zero baseline', () => {
29
- const [min, max] = resolveAxisDomain(undefined, rows, both)
30
- expect(min).toBe(0)
31
- expect(max).toBeCloseTo(44, 5)
32
- })
33
-
34
- test('auto over an ALL-NEGATIVE series keeps the largest value inside the plot', () => {
35
- // `max * pad` would push the upper bound to -5.5, i.e. below the largest datum, clipping it.
36
- const negative: ChartSeries<Row>[] = [
37
- { ...seriesFor('a'), getValue: (d) => -d.a },
38
- { ...seriesFor('b'), getValue: (d) => -d.b },
39
- ]
40
- const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, negative)
41
- expect(max).toBeGreaterThanOrEqual(-4)
42
- expect(min).toBeLessThanOrEqual(-40)
43
- })
44
-
45
- test('autoMaxFloor raises a low upper bound', () => {
46
- const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
47
- expect(max).toBe(500)
48
- })
49
-
50
- test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
51
- const [, withBoth] = resolveAxisDomain(undefined, rows, both)
52
- const [, onlyB] = resolveAxisDomain(undefined, rows, [seriesFor('b')])
53
- expect(onlyB).toBeLessThan(withBoth)
54
- })
55
-
56
- test('a domain function receives the data AND the visible series', () => {
57
- const domain = resolveAxisDomain(
58
- { domain: (data, visible) => [0, data.length * visible.length] },
59
- rows,
60
- both,
61
- )
62
- expect(domain).toEqual([0, 6])
63
- })
64
-
65
- test('empty data falls back to a usable unit domain', () => {
66
- expect(resolveAxisDomain(undefined, [], both)).toEqual([0, 1])
67
- })
68
- })
69
-
70
- describe('resolveAxisDomain — overlay bounds', () => {
71
- test('a zone past the data stretches the axis instead of clipping', () => {
72
- const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
73
- expect(max).toBeGreaterThanOrEqual(200)
74
- })
75
-
76
- test('a reference line below the data lowers the floor', () => {
77
- const [min] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, both, [-30])
78
- expect(min).toBeLessThanOrEqual(-30)
79
- })
80
-
81
- test('infinite bounds ("top/bottom of axis") are ignored, not blown up', () => {
82
- const plain = resolveAxisDomain(undefined, rows, both)
83
- expect(resolveAxisDomain(undefined, rows, both, [-Infinity, Infinity])).toEqual(plain)
84
- })
85
- })
86
-
87
- describe('resolveAxisDomain — degenerate input', () => {
88
- test('a flat all-zero series still gets a usable axis, not a zero-extent scale', () => {
89
- const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 0 }]
90
- const [min, max] = resolveAxisDomain(undefined, rows, flat)
91
- expect(max).toBeGreaterThan(min)
92
- })
93
-
94
- test('a flat non-zero series is not collapsed onto its own value', () => {
95
- const flat: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 7 }]
96
- const [min, max] = resolveAxisDomain({ autoMinCeil: Infinity }, rows, flat)
97
- expect(max).toBeGreaterThan(min)
98
- })
99
- })