@pond-ts/charts 0.72.0 → 0.74.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/dist/BandChart.js CHANGED
@@ -6,6 +6,7 @@ import { resolveCurve } from './curve.js';
6
6
  import { ContainerContext, LayersContext } from './context.js';
7
7
  import { legendLabelFor, useLegendItems, } from './swatch.js';
8
8
  import { useSlotKey } from './use-slot-key.js';
9
+ import { useXOffset } from './x-offset.js';
9
10
  /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
10
11
  * constant array and the layer entry isn't rebuilt every render. */
11
12
  const NO_BREAKS = [];
@@ -26,7 +27,7 @@ const NO_BREAKS = [];
26
27
  * </Layers>
27
28
  * ```
28
29
  */
29
- export function BandChart({ series, lower, upper, as: semantic, axis, curve, sessionBreaks = false, decimate = true, legend, index = 0, }) {
30
+ export function BandChart({ series: source, lower, upper, as: semantic, axis, curve, sessionBreaks = false, decimate = true, legend, xOffset, index = 0, }) {
30
31
  const container = useContext(ContainerContext);
31
32
  if (container === null) {
32
33
  throw new Error('<BandChart> must be rendered inside a <ChartContainer>');
@@ -35,6 +36,9 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, ses
35
36
  if (layers === null) {
36
37
  throw new Error('<BandChart> must be rendered inside a <Layers>');
37
38
  }
39
+ // The series as drawn: moved by `xOffset` (the source itself when there is
40
+ // none), so every read below shares one axis.
41
+ const series = useXOffset(source, xOffset, 'BandChart');
38
42
  const bs = useMemo(() => series instanceof ValueSeries
39
43
  ? bandFromValueSeries(series, lower, upper)
40
44
  : bandFromTimeSeries(series, lower, upper), [series, lower, upper]);
package/dist/ChartRow.js CHANGED
@@ -365,7 +365,7 @@ export function ChartRow({ height: heightProp, flex, children, }) {
365
365
  const extents = needsExtents(ax)
366
366
  ? layerList
367
367
  .filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
368
- .map((entry) => entry.layer.yExtent(ax.scale))
368
+ .map((entry) => entry.layer.yExtent())
369
369
  : [];
370
370
  const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad, ax.scale);
371
371
  // Reserve a header band at the top when any axis draws a `'top'` title,
@@ -470,7 +470,7 @@ export function ChartRow({ height: heightProp, flex, children, }) {
470
470
  // about.
471
471
  const message = logAxisWarning(ax, layerList
472
472
  .filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
473
- .map((entry) => entry.layer.yExtent(ax.scale)));
473
+ .map((entry) => entry.layer.yExtent()));
474
474
  if (message === null)
475
475
  warned.delete(ax.id);
476
476
  else if (warned.get(ax.id) !== message) {
@@ -4,6 +4,7 @@ import type { NumericColumn, ValueNumericColumn } from './column-names.js';
4
4
  import type { DecimateOption } from './decimate.js';
5
5
  import { type Curve } from './curve.js';
6
6
  import { type GapMode } from './gaps.js';
7
+ import { type XOffset } from './x-offset.js';
7
8
  export interface LineChartCommon<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
8
9
  /**
9
10
  * The series' semantic identifier — what the data _is_ / how it should read
@@ -68,6 +69,29 @@ export interface LineChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
68
69
  * no-op on a continuous axis (no provider) or a provider without `boundaries`.
69
70
  */
70
71
  sessionBreaks?: boolean;
72
+ /**
73
+ * Draw each row this long **after its key** — milliseconds or a duration
74
+ * string (`'1m'`, `'-30s'`). **Omitted ⇒ `0`**, drawn at the key. Time
75
+ * series only (throws on a `ValueSeries`, which has no time to move).
76
+ *
77
+ * For a value stamped at one instant that belongs at another: a 1-minute
78
+ * bar is keyed at its open, but its `close` is the price at the bar's
79
+ * **end**, so `xOffset="1m"` puts the close line where it happened — and the
80
+ * session's last close lands on the close, not a bar early. The series you
81
+ * pass is left as it is, so a `<Candlestick>` drawn from it still draws at
82
+ * the key; the layer moves only its own copy of the key (one pass, value
83
+ * columns shared).
84
+ *
85
+ * On a trading axis, an offset larger than a bar can carry a row out of its
86
+ * session into closed time — e.g. a negative offset on bars keyed at their
87
+ * open moves each session's first bar into the previous night. Keep it within
88
+ * the bar the value belongs to.
89
+ *
90
+ * Everything this layer reports is in **drawn** time: its auto-fit extent,
91
+ * the tracker dot, `sessionBreaks`, a sweep's span. To move the data itself,
92
+ * use pond's `series.offsetTime(by)`.
93
+ */
94
+ xOffset?: XOffset;
71
95
  /**
72
96
  * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
73
97
  * once the visible data is denser than ~2 samples per device pixel, the line
@@ -124,6 +148,6 @@ export type LineChartProps<S extends SeriesSchema = SeriesSchema, VS extends Val
124
148
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
125
149
  * it. The line breaks at gaps rather than spanning them.
126
150
  */
127
- export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, id, curve, gaps, sessionBreaks, decimate, legend, index, }: LineChartProps<S, VS>): null;
151
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series: source, column, readout, as: semantic, axis, id, curve, gaps, sessionBreaks, decimate, legend, xOffset, index, }: LineChartProps<S, VS>): null;
128
152
  export {};
129
153
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -8,6 +8,7 @@ import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
8
8
  import { ContainerContext, LayersContext, } from './context.js';
9
9
  import { legendLabelFor, useLegendItems, } from './swatch.js';
10
10
  import { useSlotKey } from './use-slot-key.js';
11
+ import { useXOffset } from './x-offset.js';
11
12
  /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
12
13
  * constant array and the layer entry isn't rebuilt every render. */
13
14
  const NO_BREAKS = [];
@@ -17,7 +18,7 @@ const NO_BREAKS = [];
17
18
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
18
19
  * it. The line breaks at gaps rather than spanning them.
19
20
  */
20
- export function LineChart({ series, column, readout, as: semantic, axis, id, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
21
+ export function LineChart({ series: source, column, readout, as: semantic, axis, id, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, xOffset, index = 0, }) {
21
22
  const container = useContext(ContainerContext);
22
23
  if (container === null) {
23
24
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -26,6 +27,9 @@ export function LineChart({ series, column, readout, as: semantic, axis, id, cur
26
27
  if (layers === null) {
27
28
  throw new Error('<LineChart> must be rendered inside a <Layers>');
28
29
  }
30
+ // The series as drawn: moved by `xOffset` (the source itself when there is
31
+ // none), so every read below shares one axis.
32
+ const series = useXOffset(source, xOffset, 'LineChart');
29
33
  const cs = useMemo(() => series instanceof ValueSeries
30
34
  ? fromValueSeries(series, column)
31
35
  : fromTimeSeries(series, column), [series, column]);
package/dist/XAxis.d.ts CHANGED
@@ -10,6 +10,18 @@ interface PlacedTick {
10
10
  * renders emphasized (bold): a band turn in stacked, an inline promotion in
11
11
  * flat — the same boundaries in both styles. */
12
12
  readonly bold?: boolean;
13
+ /** A session open on a trading axis — live time starts here after a
14
+ * collapsed gap. Outranks a plain clock tick when labels collide. */
15
+ readonly seam?: boolean;
16
+ /** An instant inside a collapsed gap (closed-market time an elapsed axis's
17
+ * wall-clock steps can land on): it sits on the seam pixel but names a
18
+ * moment the axis doesn't draw, so any live tick outranks it. */
19
+ readonly dead?: boolean;
20
+ /** Under `align="auto"`, an automatic time tick that is the first
21
+ * (`'start'`) or last (`'end'`) of the axis's **full** tick list: its label
22
+ * anchors inward. Fixed per tick rather than per drawn set, so dropping a
23
+ * colliding neighbour never re-anchors another label into a new collision. */
24
+ readonly anchor?: 'start' | 'end' | undefined;
13
25
  }
14
26
  /**
15
27
  * Thin + truncate a **category** axis's labels so a dense axis stays legible
@@ -25,6 +37,53 @@ interface PlacedTick {
25
37
  * Exported for tests only — not re-exported from the package index.
26
38
  */
27
39
  export declare function thinCategoryLabels(ticks: readonly PlacedTick[], slot: number, plotWidth: number, fontSize: number, fontFamily: string): PlacedTick[];
40
+ /**
41
+ * Which **time** ticks keep their labels: the indices of `ticks` whose drawn
42
+ * extents (per `align`) clear one another by {@link LABEL_GAP}. The tick ladder
43
+ * spaces its clock steps by the grain, but it places every session open
44
+ * unconditionally, so two anchors can land closer than a label is wide — a
45
+ * micro-session (one stray pre-market bar in a calendar derived from bars)
46
+ * puts its open a pixel left of the real open, and a seam can sit just past
47
+ * the last clock tick before it.
48
+ *
49
+ * Greedy in **priority** order — each tick is kept unless it would overlap one
50
+ * already kept, so within a pass a tick is only ever dropped by a survivor:
51
+ *
52
+ * 1. a **period turn** (`bold` — the label carrying the date / month / year,
53
+ * or a stacked band turn) before a **session open** (`seam`), before a plain
54
+ * clock tick, before a tick in **closed time** (`dead`) — a date stays on
55
+ * the turn it marks, and the seam is labelled with live time;
56
+ * 2. between two turns, or two session opens, the **later** (it owns the
57
+ * ticks that follow); between two plain ticks the earlier.
58
+ *
59
+ * A blank label (a custom `format` returning `''` to thin its own labels)
60
+ * draws nothing, so it is always kept and never blocks another.
61
+ *
62
+ * Under `'auto'` an edge label's extent follows its fixed {@link
63
+ * PlacedTick.anchor}, never its position in the drawn set — so no drop moves
64
+ * another label. The caller re-labels the survivors (see {@link fitTimeTicks}):
65
+ * a turn dropped beside a tick of a later, different period hands its period
66
+ * on (a stray New Year's open's `2027` moves onto the next real open). Two
67
+ * turns of the same level too close to both draw can't both be drawn; the
68
+ * later one is.
69
+ *
70
+ * Exported for tests only — not re-exported from the package index.
71
+ */
72
+ export declare function fitTimeLabels(ticks: readonly PlacedTick[], align: 'auto' | 'center' | 'right', fontSize: number, fontFamily: string): number[];
73
+ /**
74
+ * Place a time axis's automatic ticks with no label overprinting another:
75
+ * `place` labels a set of tick instants (flat promotions walk only the
76
+ * instants it is given), {@link fitTimeLabels} keeps the ones that clear,
77
+ * and the survivors are placed again — re-labelling can promote a survivor
78
+ * and change its width — until a pass drops nothing (each pass only removes,
79
+ * so it terminates). A re-label can also *narrow* a survivor (`Jan 2` becomes
80
+ * `2026` once the stub that turned the year is dropped), so each dropped tick
81
+ * is then offered back in time order and re-admitted if the whole set, placed
82
+ * again, still fits.
83
+ *
84
+ * Exported for tests only — not re-exported from the package index.
85
+ */
86
+ export declare function fitTimeTicks(values: readonly number[], place: (values: readonly number[]) => PlacedTick[], align: 'auto' | 'center' | 'right', fontSize: number, fontFamily: string): PlacedTick[];
28
87
  export interface XAxisProps {
29
88
  /**
30
89
  * Tick / cursor value formatting — a d3 format/time specifier string or a
@@ -85,9 +144,17 @@ export interface XAxisProps {
85
144
  * right-anchors so the edge labels stay inside the plot (the old default).
86
145
  * - `'right'` — the label sits to the **right** of an extended tick that
87
146
  * drops from the axis line (label beside the tick, not under it) — a
88
- * *style* choice (the TradingView look). It re-anchors without measuring,
89
- * so it is **not** a remedy for colliding labels; on a category axis the
90
- * measured fit (thin + middle-ellipsize) is what prevents collisions.
147
+ * *style* choice (the TradingView look), **not** a remedy for colliding
148
+ * labels. Collisions are handled by measurement, in every mode, on a
149
+ * category axis (thin + middle-ellipsize) and on a time axis's automatic
150
+ * ticks (the label that would overprint is dropped — a date / month /
151
+ * year turn outlasts a clock label — and the survivors are re-labelled so
152
+ * the dates they show stay correct; a blank label never blocks one).
153
+ * Under `'auto'` the edge labels anchor inward, so a crowded edge costs a
154
+ * label — which one follows the same priority (between two plain clock
155
+ * labels, the inner neighbour at the left edge and the edge label itself
156
+ * at the right). Explicit `ticks`, `transform` ticks and numeric axes are
157
+ * not fitted.
91
158
  */
92
159
  align?: 'auto' | 'center' | 'right';
93
160
  /**
package/dist/XAxis.js CHANGED
@@ -151,6 +151,113 @@ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily)
151
151
  }
152
152
  return out;
153
153
  }
154
+ /**
155
+ * Which **time** ticks keep their labels: the indices of `ticks` whose drawn
156
+ * extents (per `align`) clear one another by {@link LABEL_GAP}. The tick ladder
157
+ * spaces its clock steps by the grain, but it places every session open
158
+ * unconditionally, so two anchors can land closer than a label is wide — a
159
+ * micro-session (one stray pre-market bar in a calendar derived from bars)
160
+ * puts its open a pixel left of the real open, and a seam can sit just past
161
+ * the last clock tick before it.
162
+ *
163
+ * Greedy in **priority** order — each tick is kept unless it would overlap one
164
+ * already kept, so within a pass a tick is only ever dropped by a survivor:
165
+ *
166
+ * 1. a **period turn** (`bold` — the label carrying the date / month / year,
167
+ * or a stacked band turn) before a **session open** (`seam`), before a plain
168
+ * clock tick, before a tick in **closed time** (`dead`) — a date stays on
169
+ * the turn it marks, and the seam is labelled with live time;
170
+ * 2. between two turns, or two session opens, the **later** (it owns the
171
+ * ticks that follow); between two plain ticks the earlier.
172
+ *
173
+ * A blank label (a custom `format` returning `''` to thin its own labels)
174
+ * draws nothing, so it is always kept and never blocks another.
175
+ *
176
+ * Under `'auto'` an edge label's extent follows its fixed {@link
177
+ * PlacedTick.anchor}, never its position in the drawn set — so no drop moves
178
+ * another label. The caller re-labels the survivors (see {@link fitTimeTicks}):
179
+ * a turn dropped beside a tick of a later, different period hands its period
180
+ * on (a stray New Year's open's `2027` moves onto the next real open). Two
181
+ * turns of the same level too close to both draw can't both be drawn; the
182
+ * later one is.
183
+ *
184
+ * Exported for tests only — not re-exported from the package index.
185
+ */
186
+ export function fitTimeLabels(ticks, align, fontSize, fontFamily) {
187
+ const extents = ticks.map((t) => {
188
+ const font = `${t.bold ? '700 ' : ''}${fontSize}px ${fontFamily}`;
189
+ const w = labelWidth(t.label, font, fontSize);
190
+ // Mirrors the render's placement (`labelLeft` + `labelTransform`).
191
+ if (align === 'right')
192
+ return [t.x + 4, t.x + 4 + w];
193
+ if (align === 'auto' && t.anchor === 'start')
194
+ return [t.x, t.x + w];
195
+ if (align === 'auto' && t.anchor === 'end')
196
+ return [t.x - w, t.x];
197
+ return [t.x - w / 2, t.x + w / 2];
198
+ });
199
+ const blank = (i) => ticks[i].label.trim() === '';
200
+ const rank = (t) => (t.dead ? -1 : t.bold ? 2 : t.seam ? 1 : 0);
201
+ const order = ticks
202
+ .map((_, i) => i)
203
+ .sort((i, j) => {
204
+ const ri = rank(ticks[i]);
205
+ const rj = rank(ticks[j]);
206
+ if (ri !== rj)
207
+ return rj - ri;
208
+ return ri > 0 ? j - i : i - j;
209
+ });
210
+ const kept = [];
211
+ for (const i of order) {
212
+ const [l, r] = extents[i];
213
+ const clear = blank(i) ||
214
+ kept.every((j) => {
215
+ const [kl, kr] = extents[j];
216
+ return blank(j) || r + LABEL_GAP <= kl || kr + LABEL_GAP <= l;
217
+ });
218
+ if (clear)
219
+ kept.push(i);
220
+ }
221
+ return kept.sort((i, j) => i - j);
222
+ }
223
+ /**
224
+ * Place a time axis's automatic ticks with no label overprinting another:
225
+ * `place` labels a set of tick instants (flat promotions walk only the
226
+ * instants it is given), {@link fitTimeLabels} keeps the ones that clear,
227
+ * and the survivors are placed again — re-labelling can promote a survivor
228
+ * and change its width — until a pass drops nothing (each pass only removes,
229
+ * so it terminates). A re-label can also *narrow* a survivor (`Jan 2` becomes
230
+ * `2026` once the stub that turned the year is dropped), so each dropped tick
231
+ * is then offered back in time order and re-admitted if the whole set, placed
232
+ * again, still fits.
233
+ *
234
+ * Exported for tests only — not re-exported from the package index.
235
+ */
236
+ export function fitTimeTicks(values, place, align, fontSize, fontFamily) {
237
+ const fits = (placed) => fitTimeLabels(placed, align, fontSize, fontFamily);
238
+ let cur = values;
239
+ let placed = place(cur);
240
+ for (let kept = fits(placed); kept.length < placed.length;) {
241
+ cur = kept.map((i) => cur[i]);
242
+ placed = place(cur);
243
+ kept = fits(placed);
244
+ }
245
+ if (cur.length === values.length)
246
+ return placed;
247
+ const drawn = new Set(cur);
248
+ for (const v of values) {
249
+ if (drawn.has(v))
250
+ continue;
251
+ const trial = [...cur, v].sort((a, b) => a - b);
252
+ const again = place(trial);
253
+ if (fits(again).length === again.length) {
254
+ cur = trial;
255
+ placed = again;
256
+ drawn.add(v);
257
+ }
258
+ }
259
+ return placed;
260
+ }
154
261
  /**
155
262
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
156
263
  * child of {@link ChartContainer}, rendered as DOM chrome (crisp text,
@@ -399,38 +506,79 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
399
506
  }
400
507
  return out;
401
508
  };
402
- const rawTicks = customTicks
403
- ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
404
- : derived !== null
405
- ? honestDerived()
406
- : // `tickValues`, not `xScale.ticks` — d3's raw `scaleLog.ticks()` is
407
- // nearly a step function (see `yticks.ts`), so a log x needs the same
408
- // decade ladder the y axis already builds. For a time or linear scale
409
- // it defers to `scale.ticks(count)`, so this is a no-op there.
410
- tickValues(xScale, xTickCount).map((d) => ({
411
- x: xScale(d),
412
- label: tickFmt(+d),
413
- // A **period turn** renders emphasized (bold), consistently across
414
- // styles: in stacked, a tick on a band divider (matched by pixel);
415
- // in flat, a tick whose label was *promoted* to a coarser period
416
- // (its flat label differs from the terse base) — the same boundaries,
417
- // so `Feb` / `2026` read as strong in flat just as the band turns do.
418
- bold: stacked
419
- ? dividerXs.has(Math.round(xScale(d)))
420
- : flatFmt !== undefined &&
421
- baseFmt !== undefined &&
422
- flatFmt(+d) !== baseFmt(+d),
423
- }));
424
- // A category axis ticks once per category; thin + truncate its labels when they
425
- // crowd (an explicit `customTicks` axis keeps its labels verbatim). The slot is
426
- // the scale's own band pitch — under `maxBandWidth` packing it is narrower than
427
- // `plotWidth / n`, and the fit must measure against the pitch labels actually
428
- // sit on.
429
- const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 0
430
- ? thinCategoryLabels(rawTicks, 'bandwidth' in xScale
509
+ // The automatic tick instants (none when `ticks` or a `transform` lays the
510
+ // axis out). `tickValues`, not `xScale.ticks` — d3's raw `scaleLog.ticks()`
511
+ // is nearly a step function (see `yticks.ts`), so a log x needs the same
512
+ // decade ladder the y axis already builds. For a time or linear scale it
513
+ // defers to `scale.ticks(count)`, so this is a no-op there.
514
+ const autoValues = customTicks !== undefined || derived !== null
515
+ ? []
516
+ : tickValues(xScale, xTickCount).map((d) => +d);
517
+ // Place a set of automatic ticks: x, label, emphasis. The flat style labels
518
+ // only the ticks it is handed, so when the time-axis fit drops one, its
519
+ // period turn is promoted onto the next drawn tick instead of vanishing.
520
+ const disc = xKind === 'time' ? container.discontinuities : undefined;
521
+ const placeAuto = (values) => {
522
+ const flatDrawn = flatFmt !== undefined
523
+ ? xScale.flatFormat(xTickCount, values)
524
+ : undefined;
525
+ const label = flatDrawn ?? tickFmt;
526
+ return values.map((v) => ({
527
+ x: xScale(v),
528
+ // A JS `format` may return null / a number (`AxisFormat` says string);
529
+ // the fit measures the text, so normalise it as the JSX always did.
530
+ label: String(label(v) ?? ''),
531
+ // A **period turn** renders emphasized (bold), consistently across
532
+ // styles: in stacked, a tick on a band divider (matched by pixel);
533
+ // in flat, a tick whose label was *promoted* to a coarser period
534
+ // (its flat label differs from the terse base) — the same boundaries,
535
+ // so `Feb` / `2026` read as strong in flat just as the band turns do.
536
+ bold: stacked
537
+ ? dividerXs.has(Math.round(xScale(v)))
538
+ : flatDrawn !== undefined &&
539
+ baseFmt !== undefined &&
540
+ flatDrawn(v) !== baseFmt(v),
541
+ // Live from here, dead just before: a session open on a collapsed seam
542
+ // (the probe `Layers` uses for session dividers, plus the live side, so
543
+ // an instant inside the gap doesn't qualify).
544
+ seam: disc !== undefined &&
545
+ disc.distance(v - 1, v) <= 0 &&
546
+ disc.distance(v, v + 1) > 0,
547
+ dead: disc !== undefined && disc.distance(v, v + 1) <= 0,
548
+ // `auto` anchors the edge ticks of the FULL list, whichever survive.
549
+ anchor: align !== 'auto'
550
+ ? undefined
551
+ : v === autoValues[0]
552
+ ? 'start'
553
+ : v === autoValues[autoValues.length - 1]
554
+ ? 'end'
555
+ : undefined,
556
+ }));
557
+ };
558
+ // A time axis's automatic ticks drop any that would overprint a neighbour
559
+ // (session opens can crowd — see `fitTimeTicks`), re-labelling the
560
+ // survivors. A category axis ticks once per category; thin + truncate its
561
+ // labels when they crowd. The slot is the scale's own band pitch — under
562
+ // `maxBandWidth` packing it is narrower than `plotWidth / n`, and the fit
563
+ // must measure against the pitch labels actually sit on. Explicit `ticks`
564
+ // stay verbatim; derived ticks have their own honesty pass.
565
+ const timeFitted = xKind === 'time' && customTicks === undefined && derived === null;
566
+ const placed = (() => {
567
+ if (timeFitted) {
568
+ return fitTimeTicks(autoValues, placeAuto, align, theme.font.size, theme.font.family);
569
+ }
570
+ const raw = customTicks
571
+ ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
572
+ : derived !== null
573
+ ? honestDerived()
574
+ : placeAuto(autoValues);
575
+ if (xKind !== 'category' || customTicks !== undefined || raw.length === 0) {
576
+ return raw;
577
+ }
578
+ return thinCategoryLabels(raw, 'bandwidth' in xScale
431
579
  ? xScale.bandwidth()
432
- : plotWidth / rawTicks.length, plotWidth, theme.font.size, theme.font.family)
433
- : rawTicks;
580
+ : plotWidth / raw.length, plotWidth, theme.font.size, theme.font.family);
581
+ })();
434
582
  const onTop = side === 'top';
435
583
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
436
584
  // they line up with their tick-label neighbours (matches `labelOffset` below).
@@ -545,11 +693,15 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
545
693
  fontSize: `${theme.font.size}px`,
546
694
  color: color ?? theme.axis.label,
547
695
  }, children: [placed.map((t, i) => {
548
- const isFirst = i === 0;
549
- const isLast = i === placed.length - 1;
550
696
  // `center`: every label centred on its tick. `auto`: centred, but the
551
- // edge labels end-align so they stay within [0, plotWidth]. `right`:
552
- // label left-anchored just past an extended tick (beside, not under).
697
+ // edge labels end-align so they stay within [0, plotWidth] — on a time
698
+ // axis the edges of the full tick list (`anchor`, which the label fit
699
+ // measured), elsewhere the first / last drawn. `right`: label
700
+ // left-anchored just past an extended tick (beside, not under).
701
+ const isFirst = timeFitted ? t.anchor === 'start' : i === 0;
702
+ const isLast = timeFitted
703
+ ? t.anchor === 'end'
704
+ : i === placed.length - 1;
553
705
  const labelTransform = align === 'right'
554
706
  ? 'none'
555
707
  : align === 'auto' && isFirst
package/dist/area.d.ts CHANGED
@@ -23,18 +23,6 @@ export declare function columnFiniteExtent(y: Float64Array, length: number): rea
23
23
  * `xs`/`ys` are aligned index-for-index.
24
24
  */
25
25
  export declare function fillAffineArea(ctx: CanvasRenderingContext2D, xs: Float64Array, ys: Float64Array, baselinePx: number, ax: Affine, ay: Affine): void;
26
- /**
27
- * The `[min, max]` vertical extent an area occupies — the finite values of
28
- * `cs.y` widened to include `baseline`, since the fill spans from each value to
29
- * the baseline (so the baseline must be in-domain or the fill clips). `null` if
30
- * no value is finite. When `baseline` is `undefined` (`<AreaChart
31
- * baseline="floor">`) the area rests on the axis's own lower bound (resolved
32
- * later), so only the values constrain the
33
- * domain — matching {@link yExtent}.
34
- *
35
- * NaN values (the gap signal) are ignored, so a coast doesn't drag the domain.
36
- */
37
- export declare function areaExtent(cs: ChartSeries, baseline: number | undefined): [number, number] | null;
38
26
  /**
39
27
  * Fill the area between `cs`'s value line and a horizontal `baseline`, with a
40
28
  * vertical gradient (most opaque at the line, fading to transparent at the
@@ -71,7 +59,7 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
71
59
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
72
60
  * are collected by one O(N) walk ({@link collectGapEdges}).
73
61
  */
74
- export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption, banding?: BandLadder): LayerDrawStats;
62
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption, banding?: BandLadder, boundaries?: readonly number[]): LayerDrawStats;
75
63
  /**
76
64
  * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
77
65
  * vertical **hard-stop gradient in pixel space**, a colour switch at every