@pond-ts/charts 0.58.0 → 0.60.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/area.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { ChartSeries } from './data.js';
3
3
  import { type Scale, type TraceState } from './line.js';
4
+ import type { BandLadder } from './bars.js';
4
5
  import type { AreaStyle } from './theme.js';
5
6
  import type { LayerDrawStats } from './context.js';
6
7
  import { type GapMode } from './gaps.js';
@@ -69,7 +70,48 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
69
70
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
70
71
  * are collected by one O(N) walk ({@link collectGapEdges}).
71
72
  */
72
- export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): LayerDrawStats;
73
+ 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;
74
+ /**
75
+ * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
76
+ * vertical **hard-stop gradient in pixel space**, a colour switch at every
77
+ * threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
78
+ * magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
79
+ * as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
80
+ * same currency `drawBars` takes.
81
+ *
82
+ * A gradient rather than one clipped redraw per band, and that is the
83
+ * load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
84
+ * themselves at every boundary with an antialiased seam, where a gradient
85
+ * draws the identical single path once and costs O(K) colour stops. It also
86
+ * bands the **outline for free** — `strokeStyle` takes the same gradient, so
87
+ * the value line switches hue exactly at a crossing, which no per-band clip
88
+ * can do without shearing the stroke.
89
+ *
90
+ * The ladder is walked on the **magnitude** and mirrored below zero, exactly
91
+ * as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
92
+ * zero side) from band `k + 1` (the away side). Whether "away from zero" is up
93
+ * or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
94
+ * positive and finite by construction), so a flipped axis bands correctly. A
95
+ * boundary with **no position** on the scale contributes no crossing — on a
96
+ * log axis the negative mirrors (and zero) simply don't exist, which is the
97
+ * right reading. A crossing **off the plot** clamps to the gradient's ends
98
+ * (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
99
+ * zoomed-in view honest: with every visible pixel inside one band, the clamp
100
+ * degenerates the other stops and the whole plot paints that band's colour.
101
+ *
102
+ * Falls back to the top band's flat colour when there is nothing to anchor on
103
+ * (no plot height, or no boundary with a position at all) — reachable only
104
+ * with a degenerate scale stub, since every real axis positions a positive
105
+ * finite value; any flat colour is equally (in)correct there, and the top
106
+ * band's is at least stable.
107
+ *
108
+ * Like the bar ladder, breakpoints are **absolute data values**, so the
109
+ * baseline plays no part here: an area resting on a non-zero floor still bands
110
+ * at the same heights as its neighbours — measuring from the resolved baseline
111
+ * instead would silently shift every breakpoint by the floor, the quiet
112
+ * wrongness [PND-BANDBAR2] exists to remove.
113
+ */
114
+ export declare function buildBandGradient(ctx: CanvasRenderingContext2D, yScale: Scale, plotHeight: number, banding: BandLadder): CanvasGradient | string;
73
115
  /**
74
116
  * **Is the pointer inside this area?** The filled-region counterpart of
75
117
  * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
package/dist/area.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
- import { baselinePxFromScale, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
2
+ import { baselinePxFromScale, plotExtentOf, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
3
3
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
4
4
  import { cullChartSeries } from './culling.js';
5
5
  import { decimateM4Cached } from './decimate.js';
@@ -155,7 +155,7 @@ export function areaExtent(cs, baseline) {
155
155
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
156
156
  * are collected by one O(N) walk ({@link collectGapEdges}).
157
157
  */
