@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
@@ -265,10 +265,15 @@ describe('resolveSuppression - special cases', () => {
265
265
  expect(result.showEndpointLabels).toBe(true);
266
266
  });
267
267
 
268
- it('overlap area (no stack): runs through 8-cell table', () => {
268
+ it('overlap area (stack: null): runs through 8-cell table', () => {
269
269
  const spec = makeMultiSeriesLineSpec({
270
270
  markType: 'area',
271
271
  markDef: { type: 'area' },
272
+ encoding: {
273
+ x: { field: 'date', type: 'temporal' },
274
+ y: { field: 'value', type: 'quantitative', stack: null },
275
+ color: { field: 'country', type: 'nominal' },
276
+ },
272
277
  });
273
278
  const result = resolveSuppression(spec, baseCtx);
274
279
  // Cell 1 behavior: legend hidden, column on
@@ -276,6 +281,17 @@ describe('resolveSuppression - special cases', () => {
276
281
  expect(result.showEndpointLabels).toBe(true);
277
282
  });
278
283
 
284
+ it('area with no explicit stack: stacks by default (v8), legend shown, column off', () => {
285
+ const spec = makeMultiSeriesLineSpec({
286
+ markType: 'area',
287
+ markDef: { type: 'area' },
288
+ });
289
+ const result = resolveSuppression(spec, baseCtx);
290
+ expect(result.showTraditionalLegend).toBe(true);
291
+ expect(result.showEndpointLabels).toBe(false);
292
+ expect(result.showEndOfLineLabels).toBe(false);
293
+ });
294
+
279
295
  it('endpointLabels: { show: false } counts as explicit off', () => {
280
296
  const spec = makeMultiSeriesLineSpec({ endpointLabels: { show: false } });
281
297
  const result = resolveSuppression(spec, baseCtx);
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import type {
23
+ ContinuousLegendLayout,
23
24
  LayoutStrategy,
24
25
  LegendEntry,
25
26
  LegendEntryPosition,
@@ -30,8 +31,16 @@ import type {
30
31
  } from '@opendata-ai/openchart-core';
31
32
  import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
32
33
 
34
+ import { categoricalColorsForDomain } from '../compile/color-scale-range';
33
35
  import type { NormalizedChartSpec } from '../compiler/types';
34
36
  import type { MeasureFn } from '../layout/plan';
37
+ import { applyCategoricalSort } from '../layout/scales';
38
+ import {
39
+ CONTINUOUS_LABEL_GAP,
40
+ type ContinuousLegendContent,
41
+ computeContinuousLegendContent,
42
+ hasContinuousColorScale,
43
+ } from './continuous';
35
44
  import { countColorSeries, resolveSuppression } from './suppression';
36
45
  import {
37
46
  ENTRY_GAP,
@@ -79,6 +88,20 @@ export interface LegendContent {
79
88
  relativePositions?: LegendEntryPosition[];
80
89
  /** Row advance used to build entry positions. */
81
90
  rowHeight?: number;
91
+ /**
92
+ * Continuous legend payload (gradient bar / binned swatches). When set,
93
+ * `entries` is empty and `height`/`legendWidth` describe the bar block.
94
+ */
95
+ continuous?: ContinuousLegendContent;
96
+ }
97
+
98
+ /**
99
+ * True when the legend has something to render (categorical entries or a
100
+ * continuous bar). Space-reservation predicates must use this instead of
101
+ * checking `entries.length` directly.
102
+ */
103
+ export function hasLegendContent(content: LegendContent): boolean {
104
+ return content.entries.length > 0 || content.continuous != null;
82
105
  }
83
106
 
84
107
  // ---------------------------------------------------------------------------
@@ -127,6 +150,8 @@ function swatchShapeForType(markType: string): LegendEntry['shape'] {
127
150
  case 'point':
128
151
  case 'circle':
129
152
  case 'lollipop':
153
+ case 'beeswarm':
154
+ case 'range':
130
155
  return 'circle';
131
156
  default:
132
157
  return 'square';
@@ -141,6 +166,9 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
141
166
  // Conditional color definitions don't produce legend entries
142
167
  if ('condition' in colorEnc) return [];
143
168
 
169
+ // Bare value defs (constant colors) don't produce legend entries either
170
+ if (!('field' in colorEnc)) return [];
171
+
144
172
  // Sequential (quantitative) color doesn't produce discrete legend entries
145
173
  if (colorEnc.type === 'quantitative') return [];
146
174
 
@@ -155,9 +183,21 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
155
183
  ];
156
184
  const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
157
185
  const explicitRange = colorEnc.scale?.range as string[] | undefined;
158
- const palette = explicitRange ?? theme.colors.categorical;
159
186
  const shape = swatchShapeForType(spec.markType);
160
187
 
188
+ // Reproduce the ordinal color scale's domain so swatch colors index the
189
+ // palette exactly like the mark colors do. `buildOrdinalColorScale` takes an
190
+ // explicit domain verbatim, otherwise sorts the data values by `sort`.
191
+ const scaleDomain = explicitDomain
192
+ ? explicitDomain.map(String)
193
+ : applyCategoricalSort(dataValues, colorEnc.sort);
194
+
195
+ // Colors come from the same resolver the scale uses, so `highlight` muting
196
+ // and the accent-neutral series strategy show up in the legend too. An
197
+ // explicit range always wins (the scale honours it verbatim).
198
+ const domainColors =
199
+ explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight);
200
+
161
201
  // Order legend entries by explicit domain when provided so the author
162
202
  // controls which entries render first (and which get truncated last when
163
203
  // symbolLimit applies). Without explicit domain, preserve data order.
@@ -176,16 +216,14 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
176
216
 
177
217
  return uniqueValues
178
218
  .map((value, i) => {
179
- // When explicit domain+range are provided, look up the color by domain index
180
- // so legend colors match the mark colors exactly.
181
- let colorIndex = i;
182
- if (explicitDomain && explicitRange) {
183
- const domainIdx = explicitDomain.indexOf(value);
184
- if (domainIdx >= 0) colorIndex = domainIdx;
185
- }
219
+ // Look the value up in the scale domain so the color matches the mark
220
+ // even when legend order differs from domain order. Values outside the
221
+ // domain (a data value not in an explicit domain) fall back to position.
222
+ const domainIdx = scaleDomain.indexOf(value);
223
+ const colorIndex = domainIdx >= 0 ? domainIdx : i;
186
224
  return {
187
225
  label: value,
188
- color: palette[colorIndex % palette.length],
226
+ color: domainColors[colorIndex % domainColors.length],
189
227
  shape,
190
228
  active: !hiddenSet.has(value),
191
229
  };
@@ -274,6 +312,40 @@ export function computeLegendContent(
274
312
  return emptyContent('top', theme);
275
313
  }
276
314
 
315
+ // Continuous color scale (quantitative color encoding): gradient bar or
316
+ // binned swatch legend, on by default. Horizontal-bar geometry only, so the
317
+ // position resolves to top (newsroom map-key convention) unless the user
318
+ // asks for bottom; right-column positions can't fit the bar.
319
+ if (hasContinuousColorScale(spec)) {
320
+ const continuous = computeContinuousLegendContent(spec, theme, availableWidth);
321
+ const continuousPosition = spec.legend?.position === 'bottom' ? 'bottom' : 'top';
322
+ if (!continuous) {
323
+ return emptyContent(continuousPosition, theme);
324
+ }
325
+ const labelStyle: TextStyle = {
326
+ fontFamily: theme.fonts.family,
327
+ fontSize: theme.fonts.sizes.small,
328
+ fontWeight: theme.fonts.weights.normal,
329
+ fill: theme.colors.text,
330
+ lineHeight: 1.3,
331
+ fontVariant: 'tabular-nums',
332
+ };
333
+ const labelRowHeight = Math.ceil(labelStyle.fontSize * labelStyle.lineHeight);
334
+ const height = continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
335
+ return {
336
+ entries: [],
337
+ position: continuousPosition,
338
+ labelStyle,
339
+ rowCount: 1,
340
+ totalWidth: continuous.barWidth,
341
+ height,
342
+ legendWidth: continuous.barWidth,
343
+ offset: spec.legend?.offset,
344
+ ...categoricalDefaults(theme),
345
+ continuous,
346
+ };
347
+ }
348
+
277
349
  let entries = extractColorEntries(spec, theme);
278
350
 
279
351
  // Consult the shared suppression truth table so the legend, endpoint column,
@@ -291,10 +363,11 @@ export function computeLegendContent(
291
363
  entries = [];
292
364
  }
293
365
 
294
- // Bar/column/lollipop redundancy rule: when color.field matches the category
295
- // axis field, the legend just repeats the axis labels. Hide it unless the
296
- // user explicitly configured the legend.
297
- const hasRedundantLegend = spec.markType === 'bar' || spec.markType === 'lollipop';
366
+ // Bar/column/lollipop/beeswarm redundancy rule: when color.field matches the
367
+ // category axis field, the legend just repeats the axis labels. Hide it
368
+ // unless the user explicitly configured the legend.
369
+ const hasRedundantLegend =
370
+ spec.markType === 'bar' || spec.markType === 'lollipop' || spec.markType === 'beeswarm';
298
371
  if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
299
372
  const colorEnc = spec.encoding.color;
300
373
  const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
@@ -511,6 +584,35 @@ export function placeLegend(
511
584
  ): LegendLayout {
512
585
  const { position, entries, labelStyle, legendWidth, height, offset } = content;
513
586
 
587
+ // Continuous legend: gradient bar / binned swatches. Placement mirrors the
588
+ // categorical top/bottom math; the bar-relative geometry from the content
589
+ // phase is offset into chart coordinates here.
590
+ if (content.continuous) {
591
+ const c = content.continuous;
592
+ const cDx = offset?.dx ?? 0;
593
+ const cDy = offset?.dy ?? 0;
594
+ const cGap = legendGap(containerWidth);
595
+ const boundsX = chartArea.x + cDx;
596
+ const boundsY =
597
+ position === 'bottom'
598
+ ? chartArea.y + chartArea.height + xAxisHeight + cGap + cDy
599
+ : chartArea.y - axisGapBelowLegend - cGap - height + cDy;
600
+ const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
601
+ const bar = { x: bounds.x, y: bounds.y, width: c.barWidth, height: c.barHeight };
602
+ return {
603
+ type: 'continuous' as const,
604
+ mode: c.mode,
605
+ position,
606
+ bounds,
607
+ labelStyle,
608
+ bar,
609
+ colorStops: c.colorStops,
610
+ bins: c.bins.map((b) => ({ ...b, x: b.x + bar.x })),
611
+ ticks: c.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
612
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
613
+ } satisfies ContinuousLegendLayout;
614
+ }
615
+
514
616
  // Empty legend = no bounds
515
617
  if (entries.length === 0) {
516
618
  return {
@@ -593,6 +695,10 @@ export function computeLegend(
593
695
  chartArea.height,
594
696
  watermark,
595
697
  );
698
+ // Continuous legends share placeLegend's placement math.
699
+ if (content.continuous) {
700
+ return placeLegend(content, chartArea, chartArea.width, theme, 0);
701
+ }
596
702
  const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
597
703
  const offsetDx = off?.dx ?? 0;
598
704
  const offsetDy = off?.dy ?? 0;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Continuous / binned color legend content computation.
3
+ *
4
+ * Resolves a quantitative color encoding into the geometry a gradient-bar
5
+ * or binned-swatch legend needs: bar size, gradient stops or class swatches,
6
+ * and formatted value labels. Everything here is bar-relative (origin at the
7
+ * bar's left edge); `placeLegend` offsets into chart coordinates.
8
+ *
9
+ * Color resolution deliberately mirrors the mark path (`buildSequentialColorScale`
10
+ * / `buildBinnedColorScale` in layout/scales.ts plus `applyColorScaleRange`):
11
+ * an explicit `scale.range` wins, otherwise the theme's first sequential
12
+ * palette applies. Because the sequential/diverging palettes are identical in
13
+ * light and dark themes (only text/axis colors adapt), the legend matches the
14
+ * dark-adapted mark ramp exactly with no extra work.
15
+ */
16
+
17
+ import type {
18
+ ContinuousLegendBin,
19
+ ContinuousLegendTick,
20
+ EncodingChannel,
21
+ FieldFormatContext,
22
+ GradientColorStop,
23
+ ResolvedTheme,
24
+ } from '@opendata-ai/openchart-core';
25
+ import {
26
+ computeFieldFormatContext,
27
+ defaultNumberFormatter,
28
+ resolveNumberFormatter,
29
+ } from '@opendata-ai/openchart-core';
30
+ import { scaleQuantile } from 'd3-scale';
31
+
32
+ import type { NormalizedChartSpec } from '../compiler/types';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Min/max over a numeric array via reduce, not `Math.min(...values)`. The spread
40
+ * form passes every element as a function argument and throws a call-stack
41
+ * RangeError once the array is large enough (a continuous-color field over a big
42
+ * dataset), so the reduce keeps large color domains from crashing the legend.
43
+ * Empty-array result matches the old `Math.min()`/`Math.max()` identity so the
44
+ * `?? Math.min(...)` fallback callers keep their prior semantics.
45
+ */
46
+ function extent(values: number[]): [number, number] {
47
+ let min = Number.POSITIVE_INFINITY;
48
+ let max = Number.NEGATIVE_INFINITY;
49
+ for (const v of values) {
50
+ if (v < min) min = v;
51
+ if (v > max) max = v;
52
+ }
53
+ return [min, max];
54
+ }
55
+
56
+ /** Height of the gradient bar / swatch row in pixels. */
57
+ export const CONTINUOUS_BAR_HEIGHT = 10;
58
+
59
+ /** Gap between the bar and the value label row. */
60
+ export const CONTINUOUS_LABEL_GAP = 4;
61
+
62
+ /** Preferred bar width bounds (FT/Datawrapper map-key convention). */
63
+ const BAR_WIDTH_MIN = 160;
64
+ const BAR_WIDTH_MAX = 220;
65
+
66
+ /** Default class count for quantile/quantize scales without an explicit range. */
67
+ export const DEFAULT_BIN_COUNT = 5;
68
+
69
+ /** Scale types that produce a binned swatch legend. */
70
+ const BINNED_SCALE_TYPES = new Set(['quantile', 'quantize', 'threshold']);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Types
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Pre-placement continuous legend content (bar-relative coordinates). */
77
+ export interface ContinuousLegendContent {
78
+ /** Rendering mode, resolved from the color channel's scale type. */
79
+ mode: 'gradient' | 'binned';
80
+ /** Bar width in pixels. */
81
+ barWidth: number;
82
+ /** Bar height in pixels. */
83
+ barHeight: number;
84
+ /** Gradient color stops (empty in binned mode). */
85
+ colorStops: GradientColorStop[];
86
+ /** Class swatches with x relative to the bar's left edge (empty in gradient mode). */
87
+ bins: ContinuousLegendBin[];
88
+ /** Value labels with x relative to the bar's left edge. */
89
+ ticks: ContinuousLegendTick[];
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Sample `k` colors from a ramp, evenly spread by index. Returns the ramp
98
+ * unchanged when it already has `k` stops. Shared by the mark color path
99
+ * (applyColorScaleRange, buildBinnedColorScale) and the legend so class
100
+ * colors can never drift between swatches and cells.
101
+ */
102
+ export function sampleRampColors(colors: string[], k: number): string[] {
103
+ if (k <= 0 || colors.length === 0) return [];
104
+ if (colors.length === k) return [...colors];
105
+ if (k === 1) return [colors[colors.length - 1]];
106
+ const out: string[] = [];
107
+ for (let i = 0; i < k; i++) {
108
+ out.push(colors[Math.round((i * (colors.length - 1)) / (k - 1))]);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Numeric values of the color field, in data order. */
114
+ function numericFieldValues(spec: NormalizedChartSpec, field: string): number[] {
115
+ const out: number[] = [];
116
+ for (const row of spec.data) {
117
+ const v = row[field];
118
+ if (v == null) continue;
119
+ const num = typeof v === 'number' ? v : Number(v);
120
+ if (Number.isFinite(num)) out.push(num);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Format a legend value: channel format wins, then the house number style. */
126
+ function formatLegendValue(value: number, formatStr?: string, ctx?: FieldFormatContext): string {
127
+ if (formatStr) {
128
+ const fmt = resolveNumberFormatter(formatStr, ctx);
129
+ if (fmt) return fmt(value);
130
+ }
131
+ return defaultNumberFormatter(ctx)(value);
132
+ }
133
+
134
+ /**
135
+ * True when the resolved color list is one of the theme's diverging palettes
136
+ * (e.g. `scale: { scheme: 'redBlue' }` after sugar expansion). Diverging ramps
137
+ * get a midpoint label at the scale's center value.
138
+ */
139
+ function isDivergingRamp(colors: string[], theme: ResolvedTheme): boolean {
140
+ const norm = (stops: readonly string[]) => stops.map((c) => c.toLowerCase()).join('|');
141
+ const key = norm(colors);
142
+ // Match either direction: `scale: { reverse: true }` flips the stops, and a
143
+ // reversed diverging ramp is still diverging -- it must keep its midpoint label.
144
+ const flipped = norm([...colors].reverse());
145
+ return Object.values(theme.colors.diverging).some((stops) => {
146
+ const themeKey = norm(stops);
147
+ return themeKey === key || themeKey === flipped;
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Resolve the color list the marks will use, mirroring
153
+ * `applyColorScaleRange`: explicit range wins, otherwise the theme's first
154
+ * sequential palette (categorical endpoints as a last resort).
155
+ */
156
+ function resolveRampColors(channel: EncodingChannel, theme: ResolvedTheme): string[] {
157
+ const explicitRange = channel.scale?.range as string[] | undefined;
158
+ if (explicitRange?.length) return explicitRange;
159
+ return Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
160
+ }
161
+
162
+ /** Clamp the bar width into the preferred band without exceeding available space. */
163
+ function resolveBarWidth(availableWidth: number): number {
164
+ const preferred = Math.min(
165
+ BAR_WIDTH_MAX,
166
+ Math.max(BAR_WIDTH_MIN, Math.round(availableWidth * 0.4)),
167
+ );
168
+ return Math.max(0, Math.min(preferred, Math.floor(availableWidth)));
169
+ }
170
+
171
+ /** Class break values for a binned color scale, mirroring the mark scale. */
172
+ function resolveBinBreaks(channel: EncodingChannel, values: number[], binCount: number): number[] {
173
+ const scaleType = channel.scale?.type;
174
+ if (scaleType === 'threshold') {
175
+ return (channel.scale?.domain as number[] | undefined) ?? [0.5];
176
+ }
177
+ if (scaleType === 'quantile') {
178
+ const scale = scaleQuantile<number>()
179
+ .domain(values)
180
+ .range(Array.from({ length: binCount }, (_, i) => i));
181
+ return scale.quantiles();
182
+ }
183
+ // quantize: evenly spaced breaks across the (possibly explicit) domain
184
+ const explicitDomain = channel.scale?.domain as [number, number] | undefined;
185
+ const [valuesMin, valuesMax] = extent(values);
186
+ const domainMin = explicitDomain?.[0] ?? valuesMin;
187
+ const domainMax = explicitDomain?.[1] ?? valuesMax;
188
+ const breaks: number[] = [];
189
+ for (let i = 1; i < binCount; i++) {
190
+ breaks.push(domainMin + ((domainMax - domainMin) * i) / binCount);
191
+ }
192
+ return breaks;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Public API
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /** True when the spec's color channel resolves to a continuous/binned color scale. */
200
+ export function hasContinuousColorScale(spec: NormalizedChartSpec): boolean {
201
+ const colorEnc = spec.encoding.color;
202
+ if (!colorEnc || 'condition' in colorEnc || !('field' in colorEnc)) return false;
203
+ return colorEnc.type === 'quantitative';
204
+ }
205
+
206
+ /**
207
+ * Compute continuous legend content from raw numeric values and a color
208
+ * channel config. Reusable by both chart specs and map specs.
209
+ *
210
+ * Label policy: min/max only for sequential ramps, min/neutral/max for
211
+ * diverging ramps; binned scales get one boundary label per class break.
212
+ *
213
+ * @returns null when values is empty or bar width resolves to 0.
214
+ */
215
+ export function computeContinuousLegendContentForChannel(
216
+ values: number[],
217
+ channel: EncodingChannel,
218
+ theme: ResolvedTheme,
219
+ availableWidth: number,
220
+ ): ContinuousLegendContent | null {
221
+ if (values.length === 0) return null;
222
+
223
+ const barWidth = resolveBarWidth(availableWidth);
224
+ if (barWidth <= 0) return null;
225
+
226
+ const scaleType = channel.scale?.type;
227
+ const rampColors = resolveRampColors(channel, theme);
228
+ const formatStr = channel.format;
229
+ const ctx = computeFieldFormatContext(values);
230
+
231
+ if (scaleType && BINNED_SCALE_TYPES.has(scaleType)) {
232
+ const explicitRange = channel.scale?.range as string[] | undefined;
233
+ const binCount =
234
+ scaleType === 'threshold'
235
+ ? ((channel.scale?.domain as number[] | undefined) ?? [0.5]).length + 1
236
+ : (explicitRange?.length ?? DEFAULT_BIN_COUNT);
237
+ const breaks = resolveBinBreaks(channel, values, binCount);
238
+ const colors = sampleRampColors(rampColors, binCount);
239
+ const binWidth = barWidth / binCount;
240
+
241
+ const bins: ContinuousLegendBin[] = colors.map((color, i) => ({
242
+ x: i * binWidth,
243
+ width: binWidth,
244
+ color,
245
+ }));
246
+ const ticks: ContinuousLegendTick[] = breaks.map((value, i) => ({
247
+ value,
248
+ label: formatLegendValue(value, formatStr, ctx),
249
+ x: (i + 1) * binWidth,
250
+ anchor: 'middle' as const,
251
+ }));
252
+
253
+ return {
254
+ mode: 'binned',
255
+ barWidth,
256
+ barHeight: CONTINUOUS_BAR_HEIGHT,
257
+ colorStops: [],
258
+ bins,
259
+ ticks,
260
+ };
261
+ }
262
+
263
+ // Gradient mode: sequential or diverging ramp.
264
+ const [domainMin, domainMax] = extent(values);
265
+ const explicitRange = channel.scale?.range as string[] | undefined;
266
+
267
+ const stops =
268
+ explicitRange && explicitRange.length > 2
269
+ ? explicitRange
270
+ : [rampColors[0], rampColors[rampColors.length - 1]];
271
+ const colorStops: GradientColorStop[] = stops.map((color, i) => ({
272
+ offset: stops.length > 1 ? i / (stops.length - 1) : 0,
273
+ color,
274
+ }));
275
+
276
+ const ticks: ContinuousLegendTick[] = [
277
+ {
278
+ value: domainMin,
279
+ label: formatLegendValue(domainMin, formatStr, ctx),
280
+ x: 0,
281
+ anchor: 'start' as const,
282
+ },
283
+ ];
284
+ if (domainMax > domainMin) {
285
+ if (isDivergingRamp(stops, theme)) {
286
+ const neutral = (domainMin + domainMax) / 2;
287
+ ticks.push({
288
+ value: neutral,
289
+ label: formatLegendValue(neutral, formatStr, ctx),
290
+ x: barWidth / 2,
291
+ anchor: 'middle' as const,
292
+ });
293
+ }
294
+ ticks.push({
295
+ value: domainMax,
296
+ label: formatLegendValue(domainMax, formatStr, ctx),
297
+ x: barWidth,
298
+ anchor: 'end' as const,
299
+ });
300
+ }
301
+
302
+ return {
303
+ mode: 'gradient',
304
+ barWidth,
305
+ barHeight: CONTINUOUS_BAR_HEIGHT,
306
+ colorStops,
307
+ bins: [],
308
+ ticks,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Compute continuous legend content from a spec's quantitative color encoding.
314
+ * Thin wrapper around computeContinuousLegendContentForChannel.
315
+ *
316
+ * @returns null when the color encoding is not continuous or has no numeric data.
317
+ */
318
+ export function computeContinuousLegendContent(
319
+ spec: NormalizedChartSpec,
320
+ theme: ResolvedTheme,
321
+ availableWidth: number,
322
+ ): ContinuousLegendContent | null {
323
+ if (!hasContinuousColorScale(spec)) return null;
324
+ const colorEnc = spec.encoding.color as EncodingChannel;
325
+ const values = numericFieldValues(spec, colorEnc.field);
326
+ return computeContinuousLegendContentForChannel(values, colorEnc, theme, availableWidth);
327
+ }