@pond-ts/charts 0.36.0 → 0.38.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.36.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...HEAD
12
+ [0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
13
+ [0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
12
14
  [0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
13
15
  [0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
14
16
  [0.34.1]: https://github.com/pjm17971/pond-ts/compare/v0.34.0...v0.34.1
@@ -32,6 +34,81 @@ patch bumps are strictly additive.
32
34
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
33
35
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
34
36
 
37
+ ## [0.38.0] — 2026-07-03
38
+
39
+ A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
40
+ cursor** — the ChartIQ / Yahoo-Finance price-tag family, driven by the Tidal
41
+ terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit` carry no code changes —
42
+ republished in lock-step (peer ranges widen to `^0.38.0`).
43
+
44
+ ### Added
45
+
46
+ - `@pond-ts/charts`: **`<YAxisIndicator>` + `createLiveValue`** — a value pill
47
+ pinned to a y-axis edge, decoupled from the series' last point. A `LiveValue`
48
+ `source` updates it at high frequency **without re-rendering the chart** (only
49
+ the subscribed pill repaints). Props: `value` / `source`, `axis`, `side`,
50
+ `color`, `format`, `line` (dashed guide), `pointer` (callout triangle).
51
+ - `@pond-ts/charts`: **`cursor="crosshair"`** `CursorMode` — a synced vertical
52
+ line + per-series dots, each series' value pinned to its y-axis and the hovered
53
+ time pinned to the x-axis.
54
+ - `@pond-ts/charts`: **`indicator`** opt-in on `<Baseline>` (a y-axis value pill)
55
+ and `<Marker>` (an x-axis time pill, with a connector down to the mark).
56
+ - `@pond-ts/charts`: `<Baseline labelSide>` (`left` / `right`) + `labelPosition`
57
+ (`center` on the line / `above` it) for the near-line label chip.
58
+ - `@pond-ts/charts`: `<Region edges>` (default `true`; `false` = shaded fill with
59
+ no side outlines).
60
+ - `@pond-ts/charts`: `axisPillStyle`, `contrastText`, `pointerStyle` chip helpers
61
+ are exported.
62
+
63
+ ### Changed
64
+
65
+ - `@pond-ts/charts`: axis indicator pills are **solid** (colour fill +
66
+ auto-contrast text), aligned to the tick-label row, and **always show the axis
67
+ coordinate** — never a custom label (a label stays the in-plot chip).
68
+ - `@pond-ts/charts`: cursor flag / inline chips now have **square corners**; the
69
+ cursor **time** atop a flag stack renders as plain text (no chip background).
70
+ - `@pond-ts/charts`: Storybook reorganized into a feature-axis reference tree with
71
+ systematic per-prop coverage (dev-only; stories are excluded from the package).
72
+
73
+ ### Fixed
74
+
75
+ - `@pond-ts/charts`: the crosshair x-axis pill used the container's time formatter
76
+ (showing a raw number on a value axis) — it now uses the axis's own resolved
77
+ formatter, matching the ticks. The crosshair also no longer double-renders the
78
+ time (a stray per-row chip alongside the x-axis pill).
79
+
80
+ ## [0.37.0] — 2026-07-02
81
+
82
+ A `@pond-ts/charts` release: the axis wave — label, tick, and domain controls
83
+ driven by the Tidal terminal's friction. `pond-ts`, `@pond-ts/react`, and
84
+ `@pond-ts/fit` carry no code changes — republished in lock-step (peer ranges
85
+ widen to `^0.37.0`).
86
+
87
+ ### Added
88
+
89
+ - **Charts — axis title typography (`theme.axis.title`).** The rotated y-axis
90
+ title now renders a touch larger than the ticks by default and is fully
91
+ themeable (`{ color, size, opacity }`, shared with the x-axis label). (#318)
92
+ - **Charts — `YAxis labelPlacement`.** `'rotated'` (default) or `'top'` — a
93
+ horizontal title above the axis, aligned to the axis line, in a reserved
94
+ header band that clears the top tick. (#318, #320)
95
+ - **Charts — `XAxis align`.** `'center'` (**new default**), `'auto'` (previous
96
+ behaviour: centred but first/last end-anchored), or `'right'` (label beside an
97
+ extended tick). (#318)
98
+ - **Charts — `YAxis pad`.** Fractional headroom added to each side of the
99
+ resolved domain (`0` default) — lifts a tight domain off the plot edges
100
+ without hand-computing bounds. (#319)
101
+ - **Charts — `YAxis boundaryLabels`.** `false` drops the top & bottom tick
102
+ numbers (gridlines stay) for stacked layouts where the edge labels crowd. (#319)
103
+ - **Charts — new `Charts/Axes` Storybook gallery** covering the above. (#318)
104
+
105
+ ### Changed
106
+
107
+ - **Charts — domain-extreme y-tick labels now clamp inside the row** instead of
108
+ half-overflowing the top/bottom edge (resolves Tidal friction F-charts-6). (#319)
109
+ - **Charts — `XAxis` tick-label default is now `'center'`** (was the
110
+ end-anchored `'auto'`). Pass `align="auto"` for the old behaviour. (#318)
111
+
35
112
  ## [0.36.0] — 2026-07-02
36
113
 
37
114
  A `@pond-ts/charts` release: a CSS-custom-property → theme bridge so a canvas
@@ -77,7 +154,7 @@ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.35.0`).
77
154
  (`[6, 4]` dashed, `[2, 3]` ≈ dotted; omit or `[]` = solid) applied to the
78
155
  series stroke. Lets a theme set a **modeled / forecast** line (e.g. GARCH
79
156
  vol) apart from an observed one at a glance. Distinct from a `GapMode`'s
80
- inferred gap-bridge dashing (which marks *missing data*, not the whole
157
+ inferred gap-bridge dashing (which marks _missing data_, not the whole
81
158
  line). Additive: existing themes are unaffected; a solid line never touches
82
159
  `setLineDash`. New `Charts/LineChart → LineStyles` story. (#313)
83
160
 
@@ -116,7 +193,7 @@ Tidal wire-format spike. `@pond-ts/react`, `@pond-ts/charts`, and
116
193
  - **`TimeSeries.fromColumns`** — the columnar (struct-of-arrays) ingress,
117
194
  the counterpart to `fromJSON`'s row-tuple shape. Accepts either a plain
118
195
  `number[]` or a `Float64Array` per column — one polymorphic door, so a
119
- wire format only changes the *decoder*, not the ingest. `Float64Array`
196
+ wire format only changes the _decoder_, not the ingest. `Float64Array`
120
197
  columns are adopted directly (zero-copy); `number[]` columns are copied.
121
198
  A `null`/`undefined` cell or a non-finite value (`NaN`/`Infinity`) is a
122
199
  gap, identically across both input shapes. Enforces the same
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(() => {
@@ -164,6 +179,14 @@ export function ChartRow({ height, cursor, children }) {
164
179
  }
165
180
  return map;
166
181
  }, [effectiveAxes]);
182
+ // Which side each axis sits on — so an axis-edge overlay (the crosshair's
183
+ // value pills) hugs the correct gutter without re-deriving from the specs.
184
+ const axisSides = useMemo(() => {
185
+ const map = new Map();
186
+ for (const ax of effectiveAxes)
187
+ map.set(ax.id, ax.side);
188
+ return map;
189
+ }, [effectiveAxes]);
167
190
  const frame = useMemo(() => ({
168
191
  height,
169
192
  cursor,
@@ -172,6 +195,7 @@ export function ChartRow({ height, cursor, children }) {
172
195
  yScales,
173
196
  formats,
174
197
  tickValues,
198
+ axisSides,
175
199
  defaultAxisId,
176
200
  axisSlots,
177
201
  registerAxis,
@@ -187,6 +211,7 @@ export function ChartRow({ height, cursor, children }) {
187
211
  yScales,
188
212
  formats,
189
213
  tickValues,
214
+ axisSides,
190
215
  defaultAxisId,
191
216
  axisSlots,
192
217
  registerAxis,
package/dist/Layers.js CHANGED
@@ -5,7 +5,7 @@ import { drawGrid } from './grid.js';
5
5
  import { cursorParts } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange } from './viewport.js';
8
- import { flagChipStyle, flagChipX } from './chip.js';
8
+ import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
10
  /** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
11
11
  const GRID_TICKS = 5;
@@ -49,7 +49,7 @@ export function Layers({ children }) {
49
49
  }), [row.registerLayer, row.unregisterLayer]);
50
50
  const background = container.theme.background;
51
51
  const { grid: gridColor, gridDash } = container.theme.axis;
52
- const { layers, yScales, formats, defaultAxisId, tickValues } = row;
52
+ const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
53
53
  // x geometry is shared and lives on the container (uniform across rows).
54
54
  const { xScale, plotWidth } = container;
55
55
  const draw = useCallback((ctx, w, h) => {
@@ -132,6 +132,8 @@ export function Layers({ children }) {
132
132
  // The chip uses this layer's axis formatter, so a readout value reads
133
133
  // exactly as the axis labels it.
134
134
  const fmt = formats.get(axisId) ?? String;
135
+ // Which gutter the crosshair value pill hugs (the axis's own side).
136
+ const side = axisSides.get(axisId) ?? 'left';
135
137
  for (const s of entry.layer.sampleAt(cursorTime)) {
136
138
  out.push({
137
139
  px: xScale(s.x),
@@ -139,6 +141,7 @@ export function Layers({ children }) {
139
141
  value: s.value,
140
142
  color: s.color,
141
143
  format: fmt,
144
+ side,
142
145
  });
143
146
  }
144
147
  }
@@ -148,6 +151,7 @@ export function Layers({ children }) {
148
151
  layers,
149
152
  yScales,
150
153
  formats,
154
+ axisSides,
151
155
  xScale,
152
156
  defaultAxisId,
153
157
  parts.dots,
@@ -403,9 +407,13 @@ export function Layers({ children }) {
403
407
  // The time is shared across rows (one cursor, one time), so it shows **once**,
404
408
  // atop the first row — not repeated per row. (Gating it here also drops the
405
409
  // top-of-stack space reservation on the other rows, see `flagBase`.)
410
+ // Crosshair (`chip: 'axis'`) is excluded: it pins the time to the shared x-axis
411
+ // pill (`<XAxis>`), so a per-row chip here would double it (and land wrong on a
412
+ // stacked row).
406
413
  const showTime = showCursorTime &&
407
414
  cursorTime !== null &&
408
415
  (parts.line || parts.dots) &&
416
+ parts.chip !== 'axis' &&
409
417
  row.isFirstRow;
410
418
  // Flag geometry: each value flies as a flag from the top of its own staff — the
411
419
  // chip's top sits at `flagBase` (just below the time chip when shown) and the
@@ -491,9 +499,12 @@ export function Layers({ children }) {
491
499
  cursorX >= 0 &&
492
500
  cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
493
501
  trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
494
- trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.dots &&
502
+ trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.chip === 'axis' &&
503
+ trackerSamples.map((s, i) => (_jsx("line", { x1: s.side === 'right' ? Math.round(s.px) : 0, y1: Math.round(s.py), x2: s.side === 'right' ? plotWidth : Math.round(s.px), y2: Math.round(s.py), stroke: s.color, strokeWidth: 1, opacity: 0.4, strokeDasharray: "3 3", shapeRendering: "crispEdges" }, `hconn-${i}`))), parts.dots &&
495
504
  trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
496
505
  ...chipStyle,
506
+ background: 'transparent',
507
+ padding: 0,
497
508
  top: `${flagTop}px`,
498
509
  left: timeX > plotWidth * LABEL_FLIP_FRACTION
499
510
  ? undefined
@@ -518,6 +529,15 @@ export function Layers({ children }) {
518
529
  right: flip ? `${plotWidth - s.px + 8}px` : undefined,
519
530
  color: s.color,
520
531
  }, children: s.format(s.value) }, i));
532
+ }), parts.chip === 'axis' &&
533
+ trackerSamples.map((s, i) => {
534
+ const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
535
+ return (_jsx("div", { style: {
536
+ ...axisPillStyle(container.theme, s.color),
537
+ top: `${top}px`,
538
+ transform: 'translateY(-50%)',
539
+ ...axisPillX(s.side, plotWidth),
540
+ }, children: s.format(s.value) }, `ytag-${i}`));
521
541
  }), parts.chip === 'flag' &&
522
542
  cursorX !== null &&
523
543
  trackerSamples.map((s, i) => (
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
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useContext } from 'react';
3
3
  import { ContainerContext } from './context.js';
4
+ import { axisPillStyle } from './chip.js';
4
5
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
5
6
  /** Tick strip height (mark + value label) in CSS px. */
6
7
  const TICK_STRIP = 22;
@@ -17,12 +18,23 @@ const TICK_COUNT = 5;
17
18
  *
18
19
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
19
20
  */
20
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, } = {}) {
21
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, align = 'center', } = {}) {
21
22
  const container = useContext(ContainerContext);
22
23
  if (container === null) {
23
24
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
24
25
  }
25
26
  const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
27
+ // The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
28
+ // cursor is live in-bounds, pin the hovered time to this axis (covering the
29
+ // tick behind it), matching the on-axis y value pills the rows draw. Gated on
30
+ // the container default, so a per-row `cursor` override doesn't reach here.
31
+ const cursorX = container.cursorX;
32
+ const showCursorTag = container.cursor === 'crosshair' &&
33
+ cursorX !== null &&
34
+ cursorX >= 0 &&
35
+ cursorX <= plotWidth;
36
+ const cursorColor = theme.cursor ?? theme.axis.label;
37
+ const annotationColor = theme.annotation?.color ?? '#0d9488';
26
38
  // Tick formatter: an explicit `format` is resolved against the axis kind
27
39
  // (a time specifier through the time scale, a number specifier through the
28
40
  // value scale); otherwise the container's shared formatter — the one the
@@ -32,6 +44,18 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
32
44
  : xKind === 'time'
33
45
  ? resolveTimeFormat(xScale, TICK_COUNT, format)
34
46
  : resolveAxisFormat(xScale, TICK_COUNT, format);
47
+ // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
48
+ // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
49
+ // colour, reading like a tick. An indicator always shows the axis coordinate
50
+ // (the formatted `at`), never the marker's custom label (that stays the in-plot
51
+ // chip). Skipped when off-plot.
52
+ const markerTags = container.annotations
53
+ .filter((a) => a.indicator && a.kind === 'marker' && a.xs[0] !== undefined)
54
+ .map((a) => {
55
+ const at = a.xs[0];
56
+ return { id: a.id ?? `marker-at-${at}`, x: xScale(at), text: fmt(at) };
57
+ })
58
+ .filter((t) => t.x >= 0 && t.x <= plotWidth);
35
59
  const placed = customTicks
36
60
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
37
61
  : xScale.ticks(TICK_COUNT).map((d) => ({
@@ -40,6 +64,9 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
40
64
  }));
41
65
  const stripHeight = height ?? TICK_STRIP + (label ? LABEL_STRIP : 0);
42
66
  const onTop = side === 'top';
67
+ // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
68
+ // they line up with their tick-label neighbours (matches `labelOffset` below).
69
+ const pillOffset = align === 'right' ? 2 : 6;
43
70
  return (_jsxs("div", { style: {
44
71
  position: 'relative',
45
72
  marginLeft: `${leftGutter}px`,
@@ -51,23 +78,33 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
51
78
  fontSize: `${theme.font.size}px`,
52
79
  color: theme.axis.label,
53
80
  }, children: [placed.map((t, i) => {
54
- // End-align the edge labels so they stay within [0, plotWidth].
55
- const labelTransform = i === 0
81
+ const isFirst = i === 0;
82
+ const isLast = i === placed.length - 1;
83
+ // `center`: every label centred on its tick. `auto`: centred, but the
84
+ // edge labels end-align so they stay within [0, plotWidth]. `right`:
85
+ // label left-anchored just past an extended tick (beside, not under).
86
+ const labelTransform = align === 'right'
56
87
  ? 'none'
57
- : i === placed.length - 1
58
- ? 'translateX(-100%)'
59
- : 'translateX(-50%)';
88
+ : align === 'auto' && isFirst
89
+ ? 'none'
90
+ : align === 'auto' && isLast
91
+ ? 'translateX(-100%)'
92
+ : 'translateX(-50%)';
93
+ // `right` drops a longer tick alongside the label; others keep the 4px stub.
94
+ const tickHeight = align === 'right' ? theme.font.size + 4 : 4;
95
+ const labelLeft = align === 'right' ? t.x + 4 : t.x;
96
+ const labelOffset = align === 'right' ? 2 : 6;
60
97
  return (_jsxs(Fragment, { children: [_jsx("div", { style: {
61
98
  position: 'absolute',
62
99
  left: `${t.x}px`,
63
100
  [onTop ? 'bottom' : 'top']: 0,
64
101
  width: '1px',
65
- height: '4px',
102
+ height: `${tickHeight}px`,
66
103
  background: theme.axis.grid,
67
104
  } }), _jsx("div", { style: {
68
105
  position: 'absolute',
69
- left: `${t.x}px`,
70
- [onTop ? 'bottom' : 'top']: '6px',
106
+ left: `${labelLeft}px`,
107
+ [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
71
108
  transform: labelTransform,
72
109
  whiteSpace: 'nowrap',
73
110
  }, children: t.label })] }, `${t.x}-${i}`));
@@ -77,8 +114,31 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
77
114
  width: '100%',
78
115
  textAlign: 'center',
79
116
  [onTop ? 'top' : 'bottom']: 0,
80
- opacity: 0.7,
117
+ // Themeable axis-title text (shared with the rotated y-axis title).
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,
81
121
  whiteSpace: 'nowrap',
82
- }, children: label }))] }));
122
+ }, children: label })), markerTags.map((t) => (_jsxs(Fragment, { children: [_jsx("div", { style: {
123
+ position: 'absolute',
124
+ left: `${t.x}px`,
125
+ [onTop ? 'bottom' : 'top']: 0,
126
+ width: '1px',
127
+ height: `${pillOffset}px`,
128
+ background: annotationColor,
129
+ zIndex: 2,
130
+ } }), _jsx("div", { style: {
131
+ ...axisPillStyle(theme, annotationColor),
132
+ left: `${t.x}px`,
133
+ transform: 'translateX(-50%)',
134
+ [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
135
+ zIndex: 2,
136
+ }, children: t.text })] }, t.id))), showCursorTag && (_jsx("div", { style: {
137
+ ...axisPillStyle(theme, cursorColor),
138
+ left: `${cursorX}px`,
139
+ transform: 'translateX(-50%)',
140
+ [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
141
+ zIndex: 3,
142
+ }, children: fmt(+xScale.invert(cursorX)) }))] }));
83
143
  }