158
- export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true) {
158
+ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true, banding) {
159
159
  const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
160
160
  const baselinePx = yScale(baselineValue);
161
161
  // The fill gradient's vertical extent is computed from the **full** series (a
@@ -168,7 +168,13 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
168
168
  // instead of re-walking O(N) — the mountain@1M ceiling the bench profile
169
169
  // flagged. A `'none'` bridge only fills interior gaps with interpolated values
170
170
  // that stay within the finite extent, so the plain extent is exact for it too.
171
- const fill = buildGradient(ctx, columnFiniteExtent(cs.y, cs.length), yScale, baselinePx, style);
171
+ //
172
+ // **Banded** ([PND-BANDAREA]): one hard-stop pixel-space gradient carries the
173
+ // whole ladder for the fill AND the outline — see {@link buildBandGradient}
174
+ // for why a gradient rather than one clipped redraw per band.
175
+ const fill = banding !== undefined
176
+ ? buildBandGradient(ctx, yScale, plotExtentOf(ctx, xScale, yScale).height, banding)
177
+ : buildGradient(ctx, columnFiniteExtent(cs.y, cs.length), yScale, baselinePx, style);
172
178
  // Clip `cs` to what draws. **Decimated** (linear curve, `decimate !== false`):
173
179
  // cull to the visible slice, then the same {@link decimateM4} pre-pass shrinks
174
180
  // the fill + outline + gap-bridge work to O(plot width) once dense (the §2.2
@@ -237,14 +243,17 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
237
243
  ctx.fill();
238
244
  ctx.restore();
239
245
  // The outline on top: the area's top edge as a line (breaks at the same gaps
240
- // as the fill), at full opacity over the graded fill.
246
+ // as the fill), at full opacity over the graded fill. Banded, it strokes with
247
+ // the same hard-stop gradient the fill used, so the line switches hue exactly
248
+ // where it crosses a threshold — the whole point of the ladder is that the
249
+ // reader sees *where* the value sits, and the edge is the value.
241
250
  ctx.save();
242
251
  ctx.beginPath();
243
252
  if (outline !== null)
244
253
  outline(ys);
245
254
  else
246
255
  strokeAffinePolyline(ctx, cs.x, ys, ax, ay);
247
- ctx.strokeStyle = style.color;
256
+ ctx.strokeStyle = banding !== undefined ? fill : style.color;
248
257
  ctx.lineWidth = style.width;
249
258
  ctx.stroke();
250
259
  ctx.restore();
@@ -345,6 +354,114 @@ function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
345
354
  }
346
355
  return grad;
347
356
  }
357
+ /**
358
+ * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
359
+ * vertical **hard-stop gradient in pixel space**, a colour switch at every
360
+ * threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
361
+ * magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
362
+ * as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
363
+ * same currency `drawBars` takes.
364
+ *
365
+ * A gradient rather than one clipped redraw per band, and that is the
366
+ * load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
367
+ * themselves at every boundary with an antialiased seam, where a gradient
368
+ * draws the identical single path once and costs O(K) colour stops. It also
369
+ * bands the **outline for free** — `strokeStyle` takes the same gradient, so
370
+ * the value line switches hue exactly at a crossing, which no per-band clip
371
+ * can do without shearing the stroke.
372
+ *
373
+ * The ladder is walked on the **magnitude** and mirrored below zero, exactly
374
+ * as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
375
+ * zero side) from band `k + 1` (the away side). Whether "away from zero" is up
376
+ * or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
377
+ * positive and finite by construction), so a flipped axis bands correctly. A
378
+ * boundary with **no position** on the scale contributes no crossing — on a
379
+ * log axis the negative mirrors (and zero) simply don't exist, which is the
380
+ * right reading. A crossing **off the plot** clamps to the gradient's ends
381
+ * (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
382
+ * zoomed-in view honest: with every visible pixel inside one band, the clamp
383
+ * degenerates the other stops and the whole plot paints that band's colour.
384
+ *
385
+ * Falls back to the top band's flat colour when there is nothing to anchor on
386
+ * (no plot height, or no boundary with a position at all) — reachable only
387
+ * with a degenerate scale stub, since every real axis positions a positive
388
+ * finite value; any flat colour is equally (in)correct there, and the top
389
+ * band's is at least stable.
390
+ *
391
+ * Like the bar ladder, breakpoints are **absolute data values**, so the
392
+ * baseline plays no part here: an area resting on a non-zero floor still bands
393
+ * at the same heights as its neighbours — measuring from the resolved baseline
394
+ * instead would silently shift every breakpoint by the floor, the quiet
395
+ * wrongness [PND-BANDBAR2] exists to remove.
396
+ */
397
+ export function buildBandGradient(ctx, yScale, plotHeight, banding) {
398
+ const { thresholds, colors } = banding;
399
+ const fallback = colors[colors.length - 1];
400
+ // Guards NaN too — `!(x > 0)`, not `x <= 0`.
401
+ if (!(plotHeight > 0))
402
+ return fallback;
403
+ // Axis direction: does value increase toward smaller pixels (the canvas
404
+ // norm)? Probed on the ladder's own first breakpoint — positive and finite
405
+ // by construction, so it has a position on every axis kind (linear, log,
406
+ // symlog). Non-finite or equal probes default to the norm.
407
+ const pA = yScale(thresholds[0]);
408
+ const pB = yScale(thresholds[0] + 1);
409
+ const higherValueAtSmallerPx = !(Number.isFinite(pA) &&
410
+ Number.isFinite(pB) &&
411
+ pB > pA);
412
+ const crossings = [];
413
+ for (let k = 0; k < thresholds.length; k += 1) {
414
+ const zeroSide = colors[k];
415
+ const awaySide = colors[k + 1];
416
+ for (const sign of [1, -1]) {
417
+ const v = sign * thresholds[k];
418
+ const px = yScale(v);
419
+ if (!Number.isFinite(px))
420
+ continue; // no position — no crossing
421
+ const awayAbove = sign > 0 === higherValueAtSmallerPx;
422
+ crossings.push(awayAbove
423
+ ? { px, above: awaySide, below: zeroSide, k, sign }
424
+ : { px, above: zeroSide, below: awaySide, k, sign });
425
+ }
426
+ }
427
+ if (crossings.length === 0)
428
+ return fallback;
429
+ // Sort by pixel; same-pixel same-sign crossings (a duplicate breakpoint's
430
+ // empty band, or distinct breakpoints collapsed by an extreme zoom) order
431
+ // **away-band-outermost**: the away side's colour first coming from the
432
+ // away direction, the zero side's first coming from zero. That makes the
433
+ // walk below telescope — the seed reads the true outermost band and the
434
+ // last stop at the pixel is the true inner colour, with the skipped bands
435
+ // as zero-width ghosts in between — instead of seeding one band short and
436
+ // blending across the region below. Bars skip an empty band the same way
437
+ // (`bandSpanInto` clips it to nothing); a same-pixel *opposite-sign* pair
438
+ // (a folded scale) has no defined order and keeps insertion order.
439
+ crossings.sort((a, b) => {
440
+ if (a.px !== b.px)
441
+ return a.px - b.px;
442
+ if (a.sign !== b.sign)
443
+ return 0;
444
+ const awayFirst = a.sign > 0 === higherValueAtSmallerPx;
445
+ return awayFirst ? b.k - a.k : a.k - b.k;
446
+ });
447
+ const grad = ctx.createLinearGradient(0, 0, 0, plotHeight);
448
+ const offsetOf = (px) => {
449
+ const o = px / plotHeight;
450
+ return o < 0 ? 0 : o > 1 ? 1 : o;
451
+ };
452
+ // Each crossing is a hard stop: two stops at one offset, old colour then
453
+ // new. The region above the first crossing seeds the walk; clamped
454
+ // off-plot crossings collapse to zero-height regions at the ends, leaving
455
+ // the visible span in the band it actually occupies.
456
+ grad.addColorStop(0, crossings[0].above);
457
+ for (const c of crossings) {
458
+ const off = offsetOf(c.px);
459
+ grad.addColorStop(off, c.above);
460
+ grad.addColorStop(off, c.below);
461
+ }
462
+ grad.addColorStop(1, crossings[crossings.length - 1].below);
463
+ return grad;
464
+ }
348
465
  /**
349
466
  * **Is the pointer inside this area?** The filled-region counterpart of
350
467
  * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
package/dist/bars.d.ts CHANGED
@@ -55,7 +55,7 @@ export declare function resolveBarBaseline(yScale: Scale): number;
55
55
  * hit region are deliberately *not* the same geometry: the `gapPx` inset
56
56
  * separates columns visually without carving a dead channel out of the target.
57
57
  */
