basalt-ui 1.16.0 → 1.18.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 (69) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agent/rules/basalt-charts.md +58 -4
  3. package/agent/skills/basalt-design/SKILL.md +2 -2
  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 +1 -0
  11. package/dist/charts/index.d.ts.map +1 -1
  12. package/dist/charts/kinds/Bars.d.ts +9 -0
  13. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  14. package/dist/charts/kinds/Bars.js +4 -0
  15. package/dist/charts/kinds/DualPanel.d.ts +24 -0
  16. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  17. package/dist/charts/kinds/DualPanel.js +39 -9
  18. package/dist/charts/kinds/MultiLine.d.ts +9 -0
  19. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  20. package/dist/charts/kinds/MultiLine.js +10 -8
  21. package/dist/charts/kinds/StackedArea.d.ts +9 -0
  22. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  23. package/dist/charts/kinds/StackedArea.js +16 -1
  24. package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
  25. package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/ZonedLine.d.ts +9 -0
  27. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  28. package/dist/charts/kinds/ZonedLine.js +4 -0
  29. package/dist/charts/primitives/CartesianChart.d.ts +40 -2
  30. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  31. package/dist/charts/primitives/CartesianChart.js +16 -6
  32. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  33. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  34. package/dist/charts/primitives/ChartFrame.js +1 -1
  35. package/dist/charts/primitives/ChartTooltip.d.ts +12 -2
  36. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  37. package/dist/charts/primitives/ChartTooltip.js +5 -3
  38. package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
  39. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
  40. package/dist/charts/series.d.ts +9 -1
  41. package/dist/charts/series.d.ts.map +1 -1
  42. package/dist/charts/utils/domain.d.ts +15 -0
  43. package/dist/charts/utils/domain.d.ts.map +1 -1
  44. package/dist/charts/utils/domain.js +6 -1
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/cursor/resolve.test.ts +62 -0
  48. package/src/charts/cursor/resolve.ts +71 -7
  49. package/src/charts/hooks/useChartCursor.ts +12 -1
  50. package/src/charts/index.ts +1 -0
  51. package/src/charts/kinds/Bars.test.tsx +85 -0
  52. package/src/charts/kinds/Bars.tsx +13 -0
  53. package/src/charts/kinds/DualPanel.test.tsx +391 -1
  54. package/src/charts/kinds/DualPanel.tsx +74 -8
  55. package/src/charts/kinds/MultiLine.test.tsx +181 -0
  56. package/src/charts/kinds/MultiLine.tsx +19 -8
  57. package/src/charts/kinds/StackedArea.test.tsx +114 -0
  58. package/src/charts/kinds/StackedArea.tsx +25 -1
  59. package/src/charts/kinds/ZonedLine.test.tsx +110 -0
  60. package/src/charts/kinds/ZonedLine.tsx +13 -0
  61. package/src/charts/primitives/CartesianChart.test.tsx +284 -3
  62. package/src/charts/primitives/CartesianChart.tsx +64 -12
  63. package/src/charts/primitives/ChartFrame.test.tsx +42 -0
  64. package/src/charts/primitives/ChartFrame.tsx +9 -2
  65. package/src/charts/primitives/ChartTooltip.test.tsx +44 -0
  66. package/src/charts/primitives/ChartTooltip.tsx +14 -2
  67. package/src/charts/series.ts +8 -2
  68. package/src/charts/utils/domain.test.ts +31 -1
  69. package/src/charts/utils/domain.ts +19 -0
@@ -59,3 +59,65 @@ describe('resolveCursorPoint', () => {
59
59
  expect(resolveCursorPoint(i, '2026-08-02')).toBeNull()
60
60
  })
61
61
  })
