@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2

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 (159) hide show
  1. package/dist/index.d.ts +123 -14
  2. package/dist/index.js +16166 -10027
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/keys.test.ts +3 -9
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/keys.ts +5 -4
  122. package/src/compiler/normalize.ts +150 -7
  123. package/src/compiler/types.ts +11 -0
  124. package/src/compiler/validate.ts +664 -18
  125. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  126. package/src/endpoint-labels/compute.ts +40 -12
  127. package/src/endpoint-labels/format.ts +45 -15
  128. package/src/endpoint-labels/predict.ts +50 -36
  129. package/src/format/__tests__/field-format.test.ts +164 -0
  130. package/src/format/field-format.ts +27 -0
  131. package/src/geo/__tests__/compile-map.test.ts +621 -0
  132. package/src/geo/compile-map.ts +856 -0
  133. package/src/geo/join.ts +115 -0
  134. package/src/geo/projections.ts +45 -0
  135. package/src/geo/types.ts +24 -0
  136. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  137. package/src/graphs/compile-graph.ts +27 -18
  138. package/src/index.ts +20 -2
  139. package/src/layout/axes/ticks.ts +59 -10
  140. package/src/layout/axes.ts +35 -6
  141. package/src/layout/dimensions.ts +139 -83
  142. package/src/layout/facet.ts +14 -3
  143. package/src/layout/plan.ts +83 -33
  144. package/src/layout/scales.ts +132 -15
  145. package/src/legend/__tests__/continuous.test.ts +318 -0
  146. package/src/legend/__tests__/size.test.ts +348 -0
  147. package/src/legend/__tests__/suppression.test.ts +17 -1
  148. package/src/legend/compute.ts +119 -13
  149. package/src/legend/continuous.ts +327 -0
  150. package/src/legend/size.ts +407 -0
  151. package/src/legend/suppression.ts +8 -7
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -9,15 +9,15 @@ import type {
9
9
  } from '@opendata-ai/openchart-core';
10
10
  import {
11
11
  AXIS_TITLE_TRAILING_PAD,
12
- abbreviateNumber,
13
12
  BREAKPOINT_COMPACT_MAX,
13
+ computeFieldFormatContext,
14
+ defaultNumberFormatter,
14
15
  estimateTextWidth,
15
- formatNumber,
16
16
  getAxisTitleOffset,
17
+ resolveNumberFormatter,
17
18
  resolveTheme,
18
19
  TICK_LABEL_OFFSET,
19
20
  } from '@opendata-ai/openchart-core';
20
- import { format as d3Format } from 'd3-format';
21
21
  import { scaleLinear } from 'd3-scale';
22
22
  import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
23
23
  import { flattenLayers } from '../compiler/index';
@@ -75,9 +75,16 @@ export function compileLayer(
75
75
  // axes. Freeze the primary's area (and inherit its theme) for every leaf.
76
76
  const leafOptions: CompileOptions = { ...options, frozenChartArea: primaryLayout.area };
77
77
 
78
+ // Shared scales means shared *domains*, not just a shared plot rect. Each leaf
79
+ // otherwise re-fits its own domain from its own rows, so a layer holding fewer
80
+ // or narrower rows than its siblings (a label layer naming only the notable
81
+ // points, say) lands on a different scale and its marks slide off the ones
82
+ // they annotate. Union the quantitative domains across every leaf and pin them.
83
+ const sharedDomains = computeSharedDomains(leaves);
84
+
78
85
  for (const { leaf } of indexedLeaves) {
79
86
  const themedLeaf = {
80
- ...(leaf as ChartSpec),
87
+ ...withSharedDomains(leaf as ChartSpec, sharedDomains),
81
88
  theme: (leaf as ChartSpec).theme ?? spec.theme,
82
89
  darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode,
83
90
  };
@@ -96,13 +103,20 @@ export function compileLayer(
96
103
  }
97
104
  }
98
105
 
106
+ const mergedLegend = {
107
+ ...primaryLayout.legend,
108
+ ...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
109
+ } as typeof primaryLayout.legend;
110
+
99
111
  return {
100
112
  ...primaryLayout,
101
113
  marks: allMarks,
102
- legend: {
103
- ...primaryLayout.legend,
104
- ...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
105
- } as typeof primaryLayout.legend,
114
+ legend: mergedLegend,
115
+ // `legends` is what the renderer iterates, so the merged color legend has to
116
+ // land in it too. Spreading `primaryLayout` alone would leave legends[0] as
117
+ // the *pre-merge* legend and every leaf past the first would vanish from the
118
+ // rendered key while `layout.legend` claimed otherwise.
119
+ legends: [mergedLegend, ...primaryLayout.legends.slice(1)],
106
120
  };
107
121
  }
