inkplot 0.0.0-stage → 1.0.0-beta.1

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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/dist/adapt.d.ts +10 -0
  4. package/dist/adapt.js +34 -0
  5. package/dist/annotation.d.ts +120 -0
  6. package/dist/annotation.js +267 -0
  7. package/dist/aria.d.ts +24 -0
  8. package/dist/aria.js +31 -0
  9. package/dist/attrs.d.ts +81 -0
  10. package/dist/attrs.js +84 -0
  11. package/dist/axis.d.ts +137 -0
  12. package/dist/axis.js +185 -0
  13. package/dist/bars.d.ts +80 -0
  14. package/dist/bars.js +255 -0
  15. package/dist/boxplot.d.ts +57 -0
  16. package/dist/boxplot.js +177 -0
  17. package/dist/boxzoom.d.ts +39 -0
  18. package/dist/boxzoom.js +138 -0
  19. package/dist/brush.d.ts +60 -0
  20. package/dist/brush.js +185 -0
  21. package/dist/bucket.d.ts +22 -0
  22. package/dist/bucket.js +62 -0
  23. package/dist/callout.d.ts +30 -0
  24. package/dist/callout.js +98 -0
  25. package/dist/chart.d.ts +126 -0
  26. package/dist/chart.js +257 -0
  27. package/dist/crosshair.d.ts +28 -0
  28. package/dist/crosshair.js +78 -0
  29. package/dist/cursor.d.ts +76 -0
  30. package/dist/cursor.js +344 -0
  31. package/dist/curve.d.ts +50 -0
  32. package/dist/curve.js +87 -0
  33. package/dist/curves.d.ts +104 -0
  34. package/dist/curves.js +375 -0
  35. package/dist/data.d.ts +209 -0
  36. package/dist/data.js +276 -0
  37. package/dist/decimation.d.ts +86 -0
  38. package/dist/decimation.js +410 -0
  39. package/dist/density.d.ts +55 -0
  40. package/dist/density.js +252 -0
  41. package/dist/dimension.d.ts +103 -0
  42. package/dist/dimension.js +205 -0
  43. package/dist/dom-platform.d.ts +53 -0
  44. package/dist/dom-platform.js +190 -0
  45. package/dist/element.d.ts +232 -0
  46. package/dist/element.js +414 -0
  47. package/dist/errors.d.ts +85 -0
  48. package/dist/errors.js +237 -0
  49. package/dist/events.d.ts +144 -0
  50. package/dist/events.js +19 -0
  51. package/dist/focus.d.ts +22 -0
  52. package/dist/focus.js +51 -0
  53. package/dist/furniture.d.ts +70 -0
  54. package/dist/furniture.js +166 -0
  55. package/dist/geometry.d.ts +40 -0
  56. package/dist/geometry.js +7 -0
  57. package/dist/heatmap.d.ts +57 -0
  58. package/dist/heatmap.js +207 -0
  59. package/dist/hit.d.ts +103 -0
  60. package/dist/hit.js +428 -0
  61. package/dist/index.d.ts +73 -0
  62. package/dist/index.js +73 -0
  63. package/dist/input.d.ts +42 -0
  64. package/dist/input.js +166 -0
  65. package/dist/jsx-dev-runtime.d.ts +9 -0
  66. package/dist/jsx-dev-runtime.js +13 -0
  67. package/dist/jsx-runtime.d.ts +59 -0
  68. package/dist/jsx-runtime.js +81 -0
  69. package/dist/key.d.ts +118 -0
  70. package/dist/key.js +199 -0
  71. package/dist/legend.d.ts +36 -0
  72. package/dist/legend.js +220 -0
  73. package/dist/mapped.d.ts +80 -0
  74. package/dist/mapped.js +106 -0
  75. package/dist/marker.d.ts +143 -0
  76. package/dist/marker.js +213 -0
  77. package/dist/panzoom.d.ts +119 -0
  78. package/dist/panzoom.js +467 -0
  79. package/dist/plane.d.ts +173 -0
  80. package/dist/plane.js +370 -0
  81. package/dist/platform.d.ts +113 -0
  82. package/dist/platform.js +80 -0
  83. package/dist/png.d.ts +44 -0
  84. package/dist/png.js +71 -0
  85. package/dist/pointer.d.ts +54 -0
  86. package/dist/pointer.js +132 -0
  87. package/dist/quantile.d.ts +3 -0
  88. package/dist/quantile.js +11 -0
  89. package/dist/radial.d.ts +116 -0
  90. package/dist/radial.js +336 -0
  91. package/dist/react.d.ts +441 -0
  92. package/dist/react.js +252 -0
  93. package/dist/registry.d.ts +2 -0
  94. package/dist/registry.js +51 -0
  95. package/dist/renderer.d.ts +191 -0
  96. package/dist/renderer.js +164 -0
  97. package/dist/scale.d.ts +37 -0
  98. package/dist/scale.js +19 -0
  99. package/dist/scales.d.ts +69 -0
  100. package/dist/scales.js +134 -0
  101. package/dist/serialize.d.ts +77 -0
  102. package/dist/serialize.js +321 -0
  103. package/dist/series.d.ts +304 -0
  104. package/dist/series.js +513 -0
  105. package/dist/simple.d.ts +35 -0
  106. package/dist/simple.js +20 -0
  107. package/dist/sources.d.ts +116 -0
  108. package/dist/sources.js +237 -0
  109. package/dist/stack.d.ts +61 -0
  110. package/dist/stack.js +172 -0
  111. package/dist/stats.d.ts +38 -0
  112. package/dist/stats.js +159 -0
  113. package/dist/steps.d.ts +50 -0
  114. package/dist/steps.js +154 -0
  115. package/dist/stub.d.ts +3 -0
  116. package/dist/stub.js +5 -0
  117. package/dist/style-names.d.ts +1 -0
  118. package/dist/style-names.js +6 -0
  119. package/dist/summary.d.ts +44 -0
  120. package/dist/summary.js +58 -0
  121. package/dist/surface.d.ts +94 -0
  122. package/dist/surface.js +145 -0
  123. package/dist/table.d.ts +39 -0
  124. package/dist/table.js +37 -0
  125. package/dist/text.d.ts +23 -0
  126. package/dist/text.js +44 -0
  127. package/dist/theme.d.ts +174 -0
  128. package/dist/theme.js +73 -0
  129. package/dist/themes.d.ts +9 -0
  130. package/dist/themes.js +56 -0
  131. package/dist/time-intl.d.ts +194 -0
  132. package/dist/time-intl.js +144 -0
  133. package/dist/time-temporal.d.ts +236 -0
  134. package/dist/time-temporal.js +58 -0
  135. package/dist/time.d.ts +216 -0
  136. package/dist/time.js +154 -0
  137. package/dist/trend.d.ts +151 -0
  138. package/dist/trend.js +500 -0
  139. package/dist/web-components.d.ts +62 -0
  140. package/dist/web-components.js +264 -0
  141. package/package.json +53 -3