58
- export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
58
+ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number, maxWidthPx?: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
59
59
  /**
60
60
  * The value-space span `[lo, hi]` of **threshold band `k`** along a bar running
61
61
  * from `base` to `v`, or `null` when the bar doesn't reach that band.
@@ -306,6 +306,9 @@ export interface StackStyle {
306
306
  readonly fills: readonly string[];
307
307
  readonly opacity: number;
308
308
  readonly outlineWidth: number;
309
+ /** Cap on a segment's ink span in px, centred in the slot — the stacked
310
+ * counterpart of `BarStyle.maxWidth` ([PND-BARWIDTH]). Omitted ⇒ uncapped. */
311
+ readonly maxWidth?: number;
309
312
  /**
310
313
  * Optional **per-bin** fill override, aligned index-for-index to the bins
311
314
  * (bin `b` uses `binFills[b]`), taking precedence over the per-group
@@ -457,7 +460,7 @@ export declare function stackBase(orientation: Orientation, xScale: Scale, yScal
457
460
  * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
458
461
  * and the hit rect are identical.
459
462
  */
460
- export declare function segmentRect(ss: StackedBarSeries, b: number, g: number, orientation: Orientation, xScale: Scale, yScale: Scale, cumBefore: number, gapPx: number, minSpanPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
463
+ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number, orientation: Orientation, xScale: Scale, yScale: Scale, cumBefore: number, gapPx: number, minSpanPx: number, maxSpanPx?: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
461
464
  /**
462
465
  * Fill every segment of every bin in `ss`, stacking each bin's groups from the
463
466
  * value baseline outward (bottom → top vertical, left → right horizontal). A gap
@@ -484,5 +487,9 @@ export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBar
484
487
  * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
485
488
  * small; click / hover are cheap events).
486
489
  */
487
- export declare function stackAt(ss: StackedBarSeries, px: number, py: number, orientation: Orientation, xScale: Scale, yScale: Scale, gapPx: number, minSpanPx: number): [bin: number, group: number, begin: number, name: string, value: number] | null;
490
+ export declare function stackAt(ss: StackedBarSeries, px: number, py: number, orientation: Orientation, xScale: Scale, yScale: Scale, gapPx: number, minSpanPx: number,
491
+ /** Must match the draw's cap ([PND-BARWIDTH]) — this function's whole
492
+ * contract is that its rect is the drawn rect, so a cap applied to one and
493
+ * not the other silently drifts the hit target off the ink. */
494
+ maxSpanPx?: number): [bin: number, group: number, begin: number, name: string, value: number] | null;
488
495
  //# sourceMappingURL=bars.d.ts.map
package/dist/bars.js CHANGED
@@ -73,11 +73,11 @@ export function resolveBarBaseline(yScale) {
73
73
  * hit region are deliberately *not* the same geometry: the `gapPx` inset
74
74
  * separates columns visually without carving a dead channel out of the target.
75
75
  */
76
- export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
76
+ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx, maxWidthPx) {
77
77
  const v = cs.y[i];
78
78
  if (!Number.isFinite(v))
79
79
  return null;
80
- const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, gapPx, minWidthPx);
80
+ const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, gapPx, minWidthPx, maxWidthPx);
81
81
  const yValue = yScale(v);