108
122
 
@@ -142,18 +156,15 @@ function estimateYAxisLabelWidth(
142
156
  const v = Number(row[yField]);
143
157
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
144
158
  }
159
+ const ctx = computeFieldFormatContext(data.map((r) => r[yField]));
145
160
  let sampleLabel: string;
146
161
  if (yAxisFormat) {
147
- try {
148
- const fmt = d3Format(yAxisFormat);
149
- sampleLabel = fmt(maxAbsVal);
150
- } catch {
151
- sampleLabel = String(maxAbsVal);
152
- }
162
+ const fmt = resolveNumberFormatter(yAxisFormat, ctx);
163
+ sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
153
164
  } else {
154
- // Use the same formatting as tick labels instead of hardcoded magnitude guesses
155
- sampleLabel =
156
- Math.abs(maxAbsVal) >= 1000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
165
+ sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
166
+ maxAbsVal,
167
+ );
157
168
  }
158
169
  const hasNeg = data.some((r) => Number(r[yField]) < 0);
159
170
  const labelEst = (hasNeg ? '-' : '') + sampleLabel;
@@ -382,6 +393,11 @@ function compileLayerIndependent(
382
393
  const marks =
383
394
  z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
384
395
 
396
+ const mergedLegend = {
397
+ ...layout0.legend,
398
+ ...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
399
+ } as typeof layout0.legend;
400
+
385
401
  return {
386
402
  ...layout0,
387
403
  axes: {
@@ -390,10 +406,10 @@ function compileLayerIndependent(
390
406
  y2: y2Axis,
391
407
  },
392
408
  marks,
393
- legend: {
394
- ...layout0.legend,
395
- ...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
396
- } as typeof layout0.legend,
409
+ legend: mergedLegend,
410
+ // See the note in compileLayer: the renderer reads `legends`, so the merged
411
+ // legend must replace the primary's entry there as well.
412
+ legends: [mergedLegend, ...layout0.legends.slice(1)],
397
413
  tooltipDescriptors: mergedTooltips,
398
414
  };
399
415
  }
@@ -508,6 +524,86 @@ function withYDomain(leaf: ChartSpec, domain: [number, number]): ChartSpec {
508
524
  } as ChartSpec;
509
525
  }
510
526
 
527
+ type SharedDomains = { x?: [number, number]; y?: [number, number] };
528
+
529
+ /**
530
+ * Union the quantitative x/y extents across every leaf.
531
+ *
532
+ * Only channels that every leaf encodes *quantitatively* are unioned — a leaf
533
+ * mixing a nominal x with a quantitative one has no common numeric domain, and
534
+ * an author-pinned `scale.domain` on any leaf is left to win on its own.
535
+ */
536
+ function computeSharedDomains(leaves: ChartSpec[]): SharedDomains {
537
+ const shared: SharedDomains = {};
538
+
539
+ for (const channel of ['x', 'y'] as const) {
540
+ let lo = Number.POSITIVE_INFINITY;
541
+ let hi = Number.NEGATIVE_INFINITY;
542
+ let sawAny = false;
543
+ let usable = true;
544
+
545
+ for (const leaf of leaves) {
546
+ const enc = (leaf.encoding as Encoding | undefined)?.[channel];
547
+ // Skip *this channel* if any leaf can't take part: no encoding, a
548
+ // non-quantitative type, or a domain the author already pinned. Bailing
549
+ // out of the whole function here would abandon the other channel too --
550
+ // a nominal x would silently strand a quantitative y, which is the most
551
+ // common layered shape there is (bars plus their value labels).
552
+ if (!enc || !('field' in enc) || enc.type !== 'quantitative' || enc.scale?.domain) {
553
+ usable = false;
554
+ break;
555
+ }
556
+
557
+ for (const row of leaf.data ?? []) {
558
+ const v = Number((row as DataRow)[enc.field]);
559
+ if (!Number.isFinite(v)) continue;
560
+ if (v < lo) lo = v;
561
+ if (v > hi) hi = v;
562
+ sawAny = true;
563
+ }
564
+ }
565
+
566
+ if (!usable || !sawAny || lo > hi) continue;
567
+
568
+ // Pin the union through zero the way an unpinned scale would.
569
+ //
570
+ // `withSharedDomains` writes this extent onto each leaf as `scale.domain`,
571
+ // and `resolveQuantitativeScale` takes an explicit domain *verbatim* --
572
+ // skipping the `zero !== false` baselining it would otherwise apply. So a
573
+ // raw [50, 100] union lands as a literal domain and the 50-bar collapses
574
+ // to a sliver against its own baseline. Fold zero in here, honouring an
575
+ // explicit `zero: false` on any leaf (a sparkline opting out of it).
576
+ const wantsZero = leaves.every((leaf) => {
577
+ const enc = (leaf.encoding as Encoding | undefined)?.[channel];
578
+ return enc && 'scale' in enc ? enc.scale?.zero !== false : true;
579
+ });
580
+ shared[channel] = wantsZero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
581
+ }
582
+
583
+ return shared;
584
+ }
585
+
586
+ /** Pin the shared domains onto a leaf's quantitative x/y channels. */
587
+ function withSharedDomains(leaf: ChartSpec, shared: SharedDomains): ChartSpec {
588
+ if (!shared.x && !shared.y) return leaf;
589
+
590
+ const encoding = { ...(leaf.encoding as Record<string, unknown>) };
591
+
592
+ for (const channel of ['x', 'y'] as const) {
593
+ const domain = shared[channel];
594
+ const ch = encoding[channel] as Record<string, unknown> | undefined;
595
+ if (!domain || !ch) continue;
596
+ encoding[channel] = {
597
+ ...ch,
598
+ scale: { ...((ch.scale as object) ?? {}), domain },
599
+ };
600
+ }
601
+
602
+ // ChartSpec is a union discriminated on `mark`, and spreading widens the
603
+ // encoding past whichever member `leaf` is, so re-assert the original type.
604
+ return { ...leaf, encoding } as unknown as ChartSpec;
605
+ }
606
+
511
607
  function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec {
512
608
  const allData = leaves.flatMap((leaf) => leaf.data);
513
609
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared `size` channel scale.
3
+ *
4
+ * Three marks map a quantitative field onto a visual magnitude, and each used to
5
+ * build this scale inline with the same six lines and a different pair of
6
+ * constants. A size legend has to resolve the *same* scale the marks resolve or
7
+ * the key silently lies about what a big circle means, so the builder lives in
8
+ * one place and everyone -- marks and legend -- calls it.
9
+ *
10
+ * The curve is the one thing that genuinely differs, and it is a perceptual
11
+ * call, not a preference:
12
+ *
13
+ * - `sqrt` for circles (scatter, beeswarm). Perceived magnitude of a disc tracks
14
+ * its *area*, and area goes as r^2, so radius must go as sqrt(value).
15
+ * - `linear` for glyphs (text). A word reads by its *height*, which is linear in
16
+ * font size. Running font size through sqrt would be cargo-culting the bubble
17
+ * math onto a channel that doesn't share its geometry.
18
+ */
19
+
20
+ import type { DataRow, EncodingChannel } from '@opendata-ai/openchart-core';
21
+ import { max, min } from 'd3-array';
22
+ import { scaleLinear, scaleSqrt } from 'd3-scale';
23
+
24
+ /** How a size value maps onto its visual magnitude. */
25
+ export type SizeCurve = 'sqrt' | 'linear';
26
+
27
+ /**
28
+ * Per-mark size-scale defaults.
29
+ *
30
+ * These live here, next to the builder, rather than in each mark's compute
31
+ * module -- because the size *legend* needs the exact same numbers, and a key
32
+ * whose circles are a different size from the bubbles they explain is worse
33
+ * than no key. One definition, imported by both, so they cannot drift.
34
+ */
35
+ export const SIZE_SCALE_DEFAULTS = {
36
+ /** Scatter bubbles. */
37
+ point: { curve: 'sqrt', range: [3, 30] },
38
+ /**
39
+ * Beeswarm dots. Tighter than scatter's 3-30: large radii make the dodge
40
+ * layout stack dots far past the lane, so the default cap stays low.
41
+ */
42
+ beeswarm: { curve: 'sqrt', range: [2, 10] },
43
+ /**
44
+ * Text glyphs. Linear, not sqrt -- a word reads by its height, which is
45
+ * linear in font size, where a disc reads by its area.
46
+ */
47
+ text: { curve: 'linear', range: [11, 32] },
48
+ } as const satisfies Record<string, { curve: SizeCurve; range: readonly [number, number] }>;
49
+
50
+ /** Size-scale options for a mark, or null when the mark has no size channel. */
51
+ export function sizeScaleDefaultsFor(
52
+ markType: string,
53
+ ): { curve: SizeCurve; range: [number, number] } | null {
54
+ const found = (
55
+ SIZE_SCALE_DEFAULTS as Record<
56
+ string,
57
+ { curve: SizeCurve; range: readonly [number, number] } | undefined
58
+ >
59
+ )[markType];
60
+ return found ? { curve: found.curve, range: [found.range[0], found.range[1]] } : null;
61
+ }
62
+
63
+ /** A resolved size scale plus the domain and range it was built from. */
64
+ export interface ResolvedSizeScale {
65
+ /** Maps a data value to a visual magnitude (radius, or font size). */
66
+ scale: (value: number) => number;
67
+ /** The domain actually used: an explicit `scale.domain` wins over the data extent. */
68
+ domain: [number, number];
69
+ /** The output range actually used: an explicit `scale.range` wins over the default. */
70
+ range: [number, number];
71
+ /** The field the scale reads. */
72
+ field: string;
73
+ }
74
+
75
+ /**
76
+ * Build the size scale for a quantitative `size` encoding.
77
+ *
78
+ * Author overrides come from the encoding, mirroring the color path: an explicit
79
+ * `scale.domain` (e.g. `[0, 900]`) keeps the largest datum below the max radius
80
+ * so dense clusters don't blob together, and an explicit `scale.range` caps the
81
+ * output magnitudes.
82
+ *
83
+ * The scale is **clamped**: a datum outside an explicit domain renders at the
84
+ * range endpoint rather than running off to an absurd radius. A legend built
85
+ * from this must therefore key the *domain*, not the data extent -- above an
86
+ * explicit `domain[1]` every datum looks identical.
87
+ *
88
+ * Holes (`null`, `undefined`, `''`) are not zeros -- they are absent data --
89
+ * and are dropped before the extent is fitted rather than coerced into it.
90
+ *
91
+ * @returns null when there is no size field, or when the domain is degenerate
92
+ * (no finite values, or every value identical). A zero-width domain would map
93
+ * every datum to the range's first value, so there is nothing to encode and
94
+ * nothing to legend.
95
+ */
96
+ export function buildSizeScale(
97
+ sizeEncoding: EncodingChannel | undefined,
98
+ data: readonly DataRow[],
99
+ options: { curve: SizeCurve; range: readonly [number, number] },
100
+ ): ResolvedSizeScale | null {
101
+ if (!sizeEncoding || !('field' in sizeEncoding) || !sizeEncoding.field) return null;
102
+ const field = sizeEncoding.field;
103
+
104
+ const values: number[] = [];
105
+ for (const row of data) {
106
+ const raw = row[field];
107
+ // Reject holes before coercing: `Number(null)` and `Number('')` are both a
108
+ // finite 0, so a null would enter the extent as a real zero and drag the
109
+ // domain floor down -- rescaling every genuine mark, and drawing a
110
+ // minimum-size mark for a datum that has no size at all.
111
+ if (raw == null || raw === '') continue;
112
+ const v = Number(raw);
113
+ if (Number.isFinite(v)) values.push(v);
114
+ }
115
+
116
+ const explicitDomain = sizeEncoding.scale?.domain as [number, number] | undefined;
117
+
118
+ // Nothing to fit a domain to. An explicit domain still stands on its own --
119
+ // it is the author's, not the data's -- but without one there is no extent,
120
+ // and `min([]) ?? 0` / `max([]) ?? 1` would fabricate a bogus [0, 1].
121
+ if (!explicitDomain && values.length === 0) return null;
122
+
123
+ const domain: [number, number] = explicitDomain ?? [min(values) as number, max(values) as number];
124
+
125
+ // A zero-width domain maps every datum to range[0]: no magnitude is encoded,
126
+ // so callers fall back to their static default rather than render a field of
127
+ // identical minimum-size marks.
128
+ if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] >= domain[1]) {
129
+ return null;
130
+ }
131
+
132
+ const explicitRange = sizeEncoding.scale?.range as [number, number] | undefined;
133
+ const range: [number, number] = explicitRange ?? [options.range[0], options.range[1]];
134
+
135
+ const build = options.curve === 'linear' ? scaleLinear : scaleSqrt;
136
+ const scale = build().domain(domain).range(range).clamp(true);
137
+
138
+ return { scale: (value: number) => scale(value), domain, range, field };
139
+ }