@pond-ts/charts 0.58.0 → 0.59.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/YAxis.js CHANGED
@@ -17,7 +17,7 @@ const DEFAULT_TICK_COUNT = 5;
17
17
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
18
18
  * (default: the first axis).
19
19
  */
20
- export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
20
+ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
21
21
  const container = useContext(ContainerContext);
22
22
  if (container === null) {
23
23
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -35,6 +35,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
35
35
  // and layers still bind to it, which is the whole point of the prop.
36
36
  width: hide ? 0 : width,
37
37
  scale,
38
+ linearWindow,
38
39
  min,
39
40
  max,
40
41
  pad,
@@ -49,6 +50,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
49
50
  width,
50
51
  hide,
51
52
  scale,
53
+ linearWindow,
52
54
  min,
53
55
  max,
54
56
  pad,
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
@@ -1597,17 +1597,17 @@ export interface LayerEntry {
1597
1597
  }
1598
1598
  /** A y-axis declared in a {@link ChartRow} via `<YAxis>`. */
1599
1599
  /** Which scale a y axis maps its domain through. */
1600
- export type YScaleKind = 'linear' | 'log';
1600
+ export type YScaleKind = 'linear' | 'log' | 'symlog';
1601
1601
  /**
1602
- * A row's resolved y scale — d3's `scaleLinear()`, or `scaleLog()` when the
1603
- * axis asks for `scale="log"`.
1602
+ * A row's resolved y scale — d3's `scaleLinear()`, `scaleLog()` when the axis
1603
+ * asks for `scale="log"`, or `scaleSymlog()` for `scale="symlog"`.
1604
1604
  *
1605
1605
  * Deliberately the **continuous-numeric** supertype rather than `ScaleLinear`:
1606
1606
  * every consumer (the axis labels, the row's gridlines, the cursor readout, and
1607
1607
  * 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.
1608
+ * `tickFormat` / `invert` — the surface all three scales share. Keeping the
1609
+ * shared type here is what lets a log or symlog axis be transparent to the draw
1610
+ * layers instead of every layer growing a branch.
1611
1611
  */
1612
1612
  export type YScale = ScaleContinuousNumeric<number, number>;
1613
1613
  export interface AxisSpec {
@@ -1617,6 +1617,11 @@ export interface AxisSpec {
1617
1617
  readonly width: number;
1618
1618
  /** Which scale the axis maps its domain through ({@link YAxisProps.scale}). */
1619
1619
  readonly scale: YScaleKind;
1620
+ /** `scale="symlog"`'s linear window as a **fraction of the domain's largest
1621
+ * magnitude** ({@link YAxisProps.linearWindow}) — `undefined` on any other
1622
+ * scale. Domain-relative rather than absolute so it survives a domain change
1623
+ * without a recompute ([PND-SYMLOG]). */
1624
+ readonly linearWindow?: number | undefined;
1620
1625
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
1621
1626
  readonly min: number | undefined;
1622
1627
  readonly max: number | undefined;
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
  /**
@@ -75,6 +78,18 @@ interface Tickable {
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
80
  * `yTickValues`, 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
@@ -25,13 +25,28 @@ import { scaleLinear } from 'd3-scale';
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
27
  * `yTickValues`, 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
@@ -73,8 +73,8 @@ export type { RegionProps, BaselineProps, MarkerProps, ZoneProps, } from './anno
73
73
  export type { AnnotationKind, CreateSpec } from './context.js';
74
74
  export { YAxisIndicator, createLiveValue } from './indicators.js';
75
75
  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';
76
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, categoryStacks, transposeRow, } from './data.js';
77
+ export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, CategoryStackDatum, RowAt, TransposeRowOptions, } from './data.js';
78
78
  export type { Orientation } from './bars.js';
79
79
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
80
80
  export type { Curve } from './curve.js';
package/dist/index.js CHANGED
@@ -68,6 +68,11 @@ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeri
68
68
  stacksFromGroups, stacksFromColumns, stacksFromBins,
69
69
  // Categorical row-read: one bar per `{ label, value }` on the category axis.
70
70
  categoryStack,
71
+ // …and its stacked sibling: one bar per `{ label, values }`, segments named by
72
+ // `columns` ([PND-CATSTACK]). Public for the same reason every reader above is
73
+ // — a caller assembling a `StackedBarSeries` by hand needs it — and because
74
+ // API.md already documented it as public while `index.ts` did not export it.
75
+ categoryStacks,
71
76
  // The transpose reader — one row of a wide series read across into categories.
72
77
  transposeRow, } from './data.js';
73
78
  export { defaultTheme, estelaTheme } from './theme.js';
package/dist/range.d.ts CHANGED
@@ -10,6 +10,19 @@
10
10
  * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
11
  * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
12
  * stays visible and the bar never flips inside-out.
13
+ *
14
+ * `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
15
+ * slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
16
+ * is a *relative* inset, so on its own bar width is always `slot - gap` and
17
+ * fattens with the slot. Two independent sizes — slots spreading to fill the
18
+ * plot, ink pinned to N px — is what makes a measure comparable **between**
19
+ * panes, since a bar that widens with its pane reads as a different weight of
20
+ * the same thing. Expressing that with the relative knob alone requires
21
+ * predicting the slot width and back-solving the gap, which re-derives this
22
+ * function's arithmetic in consumer code.
23
+ *
24
+ * `minWidthPx` still wins: a cap below the floor yields the floor, so the two
25
+ * bounds can never invert the rect.
13
26
  */
14
- export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number): [number, number];
27
+ export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number, maxWidthPx?: number): [number, number];
15
28
  //# sourceMappingURL=range.d.ts.map
package/dist/range.js CHANGED
@@ -10,15 +10,36 @@
10
10
  * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
11
  * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
12
  * stays visible and the bar never flips inside-out.
13
+ *
14
+ * `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
15
+ * slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
16
+ * is a *relative* inset, so on its own bar width is always `slot - gap` and
17
+ * fattens with the slot. Two independent sizes — slots spreading to fill the
18
+ * plot, ink pinned to N px — is what makes a measure comparable **between**
19
+ * panes, since a bar that widens with its pane reads as a different weight of
20
+ * the same thing. Expressing that with the relative knob alone requires
21
+ * predicting the slot width and back-solving the gap, which re-derives this
22
+ * function's arithmetic in consumer code.
23
+ *
24
+ * `minWidthPx` still wins: a cap below the floor yields the floor, so the two
25
+ * bounds can never invert the rect.
13
26
  */
14
- export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1) {
27
+ export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1, maxWidthPx) {
15
28
  const a = xScale(beginMs);
16
29
  const b = xScale(endMs);
17
30
  const lo = Math.min(a, b);
18
31
  const hi = Math.max(a, b);
19
32
  const inset = gapPx / 2;
20
- const x0 = lo + inset;
21
- const x1 = hi - inset;
33
+ let x0 = lo + inset;
34
+ let x1 = hi - inset;
35
+ // The cap reads on the INSET span, not the raw slot, so `gap` keeps its
36
+ // meaning as the minimum breathing room: a bar is never wider than the gap
37
+ // allows, and never wider than the cap, whichever binds first.
38
+ if (maxWidthPx !== undefined && maxWidthPx > 0 && x1 - x0 > maxWidthPx) {
39
+ const mid = (lo + hi) / 2;
40
+ x0 = mid - maxWidthPx / 2;
41
+ x1 = mid + maxWidthPx / 2;
42
+ }
22
43
  if (x1 - x0 >= minWidthPx)
23
44
  return [x0, x1];
24
45
  const mid = (lo + hi) / 2;
package/dist/theme.d.ts CHANGED
@@ -296,10 +296,14 @@ export interface ChartTheme {
296
296
  *
297
297
  * So the two values here are the ones with no canvas counterpart: the row
298
298
  * **band** tints. Everything else resolves from tokens that already exist
299
- * and are per-metric where they should be — a selected fill takes
300
- * {@link BarStyle.highlight}, a dimmed one {@link BarStyle.dimmed} — so a
301
- * consumer who themes their bars gets a coherent list without theming it
302
- * twice.
299
+ * and are per-metric where they should be — a selected glyph fill takes
300
+ * `bar[as].highlight` and a dimmed one `bar[as].dimmed` — so a consumer who
301
+ * themes their bars gets a coherent list without theming it twice.
302
+ *
303
+ * **To be unambiguous, because this reads as a list of fields on this
304
+ * block:** `highlight` / `dimmed` are **`BarStyle`** tokens, resolved through
305
+ * `theme.bar[as]`. They are not fields of `theme.list` and there is no
306
+ * `list.dimmed` — this register carries exactly the five values below.
303
307
  *
304
308
  * **The rail is deliberately NOT per-metric.** There is one rail per row
305
309
  * and a row may carry several metrics, so it cannot resolve through
@@ -730,6 +734,28 @@ export interface BarStyle {
730
734
  readonly highlight: string;
731
735
  readonly gap: number;
732
736
  readonly minWidth: number;
737
+ /**
738
+ * Cap on a bar's **ink** width in px, applied after the `gap` inset and
739
+ * centred in the slot ([PND-BARWIDTH]). **Omitted ⇒ uncapped** — a bar is
740
+ * `slot - gap` wide, as it always was.
741
+ *
742
+ * It is the missing half of the width vocabulary, and the reason it cannot be
743
+ * spelled with `gap` alone: `gap` is a *relative* inset, so bar width tracks
744
+ * the slot and fattens as the plot widens. A **fixed** ink width is what makes
745
+ * a measure comparable *between* panes — bars that widen with their pane read
746
+ * as different weights of the same thing. Wanting both (slots spreading to
747
+ * fill, ink pinned) needs two independent knobs; with one, a consumer has to
748
+ * predict the slot width and back-solve the gap, re-deriving pond's own layout
749
+ * arithmetic in their code.
750
+ *
751
+ * Pairs with `<ChartContainer maxBandWidth>`, which caps the **slot**: that
752
+ * one decides how far the bars spread, this one how wide the ink is inside
753
+ * whatever slot results. `minWidth` still wins if the two would invert.
754
+ *
755
+ * `<BarChart maxBarWidth>` overrides this per layer, the same relationship
756
+ * `gap` has.
757
+ */
758
+ readonly maxWidth?: number;
733
759
  readonly outlineWidth: number;
734
760
  /**
735
761
  * Optional distinct **hover** fill, so a bar can read a three-step emphasis —
@@ -757,6 +783,11 @@ export interface BarStyle {
757
783
  * both states so a red/green volume bar keeps its meaning while live —
758
784
  * the one *design* exclusion rather than a path consequence.
759
785
  *
786
+ * **Scope note: that `binColors` exclusion is about the LIVE states only.**
787
+ * It does not carry over to {@link dimmed}, which *replaces* a per-bar fill
788
+ * on an unselected bar — see that token, which spells out the asymmetry and
789
+ * why emphasis preserves a per-bar colour while recession suppresses it.
790
+ *
760
791
  * The **decimated** dense-bar pass also draws the flat fill only, as it
761
792
  * already did for `highlight`.
762
793
  */
@@ -859,6 +890,29 @@ export interface BarStyle {
859
890
  * component, and drifted immediately — one consumer had three charts using
860
891
  * `color-mix` at 22%, 28% and 30% for the same concept, in the same week, for
861
892
  * no reason. One theme value fixes that permanently.
893
+ *
894
+ * **It OVERRIDES a per-bar fill, unlike the live states.** This is the one
895
+ * place `dimmed` and {@link hover} deliberately disagree, and the asymmetry
896
+ * is easy to read the wrong way round:
897
+ *
898
+ * - **{@link binColors} / {@link binFills}:** an unselected bar paints
899
+ * `dimmed`, discarding its own colour. (Hover and selection do the
900
+ * opposite — they keep the per-bar colour and pop the alpha, so a
901
+ * red/green volume bar stays red/green while live.)
902
+ * - **{@link bands} / thresholds:** an unselected banded bar draws **flat**
903
+ * in `dimmed`, discarding the ladder entirely rather than dimming each
904
+ * band.
905
+ * - **A multi-group stack** resolves per group through
906
+ * {@link StackStyle.dimmedFills} first, falling back to this flat value —
907
+ * a stack dimmed to one colour stops reading as a stack.
908
+ *
909
+ * The rule behind all three: a per-bar or per-band colour encodes *what the
910
+ * value is*, and a receded bar's whole job is to stop competing over that.
911
+ * Emphasis preserves meaning; recession suppresses it. So a chart that keeps
912
+ * `binColors` or `thresholds` for reasons unrelated to selection still gets
913
+ * a visible de-emphasis for free, and does **not** need to dim inside its own
914
+ * colour arrays. (Asked by a consumer who reasonably generalized `hover`'s
915
+ * `binColors` exclusion to this token; the exclusion is live-states-only.)
862
916
  */
863
917
  readonly dimmed?: string;
864
918
  }
package/dist/yticks.d.ts CHANGED
@@ -23,6 +23,9 @@ interface TickableScale {
23
23
  domain(): number[];
24
24
  /** Present on d3's `scaleLog` and on no other continuous scale. */
25
25
  base?: () => number;
26
+ /** Present on d3's `scaleSymlog` and on no other continuous scale — the
27
+ * linear window's half-width, i.e. the knee ([PND-SYMLOG]). */
28
+ constant?: () => number;
26
29
  }
27
30
  /**
28
31
  * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
package/dist/yticks.js CHANGED
@@ -61,6 +61,8 @@ export function resolveYTickCount(height, explicit) {
61
61
  * approach `resolveBarBaseline` takes to read `.domain()`.
62
62
  */
63
63
  export function yTickValues(scale, count) {
64
+ if (typeof scale.constant === 'function')
65
+ return symlogTickValues(scale, count);
64
66
  if (typeof scale.base !== 'function')
65
67
  return scale.ticks(count);
66
68
  const domain = scale.domain();
@@ -80,4 +82,106 @@ export function yTickValues(scale, count) {
80
82
  out.push(10 ** e);
81
83
  return out;
82
84
  }
85
+ /**
86
+ * Tick values for a **symlog** axis — linear through zero, logarithmic beyond
87
+ * ([PND-SYMLOG]).
88
+ *
89
+ * **This is the feature, not a refinement of it.** d3's `scaleSymlog` supplies
90
+ * the transform but its `ticks()` is `linearish` — evenly spaced in *value*. On
91
+ * a ±1M domain with a 20k knee that yields `-1M, -500k, 0, 500k, 1M`: **nothing
92
+ * at all below the knee**, which is the region a symlog axis exists to reveal.
93
+ * The mapping does spread that region generously (0→250px, 20k→294px,
94
+ * 100k→364px on a 500px range), so such a chart is readable-but-unlabelled —
95
+ * confidently gridded on the one part of the scale that isn't the point. Owning
96
+ * the ladder is therefore inseparable from owning the transform.
97
+ *
98
+ * The ladder, and why each piece is there:
99
+ *
100
+ * - **Zero, always.** It is the axis's centre of symmetry and the one value a
101
+ * symlog scale is chosen to keep visible.
102
+ * - **The knee, ±`constant`.** Where the reading changes from linear to
103
+ * logarithmic. Unlabelled, a reader has no way to know which régime a given
104
+ * gap belongs to, and the same pixel distance means different things either
105
+ * side of it.
106
+ * - **Decades beyond the knee, mirrored.** What a log plot is conventionally
107
+ * gridded on, thinned by the same "every `k`th power of ten" rule the log path
108
+ * above uses, so it degrades predictably as the row shrinks instead of
109
+ * exploding.
110
+ * - **Bounds are NOT labelled.** A data-derived bound is rarely round, so
111
+ * printing it puts an arbitrary number next to a decade — the noise a log grid
112
+ * exists to avoid.
113
+ *
114
+ * Below one decade of span past the knee there is nothing to grid
115
+ * logarithmically, so it defers to `scale.ticks(count)` — which is linear, and
116
+ * correct, because inside the knee symlog *is* linear.
117
+ *
118
+ * **Clip first, then thin — never the other way round.** A pan/zoom (or explicit
119
+ * bounds) can leave a window that contains *none* of the ideal ladder:
120
+ * `[510_000, 990_000]` with a 19_800 knee excludes zero, both knees, and its one
121
+ * candidate decade, so an order that thinned a symmetric ladder and clipped
122
+ * afterwards handed **`[]`** to the labels and the gridlines — an axis with no
123
+ * ticks at all, which reads as a rendering failure rather than as a scale. The
124
+ * budget is likewise spent on what *survives* the domain, not on an ideal
125
+ * two-sided ladder, so an asymmetric window is not thinned as if it were twice
126
+ * its size. If nothing survives, the linear ticks are the honest answer.
127
+ *
128
+ * Detection is structural, matching the log path's use of `base()`: `constant()`
129
+ * exists on `scaleSymlog` and on no other continuous scale.
130
+ */
131
+ function symlogTickValues(scale, count) {
132
+ const domain = scale.domain();
133
+ const lo = Math.min(domain[0], domain[domain.length - 1]);
134
+ const hi = Math.max(domain[0], domain[domain.length - 1]);
135
+ const knee = Math.abs(scale.constant?.() ?? 1);
136
+ const maxAbs = Math.max(Math.abs(lo), Math.abs(hi));
137
+ // Finiteness is checked, not assumed: an explicit `max={Infinity}` reaches here
138
+ // intact, and `floor(log10(Infinity))` is `Infinity` — which made the decade
139
+ // loop's `e += step` a no-op and hung the render in a `for` that could never
140
+ // end. d3's own linear ticks return `[]` on such a domain, so deferring is both
141
+ // safe and the truthful answer for a domain with no finite extent.
142
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || !Number.isFinite(knee))
143
+ return scale.ticks(count);
144
+ if (!(knee > 0) || !(maxAbs > knee) || !(hi > lo))
145
+ return scale.ticks(count);
146
+ // The ladder starts at the first decade **at least half a decade above the
147
+ // knee** (`× √10`), not merely above it. `ceil(log10(knee))` alone puts a
148
+ // decade arbitrarily close to the knee tick whenever the knee lands just under
149
+ // a power of ten — a data-derived `maxAbs` of 4.95e6 gives a 99k knee and a
150
+ // 100k decade, two ticks a few pixels apart whose labels round to the *same
151
+ // string*. Since the knee itself is always drawn, dropping that decade loses
152
+ // no information; a chart that prints one number twice at two positions is
153
+ // reporting something false about the scale.
154
+ const firstExp = Math.ceil(Math.log10(knee) + 0.5);
155
+ const lastExp = Math.floor(Math.log10(maxAbs));
156
+ const inDomain = (v) => v >= lo && v <= hi;
157
+ // Zero and ±knee are the fixed part of the ladder — kept whole, never thinned,
158
+ // because they are what distinguishes a symlog axis from a log one.
159
+ const fixed = [0, knee, -knee].filter(inDomain);
160
+ // Every decade past the knee that the domain actually contains, in ascending
161
+ // magnitude, each carrying whichever of ±10^e survived.
162
+ const rungs = [];
163
+ for (let e = firstExp; e <= lastExp; e += 1) {
164
+ const pair = [10 ** e, -(10 ** e)].filter(inDomain);
165
+ if (pair.length > 0)
166
+ rungs.push(pair);
167
+ }
168
+ // No rung survives ⇒ there is no logarithmic region to grid, so the linear
169
+ // ticks are the answer — the same reasoning as the knee-swallows-the-domain
170
+ // guard above, and the case that used to produce an empty axis. Note this also
171
+ // covers a window sitting *between* the knee and the first decade.
172
+ if (rungs.length === 0)
173
+ return scale.ticks(count);
174
+ // Thin the SURVIVING rungs to what is left of the budget after the fixed
175
+ // ticks. Stepping by magnitude keeps a rung's ± pair together, so the grid
176
+ // stays symmetric wherever the domain is.
177
+ const budget = Math.max(2, count);
178
+ const total = rungs.reduce((n, pair) => n + pair.length, 0);
179
+ const room = Math.max(1, budget - fixed.length);
180
+ const step = Math.max(1, Math.ceil(total / room));
181
+ const out = new Set(fixed);
182
+ for (let i = 0; i < rungs.length; i += step)
183
+ for (const v of rungs[i])
184
+ out.add(v);
185
+ return [...out].sort((a, b) => a - b);
186
+ }
83
187
  //# sourceMappingURL=yticks.js.map