82
82
  const yBase = yScale(baseline);
83
83
  return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
@@ -430,7 +430,7 @@ spans = NO_SPANS) {
430
430
  : null;
431
431
  let drawn = 0;
432
432
  for (let i = vStart; i < vEnd; i += 1) {
433
- const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
433
+ const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth, style.maxWidth);
434
434
  if (rect === null)
435
435
  continue;
436
436
  const [x0, x1, yTop, yBottom] = rect;
@@ -779,7 +779,7 @@ export function stackBase(orientation, xScale, yScale) {
779
779
  * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
780
780
  * and the hit rect are identical.
781
781
  */
782
- export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx) {
782
+ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx, maxSpanPx) {
783
783
  const G = ss.groups.length;
784
784
  const v = ss.values[b * G + g];
785
785
  // Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
@@ -801,12 +801,12 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
801
801
  if (!Number.isFinite(v) || v === 0)
802
802
  return null;
803
803
  if (orientation === 'vertical') {
804
- const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
804
+ const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx, maxSpanPx);
805
805
  const yA = yScale(cumBefore);
806
806
  const yB = yScale(cumBefore + v);
807
807
  return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
808
808
  }
809
- const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx);
809
+ const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx, maxSpanPx);
810
810
  const xA = xScale(cumBefore);
811
811
  const xB = xScale(cumBefore + v);
812
812
  return [Math.min(xA, xB), Math.max(xA, xB), y0, y1];