62
+
63
+ describe("resolveCursorPoint — 'leading' resolution (bucket-keyed domains)", () => {
64
+ const weekly = (days: string[]) => buildDomainIndex(daily(days), (p) => p.date, 'leading')
65
+
66
+ test('resolves an exact key, same as nearest', () => {
67
+ const i = weekly(['01', '08', '15'])
68
+ expect(resolveCursorPoint(i, '2026-08-08')?.date).toBe('2026-08-08')
69
+ })
70
+
71
+ test("a target anywhere inside a bucket resolves to that bucket's LEADING key, not the nearest one", () => {
72
+ // The bug this replaces: a weekly chart keyed by the Monday of each week (step 7 days),
73
+ // hovered from a daily sibling landing in the back half of the bucket, used to resolve to the
74
+ // FOLLOWING week under nearest-match. Every back-half day must resolve to the bucket it is
75
+ // actually inside.
76
+ const i = weekly(['01', '08', '15'])
77
+ expect(resolveCursorPoint(i, '2026-08-02')?.date).toBe('2026-08-01')
78
+ expect(resolveCursorPoint(i, '2026-08-05')?.date).toBe('2026-08-01')
79
+ expect(resolveCursorPoint(i, '2026-08-07')?.date).toBe('2026-08-01')
80
+ expect(resolveCursorPoint(i, '2026-08-09')?.date).toBe('2026-08-08')
81
+ expect(resolveCursorPoint(i, '2026-08-14')?.date).toBe('2026-08-08')
82
+ })
83
+
84
+ test('a key BEFORE the first bucket resolves to null — no bucket contains it', () => {
85
+ // Deliberately NOT 'nearest''s one-step tolerance. The first key is the leading edge of the
86
+ // first bucket, so anything earlier is outside every bucket; snapping it onto the first one
87
+ // would paint a crosshair on a bucket that provably does not contain the hovered key.
88
+ const i = weekly(['02', '09', '16'])
89
+ expect(resolveCursorPoint(i, '2026-08-01')).toBeNull()
90
+ expect(resolveCursorPoint(i, '2026-08-02')?.date).toBe('2026-08-02')
91
+ })
92
+
93
+ test('the final bucket ends at last + step — that instant is already outside it', () => {
94
+ const i = weekly(['01', '08', '15']) // step = 7, so the last bucket covers [Aug 15, Aug 22)
95
+ expect(resolveCursorPoint(i, '2026-08-21')?.date).toBe('2026-08-15') // last day inside
96
+ expect(resolveCursorPoint(i, '2026-08-22')).toBeNull() // exactly last + step, exclusive
97
+ expect(resolveCursorPoint(i, '2026-08-23')).toBeNull()
98
+ })
99
+
100
+ test('returns null well beyond the domain, so unrelated charts never sync', () => {
101
+ const i = weekly(['01', '08', '15'])
102
+ expect(resolveCursorPoint(i, '2026-09-30')).toBeNull()
103
+ })
104
+
105
+ test('categorical domains fall back to exact match only, same as nearest', () => {
106
+ const points = [{ date: 'Direct' }, { date: 'Referral' }]
107
+ const i = buildDomainIndex(points, (p) => p.date, 'leading')
108
+ expect(resolveCursorPoint(i, 'Direct')?.date).toBe('Direct')
109
+ expect(resolveCursorPoint(i, 'Organic')).toBeNull()
110
+ })
111
+
112
+ test('a single-point chart resolves only its own key', () => {
113
+ const i = weekly(['01'])
114
+ expect(resolveCursorPoint(i, '2026-08-01')?.date).toBe('2026-08-01')
115
+ expect(resolveCursorPoint(i, '2026-08-02')).toBeNull()
116
+ })
117
+
118
+ test('an explicit resolution argument overrides the index-built default', () => {
119
+ // Built as 'nearest', resolved as 'leading' — the per-call argument wins.
120
+ const i = index(daily(['01', '08', '15']))
121
+ expect(resolveCursorPoint(i, '2026-08-05', 'leading')?.date).toBe('2026-08-01')
122
+ })
123
+ })
@@ -22,6 +22,27 @@ export function parseKey(key: string): number | null {
22
22
  return Number.isNaN(t) ? null : t
23
23
  }
24
24
 
