@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  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__/dimensions.test.ts +49 -4
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +120 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +222 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +312 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  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/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  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
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  CompileOptions,
3
3
  Encoding,
4
+ EncodingChannel,
5
+ FieldFormatContext,
4
6
  LayoutStrategy,
5
7
  MeasureTextFn,
6
8
  Rect,
@@ -9,15 +11,17 @@ import type {
9
11
  } from '@opendata-ai/openchart-core';
10
12
  import {
11
13
  AXIS_TITLE_TRAILING_PAD,
12
- abbreviateNumber,
13
14
  axisTitleOffset,
14
15
  BREAKPOINT_COMPACT_MAX,
16
+ COMPACT_WIDTH,
15
17
  computeChrome,
18
+ computeFieldFormatContext,
16
19
  computeXAxisExtentFromLabels,
20
+ defaultNumberFormatter,
17
21
  estimateTextWidth,
18
- formatNumber,
19
22
  HPAD_COMPACT_FRACTION,
20
23
  HPAD_COMPACT_MIN,
24
+ isAxislessMark,
21
25
  LABEL_GAP_COMPACT,
22
26
  LABEL_GAP_DEFAULT,
23
27
  MAX_LEFT_LABEL_FRACTION_COMPACT,
@@ -25,14 +29,20 @@ import {
25
29
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
26
30
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
27
31
  NARROW_VIEWPORT_MAX,
32
+ resolveNumberFormatter,
28
33
  TOP_PAD_EXTRA_NARROW,
29
34
  } from '@opendata-ai/openchart-core';
30
- import { format as d3Format } from 'd3-format';
31
35
 
32
36
  import type { NormalizedChartSpec } from '../compiler/types';
33
37
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
34
- import { computeLegendContent, type LegendContent } from '../legend/compute';
35
- import { legendGap } from '../legend/wrap';
38
+ import { computeLegendContent, hasLegendContent, type LegendContent } from '../legend/compute';
39
+ import {
40
+ computeSizeLegendContent,
41
+ SIZE_LEGEND_GAP,
42
+ type SizeLegendContent,
43
+ sizeLegendScaleFor,
44
+ } from '../legend/size';
45
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
36
46
  import { yTickPositionIsInline } from './axes';
37
47
  import { resolveBandTickAngle } from './axes/rotation';
38
48
  import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
@@ -55,6 +65,15 @@ export interface LayoutPlan {
55
65
  leftGutter: number;
56
66
  xAxisExtent: number;
57
67
  legendContent: LegendContent;
68
+ /**
69
+ * Size legend content, when a quantitative `size` channel is encoded.
70
+ *
71
+ * Kept beside `legendContent` rather than inside it: a chart can carry BOTH a
72
+ * color legend and a size legend, and folding them into one slot is what made
73
+ * the size channel unkeyable in the first place. `dimensions.ts` reserves
74
+ * right-margin for this independently of the color legend's position.
75
+ */
76
+ sizeLegendContent?: SizeLegendContent | null;
58
77
  chrome: ResolvedChrome;
59
78
  yTickValues: unknown[];
60
79
  yTickCount: number;
@@ -85,9 +104,16 @@ export function createMeasureFn(measureText?: MeasureTextFn): MeasureFn {
85
104
  // Helpers
86
105
  // ---------------------------------------------------------------------------
87
106
 
88
- /** Format a sample label from magnitude for gutter seeding. */
89
- function sampleLabelFromMagnitude(maxAbsVal: number): string {
90
- return maxAbsVal >= 1_000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
107
+ function sampleLabelFromContext(
108
+ maxAbsVal: number,
109
+ yAxisFormat: string | undefined,
110
+ ctx: FieldFormatContext,
111
+ ): string {
112
+ if (yAxisFormat) {
113
+ const fmt = resolveNumberFormatter(yAxisFormat, ctx);
114
+ if (fmt) return fmt(maxAbsVal);
115
+ }
116
+ return defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(maxAbsVal);
91
117
  }
92
118
 
93
119
  // ---------------------------------------------------------------------------
@@ -126,7 +152,7 @@ export function resolveLayoutPlan(
126
152
  ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
127
153
  : padding;
128
154
  const encoding = renderSpec.encoding as Encoding;
129
- const isRadial = renderSpec.markType === 'arc';
155
+ const isRadial = isAxislessMark(renderSpec.markType);
130
156
  const axisMargin = theme.spacing.axisMargin;
131
157
 
132
158
  // Resolve chromeMode
@@ -149,7 +175,7 @@ export function resolveLayoutPlan(
149
175
  measure,
150
176
  );
151
177
  const bottomLegendReservation =
152
- legendContent.entries.length > 0 && legendContent.position === 'bottom'
178
+ hasLegendContent(legendContent) && legendContent.position === 'bottom'
153
179
  ? legendContent.height + legendGap(width)
154
180
  : 0;
155
181
  const chrome = computeChrome(
@@ -166,6 +192,8 @@ export function resolveLayoutPlan(
166
192
  leftGutter: hPad,
167
193
  xAxisExtent: 0,
168
194
  legendContent,
195
+ // Sparkline / radial: no positional axes and no bubbles, so no size key.
196
+ sizeLegendContent: null,
169
197
  chrome,
170
198
  yTickValues: [],
171
199
  yTickCount: 0,
@@ -231,7 +259,7 @@ export function resolveLayoutPlan(
231
259
  leftGutter = Math.max(leftGutter, reserved);
232
260
  }
233
261
  } else {
234
- // Quantitative: seed with a formatted label (better than '1.5B' guess)
262
+ // Quantitative: seed with a formatted label
235
263
  const yField = encoding.y.field;
236
264
  const yAxisFormat = yAxisCfg?.format as string | undefined;
237
265
  let maxAbsVal = 0;
@@ -239,16 +267,8 @@ export function resolveLayoutPlan(
239
267
  const v = Number(row[yField]);
240
268
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
241
269
  }
242
- let sampleLabel: string;
243
- if (yAxisFormat) {
244
- try {
245
- sampleLabel = d3Format(yAxisFormat)(maxAbsVal);
246
- } catch {
247
- sampleLabel = String(maxAbsVal);
248
- }
249
- } else {
250
- sampleLabel = sampleLabelFromMagnitude(maxAbsVal);
251
- }
270
+ const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
271
+ const sampleLabel = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
252
272
  const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
253
273
  const labelWidth = measure(
254
274
  negPrefix + sampleLabel,
@@ -300,6 +320,26 @@ export function resolveLayoutPlan(
300
320
  }
301
321
  }
302
322
 
323
+ // Size legend: keys a quantitative `size` channel with graduated circles.
324
+ // Independent of the color legend -- a bubble chart keys BOTH continent
325
+ // (color) and population (size), so this is computed alongside, not instead.
326
+ // Its content is width-independent, so it sits outside the convergence loop.
327
+ //
328
+ // Dropped below COMPACT_WIDTH. The block is a fixed ~90px (it can't reflow the
329
+ // way a categorical legend's entries can), which on a phone-width chart is a
330
+ // quarter of the SVG spent on the key and a plot too narrow to read the bubbles
331
+ // it explains. The same threshold the color legend goes compact at.
332
+ const sizeScaleOpts = sizeLegendScaleFor(renderSpec.markType);
333
+ const sizeLegendContent =
334
+ sizeScaleOpts && chartSpec.legend?.show !== false && width >= COMPACT_WIDTH
335
+ ? computeSizeLegendContent(
336
+ renderSpec.encoding.size as EncodingChannel | undefined,
337
+ renderSpec.data,
338
+ theme,
339
+ sizeScaleOpts,
340
+ )
341
+ : null;
342
+
303
343
  for (let iter = 0; iter < 2; iter++) {
304
344
  // Legend content
305
345
  const legendAvailWidth = width - padding - leftGutter;
@@ -316,7 +356,7 @@ export function resolveLayoutPlan(
316
356
 
317
357
  // Chrome
318
358
  const bottomLegendReservation =
319
- legendContent.entries.length > 0 && legendContent.position === 'bottom'
359
+ hasLegendContent(legendContent) && legendContent.position === 'bottom'
320
360
  ? legendContent.height + legendGap(width)
321
361
  : 0;
322
362
  const chrome = computeChrome(
@@ -336,7 +376,24 @@ export function resolveLayoutPlan(
336
376
  // footprint in the bottom margin instead of the (smaller) flat one.
337
377
  let effectiveXTickAngle = xTickAngle;
338
378
  if (xTickAngle === undefined && xLabels.length > 1) {
339
- const rightMarginEst = hPad + (isRadial ? hPad : axisMargin);
379
+ // The right-hand legend column narrows the plot (dimensions.ts reserves
380
+ // it), so it has to be in this estimate too. Without it the planner sizes
381
+ // bands against a plot up to ~100px wider than the real one, concludes the
382
+ // labels fit flat, and computeAxes then rotates them against the true
383
+ // bandwidth -- reserving the flat footprint for rotated labels and clipping
384
+ // the axis. Bites a nominal-x beeswarm with a size channel.
385
+ //
386
+ // Mirrors the shared-column reservation in dimensions.ts: the color and
387
+ // size legends stack in ONE column, so it is the wider of the two, not the
388
+ // sum. These two must agree -- an estimate wider than the real margin
389
+ // rotates labels that would have fit.
390
+ const colorInGutter =
391
+ hasLegendContent(legendContent) &&
392
+ (legendContent.position === 'right' || legendContent.position === 'bottom-right');
393
+ const colorLegendReserve = colorInGutter ? legendContent.legendWidth + 8 : 0;
394
+ const sizeLegendReserve = sizeLegendContent ? sizeLegendContent.width + SIZE_LEGEND_GAP : 0;
395
+ const legendReserve = Math.max(colorLegendReserve, sizeLegendReserve);
396
+ const rightMarginEst = hPad + (isRadial ? hPad : axisMargin) + legendReserve;
340
397
  const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
341
398
  // Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
342
399
  // bandwidth = step * (1 - paddingInner). Uses the same override resolution
@@ -386,7 +443,7 @@ export function resolveLayoutPlan(
386
443
 
387
444
  // Top margin
388
445
  const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
389
- const hasTopLegend = legendContent.entries.length > 0 && legendContent.position === 'top';
446
+ const hasTopLegend = hasLegendContent(legendContent) && legendContent.position === 'top';
390
447
  const gap = legendGap(width);
391
448
  const inlineTickOverhang = yIsInline
392
449
  ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
@@ -395,7 +452,8 @@ export function resolveLayoutPlan(
395
452
  const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
396
453
 
397
454
  let topMargin = topPad + chrome.topHeight;
398
- if (hasTopLegend) topMargin += legendContent.height + gap;
455
+ // Mirrors the dimensions.ts margin stack: min gap above the top legend.
456
+ if (hasTopLegend) topMargin += TOP_LEGEND_GAP_ABOVE + legendContent.height + gap;
399
457
  topMargin += effectiveTopAxisGap;
400
458
 
401
459
  const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
@@ -500,16 +558,8 @@ export function resolveLayoutPlan(
500
558
  const v = Number(row[yField]);
501
559
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
502
560
  }
503
- let sample: string;
504
- if (yAxisFormat) {
505
- try {
506
- sample = d3Format(yAxisFormat)(maxAbsVal);
507
- } catch {
508
- sample = String(maxAbsVal);
509
- }
510
- } else {
511
- sample = sampleLabelFromMagnitude(maxAbsVal);
512
- }
561
+ const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
562
+ const sample = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
513
563
  const neg = renderSpec.data.some((r) => Number(r[encoding.y!.field]) < 0) ? '-' : '';
514
564
  const maxLabelWidth = measure(
515
565
  neg + sample,
@@ -561,6 +611,7 @@ export function resolveLayoutPlan(
561
611
  leftGutter: gutterWithTitle,
562
612
  xAxisExtent,
563
613
  legendContent,
614
+ sizeLegendContent,
564
615
  chrome,
565
616
  yTickValues,
566
617
  yTickCount,
@@ -10,9 +10,11 @@ import type {
10
10
  DataRow,
11
11
  Encoding,
12
12
  EncodingChannel,
13
+ FieldFormatContext,
13
14
  Rect,
14
15
  ScaleType,
15
16
  } from '@opendata-ai/openchart-core';
17
+ import { computeFieldFormatContext } from '@opendata-ai/openchart-core';
16
18
  import { extent, max, min } from 'd3-array';
17
19
  import type {
18
20
  ScaleBand,
@@ -44,6 +46,7 @@ import {
44
46
  } from 'd3-scale';
45
47
 
46
48
  import type { NormalizedChartSpec } from '../compiler/types';
49
+ import { DEFAULT_BIN_COUNT, sampleRampColors } from '../legend/continuous';
47
50
 
48
51
  // ---------------------------------------------------------------------------
49
52
  // Types
@@ -91,6 +94,8 @@ export interface ResolvedScale {
91
94
  type: ResolvedScaleType;
92
95
  /** The encoding channel this scale was derived from. */
93
96
  channel: EncodingChannel;
97
+ /** Per-field formatting context (extent, allIntegers) from the ORIGINAL data. */
98
+ formatContext?: FieldFormatContext;
94
99
  }
95
100
 
96
101
  /** All resolved scales for a chart. */
@@ -145,12 +150,13 @@ function uniqueStrings(values: unknown[]): string[] {
145
150
  * - 'ascending': sort alphabetically/numerically ascending
146
151
  * - 'descending': sort descending
147
152
  * - null | undefined: preserve data order (no sorting)
153
+ *
154
+ * VL sort forms ('-y', value arrays, { field, op, order }) are resolved into
155
+ * an explicit scale.domain by the pre-validation sugar expansion; any that
156
+ * reach this point fall back to data order.
148
157
  */
149
- function applyCategoricalSort(
150
- values: string[],
151
- sort: 'ascending' | 'descending' | null | undefined,
152
- ): string[] {
153
- if (!sort) return values;
158
+ export function applyCategoricalSort(values: string[], sort: EncodingChannel['sort']): string[] {
159
+ if (sort !== 'ascending' && sort !== 'descending') return values;
154
160
 
155
161
  const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
156
162
  if (sort === 'descending') sorted.reverse();
@@ -509,17 +515,27 @@ export function estimateBandStep(
509
515
  return plotWidth / denom;
510
516
  }
511
517
 
518
+ /**
519
+ * Gap between bands, as a fraction of the step. The 0.35 default is a *bar*
520
+ * value: bars need air between them to read as separate quantities. Heatmap
521
+ * cells are the opposite -- they tile, and the gap is a hairline gutter that
522
+ * only exists to keep adjacent fills from bleeding into each other.
523
+ */
524
+ const DEFAULT_BAND_PADDING = 0.35;
525
+ const HEATMAP_BAND_PADDING = 0.04;
526
+
512
527
  function buildBandScale(
513
528
  channel: EncodingChannel,
514
529
  data: DataRow[],
515
530
  rangeStart: number,
516
531
  rangeEnd: number,
532
+ defaultPadding: number = DEFAULT_BAND_PADDING,
517
533
  ): ResolvedScale {
518
534
  const values = channel.scale?.domain
519
535
  ? (channel.scale.domain as string[])
520
536
  : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
521
537
 
522
- const padding = channel.scale?.padding ?? 0.35;
538
+ const padding = channel.scale?.padding ?? defaultPadding;
523
539
  const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
524
540
 
525
541
  if (channel.scale?.paddingInner !== undefined) {
@@ -594,10 +610,20 @@ function buildSequentialColorScale(
594
610
  const explicitRange = channel.scale?.range as string[] | undefined;
595
611
  const colors = explicitRange ?? palette;
596
612
 
597
- const scale = scaleLinear<string>()
598
- .domain([domainMin, domainMax])
599
- .range([colors[0], colors[colors.length - 1]])
600
- .clamp(true);
613
+ // Explicit multi-stop ranges (e.g. a diverging scheme) interpolate piecewise
614
+ // through every stop, with evenly-spaced domain pivots to match. Without an
615
+ // explicit range the endpoints-only form is kept so applyColorScaleRange can
616
+ // swap in the theme ramp endpoints post-hoc.
617
+ const stops =
618
+ explicitRange && explicitRange.length > 2
619
+ ? explicitRange
620
+ : [colors[0], colors[colors.length - 1]];
621
+ const domain =
622
+ stops.length > 2
623
+ ? stops.map((_, i) => domainMin + ((domainMax - domainMin) * i) / (stops.length - 1))
624
+ : [domainMin, domainMax];
625
+
626
+ const scale = scaleLinear<string>().domain(domain).range(stops).clamp(true);
601
627
 
602
628
  // Cast: sequential color scale (number -> string) is structurally incompatible
603
629
  // with D3Scale (number -> number), but is only ever accessed via scales.color
@@ -605,6 +631,45 @@ function buildSequentialColorScale(
605
631
  return { scale: scale as unknown as D3Scale, type: 'sequential', channel };
606
632
  }
607
633
 
634
+ /**
635
+ * Build a binned color scale (quantile/quantize/threshold) for a quantitative
636
+ * color encoding with an explicit `scale.type`. The range holds colors: an
637
+ * explicit `scale.range` wins; otherwise a placeholder sampled from the
638
+ * default palette that `applyColorScaleRange` replaces with the theme's
639
+ * sequential ramp.
640
+ */
641
+ function buildBinnedColorScale(
642
+ channel: EncodingChannel,
643
+ data: DataRow[],
644
+ palette: string[],
645
+ ): ResolvedScale {
646
+ const values = parseNumbers(fieldValues(data, channel.field));
647
+ const explicitRange = channel.scale?.range as string[] | undefined;
648
+ const scaleType = channel.scale?.type as 'quantile' | 'quantize' | 'threshold';
649
+
650
+ if (scaleType === 'threshold') {
651
+ // Threshold scales require explicit domain breakpoints; colors = breaks + 1.
652
+ const breaks = (channel.scale?.domain as number[] | undefined) ?? [0.5];
653
+ const colors = sampleRampColors(explicitRange ?? palette, breaks.length + 1);
654
+ const scale = scaleThreshold<number, string>().domain(breaks).range(colors);
655
+ return { scale: scale as unknown as D3Scale, type: 'threshold', channel };
656
+ }
657
+
658
+ const binCount = explicitRange?.length ?? DEFAULT_BIN_COUNT;
659
+ const colors = explicitRange ?? sampleRampColors(palette, binCount);
660
+
661
+ if (scaleType === 'quantile') {
662
+ const scale = scaleQuantile<string>().domain(values).range(colors);
663
+ return { scale: scale as unknown as D3Scale, type: 'quantile', channel };
664
+ }
665
+
666
+ const explicitDomain = channel.scale?.domain as [number, number] | undefined;
667
+ const domainMin = explicitDomain?.[0] ?? min(values) ?? 0;
668
+ const domainMax = explicitDomain?.[1] ?? max(values) ?? 1;
669
+ const scale = scaleQuantize<string>().domain([domainMin, domainMax]).range(colors);
670
+ return { scale: scale as unknown as D3Scale, type: 'quantize', channel };
671
+ }
672
+
608
673
  // ---------------------------------------------------------------------------
609
674
  // Positional scale selection
610
675
  // ---------------------------------------------------------------------------
@@ -661,9 +726,23 @@ function buildPositionalScale(
661
726
  return buildLinearScale(channel, data, rangeStart, rangeEnd);
662
727
  case 'nominal':
663
728
  case 'ordinal':
664
- // Bar charts use band scales for their categorical axis (both orientations)
729
+ // Bar and range charts use band scales for their categorical axis (both
730
+ // orientations). Beeswarm lanes are band scales too: each category gets
731
+ // a band whose center anchors one swarm, on whichever axis carries the
732
+ // nominal channel.
733
+ //
734
+ // `rect` is the only mark that needs a band on *both* axes: a heatmap cell
735
+ // is sized by the bandwidth of each axis, so it takes no `axis` guard. A
736
+ // point scale would give it zero width and height (and it did: `rect` cells
737
+ // rendered as invisible zero-area marks). It also tiles, so it takes the
738
+ // hairline gutter rather than the bar-sized gap.
739
+ if (chartType === 'rect') {
740
+ return buildBandScale(channel, data, rangeStart, rangeEnd, HEATMAP_BAND_PADDING);
741
+ }
665
742
  if (
666
743
  chartType === 'bar' ||
744
+ chartType === 'beeswarm' ||
745
+ chartType === 'range' ||
667
746
  ((chartType === 'circle' || chartType === 'lollipop') && axis === 'y')
668
747
  ) {
669
748
  return buildBandScale(channel, data, rangeStart, rangeEnd);
@@ -694,8 +773,17 @@ export function computeScales(
694
773
  const result: ResolvedScales = {};
695
774
  const encoding = spec.encoding as Encoding;
696
775
 
697
- // Scatter/bubble charts should NOT include zero by default (tight domain fits data range)
698
- if (spec.markType === 'point') {
776
+ // Marks that encode position (not length) default to zero: false so the
777
+ // domain fits the data range. Line charts show trends — anchoring at zero
778
+ // wastes space. Scatter/beeswarm encode raw observations whose spread is
779
+ // the story. Text marks must match the point layer they label.
780
+ if (
781
+ spec.markType === 'point' ||
782
+ spec.markType === 'line' ||
783
+ spec.markType === 'beeswarm' ||
784
+ spec.markType === 'range' ||
785
+ spec.markType === 'text'
786
+ ) {
699
787
  if (encoding.x?.type === 'quantitative' && encoding.x.scale?.zero === undefined) {
700
788
  if (!encoding.x.scale) {
701
789
  (encoding.x as { scale?: Record<string, unknown> }).scale = { zero: false };
@@ -717,6 +805,13 @@ export function computeScales(
717
805
  // Without this, stacked bars would clip past the chart area.
718
806
  let xData = data;
719
807
  let xChannel = encoding.x;
808
+ // Range charts span x to x2: the x-domain must cover both endpoint fields.
809
+ // Synthetic rows map x2 values into the x field so buildLinearScale sees them.
810
+ if (spec.markType === 'range' && encoding.x2 && encoding.x.type === 'quantitative') {
811
+ const xField = encoding.x.field;
812
+ const x2Field = encoding.x2.field;
813
+ xData = [...data, ...data.map((row) => ({ [xField]: row[x2Field] }) as DataRow)];
814
+ }
720
815
  const xStackEnabled =
721
816
  encoding.x.stack === true ||
722
817
  encoding.x.stack === 'zero' ||
@@ -788,6 +883,9 @@ export function computeScales(
788
883
  spec.markType,
789
884
  'x',
790
885
  );
886
+ if (result.x && encoding.x.type === 'quantitative' && encoding.x.field) {
887
+ result.x.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.x!.field]));
888
+ }
791
889
  }
792
890
 
793
891
  if (encoding.y) {
@@ -797,6 +895,12 @@ export function computeScales(
797
895
  // Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
798
896
  let yData = data;
799
897
  let yChannel = encoding.y;
898
+ // Range charts span y to y2 (vertical form): cover both endpoint fields.
899
+ if (spec.markType === 'range' && encoding.y2 && encoding.y.type === 'quantitative') {
900
+ const yField = encoding.y.field;
901
+ const y2Field = encoding.y2.field;
902
+ yData = [...data, ...data.map((row) => ({ [yField]: row[y2Field] }) as DataRow)];
903
+ }
800
904
  const isVerticalBar =
801
905
  spec.markType === 'bar' &&
802
906
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
@@ -904,6 +1008,9 @@ export function computeScales(
904
1008
  spec.markType,
905
1009
  'y',
906
1010
  );
1011
+ if (result.y && encoding.y.type === 'quantitative' && encoding.y.field) {
1012
+ result.y.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.y!.field]));
1013
+ }
907
1014
  }
908
1015
 
909
1016
  if (encoding.color) {
@@ -923,8 +1030,18 @@ export function computeScales(
923
1030
  // Only build color scales for field-based encodings, not conditional value defs
924
1031
  if ('field' in encoding.color) {
925
1032
  if (encoding.color.type === 'quantitative') {
926
- // Sequential color scale for value-based coloring
927
- result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
1033
+ const colorScaleType = encoding.color.scale?.type;
1034
+ if (
1035
+ colorScaleType === 'quantile' ||
1036
+ colorScaleType === 'quantize' ||
1037
+ colorScaleType === 'threshold'
1038
+ ) {
1039
+ // Binned color scale: discrete classes for value-based coloring
1040
+ result.color = buildBinnedColorScale(encoding.color, data, defaultPalette);
1041
+ } else {
1042
+ // Sequential color scale for value-based coloring
1043
+ result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
1044
+ }
928
1045
  } else {
929
1046
  // Categorical color scale for nominal/ordinal grouping
930
1047
  result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);