@@ -865,7 +865,7 @@ spans = NO_SPANS) {
865
865
  let cumNeg = base;
866
866
  for (let g = 0; g < G; g += 1) {
867
867
  const v = ss.values[b * G + g];
868
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
868
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx, style.maxWidth);
869
869
  if (Number.isFinite(v)) {
870
870
  if (v > 0)
871
871
  cumPos += v;
@@ -1011,7 +1011,11 @@ spans = NO_SPANS) {
1011
1011
  * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
1012
1012
  * small; click / hover are cheap events).
1013
1013
  */
1014
- export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
1014
+ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx,
1015
+ /** Must match the draw's cap ([PND-BARWIDTH]) — this function's whole
1016
+ * contract is that its rect is the drawn rect, so a cap applied to one and
1017
+ * not the other silently drifts the hit target off the ink. */
1018
+ maxSpanPx) {
1015
1019
  const G = ss.groups.length;
1016
1020
  const base = stackBase(orientation, xScale, yScale);
1017
1021
  for (let b = 0; b < ss.length; b += 1) {
@@ -1021,7 +1025,7 @@ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanP
1021
1025
  let cumNeg = base;
1022
1026
  for (let g = 0; g < G; g += 1) {
1023
1027
  const v = ss.values[b * G + g];
1024
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
1028
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx, maxSpanPx);
1025
1029
  if (Number.isFinite(v)) {
1026
1030
  if (v > 0)
1027
1031
  cumPos += v;
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { ScaleContinuousNumeric, ScaleLinear, ScaleTime } from 'd3-scale';
2
+ import type { ScaleContinuousNumeric, ScaleLinear, ScaleLogarithmic, ScaleSymLog, ScaleTime } from 'd3-scale';
3
3
  import type { ChartTheme } from './theme.js';
4
4
  import type { AxisFormat, CursorFormat } from './format.js';
5
5
  import type { LegendItemSpec } from './swatch.js';
@@ -25,6 +25,19 @@ export interface LabelPlacement {
25
25
  readonly lane: number;
26
26
  readonly label: string | null;
27
27
  }
28
+ /**
29
+ * The container's shared x→pixel scale, in every kind it can resolve to. All
30
+ * five are callable (`value → px`) and expose `invert` / `ticks` /
31
+ * `tickFormat`, which is the whole surface the library — and a consumer
32
+ * reading the frame ({@link ChartFrame.xScale}) — uses; that shared shape is
33
+ * what lets a trading-time or ordinal axis drop in where a linear one went.
34
+ *
35
+ * Named (rather than written inline on {@link ContainerFrame.xScale}) because
36
+ * {@link useChartFrame} publishes it: a consumer positioning DOM chrome over
37
+ * the plot needs to be able to *write down the type* of the scale it maps
38
+ * through.
39
+ */
40
+ export type ChartXScale = ScaleTime<number, number> | ScaleLinear<number, number> | ScaleLogarithmic<number, number> | ScaleSymLog<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
28
41
  export interface ContainerFrame {
29
42
  readonly timeRange: readonly [number, number];
30
43
  readonly width: number;
@@ -355,7 +368,16 @@ export interface ContainerFrame {
355
368
  * (axis labels, gridlines, cursor pill) reads durations without any consumer
356
369
  * knowing about the mode.
357
370
  */
358
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
371
+ readonly xScale: ChartXScale;
372
+ /**
373
+ * Is the x scale **logarithmic** (`log` or `symlog`)?
374
+ *
375
+ * Detected structurally rather than threaded from the prop: `base()` exists
376
+ * on d3's log scales and on no other continuous scale, which is the same test
377
+ * `yticks.ts` already uses on the y side. Read by the viewport gestures,
378
+ * which must do their arithmetic in log space (see `ViewportOptions`).
379
+ */
380
+ readonly xIsLog: boolean;
359
381
  /**
360
382
  * The discontinuity provider backing a **trading-time** x axis, if one was
361
383
  * supplied to the container — closed-market time (weekends, holidays,
@@ -1597,17 +1619,17 @@ export interface LayerEntry {
1597
1619
  }
1598
1620
  /** A y-axis declared in a {@link ChartRow} via `<YAxis>`. */
1599
1621
  /** Which scale a y axis maps its domain through. */
1600
- export type YScaleKind = 'linear' | 'log';
1622
+ export type YScaleKind = 'linear' | 'log' | 'symlog';
1601
1623
  /**
1602
- * A row's resolved y scale — d3's `scaleLinear()`, or `scaleLog()` when the
1603
- * axis asks for `scale="log"`.
1624
+ * A row's resolved y scale — d3's `scaleLinear()`, `scaleLog()` when the axis
1625
+ * asks for `scale="log"`, or `scaleSymlog()` for `scale="symlog"`.
1604
1626
  *
1605
1627
  * Deliberately the **continuous-numeric** supertype rather than `ScaleLinear`:
1606
1628
  * every consumer (the axis labels, the row's gridlines, the cursor readout, and
1607
1629
  * every draw layer) only ever calls it, or reads `domain` / `range` / `ticks` /
1608
- * `tickFormat` / `invert` — the surface both scales share. Keeping the shared
1609
- * type here is what lets a log axis be transparent to the draw layers instead
1610
- * of every layer growing a branch.
1630
+ * `tickFormat` / `invert` — the surface all three scales share. Keeping the
1631
+ * shared type here is what lets a log or symlog axis be transparent to the draw
1632
+ * layers instead of every layer growing a branch.
1611
1633
  */
1612
1634
  export type YScale = ScaleContinuousNumeric<number, number>;
1613
1635
  export interface AxisSpec {
@@ -1617,6 +1639,11 @@ export interface AxisSpec {
1617
1639
  readonly width: number;
1618
1640
  /** Which scale the axis maps its domain through ({@link YAxisProps.scale}). */
1619
1641
  readonly scale: YScaleKind;
1642
+ /** `scale="symlog"`'s linear window as a **fraction of the domain's largest
1643
+ * magnitude** ({@link YAxisProps.linearWindow}) — `undefined` on any other
1644
+ * scale. Domain-relative rather than absolute so it survives a domain change
1645
+ * without a recompute ([PND-SYMLOG]). */
1646
+ readonly linearWindow?: number | undefined;
1620
1647
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
1621
1648
  readonly min: number | undefined;
1622
1649
  readonly max: number | undefined;
@@ -1650,6 +1677,17 @@ export interface AxisSpec {
1650
1677
  */
1651
1678
  export interface RowFrame {
1652
1679
  readonly height: number;
1680
+ /**
1681
+ * The plot's **top inset** within the row's box, in px — the header band
1682
+ * reserved when any axis in the row draws a `labelPlacement="top"` title,
1683
+ * and `0` when none does. The y-scales' range is `[height, topInset]`, so
1684
+ * the drawable plot is `topInset … height`.
1685
+ *
1686
+ * Carried on the frame (rather than staying a local in `ChartRow`) because
1687
+ * {@link useChartFrame} publishes it — without it a consumer placing an
1688
+ * overlay inside the plot would silently sit under a top axis title.
1689
+ */
1690
+ readonly topInset: number;
1653
1691
  readonly yScales: ReadonlyMap<string, YScale>;
1654
1692
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
1655
1693
  * against its scale) — used by both the tick labels and the cursor readout, so
package/dist/data.d.ts CHANGED
@@ -499,6 +499,44 @@ export interface CategoryDatum {
499
499
  readonly label: string;
500
500
  readonly value: number;
501
501
  }
502
+ /**
503
+ * One category of a **stacked** category chart ([PND-CATSTACK]) — a label plus a
504
+ * value **per group**, read by name through `<BarChart columns>`.
505
+ *
506
+ * The `values` record is the shape the list family already uses
507
+ * (`ListRow.values`), deliberately: "a row with named values" is one concept in
508
+ * this library and a category with several groups is exactly that. A missing or
509
+ * non-finite entry reads as a gap, so a group absent from one category is a hole
510
+ * rather than a zero — the same rule every other reader applies.
511
+ */
512
+ export interface CategoryStackDatum {
513
+ readonly label: string;
514
+ readonly values: Readonly<Record<string, number | undefined>>;
515
+ }
516
+ /**
517
+ * Build a {@link StackedBarSeries} from ordered categories carrying a value
518
+ * **per group** — the stacked counterpart of {@link categoryStack}
519
+ * ([PND-CATSTACK]).
520
+ *
521
+ * Geometry is identical to the single-value case (one unit slot `[i, i+1]` per
522
+ * category, `marks` carrying the names), so the categorical axis derives its
523
+ * ordered labels exactly as before and a pinned selection still keys on the
524
+ * stable name. Only `groups` and the `values` layout differ, and both match
525
+ * {@link stacksFromColumns} — `values[i * G + g]`, bin-major — so this reaches
526
+ * the shipped `drawStacks` path with no new draw code.
527
+ *
528
+ * **Why this replaces a real workaround.** Composing the same picture from one
529
+ * `<BarChart categories>` layer per cumulative total (drawn outermost-first so
530
+ * each overpaints the one beneath) costs three things a first-class stack does
531
+ * not: hand-assembled legends, label thinning that cannot see the other layers,
532
+ * and — since selection entries key on `(layer id, mark)` — a controlled set
533
+ * that must be replicated across every segment layer, where missing one makes a
534
+ * selected bar recede *from the waist up*. With one layer and one `mark` per
535
+ * bar, that last failure is not expressible.
536
+ *
537
+ * O(n·G) with one `Float64Array` allocation, matching `stacksFromColumns`.
538
+ */
539
+ export declare function categoryStacks(records: readonly CategoryStackDatum[], columns: readonly string[]): StackedBarSeries;
502
540
  /**
503
541
  * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
504
542
  * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
package/dist/data.js CHANGED
@@ -619,6 +619,49 @@ export function stacksFromBins(bins, columns, options = {}) {
619
619
  }
620
620
  return { begin, end, groups: columns, values, length: n };
621
621
  }
622
+ /**
623
+ * Build a {@link StackedBarSeries} from ordered categories carrying a value
624
+ * **per group** — the stacked counterpart of {@link categoryStack}
625
+ * ([PND-CATSTACK]).
626
+ *
627
+ * Geometry is identical to the single-value case (one unit slot `[i, i+1]` per
628
+ * category, `marks` carrying the names), so the categorical axis derives its
629
+ * ordered labels exactly as before and a pinned selection still keys on the
630
+ * stable name. Only `groups` and the `values` layout differ, and both match
631
+ * {@link stacksFromColumns} — `values[i * G + g]`, bin-major — so this reaches
632
+ * the shipped `drawStacks` path with no new draw code.
633
+ *
634
+ * **Why this replaces a real workaround.** Composing the same picture from one
635
+ * `<BarChart categories>` layer per cumulative total (drawn outermost-first so
636
+ * each overpaints the one beneath) costs three things a first-class stack does
637
+ * not: hand-assembled legends, label thinning that cannot see the other layers,
638
+ * and — since selection entries key on `(layer id, mark)` — a controlled set
639
+ * that must be replicated across every segment layer, where missing one makes a
640
+ * selected bar recede *from the waist up*. With one layer and one `mark` per
641
+ * bar, that last failure is not expressible.
642
+ *
643
+ * O(n·G) with one `Float64Array` allocation, matching `stacksFromColumns`.
644
+ */
645
+ export function categoryStacks(records, columns) {
646
+ const n = records.length;
647
+ const G = columns.length;
648
+ const begin = new Float64Array(n);
649
+ const end = new Float64Array(n);
650
+ const values = new Float64Array(n * G);
651
+ const marks = new Array(n);
652
+ for (let i = 0; i < n; i += 1) {
653
+ begin[i] = i;
654
+ end[i] = i + 1;
655
+ marks[i] = records[i].label;
656
+ const row = records[i].values;
657
+ for (let g = 0; g < G; g += 1) {
658
+ const v = row[columns[g]];
659
+ // A missing key and a non-finite value are the same thing here: no bar.
660
+ values[i * G + g] = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
661
+ }
662
+ }
663
+ return { begin, end, groups: columns, values, length: n, marks };
664
+ }
622
665
  /**
623
666
  * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
624
667
  * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
package/dist/format.d.ts CHANGED
@@ -47,6 +47,9 @@ interface Tickable {
47
47
  tickFormat(count: number, specifier?: string): (value: number) => string;
48
48
  /** Present on d3's `scaleLog` and on no other continuous scale. */
49
49
  base?: () => number;
50
+ /** Present on d3's `scaleSymlog` and on no other continuous scale — the linear
51
+ * window's half-width in data units. */
52
+ constant?: () => number;
50
53
  domain?: () => number[];
51
54
  }
52
55
  /**
@@ -74,7 +77,19 @@ interface Tickable {
74
77
  * render blank for almost every real number. A linear scale's `tickFormat`
75
78
  * applies the specifier to whatever it is handed, which is what every consumer
76
79
  * of this function actually wants; the axis's own tick *thinning* is handled by
77
- * `yTickValues`, not here.
80
+ * `tickValues`, not here.
81
+ *
82
+ * **A symlog scale's precision comes from its knee, not its span** ([PND-SYMLOG]).
83
+ * `scaleSymlog.tickFormat` is `linearish`, so it derives precision from the
84
+ * domain — and a symlog axis is chosen precisely when the interesting values are
85
+ * *orders of magnitude smaller* than the domain. On `[-1, 1]` with a `0.02` knee
86
+ * the ladder emits `-0.02, 0, 0.02` and a span-derived formatter labels all three
87
+ * **`"0.0"`**: three ticks at three positions asserting the same value, on the
88
+ * axis whose whole purpose was to separate them. Formatting through a linear
89
+ * scale over `[-knee, knee]` calibrates to the smallest magnitude the ladder can
90
+ * emit. It changes nothing when the knee is already coarse (a 20k knee on a ±1M
91
+ * domain formats identically), and the cursor readout inherits the same extra
92
+ * precision — which is wanted, since near-zero is where a symlog readout is read.
78
93
  */
79
94
  export declare function resolveAxisFormat(scale: Tickable, count: number, format: AxisFormat | undefined): (value: number) => string;
80
95
  /** The slice of a d3 **time** scale {@link resolveTimeFormat} needs. A d3
package/dist/format.js CHANGED
@@ -24,14 +24,29 @@ import { scaleLinear } from 'd3-scale';
24
24
  * render blank for almost every real number. A linear scale's `tickFormat`
25
25
  * applies the specifier to whatever it is handed, which is what every consumer
26
26
  * of this function actually wants; the axis's own tick *thinning* is handled by
27
- * `yTickValues`, not here.
27
+ * `tickValues`, not here.
28
+ *
29
+ * **A symlog scale's precision comes from its knee, not its span** ([PND-SYMLOG]).
30
+ * `scaleSymlog.tickFormat` is `linearish`, so it derives precision from the
31
+ * domain — and a symlog axis is chosen precisely when the interesting values are
32
+ * *orders of magnitude smaller* than the domain. On `[-1, 1]` with a `0.02` knee
33
+ * the ladder emits `-0.02, 0, 0.02` and a span-derived formatter labels all three
34
+ * **`"0.0"`**: three ticks at three positions asserting the same value, on the
35
+ * axis whose whole purpose was to separate them. Formatting through a linear
36
+ * scale over `[-knee, knee]` calibrates to the smallest magnitude the ladder can
37
+ * emit. It changes nothing when the knee is already coarse (a 20k knee on a ±1M
38
+ * domain formats identically), and the cursor readout inherits the same extra
39
+ * precision — which is wanted, since near-zero is where a symlog readout is read.
28
40
  */
29
41
  export function resolveAxisFormat(scale, count, format) {
30
42
  if (typeof format === 'function')
31
43
  return format;
44
+ const knee = typeof scale.constant === 'function' ? Math.abs(scale.constant()) : 0;
32
45
  const source = typeof scale.base === 'function' && typeof scale.domain === 'function'
33
46
  ? scaleLinear().domain(scale.domain())
34
- : scale;
47
+ : Number.isFinite(knee) && knee > 0
48
+ ? scaleLinear().domain([-knee, knee])
49
+ : scale;
35
50
  return format !== undefined
36
51
  ? source.tickFormat(count, format)
37
52
  : source.tickFormat(count);
package/dist/index.d.ts CHANGED
@@ -64,6 +64,9 @@ export type { LegendProps, LegendPlacement } from './Legend.js';
64
64
  export type { SwatchSpec, LegendItemInput } from './swatch.js';
65
65
  export { useChartLegend } from './useChartLegend.js';
66
66
  export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
67
+ export { useChartFrame } from './useChartFrame.js';
68
+ export type { ChartFrame, ChartFrameRow, ChartBands, ChartBand, } from './useChartFrame.js';
69
+ export type { ChartXScale } from './context.js';
67
70
  export { scaleTradingTime } from './tradingTimeScale.js';
68
71
  export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
69
72
  export { scaleBand } from './bandScale.js';
@@ -73,8 +76,8 @@ export type { RegionProps, BaselineProps, MarkerProps, ZoneProps, } from './anno
73
76
  export type { AnnotationKind, CreateSpec } from './context.js';
74
77
  export { YAxisIndicator, createLiveValue } from './indicators.js';
75
78
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
76
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
77
- export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, RowAt, TransposeRowOptions, } from './data.js';
79
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, categoryStacks, transposeRow, } from './data.js';
80
+ export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, CategoryStackDatum, RowAt, TransposeRowOptions, } from './data.js';
78
81
  export type { Orientation } from './bars.js';
79
82
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
80
83
  export type { Curve } from './curve.js';
package/dist/index.js CHANGED
@@ -52,6 +52,12 @@ export { Legend } from './Legend.js';
52
52
  // consumers who design their own key (horizontal strips, ticker-compare,
53
53
  // values-in-the-legend).
54
54
  export { useChartLegend } from './useChartLegend.js';
55
+ // The resolved plot geometry — the plot rect, the axis gutters, the shared x
56
+ // scale, a row's y scales, and (on a category axis) the ordinal slot edges.
57
+ // What a consumer aligning DOM chrome to the plot would otherwise re-derive
58
+ // by mirroring the library's own gutter arithmetic — a duplicate that drifts
59
+ // silently the moment the library changes how a gutter is sized.
60
+ export { useChartFrame } from './useChartFrame.js';
55
61
  export { scaleTradingTime } from './tradingTimeScale.js';
56
62
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
57
63
  export { scaleBand } from './bandScale.js';
@@ -68,6 +74,11 @@ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeri
68
74
  stacksFromGroups, stacksFromColumns, stacksFromBins,
69
75
  // Categorical row-read: one bar per `{ label, value }` on the category axis.
70
76
  categoryStack,
77
+ // …and its stacked sibling: one bar per `{ label, values }`, segments named by
78
+ // `columns` ([PND-CATSTACK]). Public for the same reason every reader above is
79
+ // — a caller assembling a `StackedBarSeries` by hand needs it — and because
80
+ // API.md already documented it as public while `index.ts` did not export it.
81
+ categoryStacks,
71
82
  // The transpose reader — one row of a wide series read across into categories.
72
83
  transposeRow, } from './data.js';
73
84
  export { defaultTheme, estelaTheme } from './theme.js';