@pond-ts/charts 0.55.0 → 0.56.2

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/context.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ScaleLinear, ScaleTime } from 'd3-scale';
1
+ import type { ScaleContinuousNumeric, ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
4
  import type { LegendItemSpec } from './swatch.js';
@@ -761,11 +761,27 @@ export interface LayerEntry {
761
761
  readonly index: number;
762
762
  }
763
763
  /** A y-axis declared in a {@link ChartRow} via `<YAxis>`. */
764
+ /** Which scale a y axis maps its domain through. */
765
+ export type YScaleKind = 'linear' | 'log';
766
+ /**
767
+ * A row's resolved y scale — d3's `scaleLinear()`, or `scaleLog()` when the
768
+ * axis asks for `scale="log"`.
769
+ *
770
+ * Deliberately the **continuous-numeric** supertype rather than `ScaleLinear`:
771
+ * every consumer (the axis labels, the row's gridlines, the cursor readout, and
772
+ * every draw layer) only ever calls it, or reads `domain` / `range` / `ticks` /
773
+ * `tickFormat` / `invert` — the surface both scales share. Keeping the shared
774
+ * type here is what lets a log axis be transparent to the draw layers instead
775
+ * of every layer growing a branch.
776
+ */
777
+ export type YScale = ScaleContinuousNumeric<number, number>;
764
778
  export interface AxisSpec {
765
779
  readonly id: string;
766
780
  readonly side: 'left' | 'right';
767
781
  /** Gutter width in CSS pixels. */
768
782
  readonly width: number;
783
+ /** Which scale the axis maps its domain through ({@link YAxisProps.scale}). */
784
+ readonly scale: YScaleKind;
769
785
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
770
786
  readonly min: number | undefined;
771
787
  readonly max: number | undefined;
@@ -799,7 +815,7 @@ export interface AxisSpec {
799
815
  */
800
816
  export interface RowFrame {
801
817
  readonly height: number;
802
- readonly yScales: ReadonlyMap<string, ScaleLinear<number, number>>;
818
+ readonly yScales: ReadonlyMap<string, YScale>;
803
819
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
804
820
  * against its scale) — used by both the tick labels and the cursor readout, so
805
821
  * a value reads identically in both. */
package/dist/dev.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare const isDev: boolean;
2
+ //# sourceMappingURL=dev.d.ts.map
package/dist/dev.js ADDED
@@ -0,0 +1,2 @@
1
+ export const isDev = typeof process === 'undefined' || process?.env?.NODE_ENV !== 'production';
2
+ //# sourceMappingURL=dev.js.map
package/dist/domain.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { YScaleKind } from './context.js';
1
2
  /**
2
3
  * Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
3
4
  * the layers linked to it. An `undefined` bound auto-fits the data: with no
@@ -19,6 +20,58 @@
19
20
  * `pad × span` on each side — headroom without hand-computing bounds, useful to
20
21
  * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
21
22
  * domain was resolved (explicit or auto); `0` is a no-op.
23
+ *
24
+ * `scale` selects the spacing. `'log'` delegates to {@link resolveLogDomain},
25
+ * which applies **every policy above** — verbatim explicit bounds, an auto-fit
26
+ * side that moves rather than a caller's bound being discarded, `.nice()` on a
27
+ * fully auto-fit domain — and differs only where a log axis forces it to: a
28
+ * non-positive bound has no position and is refused, and `pad` is a fraction of
29
+ * the *decades* spanned rather than of the difference.
30
+ */
31
+ export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
32
+ /**
33
+ * Does resolving this axis's domain need its layers' extents walked?
34
+ * `yExtent()` is O(points) per layer, so the caller only pays it when a side
35
+ * actually auto-fits.
36
+ *
37
+ * A log axis **refuses a non-positive bound** ({@link resolveLogDomain}), which
38
+ * means such a bound is not a bound: that side auto-fits and needs the data. The
39
+ * naive `min === undefined || max === undefined` test misses this, and the miss
40
+ * is silent — `<YAxis scale="log" min={0} max={1e6}>` looked fully explicit, so
41
+ * no extents were gathered, so the refused floor fell back to the empty-data
42
+ * placeholder instead of the data's own floor. (`resolveLogDomain`'s unit tests
43
+ * passed throughout: they hand it the extents directly, which is precisely what
44
+ * the component was not doing.)
45
+ */
46
+ export declare function needsExtents(axis: {
47
+ readonly scale: YScaleKind;
48
+ readonly min: number | undefined;
49
+ readonly max: number | undefined;
50
+ }): boolean;
51
+ /**
52
+ * The dev-mode complaint a `scale="log"` axis has about its own bounds and the
53
+ * data linked to it, or `null` when it has none. Pure, so the policy is unit
54
+ * tested directly rather than through a rendered console spy.
55
+ *
56
+ * **Every case here is unambiguous**, which is the whole design constraint. The
57
+ * previous version warned whenever a linked extent reached zero, and that fires
58
+ * on *every* `BarChart` — `barExtent` always widens its low end to `0` so a bar
59
+ * can reach its baseline, whether or not the data goes anywhere near it. So the
60
+ * `WithBars` story warned, on strictly positive data, with text asserting
61
+ * something false about it. A dev warning that cries wolf gets muted, and then
62
+ * the real ones are lost too.
63
+ *
64
+ * The cost of that precision is the one genuinely ambiguous shape: an extent of
65
+ * exactly `[0, hi]`, which is what a line touching zero *and* a bar layer on
66
+ * positive data both report. It is not warned about. That case is no longer
67
+ * silent, though — a sample with no position on the axis now renders as a
68
+ * **gap** rather than being bridged straight over, so the picture itself says
69
+ * the data is missing there.
22
70
  */
23
- export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number): [number, number];
71
+ export declare function logAxisWarning(axis: {
72
+ readonly id: string;
73
+ readonly scale: YScaleKind;
74
+ readonly min: number | undefined;
75
+ readonly max: number | undefined;
76
+ }, extents: readonly (readonly [number, number] | null)[]): string | null;
24
77
  //# sourceMappingURL=domain.d.ts.map
package/dist/domain.js CHANGED
@@ -1,4 +1,4 @@
1
- import { scaleLinear } from 'd3-scale';
1
+ import { scaleLinear, scaleLog } from 'd3-scale';
2
2
  /**
3
3
  * Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
4
4
  * the layers linked to it. An `undefined` bound auto-fits the data: with no
@@ -20,8 +20,17 @@ import { scaleLinear } from 'd3-scale';
20
20
  * `pad × span` on each side — headroom without hand-computing bounds, useful to
21
21
  * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
22
22
  * domain was resolved (explicit or auto); `0` is a no-op.
23
+ *
24
+ * `scale` selects the spacing. `'log'` delegates to {@link resolveLogDomain},
25
+ * which applies **every policy above** — verbatim explicit bounds, an auto-fit
26
+ * side that moves rather than a caller's bound being discarded, `.nice()` on a
27
+ * fully auto-fit domain — and differs only where a log axis forces it to: a
28
+ * non-positive bound has no position and is refused, and `pad` is a fraction of
29
+ * the *decades* spanned rather than of the difference.
23
30
  */
24
- export function resolveYDomain(min, max, extents, pad = 0) {
31
+ export function resolveYDomain(min, max, extents, pad = 0, scale = 'linear') {
32
+ if (scale === 'log')
33
+ return resolveLogDomain(min, max, extents, pad);
25
34
  const result = resolveBase(min, max, extents);
26
35
  if (pad) {
27
36
  const [lo, hi] = result;
@@ -30,6 +39,190 @@ export function resolveYDomain(min, max, extents, pad = 0) {
30
39
  }
31
40
  return result;
32
41
  }
42
+ /** Smallest positive value a log domain will fall back to when the data offers
43
+ * nothing positive at all. Arbitrary but finite — a log scale has no natural
44
+ * zero to anchor on, and `[0, 1]` (the linear empty-data domain) has no
45
+ * position for its own low end: `scaleLog().domain([0, 1])(x)` is `NaN` for
46
+ * every `x`, which poisons every coordinate drawn against it. */
47
+ const LOG_EMPTY_LO = 1;
48
+ const LOG_EMPTY_HI = 10;
49
+ /**
50
+ * The log analog of {@link resolveBase} + padding.
51
+ *
52
+ * The **policy** is deliberately identical to linear's, so `scale="log"` changes
53
+ * how a domain is spaced and not what the props mean:
54
+ *
55
+ * - **Two explicit bounds are verbatim** (an inverted pair is a deliberate axis
56
+ * flip; we don't second-guess it).
57
+ * - **A partial explicit bound is never discarded.** When one side is explicit
58
+ * and the resolved domain would invert, the **auto-fit** side moves — exactly
59
+ * as {@link resolveBase} does. (This inverted the caller's policy until the
60
+ * log-axis review: `resolveLogDomain(undefined, 100, [[1000, 2000]])` returned
61
+ * `[1000, 10000]`, silently throwing away the requested `max` and putting the
62
+ * axis three decades from where it was asked to be.)
63
+ * - **A fully auto-fit domain is `.nice()`d**, the promise {@link resolveYDomain}
64
+ * already documents. `scaleLog().nice()` extends to whole powers of ten, so
65
+ * the extremes get headroom instead of sitting clipped against the plot edge
66
+ * and the decade ticks land on the domain bounds.
67
+ *
68
+ * Two things differ from linear, and both are consequences of the same fact —
69
+ * that a log axis has no position for zero:
70
+ *
71
+ * - **Non-positive bounds are unusable.** Auto-fit takes the smallest
72
+ * *positive* extent rather than the smallest, so one zero sample doesn't
73
+ * collapse the axis; an explicit non-positive `min`/`max` is ignored in
74
+ * favour of the data (a caller asking for `min={0}` on a log axis has asked
75
+ * for `NaN` — see {@link logAxisWarning} — which we will not hand to a scale).
76
+ * A bound refused this way is treated as *absent* from here on, so the side
77
+ * that survives is still honoured as an explicit bound.
78
+ * - **Padding is multiplicative.** `pad` is a *fraction of the domain*, and on
79
+ * a log axis the domain's span is a ratio, not a difference. Padding
80
+ * additively would add a constant number of bytes to a decade — invisible at
81
+ * the top, enormous at the bottom. Applying it in log space adds the same
82
+ * *fraction of a decade* at both ends, which is what the linear behaviour
83
+ * looks like to the eye. (The expression is sign-correct on a flipped domain
84
+ * for the same reason linear's is: `log10(hi/lo)` goes negative, so both ends
85
+ * still move outward.)
86
+ */
87
+ function resolveLogDomain(min, max, extents, pad) {
88
+ // A non-positive explicit bound is refused, and from here on is simply absent
89
+ // — so `max={1e6}` with a refused `min={0}` still behaves as "explicit top,
90
+ // auto-fit floor" rather than falling into the both-explicit branch.
91
+ const explicitLo = min !== undefined && min > 0 ? min : undefined;
92
+ const explicitHi = max !== undefined && max > 0 ? max : undefined;
93
+ const [lo, hi] = resolveLogBase(explicitLo, explicitHi, extents);
94
+ if (!pad)
95
+ return [lo, hi];
96
+ // A fraction of the *decades* spanned, added at each end.
97
+ const decades = Math.log10(hi / lo) * pad;
98
+ return [lo / 10 ** decades, hi * 10 ** decades];
99
+ }
100
+ /** {@link resolveLogDomain} minus the padding — the domain itself. */
101
+ function resolveLogBase(explicitLo, explicitHi, extents) {
102
+ // Both bounds explicit (and positive): trust them verbatim, matching
103
+ // `resolveBase` — including an intentional flip.
104
+ if (explicitLo !== undefined && explicitHi !== undefined) {
105
+ return [explicitLo, explicitHi];
106
+ }
107
+ let dataLo = Infinity;
108
+ let dataHi = -Infinity;
109
+ for (const e of extents) {
110
+ if (!e)
111
+ continue;
112
+ // The low end walks both ends of the extent: a series whose min is 0 or
113
+ // negative can still have a positive max, and that max is the only
114
+ // positive floor it can offer.
115
+ if (e[0] > 0 && e[0] < dataLo)
116
+ dataLo = e[0];
117
+ else if (e[1] > 0 && e[1] < dataLo)
118
+ dataLo = e[1];
119
+ if (e[1] > dataHi)
120
+ dataHi = e[1];
121
+ }
122
+ if (dataLo === Infinity || !(dataHi > 0)) {
123
+ // Nothing positive to fit — the log counterpart of linear's `[0, 1]`.
124
+ dataLo = LOG_EMPTY_LO;
125
+ dataHi = LOG_EMPTY_HI;
126
+ }
127
+ else if (dataLo === dataHi) {
128
+ // Flat — give it room, so a constant line sits mid-row rather than on an
129
+ // edge (linear's ±1, expressed as a ratio: half a decade each way).
130
+ dataLo /= 10 ** 0.5;
131
+ dataHi *= 10 ** 0.5;
132
+ }
133
+ let lo = explicitLo ?? dataLo;
134
+ let hi = explicitHi ?? dataHi;
135
+ // A partial explicit bound can sit at or past the auto-fit other side. Keep
136
+ // the axis ascending by moving the *auto-fit* side — never discard the
137
+ // caller's explicit bound. Exactly one side is explicit here: both-explicit
138
+ // returned early, and a both-auto domain can't invert after the guards above.
139
+ if (lo >= hi) {
140
+ if (explicitLo === undefined)
141
+ lo = hi / 10; // hi is explicit → preserve it
142
+ else
143
+ hi = lo * 10; // lo is explicit → preserve it
144
+ }
145
+ // Fully auto-fit → round out to whole powers of ten, for headroom and so the
146
+ // decade ticks reach the domain bounds. A partial/full explicit bound is left
147
+ // exact, exactly as `resolveBase` leaves it.
148
+ if (explicitLo === undefined && explicitHi === undefined) {
149
+ return scaleLog().domain([lo, hi]).nice().domain();
150
+ }
151
+ return [lo, hi];
152
+ }
153
+ /**
154
+ * Does resolving this axis's domain need its layers' extents walked?
155
+ * `yExtent()` is O(points) per layer, so the caller only pays it when a side
156
+ * actually auto-fits.
157
+ *
158
+ * A log axis **refuses a non-positive bound** ({@link resolveLogDomain}), which
159
+ * means such a bound is not a bound: that side auto-fits and needs the data. The
160
+ * naive `min === undefined || max === undefined` test misses this, and the miss
161
+ * is silent — `<YAxis scale="log" min={0} max={1e6}>` looked fully explicit, so
162
+ * no extents were gathered, so the refused floor fell back to the empty-data
163
+ * placeholder instead of the data's own floor. (`resolveLogDomain`'s unit tests
164
+ * passed throughout: they hand it the extents directly, which is precisely what
165
+ * the component was not doing.)
166
+ */
167
+ export function needsExtents(axis) {
168
+ if (axis.scale === 'log') {
169
+ return (!(axis.min !== undefined && axis.min > 0) ||
170
+ !(axis.max !== undefined && axis.max > 0));
171
+ }
172
+ return axis.min === undefined || axis.max === undefined;
173
+ }
174
+ /**
175
+ * The dev-mode complaint a `scale="log"` axis has about its own bounds and the
176
+ * data linked to it, or `null` when it has none. Pure, so the policy is unit
177
+ * tested directly rather than through a rendered console spy.
178
+ *
179
+ * **Every case here is unambiguous**, which is the whole design constraint. The
180
+ * previous version warned whenever a linked extent reached zero, and that fires
181
+ * on *every* `BarChart` — `barExtent` always widens its low end to `0` so a bar
182
+ * can reach its baseline, whether or not the data goes anywhere near it. So the
183
+ * `WithBars` story warned, on strictly positive data, with text asserting
184
+ * something false about it. A dev warning that cries wolf gets muted, and then
185
+ * the real ones are lost too.
186
+ *
187
+ * The cost of that precision is the one genuinely ambiguous shape: an extent of
188
+ * exactly `[0, hi]`, which is what a line touching zero *and* a bar layer on
189
+ * positive data both report. It is not warned about. That case is no longer
190
+ * silent, though — a sample with no position on the axis now renders as a
191
+ * **gap** rather than being bridged straight over, so the picture itself says
192
+ * the data is missing there.
193
+ */
194
+ export function logAxisWarning(axis, extents) {
195
+ if (axis.scale !== 'log')
196
+ return null;
197
+ const reasons = [];
198
+ // `!(x > 0)` rather than `x <= 0` so a NaN bound is caught too — it is refused
199
+ // by the same rule and is just as invisible.
200
+ if (axis.min !== undefined && !(axis.min > 0)) {
201
+ reasons.push(`min={${axis.min}} was ignored (it is not a positive number)`);
202
+ }
203
+ if (axis.max !== undefined && !(axis.max > 0)) {
204
+ reasons.push(`max={${axis.max}} was ignored (it is not a positive number)`);
205
+ }
206
+ const present = extents.filter((e) => e !== null);
207
+ if (present.some((e) => e[0] < 0)) {
208
+ reasons.push('data linked to this axis includes negative values');
209
+ }
210
+ // Independent of the above, not an `else`: an axis whose data is *entirely*
211
+ // non-positive is both negative-valued and undrawable, and the second fact is
212
+ // the one that explains the empty plot.
213
+ if (present.length > 0 && !present.some((e) => e[1] > 0)) {
214
+ // There is data, and none of it is positive — the whole axis is a fallback
215
+ // domain and nothing will be drawn against it.
216
+ reasons.push(`no data linked to this axis is positive, so the domain fell back to ` +
217
+ `[${LOG_EMPTY_LO}, ${LOG_EMPTY_HI}]`);
218
+ }
219
+ if (reasons.length === 0)
220
+ return null;
221
+ return (`<YAxis id="${axis.id}" scale="log">: ${reasons.join('; ')}. ` +
222
+ 'A log scale has no position for zero or negative numbers (d3 maps them ' +
223
+ 'to NaN), so such values are refused as bounds and are not drawn. Filter ' +
224
+ 'them out, or use the default linear scale.');
225
+ }
33
226
  function resolveBase(min, max, extents) {
34
227
  // Both bounds explicit: trust them verbatim (allows an intentional flip).
35
228
  if (min !== undefined && max !== undefined)
package/dist/format.d.ts CHANGED
@@ -45,6 +45,9 @@ export type CursorFormat = string | ((value: number, ctx: {
45
45
  * optional specifier. A d3 `ScaleLinear` / `ScaleTime` satisfies it. */
46
46
  interface Tickable {
47
47
  tickFormat(count: number, specifier?: string): (value: number) => string;
48
+ /** Present on d3's `scaleLog` and on no other continuous scale. */
49
+ base?: () => number;
50
+ domain?: () => number[];
48
51
  }
49
52
  /**
50
53
  * Resolve an {@link AxisFormat} (or `undefined`) to a `(value) => string`
@@ -55,6 +58,23 @@ interface Tickable {
55
58
  * - a **specifier string** → `scale.tickFormat(count, specifier)` — d3 applies
56
59
  * the specifier, so the readout matches ticks formatted the same way;
57
60
  * - **`undefined`** → `scale.tickFormat(count)` — the scale's default.
61
+ *
62
+ * **A log scale is formatted through a linear one over the same domain.**
63
+ * `scaleLog.tickFormat` is not a value formatter — it is a *tick* formatter,
64
+ * and it deliberately returns `''` for anything it doesn't consider a
65
+ * significant tick:
66
+ *
67
+ * ```
68
+ * scaleLog().domain([1.9e10, 2.6e17]).tickFormat(6, '.3s')(1.97e17) === ''
69
+ * ```
70
+ *
71
+ * That is correct for axis labels (it is how a log axis thins them) and
72
+ * catastrophic for the cursor readout, `YAxisIndicator` and `Baseline` chips,
73
+ * which share this formatter and ask it about arbitrary values — they would
74
+ * render blank for almost every real number. A linear scale's `tickFormat`
75
+ * applies the specifier to whatever it is handed, which is what every consumer
76
+ * of this function actually wants; the axis's own tick *thinning* is handled by
77
+ * `yTickValues`, not here.
58
78
  */
59
79
  export declare function resolveAxisFormat(scale: Tickable, count: number, format: AxisFormat | undefined): (value: number) => string;
60
80
  /** The slice of a d3 **time** scale {@link resolveTimeFormat} needs. A d3
package/dist/format.js CHANGED
@@ -1,11 +1,4 @@
1
- /**
2
- * Axis value formatting — the single formatter shared by an axis's tick labels
3
- * and the cursor readout, so a value reads the same in both places (the readout
4
- * "matches the axes"). d3-style: a format **specifier string** (e.g. `'.0%'`,
5
- * `',.2f'`) is applied through the scale's own `tickFormat` (the same path d3
6
- * uses for the ticks), a **function** is used verbatim, and `undefined` falls
7
- * back to the scale's default `tickFormat`.
8
- */
1
+ import { scaleLinear } from 'd3-scale';
9
2
  /**
10
3
  * Resolve an {@link AxisFormat} (or `undefined`) to a `(value) => string`
11
4
  * formatter, given the `scale` it formats against and the axis `count` (so the
@@ -15,13 +8,33 @@
15
8
  * - a **specifier string** → `scale.tickFormat(count, specifier)` — d3 applies
16
9
  * the specifier, so the readout matches ticks formatted the same way;
17
10
  * - **`undefined`** → `scale.tickFormat(count)` — the scale's default.
11
+ *
12
+ * **A log scale is formatted through a linear one over the same domain.**
13
+ * `scaleLog.tickFormat` is not a value formatter — it is a *tick* formatter,
14
+ * and it deliberately returns `''` for anything it doesn't consider a
15
+ * significant tick:
16
+ *
17
+ * ```
18
+ * scaleLog().domain([1.9e10, 2.6e17]).tickFormat(6, '.3s')(1.97e17) === ''
19
+ * ```
20
+ *
21
+ * That is correct for axis labels (it is how a log axis thins them) and
22
+ * catastrophic for the cursor readout, `YAxisIndicator` and `Baseline` chips,
23
+ * which share this formatter and ask it about arbitrary values — they would
24
+ * render blank for almost every real number. A linear scale's `tickFormat`
25
+ * applies the specifier to whatever it is handed, which is what every consumer
26
+ * of this function actually wants; the axis's own tick *thinning* is handled by
27
+ * `yTickValues`, not here.
18
28
  */
19
29
  export function resolveAxisFormat(scale, count, format) {
20
30
  if (typeof format === 'function')
21
31
  return format;
32
+ const source = typeof scale.base === 'function' && typeof scale.domain === 'function'
33
+ ? scaleLinear().domain(scale.domain())
34
+ : scale;
22
35
  return format !== undefined
23
- ? scale.tickFormat(count, format)
24
- : scale.tickFormat(count);
36
+ ? source.tickFormat(count, format)
37
+ : source.tickFormat(count);
25
38
  }
26
39
  /**
27
40
  * The time analog of {@link resolveAxisFormat} — resolve an {@link AxisFormat}
package/dist/gaps.d.ts CHANGED
@@ -72,6 +72,39 @@ export interface GapEdge {
72
72
  /** Pixel y of the next-good sample (`toIndex`). */
73
73
  readonly toY: number;
74
74
  }
75
+ /**
76
+ * Return `values` with every entry that has **no position on `scale`** replaced
77
+ * by `NaN` — i.e. converted into the gap signal the rest of this module, the
78
+ * `.defined` predicates, and the affine draw loops already speak. Returns the
79
+ * input array itself (no copy) when nothing needs gapping, which is the
80
+ * overwhelmingly common case.
81
+ *
82
+ * **Why the raw value is not the right test.** Every gap check in this package
83
+ * asks `Number.isFinite(value)`, and `0` is finite — so on a **log** axis a zero
84
+ * sample sailed through as real data, d3 emitted `lineTo(x, NaN)` for it, and
85
+ * the canvas spec says a path op with a non-finite coordinate is *dropped*. Not
86
+ * drawn as a break: dropped. The pen stays where it was, the next point draws
87
+ * from there, and the two neighbours of the missing sample join with a straight
88
+ * line — a bridge over absent data, which is the one thing
89
+ * `docs/rfcs/charts.md` trap #2 exists to prevent, arrived at silently by a
90
+ * chart that had asked for the honest `'empty'` mode.
91
+ *
92
+ * Testing the **scaled** coordinate instead is both the honest answer and the
93
+ * general one: it needs no knowledge of which scale kind is in play, and any
94
+ * future scale with a restricted domain (`sqrt`, `pow` with a fractional
95
+ * exponent) inherits it. Normalizing to `NaN` here rather than special-casing
96
+ * three `.defined` predicates means the gap *modes* keep working too — a
97
+ * `'dashed'` connector spans it, `'none'` interpolates across it, `'fade'`
98
+ * drops to the floor at it — instead of the value being invisible to
99
+ * {@link collectGapEdges} and {@link bridgeGaps}, which read the values directly.
100
+ *
101
+ * **Costs nothing on a linear axis.** An affine scale ({@link affineOf}) maps
102
+ * every finite value to a finite pixel by construction, so there is nothing to
103
+ * find and the walk is skipped outright. That is also exactly the condition
104
+ * under which the callers take their affine fast path, so the O(N) probe only
105
+ * ever runs where the draw was already on the d3-scale path.
106
+ */
107
+ export declare function gapUnscalable(values: Float64Array, length: number, scale: Scale): Float64Array;
75
108
  /**
76
109
  * Return a copy of `values` with **interior** gaps (NaN runs that have a finite
77
110
  * sample on each side) linearly interpolated across, for the `none` mode — so d3
package/dist/gaps.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { affineOf } from './affine.js';
1
2
  /** The default gap mode — break at the gap, leave a hole (today's behavior). */
2
3
  export const DEFAULT_GAP_MODE = 'empty';
3
4
  /**
@@ -6,6 +7,54 @@ export const DEFAULT_GAP_MODE = 'empty';
6
7
  * inferred bridge reads as secondary to measured data.
7
8
  */
8
9
  export const DEFAULT_GAP_CONNECTOR_OPACITY = 0.5;
10
+ /**
11
+ * Return `values` with every entry that has **no position on `scale`** replaced
12
+ * by `NaN` — i.e. converted into the gap signal the rest of this module, the
13
+ * `.defined` predicates, and the affine draw loops already speak. Returns the
14
+ * input array itself (no copy) when nothing needs gapping, which is the
15
+ * overwhelmingly common case.
16
+ *
17
+ * **Why the raw value is not the right test.** Every gap check in this package
18
+ * asks `Number.isFinite(value)`, and `0` is finite — so on a **log** axis a zero
19
+ * sample sailed through as real data, d3 emitted `lineTo(x, NaN)` for it, and
20
+ * the canvas spec says a path op with a non-finite coordinate is *dropped*. Not
21
+ * drawn as a break: dropped. The pen stays where it was, the next point draws
22
+ * from there, and the two neighbours of the missing sample join with a straight
23
+ * line — a bridge over absent data, which is the one thing
24
+ * `docs/rfcs/charts.md` trap #2 exists to prevent, arrived at silently by a
25
+ * chart that had asked for the honest `'empty'` mode.
26
+ *
27
+ * Testing the **scaled** coordinate instead is both the honest answer and the
28
+ * general one: it needs no knowledge of which scale kind is in play, and any
29
+ * future scale with a restricted domain (`sqrt`, `pow` with a fractional
30
+ * exponent) inherits it. Normalizing to `NaN` here rather than special-casing
31
+ * three `.defined` predicates means the gap *modes* keep working too — a
32
+ * `'dashed'` connector spans it, `'none'` interpolates across it, `'fade'`
33
+ * drops to the floor at it — instead of the value being invisible to
34
+ * {@link collectGapEdges} and {@link bridgeGaps}, which read the values directly.
35
+ *
36
+ * **Costs nothing on a linear axis.** An affine scale ({@link affineOf}) maps
37
+ * every finite value to a finite pixel by construction, so there is nothing to
38
+ * find and the walk is skipped outright. That is also exactly the condition
39
+ * under which the callers take their affine fast path, so the O(N) probe only
40
+ * ever runs where the draw was already on the d3-scale path.
41
+ */
42
+ export function gapUnscalable(values, length, scale) {
43
+ if (affineOf(scale) !== null)
44
+ return values;
45
+ let out = null;
46
+ for (let i = 0; i < length; i += 1) {
47
+ const v = values[i];
48
+ if (!Number.isFinite(v) || Number.isFinite(scale(v)))
49
+ continue;
50
+ // Copy lazily — a log axis whose data never touches zero (the normal case)
51
+ // pays a scan and no allocation.
52
+ if (out === null)
53
+ out = values.slice();
54
+ out[i] = NaN;
55
+ }
56
+ return out ?? values;
57
+ }
9
58
  /**
10
59
  * Return a copy of `values` with **interior** gaps (NaN runs that have a finite
11
60
  * sample on each side) linearly interpolated across, for the `none` mode — so d3
package/dist/line.js CHANGED
@@ -2,7 +2,7 @@ import { line as d3line, curveLinear } from 'd3-shape';
2
2
  import { cullChartSeries } from './culling.js';
3
3
  import { decimateM4Cached } from './decimate.js';
4
4
  import { affineOf } from './affine.js';
5
- import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
5
+ import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
6
6
  /** Shared empty boundary list — passed to `sessionRuns` when a decimated series
7
7
  * already carries its session breaks as baked-in `NaN` points. */
8
8
  const EMPTY_BOUNDARIES = [];
@@ -131,6 +131,15 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
131
131
  else {
132
132
  cs = cullChartSeries(source, xScale);
133
133
  }
134
+ // Normalize any value with **no position on the y scale** (a zero or negative
135
+ // sample on a log axis) into the ordinary NaN gap signal, so every consumer
136
+ // below — the `.defined` break, `bridgeGaps`, `collectGapEdges` — treats it as
137
+ // the absence it is instead of emitting a dropped `lineTo(x, NaN)` that
138
+ // silently bridges its neighbours. A no-op on an affine (linear) y scale; see
139
+ // {@link gapUnscalable}.
140
+ const scaledY = gapUnscalable(cs.y, cs.length, yScale);
141
+ if (scaledY !== cs.y)
142
+ cs = { ...cs, y: scaledY };
134
143
  // Split into independent index runs at each boundary; no boundary inside the
135
144
  // data ⇒ one run over the whole series (the hot path — no slicing, so the draw
136
145
  // is byte-identical to the pre-boundary single pass). When the series was
package/dist/theme.d.ts CHANGED
@@ -315,6 +315,18 @@ export interface AreaStyle {
315
315
  readonly width: number;
316
316
  readonly fill: string;
317
317
  readonly fillOpacity: number;
318
+ /**
319
+ * Fill flat instead of grading to transparent at the baseline. Default
320
+ * (omitted / `false`) keeps the gradient — the elevation look a single area
321
+ * wants.
322
+ *
323
+ * Set it for **stacked** areas. A stack is drawn as overlapping cumulative
324
+ * bands, so a fade to transparent at the baseline lets every band below show
325
+ * through the one above it and the composition reads as mush. A flat fill is
326
+ * what makes the slabs opaque to each other. (`fillOpacity` still applies, so
327
+ * a stack can be uniformly translucent — just not *graded*.)
328
+ */
329
+ readonly flatFill?: boolean;
318
330
  }
319
331
  /**
320
332
  * A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
@@ -26,7 +26,9 @@ export type TimeRange = readonly [number, number];
26
26
  export declare function clampToBounds(range: TimeRange, bounds: TimeRange): [number, number];
27
27
  /**
28
28
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
29
- * dragging the plot right reveals earlier data, i.e. a negative `dt`.
29
+ * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
30
+ * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
31
+ * delta through `xScale.invert()`, so it is fractional by construction.
30
32
  */
31
33
  export declare function panRange(range: TimeRange, dt: number): [number, number];
32
34
  /**
@@ -34,6 +36,12 @@ export declare function panRange(range: TimeRange, dt: number): [number, number]
34
36
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
35
37
  * duration never drops below `minDuration` (the zoom-in floor); at the floor the
36
38
  * pivot keeps its fractional position in the window.
39
+ *
40
+ * The result is snapped to whole milliseconds ({@link roundRange}). `minDuration`
41
+ * is applied **before** the snap, so the floor is honoured in the units the
42
+ * caller expressed it in; a `minDuration` below 1 ms cannot be represented and
43
+ * lands on the 1 ms floor the snap guarantees, which is the finest view this
44
+ * model has.
37
45
  */
38
46
  export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number): [number, number];
39
47
  /**
package/dist/viewport.js CHANGED
@@ -36,28 +36,64 @@ export function clampToBounds(range, bounds) {
36
36
  return [hi - span, hi];
37
37
  return [range[0], range[1]];
38
38
  }
39
+ /**
40
+ * Snap a computed view range to **whole milliseconds** — the last step of every
41
+ * gesture that derives a range from pixels.
42
+ *
43
+ * A wheel-zoom or drag-pan turns a pixel position into a time via
44
+ * `xScale.invert()`, so the result is fractional *by construction*: an ordinary
45
+ * scroll produces `1.7e12 + 0.37`. The epoch millisecond is this model's atomic
46
+ * unit — a sub-millisecond view range is not a finer view, it is a number with
47
+ * no meaning — and downstream consumers are entitled to assume it. One of them
48
+ * did: `Temporal.Instant` refuses a non-integer epoch ms outright, so a
49
+ * `cursorSequence` over a calendar grain threw on a plain scroll and unmounted
50
+ * the page. Core now floors the instant, which fixes that symptom; rounding
51
+ * here closes the class, because nothing downstream ever sees the fraction.
52
+ *
53
+ * **Never collapses a positive span.** `[10.4, 10.6]` would otherwise round to
54
+ * `[10, 10]` — a zero-width view, which is a division by zero in every scale
55
+ * built from it. A span that survives rounding keeps its rounded width; one
56
+ * that doesn't is opened to the 1 ms floor. A range that arrives degenerate
57
+ * (`hi <= lo`) is passed through rounded, since widening it would invent a view
58
+ * the caller didn't ask for.
59
+ */
60
+ function roundRange(lo, hi) {
61
+ const a = Math.round(lo);
62
+ const b = Math.round(hi);
63
+ // `Math.round` is monotonic, so `b < a` is impossible for `hi >= lo`; the only
64
+ // way a positive span collapses is both ends landing on the same integer.
65
+ return b === a && hi > lo ? [a, a + 1] : [a, b];
66
+ }
39
67
  /**
40
68
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
41
- * dragging the plot right reveals earlier data, i.e. a negative `dt`.
69
+ * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
70
+ * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
71
+ * delta through `xScale.invert()`, so it is fractional by construction.
42
72
  */
43
73
  export function panRange(range, dt) {
44
- return [range[0] + dt, range[1] + dt];
74
+ return roundRange(range[0] + dt, range[1] + dt);
45
75
  }
46
76
  /**
47
77
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
48
78
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
49
79
  * duration never drops below `minDuration` (the zoom-in floor); at the floor the
50
80
  * pivot keeps its fractional position in the window.
81
+ *
82
+ * The result is snapped to whole milliseconds ({@link roundRange}). `minDuration`
83
+ * is applied **before** the snap, so the floor is honoured in the units the
84
+ * caller expressed it in; a `minDuration` below 1 ms cannot be represented and
85
+ * lands on the 1 ms floor the snap guarantees, which is the finest view this
86
+ * model has.
51
87
  */
52
88
  export function zoomRange(range, pivot, factor, minDuration = 1) {
53
89
  const lo = pivot - (pivot - range[0]) * factor;
54
90
  const hi = pivot + (range[1] - pivot) * factor;
55
91
  if (hi - lo >= minDuration)
56
- return [lo, hi];
92
+ return roundRange(lo, hi);
57
93
  // Floor reached: hold the pivot's fractional position, set span = minDuration.
58
94
  const span = range[1] - range[0];
59
95
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
60
- return [pivot - minDuration * frac, pivot + minDuration * (1 - frac)];
96
+ return roundRange(pivot - minDuration * frac, pivot + minDuration * (1 - frac));
61
97
  }
62
98
  /**
63
99
  * Pan a range on a **trading-time** axis: shift both endpoints by the same