@pond-ts/charts 0.55.0 → 0.57.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/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/index.d.ts CHANGED
@@ -64,8 +64,8 @@ export { scaleTradingTime } from './tradingTimeScale.js';
64
64
  export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
65
65
  export { scaleBand } from './bandScale.js';
66
66
  export type { ScaleBand } from './bandScale.js';
67
- export { Region, Baseline, Marker } from './annotations.js';
68
- export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
67
+ export { Region, Baseline, Marker, Zone } from './annotations.js';
68
+ export type { RegionProps, BaselineProps, MarkerProps, ZoneProps, } from './annotations.js';
69
69
  export type { AnnotationKind, CreateSpec } from './context.js';
70
70
  export { YAxisIndicator, createLiveValue } from './indicators.js';
71
71
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
package/dist/index.js CHANGED
@@ -51,8 +51,9 @@ export { scaleTradingTime } from './tradingTimeScale.js';
51
51
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
52
52
  export { scaleBand } from './bandScale.js';
53
53
  // Annotations — user-authored marks in the turquoise register (distinct from the
54
- // data): a shaded span, a horizontal value line, a vertical x line.
55
- export { Region, Baseline, Marker } from './annotations.js';
54
+ // data): a shaded x span, a horizontal value line, a vertical x line, and a
55
+ // shaded y span (`<Zone>` value-axis classifications: AQI categories, HR zones).
56
+ export { Region, Baseline, Marker, Zone } from './annotations.js';
56
57
  // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
57
58
  // `createLiveValue` is the high-frequency, isolated-repaint update path.
58
59
  export { YAxisIndicator, createLiveValue } from './indicators.js';
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
@@ -179,20 +179,36 @@ export interface ChartTheme {
179
179
  readonly color: string;
180
180
  readonly fillOpacity: number;
181
181
  readonly depth: readonly [number, number, number];
182
+ /**
183
+ * Optional dash pattern for the register's **lines** — px on/off lengths,
184
+ * the same shape as {@link LineStyle.dash} (`[6, 4]` = 6 on, 4 off). Omit or
185
+ * `[]` for solid strokes. Applies to a marker's / baseline's line and to
186
+ * region + zone boundaries; fills are never dashed.
187
+ *
188
+ * Worth reaching for when marks share a plot with data lines: a *dashed*
189
+ * reference line reads as placed rather than measured, doing the "this isn't
190
+ * data" work that colour alone can't when the register hue is near a series
191
+ * hue. Set it per {@link roles | role} to dash one kind of mark only.
192
+ */
193
+ readonly dash?: readonly number[];
182
194
  /**
183
195
  * **Optional per-role overrides** — a small map from a role name to its
184
- * `color` (and optionally `fillOpacity`), so distinct marks can be styled
185
- * at once without splitting the whole register: a `<Baseline role="atm">`
186
- * green, a `<Marker role="ref">` in another hue, each still drawn through
196
+ * `color` (and optionally `fillOpacity` / `dash`), so distinct marks can be
197
+ * styled at once without splitting the whole register: a `<Baseline
198
+ * role="atm">` green, a `<Marker role="ref">` in another hue, a `<Zone
199
+ * role="good">` per band of a value-axis scale — each still drawn through
187
200
  * the shared {@link depth} ramp. A mark's `role` resolves
188
- * `roles[role] ?? { color, fillOpacity }` (an unknown/unset role is the
189
- * base register). Colour stays a **theme** concern — there is no per-mark
190
- * colour prop (the one-styling-channel discipline).
201
+ * `roles[role] ?? { color, fillOpacity, dash }` (an unknown/unset role is
202
+ * the base register). Colour stays a **theme** concern — there is no
203
+ * per-mark colour prop (the one-styling-channel discipline), which is why a
204
+ * *scale* of bands (AQI categories, HR zones) is a role map and not six
205
+ * colours at the call site.
191
206
  */
192
207
  readonly roles?: {
193
208
  readonly [role: string]: {
194
209
  readonly color: string;
195
210
  readonly fillOpacity?: number;
211
+ readonly dash?: readonly number[];
196
212
  };
197
213
  };
198
214
  };
