@pond-ts/charts 0.35.0 → 0.37.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 CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
8
8
  them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
9
  patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...HEAD
12
+ [0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
13
+ [0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
12
14
  [0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
13
15
  [0.34.1]: https://github.com/pjm17971/pond-ts/compare/v0.34.0...v0.34.1
14
16
  [0.34.0]: https://github.com/pjm17971/pond-ts/compare/v0.33.0...v0.34.0
@@ -31,6 +33,70 @@ patch bumps are strictly additive.
31
33
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
32
34
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
33
35
 
36
+ ## [0.37.0] — 2026-07-02
37
+
38
+ A `@pond-ts/charts` release: the axis wave — label, tick, and domain controls
39
+ driven by the Tidal terminal's friction. `pond-ts`, `@pond-ts/react`, and
40
+ `@pond-ts/fit` carry no code changes — republished in lock-step (peer ranges
41
+ widen to `^0.37.0`).
42
+
43
+ ### Added
44
+
45
+ - **Charts — axis title typography (`theme.axis.title`).** The rotated y-axis
46
+ title now renders a touch larger than the ticks by default and is fully
47
+ themeable (`{ color, size, opacity }`, shared with the x-axis label). (#318)
48
+ - **Charts — `YAxis labelPlacement`.** `'rotated'` (default) or `'top'` — a
49
+ horizontal title above the axis, aligned to the axis line, in a reserved
50
+ header band that clears the top tick. (#318, #320)
51
+ - **Charts — `XAxis align`.** `'center'` (**new default**), `'auto'` (previous
52
+ behaviour: centred but first/last end-anchored), or `'right'` (label beside an
53
+ extended tick). (#318)
54
+ - **Charts — `YAxis pad`.** Fractional headroom added to each side of the
55
+ resolved domain (`0` default) — lifts a tight domain off the plot edges
56
+ without hand-computing bounds. (#319)
57
+ - **Charts — `YAxis boundaryLabels`.** `false` drops the top & bottom tick
58
+ numbers (gridlines stay) for stacked layouts where the edge labels crowd. (#319)
59
+ - **Charts — new `Charts/Axes` Storybook gallery** covering the above. (#318)
60
+
61
+ ### Changed
62
+
63
+ - **Charts — domain-extreme y-tick labels now clamp inside the row** instead of
64
+ half-overflowing the top/bottom edge (resolves Tidal friction F-charts-6). (#319)
65
+ - **Charts — `XAxis` tick-label default is now `'center'`** (was the
66
+ end-anchored `'auto'`). Pass `align="auto"` for the old behaviour. (#318)
67
+
68
+ ## [0.36.0] — 2026-07-02
69
+
70
+ A `@pond-ts/charts` release: a CSS-custom-property → theme bridge so a canvas
71
+ chart can follow a design system's tokens and dark/light toggle. `pond-ts`,
72
+ `@pond-ts/react`, and `@pond-ts/fit` carry no code changes — republished in
73
+ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.36.0`).
74
+
75
+ ### Added
76
+
77
+ - **Charts — `cssVarTheme(base, resolve, opts?)`.** Builds a `ChartTheme` by
78
+ overlaying CSS custom properties onto a base theme: a typed `resolve`
79
+ receives a `readVar` and returns only the slots to override. An unresolved
80
+ var keeps the base value (a missing token never blanks a colour). DOM-only by
81
+ design; safe under SSR / worker (returns the base + any literal fallbacks).
82
+ The typed `ChartTheme` stays the single styling channel — this generates it
83
+ from CSS rather than adding a second one. (#315)
84
+ - **Charts — `useChartTheme(base, resolve, opts?)`.** Wraps `cssVarTheme` and
85
+ re-resolves on a `data-theme` / `class` change (a `MutationObserver` on the
86
+ root, configurable via `{ target, attributes }`), so a chart follows
87
+ dark/light with the page — no `mode` prop threaded through. Returns a new
88
+ theme reference only when the resolved theme actually changed (the repaint
89
+ signal `ChartContainer` keys on), so an unrelated attribute toggle doesn't
90
+ repaint. Lives in `@pond-ts/charts` (not `@pond-ts/react`) to keep the
91
+ package graph acyclic. (#315)
92
+ - **Docs — charts recipes.** [Theming charts](https://pjm17971.github.io/pond-ts/docs/recipes/theming)
93
+ (the `ChartTheme` model, semantic identifiers, per-series dash, the CSS-var
94
+ bridge), [Using @pond-ts/charts](https://pjm17971.github.io/pond-ts/docs/recipes/using-charts)
95
+ (install, the Storybook `react-docgen` gotcha, the repaint contract,
96
+ in-dev consumption), and
97
+ [Resizable multi-panel layout](https://pjm17971.github.io/pond-ts/docs/recipes/resizable-panels).
98
+ (#314, #315, #316)
99
+
34
100
  ## [0.35.0] — 2026-07-02
35
101
 
36
102
  A `@pond-ts/charts` release: per-series line dash patterns. `pond-ts`,
package/dist/ChartRow.js CHANGED
@@ -12,6 +12,10 @@ const IMPLICIT_AXIS_ID = '__default__';
12
12
  /** Axis tick count for the per-axis formatter — matches `<YAxis>`'s tick count
13
13
  * so the readout formatter is calibrated exactly as the labels are. */
14
14
  const AXIS_TICK_COUNT = 5;
15
+ /** Vertical band (px) reserved above the plot for a `labelPlacement="top"` axis
16
+ * title, so it clears the top tick + plotted data. Sized for the default title
17
+ * (~`font.size + 1`); a much larger themed title may want more room. */
18
+ const TOP_LABEL_HEADER = 16;
15
19
  /**
16
20
  * A horizontal band sharing the container's time axis. `ChartRow` owns the
17
21
  * **horizontal layout** (axes left/right around a `<Layers>` plot area) and
@@ -91,6 +95,8 @@ export function ChartRow({ height, cursor, children }) {
91
95
  width: 0,
92
96
  min: undefined,
93
97
  max: undefined,
98
+ pad: 0,
99
+ labelPlacement: 'rotated',
94
100
  format: undefined,
95
101
  tickValues: undefined,
96
102
  index: 0,
@@ -126,6 +132,11 @@ export function ChartRow({ height, cursor, children }) {
126
132
  const containerLeftSlots = container.leftSlots;
127
133
  const containerRightSlots = container.rightSlots;
128
134
  const { axisSlots, leftPad, rightPad } = useMemo(() => placeAxisSlots(leftAxes, rightAxes, containerLeftSlots, containerRightSlots), [leftAxes, rightAxes, containerLeftSlots, containerRightSlots]);
135
+ // Header band reserved at the top of the plot when any axis draws a `'top'`
136
+ // title — the scale range then starts below it (see below).
137
+ const topHeader = effectiveAxes.some((ax) => ax.labelPlacement === 'top')
138
+ ? TOP_LABEL_HEADER
139
+ : 0;
129
140
  // One y-scale per axis. A layer counts toward an axis when its (late-resolved)
130
141
  // axis id matches; `resolveYDomain` handles the auto-fit + empty/flat/inverted
131
142
  // edges. yExtent() is O(points), so only walk the layers when a bound auto-fits.
@@ -137,11 +148,15 @@ export function ChartRow({ height, cursor, children }) {
137
148
  .filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
138
149
  .map((entry) => entry.layer.yExtent())
139
150
  : [];
140
- const [lo, hi] = resolveYDomain(ax.min, ax.max, extents);
141
- map.set(ax.id, scaleLinear().domain([lo, hi]).range([height, 0]));
151
+ const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad);
152
+ // Reserve a header band at the top when any axis draws a `'top'` title,
153
+ // so the title clears the top tick + plot (the whole row shifts down
154
+ // uniformly, keeping stacked axes aligned). No top titles ⇒ range top 0,
155
+ // so nothing changes for existing charts.
156
+ map.set(ax.id, scaleLinear().domain([lo, hi]).range([height, topHeader]));
142
157
  }
143
158
  return map;
144
- }, [effectiveAxes, layerList, height, defaultAxisId]);
159
+ }, [effectiveAxes, layerList, height, defaultAxisId, topHeader]);
145
160
  // A value formatter per axis (its `format` resolved against its scale) — shared
146
161
  // by the axis tick labels and the cursor readout so a value reads the same.
147
162
  const formats = useMemo(() => {
package/dist/XAxis.d.ts CHANGED
@@ -24,6 +24,19 @@ export interface XAxisProps {
24
24
  readonly at: number;
25
25
  readonly label: string;
26
26
  }>;
27
+ /**
28
+ * Horizontal placement of each tick label relative to its tick.
29
+ * - **`'center'` (default)** — every label centred on its tick. Note the
30
+ * first/last labels can then extend past the plot edges (the strip doesn't
31
+ * clip), so a wide first label may reach into the left y-axis gutter; use
32
+ * `'auto'` if that crowds.
33
+ * - `'auto'` — centred, but the first label left-anchors and the last
34
+ * right-anchors so the edge labels stay inside the plot (the old default).
35
+ * - `'right'` — the label sits to the **right** of an extended tick that
36
+ * drops from the axis line (label beside the tick, not under it) — useful
37
+ * for dense or wide labels that would collide when centred.
38
+ */
39
+ align?: 'auto' | 'center' | 'right';
27
40
  }
28
41
  /**
29
42
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
@@ -35,5 +48,5 @@ export interface XAxisProps {
35
48
  *
36
49
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
37
50
  */
38
- export declare function XAxis({ format, label, side, height, ticks: customTicks, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
51
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
39
52
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -17,7 +17,7 @@ const TICK_COUNT = 5;
17
17
  *
18
18
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
19
19
  */
20
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, } = {}) {
20
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, align = 'center', } = {}) {
21
21
  const container = useContext(ContainerContext);
22
22
  if (container === null) {
23
23
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -51,23 +51,33 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
51
51
  fontSize: `${theme.font.size}px`,
52
52
  color: theme.axis.label,
53
53
  }, children: [placed.map((t, i) => {
54
- // End-align the edge labels so they stay within [0, plotWidth].
55
- const labelTransform = i === 0
54
+ const isFirst = i === 0;
55
+ const isLast = i === placed.length - 1;
56
+ // `center`: every label centred on its tick. `auto`: centred, but the
57
+ // edge labels end-align so they stay within [0, plotWidth]. `right`:
58
+ // label left-anchored just past an extended tick (beside, not under).
59
+ const labelTransform = align === 'right'
56
60
  ? 'none'
57
- : i === placed.length - 1
58
- ? 'translateX(-100%)'
59
- : 'translateX(-50%)';
61
+ : align === 'auto' && isFirst
62
+ ? 'none'
63
+ : align === 'auto' && isLast
64
+ ? 'translateX(-100%)'
65
+ : 'translateX(-50%)';
66
+ // `right` drops a longer tick alongside the label; others keep the 4px stub.
67
+ const tickHeight = align === 'right' ? theme.font.size + 4 : 4;
68
+ const labelLeft = align === 'right' ? t.x + 4 : t.x;
69
+ const labelOffset = align === 'right' ? 2 : 6;
60
70
  return (_jsxs(Fragment, { children: [_jsx("div", { style: {
61
71
  position: 'absolute',
62
72
  left: `${t.x}px`,
63
73
  [onTop ? 'bottom' : 'top']: 0,
64
74
  width: '1px',
65
- height: '4px',
75
+ height: `${tickHeight}px`,
66
76
  background: theme.axis.grid,
67
77
  } }), _jsx("div", { style: {
68
78
  position: 'absolute',
69
- left: `${t.x}px`,
70
- [onTop ? 'bottom' : 'top']: '6px',
79
+ left: `${labelLeft}px`,
80
+ [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
71
81
  transform: labelTransform,
72
82
  whiteSpace: 'nowrap',
73
83
  }, children: t.label })] }, `${t.x}-${i}`));
@@ -77,7 +87,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
77
87
  width: '100%',
78
88
  textAlign: 'center',
79
89
  [onTop ? 'top' : 'bottom']: 0,
80
- opacity: 0.7,
90
+ // Themeable axis-title text (shared with the rotated y-axis title).
91
+ fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
92
+ color: theme.axis.title?.color ?? theme.axis.label,
93
+ opacity: theme.axis.title?.opacity ?? 0.85,
81
94
  whiteSpace: 'nowrap',
82
95
  }, children: label }))] }));
83
96
  }
package/dist/YAxis.d.ts CHANGED
@@ -11,9 +11,25 @@ export interface YAxisProps {
11
11
  side?: 'left' | 'right';
12
12
  /** Display label / unit (e.g. `bpm`); defaults to `id`. */
13
13
  label?: string;
14
+ /**
15
+ * How the axis title (`label`) is drawn:
16
+ * - **`'rotated'` (default)** — a thin vertical strip down the outer edge
17
+ * (the standard y-axis convention; fits long labels in a narrow gutter).
18
+ * - `'top'` — horizontal, at the top of the axis, aligned to its side. Reads
19
+ * better for short unit labels; keep it terse and pair it with a domain
20
+ * that has headroom (auto-fit / padded) so it doesn't crowd the top tick.
21
+ */
22
+ labelPlacement?: 'rotated' | 'top';
14
23
  /** Explicit domain bounds; omit to auto-fit the charts linked to this axis. */
15
24
  min?: number;
16
25
  max?: number;
26
+ /**
27
+ * Fractional headroom added to each side of the resolved domain — `0` (the
28
+ * default) means none. Lifts a tight domain off the plot edges without
29
+ * hand-computing bounds (e.g. `pad={0.05}` adds 5% of the span top & bottom).
30
+ * Applies to an explicit `[min, max]` or an auto-fit domain.
31
+ */
32
+ pad?: number;
17
33
  /**
18
34
  * Value formatting for the tick labels (and the cursor readout, which matches):
19
35
  * a d3 format specifier string (e.g. `'.0%'`, `',.2f'`) or a `(value) => string`
@@ -37,6 +53,14 @@ export interface YAxisProps {
37
53
  readonly at: number;
38
54
  readonly label: string;
39
55
  }>;
56
+ /**
57
+ * Render the tick labels at the domain extremes (the top & bottom ticks)?
58
+ * **Default `true`.** `false` drops just those two numbers — the gridlines
59
+ * stay — for when the min/max labels crowd a stacked row's edges and you'd
60
+ * rather omit them than keep them. (Extreme labels are otherwise clamped to
61
+ * stay inside the row, never overflowing the edge.)
62
+ */
63
+ boundaryLabels?: boolean;
40
64
  /** Gutter width in CSS pixels (default 50). */
41
65
  width?: number;
42
66
  /**
@@ -53,5 +77,5 @@ export interface YAxisProps {
53
77
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
54
78
  * (default: the first axis).
55
79
  */
56
- export declare function YAxis({ id, side, label, min, max, format, ticks, width, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
80
+ export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
57
81
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -13,7 +13,7 @@ const TICK_COUNT = 5;
13
13
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
14
14
  * (default: the first axis).
15
15
  */
16
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, width = DEFAULT_WIDTH, index = 0, }) {
16
+ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', index = 0, }) {
17
17
  const container = useContext(ContainerContext);
18
18
  if (container === null) {
19
19
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -28,10 +28,12 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, width
28
28
  width,
29
29
  min,
30
30
  max,
31
+ pad,
32
+ labelPlacement,
31
33
  format,
32
34
  tickValues: ticks?.map((t) => t.at),
33
35
  index,
34
- }), [id, side, width, min, max, format, ticks, index]);
36
+ }), [id, side, width, min, max, pad, labelPlacement, format, ticks, index]);
35
37
  // A stable per-instance slot (see useSlotKey) keeps this axis in a fixed
36
38
  // registry position, so a min/max/side change updates in place rather than
37
39
  // re-appending (which would move the first axis behind a later one and
@@ -76,27 +78,50 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, width
76
78
  fontSize: `${theme.font.size}px`,
77
79
  color: theme.axis.label,
78
80
  }, children: [yScale &&
79
- tickList.map(({ value, label }) => (_jsx("div", { style: {
80
- position: 'absolute',
81
- top: `${yScale(value)}px`,
82
- [side === 'left' ? 'right' : 'left']: '4px',
83
- transform: 'translateY(-50%)',
84
- whiteSpace: 'nowrap',
85
- }, children: label }, value))), _jsx("div", { style: {
81
+ tickList.map(({ value, label }, i) => {
82
+ // Drop just the top & bottom labels when boundary labels are off
83
+ // (gridlines are drawn separately, so they stay).
84
+ if (!boundaryLabels && (i === 0 || i === tickList.length - 1))
85
+ return null;
86
+ // Clamp the label's centre so a domain-extreme label stays inside
87
+ // the row instead of half-overflowing the top/bottom edge (and
88
+ // colliding across a splitter in a stacked layout) — F-charts-6.
89
+ const half = theme.font.size / 2 + 1;
90
+ const top = Math.max(half, Math.min(row.height - half, yScale(value)));
91
+ return (_jsx("div", { style: {
92
+ position: 'absolute',
93
+ top: `${top}px`,
94
+ [side === 'left' ? 'right' : 'left']: '4px',
95
+ transform: 'translateY(-50%)',
96
+ whiteSpace: 'nowrap',
97
+ }, children: label }, value));
98
+ }), labelPlacement === 'top' ? (_jsx("div", { style: {
99
+ position: 'absolute',
100
+ top: 0,
101
+ // Align to the axis line (the plot-facing edge), matching the tick
102
+ // labels' alignment, rather than floating at the outer gutter edge.
103
+ [side === 'left' ? 'right' : 'left']: '4px',
104
+ fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
105
+ color: theme.axis.title?.color ?? theme.axis.label,
106
+ opacity: theme.axis.title?.opacity ?? 0.85,
107
+ whiteSpace: 'nowrap',
108
+ pointerEvents: 'none',
109
+ }, children: label ?? id })) : (_jsx("div", { style: {
86
110
  position: 'absolute',
87
111
  [side === 'left' ? 'left' : 'right']: '1px',
88
112
  top: 0,
89
113
  bottom: 0,
90
- width: `${theme.font.size + 2}px`,
114
+ width: `${(theme.axis.title?.size ?? theme.font.size + 1) + 3}px`,
91
115
  display: 'flex',
92
116
  alignItems: 'center',
93
117
  justifyContent: 'center',
94
- fontSize: `${theme.font.size - 1}px`,
95
- opacity: 0.7,
118
+ fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
119
+ color: theme.axis.title?.color ?? theme.axis.label,
120
+ opacity: theme.axis.title?.opacity ?? 0.85,
96
121
  pointerEvents: 'none',
97
122
  }, children: _jsx("span", { style: {
98
123
  whiteSpace: 'nowrap',
99
124
  transform: `rotate(${side === 'left' ? -90 : 90}deg)`,
100
- }, children: label ?? id }) })] }) }));
125
+ }, children: label ?? id }) }))] }) }));
101
126
  }
102
127
  //# sourceMappingURL=YAxis.js.map
package/dist/context.d.ts CHANGED
@@ -396,6 +396,10 @@ export interface AxisSpec {
396
396
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
397
397
  readonly min: number | undefined;
398
398
  readonly max: number | undefined;
399
+ /** Fractional headroom added to each side of the resolved domain (`0` = none). */
400
+ readonly pad: number;
401
+ /** Title placement; `'top'` makes the row reserve a header band above the plot. */
402
+ readonly labelPlacement: 'rotated' | 'top';
399
403
  /** Value formatting for the tick labels + the cursor readout ({@link AxisFormat}),
400
404
  * or `undefined` for the scale's d3 default. */
401
405
  readonly format: AxisFormat | undefined;
@@ -0,0 +1,50 @@
1
+ import type { ChartTheme } from './theme.js';
2
+ /**
3
+ * A deep-partial of {@link ChartTheme} — every leaf optional, arrays kept whole
4
+ * (so `axis.gridDash` / `annotation.depth` replace rather than merge
5
+ * element-wise). The shape a {@link cssVarTheme} resolver returns: name only the
6
+ * slots you're driving from CSS, everything else falls through to the base.
7
+ */
8
+ export type ChartThemeOverrides = DeepPartial<ChartTheme>;
9
+ type DeepPartial<T> = T extends readonly unknown[] ? T : T extends object ? {
10
+ [K in keyof T]?: DeepPartial<T[K]> | undefined;
11
+ } : T;
12
+ /**
13
+ * Reads a CSS custom property's computed value. Returns the trimmed value, or
14
+ * `fallback` when the property is empty / unset / there's no DOM (SSR, worker).
15
+ * `undefined` from a resolver leaf means "leave the base theme's value" — so a
16
+ * missing var never blanks a colour.
17
+ */
18
+ export type VarReader = (name: string, fallback?: string) => string | undefined;
19
+ /**
20
+ * Build a {@link ChartTheme} by overlaying CSS-custom-property values onto a
21
+ * `base` theme — the adapter that lets a chart track a design system's tokens
22
+ * (and its dark/light toggle) without hand-mirroring hex values.
23
+ *
24
+ * `resolve` receives a {@link VarReader} and returns only the slots to override
25
+ * (a {@link ChartThemeOverrides}); the result is `base` deep-merged with them.
26
+ * The typed `ChartTheme` stays the one styling channel — this **generates** it
27
+ * from CSS, it doesn't add a second one.
28
+ *
29
+ * ```ts
30
+ * const theme = cssVarTheme(defaultTheme, (v) => ({
31
+ * line: { default: { color: v('--td-primary') }, secondary: { color: v('--td-secondary') } },
32
+ * axis: { label: v('--td-text-3'), grid: v('--td-hairline') },
33
+ * cursor: v('--td-text-3'),
34
+ * font: { family: v('--td-font-mono') },
35
+ * }));
36
+ * ```
37
+ *
38
+ * **DOM-only, by design.** It reads `getComputedStyle` off `opts.element` (or
39
+ * `document.documentElement`). With no DOM — SSR, an OffscreenCanvas worker —
40
+ * every `readVar` returns its fallback (or `undefined`, kept from `base`), so
41
+ * the call is safe and returns the base theme (plus any literal fallbacks). For
42
+ * a live chart that follows a theme toggle, use {@link useChartTheme}, which
43
+ * wraps this and re-resolves on a `data-theme` change — don't call `cssVarTheme`
44
+ * per frame (`getComputedStyle` is a layout read).
45
+ */
46
+ export declare function cssVarTheme(base: ChartTheme, resolve: (readVar: VarReader) => ChartThemeOverrides, opts?: {
47
+ element?: Element;
48
+ }): ChartTheme;
49
+ export {};
50
+ //# sourceMappingURL=css-theme.d.ts.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Build a {@link ChartTheme} by overlaying CSS-custom-property values onto a
3
+ * `base` theme — the adapter that lets a chart track a design system's tokens
4
+ * (and its dark/light toggle) without hand-mirroring hex values.
5
+ *
6
+ * `resolve` receives a {@link VarReader} and returns only the slots to override
7
+ * (a {@link ChartThemeOverrides}); the result is `base` deep-merged with them.
8
+ * The typed `ChartTheme` stays the one styling channel — this **generates** it
9
+ * from CSS, it doesn't add a second one.
10
+ *
11
+ * ```ts
12
+ * const theme = cssVarTheme(defaultTheme, (v) => ({
13
+ * line: { default: { color: v('--td-primary') }, secondary: { color: v('--td-secondary') } },
14
+ * axis: { label: v('--td-text-3'), grid: v('--td-hairline') },
15
+ * cursor: v('--td-text-3'),
16
+ * font: { family: v('--td-font-mono') },
17
+ * }));
18
+ * ```
19
+ *
20
+ * **DOM-only, by design.** It reads `getComputedStyle` off `opts.element` (or
21
+ * `document.documentElement`). With no DOM — SSR, an OffscreenCanvas worker —
22
+ * every `readVar` returns its fallback (or `undefined`, kept from `base`), so
23
+ * the call is safe and returns the base theme (plus any literal fallbacks). For
24
+ * a live chart that follows a theme toggle, use {@link useChartTheme}, which
25
+ * wraps this and re-resolves on a `data-theme` change — don't call `cssVarTheme`
26
+ * per frame (`getComputedStyle` is a layout read).
27
+ */
28
+ export function cssVarTheme(base, resolve, opts) {
29
+ const el = opts?.element ??
30
+ (typeof document !== 'undefined' ? document.documentElement : undefined);
31
+ const style = el && typeof getComputedStyle === 'function'
32
+ ? getComputedStyle(el)
33
+ : undefined;
34
+ const readVar = (name, fallback) => {
35
+ const raw = style?.getPropertyValue(name).trim();
36
+ return raw ? raw : fallback;
37
+ };
38
+ return deepMerge(base, resolve(readVar));
39
+ }
40
+ /**
41
+ * Deep-merge `partial` over `base`: recurse into plain objects, replace at
42
+ * leaves and arrays, and **skip `undefined`** in `partial` (so an unresolved
43
+ * var keeps the base value). Never mutates `base`; each merged object level is
44
+ * freshly spread, and untouched subtrees are shared by reference (safe — a
45
+ * `ChartTheme` is read-only).
46
+ */
47
+ function deepMerge(base, partial) {
48
+ if (partial === undefined)
49
+ return base;
50
+ if (base === null ||
51
+ typeof base !== 'object' ||
52
+ Array.isArray(base) ||
53
+ Array.isArray(partial) ||
54
+ typeof partial !== 'object' ||
55
+ partial === null) {
56
+ // Leaf, array (replace whole), or a partial that introduces a value where
57
+ // the base had none — the partial wins.
58
+ return partial;
59
+ }
60
+ const out = { ...base };
61
+ for (const key of Object.keys(partial)) {
62
+ // Never let a resolver's key rewrite the prototype chain.
63
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
64
+ continue;
65
+ const pv = partial[key];
66
+ if (pv === undefined)
67
+ continue;
68
+ out[key] = deepMerge(base[key], pv);
69
+ }
70
+ return out;
71
+ }
72
+ //# sourceMappingURL=css-theme.js.map
package/dist/domain.d.ts CHANGED
@@ -14,6 +14,11 @@
14
14
  * can otherwise invert it (e.g. `min=5` with no data would naively give
15
15
  * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
16
16
  * is a deliberate axis flip; we don't second-guess it).
17
+ *
18
+ * `pad` (fractional, default `0`) expands the resolved domain outward by
19
+ * `pad × span` on each side — headroom without hand-computing bounds, useful to
20
+ * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
21
+ * domain was resolved (explicit or auto); `0` is a no-op.
17
22
  */
18
- export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>): [number, number];
23
+ export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number): [number, number];
19
24
  //# sourceMappingURL=domain.d.ts.map
package/dist/domain.js CHANGED
@@ -15,8 +15,22 @@ import { scaleLinear } from 'd3-scale';
15
15
  * can otherwise invert it (e.g. `min=5` with no data would naively give
16
16
  * `[5, 1]`). Two explicit bounds are returned as-is (an inverted explicit domain
17
17
  * is a deliberate axis flip; we don't second-guess it).
18
+ *
19
+ * `pad` (fractional, default `0`) expands the resolved domain outward by
20
+ * `pad × span` on each side — headroom without hand-computing bounds, useful to
21
+ * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
22
+ * domain was resolved (explicit or auto); `0` is a no-op.
18
23
  */
19
- export function resolveYDomain(min, max, extents) {
24
+ export function resolveYDomain(min, max, extents, pad = 0) {
25
+ const result = resolveBase(min, max, extents);
26
+ if (pad) {
27
+ const [lo, hi] = result;
28
+ const p = pad * (hi - lo);
29
+ return [lo - p, hi + p];
30
+ }
31
+ return result;
32
+ }
33
+ function resolveBase(min, max, extents) {
20
34
  // Both bounds explicit: trust them verbatim (allows an intentional flip).
21
35
  if (min !== undefined && max !== undefined)
22
36
  return [min, max];
package/dist/index.d.ts CHANGED
@@ -52,5 +52,9 @@ export type { Curve } from './curve.js';
52
52
  export type { GapMode } from './gaps.js';
53
53
  export { defaultTheme, estelaTheme } from './theme.js';
54
54
  export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, BarStyle, } from './theme.js';
55
+ export { cssVarTheme } from './css-theme.js';
56
+ export type { ChartThemeOverrides, VarReader } from './css-theme.js';
57
+ export { useChartTheme } from './useChartTheme.js';
58
+ export type { UseChartThemeOptions } from './useChartTheme.js';
55
59
  export type { CursorMode, TrackerInfo, TrackerSample, SelectInfo, } from './context.js';
56
60
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -34,4 +34,9 @@ export { BarChart } from './BarChart.js';
34
34
  export { Region, Baseline, Marker } from './annotations.js';
35
35
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
36
36
  export { defaultTheme, estelaTheme } from './theme.js';
37
+ // CSS-custom-property → ChartTheme bridge: build a theme from a design system's
38
+ // tokens (`cssVarTheme`), and a hook that re-resolves it on a `data-theme`
39
+ // toggle so a canvas chart follows dark/light (`useChartTheme`).
40
+ export { cssVarTheme } from './css-theme.js';
41
+ export { useChartTheme } from './useChartTheme.js';
37
42
  //# sourceMappingURL=index.js.map
package/dist/theme.d.ts CHANGED
@@ -88,6 +88,17 @@ export interface ChartTheme {
88
88
  readonly grid: string;
89
89
  /** Gridline dash pattern (px on/off pairs); `[]` for solid. */
90
90
  readonly gridDash: readonly number[];
91
+ /**
92
+ * Typography for the axis **title** — the rotated y-axis unit strip and the
93
+ * x-axis label (distinct from the per-tick `label` colour above). Omit a
94
+ * field to fall back: `color` → `label`, `size` → `font.size + 1` (a touch
95
+ * larger than the ticks so the rotated strip reads), `opacity` → `0.85`.
96
+ */
97
+ readonly title?: {
98
+ readonly color?: string;
99
+ readonly size?: number;
100
+ readonly opacity?: number;
101
+ };
91
102
  };
92
103
  /** Label / tick typography. One source for axes + chrome. */
93
104
  readonly font: {
@@ -0,0 +1,50 @@
1
+ import { type ChartThemeOverrides, type VarReader } from './css-theme.js';
2
+ import type { ChartTheme } from './theme.js';
3
+ /** Options for {@link useChartTheme}. */
4
+ export interface UseChartThemeOptions {
5
+ /**
6
+ * Element whose CSS custom properties are read and whose attribute changes
7
+ * are watched. **Default `document.documentElement`** (`<html>`) — the usual
8
+ * home of a `data-theme` toggle. Pass a scoped element to theme one subtree.
9
+ */
10
+ target?: Element;
11
+ /**
12
+ * Attributes that, when they change on `target`, trigger a re-resolve.
13
+ * **Default `['data-theme', 'class']`** — the two common dark/light switches.
14
+ */
15
+ attributes?: readonly string[];
16
+ }
17
+ /**
18
+ * Live {@link ChartTheme} bound to CSS custom properties: resolves `resolve`
19
+ * against the DOM (via {@link cssVarTheme}) and **re-resolves whenever the
20
+ * theme toggle flips** — a `MutationObserver` watches `target`'s
21
+ * `data-theme` / `class`, so `<ChartContainer theme={useChartTheme(...)} />`
22
+ * follows dark/light with no `mode` prop threaded through and no hand-ordered
23
+ * attribute-then-read dance.
24
+ *
25
+ * When the resolved theme changes it returns a **new** reference, which is the
26
+ * repaint signal — `ChartContainer` redraws when handed a new `theme`. A
27
+ * watched mutation that doesn't change the resolved values (e.g. an app
28
+ * toggling an unrelated `class` on `<html>`) returns the *same* reference, so
29
+ * it doesn't repaint. Resolution runs on mount and on watched-attribute changes
30
+ * only — never per frame — so the `getComputedStyle` read stays cheap.
31
+ *
32
+ * ```tsx
33
+ * const theme = useChartTheme(defaultTheme, (v) => ({
34
+ * line: { default: { color: v('--td-primary') } },
35
+ * axis: { label: v('--td-text-3'), grid: v('--td-hairline') },
36
+ * }));
37
+ * return <ChartContainer width={w} theme={theme}>…</ChartContainer>;
38
+ * ```
39
+ *
40
+ * `base` and `resolve` are read fresh on every resolve (held in refs), so
41
+ * inline literals are fine — they don't need memoizing and don't re-subscribe
42
+ * the observer. **But** because a resolve only fires on mount + a watched
43
+ * mutation, changing `base`/`resolve` alone won't re-resolve until the next
44
+ * toggle; if you need to swap them and re-resolve immediately, change `target`
45
+ * / `attributes` (which re-subscribes) or remount. SSR-safe: the first value
46
+ * resolves with no DOM (returns `base` + any literal fallbacks); the client
47
+ * re-resolves on mount.
48
+ */
49
+ export declare function useChartTheme(base: ChartTheme, resolve: (readVar: VarReader) => ChartThemeOverrides, opts?: UseChartThemeOptions): ChartTheme;
50
+ //# sourceMappingURL=useChartTheme.d.ts.map
@@ -0,0 +1,79 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { cssVarTheme, } from './css-theme.js';
3
+ /**
4
+ * Live {@link ChartTheme} bound to CSS custom properties: resolves `resolve`
5
+ * against the DOM (via {@link cssVarTheme}) and **re-resolves whenever the
6
+ * theme toggle flips** — a `MutationObserver` watches `target`'s
7
+ * `data-theme` / `class`, so `<ChartContainer theme={useChartTheme(...)} />`
8
+ * follows dark/light with no `mode` prop threaded through and no hand-ordered
9
+ * attribute-then-read dance.
10
+ *
11
+ * When the resolved theme changes it returns a **new** reference, which is the
12
+ * repaint signal — `ChartContainer` redraws when handed a new `theme`. A
13
+ * watched mutation that doesn't change the resolved values (e.g. an app
14
+ * toggling an unrelated `class` on `<html>`) returns the *same* reference, so
15
+ * it doesn't repaint. Resolution runs on mount and on watched-attribute changes
16
+ * only — never per frame — so the `getComputedStyle` read stays cheap.
17
+ *
18
+ * ```tsx
19
+ * const theme = useChartTheme(defaultTheme, (v) => ({
20
+ * line: { default: { color: v('--td-primary') } },
21
+ * axis: { label: v('--td-text-3'), grid: v('--td-hairline') },
22
+ * }));
23
+ * return <ChartContainer width={w} theme={theme}>…</ChartContainer>;
24
+ * ```
25
+ *
26
+ * `base` and `resolve` are read fresh on every resolve (held in refs), so
27
+ * inline literals are fine — they don't need memoizing and don't re-subscribe
28
+ * the observer. **But** because a resolve only fires on mount + a watched
29
+ * mutation, changing `base`/`resolve` alone won't re-resolve until the next
30
+ * toggle; if you need to swap them and re-resolve immediately, change `target`
31
+ * / `attributes` (which re-subscribes) or remount. SSR-safe: the first value
32
+ * resolves with no DOM (returns `base` + any literal fallbacks); the client
33
+ * re-resolves on mount.
34
+ */
35
+ export function useChartTheme(base, resolve, opts) {
36
+ const baseRef = useRef(base);
37
+ baseRef.current = base;
38
+ const resolveRef = useRef(resolve);
39
+ resolveRef.current = resolve;
40
+ const target = opts?.target;
41
+ // Stable key for the effect dep so an inline `attributes` array doesn't
42
+ // re-subscribe every render.
43
+ const attrKey = (opts?.attributes ?? ['data-theme', 'class']).join(',');
44
+ const compute = () => cssVarTheme(baseRef.current, resolveRef.current, target ? { element: target } : undefined);
45
+ const [theme, setTheme] = useState(compute);
46
+ // Re-resolve, but only push a new reference when the resolved theme actually
47
+ // changed — so a watched-but-unrelated mutation (e.g. an app toggling a
48
+ // scroll-lock `class` on `<html>`) doesn't force a repaint. Identical values
49
+ // ⇒ return the previous reference ⇒ React bails out.
50
+ const resolveAndApply = () => {
51
+ const next = compute();
52
+ setTheme((prev) => (themesEqual(prev, next) ? prev : next));
53
+ };
54
+ useEffect(() => {
55
+ const el = target ??
56
+ (typeof document !== 'undefined' ? document.documentElement : undefined);
57
+ if (!el || typeof MutationObserver === 'undefined')
58
+ return;
59
+ // Re-resolve on mount: the SSR/first value was computed without a DOM.
60
+ resolveAndApply();
61
+ const observer = new MutationObserver(resolveAndApply);
62
+ observer.observe(el, {
63
+ attributes: true,
64
+ attributeFilter: attrKey.split(','),
65
+ });
66
+ return () => observer.disconnect();
67
+ // `resolveAndApply` closes over stable refs; re-subscribe only on
68
+ // target/attr change.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [target, attrKey]);
71
+ return theme;
72
+ }
73
+ /** Value-equality for two resolved themes. A `ChartTheme` is a plain tree of
74
+ * strings / numbers / small arrays (no functions, stable key order), so a
75
+ * JSON compare is correct and cheap at the mount/toggle cadence this runs at. */
76
+ function themesEqual(a, b) {
77
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
78
+ }
79
+ //# sourceMappingURL=useChartTheme.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.35.0",
42
- "pond-ts": "^0.35.0",
41
+ "@pond-ts/react": "^0.37.0",
42
+ "pond-ts": "^0.37.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {