@thomasfosterau/charts 0.0.0-stage → 0.1.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.
@@ -0,0 +1,936 @@
1
+ import { a as isDataRef, i as validateSpec, o as isInlineData } from "./spec-FF2Tds2a.mjs";
2
+ import { area, line } from "d3-shape";
3
+ import { scaleBand, scaleLinear, scaleOrdinal, scalePoint, scaleSqrt, scaleUtc } from "d3-scale";
4
+ // src/core/accessor.ts
5
+ /**
6
+ * Creates a typed accessor for an encoding channel.
7
+ *
8
+ * Values are reconstructed from the channel's **declared** type — never by
9
+ * sniffing the runtime shape of the JSON value (the cross-platform invariant
10
+ * every interpreter must follow):
11
+ *
12
+ * - `quantitative` → finite number
13
+ * - `temporal` → Date, parsed from ISO 8601 strings or epoch milliseconds
14
+ * - `ordinal` / `nominal` → category string (numbers/booleans are stringified)
15
+ *
16
+ * Missing fields and nulls yield `null`. Values that contradict the declared
17
+ * type throw: a declared type is a contract, and violating it is a data error
18
+ * the caller must surface, not hide.
19
+ */
20
+ function makeAccessor(channel) {
21
+ switch (channel.type) {
22
+ case "quantitative": return (datum) => toQuantitative(datum[channel.field], channel.field);
23
+ case "temporal": return (datum) => toTemporal(datum[channel.field], channel.field);
24
+ case "ordinal":
25
+ case "nominal": return (datum) => toCategory(datum[channel.field]);
26
+ }
27
+ }
28
+ function toQuantitative(value, field) {
29
+ if (value === null || value === void 0) return null;
30
+ if (typeof value === "number" && Number.isFinite(value)) return value;
31
+ throw new TypeError(`Field "${field}" is declared quantitative but datum value ${JSON.stringify(value)} is not a finite number`);
32
+ }
33
+ function toTemporal(value, field) {
34
+ if (value === null || value === void 0) return null;
35
+ if (typeof value === "number" || typeof value === "string") {
36
+ const date = new Date(value);
37
+ if (!Number.isNaN(date.getTime())) return date;
38
+ }
39
+ throw new TypeError(`Field "${field}" is declared temporal but datum value ${JSON.stringify(value)} is not a parseable date (expected ISO 8601 string or epoch milliseconds)`);
40
+ }
41
+ function toCategory(value) {
42
+ if (value === null || value === void 0) return null;
43
+ return typeof value === "string" ? value : String(value);
44
+ }
45
+ // src/core/geometry/position.ts
46
+ /**
47
+ * Maps a typed field value to a plot pixel position.
48
+ *
49
+ * Band scales position at the band centre; other scales map directly.
50
+ * Returns undefined for missing values or values outside a categorical domain.
51
+ */
52
+ function positionOf(scale, value) {
53
+ if (value === null) return void 0;
54
+ switch (scale.kind) {
55
+ case "linear": return typeof value === "number" ? scale.d3(value) : void 0;
56
+ case "utc": return value instanceof Date ? scale.d3(value) : void 0;
57
+ case "band": {
58
+ const start = scale.d3(asCategory$1(value));
59
+ return start === void 0 ? void 0 : start + scale.d3.bandwidth() / 2;
60
+ }
61
+ case "point": return scale.d3(asCategory$1(value));
62
+ }
63
+ }
64
+ /**
65
+ * The pixel position of the zero baseline for a y scale, clamped to the plot.
66
+ * Non-linear y scales (temporal/categorical) baseline at the bottom of the range.
67
+ */
68
+ function baselineOf(scale) {
69
+ const range = scale.resolved.range;
70
+ const lo = Math.min(...range);
71
+ const hi = Math.max(...range);
72
+ if (scale.kind === "linear") return Math.min(Math.max(scale.d3(0), lo), hi);
73
+ return hi;
74
+ }
75
+ function asCategory$1(value) {
76
+ return typeof value === "string" ? value : String(value);
77
+ }
78
+ // src/core/geometry/series.ts
79
+ /**
80
+ * Splits data rows into series by the colour channel, preserving data order.
81
+ * Without a colour channel, returns a single unsplit series.
82
+ */
83
+ function splitSeries(data, colorChannel, colorScale) {
84
+ if (!colorChannel || !colorScale) return [{
85
+ key: null,
86
+ color: void 0,
87
+ rows: data
88
+ }];
89
+ const accessor = makeAccessor(colorChannel);
90
+ const groups = /* @__PURE__ */ new Map();
91
+ for (const datum of data) {
92
+ const value = accessor(datum);
93
+ if (value === null) continue;
94
+ const key = typeof value === "string" ? value : String(value);
95
+ const group = groups.get(key);
96
+ if (group) group.push(datum);
97
+ else groups.set(key, [datum]);
98
+ }
99
+ return [...groups.entries()].map(([key, rows]) => ({
100
+ key,
101
+ color: colorOf(colorScale, key),
102
+ rows
103
+ }));
104
+ }
105
+ /** Resolves a colour for a single datum, or undefined without a colour encoding. */
106
+ function colorOfDatum(datum, colorChannel, colorScale) {
107
+ if (!colorChannel || !colorScale) return void 0;
108
+ const value = makeAccessor(colorChannel)(datum);
109
+ if (value === null) return void 0;
110
+ return colorOf(colorScale, value);
111
+ }
112
+ function colorOf(scale, value) {
113
+ const result = scale.apply(value);
114
+ return typeof result === "string" ? result : void 0;
115
+ }
116
+ // src/core/geometry/line.ts
117
+ /** Builds line chart geometry: one series per colour category, each with points and an SVG path. */
118
+ function buildLineGeometry(args) {
119
+ return {
120
+ mark: "line",
121
+ series: buildSeries(args)
122
+ };
123
+ }
124
+ /** Shared series-of-points construction for line and area marks. */
125
+ function buildSeries(args) {
126
+ const xAccessor = makeAccessor(args.x);
127
+ const yAccessor = makeAccessor(args.y);
128
+ const pathOf = args.pathOf ?? linePathOf;
129
+ return splitSeries(args.data, args.color, args.colorScale).map((group) => {
130
+ const points = [];
131
+ for (const datum of group.rows) {
132
+ const px = positionOf(args.xScale, xAccessor(datum));
133
+ const py = positionOf(args.yScale, yAccessor(datum));
134
+ if (px === void 0 || py === void 0) continue;
135
+ points.push({
136
+ x: px,
137
+ y: py,
138
+ datum
139
+ });
140
+ }
141
+ return {
142
+ key: group.key,
143
+ color: group.color,
144
+ points,
145
+ path: points.length > 0 ? pathOf(points) : ""
146
+ };
147
+ });
148
+ }
149
+ function linePathOf(points) {
150
+ return line().x((point) => point.x).y((point) => point.y)([...points]) ?? "";
151
+ }
152
+ // src/core/geometry/area.ts
153
+ /**
154
+ * Builds area chart geometry: like a line, but each series path is closed
155
+ * down to the zero baseline (or the bottom of the plot when zero is not in
156
+ * the y domain).
157
+ */
158
+ function buildAreaGeometry(args) {
159
+ const baseline = baselineOf(args.yScale);
160
+ return {
161
+ mark: "area",
162
+ series: buildSeries({
163
+ ...args,
164
+ pathOf: (points) => areaPathOf(points, baseline)
165
+ })
166
+ };
167
+ }
168
+ function areaPathOf(points, baseline) {
169
+ return area().x((point) => point.x).y1((point) => point.y).y0(baseline)([...points]) ?? "";
170
+ }
171
+ // src/core/geometry/bar.ts
172
+ /**
173
+ * Builds bar chart geometry. Bars grow from the zero baseline, so negative
174
+ * values produce bars below it. Band x-scales give bars their bandwidth;
175
+ * continuous x-scales get a derived width. With a range channel (`x2` or
176
+ * `y2`, spec 1.1) each bar spans from its start to its end instead: a
177
+ * horizontal range bar (a Gantt row) for `x2`, a vertical one for `y2`.
178
+ */
179
+ function buildBarGeometry(args) {
180
+ if (args.x2 !== void 0) return buildRangeBars(args, "x", args.x2);
181
+ if (args.y2 !== void 0) return buildRangeBars(args, "y", args.y2);
182
+ const xAccessor = makeAccessor(args.x);
183
+ const yAccessor = makeAccessor(args.y);
184
+ const baseline = baselineOf(args.yScale);
185
+ const barWidth = barWidthOf(args.xScale, args.data.length);
186
+ const bars = [];
187
+ for (const datum of args.data) {
188
+ const xCenter = positionOf(args.xScale, xAccessor(datum));
189
+ const yPosition = positionOf(args.yScale, yAccessor(datum));
190
+ if (xCenter === void 0 || yPosition === void 0) continue;
191
+ bars.push({
192
+ x: xCenter - barWidth / 2,
193
+ y: Math.min(yPosition, baseline),
194
+ width: barWidth,
195
+ height: Math.abs(yPosition - baseline),
196
+ color: colorOfDatum(datum, args.color, args.colorScale),
197
+ datum
198
+ });
199
+ }
200
+ return {
201
+ mark: "bar",
202
+ bars
203
+ };
204
+ }
205
+ /**
206
+ * Range bars along `axis`: each spans the start channel's position to the
207
+ * end channel's. An end before its start is normalised (the rect runs
208
+ * between the two whichever comes first), so a reversed row still draws.
209
+ * The other axis is the band each bar sits in.
210
+ */
211
+ function buildRangeBars(args, axis, end) {
212
+ const along = axis === "x" ? args.xScale : args.yScale;
213
+ const across = axis === "x" ? args.yScale : args.xScale;
214
+ const startAccessor = makeAccessor(axis === "x" ? args.x : args.y);
215
+ const endAccessor = makeAccessor(end);
216
+ const bandAccessor = makeAccessor(axis === "x" ? args.y : args.x);
217
+ const thickness = barWidthOf(across, args.data.length);
218
+ const bars = [];
219
+ for (const datum of args.data) {
220
+ const from = positionOf(along, startAccessor(datum));
221
+ const to = positionOf(along, endAccessor(datum));
222
+ const centre = positionOf(across, bandAccessor(datum));
223
+ if (from === void 0 || to === void 0 || centre === void 0) continue;
224
+ const low = Math.min(from, to);
225
+ const length = Math.abs(to - from);
226
+ const color = colorOfDatum(datum, args.color, args.colorScale);
227
+ bars.push(axis === "x" ? {
228
+ x: low,
229
+ y: centre - thickness / 2,
230
+ width: length,
231
+ height: thickness,
232
+ color,
233
+ datum
234
+ } : {
235
+ x: centre - thickness / 2,
236
+ y: low,
237
+ width: thickness,
238
+ height: length,
239
+ color,
240
+ datum
241
+ });
242
+ }
243
+ return {
244
+ mark: "bar",
245
+ bars
246
+ };
247
+ }
248
+ function barWidthOf(xScale, count) {
249
+ if (xScale.kind === "band") return xScale.d3.bandwidth();
250
+ const range = xScale.resolved.range;
251
+ const extent = Math.abs(Math.max(...range) - Math.min(...range));
252
+ return count > 0 ? Math.max(1, extent / count * .8) : 1;
253
+ }
254
+ // src/core/scale.ts
255
+ /**
256
+ * Default categorical palette (Tableau 10). Interpreters may override; the
257
+ * core resolves concrete colours so geometry is directly drawable.
258
+ */
259
+ const CATEGORICAL_PALETTE = [
260
+ "#4e79a7",
261
+ "#f28e2c",
262
+ "#e15759",
263
+ "#76b7b4",
264
+ "#59a14f",
265
+ "#edc949",
266
+ "#af7aa1",
267
+ "#ff9da7",
268
+ "#9c755f",
269
+ "#bab0ab"
270
+ ];
271
+ /** Default radius range (pixels) for the size channel on point marks. */
272
+ const DEFAULT_RADIUS_RANGE = [3, 12];
273
+ /** Default point radius when no size encoding is present. */
274
+ const DEFAULT_POINT_RADIUS = 4;
275
+ /**
276
+ * Resolves a positional (x or y) scale from a channel's declared type.
277
+ *
278
+ * - `quantitative` → linear
279
+ * - `temporal` → UTC time (UTC for cross-platform determinism)
280
+ * - `ordinal`/`nominal` → band for bar marks (bars need bandwidth),
281
+ * point for line/point/area marks (single positions)
282
+ */
283
+ function resolvePositionalScale(channel, mark, pixelRange, data, hint, rangeEnd) {
284
+ switch (channel.type) {
285
+ case "quantitative": return resolveLinear(channel, mark, pixelRange, data, hint, rangeEnd);
286
+ case "temporal": return resolveUtc(channel, pixelRange, data, hint, rangeEnd);
287
+ case "ordinal":
288
+ case "nominal": return resolveCategorical(channel, mark, pixelRange, data, hint);
289
+ }
290
+ }
291
+ function resolveLinear(channel, mark, pixelRange, data, hint, rangeEnd) {
292
+ let domain;
293
+ const hintDomain = numericHintDomain(hint);
294
+ if (hintDomain) domain = hintDomain;
295
+ else {
296
+ const values = valuesOf(data, channel, rangeEnd).filter((value) => typeof value === "number");
297
+ domain = values.length > 0 ? [Math.min(...values), Math.max(...values)] : [0, 1];
298
+ }
299
+ if (hint?.zero ?? (mark === "bar" && rangeEnd === void 0)) domain = [Math.min(domain[0], 0), Math.max(domain[1], 0)];
300
+ if (domain[0] === domain[1]) domain = [domain[0] - 1, domain[1] + 1];
301
+ let d3Scale = scaleLinear().domain(domain).range(pixelRange);
302
+ if (hint?.nice) d3Scale = d3Scale.nice();
303
+ const finalDomain = d3Scale.domain();
304
+ return {
305
+ kind: "linear",
306
+ d3: d3Scale,
307
+ resolved: {
308
+ kind: "linear",
309
+ domain: finalDomain,
310
+ range: [...pixelRange],
311
+ apply: (value) => typeof value === "number" ? d3Scale(value) : void 0,
312
+ bandwidth: 0
313
+ }
314
+ };
315
+ }
316
+ function resolveUtc(channel, pixelRange, data, hint, rangeEnd) {
317
+ let domain;
318
+ const hintDomain = temporalHintDomain(hint);
319
+ if (hintDomain) domain = hintDomain;
320
+ else {
321
+ const times = valuesOf(data, channel, rangeEnd).filter((value) => value instanceof Date).map((value) => value.getTime());
322
+ domain = times.length > 0 ? [new Date(Math.min(...times)), new Date(Math.max(...times))] : [new Date(Date.UTC(2e3, 0, 1)), new Date(Date.UTC(2e3, 0, 2))];
323
+ }
324
+ if (domain[0].getTime() === domain[1].getTime()) {
325
+ const dayMs = 864e5;
326
+ domain = [/* @__PURE__ */ new Date(domain[0].getTime() - dayMs), new Date(domain[1].getTime() + dayMs)];
327
+ }
328
+ let d3Scale = scaleUtc().domain(domain).range(pixelRange);
329
+ if (hint?.nice) d3Scale = d3Scale.nice();
330
+ const finalDomain = d3Scale.domain();
331
+ return {
332
+ kind: "utc",
333
+ d3: d3Scale,
334
+ resolved: {
335
+ kind: "utc",
336
+ domain: finalDomain,
337
+ range: [...pixelRange],
338
+ apply: (value) => value instanceof Date ? d3Scale(value) : void 0,
339
+ bandwidth: 0
340
+ }
341
+ };
342
+ }
343
+ /** Every value of `channel` and, for a range, of its end channel `rangeEnd`. */
344
+ function valuesOf(data, channel, rangeEnd) {
345
+ const accessors = rangeEnd === void 0 ? [makeAccessor(channel)] : [makeAccessor(channel), makeAccessor(rangeEnd)];
346
+ return data.flatMap((datum) => accessors.map((accessor) => accessor(datum)));
347
+ }
348
+ function resolveCategorical(channel, mark, pixelRange, data, hint) {
349
+ const domain = categoricalDomain(channel, data, hint);
350
+ if (mark === "bar") {
351
+ const d3Scale = scaleBand().domain(domain).range(pixelRange).padding(.1);
352
+ return {
353
+ kind: "band",
354
+ d3: d3Scale,
355
+ resolved: {
356
+ kind: "band",
357
+ domain,
358
+ range: [...pixelRange],
359
+ apply: (value) => d3Scale(asCategory(value)),
360
+ bandwidth: d3Scale.bandwidth()
361
+ }
362
+ };
363
+ }
364
+ const d3Scale = scalePoint().domain(domain).range(pixelRange).padding(.5);
365
+ return {
366
+ kind: "point",
367
+ d3: d3Scale,
368
+ resolved: {
369
+ kind: "point",
370
+ domain,
371
+ range: [...pixelRange],
372
+ apply: (value) => d3Scale(asCategory(value)),
373
+ bandwidth: 0
374
+ }
375
+ };
376
+ }
377
+ /**
378
+ * Resolves the colour scale for a colour channel.
379
+ *
380
+ * - `ordinal`/`nominal` → categorical palette
381
+ * - `quantitative`/`temporal` → normalised [0, 1] linear scale; interpreters
382
+ * map the normalised value onto their own gradient
383
+ */
384
+ function resolveColorScale(channel, data) {
385
+ if (channel.type === "ordinal" || channel.type === "nominal") {
386
+ const domain = categoricalDomain(channel, data);
387
+ const d3Scale = scaleOrdinal().domain(domain).range(CATEGORICAL_PALETTE.slice(0, Math.max(domain.length, 1)));
388
+ return {
389
+ kind: "ordinal",
390
+ domain,
391
+ range: domain.map((category) => d3Scale(category)),
392
+ apply: (value) => d3Scale(asCategory(value)),
393
+ bandwidth: 0
394
+ };
395
+ }
396
+ const accessor = makeAccessor(channel);
397
+ const values = [];
398
+ for (const datum of data) {
399
+ const value = accessor(datum);
400
+ if (typeof value === "number") values.push(value);
401
+ else if (value instanceof Date) values.push(value.getTime());
402
+ }
403
+ const domain = values.length > 0 ? [Math.min(...values), Math.max(...values)] : [0, 1];
404
+ const d3Scale = scaleLinear().domain(domain).range([0, 1]);
405
+ return {
406
+ kind: "linear",
407
+ domain,
408
+ range: [0, 1],
409
+ apply: (value) => {
410
+ if (typeof value === "number") return d3Scale(value);
411
+ if (value instanceof Date) return d3Scale(value.getTime());
412
+ },
413
+ bandwidth: 0
414
+ };
415
+ }
416
+ /**
417
+ * Resolves the size scale for point marks: quantitative values map onto a
418
+ * radius range with square-root scaling so symbol *area* is proportional to
419
+ * the value.
420
+ */
421
+ function resolveSizeScale(channel, data) {
422
+ const accessor = makeAccessor(channel);
423
+ const values = [];
424
+ for (const datum of data) {
425
+ const value = accessor(datum);
426
+ if (typeof value === "number") values.push(value);
427
+ }
428
+ const domain = values.length > 0 ? [Math.min(...values), Math.max(...values)] : [0, 1];
429
+ const d3Scale = scaleSqrt().domain(domain).range(DEFAULT_RADIUS_RANGE);
430
+ return {
431
+ kind: "linear",
432
+ domain,
433
+ range: [...DEFAULT_RADIUS_RANGE],
434
+ apply: (value) => typeof value === "number" ? d3Scale(value) : void 0,
435
+ bandwidth: 0
436
+ };
437
+ }
438
+ function numericHintDomain(hint) {
439
+ if (!hint?.domain || hint.domain.length < 2) return void 0;
440
+ const [min, max] = [hint.domain[0], hint.domain[hint.domain.length - 1]];
441
+ if (typeof min === "number" && typeof max === "number") return [min, max];
442
+ }
443
+ function temporalHintDomain(hint) {
444
+ if (!hint?.domain || hint.domain.length < 2) return void 0;
445
+ const min = new Date(hint.domain[0]);
446
+ const max = new Date(hint.domain[hint.domain.length - 1]);
447
+ if (!Number.isNaN(min.getTime()) && !Number.isNaN(max.getTime())) return [min, max];
448
+ }
449
+ function categoricalDomain(channel, data, hint) {
450
+ if (hint?.domain && hint.domain.length > 0) return hint.domain.map((value) => String(value));
451
+ const accessor = makeAccessor(channel);
452
+ const seen = /* @__PURE__ */ new Set();
453
+ const domain = [];
454
+ for (const datum of data) {
455
+ const value = accessor(datum);
456
+ if (typeof value === "string" && !seen.has(value)) {
457
+ seen.add(value);
458
+ domain.push(value);
459
+ }
460
+ }
461
+ return domain;
462
+ }
463
+ function asCategory(value) {
464
+ return typeof value === "string" ? value : String(value);
465
+ }
466
+ // src/core/geometry/point.ts
467
+ /** Builds scatter/point chart geometry: one circle per datum. */
468
+ function buildPointGeometry(args) {
469
+ const xAccessor = makeAccessor(args.x);
470
+ const yAccessor = makeAccessor(args.y);
471
+ const sizeAccessor = args.size ? makeAccessor(args.size) : void 0;
472
+ const points = [];
473
+ for (const datum of args.data) {
474
+ const cx = positionOf(args.xScale, xAccessor(datum));
475
+ const cy = positionOf(args.yScale, yAccessor(datum));
476
+ if (cx === void 0 || cy === void 0) continue;
477
+ points.push({
478
+ cx,
479
+ cy,
480
+ r: radiusOf(datum, sizeAccessor, args.sizeScale),
481
+ color: colorOfDatum(datum, args.color, args.colorScale),
482
+ datum
483
+ });
484
+ }
485
+ return {
486
+ mark: "point",
487
+ points
488
+ };
489
+ }
490
+ function radiusOf(datum, sizeAccessor, sizeScale) {
491
+ if (!sizeAccessor || !sizeScale) return 4;
492
+ const value = sizeAccessor(datum);
493
+ if (value === null) return 4;
494
+ const radius = sizeScale.apply(value);
495
+ return typeof radius === "number" ? radius : 4;
496
+ }
497
+ // src/core/geometry/tree.ts
498
+ /**
499
+ * The inline style every interpreter puts on a tree's nested-list text
500
+ * alternative: in the accessibility tree, out of sight (the classic
501
+ * visually-hidden pattern, inline because these interpreters ship no CSS).
502
+ */
503
+ const VISUALLY_HIDDEN_STYLE = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
504
+ /** The text of one outline entry: its label, then its colour category in words. */
505
+ function outlineText(node) {
506
+ return node.category === void 0 ? node.label : `${node.label}, ${node.category}`;
507
+ }
508
+ /** Height of a node box, in pixels. */
509
+ const NODE_HEIGHT = 28;
510
+ /** The width of one label character, estimated (the pure path has no text measurer). */
511
+ const CHAR_WIDTH = 7;
512
+ /** Horizontal padding inside a node box. */
513
+ const NODE_PADDING = 16;
514
+ /** The narrowest a node box gets. */
515
+ const MIN_NODE_WIDTH = 24;
516
+ /** How a channel's value names a node: dates as ISO strings, everything else as text. */
517
+ function keyOf(value) {
518
+ if (value === null) return null;
519
+ return value instanceof Date ? value.toISOString() : String(value);
520
+ }
521
+ /**
522
+ * Turns rows into a forest, checking the row-level rules `parseSpec` cannot:
523
+ * ids are present and unique, every parent is an id, and there are no
524
+ * cycles. Each error names the offending row (1-based), and is thrown, so
525
+ * `resolveChart` shows it in place of the chart.
526
+ */
527
+ function buildForest(data, id, parent, label) {
528
+ const idOf = makeAccessor(id);
529
+ const parentOf = makeAccessor(parent);
530
+ const labelOf = label === void 0 ? void 0 : makeAccessor(label);
531
+ const byId = /* @__PURE__ */ new Map();
532
+ const parents = /* @__PURE__ */ new Map();
533
+ data.forEach((datum, index) => {
534
+ const key = keyOf(idOf(datum));
535
+ if (key === null) throw new Error(`Tree row ${index + 1}: missing id`);
536
+ if (byId.has(key)) throw new Error(`Tree row ${index + 1}: duplicate id "${key}"`);
537
+ byId.set(key, {
538
+ id: key,
539
+ label: keyOf(labelOf?.(datum) ?? null) ?? key,
540
+ datum,
541
+ children: []
542
+ });
543
+ parents.set(key, keyOf(parentOf(datum)));
544
+ });
545
+ const roots = [];
546
+ data.forEach((datum, index) => {
547
+ const key = keyOf(idOf(datum)) ?? "";
548
+ const row = byId.get(key);
549
+ const parentKey = parents.get(key) ?? null;
550
+ if (row === void 0) return;
551
+ if (parentKey === null) {
552
+ roots.push(row);
553
+ return;
554
+ }
555
+ const parentRow = byId.get(parentKey);
556
+ if (parentRow === void 0) throw new Error(`Tree row ${index + 1}: parent "${parentKey}" is not an id`);
557
+ parentRow.children.push(row);
558
+ });
559
+ const reached = /* @__PURE__ */ new Set();
560
+ const visit = (row) => {
561
+ reached.add(row.id);
562
+ row.children.forEach(visit);
563
+ };
564
+ roots.forEach(visit);
565
+ const stranded = data.findIndex((datum) => !reached.has(keyOf(idOf(datum)) ?? ""));
566
+ if (stranded !== -1) throw new Error(`Tree row ${stranded + 1}: its parents form a cycle`);
567
+ return roots;
568
+ }
569
+ /**
570
+ * Places the forest in unit coordinates: leaves take consecutive breadth
571
+ * slots in depth-first order, and every parent sits centred over its first
572
+ * and last child. Linear in the number of rows, never overlapping (siblings'
573
+ * subtrees occupy disjoint slot ranges), and a forest's roots stand side by
574
+ * side as if under one invisible root.
575
+ */
576
+ function place(roots) {
577
+ const nodes = [];
578
+ let nextLeaf = 0;
579
+ let maxDepth = 0;
580
+ const walk = (row, depth, parent) => {
581
+ maxDepth = Math.max(maxDepth, depth);
582
+ const index = nodes.length;
583
+ nodes.push({
584
+ row,
585
+ slot: 0,
586
+ depth,
587
+ parent
588
+ });
589
+ if (row.children.length === 0) {
590
+ const leaf = {
591
+ row,
592
+ slot: nextLeaf,
593
+ depth,
594
+ parent
595
+ };
596
+ nextLeaf += 1;
597
+ nodes[index] = leaf;
598
+ return leaf;
599
+ }
600
+ const self = {
601
+ row,
602
+ slot: 0,
603
+ depth,
604
+ parent
605
+ };
606
+ nodes[index] = self;
607
+ const placed = row.children.map((child) => walk(child, depth + 1, self));
608
+ self.slot = ((placed[0]?.slot ?? 0) + (placed.at(-1)?.slot ?? 0)) / 2;
609
+ return self;
610
+ };
611
+ roots.forEach((root) => walk(root, 0, void 0));
612
+ return {
613
+ nodes,
614
+ leaves: Math.max(nextLeaf, 1),
615
+ depth: maxDepth
616
+ };
617
+ }
618
+ /** The nested depth-first structure of the forest: the interpreters' text alternative. */
619
+ function outlineOf(rows, color) {
620
+ const categoryOf = color === void 0 ? void 0 : makeAccessor(color);
621
+ return rows.map((row) => ({
622
+ id: row.id,
623
+ label: row.label,
624
+ category: keyOf(categoryOf?.(row.datum) ?? null) ?? void 0,
625
+ children: outlineOf(row.children, color)
626
+ }));
627
+ }
628
+ /** An SVG path from a parent's edge to a child's, along the depth axis. */
629
+ function linkPath(from, to, vertical, curved) {
630
+ if (vertical) {
631
+ const x1 = from.x + from.width / 2;
632
+ const y1 = from.y + from.height;
633
+ const x2 = to.x + to.width / 2;
634
+ const y2 = to.y;
635
+ const mid = (y1 + y2) / 2;
636
+ return curved ? `M${x1},${y1}C${x1},${mid} ${x2},${mid} ${x2},${y2}` : `M${x1},${y1}V${mid}H${x2}V${y2}`;
637
+ }
638
+ const x1 = from.x + from.width;
639
+ const y1 = from.y + from.height / 2;
640
+ const x2 = to.x;
641
+ const y2 = to.y + to.height / 2;
642
+ const mid = (x1 + x2) / 2;
643
+ return curved ? `M${x1},${y1}C${mid},${y1} ${mid},${y2} ${x2},${y2}` : `M${x1},${y1}H${mid}V${y2}H${x2}`;
644
+ }
645
+ /** A placed node's box in plot pixels: sized from its label, centred in its slot, at its depth's level. */
646
+ function boxOf(node, frame, color) {
647
+ const { vertical, depth, length, slotSize } = frame;
648
+ const levelSize = depth === 0 ? 0 : length / depth;
649
+ const estimate = node.row.label.length * CHAR_WIDTH + NODE_PADDING;
650
+ const width = vertical ? Math.max(Math.min(estimate, slotSize - 4), MIN_NODE_WIDTH) : Math.max(Math.min(estimate, Math.max(levelSize - 16, MIN_NODE_WIDTH)), MIN_NODE_WIDTH);
651
+ const height = vertical ? NODE_HEIGHT : Math.min(NODE_HEIGHT, slotSize - 4);
652
+ const centreBreadth = (node.slot + .5) * slotSize;
653
+ const depthStart = depth === 0 ? 0 : node.depth / depth * (length - (vertical ? height : width));
654
+ return {
655
+ id: node.row.id,
656
+ label: node.row.label,
657
+ depth: node.depth,
658
+ x: vertical ? centreBreadth - width / 2 : depthStart,
659
+ y: vertical ? depthStart : centreBreadth - height / 2,
660
+ width,
661
+ height,
662
+ color,
663
+ datum: node.row.datum
664
+ };
665
+ }
666
+ /** One link per non-root node, from its parent's box to its own. */
667
+ function linksOf(nodes, boxes, vertical, curved) {
668
+ return nodes.flatMap((node) => {
669
+ const box = boxes.get(node);
670
+ const parentBox = node.parent === void 0 ? void 0 : boxes.get(node.parent);
671
+ return box === void 0 || parentBox === void 0 ? [] : [{
672
+ from: parentBox.id,
673
+ to: box.id,
674
+ path: linkPath(parentBox, box, vertical, curved)
675
+ }];
676
+ });
677
+ }
678
+ /**
679
+ * Builds a `tree` mark's geometry (spec 1.1): node boxes, the links between
680
+ * them and the nested outline the interpreters render as the accessible
681
+ * text alternative. Pure and DOM-free; label widths are estimated from
682
+ * character counts, as the pure path's tick layout does.
683
+ *
684
+ * @throws {Error} on a duplicate or missing id, an unknown parent, or a cycle; the message names the row.
685
+ */
686
+ function buildTreeGeometry(args) {
687
+ const roots = buildForest(args.data, args.id, args.parent, args.label);
688
+ const { nodes, leaves, depth } = place(roots);
689
+ const vertical = args.options?.orientation !== "horizontal";
690
+ const curved = args.options?.linkStyle === "curved";
691
+ const breadth = vertical ? args.plot.width : args.plot.height;
692
+ const frame = {
693
+ vertical,
694
+ depth,
695
+ length: vertical ? args.plot.height : args.plot.width,
696
+ slotSize: breadth / leaves
697
+ };
698
+ const boxes = new Map(nodes.map((node) => [node, boxOf(node, frame, colorOfDatum(node.row.datum, args.color, args.colorScale))]));
699
+ const links = linksOf(nodes, boxes, vertical, curved);
700
+ return {
701
+ mark: "tree",
702
+ nodes: nodes.map((node) => boxes.get(node)).filter((box) => box !== void 0),
703
+ links,
704
+ outline: outlineOf(roots, args.color)
705
+ };
706
+ }
707
+ // src/core/layout.ts
708
+ /** Default margins, leaving room for axis ticks and labels. */
709
+ const DEFAULT_MARGINS = {
710
+ top: 20,
711
+ right: 20,
712
+ bottom: 40,
713
+ left: 48
714
+ };
715
+ /**
716
+ * Computes chart dimensions from an outer box and optional margin overrides.
717
+ *
718
+ * Missing margins take their {@link DEFAULT_MARGINS} value. The plot area
719
+ * never goes negative: its width and height are clamped at zero, while the
720
+ * margins are kept as given.
721
+ */
722
+ function computeDimensions(outer, margins) {
723
+ const resolved = {
724
+ ...DEFAULT_MARGINS,
725
+ ...margins
726
+ };
727
+ return {
728
+ outer,
729
+ margins: resolved,
730
+ plot: {
731
+ width: Math.max(0, outer.width - resolved.left - resolved.right),
732
+ height: Math.max(0, outer.height - resolved.top - resolved.bottom)
733
+ }
734
+ };
735
+ }
736
+ // src/core/ticks.ts
737
+ /** Default tick count for continuous scales. */
738
+ const DEFAULT_TICK_COUNT = 5;
739
+ /**
740
+ * Computes axis ticks for a positional scale.
741
+ *
742
+ * - Continuous scales (linear, UTC) use d3's tick generation and formatting.
743
+ * UTC formatting is deterministic across machines and time zones.
744
+ * - Categorical scales (band, point) emit one tick per category, centred on
745
+ * the band.
746
+ */
747
+ function computeTicks(scale, count = 5) {
748
+ switch (scale.kind) {
749
+ case "linear": {
750
+ const format = scale.d3.tickFormat(count);
751
+ return scale.d3.ticks(count).map((value) => ({
752
+ value,
753
+ position: scale.d3(value),
754
+ label: format(value)
755
+ }));
756
+ }
757
+ case "utc": {
758
+ const format = scale.d3.tickFormat(count);
759
+ return scale.d3.ticks(count).map((value) => ({
760
+ value,
761
+ position: scale.d3(value),
762
+ label: format(value)
763
+ }));
764
+ }
765
+ case "band": {
766
+ const bandwidth = scale.d3.bandwidth();
767
+ return scale.d3.domain().map((value) => ({
768
+ value,
769
+ position: (scale.d3(value) ?? 0) + bandwidth / 2,
770
+ label: value
771
+ }));
772
+ }
773
+ case "point": return scale.d3.domain().map((value) => ({
774
+ value,
775
+ position: scale.d3(value) ?? 0,
776
+ label: value
777
+ }));
778
+ }
779
+ }
780
+ // src/core/context.ts
781
+ /**
782
+ * The Layer 2 entry point: turns (spec, measured box, resolved data) into
783
+ * everything an interpreter needs to draw — dimensions, scales, geometry,
784
+ * and ticks.
785
+ *
786
+ * Pure and synchronous: no DOM, no reactivity, no rendering, no data
787
+ * fetching. When the spec's data is a reference (`data.ref`), the host must
788
+ * resolve it and pass the rows; this function never fetches.
789
+ *
790
+ * @param spec - A parsed portable chart spec.
791
+ * @param box - The measured outer pixel box available to the chart.
792
+ * @param data - Resolved data rows. Optional when the spec carries inline values.
793
+ * @param options - Layout options (margins).
794
+ *
795
+ * @throws {Error} when no data rows are available (spec has a ref and no rows were passed).
796
+ * @throws {TypeError} when a datum value contradicts its channel's declared type.
797
+ */
798
+ function computeContext(spec, box, data, options) {
799
+ const rows = resolveRows(spec, data);
800
+ const dimensions = computeDimensions(box, options?.margins);
801
+ const xPositional = spec.encoding.x ? resolvePositionalScale(spec.encoding.x, spec.mark, [0, dimensions.plot.width], rows, spec.scales?.x, spec.encoding.x2) : void 0;
802
+ const yPositional = spec.encoding.y ? resolvePositionalScale(spec.encoding.y, spec.mark, [dimensions.plot.height, 0], rows, spec.scales?.y, spec.encoding.y2) : void 0;
803
+ const colorScale = spec.encoding.color ? resolveColorScale(spec.encoding.color, rows) : void 0;
804
+ const sizeScale = spec.encoding.size ? resolveSizeScale(spec.encoding.size, rows) : void 0;
805
+ return {
806
+ dimensions,
807
+ scales: buildScales(xPositional, yPositional, colorScale, sizeScale),
808
+ geometry: spec.mark === "tree" ? buildTreeMark(spec, rows, dimensions.plot, colorScale) : buildGeometry(spec, rows, xPositional, yPositional, colorScale, sizeScale),
809
+ ticks: buildTicks(xPositional, yPositional)
810
+ };
811
+ }
812
+ function resolveRows(spec, data) {
813
+ if (data) return data;
814
+ if (isInlineData(spec.data)) return spec.data.values;
815
+ throw new Error(`Chart data is a ${spec.data.ref.kind} reference ("${spec.data.ref.id}"); the host must resolve it and pass rows to computeContext`);
816
+ }
817
+ function buildScales(x, y, color, size) {
818
+ return {
819
+ ...x ? { x: x.resolved } : {},
820
+ ...y ? { y: y.resolved } : {},
821
+ ...color ? { color } : {},
822
+ ...size ? { size } : {}
823
+ };
824
+ }
825
+ function buildTicks(x, y) {
826
+ return {
827
+ ...x ? { x: computeTicks(x) } : {},
828
+ ...y ? { y: computeTicks(y) } : {}
829
+ };
830
+ }
831
+ /** A `tree` mark's geometry; `parseSpec` has guaranteed its `id` and `parent` channels. */
832
+ function buildTreeMark(spec, rows, plot, colorScale) {
833
+ const { id, parent, label, color } = spec.encoding;
834
+ if (id === void 0 || parent === void 0) return {
835
+ mark: "tree",
836
+ nodes: [],
837
+ links: [],
838
+ outline: []
839
+ };
840
+ return buildTreeGeometry({
841
+ data: rows,
842
+ plot,
843
+ id,
844
+ parent,
845
+ label,
846
+ color,
847
+ colorScale,
848
+ options: spec.tree
849
+ });
850
+ }
851
+ function buildGeometry(spec, rows, xScale, yScale, colorScale, sizeScale) {
852
+ if (!spec.encoding.x || !spec.encoding.y || !xScale || !yScale) return emptyGeometry(spec.mark);
853
+ const args = {
854
+ data: rows,
855
+ x: spec.encoding.x,
856
+ y: spec.encoding.y,
857
+ xScale,
858
+ yScale,
859
+ color: spec.encoding.color,
860
+ colorScale
861
+ };
862
+ switch (spec.mark) {
863
+ case "line": return buildLineGeometry(args);
864
+ case "area": return buildAreaGeometry(args);
865
+ case "bar": return buildBarGeometry({
866
+ ...args,
867
+ x2: spec.encoding.x2,
868
+ y2: spec.encoding.y2
869
+ });
870
+ case "point": return buildPointGeometry({
871
+ ...args,
872
+ size: spec.encoding.size,
873
+ sizeScale
874
+ });
875
+ case "tree": return emptyGeometry("tree");
876
+ }
877
+ }
878
+ function emptyGeometry(mark) {
879
+ switch (mark) {
880
+ case "line": return {
881
+ mark: "line",
882
+ series: []
883
+ };
884
+ case "area": return {
885
+ mark: "area",
886
+ series: []
887
+ };
888
+ case "bar": return {
889
+ mark: "bar",
890
+ bars: []
891
+ };
892
+ case "point": return {
893
+ mark: "point",
894
+ points: []
895
+ };
896
+ case "tree": return {
897
+ mark: "tree",
898
+ nodes: [],
899
+ links: [],
900
+ outline: []
901
+ };
902
+ }
903
+ }
904
+ // src/core/resolve.ts
905
+ /**
906
+ * Validates a spec and computes its geometry, turning every failure into a
907
+ * message rather than a throw: an invalid spec, a data reference with no
908
+ * resolved rows, or an error from {@link computeContext}. The bundled Svelte
909
+ * `Chart` component is this function plus SVG.
910
+ */
911
+ function resolveChart(spec, box, data, options) {
912
+ const validation = validateSpec(spec);
913
+ if (!validation.ok) return {
914
+ kind: "error",
915
+ message: `Invalid chart spec: ${validation.errors[0] ?? ""}`
916
+ };
917
+ if (isDataRef(validation.value.data) && !data) return {
918
+ kind: "error",
919
+ message: "Chart data is stored externally; pass resolved rows via the data prop"
920
+ };
921
+ try {
922
+ return {
923
+ kind: "ready",
924
+ spec: validation.value,
925
+ context: computeContext(validation.value, box, data, options)
926
+ };
927
+ } catch (error) {
928
+ return {
929
+ kind: "error",
930
+ message: error instanceof Error ? error.message : "Failed to compute chart"
931
+ };
932
+ }
933
+ }
934
+ export { buildBarGeometry as _, DEFAULT_MARGINS as a, makeAccessor as b, buildTreeGeometry as c, CATEGORICAL_PALETTE as d, DEFAULT_POINT_RADIUS as f, resolveSizeScale as g, resolvePositionalScale as h, computeTicks as i, outlineText as l, resolveColorScale as m, computeContext as n, computeDimensions as o, DEFAULT_RADIUS_RANGE as p, DEFAULT_TICK_COUNT as r, VISUALLY_HIDDEN_STYLE as s, resolveChart as t, buildPointGeometry as u, buildAreaGeometry as v, buildLineGeometry as y };
935
+
936
+ //# sourceMappingURL=core-BVbfW7N6.mjs.map