package/dist/trend.js ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Trendlines: a curve fitted to the data, which can be read anywhere along the domain.
3
+ *
4
+ * <DomainSeries>
5
+ * <LinearTrend><ObjectDataSource source={rows} domain="t" value="v" /></LinearTrend>
6
+ * <LinePath><LinearCurve /></LinePath>
7
+ * </DomainSeries>
8
+ *
9
+ * Trendline
10
+ * ├─ LinearTrend the least-squares line
11
+ * ├─ PolynomialTrend a polynomial of a given `degree`
12
+ * ├─ SinusoidalTrend a constant plus a sine wave: of a given `period`, or the one that fits best
13
+ * └─ LoessTrend local linear regression, as smooth as `smoothness` says: 0 joins the points, 1 is the line
14
+ *
15
+ * A trendline is a `DataTransform` that behaves like a `SyntheticDataSource`: the fit is made once for each change of
16
+ * its source, and the points it gives are samples of the fitted curve, made when drawn: one for each pixel of width across
17
+ * the part of the domain in view (or `count`, always, across the whole of it). So zooming in stays smooth, and the line
18
+ * reaches `extend` further than the data at each end (a fraction of the data's span; one number, or `[before, after]`),
19
+ * which is how a trendline is extrapolated. Default 0.
20
+ *
21
+ * It fits the source's value against its domain. `x` names a channel of the source to fit against instead, which is how a
22
+ * scatter (an `XYSeries`) gets a trendline: `<LinearTrend x="x">`. Its own points have the sampled positions as the domain
23
+ * and as the channel `domain`, which is what an `XYSeries` reads x from. A point with a gap in either column is left out of the fit.
24
+ *
25
+ * Read it from code too, outside any chart: `trend.evaluate(x)` is the fitted value at a number, or at every number of an
26
+ * array (a `Float64Array` back), and `trend.fit` says how good the fit is: the number of points, `r2`, `rmse`, and what the
27
+ * kind of fit knows (coefficients, period, bandwidth). `readTable(trend)` is the sampled curve as a table.
28
+ *
29
+ * To write another, extend `Trendline` and make a `TrendModel` from the points in `fitModel`.
30
+ */
31
+ /* oxlint-disable typescript/no-non-null-assertion -- numeric kernels over flat typed-array buffers (stride-addressed rows): every index is bounded by the row count, which `noUncheckedIndexedAccess` cannot see */
32
+ import { AUTO, NUMBER, STRING } from './attrs.js';
33
+ import { DataSource, DataTransform } from './data.js';
34
+ import { fail } from './element.js';
35
+ const DEFAULT_SAMPLES = 512;
36
+ const NONE = { min: Infinity, max: -Infinity };
37
+ /** The base of trendlines: reads the points, fits them once for each change, and samples the curve. */
38
+ export class Trendline extends DataTransform {
39
+ static attrTypes = { ...DataTransform.attrTypes, x: STRING, extend: AUTO, count: NUMBER };
40
+ #version = 0;
41
+ #cache = null;
42
+ #fit = null;
43
+ get source() { return this.child(0, DataSource, DataTransform); }
44
+ invalidate() {
45
+ this.#version++;
46
+ super.invalidate();
47
+ }
48
+ get version() { return this.#version; }
49
+ get channelNames() { return ['domain']; }
50
+ get pointCount() { return this.attrs.count ?? DEFAULT_SAMPLES; }
51
+ /** The points the fit is made from and the curve, made again only after a change. */
52
+ #state() {
53
+ if (this.#cache && this.#cache.version === this.#version)
54
+ return this.#cache;
55
+ const name = this.attrs.x, src = this.source;
56
+ const plan = src.plan({ width: 0, height: 0, domainRange: { min: -Infinity, max: Infinity }, channels: name ? [name] : [] });
57
+ const stride = name ? 3 : 2, buf = new Float64Array(plan.capacity * stride), { count } = plan.fill(buf);
58
+ const xc = name ? 2 : 0;
59
+ let xs = new Float64Array(count), ys = new Float64Array(count), n = 0;
60
+ for (let i = 0; i < count; i++) {
61
+ const x = buf[stride * i + xc], y = buf[stride * i + 1];
62
+ if (!Number.isFinite(x) || !Number.isFinite(y))
63
+ continue;
64
+ xs[n] = x;
65
+ ys[n] = y;
66
+ n++;
67
+ }
68
+ xs = xs.slice(0, n);
69
+ ys = ys.slice(0, n);
70
+ let sorted = true;
71
+ for (let i = 1; i < n; i++)
72
+ if (xs[i] < xs[i - 1]) {
73
+ sorted = false;
74
+ break;
75
+ }
76
+ if (!sorted) {
77
+ const order = Uint32Array.from({ length: n }, (_, i) => i).sort((p, q) => xs[p] - xs[q]);
78
+ xs = Float64Array.from(order, (i) => xs[i]);
79
+ ys = Float64Array.from(order, (i) => ys[i]);
80
+ }
81
+ const model = n > 0 ? this.fitModel(xs, ys) : null;
82
+ return (this.#cache = { version: this.#version, xs, ys, model });
83
+ }
84
+ /** How well the curve fits, or null when there are too few points to fit one. */
85
+ get fit() {
86
+ if (this.#fit && this.#fit.version === this.#version)
87
+ return this.#fit.value;
88
+ const { xs, ys, model } = this.#state();
89
+ let value = null;
90
+ if (model) {
91
+ const n = xs.length;
92
+ let mean = 0;
93
+ for (let i = 0; i < n; i++)
94
+ mean += ys[i];
95
+ mean /= n;
96
+ let sse = 0, sst = 0;
97
+ for (let i = 0; i < n; i++) {
98
+ const r = ys[i] - model.at(xs[i]);
99
+ sse += r * r;
100
+ sst += (ys[i] - mean) ** 2;
101
+ }
102
+ value = { n, r2: sst > 0 ? 1 - sse / sst : sse === 0 ? 1 : 0, rmse: Math.sqrt(sse / n), ...model.details };
103
+ }
104
+ this.#fit = { version: this.#version, value };
105
+ return value;
106
+ }
107
+ evaluate(x) {
108
+ const model = this.#state().model;
109
+ if (typeof x === 'number')
110
+ return model ? model.at(x) : NaN;
111
+ const out = new Float64Array(x.length);
112
+ for (let i = 0; i < out.length; i++)
113
+ out[i] = model ? model.at(x[i]) : NaN;
114
+ return out;
115
+ }
116
+ /** The span the line covers: the data's, and the extension at each end. */
117
+ #span() {
118
+ const { xs } = this.#state();
119
+ if (!xs.length)
120
+ return null;
121
+ const lo = xs[0], hi = xs[xs.length - 1], e = this.attrs.extend ?? 0;
122
+ const [before, after] = typeof e === 'number' ? [e, e] : e;
123
+ if (!(before >= 0 && after >= 0))
124
+ fail(`${this.constructor.name}: extend must be 0 or more (got ${String(e)})`);
125
+ return { min: lo - (hi - lo) * before, max: hi + (hi - lo) * after };
126
+ }
127
+ /** The samples for a plot: where they are and how many. */
128
+ #samples(context) {
129
+ const span = this.#span();
130
+ if (!span)
131
+ return new Float64Array(0);
132
+ const count = this.attrs.count;
133
+ const lo = count === undefined ? Math.max(span.min, context.domainRange.min) : span.min;
134
+ const hi = count === undefined ? Math.min(span.max, context.domainRange.max) : span.max;
135
+ if (!(lo <= hi))
136
+ return new Float64Array(0);
137
+ const n = count ?? (context.width > 0 ? Math.max(2, Math.ceil(context.width)) : DEFAULT_SAMPLES);
138
+ return Float64Array.from({ length: n }, (_, i) => (n === 1 ? lo : i === n - 1 ? hi : lo + ((hi - lo) * i) / (n - 1)));
139
+ }
140
+ domain() { return this.#span(); }
141
+ valueExtent() {
142
+ const ys = this.evaluate(this.#samples({ width: 0, height: 0, domainRange: { min: -Infinity, max: Infinity }, channels: [] }));
143
+ let min = Infinity, max = -Infinity;
144
+ for (const y of ys)
145
+ if (Number.isFinite(y)) {
146
+ if (y < min)
147
+ min = y;
148
+ if (y > max)
149
+ max = y;
150
+ }
151
+ return min <= max ? { min, max } : null;
152
+ }
153
+ channelExtent(name) {
154
+ if (name !== 'domain')
155
+ fail(`${this.constructor.name} has no channel "${name}" (it has domain)`);
156
+ return this.domain();
157
+ }
158
+ plan(context) {
159
+ for (const c of context.channels)
160
+ if (c !== 'domain')
161
+ fail(`${this.constructor.name} has no channel "${c}" (it has domain)`);
162
+ const xs = this.#samples(context), n = xs.length, stride = 2 + context.channels.length;
163
+ return {
164
+ capacity: n,
165
+ fill: (out) => {
166
+ const ys = this.evaluate(xs);
167
+ let min = Infinity, max = -Infinity;
168
+ for (let i = 0; i < n; i++) {
169
+ out[stride * i] = xs[i];
170
+ out[stride * i + 1] = ys[i];
171
+ for (let j = 0; j < context.channels.length; j++)
172
+ out[stride * i + 2 + j] = xs[i];
173
+ if (ys[i] < min)
174
+ min = ys[i];
175
+ if (ys[i] > max)
176
+ max = ys[i];
177
+ }
178
+ const x = n ? { min: xs[0], max: xs[n - 1] } : NONE;
179
+ return { count: n, extents: [x, min <= max ? { min, max } : NONE, ...context.channels.map(() => x)] };
180
+ },
181
+ };
182
+ }
183
+ }
184
+ /** The least-squares line. `fit.coefficients` is `[intercept, slope]`. */
185
+ export class LinearTrend extends Trendline {
186
+ fitModel(xs, ys) { return leastSquares(xs, ys); }
187
+ }
188
+ /** The least-squares line through the points; null when they share one position. */
189
+ function leastSquares(xs, ys) {
190
+ const n = xs.length;
191
+ let mx = 0, my = 0;
192
+ for (let i = 0; i < n; i++) {
193
+ mx += xs[i];
194
+ my += ys[i];
195
+ }
196
+ mx /= n;
197
+ my /= n;
198
+ let sxx = 0, sxy = 0;
199
+ for (let i = 0; i < n; i++) {
200
+ const dx = xs[i] - mx;
201
+ sxx += dx * dx;
202
+ sxy += dx * (ys[i] - my);
203
+ }
204
+ if (!(sxx > 0))
205
+ return null;
206
+ const slope = sxy / sxx, intercept = my - slope * mx;
207
+ return { at: (x) => intercept + slope * x, details: { coefficients: [intercept, slope] } };
208
+ }
209
+ const MAX_DEGREE = 12;
210
+ /** Solve `a x = b` for a square `a` (rows of `m` numbers) by elimination with pivoting; null when it is singular. Both are overwritten. */
211
+ function solve(a, b, m) {
212
+ let scale = 0;
213
+ for (let i = 0; i < m; i++)
214
+ scale = Math.max(scale, Math.abs(a[i * m + i]));
215
+ for (let c = 0; c < m; c++) {
216
+ let p = c;
217
+ for (let r = c + 1; r < m; r++)
218
+ if (Math.abs(a[r * m + c]) > Math.abs(a[p * m + c]))
219
+ p = r;
220
+ if (!(Math.abs(a[p * m + c]) > 1e-13 * scale))
221
+ return null;
222
+ if (p !== c) {
223
+ for (let k = 0; k < m; k++) {
224
+ const t = a[c * m + k];
225
+ a[c * m + k] = a[p * m + k];
226
+ a[p * m + k] = t;
227
+ }
228
+ const t = b[c];
229
+ b[c] = b[p];
230
+ b[p] = t;
231
+ }
232
+ for (let r = c + 1; r < m; r++) {
233
+ const f = a[r * m + c] / a[c * m + c];
234
+ for (let k = c; k < m; k++)
235
+ a[r * m + k] -= f * a[c * m + k];
236
+ b[r] -= f * b[c];
237
+ }
238
+ }
239
+ const x = new Float64Array(m);
240
+ for (let r = m - 1; r >= 0; r--) {
241
+ let s = b[r];
242
+ for (let k = r + 1; k < m; k++)
243
+ s -= a[r * m + k] * x[k];
244
+ x[r] = s / a[r * m + r];
245
+ }
246
+ return x;
247
+ }
248
+ /**
249
+ * A polynomial of `degree` (2 by default: a parabola). It is fitted on the positions centred and scaled to -1..1, which
250
+ * keeps high degrees well-behaved; `fit.coefficients` are in that scaled position `u = (x - center) / scale`. It needs
251
+ * more distinct positions than its degree.
252
+ */
253
+ export class PolynomialTrend extends Trendline {
254
+ static attrTypes = { ...Trendline.attrTypes, degree: NUMBER };
255
+ fitModel(xs, ys) {
256
+ const d = this.attrs.degree ?? 2;
257
+ if (!Number.isInteger(d) || d < 1 || d > MAX_DEGREE)
258
+ fail(`PolynomialTrend: degree is a whole number from 1 to ${MAX_DEGREE} (got ${d})`);
259
+ const n = xs.length, m = d + 1;
260
+ const lo = xs[0], hi = xs[n - 1], center = (lo + hi) / 2, scale = (hi - lo) / 2;
261
+ if (!(scale > 0) || n < m)
262
+ return null;
263
+ const pow = new Float64Array(2 * d + 1), rhs = new Float64Array(m);
264
+ for (let i = 0; i < n; i++) {
265
+ const u = (xs[i] - center) / scale;
266
+ let p = 1;
267
+ for (let k = 0; k <= 2 * d; k++) {
268
+ pow[k] += p;
269
+ if (k < m)
270
+ rhs[k] += p * ys[i];
271
+ p *= u;
272
+ }
273
+ }
274
+ const a = new Float64Array(m * m);
275
+ for (let r = 0; r < m; r++)
276
+ for (let c = 0; c < m; c++)
277
+ a[r * m + c] = pow[r + c];
278
+ const coefficients = solve(a, rhs, m);
279
+ if (!coefficients)
280
+ return null;
281
+ return {
282
+ at: (x) => { const u = (x - center) / scale; let v = 0; for (let k = d; k >= 0; k--)
283
+ v = v * u + coefficients[k]; return v; },
284
+ details: { coefficients: Array.from(coefficients), center, scale },
285
+ };
286
+ }
287
+ }
288
+ /** Frequencies tried when the period is not given, and points used to try them (the best is then refined on all). */
289
+ const SCAN_FREQUENCIES = 3000;
290
+ const SCAN_POINTS = 2000;
291
+ /** Sums for the least-squares fit of `c + a cos(wx) + b sin(wx)` at one frequency: the coefficients and the squared error left. */
292
+ function sinFit(xs, ys, idx, n, w, center) {
293
+ let s1 = 0, sc = 0, ss = 0, scc = 0, sss = 0, scs = 0, sy = 0, syc = 0, sys = 0, syy = 0;
294
+ for (let k = 0; k < n; k++) {
295
+ const i = idx ? idx(k) : k, t = w * (xs[i] - center), c = Math.cos(t), s = Math.sin(t), y = ys[i];
296
+ s1++;
297
+ sc += c;
298
+ ss += s;
299
+ scc += c * c;
300
+ sss += s * s;
301
+ scs += c * s;
302
+ sy += y;
303
+ syc += y * c;
304
+ sys += y * s;
305
+ syy += y * y;
306
+ }
307
+ const a = Float64Array.of(s1, sc, ss, sc, scc, scs, ss, scs, sss), b = Float64Array.of(sy, syc, sys);
308
+ const beta = solve(a, Float64Array.from(b), 3);
309
+ if (!beta)
310
+ return null;
311
+ const sse = syy - (beta[0] * sy + beta[1] * syc + beta[2] * sys);
312
+ return { c: beta[0], a: beta[1], b: beta[2], sse: Math.max(0, sse) };
313
+ }
314
+ /**
315
+ * A constant plus one sine wave. Give the `period` and only the offset, amplitude and phase are fitted (a linear fit). Without
316
+ * it the period is found too: a periodogram over the periods the data can show (from the whole span down to about two of its
317
+ * typical gaps, which works for unevenly spaced points), then a refinement of the best on every point. `fit.period` and
318
+ * `fit.amplitude` say what was found. A wave with fewer than three points, or with every point at one position, has no fit.
319
+ */
320
+ export class SinusoidalTrend extends Trendline {
321
+ static attrTypes = { ...Trendline.attrTypes, period: NUMBER };
322
+ fitModel(xs, ys) {
323
+ const n = xs.length, given = this.attrs.period;
324
+ if (given !== undefined && !(given > 0))
325
+ fail(`SinusoidalTrend: period must be above 0 (got ${given})`);
326
+ const lo = xs[0], hi = xs[n - 1], span = hi - lo, center = (lo + hi) / 2;
327
+ if (n < 3 || !(span > 0))
328
+ return null;
329
+ let period = given;
330
+ if (period === undefined) {
331
+ // Typical gap: the median of the positive ones.
332
+ const gaps = [];
333
+ for (let i = 1; i < n; i++)
334
+ if (xs[i] > xs[i - 1])
335
+ gaps.push(xs[i] - xs[i - 1]);
336
+ gaps.sort((p, q) => p - q);
337
+ const gap = gaps[gaps.length >> 1];
338
+ const fMin = 1 / span, fMax = Math.max(fMin * 2, 1 / (2 * gap));
339
+ const df = (fMax - fMin) / Math.min(SCAN_FREQUENCIES, Math.max(8, Math.ceil(4 * span * (fMax - fMin))));
340
+ const use = Math.min(n, SCAN_POINTS), idx = use < n ? (k) => Math.floor((k * (n - 1)) / (use - 1)) : null;
341
+ let best = fMin, bestSse = Infinity;
342
+ for (let f = fMin; f <= fMax + 1e-12; f += df) {
343
+ const r = sinFit(xs, ys, idx, use, 2 * Math.PI * f, center);
344
+ if (r && r.sse < bestSse) {
345
+ bestSse = r.sse;
346
+ best = f;
347
+ }
348
+ }
349
+ // Refine on all the points by golden-section search within a step of the best.
350
+ let a = Math.max(fMin * 0.5, best - df), b = best + df;
351
+ const phi = (Math.sqrt(5) - 1) / 2, cost = (f) => sinFit(xs, ys, null, n, 2 * Math.PI * f, center)?.sse ?? Infinity;
352
+ let c = b - phi * (b - a), d = a + phi * (b - a), fc = cost(c), fd = cost(d);
353
+ for (let it = 0; it < 40; it++) {
354
+ if (fc < fd) {
355
+ b = d;
356
+ d = c;
357
+ fd = fc;
358
+ c = b - phi * (b - a);
359
+ fc = cost(c);
360
+ }
361
+ else {
362
+ a = c;
363
+ c = d;
364
+ fc = fd;
365
+ d = a + phi * (b - a);
366
+ fd = cost(d);
367
+ }
368
+ }
369
+ period = 1 / ((a + b) / 2);
370
+ }
371
+ const w = (2 * Math.PI) / period, r = sinFit(xs, ys, null, n, w, center);
372
+ if (!r)
373
+ return null;
374
+ const { c, a, b } = r;
375
+ return {
376
+ at: (x) => c + a * Math.cos(w * (x - center)) + b * Math.sin(w * (x - center)),
377
+ details: { coefficients: [c, a, b], center, period, amplitude: Math.hypot(a, b) },
378
+ };
379
+ }
380
+ }
381
+ /** A window holds at least this many points, so a small bandwidth between sparse points still has a line to fit. */
382
+ const MIN_WINDOW = 3;
383
+ /** The reach at smoothness 0.5, as a fraction of the span; `smoothness / (1 - smoothness)` times this is the reach in general. */
384
+ const REACH_AT_HALF = 0.3;
385
+ /**
386
+ * Local linear regression (the method behind LOESS and LOWESS), centred on each position and reaching both ways: at each place
387
+ * it fits a straight line to the points near it, weighting nearer ones more, and takes that line's value. `smoothness` sets
388
+ * how far it reaches, as `smoothness / (1 - smoothness)` times 0.3 of the data's span: 0 reaches nowhere and the curve joins the
389
+ * points (straight between neighbours; points sharing a position are averaged), larger values are smoother, and 1 reaches everywhere
390
+ * equally, which is the least-squares line (it is that exactly). Where the points are sparse the reach widens to hold at least
391
+ * three of them. Past the ends of the data it continues the local line at the end, with a slope over a wide reach. Each place costs the points in its reach, so a very smooth curve over very many points is slow to sample: pick `count`.
392
+ * It needs sorted points, which it makes itself. `fit.bandwidth` is the reach.
393
+ */
394
+ export class LoessTrend extends Trendline {
395
+ static attrTypes = { ...Trendline.attrTypes, smoothness: NUMBER, kernel: STRING };
396
+ fitModel(xs, ys) {
397
+ const s = this.attrs.smoothness ?? 0.5, kernel = this.attrs.kernel ?? 'tricube';
398
+ if (!(s >= 0 && s <= 1))
399
+ fail(`LoessTrend: smoothness is from 0 to 1 (got ${s})`);
400
+ if (kernel !== 'tricube' && kernel !== 'gaussian')
401
+ fail(`LoessTrend: kernel is 'tricube' or 'gaussian' (got "${kernel}")`);
402
+ const n = xs.length, span = xs[n - 1] - xs[0];
403
+ if (!(span > 0))
404
+ return null;
405
+ if (s === 1)
406
+ return leastSquares(xs, ys);
407
+ if (s === 0)
408
+ return interpolation(xs, ys);
409
+ const h0 = (REACH_AT_HALF * span * s) / (1 - s);
410
+ const weight = kernel === 'tricube'
411
+ ? (q) => { const t = 1 - q * q * q; return t * t * t; }
412
+ : (q) => Math.exp(-4.5 * q * q); // sigma = h / 3: the weight is negligible at the window's edge
413
+ // The local line at x0 (its value there and its slope) from the points within `reach`, widened to hold three.
414
+ const local = (x0, reach) => {
415
+ let h = reach;
416
+ let lo = lowerBound(xs, x0 - h), hi = lowerBound(xs, x0 + h, true);
417
+ if (hi - lo < MIN_WINDOW) {
418
+ // Widen to the distance of the third nearest point.
419
+ let l = lowerBound(xs, x0) - 1, r = l + 1, d = 0;
420
+ for (let k = 0; k < Math.min(MIN_WINDOW, n); k++) {
421
+ const dl = l >= 0 ? x0 - xs[l] : Infinity, dr = r < n ? xs[r] - x0 : Infinity;
422
+ if (dl <= dr) {
423
+ d = dl;
424
+ l--;
425
+ }
426
+ else {
427
+ d = dr;
428
+ r++;
429
+ }
430
+ }
431
+ h = Math.max(h, d * (1 + 1e-9));
432
+ lo = lowerBound(xs, x0 - h);
433
+ hi = lowerBound(xs, x0 + h, true);
434
+ }
435
+ let s0 = 0, s1 = 0, s2 = 0, t0 = 0, t1 = 0;
436
+ for (let j = lo; j < hi; j++) {
437
+ const dx = xs[j] - x0, w = weight(Math.abs(dx) / h);
438
+ s0 += w;
439
+ s1 += w * dx;
440
+ s2 += w * dx * dx;
441
+ t0 += w * ys[j];
442
+ t1 += w * dx * ys[j];
443
+ }
444
+ if (!(s0 > 0))
445
+ return null;
446
+ const det = s0 * s2 - s1 * s1;
447
+ return det > 1e-12 * s0 * s2 ? { a: (t0 * s2 - t1 * s1) / det, b: (s0 * t1 - s1 * t0) / det } : { a: t0 / s0, b: 0 };
448
+ };
449
+ const first = xs[0], last = xs[n - 1];
450
+ const at = (x) => {
451
+ if (!Number.isFinite(x))
452
+ return NaN;
453
+ if (x >= first && x <= last)
454
+ return local(x, h0)?.a ?? NaN;
455
+ // Past the data: the local line at the end, continued with a slope taken over a wide reach, since the few points nearest
456
+ // the end give a slope that is mostly noise.
457
+ const end = x < first ? first : last, edge = local(end, h0), slope = local(end, Math.max(h0, REACH_AT_HALF * span));
458
+ return edge && slope ? edge.a + slope.b * (x - end) : NaN;
459
+ };
460
+ return { at, details: { bandwidth: h0 } };
461
+ }
462
+ }
463
+ /** The index of the first x not below `x` (or, with `after`, the first above it). */
464
+ function lowerBound(xs, x, after = false) {
465
+ let lo = 0, hi = xs.length;
466
+ while (lo < hi) {
467
+ const mid = (lo + hi) >> 1;
468
+ if (after ? xs[mid] <= x : xs[mid] < x)
469
+ lo = mid + 1;
470
+ else
471
+ hi = mid;
472
+ }
473
+ return lo;
474
+ }
475
+ /** The curve straight through the points (averaging the ones that share a position), continuing the end segments past them. */
476
+ function interpolation(xs, ys) {
477
+ const px = [], py = [];
478
+ for (let i = 0; i < xs.length;) {
479
+ let j = i, sum = 0;
480
+ while (j < xs.length && xs[j] === xs[i])
481
+ sum += ys[j++];
482
+ px.push(xs[i]);
483
+ py.push(sum / (j - i));
484
+ i = j;
485
+ }
486
+ const m = px.length;
487
+ if (m < 2)
488
+ return null;
489
+ const x = Float64Array.from(px);
490
+ return {
491
+ at: (v) => {
492
+ if (!Number.isFinite(v))
493
+ return NaN;
494
+ const k = Math.min(m - 1, Math.max(1, lowerBound(x, v)));
495
+ const x0 = px[k - 1], x1 = px[k];
496
+ return py[k - 1] + ((py[k] - py[k - 1]) * (v - x0)) / (x1 - x0);
497
+ },
498
+ details: { bandwidth: 0 },
499
+ };
500
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The Web Components adapter: chart elements as custom HTML elements, so that a chart can be written, or pre-rendered, as markup.
3
+ *
4
+ * import * as inkplot from 'inkplot';
5
+ * import { defineElements } from 'inkplot/web-components';
6
+ * defineElements({ Chart: inkplot.Chart, CartesianPlane: inkplot.CartesianPlane, Axis: inkplot.Axis, … }, { prefix: 'ch' });
7
+ *
8
+ * <ch-chart style="display:block; height:300px" meta-title="Sales">
9
+ * <ch-cartesian-plane>
10
+ * <ch-axis orientation="bottom" meta-title="Month"><ch-linear-scale></ch-linear-scale></ch-axis>
11
+ * <ch-domain-series name="2024"><ch-flat-data-source data="[1,3,2]" regulardomain-step="1"></ch-flat-data-source> …
12
+ *
13
+ * Only the classes you give are defined, so nothing else is bundled. The tag is the prefix and the key, kebab-cased
14
+ * (`ch-cartesian-plane`).
15
+ *
16
+ * How it follows the DOM:
17
+ * - The element tree is the DOM tree: a wrapper finds its parent wrapper among its ancestors and joins it in document order
18
+ * when connected, and leaves when disconnected. Moving a node moves the chart element. The outermost wrapper (one with
19
+ * `attach`, a Chart) draws into a shadow root, so the wrapper elements inside it are never rendered, and the page's CSS does not reach the chart.
20
+ * Give that element a size; it is `display: block`.
21
+ * - Attributes are the element's attributes. HTML lowercases names, so `regulardomain-step` finds `regularDomain.step` (the
22
+ * class' `attrTypes` and `STYLE_NAMES` say how the names are spelled). Hyphens nest, as in attrs.ts: `meta-title`, `style-ticklabel-size`.
23
+ * Text becomes a value by the class' `attrTypes`: `STRING` stays text; otherwise `true`/`false`, numbers, and `[…]`/`{…}` (JSON)
24
+ * convert, and a present attribute with no value is true. Changing an attribute later updates the element (on the next microtask).
25
+ * - Values HTML cannot carry are set as JavaScript: `element.props = { format: fn, data: new Float64Array(…) }` on the wrapper,
26
+ * and they win over attributes. `wrapper.element` is the chart element itself, for calling its methods.
27
+ * - Callbacks (`onClick`, `onHover`, `onDrag`, `onFlush`: attributes that are functions) are events: once the page listens, with
28
+ * `addEventListener('click', …)` or `onclick=""`, the wrapper dispatches a bubbling `CustomEvent` named like the DOM's (`click`,
29
+ * `hover`, `drag`, `flush`) with the callback's argument as `detail`. (Until then no callback is set, so the chart stays serializable.)
30
+ * `on…` attributes are the DOM's own and are not read as chart attributes.
31
+ * - `style`, `class` and the other attributes every HTML element has are the page's, not the chart's: set the chart's `style` blocks with `style-…` names.
32
+ */
33
+ import { type AttrTypes } from './attrs.ts';
34
+ import { type DOMPlatformOptions } from './dom-platform.ts';
35
+ import type { AnyArgs, AnyElement } from './element.ts';
36
+ type Plain = Record<string, unknown>;
37
+ type ElementConstructor = (new (...args: AnyArgs) => AnyElement) & {
38
+ attrTypes?: AttrTypes;
39
+ };
40
+ /** A defined wrapper element, as the page sees it. */
41
+ export interface InkplotElement extends HTMLElement {
42
+ /** The chart element behind this tag; null until the tag is connected. */
43
+ readonly element: AnyElement | null;
44
+ /** Attributes set from JavaScript: functions, data and objects. They win over the tag's attributes. */
45
+ props: Plain;
46
+ /** How the chart is drawn, for the outermost element; read when it connects. */
47
+ platformOptions?: DOMPlatformOptions;
48
+ }
49
+ export interface DefineOptions {
50
+ /** The start of every tag name, which has to be a valid custom element name with the rest: `ch` gives `ch-axis`. */
51
+ prefix: string;
52
+ /** Where to define them. Default: the page's `customElements`. */
53
+ registry?: CustomElementRegistry;
54
+ /** More camelCase names that a `style-…` attribute may contain, for selectors added by augmenting `Selectors`. */
55
+ names?: readonly string[];
56
+ }
57
+ /**
58
+ * Define a custom element for each class. `classes` maps a name to a class, and the name gives the tag. Returns the tags by name.
59
+ * Does nothing at import, so a module that imports this can be loaded where there is no DOM.
60
+ */
61
+ export declare function defineElements(classes: Readonly<Record<string, ElementConstructor>>, options: DefineOptions): Record<string, string>;
62
+ export {};