@pond-ts/charts 0.42.0 → 0.44.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.
@@ -1,8 +1,21 @@
1
- import type { SeriesSchema, TimeSeries } from 'pond-ts';
1
+ import { ValueSeries } from 'pond-ts';
2
+ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
2
3
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
3
- export interface ScatterChartProps<S extends SeriesSchema> {
4
- /** The source series. Its key column supplies the time axis (each point's x). */
5
- series: TimeSeries<S>;
4
+ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
+ /**
6
+ * The source series. A `TimeSeries` scatters against the time axis; a
7
+ * `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
8
+ * for natively value-keyed data — IV marks keyed by strike) against its
9
+ * value axis — the container infers which from the data, no axis-type prop
10
+ * (mirrors `<LineChart>`). Either way the key / axis column supplies each
11
+ * point's x and `column` supplies y.
12
+ *
13
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
14
+ * so passing `series={s.byValue('dist')}` inline re-registers this layer
15
+ * every render — memoize the projection (`useMemo`) on a frequently
16
+ * re-rendering chart.
17
+ */
18
+ series: TimeSeries<S> | ValueSeries<VS>;
6
19
  /** Name of the numeric value column — each point's y. */
7
20
  column: string;
8
21
  /**
@@ -24,6 +37,11 @@ export interface ScatterChartProps<S extends SeriesSchema> {
24
37
  * the key the controlled `selected` echo, dedup, and (later) multi-select all
25
38
  * match on — so a selection survives a data update where a sample `key` goes
26
39
  * stale.
40
+ *
41
+ * A point's identity within the series is its **x** (key / axis value). The
42
+ * key contract allows duplicate x's (equal timestamps; a value-axis plateau
43
+ * from `byValue('cumDist')`) — points sharing an x share identity, so
44
+ * selecting one highlights the last drawn point at that x.
27
45
  */
28
46
  id?: string;
29
47
  /**
@@ -59,6 +77,14 @@ export interface ScatterChartProps<S extends SeriesSchema> {
59
77
  * dense scatter is noise; this is for a handful of called-out marks.
60
78
  */
61
79
  label?: string | boolean;
80
+ /**
81
+ * A **pixel** shift applied to every point's x — zoom-stable. **Default `0`.**
82
+ * For pairing marks that share a key side by side (a call and a put mark at one
83
+ * strike: `offset={-4}` / `offset={+4}`). Pairs with `<BoxPlot offset>`; on the
84
+ * scatter the shift is exact — both the draw and the click hit-test move
85
+ * together, so a nudged point still selects.
86
+ */
87
+ offset?: number;
62
88
  /**
63
89
  * @internal Declaration position among the `<Layers>` children, injected by
64
90
  * `Layers` so z-order follows JSX order. Do not set.
@@ -66,7 +92,8 @@ export interface ScatterChartProps<S extends SeriesSchema> {
66
92
  index?: number;
67
93
  }
68
94
  /**
69
- * A scatter draw layer: one mark per finite point at `(time, column-value)`,
95
+ * A scatter draw layer: one mark per finite point at `(x, column-value)`
96
+ * — x from the series' key / axis column (time or value axis) —
70
97
  * with **data-driven radius + colour** (the signed-off exception — encode from
71
98
  * columns via scales, not a per-event style callback). Reads `column` into a
72
99
  * {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
@@ -92,5 +119,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
92
119
  * </Layers>
93
120
  * ```
94
121
  */
95
- export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
122
+ export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
96
123
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -1,11 +1,13 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { fromTimeSeries } from './data.js';
2
+ import { ValueSeries } from 'pond-ts';
3
+ import { fromTimeSeries, fromValueSeries } from './data.js';
3
4
  import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
4
5
  import { resolveEncoding, } from './encoding.js';
5
6
  import { ContainerContext, LayersContext } from './context.js';
6
7
  import { useSlotKey } from './use-slot-key.js';
7
8
  /**
8
- * A scatter draw layer: one mark per finite point at `(time, column-value)`,
9
+ * A scatter draw layer: one mark per finite point at `(x, column-value)`
10
+ * — x from the series' key / axis column (time or value axis) —
9
11
  * with **data-driven radius + colour** (the signed-off exception — encode from
10
12
  * columns via scales, not a per-event style callback). Reads `column` into a
11
13
  * {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
@@ -31,7 +33,7 @@ import { useSlotKey } from './use-slot-key.js';
31
33
  * </Layers>
32
34
  * ```
33
35
  */
34
- export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
36
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
35
37
  const container = useContext(ContainerContext);
36
38
  if (container === null) {
37
39
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -40,7 +42,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
40
42
  if (layers === null) {
41
43
  throw new Error('<ScatterChart> must be rendered inside a <Layers>');
42
44
  }
43
- const cs = useMemo(() => fromTimeSeries(series, column), [series, column]);
45
+ const cs = useMemo(() => series instanceof ValueSeries
46
+ ? fromValueSeries(series, column)
47
+ : fromTimeSeries(series, column), [series, column]);
44
48
  // Styling: semantic identifier → theme scatter style. The single styling
45
49
  // channel for the base mark.
46
50
  const { scatter } = container.theme;
@@ -53,13 +57,26 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
53
57
  // pulls a named numeric column to a Float64Array (gaps NaN) — the same path
54
58
  // fromTimeSeries uses; an unknown / non-numeric column throws there (eager,
55
59
  // so a typo surfaces at render, not silently as base-styled points).
56
- const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
60
+ const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => series instanceof ValueSeries
61
+ ? fromValueSeries(series, col).y
62
+ : fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
57
63
  // Per-point label accessor: a column name reads that field, `true` reads the
58
64
  // plotted column, anything else (false / omitted) ⇒ no labels.
59
65
  const labelAt = useMemo(() => {
60
66
  if (label === undefined || label === false)
61
67
  return undefined;
62
68
  const field = label === true ? column : label;
69
+ if (series instanceof ValueSeries) {
70
+ // Columnar read — a ValueSeries has no per-row events. The field is a
71
+ // runtime string, cast onto the schema-literal column name (the same
72
+ // pattern as data.ts' readValueColumn). A gap or an unknown column reads
73
+ // undefined => no label at that point.
74
+ const col = series.column(field);
75
+ return (i) => {
76
+ const v = col?.read(i);
77
+ return v === undefined || v === null ? undefined : String(v);
78
+ };
79
+ }
63
80
  return (i) => {
64
81
  // series.at(i) is O(1) per row (columnar eventAt cache), so a label per
65
82
  // point stays cheap. The field is a runtime string → cast off the literal-
@@ -71,26 +88,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
71
88
  return v === undefined || v === null ? undefined : String(v);
72
89
  };
73
90
  }, [label, column, series]);
74
- // The point's stable key is its event begin (epoch ms) the same as cs.x[i],
75
- // which is the key column's begin buffer. Used for selection identity.
91
+ // The point's stable key is its x — the event begin (epoch ms) on a time
92
+ // axis, the axis value on a value axis; either way it's cs.x[i], the key
93
+ // column's begin buffer. Used for selection identity.
76
94
  const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
77
95
  const entry = useMemo(() => ({
78
96
  layer: {
79
97
  yExtent: () => scatterExtent(cs),
80
- xKind: 'time',
98
+ // The container infers the shared x scale's kind + auto-fit domain from
99
+ // its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
100
+ xKind: series instanceof ValueSeries ? 'value' : 'time',
81
101
  xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
82
- sampleAt: (time) => {
102
+ sampleAt: (x) => {
83
103
  // No readout past the data (tracker policy — the dot snaps to a drawn
84
- // mark, never extrapolates past the span); bounds from the time axis.
85
- if (cs.length === 0 ||
86
- time < cs.x[0] ||
87
- time > cs.x[cs.length - 1]) {
104
+ // mark, never extrapolates past the span); bounds from the columnar x
105
+ // axis (epoch ms or axis value — the bisect doesn't care).
106
+ if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
88
107
  return [];
89
108
  }
90
109
  // Nearest *drawn* point by index (skips gaps) — O(log N). Reading by
91
110
  // index gives the value, the snap-to x, and the encoded colour in one
92
111
  // shot, so the readout swatch matches the mark the user sees.
93
- const i = nearestIndex(cs, time);
112
+ const i = nearestIndex(cs, x);
94
113
  if (i < 0)
95
114
  return [];
96
115
  return [
@@ -108,9 +127,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
108
127
  ...(id === undefined
109
128
  ? {}
110
129
  : {
111
- hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
130
+ hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
112
131
  }),
113
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
132
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
114
133
  },
115
134
  axisId: axis,
116
135
  index,
@@ -126,6 +145,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
126
145
  labelAt,
127
146
  font,
128
147
  container.selected,
148
+ offset,
129
149
  axis,
130
150
  index,
131
151
  ]);
package/dist/XAxis.js CHANGED
@@ -8,6 +8,36 @@ const TICK_STRIP = 22;
8
8
  /** Extra height reserved for an axis `label` line. */
9
9
  const LABEL_STRIP = 16;
10
10
  const TICK_COUNT = 5;
11
+ /**
12
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
13
+ * every `stride`-th label (so a kept label has room), and ellipsize one that still
14
+ * overruns its space. `stride` grows with the longest label vs the per-category
15
+ * slot width, so a few short categories keep every full label and many long ones
16
+ * decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
17
+ * for placement; the exact metric is the browser's. Rotation is a later option.
18
+ */
19
+ function thinCategoryLabels(ticks, plotWidth, fontSize) {
20
+ const n = ticks.length;
21
+ const slot = plotWidth / n; // per-category width in px
22
+ // Before first layout `plotWidth` is 0 → `slot` is 0 and the stride/room math
23
+ // below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
24
+ // the ticks through untouched until a real width arrives.
25
+ if (!(slot > 0))
26
+ return [...ticks];
27
+ const charW = fontSize * 0.62; // ~average glyph advance
28
+ const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
29
+ const stride = Math.max(1, Math.ceil((longest * charW) / slot));
30
+ const room = Math.max(1, Math.floor((slot * stride) / charW));
31
+ const out = [];
32
+ for (let i = 0; i < n; i += stride) {
33
+ const s = ticks[i].label;
34
+ out.push({
35
+ x: ticks[i].x,
36
+ label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
37
+ });
38
+ }
39
+ return out;
40
+ }
11
41
  /**
12
42
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
13
43
  * child of {@link ChartContainer}, rendered as DOM chrome (crisp text,
@@ -39,7 +69,11 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
39
69
  // (a time specifier through the time scale, a number specifier through the
40
70
  // value scale); otherwise the container's shared formatter — the one the
41
71
  // cursor readout uses, so a tick and the cursor read identically.
42
- const fmt = format === undefined
72
+ const fmt =
73
+ // A category axis labels by name (the container's `formatTime` = the band
74
+ // scale's label lookup); a d3 number/time `format` can't name a category, so
75
+ // it's ignored here (customize the labels in the `categories` data instead).
76
+ format === undefined || xKind === 'category'
43
77
  ? formatTime
44
78
  : xKind === 'time'
45
79
  ? resolveTimeFormat(xScale, TICK_COUNT, format)
@@ -90,12 +124,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
90
124
  markerLanes.set(t.id, lane);
91
125
  }
92
126
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
93
- const placed = customTicks
127
+ const rawTicks = customTicks
94
128
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
95
129
  : xScale.ticks(TICK_COUNT).map((d) => ({
96
130
  x: xScale(d),
97
131
  label: fmt(+d),
98
132
  }));
133
+ // A category axis ticks once per category; thin + truncate its labels when they
134
+ // crowd (an explicit `customTicks` axis keeps its labels verbatim).
135
+ const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
136
+ ? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
137
+ : rawTicks;
99
138
  const onTop = side === 'top';
100
139
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
101
140
  // they line up with their tick-label neighbours (matches `labelOffset` below).
@@ -1,4 +1,4 @@
1
- import { type AnnotationSpec, type LabelPlacement } from './context.js';
1
+ import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from './context.js';
2
2
  /**
3
3
  * Lane placement for the **top-flag** labels (markers + regions). Returns, per
4
4
  * slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
@@ -16,6 +16,21 @@ import { type AnnotationSpec, type LabelPlacement } from './context.js';
16
16
  * label) so the static labels hold their lanes as it crosses them.
17
17
  */
18
18
  export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
19
+ /**
20
+ * Snap a dragged plot-pixel `px` to the nearest **guideline** within
21
+ * {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
22
+ * boundary** (a session collapse point). Returns the **axis** value to snap to,
23
+ * or `null` if none is near (the caller keeps the raw position). Excludes the
24
+ * dragging mark's own `key`, and reads the same registry the guides draw from,
25
+ * so a drag visibly clicks onto the lines you can see.
26
+ *
27
+ * At a disjoint boundary the close and the next open share a pixel, so the value
28
+ * depends on which side of it the pointer is on (see below). Nearest-pixel wins
29
+ * across both kinds of target, so an annotation sitting *exactly* on a boundary
30
+ * open ties and — processed first — takes it (its own guideline), which is the
31
+ * same instant the right-side heuristic would pick anyway.
32
+ */
33
+ export declare function snapToGuides(container: ContainerFrame, selfKey: symbol, px: number): number | null;
19
34
  /**
20
35
  * Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
21
36
  * the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
@@ -28,6 +43,27 @@ export declare function orderRegion(v: number, pivot: number): {
28
43
  from: number;
29
44
  to: number;
30
45
  };
46
+ /** The slice of a scale a rigid pixel-move needs: value → pixel and back.
47
+ * `invert` may return a `Date` (a d3 `scaleTime`) — the move coerces with `+`. */
48
+ interface InvertibleScale {
49
+ (value: number): number;
50
+ invert(pixel: number): number | Date;
51
+ }
52
+ /**
53
+ * Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
54
+ * the box moves rigidly in *pixel* space — each edge's pixel position shifts by
55
+ * the same `dpx`, then inverts back to an axis value.
56
+ *
57
+ * This is the move that stays correct on a **discontinuous** (trading-time) axis:
58
+ * a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
59
+ * when they sit in different gap-contexts, distorting the box as it crosses a
60
+ * collapsed gap. On a continuous (affine) scale it is identical to the value-delta
61
+ * move, so this is a no-op there.
62
+ */
63
+ export declare function moveRegionByPixels(scale: InvertibleScale, from: number, to: number, dpx: number): {
64
+ from: number;
65
+ to: number;
66
+ };
31
67
  export interface MarkerProps {
32
68
  /** x position in axis units — epoch ms on a time axis, the value on a value
33
69
  * axis. (The generalisation of the mockup's "time line": a mark at an x, time
@@ -157,4 +193,5 @@ export interface RegionProps {
157
193
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
158
194
  * flies as a flag off the left edge. */
159
195
  export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
196
+ export {};
160
197
  //# sourceMappingURL=annotations.d.ts.map
@@ -257,13 +257,20 @@ function useAnnotationHover(container, id, hovered) {
257
257
  /** Pixel radius within which a drag snaps to a guideline (another mark's x). */
258
258
  const SNAP_PX = 6;
259
259
  /**
260
- * Snap a dragged plot-pixel `px` to the nearest **guideline** — another
261
- * annotation's x — within {@link SNAP_PX}. Returns that guideline's **axis** value
262
- * to snap to, or `null` if none is near (the caller keeps the raw position).
263
- * Excludes the dragging mark's own `key`, and reads the same registry the guides
264
- * draw from, so a drag visibly clicks onto the lines you can see.
260
+ * Snap a dragged plot-pixel `px` to the nearest **guideline** within
261
+ * {@link SNAP_PX} another annotation's x, **or** a trading-axis **disjoint
262
+ * boundary** (a session collapse point). Returns the **axis** value to snap to,
263
+ * or `null` if none is near (the caller keeps the raw position). Excludes the
264
+ * dragging mark's own `key`, and reads the same registry the guides draw from,
265
+ * so a drag visibly clicks onto the lines you can see.
266
+ *
267
+ * At a disjoint boundary the close and the next open share a pixel, so the value
268
+ * depends on which side of it the pointer is on (see below). Nearest-pixel wins
269
+ * across both kinds of target, so an annotation sitting *exactly* on a boundary
270
+ * open ties and — processed first — takes it (its own guideline), which is the
271
+ * same instant the right-side heuristic would pick anyway.
265
272
  */
266
- function snapToGuides(container, selfKey, px) {
273
+ export function snapToGuides(container, selfKey, px) {
267
274
  // The container's snap toggle gates guideline snapping — off ⇒ the drag keeps
268
275
  // its raw position (no clicking onto neighbours).
269
276
  if (!container.snap)
@@ -281,6 +288,23 @@ function snapToGuides(container, selfKey, px) {
281
288
  }
282
289
  }
283
290
  }
291
+ // Disjoint boundaries: on a trading-time axis a session close and the next
292
+ // open collapse to the **same pixel**, so a boundary is one snap target with
293
+ // two possible instants. Snap to the one on the side of the boundary the
294
+ // pointer is on — left of it → the pre-gap edge (the previous session's
295
+ // *close*, `clampDown` out of the gap); at/right of it → the post-gap *open*.
296
+ const disc = container.discontinuities;
297
+ if (disc?.boundaries) {
298
+ const [d0, d1] = container.timeRange;
299
+ for (const open of disc.boundaries(d0, d1)) {
300
+ const bpx = container.xScale(open);
301
+ const d = Math.abs(bpx - px);
302
+ if (d < bestDist) {
303
+ bestDist = d;
304
+ best = px < bpx ? disc.clampDown(open - 1) : open;
305
+ }
306
+ }
307
+ }
284
308
  return best;
285
309
  }
286
310
  /**
@@ -294,6 +318,23 @@ function snapToGuides(container, selfKey, px) {
294
318
  export function orderRegion(v, pivot) {
295
319
  return v <= pivot ? { from: v, to: pivot } : { from: pivot, to: v };
296
320
  }
321
+ /**
322
+ * Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
323
+ * the box moves rigidly in *pixel* space — each edge's pixel position shifts by
324
+ * the same `dpx`, then inverts back to an axis value.
325
+ *
326
+ * This is the move that stays correct on a **discontinuous** (trading-time) axis:
327
+ * a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
328
+ * when they sit in different gap-contexts, distorting the box as it crosses a
329
+ * collapsed gap. On a continuous (affine) scale it is identical to the value-delta
330
+ * move, so this is a no-op there.
331
+ */
332
+ export function moveRegionByPixels(scale, from, to, dpx) {
333
+ return {
334
+ from: +scale.invert(scale(from) + dpx),
335
+ to: +scale.invert(scale(to) + dpx),
336
+ };
337
+ }
297
338
  /** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
298
339
  * filled, no outline) with text in the annotation register. */
299
340
  function Chip({ theme, color, style, children, }) {
@@ -550,25 +591,27 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
550
591
  const s = dragRef.current;
551
592
  if (s === null)
552
593
  return;
553
- // Raw position = start + TOTAL pointer delta (snap-independent),
554
- // so dragging past SNAP_PX escapes a snapped edge.
555
- const delta = +container.xScale.invert(px) -
556
- +container.xScale.invert(s.startPx);
557
- let nf = s.from + delta;
558
- let nt = s.to + delta;
559
- // Snap whichever edge lands near a guideline, keeping the width —
560
- // output only, so the raw drift above can pull free of it.
561
- const sf = snapToGuides(container, selfKey, container.xScale(nf));
562
- const st = snapToGuides(container, selfKey, container.xScale(nt));
563
- if (sf !== null) {
564
- nt += sf - nf;
565
- nf = sf;
566
- }
567
- else if (st !== null) {
568
- nf += st - nt;
569
- nt = st;
570
- }
571
- onChange?.({ from: nf, to: nt });
594
+ // Rigid move by the TOTAL pointer *pixel* delta from the press
595
+ // origin each edge shifts the same pixels through the scale, so
596
+ // the box holds its shape even across a collapsed gap (a shared
597
+ // value-delta would drift the edges apart there).
598
+ const moved = moveRegionByPixels(container.xScale, s.from, s.to, px - s.startPx);
599
+ // Snap either edge to a guideline, shifting BOTH by the same pixel
600
+ // correction so the box keeps its width; snap-independent, so a
601
+ // drag past SNAP_PX releases cleanly.
602
+ const fpx = container.xScale(moved.from);
603
+ const tpx = container.xScale(moved.to);
604
+ const sf = snapToGuides(container, selfKey, fpx);
605
+ const st = snapToGuides(container, selfKey, tpx);
606
+ const d = sf !== null
607
+ ? container.xScale(sf) - fpx
608
+ : st !== null
609
+ ? container.xScale(st) - tpx
610
+ : 0;
611
+ onChange?.({
612
+ from: +container.xScale.invert(fpx + d),
613
+ to: +container.xScale.invert(tpx + d),
614
+ });
572
615
  } }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
573
616
  edgeRef.current = to; // the fixed pivot = the far edge
574
617
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A d3-scale-shaped **ordinal band scale** for a categorical x-axis — the
3
+ * transpose view's "columns on x" (categorical-axis RFC, Phase 1). It exposes the
4
+ * slice of the d3 scale surface `@pond-ts/charts` actually uses, so it drops in
5
+ * wherever the container's `xScale` goes (the same trick {@link TradingTimeScale}
6
+ * uses for a discontinuous time axis).
7
+ *
8
+ * **Numeric slot-index domain (the load-bearing choice).** The domain is
9
+ * `[0, n]` — one unit slot per category, slot `i` occupying `[i, i+1]` — *not* a
10
+ * `string[]`. So the pixel **mapping stays linear** and the container's numeric
11
+ * domain / auto-fit / `range` pipeline is untouched; a bar layer draws each
12
+ * category with the ordinary `barSpanPx(i, i+1, …)`. The category-ness lives in
13
+ * three methods only:
14
+ *
15
+ * - {@link ScaleBand.ticks} → the band **centres** (`i + 0.5`), one per category;
16
+ * - {@link ScaleBand.invert} → snaps a pixel to the nearest slot's centre (the
17
+ * categorical crosshair UX, and it keeps the `+xScale.invert` call sites happy);
18
+ * - {@link ScaleBand.label} → the category name at a slot (the axis formatter).
19
+ *
20
+ * The category labels are carried alongside for {@link ScaleBand.label}; the
21
+ * numeric domain is authoritative for geometry.
22
+ */
23
+ export interface ScaleBand {
24
+ /** Slot value (`i` = left edge, `i + 0.5` = centre) → pixel. Linear. */
25
+ (value: number): number;
26
+ /** Pixel → the nearest slot's **centre** value (`i + 0.5`), clamped to a real slot. */
27
+ invert(pixel: number): number;
28
+ /** One tick per category, at its band **centre** (`i + 0.5`). */
29
+ ticks(count?: number): number[];
30
+ /**
31
+ * A formatter mapping a slot value → the category name (the numeric `specifier`
32
+ * is ignored — a category axis labels by name, not a number format). Present so
33
+ * the scale is a safe drop-in wherever the container resolves a `tickFormat`.
34
+ */
35
+ tickFormat(count?: number, specifier?: string): (value: number) => string;
36
+ /** One slot's width in pixels (`|range| / slots`). The bar's `gap` insets within it. */
37
+ bandwidth(): number;
38
+ /** Slot pitch in pixels — same as {@link bandwidth} (padding is the bar's `gap`). */
39
+ step(): number;
40
+ /** The category name at slot value `v` (`categories[floor(v)]`), or `''`. */
41
+ label(value: number): string;
42
+ domain(): [number, number];
43
+ domain(next: readonly [number, number]): ScaleBand;
44
+ range(): [number, number];
45
+ range(next: readonly [number, number]): ScaleBand;
46
+ copy(): ScaleBand;
47
+ }
48
+ /**
49
+ * Build a {@link ScaleBand} over an ordered list of category names. Configure like
50
+ * a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
51
+ * container sets `domain([0, n])` from the layer's slot extent and `range([0,
52
+ * plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
53
+ * so the two must agree on count (`categories.length === n`), which they do when
54
+ * both come from the same layer's `xCategories()` / slot extent.
55
+ */
56
+ export declare function scaleBand(categories: readonly string[]): ScaleBand;
57
+ //# sourceMappingURL=bandScale.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { scaleLinear } from 'd3-scale';
2
+ /**
3
+ * Build a {@link ScaleBand} over an ordered list of category names. Configure like
4
+ * a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
5
+ * container sets `domain([0, n])` from the layer's slot extent and `range([0,
6
+ * plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
7
+ * so the two must agree on count (`categories.length === n`), which they do when
8
+ * both come from the same layer's `xCategories()` / slot extent.
9
+ */
10
+ export function scaleBand(categories) {
11
+ let domain = [0, Math.max(1, categories.length)];
12
+ let range = [0, 1];
13
+ const lin = scaleLinear();
14
+ const sync = () => lin.domain(domain).range(range);
15
+ /** Slot count = the domain width (the container sets `[0, n]`). */
16
+ const slots = () => Math.max(0, Math.round(domain[1] - domain[0]));
17
+ const scale = ((value) => {
18
+ sync();
19
+ return lin(value);
20
+ });
21
+ scale.invert = (pixel) => {
22
+ sync();
23
+ const v = lin.invert(pixel);
24
+ const n = slots();
25
+ if (n === 0)
26
+ return domain[0];
27
+ // Snap to the nearest slot's centre, clamped to a real slot.
28
+ const i = Math.min(n - 1, Math.max(0, Math.floor(v - domain[0])));
29
+ return domain[0] + i + 0.5;
30
+ };
31
+ scale.ticks = () => {
32
+ const n = slots();
33
+ const out = [];
34
+ for (let i = 0; i < n; i += 1)
35
+ out.push(domain[0] + i + 0.5);
36
+ return out;
37
+ };
38
+ scale.bandwidth = () => {
39
+ const n = slots();
40
+ if (n === 0)
41
+ return 0;
42
+ return Math.abs((range[1] - range[0]) / n);
43
+ };
44
+ scale.step = scale.bandwidth;
45
+ scale.label = (value) => {
46
+ const i = Math.floor(value - domain[0]);
47
+ return i >= 0 && i < categories.length ? categories[i] : '';
48
+ };
49
+ scale.tickFormat = () => scale.label;
50
+ function domainFn(next) {
51
+ if (next === undefined)
52
+ return [domain[0], domain[1]];
53
+ domain = [next[0], next[1]];
54
+ return scale;
55
+ }
56
+ scale.domain = domainFn;
57
+ function rangeFn(next) {
58
+ if (next === undefined)
59
+ return [range[0], range[1]];
60
+ range = [next[0], next[1]];
61
+ return scale;
62
+ }
63
+ scale.range = rangeFn;
64
+ scale.copy = () => scaleBand(categories).domain(domain).range(range);
65
+ return scale;
66
+ }
67
+ //# sourceMappingURL=bandScale.js.map
package/dist/bars.d.ts CHANGED
@@ -118,18 +118,33 @@ export interface StackStyle {
118
118
  readonly fills: readonly string[];
119
119
  readonly opacity: number;
120
120
  readonly outlineWidth: number;
121
+ /**
122
+ * Optional **per-bin** fill override, aligned index-for-index to the bins
123
+ * (bin `b` uses `binFills[b]`), taking precedence over the per-group
124
+ * {@link fills} for that whole bin. This is the single-series band case —
125
+ * colour each bar by its category (heart-rate / power zones, value bands) —
126
+ * so it's normally paired with a `G === 1` stack. A `null`/`undefined` entry
127
+ * falls back to the group fill.
128
+ */
129
+ readonly binFills?: readonly (string | undefined)[];
121
130
  }
122
131
  /** The narrowed selection / hover identity a stacked segment matches against:
123
- * the series `id`, the bin's `begin` (its `key`), and the group (its `label`). */
132
+ * the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
133
+ * When the series carries `marks` (the categorical axis), the match keys on the
134
+ * stable `mark` (the column name) instead of the `key` slot index. */
124
135
  export interface StackMark {
125
136
  readonly id: string;
126
137
  readonly key: number;
127
138
  readonly label: string;
139
+ readonly mark?: string;
128
140
  }
129
141
  /**
130
- * The `[min, max]` extent of the **value (stacked) axis** always `[0, maxTotal]`,
131
- * where `maxTotal` is the tallest bin's summed finite non-negative segments. `0` is
132
- * pulled in so the stack rests on a visible baseline (the bar analog of
142
+ * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
143
+ * stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
144
+ * non-negative segments. For a **single-group** series (`G === 1` the plain /
145
+ * categorical bar case) it spans the values' own `[min, max]`, so a **negative**
146
+ * bar's floor is in the domain (segments below the baseline stay visible). `0` is
147
+ * always pulled in so the bars rest on a visible baseline (the bar analog of
133
148
  * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
134
149
  * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
135
150
  * auto-fit for a horizontal one.
@@ -162,8 +177,10 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
162
177
  /**
163
178
  * Fill every segment of every bin in `ss`, stacking each bin's groups from the
164
179
  * value baseline outward (bottom → top vertical, left → right horizontal). A gap
165
- * (non-finite / negative) segment is skipped and adds nothing to the running
166
- * total, so the segments above it close the space. A segment matching the current
180
+ * (non-finite, or a negative segment of a true multi-group stack) is skipped and
181
+ * adds nothing to the running total, so the segments above it close the space; a
182
+ * single-group series draws its negative bars below the baseline (see
183
+ * {@link segmentRect}). A segment matching the current
167
184
  * `selection` (same series `id`, bin `key` **and** group `label`) draws in its
168
185
  * group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
169
186
  * without the outline; all others use the flat `fill`. `globalAlpha` carries the