@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/CHANGELOG.md +288 -1
- package/dist/AreaChart.d.ts +18 -0
- package/dist/AreaChart.js +24 -1
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +113 -12
- package/dist/ChartContainer.d.ts +44 -1
- package/dist/ChartContainer.js +18 -2
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +14 -1
- package/dist/YAxis.d.ts +56 -1
- package/dist/YAxis.js +28 -3
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +139 -4
- package/dist/bars.js +300 -33
- package/dist/context.d.ts +30 -5
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/line.js +10 -1
- package/dist/theme.d.ts +73 -6
- package/dist/theme.js +5 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- package/package.json +3 -3
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
|
-
?
|
|
24
|
-
:
|
|
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
|
-
|
|
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
|
|
185
|
-
* at once without splitting the whole register: a `<Baseline
|
|
186
|
-
* green, a `<Marker role="ref">` in another hue,
|
|
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
|
|
189
|
-
* base register). Colour stays a **theme** concern — there is no
|
|
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',
|
package/dist/viewport.d.ts
CHANGED
|
@@ -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
|
/**
|