84
144
  //# sourceMappingURL=XAxis.js.map
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
@@ -51,9 +51,16 @@ export interface MarkerProps {
51
51
  /** Make the marker **editable** (in edit mode): dragging its line reports the
52
52
  * new `at` (controlled — wire it back to `at`). The whole line moves. */
53
53
  onChange?: (at: number) => void;
54
+ /** Also pin this marker's **time** to the **x-axis** as an on-axis pill (drawn
55
+ * by `<XAxis>` at `at`, in the annotation colour) — the axis-edge counterpart
56
+ * of the near-line chip. Default `false`. The pill always shows the formatted
57
+ * `at` (the axis coordinate), never the custom `label` (which stays the
58
+ * near-line chip) — an indicator reads like a tick. A connector links the
59
+ * marker line to its pill. */
60
+ indicator?: boolean;
54
61
  }
55
62
  /** A vertical line at an x position (a time, a distance, a lap boundary). */
56
- export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
63
+ export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
57
64
  export interface BaselineProps {
58
65
  /** y value in the linked axis's units. */
59
66
  value: number;
@@ -62,6 +69,11 @@ export interface BaselineProps {
62
69
  /** Chip label. Omit to format `value` with that axis's formatter; pass `false`
63
70
  * (or `''`) to render **no label chip**. */
64
71
  label?: string | false;
72
+ /** Which side of the chart the near-line label chip sits. **Default `left`.** */
73
+ labelSide?: 'left' | 'right';
74
+ /** Where the label chip sits relative to the line: **`center`** (default) rides
75
+ * on the line, vertically centred; `above` sits just on top of it. */
76
+ labelPosition?: 'center' | 'above';
65
77
  /** Stable consumer id — a click reports it via `onSelectAnnotation`. */
66
78
  id?: string;
67
79
  /** Controlled selection — brightens to the front (level 1). Handles are an
@@ -82,10 +94,16 @@ export interface BaselineProps {
82
94
  /** Make the baseline **editable** (in edit mode): dragging it vertically reports
83
95
  * the new `value` (controlled — wire it back to `value`). */
84
96
  onChange?: (value: number) => void;
97
+ /** Also pin this baseline's **value** to its **y-axis** as an on-axis pill (in
98
+ * the annotation colour) — the axis-edge counterpart of the near-line chip.
99
+ * Default `false`. The pill always shows the formatted `value` (the axis
100
+ * coordinate), never the custom `label` (which stays the near-line chip) — an
101
+ * indicator reads like a tick. */
102
+ indicator?: boolean;
85
103
  }
86
104
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
87
105
  * Its label anchors at the left, at the line's height. */
88
- export declare function Baseline({ value, axis, label, id, selected, selectable, hovered, editing, onChange, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
106
+ export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
89
107
  export interface RegionProps {
90
108
  /** Start x in axis units (time or value). */
91
109
  from: number;
@@ -122,8 +140,12 @@ export interface RegionProps {
122
140
  from: number;
123
141
  to: number;
124
142
  }) => void;
143
+ /** Draw the vertical **side outlines** at `from`/`to`. **Default `true`.**
144
+ * `false` shades the span with no edge lines (fill only) — a soft highlight
145
+ * band. Edit-mode resizing still works (the grab areas are invisible). */
146
+ edges?: boolean;
125
147
  }
126
148
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
127
149
  * flies as a flag off the left edge. */
128
- export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, }: RegionProps): import("react/jsx-runtime").JSX.Element;
150
+ export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
129
151
  //# sourceMappingURL=annotations.d.ts.map