@pond-ts/charts 0.36.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,8 @@ 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.36.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
12
13
  [0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
13
14
  [0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
14
15
  [0.34.1]: https://github.com/pjm17971/pond-ts/compare/v0.34.0...v0.34.1
@@ -32,6 +33,38 @@ patch bumps are strictly additive.
32
33
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
33
34
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
34
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
+
35
68
  ## [0.36.0] — 2026-07-02
36
69
 
37
70
  A `@pond-ts/charts` release: a CSS-custom-property → theme bridge so a canvas
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;
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/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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.36.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.36.0",
42
- "pond-ts": "^0.36.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": {