25
+ /**
26
+ * How a foreign key maps onto a chart's own points.
27
+ *
28
+ * `'nearest'` (default) treats every key as a POINT — the foreign key resolves to whichever own
29
+ * point sits closest to it. Correct when a chart's x-domain is a set of instants.
30
+ *
31
+ * `'leading'` treats every key as the LEADING EDGE of a bucket covering `[key, nextKey)` — the
32
+ * foreign key resolves to the LAST own key `<=` it. Use this when a chart's keys are bucket
33
+ * starts (a weekly series keyed by the Monday of each week, a monthly series keyed by the 1st):
34
+ * under `'nearest'`, a target landing in the back half of a bucket resolves to the FOLLOWING
35
+ * bucket instead of the one it's actually inside, and the shared crosshair lands one column off
36
+ * for every back-half hover.
37
+ *
38
+ * The two modes differ at the DOMAIN EDGES too, and deliberately: `'nearest'` tolerates one step
39
+ * past each end (the target is an instant, so the closest point is still a defensible answer),
40
+ * while `'leading'` bounds strictly to `[first, last + step)` — outside that range no bucket
41
+ * CONTAINS the key, and answering with the nearest one would paint a crosshair on a bucket that
42
+ * provably excludes it.
43
+ */
44
+ export type CursorResolution = 'nearest' | 'leading'
45
+
25
46
  export type DomainIndex<T> = {
26
47
  byKey: Map<string, T>
27
48
  /** Parsed positions, ascending. Empty when the domain isn't parseable. */
@@ -30,10 +51,16 @@ export type DomainIndex<T> = {
30
51
  points: T[]
31
52
  /** Median spacing — the tolerance within which a foreign key still resolves. */
32
53
  step: number
54
+ /** The resolution strategy this index was built for. */
55
+ resolution: CursorResolution
33
56
  }
34
57
 
35
58
  /** Build the lookup structure once per `data` identity. */
36
- export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string): DomainIndex<T> {
59
+ export function buildDomainIndex<T>(
60
+ data: readonly T[],
61
+ getKey: (d: T) => string,
62
+ resolution: CursorResolution = 'nearest',
63
+ ): DomainIndex<T> {
37
64
  const byKey = new Map<string, T>()
38
65
  const parsed: { value: number; point: T }[] = []
39
66
  let parseable = true
@@ -48,7 +75,9 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
48
75
 
49
76
  // Under two parseable points there is no step to reason about, so nearest-match would be
50
77
  // unbounded — fall back to exact match.
51
- if (!parseable || parsed.length < 2) return { byKey, values: [], points: [], step: 0 }
78
+ if (!parseable || parsed.length < 2) {
79
+ return { byKey, values: [], points: [], step: 0, resolution }
80
+ }
52
81
 
53
82
  parsed.sort((a, b) => a.value - b.value)
54
83
  const values = parsed.map((p) => p.value)
@@ -60,15 +89,39 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
60
89
  }
61
90
  diffs.sort((a, b) => a - b)
62
91
 
63
- return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0 }
92
+ return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0, resolution }
93
+ }
94
+
95
+ /** `'leading'` resolution: the last own position `<= target`. The caller has already rejected
96
+ * anything outside `[first, last + step)`, so a containing bucket is guaranteed to exist. */
97
+ function resolveLeading<T>(index: DomainIndex<T>, target: number): T {
98
+ let lo = 0
99
+ let hi = index.values.length - 1
100
+ let result = 0
101
+ while (lo <= hi) {
102
+ const mid = (lo + hi) >> 1
103
+ if ((index.values[mid] as number) <= target) {
104
+ result = mid
105
+ lo = mid + 1
106
+ } else {
107
+ hi = mid - 1
108
+ }
109
+ }
110
+ return index.points[result] as T
64
111
  }
65
112
 
66
113
  /**
67
- * The own point a broadcast `key` maps to: exact match first, else the nearest parsed position
68
- * within one domain step. Out of range → null, so charts over unrelated domains never paint a
69
- * cursor for each other — which is what makes "shared by default" safe.
114
+ * The own point a broadcast `key` maps to: exact match first, else — per `index.resolution` — the
115
+ * nearest parsed position or the last position at/before the target, both within one domain step.
116
+ * Out of range → null, so charts over unrelated domains never paint a cursor for each other —
117
+ * which is what makes "shared by default" safe. A categorical (unparseable) domain falls back to
118
+ * exact-match-only in BOTH resolutions, since "nearest"/"leading" over a category is meaningless.
70
119
  */
71
- export function resolveCursorPoint<T>(index: DomainIndex<T>, key: string): T | null {
120
+ export function resolveCursorPoint<T>(
121
+ index: DomainIndex<T>,
122
+ key: string,
123
+ resolution: CursorResolution = index.resolution,
124
+ ): T | null {
72
125
  const exact = index.byKey.get(key)
73
126
  if (exact !== undefined) return exact
74
127
 
@@ -77,6 +130,17 @@ export function resolveCursorPoint<T>(index: DomainIndex<T>, key: string): T | n
77
130
 
78
131
  const first = index.values[0] as number
79
132
  const last = index.values[index.values.length - 1] as number
133
+
134
+ if (resolution === 'leading') {
135
+ // Containment, NOT tolerance. A bucket keyed `k` covers `[k, nextKey)`, so a target before the
136
+ // first key sits in no bucket at all, and `last + step` is the first instant past the final
137
+ // one. Borrowing `'nearest'`'s symmetric ±step window here would snap a pre-domain target onto
138
+ // the first bucket and a post-domain one onto the last — painting a crosshair on a bucket that
139
+ // provably does not contain the hovered key, which is the exact lie this mode exists to remove.
140
+ if (target < first || target >= last + index.step) return null
141
+ return resolveLeading(index, target)
142
+ }
143
+
80
144
  if (target < first - index.step || target > last + index.step) return null
81
145
 
82
146
  let lo = 0
@@ -1,6 +1,7 @@
1
1
  import { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import type { KeyboardEvent, PointerEvent } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
5
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
6
7
 
@@ -38,6 +39,7 @@ export function useChartCursor<T>({
38
39
  getKey,
39
40
  xScale,
40
41
  marginLeft,
42
+ resolution = 'nearest',
41
43
  }: {
42
44
  data: readonly T[]
43
45
  chartId: string
@@ -45,6 +47,12 @@ export function useChartCursor<T>({
45
47
  /** Own x scale — maps a domain key to a plot-local x offset. */
46
48
  xScale: (key: string) => number | undefined
47
49
  marginLeft: number
50
+ /**
51
+ * How a sibling's broadcast key resolves against this chart's own points. Default `'nearest'`
52
+ * (point domains). Pass `'leading'` when `getKey` returns a bucket's leading edge (a weekly
53
+ * series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
54
+ */
55
+ resolution?: CursorResolution
48
56
  }): ChartCursor<T> {
49
57
  const store = useCursorStore()
50
58
  const cursor = useCursorState()
@@ -58,7 +66,10 @@ export function useChartCursor<T>({
58
66
  const xScaleRef = useRef(xScale)
59
67
  xScaleRef.current = xScale
60
68
 
61
- const index = useMemo(() => buildDomainIndex(data, getKeyRef.current), [data])
69
+ const index = useMemo(
70
+ () => buildDomainIndex(data, getKeyRef.current, resolution),
71
+ [data, resolution],
72
+ )
62
73
 
63
74
  const frameRef = useRef<number | null>(null)
64
75
  const pendingRef = useRef<CursorAnchor | null>(null)
@@ -91,6 +91,7 @@ export { AreaGradient, areaFillUrl } from './primitives/AreaGradient'
91
91
 
92
92
  // ── Hooks ────────────────────────────────────────────────────────────────
93
93
  export { useChartCursor, type ChartCursor, type CursorAnchor } from './hooks/useChartCursor'
94
+ export type { CursorResolution } from './cursor/resolve'
94
95
  export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
95
96
 
96
97
  // ── Utils ────────────────────────────────────────────────────────────────
@@ -8,6 +8,9 @@
8
8
  */
9
9
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
10
10
  import { describe, expect, test } from 'bun:test'
11
+ import { ChartCursorScope } from '../cursor/scope'
12
+ import { CartesianChart } from '../primitives/CartesianChart'
13
+ import type { ChartSeries } from '../series'
11
14
  import { Bars } from './Bars'
12
15
  import type { BarsBar } from './Bars'
13
16
 
@@ -89,6 +92,88 @@ describe('Bars — lines[].strokeOpacity dims the plotted stroke', () => {
89
92
  })
90
93
  })
91
94
 
95
+ describe('Bars — formatX', () => {
96
+ test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
97
+ const { container } = render(
98
+ <Bars<Row>
99
+ data={rows}
100
+ chartId="bars-formatx"
101
+ getX={(d) => d.date}
102
+ getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
103
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
104
+ formatX={(key) => `X:${key}`}
105
+ />,
106
+ )
107
+ expect(container.innerHTML).toContain('X:2026-08-01')
108
+ })
109
+ })
110
+
111
+ describe('Bars — cursorResolution threads through to sibling resolution', () => {
112
+ // Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a Bars sibling folded into
113
+ // 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — the exact shape of the
114
+ // playground's "Weekly digest" pairing. Proves the prop reaches THIS kind, not just
115
+ // `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
116
+ const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
117
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
118
+ shown: i,
119
+ quiet: i,
120
+ trend: i,
121
+ }))
122
+ const foldedRows: Row[] = [
123
+ { date: '2026-08-01', shown: 0, quiet: 0, trend: 0 },
124
+ { date: '2026-08-08', shown: 1, quiet: 1, trend: 1 },
125
+ ]
126
+ const dailySeries: ChartSeries<Row>[] = [
127
+ { key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
128
+ ]
129
+
130
+ function renderPair(cursorResolution?: 'leading') {
131
+ render(
132
+ <ChartCursorScope>
133
+ <CartesianChart<Row>
134
+ data={dailyRows}
135
+ chartId="bars-daily"
136
+ getX={(d) => d.date}
137
+ series={dailySeries}
138
+ ariaLabel="Daily"
139
+ >
140
+ {() => null}
141
+ </CartesianChart>
142
+ <Bars<Row>
143
+ data={foldedRows}
144
+ chartId="bars-folded"
145
+ getX={(d) => d.date}
146
+ getValue={(d, key) => d[key as 'shown']}
147
+ positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
148
+ ariaLabel="Folded"
149
+ {...(cursorResolution !== undefined && { cursorResolution })}
150
+ />
151
+ </ChartCursorScope>,
152
+ )
153
+ }
154
+
155
+ // Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
156
+ // via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
157
+ function driveDailyToAug05() {
158
+ const dailySlider = screen.getByRole('slider', { name: 'Daily' })
159
+ for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
160
+ }
161
+
162
+ test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
163
+ renderPair('leading')
164
+ driveDailyToAug05()
165
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
166
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
167
+ })
168
+
169
+ test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
170
+ renderPair()
171
+ driveDailyToAug05()
172
+ const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
173
+ expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
174
+ })
175
+ })
176
+
92
177
  describe('BarsBar<T> — the `= unknown` default stays assignable', () => {
93
178
  test('an existing untyped BarsBar[] array is assignable to BarsBar<Row>[] contravariantly', () => {
94
179
  const bars: BarsBar[] = [{ key: 'shown', label: 'Shown', color: '#111' }]
@@ -1,6 +1,7 @@
1
1
  import { curveMonotoneX } from '@visx/curve'
2
2
  import { LinePath } from '@visx/shape'
3
3
  import { memo, useMemo } from 'react'
4
+ import type { CursorResolution } from '../cursor/resolve'
4
5
  import { VX } from '../../tokens'
5
6
  import type { ChartMargin } from '../../tokens'
6
7
  import { CartesianChart } from '../primitives/CartesianChart'
@@ -108,6 +109,14 @@ export type BarsProps<T> = {
108
109
 
109
110
  /** X tick count override. Default: as many as fit. */
110
111
  xTicks?: number
112
+ /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
113
+ formatX?: (key: string) => string
114
+ /**
115
+ * How a sibling chart's broadcast cursor key resolves against this chart's points. Default
116
+ * `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
117
+ * keyed by its Monday) — see `CursorResolution`.
118
+ */
119
+ cursorResolution?: CursorResolution
111
120
 
112
121
  /** `false` disables the tooltip entirely (and with it the crosshair dots). */
113
122
  tooltip?: CartesianTooltipConfig<T> | false
@@ -152,6 +161,8 @@ function BarsInner<T>(props: BarsProps<T>) {
152
161
  barLayout = 'stacked',
153
162
  barOpacity,
154
163
  xTicks,
164
+ formatX,
165
+ cursorResolution,
155
166
  tooltip,
156
167
  margin,
157
168
  legend,
@@ -269,6 +280,8 @@ function BarsInner<T>(props: BarsProps<T>) {
269
280
  y={yConfig}
270
281
  {...(y2 !== undefined && { y2 })}
271
282
  {...(xTicks !== undefined && { xTicks })}
283
+ {...(formatX !== undefined && { formatX })}
284
+ {...(cursorResolution !== undefined && { cursorResolution })}
272
285
  {...(zones !== undefined && { zones })}
273
286
  {...(mappedRefLines !== undefined && { refLines: mappedRefLines })}
274
287
  {...(height !== undefined && { height })}