@@ -315,6 +331,18 @@ export interface AreaStyle {
315
331
  readonly width: number;
316
332
  readonly fill: string;
317
333
  readonly fillOpacity: number;
334
+ /**
335
+ * Fill flat instead of grading to transparent at the baseline. Default
336
+ * (omitted / `false`) keeps the gradient — the elevation look a single area
337
+ * wants.
338
+ *
339
+ * Set it for **stacked** areas. A stack is drawn as overlapping cumulative
340
+ * bands, so a fade to transparent at the baseline lets every band below show
341
+ * through the one above it and the composition reads as mush. A flat fill is
342
+ * what makes the slabs opaque to each other. (`fillOpacity` still applies, so
343
+ * a stack can be uniformly translucent — just not *graded*.)
344
+ */
345
+ readonly flatFill?: boolean;
318
346
  }
319
347
  /**
320
348
  * A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
@@ -360,6 +388,45 @@ export interface BarStyle {
360
388
  * already did for `highlight`.
361
389
  */
362
390
  readonly hover?: string;
391
+ /**
392
+ * The **threshold-band ladder** — ordered fills for a bar coloured *along its
393
+ * length* against `<BarChart thresholds>`: `bands[0]` up to the first
394
+ * threshold, `bands[1]` between the first and second, and so on. A ladder of
395
+ * `n` thresholds reads `n + 1` entries.
396
+ *
397
+ * This lives on `BarStyle` rather than as a `theme.bar.bands` sibling because
398
+ * `theme.bar` is a semantic **map** (`{ default, [semantic]: BarStyle }`) — a
399
+ * top-level key would collide with a role of that name. Per-role is also the
400
+ * more useful shape: `bar.default.bands` and `bar.capacity.bands` can differ,
401
+ * and the ladder resolves through the same `bar[semantic] ?? bar.default`
402
+ * lookup as every other bar colour.
403
+ *
404
+ * **Overridden by `<BarChart bandColors>`** at the call site. If neither
405
+ * resolves enough entries for the ladder, the bar falls back to its flat
406
+ * {@link fill} and (in dev) warns — a silently-unbanded bar is exactly the
407
+ * failure mode [PND-BANDBAR2] exists to remove.
408
+ *
409
+ * Read by the single-series `drawBars` path and by the `G === 1` stacked path
410
+ * (which is where `categories` and every horizontal bar live). A genuine
411
+ * **multi-group stack** ignores it and warns: banding a segment that is
412
+ * already one slice of a total has no defined meaning.
413
+ */
414
+ readonly bands?: readonly string[];
415
+ /**
416
+ * Stroke for a **selected** bar's outline, where the default is the bar's own
417
+ * resolved fill. The one selection cue that still works when the fill cannot
418
+ * change — a `binColors` bar keeps its own colour by design, so without this
419
+ * the alpha pop was the whole signal and nothing about it was themeable
420
+ * ([PND-CATEMPH]).
421
+ */
422
+ readonly selectedOutline?: string;
423
+ /**
424
+ * The alpha a hovered / selected bar pops to. **Default `1`** (the shipped
425
+ * behaviour). Previously the pop was hard-coded, so a theme could set the
426
+ * resting {@link opacity} floor but not the *difference* between resting and
427
+ * live — which is the part that reads as emphasis.
428
+ */
429
+ readonly emphasisOpacity?: number;
363
430
  }
364
431
  /**
365
432
  * The neutral default theme. `default` / `primary` match the M1 `LineChart`
package/dist/theme.js CHANGED
@@ -109,6 +109,11 @@ export const defaultTheme = {
109
109
  gap: 1,
110
110
  minWidth: 1,
111
111
  outlineWidth: 1.5,
112
+ // The default threshold ladder: the bar's own blue as the in-range band,
113
+ // then amber, then red. Three entries serves the common two-threshold
114
+ // ok/warning/alarm ladder out of the box; a longer `thresholds` needs a
115
+ // longer ladder from the theme or `bandColors`.
116
+ bands: ['#2563eb', '#e8a13c', '#d64545'],
112
117
  },
113
118
  secondary: {
114
119
  fill: '#e8836b',
@@ -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
  /**