inkplot 0.0.1-placeholder.0 → 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 +45 -1
  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 +48 -3
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Binning as a data transform: points in, a count (or a sum, or a mean) for each bin out. A histogram is this and `BarPath`;
3
+ * a heat map is this over two dimensions and `HeatMap`.
4
+ *
5
+ * <DomainSeries>
6
+ * <Density width={5}><ObjectDataSource source={people} domain="age" value="age" /></Density>
7
+ * <BarPath width={1} />
8
+ * </DomainSeries>
9
+ *
10
+ * `by` names the dimensions to bin, any number of them: `'domain'` and `'value'` are the source's own two columns, and any
11
+ * other name is one of its channels. The default is `['domain']`. Every point that has a finite number in each of those
12
+ * dimensions lands in one bin. Each bin is one point of the output, at the bin's centre:
13
+ *
14
+ * one dimension domain = the bin's centre, value = the statistic (`stat`)
15
+ * two or more domain = the first dimension's centre, value = the second's, and each further dimension is a channel of its name
16
+ *
17
+ * and these channels in any case: `domain` (the first dimension's centre again, since an `XYSeries` takes its x from a channel), `count` (the points in the bin), `sum` (the total of the `weight` channel, which
18
+ * is `count` when there is none), `mean` (`sum / count`), `fraction` (the bin's share of the total), `density` (the share per unit
19
+ * of the bin's size, so a histogram of it has area 1), and `width` and `height`: the size of the bin along the first and the
20
+ * second dimension, so a renderer can draw a bin as the box it is. `weight` is what each point counts for: left out, a point
21
+ * counts as 1; with a channel (or `'value'`) named, `sum` and `mean` are of it. `stat` is the value of a one-dimensional
22
+ * output: `'count'` (the default; with a `weight`, `'sum'`), `'sum'`, `'mean'`, `'fraction'` or `'density'`.
23
+ *
24
+ * Bin size is set for each dimension, as a number (every dimension) or an array with one for each:
25
+ * `width` the size of a bin, in the dimension's own units. Edges fall at `origin` plus a whole number of widths (origin 0 by default).
26
+ * `bins` how many bins, spread evenly over the dimension's extent.
27
+ * `extent` the span to bin over, `{ min, max }`, instead of the data's own; points outside it are left out.
28
+ * With neither `width` nor `bins` the Freedman–Diaconis rule picks a width from the spread of the points (Sturges' rule when
29
+ * that is zero), never more than `maxBins` (default 200) bins along a dimension. A bin is closed on the left and open on the
30
+ * right, except the last, which holds the top edge. With one dimension empty bins are kept as zeros (so bars are evenly
31
+ * spaced); with more they are left out (`empty` says otherwise). The result is worked out once for each change of the source or the attributes.
32
+ */
33
+ /* 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 */
34
+ import { AUTO, NUMBER, STRING } from './attrs.js';
35
+ import { DataSource, DataTransform } from './data.js';
36
+ import { fail } from './element.js';
37
+ import { quantile } from './quantile.js';
38
+ const STATS = ['count', 'sum', 'mean', 'fraction', 'density'];
39
+ /** Channels beside the statistics: the bin's size along the first two dimensions. */
40
+ const SIZES = ['width', 'height'];
41
+ /** Most cells a grid may have: a dense array of them is held. */
42
+ const MAX_CELLS = 4_000_000;
43
+ const list = (v, k) => (Array.isArray(v) ? v[k] : v);
44
+ export class Density extends DataTransform {
45
+ static attrTypes = { ...DataTransform.attrTypes, by: AUTO, weight: STRING, stat: STRING, width: AUTO, bins: AUTO, origin: AUTO, extent: AUTO, maxBins: NUMBER, empty: STRING };
46
+ #version = 0;
47
+ #cache = null;
48
+ get source() { return this.child(0, DataSource, DataTransform); }
49
+ invalidate() {
50
+ this.#version++;
51
+ super.invalidate();
52
+ }
53
+ get version() { return this.#version; }
54
+ get #dims() { return this.attrs.by ?? ['domain']; }
55
+ /** What it offers: the other dimensions, then the statistics and sizes. */
56
+ get channelNames() {
57
+ const dims = this.#dims;
58
+ return ['domain', ...(dims.length > 2 ? dims.slice(2) : []), ...STATS, ...SIZES];
59
+ }
60
+ #compute() {
61
+ if (this.#cache && this.#cache.version === this.#version)
62
+ return this.#cache.value;
63
+ const a = this.attrs, dims = this.#dims, k = dims.length;
64
+ if (k === 0)
65
+ fail('Density: `by` needs at least one dimension');
66
+ if (new Set(dims).size !== k)
67
+ fail('Density: `by` names a dimension twice');
68
+ const weightName = a.weight;
69
+ // Read what is needed from the source: its own two columns, and a channel for each other name.
70
+ const names = [...new Set([...dims, ...(weightName ? [weightName] : [])].filter((n) => n !== 'domain' && n !== 'value'))];
71
+ const plan = this.source.plan({ width: 0, height: 0, domainRange: { min: -Infinity, max: Infinity }, channels: names });
72
+ const stride = 2 + names.length, buf = new Float64Array(plan.capacity * stride), { count: n } = plan.fill(buf);
73
+ const column = (name) => (name === 'domain' ? 0 : name === 'value' ? 1 : 2 + names.indexOf(name));
74
+ const cols = dims.map(column), wcol = weightName ? column(weightName) : -1;
75
+ // The points that count: every dimension (and the weight) a number.
76
+ const rows = [];
77
+ for (let i = 0; i < n; i++) {
78
+ let ok = true;
79
+ for (const c of cols) {
80
+ const v = buf[stride * i + c];
81
+ if (!Number.isFinite(v)) {
82
+ ok = false;
83
+ break;
84
+ }
85
+ }
86
+ if (ok && wcol >= 0) {
87
+ const w = buf[stride * i + wcol];
88
+ if (!Number.isFinite(w))
89
+ ok = false;
90
+ }
91
+ if (ok)
92
+ rows.push(i);
93
+ }
94
+ // One dimension's bins from the data and the attributes.
95
+ const axes = dims.map((_, d) => {
96
+ const v = Float64Array.from(rows, (i) => buf[stride * i + cols[d]]);
97
+ const given = a.extent === undefined ? undefined : Array.isArray(a.extent) ? a.extent[d] : a.extent;
98
+ let min = Infinity, max = -Infinity;
99
+ if (given) {
100
+ min = given.min;
101
+ max = given.max;
102
+ }
103
+ else
104
+ for (const x of v) {
105
+ if (x < min)
106
+ min = x;
107
+ if (x > max)
108
+ max = x;
109
+ }
110
+ if (!(min <= max))
111
+ return { lo: 0, width: 1, n: 0 };
112
+ const width = list(a.width, d), bins = list(a.bins, d), origin = list(a.origin, d) ?? 0, maxBins = a.maxBins ?? 200;
113
+ if (width !== undefined && !(width > 0))
114
+ fail(`Density: width must be above 0 (got ${width})`);
115
+ if (bins !== undefined && !(bins >= 1))
116
+ fail(`Density: bins must be at least 1 (got ${bins})`);
117
+ if (width !== undefined) {
118
+ const i0 = Math.floor((min - origin) / width + 1e-9), i1 = Math.floor((max - origin) / width + 1e-9);
119
+ if (i1 - i0 + 1 > MAX_CELLS)
120
+ fail(`Density: width ${width} makes ${i1 - i0 + 1} bins along "${dims[d]}": use a wider bin`);
121
+ return { lo: origin + i0 * width, width, n: i1 - i0 + 1 };
122
+ }
123
+ const span = max - min;
124
+ if (span === 0)
125
+ return { lo: min - 0.5, width: 1, n: 1 }; // no spread: one bin round the value
126
+ if (bins !== undefined) {
127
+ const nb = Math.floor(bins);
128
+ return { lo: min, width: span > 0 ? span / nb : 1, n: nb };
129
+ }
130
+ // The rule: Freedman–Diaconis from the spread of the points, else Sturges.
131
+ v.sort();
132
+ const m = v.length;
133
+ const iqr = m > 1 ? quantile(v, 0.75) - quantile(v, 0.25) : 0;
134
+ let nb = iqr > 0 && span > 0 ? Math.ceil(span / ((2 * iqr) / Math.cbrt(m))) : Math.ceil(Math.log2(Math.max(m, 1)) + 1);
135
+ nb = Math.max(1, Math.min(maxBins, nb));
136
+ return { lo: min, width: span > 0 ? span / nb : 1, n: nb };
137
+ });
138
+ let cells = 1;
139
+ for (const ax of axes) {
140
+ cells *= Math.max(ax.n, 1);
141
+ if (cells > MAX_CELLS)
142
+ fail(`Density: ${axes.map((x) => x.n).join(' by ')} bins is too many cells: use \`width\` or \`bins\` to make them fewer`);
143
+ }
144
+ const counts = new Float64Array(cells), sums = new Float64Array(cells);
145
+ if (!axes.some((ax) => ax.n === 0)) {
146
+ for (const i of rows) {
147
+ let at = 0, inside = true;
148
+ for (let d = 0; d < k; d++) {
149
+ const ax = axes[d], v = buf[stride * i + cols[d]], x = Math.floor((v - ax.lo) / ax.width + 1e-9);
150
+ // The top edge belongs to the last bin; a point past the ends (an explicit extent) is left out.
151
+ const idx = v === ax.lo + ax.n * ax.width ? ax.n - 1 : x;
152
+ if (idx < 0 || idx >= ax.n) {
153
+ inside = false;
154
+ break;
155
+ }
156
+ at = at * ax.n + idx;
157
+ }
158
+ if (!inside)
159
+ continue;
160
+ counts[at]++;
161
+ sums[at] += wcol >= 0 ? buf[stride * i + wcol] : 1;
162
+ }
163
+ }
164
+ // The output: each bin, ascending in the first dimension and then the second…
165
+ const keepEmpty = (a.empty ?? (k === 1 ? 'keep' : 'omit')) === 'keep';
166
+ const kept = [];
167
+ for (let c = 0; c < cells; c++)
168
+ if (keepEmpty ? counts.length > 0 && axes.every((x) => x.n > 0) : counts[c] > 0)
169
+ kept.push(c);
170
+ const m = kept.length;
171
+ let total = 0;
172
+ for (const c of kept)
173
+ total += sums[c];
174
+ const volume = axes.reduce((p, ax) => p * ax.width, 1);
175
+ const centres = axes.map(() => new Float64Array(m));
176
+ const out = Object.fromEntries([...STATS, ...SIZES].map((s) => [s, new Float64Array(m)]));
177
+ kept.forEach((cell, r) => {
178
+ let rest = cell;
179
+ for (let d = k - 1; d >= 0; d--) {
180
+ const ax = axes[d], idx = rest % ax.n;
181
+ rest = (rest - idx) / ax.n;
182
+ centres[d][r] = ax.lo + (idx + 0.5) * ax.width;
183
+ }
184
+ const cnt = counts[cell], sum = sums[cell];
185
+ out.count[r] = cnt;
186
+ out.sum[r] = sum;
187
+ out.mean[r] = cnt > 0 ? sum / cnt : NaN;
188
+ out.fraction[r] = total > 0 ? sum / total : NaN;
189
+ out.density[r] = total > 0 ? sum / total / volume : NaN;
190
+ out.width[r] = axes[0].width;
191
+ out.height[r] = k > 1 ? axes[1].width : NaN;
192
+ });
193
+ const value = { centres, columns: out, count: m };
194
+ this.#cache = { version: this.#version, value };
195
+ return value;
196
+ }
197
+ /** The value column: the statistic for one dimension, else the second dimension's centres. */
198
+ #value(c) {
199
+ if (this.#dims.length > 1)
200
+ return c.centres[1];
201
+ const stat = this.attrs.stat ?? (this.attrs.weight ? 'sum' : 'count');
202
+ if (!STATS.includes(stat))
203
+ fail(`Density: stat is one of ${STATS.join(', ')} (got "${stat}")`);
204
+ return c.columns[stat];
205
+ }
206
+ /** A named channel's column. */
207
+ #channel(c, name) {
208
+ const dims = this.#dims, extra = dims.indexOf(name);
209
+ if (name === 'domain')
210
+ return c.centres[0];
211
+ if (extra >= 2)
212
+ return c.centres[extra];
213
+ if (STATS.includes(name) || SIZES.includes(name))
214
+ return c.columns[name];
215
+ return fail(`Density has no channel "${name}" (it has ${this.channelNames.join(', ')})`);
216
+ }
217
+ #extent(a) {
218
+ let min = Infinity, max = -Infinity;
219
+ for (const v of a)
220
+ if (Number.isFinite(v)) {
221
+ if (v < min)
222
+ min = v;
223
+ if (v > max)
224
+ max = v;
225
+ }
226
+ return min <= max ? { min, max } : null;
227
+ }
228
+ get pointCount() { return this.#compute().count; }
229
+ domain() {
230
+ const c = this.#compute(), d = c.centres[0];
231
+ return d.length > 0 ? { min: d[0], max: d[d.length - 1] } : null;
232
+ }
233
+ valueExtent() { return this.#extent(this.#value(this.#compute())); }
234
+ channelExtent(name) { return this.#extent(this.#channel(this.#compute(), name)); }
235
+ plan(context) {
236
+ const c = this.#compute(), want = context.channels.map((n) => this.#channel(c, n)), stride = 2 + want.length;
237
+ const domain = c.centres[0], value = this.#value(c), count = c.count;
238
+ return {
239
+ capacity: count,
240
+ fill: (out) => {
241
+ for (let i = 0; i < count; i++) {
242
+ out[stride * i] = domain[i];
243
+ out[stride * i + 1] = value[i];
244
+ for (let j = 0; j < want.length; j++)
245
+ out[stride * i + 2 + j] = want[j][i];
246
+ }
247
+ const none = { min: Infinity, max: -Infinity };
248
+ return { count, extents: [this.#extent(domain) ?? none, this.#extent(value) ?? none, ...want.map((w) => this.#extent(w) ?? none)] };
249
+ },
250
+ };
251
+ }
252
+ }
@@ -0,0 +1,103 @@
1
+ import { type AnyElement, type BaseAttributes, ChartElement } from './element.ts';
2
+ import type { Extent } from './geometry.ts';
3
+ import { Scale } from './scale.ts';
4
+ /** One mark on an axis: where (`value`), what it says, and whether it is a labelled tick or a minor one. */
5
+ export interface Tick {
6
+ value: number;
7
+ label: string;
8
+ major: boolean;
9
+ }
10
+ export interface DimensionAttributes extends BaseAttributes {
11
+ /** Fix an end of the range. Beats everything else, including `nice`. */
12
+ min?: number;
13
+ /** Fix the top end of the range. Beats everything else, including `nice`. */
14
+ max?: number;
15
+ /** Values the range must cover, e.g. `[0]` to anchor a bar-like axis at zero. */
16
+ include?: readonly number[];
17
+ /** Extra room at both ends as a fraction of the data's span. */
18
+ padding?: number;
19
+ /** Round the ends outwards to tidy tick values. Recommended with a RadarPlane, whose spokes come from the ticks. */
20
+ nice?: boolean;
21
+ /** Run from max to min along the axis. */
22
+ inverted?: boolean;
23
+ /** The axis title (or, on a `ColorAxis` or `SizeAxis`, the default title of its key). Default: none. */
24
+ title?: string;
25
+ /** Ask for about this many ticks, whatever the length. */
26
+ tickCount?: number;
27
+ /** Put ticks exactly here (those inside the range are drawn), or at whatever a function of the range returns. Beats `tickStep` and `tickCount`. */
28
+ ticks?: readonly number[] | ((range: Readonly<Extent>) => readonly number[]);
29
+ /** Put ticks at every multiple of this step. Beats `tickCount`. */
30
+ tickStep?: number;
31
+ /** Turns a tick's number into its label, e.g. (v) => `${v}%`. Default: plain digits, grouped by `locale` if one is set. */
32
+ format?: (value: number) => string;
33
+ /** A BCP 47 tag for how this writes its numbers (grouping and decimal mark). Default: the chart's `locale`, else plain digits. A `format` beats it. */
34
+ locale?: string;
35
+ /** Called when the view (what pan, zoom or a reset has set) changes: the new range, or null when it is back to the natural one. */
36
+ onViewChange?: (view: Readonly<Extent> | null) => void;
37
+ }
38
+ /** Anything that turns a data range into 0..1. Its one child is the scale that does the turning. */
39
+ export declare abstract class Dimension<Parent extends AnyElement = AnyElement, A extends DimensionAttributes = DimensionAttributes> extends ChartElement<Parent, [scale: Scale], A> {
40
+ #private;
41
+ static attrTypes: {
42
+ title: 1;
43
+ tickCount: 2;
44
+ tickStep: 2;
45
+ locale: 1;
46
+ onViewChange: 0;
47
+ id: 1;
48
+ meta: {
49
+ title: 1;
50
+ description: 1;
51
+ role: 1;
52
+ };
53
+ };
54
+ private lo;
55
+ private hi;
56
+ private resolved;
57
+ private view;
58
+ private reachPx;
59
+ private lengthPx;
60
+ /** Scratch for `fractionOf`, so a single-value lookup (called per tick) allocates nothing. */
61
+ private readonly tmp;
62
+ /** The resolved range after the last layout: the view if one is set, otherwise the natural range. */
63
+ get range(): Readonly<Extent>;
64
+ /** True while a view range overrides the natural one. */
65
+ get isZoomed(): boolean;
66
+ /** The range pan/zoom has set, which takes effect at the next layout (`range` still holds the last one until then). */
67
+ get viewRange(): Readonly<Extent> | null;
68
+ get scale(): Scale;
69
+ /**
70
+ * Start a layout pass: forget last pass's contributions. `lengthInPixels` is how long the dimension is expected to be
71
+ * drawn, which is what turns a reach in pixels into data; 0 (the default) where that is not meaningful.
72
+ */
73
+ begin(lengthInPixels?: number): void;
74
+ /** A series asks for room of `pixels` past the ends of its data, so that marks centred on them are not cut by the plot. Ignored once resolved. */
75
+ reach(pixels: number): void;
76
+ /** A series reports the extent it will draw. Ignored once the range is resolved. */
77
+ contribute(min: number, max: number): void;
78
+ /** Fix the range for this pass. Does nothing if it is already fixed. */
79
+ resolve(): void;
80
+ /**
81
+ * The range these data extents ask for once the attributes have had their say: `include`, `padding`, fixed
82
+ * `min` and `max`, what the scale can represent, and tidy ends. With nothing to fit it is 0 to 1.
83
+ */
84
+ fit(lo: number, hi: number): Extent;
85
+ /** Temporary range override (pan/zoom). `null` returns to the attribute-driven range. */
86
+ setViewRange(range: Extent | null): void;
87
+ /** In place over an interleaved buffer (the hot path), applying `inverted` after the scale. */
88
+ normalize(buffer: Float64Array, count: number, stride: number, offset: number): void;
89
+ denormalize(fraction: number): number;
90
+ /** Position of one value along the dimension, 0..1. */
91
+ fractionOf(value: number): number;
92
+ /**
93
+ * One value as text, the way this dimension would label a tick but with the precision a reading needs: its `format`,
94
+ * else its scale's own labelling (a band's category name), else the number to five significant figures.
95
+ */
96
+ formatValue(value: number): string;
97
+ /** Where the ticks are: the attributes' say if they have one, else the scale's choice of about `count`. Always inside the range. */
98
+ private tickValues;
99
+ /** Overridden by dimensions that know their length matters (an axis wants more ticks when longer). */
100
+ protected tickCountFor(_lengthInPixels: number): number;
101
+ /** The ticks for a dimension drawn `lengthInPixels` long. `tickCount` wins; else `count`; else a number that suits the length. */
102
+ getTicks(lengthInPixels: number, count?: number): readonly Tick[];
103
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * A dimension: a scale plus the range it covers, the view into it, and how it fits data.
3
+ *
4
+ * How a range gets decided (the "fit protocol"), because several elements take part:
5
+ * 1. The plane calls `begin()` on each dimension to forget last layout's data.
6
+ * 2. Each series `contribute()`s the extent it will draw (for the value axis, only the part inside the view).
7
+ * 3. The plane calls `resolve()`, which fixes the range once: a pan/zoom `view` wins, otherwise `fit()` applies the
8
+ * attributes (`min`/`max`/`include`/`padding`/`nice`) to what was contributed.
9
+ * After `resolve()` further contributions are ignored, which is what lets a series' domain window depend on the domain
10
+ * dimension's resolved range while still being allowed to contribute to the value dimension afterwards.
11
+ */
12
+ import { AUTO, NUMBER, STRING } from './attrs.js';
13
+ import { ChartElement } from './element.js';
14
+ import { Scale } from './scale.js';
15
+ const numberFormats = new Map();
16
+ /** A cached `Intl.NumberFormat`: tick labels are made often, and building one is the slow part. */
17
+ function numberFormat(locale, options) {
18
+ const key = locale + JSON.stringify(options);
19
+ let f = numberFormats.get(key);
20
+ if (!f) {
21
+ f = new Intl.NumberFormat(locale, options);
22
+ numberFormats.set(key, f);
23
+ }
24
+ return f;
25
+ }
26
+ /** Anything that turns a data range into 0..1. Its one child is the scale that does the turning. */
27
+ export class Dimension extends ChartElement {
28
+ static attrTypes = { ...ChartElement.attrTypes, title: STRING, tickCount: NUMBER, tickStep: NUMBER, locale: STRING, onViewChange: AUTO };
29
+ #range = { min: 0, max: 1 };
30
+ lo = Infinity;
31
+ hi = -Infinity;
32
+ resolved = false;
33
+ view = null;
34
+ // The most room in pixels any series asks beyond the ends of its data (a marker's radius), and the axis' length to turn it into data.
35
+ reachPx = 0;
36
+ lengthPx = 0;
37
+ /** Scratch for `fractionOf`, so a single-value lookup (called per tick) allocates nothing. */
38
+ tmp = new Float64Array(1);
39
+ /** The resolved range after the last layout: the view if one is set, otherwise the natural range. */
40
+ get range() { return this.#range; }
41
+ /** True while a view range overrides the natural one. */
42
+ get isZoomed() { return this.view !== null; }
43
+ /** The range pan/zoom has set, which takes effect at the next layout (`range` still holds the last one until then). */
44
+ get viewRange() { return this.view; }
45
+ get scale() { return this.child(0, Scale); }
46
+ // ─── Engine protocol: a plane and its series negotiate the range through these. ───
47
+ /**
48
+ * Start a layout pass: forget last pass's contributions. `lengthInPixels` is how long the dimension is expected to be
49
+ * drawn, which is what turns a reach in pixels into data; 0 (the default) where that is not meaningful.
50
+ */
51
+ begin(lengthInPixels = 0) { this.lo = Infinity; this.hi = -Infinity; this.resolved = false; this.reachPx = 0; this.lengthPx = lengthInPixels; }
52
+ /** A series asks for room of `pixels` past the ends of its data, so that marks centred on them are not cut by the plot. Ignored once resolved. */
53
+ reach(pixels) { if (!this.resolved && pixels > this.reachPx)
54
+ this.reachPx = pixels; }
55
+ /** A series reports the extent it will draw. Ignored once the range is resolved. */
56
+ contribute(min, max) {
57
+ if (this.resolved)
58
+ return;
59
+ if (min < this.lo)
60
+ this.lo = min;
61
+ if (max > this.hi)
62
+ this.hi = max;
63
+ }
64
+ /** Fix the range for this pass. Does nothing if it is already fixed. */
65
+ resolve() {
66
+ if (this.resolved)
67
+ return;
68
+ this.resolved = true;
69
+ this.#range = this.view ?? this.fit(this.lo, this.hi);
70
+ }
71
+ /**
72
+ * The range these data extents ask for once the attributes have had their say: `include`, `padding`, fixed
73
+ * `min` and `max`, what the scale can represent, and tidy ends. With nothing to fit it is 0 to 1.
74
+ */
75
+ fit(lo, hi) {
76
+ const a = this.attrs, scale = this.scale;
77
+ for (const v of a.include ?? []) {
78
+ if (v < lo)
79
+ lo = v;
80
+ if (v > hi)
81
+ hi = v;
82
+ }
83
+ // Nothing contributed (no data yet): a unit range keeps every downstream division finite.
84
+ if (!(lo <= hi)) {
85
+ lo = 0;
86
+ hi = 1;
87
+ }
88
+ const span = hi - lo;
89
+ if (a.padding && span > 0) {
90
+ lo -= span * a.padding;
91
+ hi += span * a.padding;
92
+ }
93
+ let r = { min: a.min ?? lo, max: a.max ?? hi };
94
+ // A flat line has zero span; open it up so the scale has something to divide by.
95
+ if (r.min === r.max) {
96
+ const d = r.min === 0 ? 1 : Math.abs(r.min) * 0.1;
97
+ r = { min: r.min - d, max: r.max + d };
98
+ }
99
+ r = scale.constrain(r);
100
+ // Room for what reaches past the ends of the data: the same number of pixels at each end, in the scale's own terms.
101
+ // An end the attributes fixed is left where it was put.
102
+ const m = this.reachPx, L = this.lengthPx;
103
+ if (m > 0 && L > 2 * m + 1 && (a.min === undefined || a.max === undefined)) {
104
+ const f = m / (L - 2 * m), lo2 = scale.denormalize(-f, r), hi2 = scale.denormalize(1 + f, r);
105
+ r = scale.constrain({ min: a.min ?? (Number.isFinite(lo2) ? lo2 : r.min), max: a.max ?? (Number.isFinite(hi2) ? hi2 : r.max) });
106
+ }
107
+ if (a.nice) {
108
+ const n = scale.nice(r);
109
+ r = { min: a.min ?? n.min, max: a.max ?? n.max };
110
+ }
111
+ return r;
112
+ }
113
+ /** Temporary range override (pan/zoom). `null` returns to the attribute-driven range. */
114
+ setViewRange(range) {
115
+ const old = this.view;
116
+ if (old === range || (old && range && old.min === range.min && old.max === range.max))
117
+ return;
118
+ this.view = range;
119
+ this.invalidate();
120
+ this.attrs.onViewChange?.(range);
121
+ }
122
+ /** In place over an interleaved buffer (the hot path), applying `inverted` after the scale. */
123
+ normalize(buffer, count, stride, offset) {
124
+ this.scale.normalize(buffer, count, stride, offset, this.range);
125
+ if (this.attrs.inverted) {
126
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
127
+ for (let k = 0, i = offset; k < count; k++, i += stride)
128
+ buffer[i] = 1 - buffer[i];
129
+ }
130
+ }
131
+ denormalize(fraction) {
132
+ return this.scale.denormalize(this.attrs.inverted ? 1 - fraction : fraction, this.range);
133
+ }
134
+ /** Position of one value along the dimension, 0..1. */
135
+ fractionOf(value) {
136
+ this.tmp[0] = value;
137
+ this.normalize(this.tmp, 1, 1, 0);
138
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
139
+ return this.tmp[0];
140
+ }
141
+ /**
142
+ * One value as text, the way this dimension would label a tick but with the precision a reading needs: its `format`,
143
+ * else its scale's own labelling (a band's category name), else the number to five significant figures.
144
+ */
145
+ formatValue(value) {
146
+ const a = this.attrs;
147
+ if (a.format)
148
+ return a.format(value);
149
+ const label = this.scale.label?.(value);
150
+ if (label !== undefined)
151
+ return label;
152
+ const locale = a.locale ?? this.chart?.attrs.locale;
153
+ if (locale)
154
+ return numberFormat(locale, Math.abs(value) >= 1e5 ? { maximumFractionDigits: 0 } : { maximumSignificantDigits: 5 }).format(value);
155
+ return Math.abs(value) >= 1e5 ? value.toFixed(0) : String(+value.toPrecision(5));
156
+ }
157
+ /** A tick's number as plain text, or in the locale's way if there is one. */
158
+ #plain(value, decimals) {
159
+ const locale = this.attrs.locale ?? this.chart?.attrs.locale;
160
+ return locale ? numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }).format(value) : value.toFixed(decimals);
161
+ }
162
+ /** Where the ticks are: the attributes' say if they have one, else the scale's choice of about `count`. Always inside the range. */
163
+ tickValues(count) {
164
+ const a = this.attrs, r = this.range, lo = Math.min(r.min, r.max), hi = Math.max(r.min, r.max);
165
+ const eps = (hi - lo) * 1e-9;
166
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- the attribute type with its default filled in
167
+ const explicit = a.ticks;
168
+ if (explicit) {
169
+ const given = typeof explicit === 'function' ? explicit(r) : explicit;
170
+ return given.filter((v) => v >= lo - eps && v <= hi + eps);
171
+ }
172
+ if (a.tickStep && a.tickStep > 0) {
173
+ const out = [], step = a.tickStep;
174
+ for (let k = Math.ceil(lo / step - 1e-9); k * step <= hi + eps && out.length < 1000; k++)
175
+ out.push(Number((k * step).toPrecision(12)));
176
+ return out;
177
+ }
178
+ return this.scale.ticks(r, count);
179
+ }
180
+ /** Overridden by dimensions that know their length matters (an axis wants more ticks when longer). */
181
+ tickCountFor(_lengthInPixels) { return 5; }
182
+ /** The ticks for a dimension drawn `lengthInPixels` long. `tickCount` wins; else `count`; else a number that suits the length. */
183
+ getTicks(lengthInPixels, count) {
184
+ const a = this.attrs;
185
+ count = a.tickCount ?? count ?? this.tickCountFor(lengthInPixels);
186
+ const values = this.tickValues(count);
187
+ let step = values.length > 1 ? Infinity : Math.abs(this.range.max - this.range.min) || 1;
188
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
189
+ for (let i = 1; i < values.length; i++) {
190
+ const d = Math.abs(values[i] - values[i - 1]);
191
+ if (d > 0 && d < step)
192
+ step = d;
193
+ }
194
+ // Enough decimals to tell neighbouring ticks apart, and the same for all so the labels line up.
195
+ const decimals = Math.min(10, Math.max(0, Math.ceil(-Math.log10(step) - 1e-9)));
196
+ const set = !a.format ? this.scale.labels?.(values, this.range) : undefined;
197
+ return values.map((value, i) => {
198
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- in range: the index comes from a loop bound or a length checked in the same function
199
+ let label = a.format ? a.format(value) : set ? set[i] : this.scale.label?.(value) ?? this.#plain(value, decimals);
200
+ if (label === '-0')
201
+ label = '0'; // toFixed keeps the sign of a tiny negative
202
+ return { value, label, major: true };
203
+ });
204
+ }
205
+ }
@@ -0,0 +1,53 @@
1
+ /** The browser platform: renders into a real SVG element and listens for resize and pointer input. */
2
+ import type { Size } from './geometry.ts';
3
+ import { type Platform, type PlatformNode, type RawFocus, type RawKey, type RawPointer, type SvgTag, type TextMeasurer, type Unsubscribe } from './platform.ts';
4
+ /** The part of an element the DOM platform needs. A real `HTMLElement` fits. */
5
+ export interface DOMContainer {
6
+ readonly ownerDocument: {
7
+ createElementNS(namespace: string, tag: string): unknown;
8
+ };
9
+ readonly clientWidth: number;
10
+ readonly clientHeight: number;
11
+ appendChild(node: unknown): unknown;
12
+ }
13
+ /** Choices that differ between pages: a fixed size for static layouts, canvas-free text metrics, and flush timing. */
14
+ export interface DOMPlatformOptions {
15
+ /** A fixed size. Default: the container's own size, followed as it changes. */
16
+ size?: Size;
17
+ /** Measure labels with the browser rather than estimating them. Default true. */
18
+ preciseText?: boolean;
19
+ /** When to flush after an invalidation. Default 'frame'. 'manual' leaves it to `chart.flush()`. */
20
+ schedule?: 'frame' | 'microtask' | 'manual' | ((callback: () => void) => Unsubscribe);
21
+ }
22
+ /**
23
+ * Draws into a live `<svg>` appended to a container, and reports the container's size and pointer input on it.
24
+ * The container needs a height of its own; without one the platform assumes 600 wide by 300 high.
25
+ * Pointer events are captured while a button is down. Once something in the chart listens for the pointer (the chart then
26
+ * subscribes through `onPointer`) the svg sets `touch-action: none`, so a drag or a wheel turn over the chart belongs to the
27
+ * chart (`setTouchMode('two-finger')` lets one finger scroll the page instead); until then it sets none, and the chart is a picture.
28
+ */
29
+ export declare class DOMPlatform implements Platform {
30
+ #private;
31
+ readonly name = "dom";
32
+ readonly root: PlatformNode;
33
+ schedule?: (callback: () => void) => Unsubscribe;
34
+ measureText?: TextMeasurer;
35
+ constructor(container: DOMContainer, options?: DOMPlatformOptions);
36
+ /** `|| 600` / `|| 300`: a container that has not been laid out yet reports 0, which would give an unusable chart. */
37
+ size(): Size;
38
+ createNode(tag: SvgTag): PlatformNode;
39
+ setAttribute(node: PlatformNode, name: string, value: string | number): void;
40
+ setText(node: PlatformNode, text: string): void;
41
+ insertBefore(parent: PlatformNode, node: PlatformNode, before: PlatformNode | null): void;
42
+ remove(node: PlatformNode): void;
43
+ setCursor(cursor: string): void;
44
+ setTouchMode(mode: 'always' | 'two-finger'): void;
45
+ onResize(callback: (size: Size) => void): Unsubscribe;
46
+ onPointer(callback: (pointer: RawPointer) => boolean | void): Unsubscribe;
47
+ onKey(callback: (key: RawKey, node: PlatformNode) => boolean | void): Unsubscribe;
48
+ onFocus(callback: (focus: RawFocus) => void): Unsubscribe;
49
+ /** A visually hidden live region beside the chart: SVG text is not reliably announced when it changes, an HTML status is. */
50
+ announce(text: string): void;
51
+ focus(node: PlatformNode): void;
52
+ dispose(): void;
53
+ }