@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
@@ -3,8 +3,9 @@
3
3
  * overlap to numbered dot markers with footnote text below the chart.
4
4
  *
5
5
  * Single deterministic pass: run collision resolution once at the full chart
6
- * area, then greedily place by priority. Annotations that overlap an
7
- * already-placed one get demoted. No iterative layout convergence.
6
+ * area, then greedily place by priority. A candidate is demoted when it either
7
+ * overlaps an already-placed label or escapes the plot area. No iterative
8
+ * layout convergence.
8
9
  */
9
10
 
10
11
  import type {
@@ -22,6 +23,17 @@ export interface ThinningResult {
22
23
  footnotes: AnnotationFootnote[];
23
24
  }
24
25
 
26
+ /**
27
+ * Max share of the plot area inline annotation labels may collectively claim
28
+ * before further candidates demote to footnotes. Measured coverage for a
29
+ * 6-callout line chart runs ~5% at 860px and ~16% at 300px, so 10% leaves wide
30
+ * charts fully inline and demotes the crowded tail on narrow ones.
31
+ */
32
+ const COVERAGE_BUDGET = 0.1;
33
+
34
+ /** Gap enforced between inline labels; labels closer than this count as colliding. */
35
+ const COLLISION_PADDING = 4;
36
+
25
37
  /**
26
38
  * Apply auto-thinning to resolved annotations. Text annotations that still
27
39
  * overlap after collision resolution are demoted to footnote markers.
@@ -37,11 +49,22 @@ export interface ThinningResult {
37
49
  * minus any that were skipped by `computeAnnotations`).
38
50
  * @param specs - The full spec annotations array, index-aligned with `annotations`.
39
51
  * Non-text specs are ignored; text specs provide `priority` and `responsive`.
52
+ * @param plotArea - The plot rect. Sizes the coverage budget: labels are charged
53
+ * against the plot they crowd, so the same label costs a larger share of a
54
+ * smaller plot. Omit to skip the budget test.
55
+ * @param containerBounds - The rect a label must fit inside to stay inline. This
56
+ * is the whole chart, NOT the plot: annotations render outside the clip path and
57
+ * legitimately sit in the margins (above a peak, below a trough), which is what
58
+ * collision avoidance already assumes. Fencing them to the plot would demote
59
+ * ordinary callouts. Only labels escaping the chart entirely are demoted.
60
+ * Omit to skip the containment test.
40
61
  */
41
62
  export function thinAnnotations(
42
63
  annotations: ResolvedAnnotation[],
43
64
  specs: Annotation[],
44
65
  measure: AnnotationMeasureTextFn,
66
+ plotArea?: Rect,
67
+ containerBounds?: Rect,
45
68
  ): ThinningResult {
46
69
  if (annotations.length <= 1) {
47
70
  return { annotations, footnotes: [] };
@@ -49,11 +72,20 @@ export function thinAnnotations(
49
72
 
50
73
  // Build index pairs: (resolved, spec, original index).
51
74
  // Only text specs carry priority/responsive; non-text specs are ignored.
52
- const entries = annotations.map((a, i) => ({
53
- resolved: a,
54
- spec: i < specs.length && specs[i].type === 'text' ? (specs[i] as TextAnnotation) : undefined,
55
- index: i,
56
- }));
75
+ //
76
+ // `annotations` is a filtered view of `specs` -- an annotation that resolves to
77
+ // nothing (position outside the domain) is dropped -- so index into `specs` by
78
+ // the stamped `specIndex`, never by position in `annotations`. Falling back to
79
+ // `i` keeps a hand-built layout (no specIndex) working as it did before.
80
+ const entries = annotations.map((a, i) => {
81
+ const specIndex = a.specIndex ?? i;
82
+ const spec = specs[specIndex];
83
+ return {
84
+ resolved: a,
85
+ spec: spec?.type === 'text' ? (spec as TextAnnotation) : undefined,
86
+ index: i,
87
+ };
88
+ });
57
89
 
58
90
  // Separate pinned (responsive: false) from candidates
59
91
  const pinned: typeof entries = [];
@@ -85,8 +117,26 @@ export function thinAnnotations(
85
117
  }
86
118
  }
87
119
 
88
- // Greedily place candidates; demote overlapping ones to footnotes
120
+ // Greedily place candidates; demote overlapping ones to footnotes.
121
+ //
122
+ // Overlap and containment alone under-thin at narrow widths: collision
123
+ // resolution spreads labels apart and clamping pulls them back inside, so
124
+ // they neither collide nor escape the plot — they just crowd it. Layer a
125
+ // coverage budget on top: inline labels may claim at most COVERAGE_BUDGET of
126
+ // the plot area, and candidates that push past it demote. Coverage rises as
127
+ // the plot shrinks (a fixed label costs a larger share of a smaller plot),
128
+ // so this only bites at small sizes and leaves roomy charts untouched.
129
+ //
130
+ // Pinned labels are exempt: they are guaranteed placement, so charging them
131
+ // against the budget could exhaust it before any candidate is seen and evict
132
+ // labels that would otherwise fit. They still block via `placedBounds`.
89
133
  const footnotes: AnnotationFootnote[] = [];
134
+ const plotAreaSize = plotArea ? plotArea.width * plotArea.height : 0;
135
+ const coverageBudget = plotAreaSize * COVERAGE_BUDGET;
136
+ // Containment is checked against the chart, not the plot — see the param docs.
137
+ // Callers that pass no container fall back to the plot rect.
138
+ const containment = containerBounds ?? plotArea;
139
+ let usedArea = 0;
90
140
 
91
141
  for (const entry of candidates) {
92
142
  const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label!, measure);
@@ -96,11 +146,20 @@ export function thinAnnotations(
96
146
  continue;
97
147
  }
98
148
 
149
+ const padded = {
150
+ x: bounds.x - COLLISION_PADDING,
151
+ y: bounds.y - COLLISION_PADDING,
152
+ width: bounds.width + COLLISION_PADDING * 2,
153
+ height: bounds.height + COLLISION_PADDING * 2,
154
+ };
99
155
  const overlaps = placedBounds.some(
100
- (pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
156
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(padded, pb),
101
157
  );
102
158
 
103
- if (overlaps) {
159
+ const candidateArea = bounds.width * bounds.height;
160
+ const overBudget = plotAreaSize > 0 && usedArea + candidateArea > coverageBudget;
161
+
162
+ if (overlaps || !fitsWithin(bounds, containment) || overBudget) {
104
163
  const footnoteIndex = footnotes.length + 1;
105
164
  footnotes.push({
106
165
  index: footnoteIndex,
@@ -109,8 +168,23 @@ export function thinAnnotations(
109
168
  entry.resolved.footnoteIndex = footnoteIndex;
110
169
  } else {
111
170
  placedBounds.push(bounds);
171
+ usedArea += candidateArea;
112
172
  }
113
173
  }
114
174
 
115
175
  return { annotations, footnotes };
116
176
  }
177
+
178
+ /**
179
+ * Whether a label's bounds sit entirely inside the plot. No plot area means no
180
+ * containment constraint, so everything fits.
181
+ */
182
+ function fitsWithin(bounds: Rect, plotArea: Rect | undefined): boolean {
183
+ if (!plotArea || plotArea.width <= 0 || plotArea.height <= 0) return true;
184
+ return (
185
+ bounds.x >= plotArea.x &&
186
+ bounds.y >= plotArea.y &&
187
+ bounds.x + bounds.width <= plotArea.x + plotArea.width &&
188
+ bounds.y + bounds.height <= plotArea.y + plotArea.height
189
+ );
190
+ }
@@ -24,15 +24,14 @@ import type {
24
24
  } from '@opendata-ai/openchart-core';
25
25
  import {
26
26
  adaptTheme,
27
- buildD3Formatter,
28
27
  computeChrome,
29
28
  estimateTextWidth,
30
- formatNumber,
31
29
  resolveTheme,
32
30
  } from '@opendata-ai/openchart-core';
33
-
31
+ import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
34
32
  import { resolveAnimation } from '../compiler/animation';
35
33
  import { compile as compileSpec } from '../compiler/index';
34
+ import { resolveFieldFormatter } from '../format/field-format';
36
35
  import type { NormalizedBarListSpec } from './types';
37
36
 
38
37
  // ---------------------------------------------------------------------------
@@ -57,7 +56,15 @@ const BARLIST_COLORS = ['#06b6d4', '#34d399', '#fbbf24', '#f472b6', '#a78bfa'];
57
56
  // ---------------------------------------------------------------------------
58
57
 
59
58
  export function compileBarList(spec: unknown, options: CompileOptions): BarListLayout {
60
- const { spec: normalized } = compileSpec(spec);
59
+ // Expand deprecated top-level sugar (valueFormat -> encoding.value.format)
60
+ // before validation, then validate + normalize via the shared pipeline.
61
+ const sugarWarnings: string[] = [];
62
+ const expandedSpec =
63
+ spec && typeof spec === 'object' && !Array.isArray(spec)
64
+ ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
65
+ : spec;
66
+ const { spec: normalized, warnings } = compileSpec(expandedSpec);
67
+ emitSpecWarnings([...sugarWarnings, ...warnings], options.onWarn);
61
68
 
62
69
  if (!('type' in normalized) || normalized.type !== 'barlist') {
63
70
  throw new Error(
@@ -158,8 +165,11 @@ export function compileBarList(spec: unknown, options: CompileOptions): BarListL
158
165
  return palette[idx % palette.length];
159
166
  }
160
167
 
161
- // Value formatter
162
- const formatter = buildD3Formatter(barlistSpec.valueFormat) ?? formatNumber;
168
+ // Value formatter: prefer encoding-level format (v8 canonical), fall back to deprecated valueFormat
169
+ const formatter = resolveFieldFormatter({
170
+ surfaceFormat: barlistSpec.encoding.value.format ?? barlistSpec.valueFormat,
171
+ values: validRows.map((r) => r[valueField]),
172
+ });
163
173
 
164
174
  // Compute label width: measure all labels and use a consistent width
165
175
  const measureText =
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Shared numeric value formatter for data labels.
3
3
  *
4
- * Used by bar, column, and dot label computation to display a value:
5
- * abbreviated (K/M/B/T) for magnitudes >= 1000, otherwise the default
6
- * numeric format.
4
+ * Used by bar, column, and dot label computation to display a value
5
+ * with smart compact defaults (k/M/B/T).
7
6
  */
8
7
 
9
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
8
+ import type { FieldFormatContext } from '@opendata-ai/openchart-core';
9
+ import { defaultNumberFormatter } from '@opendata-ai/openchart-core';
10
10
 
11
- /** Format a label value for display (abbreviate large numbers). */
12
- export function formatLabelValue(value: number): string {
13
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
14
- return formatNumber(value);
11
+ const defaultFmt = defaultNumberFormatter();
12
+
13
+ /** Format a label value for display (compact by default). */
14
+ export function formatLabelValue(value: number, ctx?: FieldFormatContext): string {
15
+ return (ctx ? defaultNumberFormatter(ctx) : defaultFmt)(value);
15
16
  }
@@ -1,4 +1,5 @@
1
1
  import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
2
+ import { buildD3Formatter } from '@opendata-ai/openchart-core';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import type { NormalizedChartSpec } from '../../../compiler/types';
4
5
  import { computeScales } from '../../../layout/scales';
@@ -58,7 +59,9 @@ function makeGroupedBarSpec(): NormalizedChartSpec {
58
59
  { category: 'Q3', value: 70, region: 'West' },
59
60
  ],
60
61
  encoding: {
61
- x: { field: 'value', type: 'quantitative' },
62
+ // Grouped is opt-out since v8 (stacked is now the default): stamp
63
+ // stack: null explicitly so this fixture stays grouped.
64
+ x: { field: 'value', type: 'quantitative', stack: null },
62
65
  y: { field: 'category', type: 'nominal' },
63
66
  color: { field: 'region', type: 'nominal' },
64
67
  },
@@ -214,7 +217,46 @@ describe('computeBarMarks', () => {
214
217
  });
215
218
  });
216
219
 
217
- describe('grouped bars (default)', () => {
220
+ describe('stack: undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
221
+ function makeDefaultStackBarSpec(): NormalizedChartSpec {
222
+ return {
223
+ markType: 'bar',
224
+ markDef: { type: 'bar' },
225
+ data: [
226
+ { category: 'Q1', value: 50, region: 'East' },
227
+ { category: 'Q1', value: 40, region: 'West' },
228
+ { category: 'Q2', value: 60, region: 'East' },
229
+ { category: 'Q2', value: 55, region: 'West' },
230
+ ],
231
+ encoding: {
232
+ x: { field: 'value', type: 'quantitative' },
233
+ y: { field: 'category', type: 'nominal' },
234
+ color: { field: 'region', type: 'nominal' },
235
+ },
236
+ chrome: {},
237
+ annotations: [],
238
+ responsive: true,
239
+ theme: {},
240
+ darkMode: 'off',
241
+ labels: { density: 'auto', format: '' },
242
+ };
243
+ }
244
+
245
+ it('stacks segments end-to-end without an explicit stack property', () => {
246
+ const spec = makeDefaultStackBarSpec();
247
+ const scales = computeScales(spec, chartArea, spec.data);
248
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
249
+
250
+ const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
251
+ expect(q1Marks).toHaveLength(2);
252
+ expect(q1Marks[0].y).toBe(q1Marks[1].y);
253
+ const [first, second] = q1Marks;
254
+ expect(second.x).toBeCloseTo(first.x + first.width, 0);
255
+ expect(marks.every((m) => m.stackGroup !== undefined)).toBe(true);
256
+ });
257
+ });
258
+
259
+ describe('grouped bars (stack: null)', () => {
218
260
  it('produces marks for all data rows', () => {
219
261
  const spec = makeGroupedBarSpec();
220
262
  const scales = computeScales(spec, chartArea, spec.data);
@@ -329,7 +371,10 @@ describe('computeBarMarks', () => {
329
371
  markDef: { type: 'bar' },
330
372
  data,
331
373
  encoding: {
332
- x: { field: 'dollars', type: 'quantitative' },
374
+ // Grouped is opt-out since v8 (stacked is now the default): stamp
375
+ // stack: null explicitly since this test exercises the
376
+ // grouped-bar-only readable-floor reclaim.
377
+ x: { field: 'dollars', type: 'quantitative', stack: null },
333
378
  y: { field: 'district', type: 'nominal' },
334
379
  color: { field: 'source', type: 'nominal' },
335
380
  },
@@ -470,10 +515,12 @@ describe('computeBarMarks', () => {
470
515
  markDef: { type: 'bar' },
471
516
  data: wageData,
472
517
  encoding: {
518
+ // Grouped is opt-out since v8 (stacked is now the default): stamp
519
+ // stack: null explicitly for the grouped case.
473
520
  x: {
474
521
  field: 'pay',
475
522
  type: 'quantitative',
476
- ...(stackZero ? { stack: 'zero' } : {}),
523
+ stack: stackZero ? 'zero' : null,
477
524
  },
478
525
  y: { field: 'size', type: 'nominal' },
479
526
  color: { field: 'year', type: 'nominal' },
@@ -487,7 +534,7 @@ describe('computeBarMarks', () => {
487
534
  };
488
535
  }
489
536
 
490
- it('groups by default: bars sit at different y positions within each category', () => {
537
+ it('grouped (stack: null): bars sit at different y positions within each category', () => {
491
538
  const spec = makeWageSpec(false);
492
539
  const scales = computeScales(spec, chartArea, spec.data);
493
540
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
@@ -500,12 +547,12 @@ describe('computeBarMarks', () => {
500
547
  expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
501
548
  });
502
549
 
503
- it('groups by default: scale domain covers max individual value, not stacked sum', () => {
550
+ it('grouped (stack: null): scale domain covers max individual value, not stacked sum', () => {
504
551
  const spec = makeWageSpec(false);
505
552
  const scales = computeScales(spec, chartArea, spec.data);
506
553
 
507
554
  // Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
508
- // Default grouped bars should NOT extend domain to 137100.
555
+ // Grouped bars should NOT extend domain to 137100.
509
556
  const xScale = scales.x!.scale;
510
557
  const domain = xScale.domain() as number[];
511
558
  expect(domain[1]).toBeLessThan(137100);
@@ -711,7 +758,7 @@ describe('computeBarLabels', () => {
711
758
  const spec = makeSimpleBarSpec();
712
759
  const scales = computeScales(spec, chartArea, spec.data);
713
760
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
714
- const labels = computeBarLabels(marks, chartArea, 'auto', '$,.0f');
761
+ const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
715
762
 
716
763
  const texts = labels.map((l) => l.text);
717
764
  expect(texts).toContain('$50');
@@ -740,7 +787,7 @@ describe('computeBarLabels', () => {
740
787
  };
741
788
  const scales = computeScales(spec, chartArea, spec.data);
742
789
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
743
- const labels = computeBarLabels(marks, chartArea, 'all', '$,.2~fT');
790
+ const labels = computeBarLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
744
791
 
745
792
  const texts = labels.map((l) => l.text);
746
793
  expect(texts).toContain('$3.75T');
@@ -751,7 +798,7 @@ describe('computeBarLabels', () => {
751
798
  const spec = makeSimpleBarSpec();
752
799
  const scales = computeScales(spec, chartArea, spec.data);
753
800
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
754
- const labels = computeBarLabels(marks, chartArea, 'auto', '.0f%');
801
+ const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
755
802
 
756
803
  const texts = labels.map((l) => l.text);
757
804
  expect(texts).toContain('50%');
@@ -1,4 +1,5 @@
1
1
  import type { RectMark } from '@opendata-ai/openchart-core';
2
+ import { buildD3Formatter } from '@opendata-ai/openchart-core';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import { computeBarLabels } from '../labels';
4
5
 
@@ -163,7 +164,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
163
164
  makeAbbreviatedMark(2, 14000, '14K'),
164
165
  ];
165
166
 
166
- const labels = computeBarLabels(thousandMarks, chartArea, 'all', '$~s');
167
+ const labels = computeBarLabels(thousandMarks, chartArea, 'all', buildD3Formatter('$~s'));
167
168
  expect(labels).toHaveLength(3);
168
169
  expect(labels[0].text).toBe('$6k');
169
170
  expect(labels[1].text).toBe('$7k');
@@ -173,7 +174,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
173
174
  it('$~s format works for millions', () => {
174
175
  const millionMarks: RectMark[] = [makeAbbreviatedMark(0, 1500000, '1.5M')];
175
176
 
176
- const labels = computeBarLabels(millionMarks, chartArea, 'all', '$~s');
177
+ const labels = computeBarLabels(millionMarks, chartArea, 'all', buildD3Formatter('$~s'));
177
178
  expect(labels).toHaveLength(1);
178
179
  expect(labels[0].text).toBe('$1.5M');
179
180
  });
@@ -181,7 +182,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
181
182
  it('handles comma-formatted aria values', () => {
182
183
  const commaMarks: RectMark[] = [makeAbbreviatedMark(0, 500, '500')];
183
184
 
184
- const labels = computeBarLabels(commaMarks, chartArea, 'all', '$,.0f');
185
+ const labels = computeBarLabels(commaMarks, chartArea, 'all', buildD3Formatter('$,.0f'));
185
186
  expect(labels).toHaveLength(1);
186
187
  expect(labels[0].text).toBe('$500');
187
188
  });
@@ -211,7 +212,7 @@ describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
211
212
  makeUnicodeMinusMark(1, '\u22125'), // −5
212
213
  ];
213
214
 
214
- const labels = computeBarLabels(unicodeMarks, chartArea, 'all', '+.0f%');
215
+ const labels = computeBarLabels(unicodeMarks, chartArea, 'all', buildD3Formatter('+.0f%'));
215
216
  expect(labels).toHaveLength(2);
216
217
  expect(labels[0].text).toBe('\u221234%'); // −34%
217
218
  expect(labels[1].text).toBe('\u22125%'); // −5%
@@ -138,12 +138,8 @@ export function computeBarMarks(
138
138
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
139
139
 
140
140
  if (needsStacking) {
141
- // stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
142
- const stackEnabled =
143
- xChannel.stack === true ||
144
- xChannel.stack === 'zero' ||
145
- xChannel.stack === 'normalize' ||
146
- xChannel.stack === 'center';
141
+ // stack: undefined/true/'zero'/'normalize'/'center' -> stacked (default); null/false -> grouped
142
+ const stackEnabled = xChannel.stack !== null && xChannel.stack !== false;
147
143
 
148
144
  if (!stackEnabled) {
149
145
  marks = computeGroupedBars(
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Mark } from '@opendata-ai/openchart-core';
8
+ import { resolveFieldFormatter } from '../../format/field-format';
8
9
  import type { ChartRenderer } from '../registry';
9
10
  import { computeBarMarks } from './compute';
10
11
  import { computeBarLabels } from './labels';
@@ -26,11 +27,17 @@ export const barRenderer: ChartRenderer = (
26
27
  // Compute and attach value labels (respects spec.labels.density)
27
28
  const valueField =
28
29
  spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
30
+ const labelFormatter = resolveFieldFormatter({
31
+ surfaceFormat: spec.labels.format,
32
+ channelFormat:
33
+ spec.encoding?.x && 'format' in spec.encoding.x ? spec.encoding.x.format : undefined,
34
+ values: valueField ? spec.data.map((r) => r[valueField]) : [],
35
+ });
29
36
  const labels = computeBarLabels(
30
37
  marks,
31
38
  chartArea,
32
39
  spec.labels.density,
33
- spec.labels.format,
40
+ labelFormatter,
34
41
  spec.labels.prefix,
35
42
  valueField,
36
43
  spec.labels.color,
@@ -16,11 +16,11 @@
16
16
  import type {
17
17
  LabelCandidate,
18
18
  LabelDensity,
19
+ NumberFormatter,
19
20
  RectMark,
20
21
  ResolvedLabel,
21
22
  } from '@opendata-ai/openchart-core';
22
23
  import {
23
- buildD3Formatter,
24
24
  estimateTextWidth,
25
25
  getRepresentativeColor,
26
26
  pickLabelColor,
@@ -93,7 +93,7 @@ export function computeBarLabels(
93
93
  marks: RectMark[],
94
94
  _chartArea: { x: number; y: number; width: number; height: number },
95
95
  density: LabelDensity = 'auto',
96
- labelFormat?: string,
96
+ labelFormatter?: NumberFormatter | null,
97
97
  labelPrefix?: string,
98
98
  valueField?: string,
99
99
  labelColor?: string,
@@ -113,7 +113,7 @@ export function computeBarLabels(
113
113
  // horizontal nudge back onto this display anchor so textAnchor stays honored.
114
114
  const displayAnchorX: number[] = [];
115
115
 
116
- const formatter = buildD3Formatter(labelFormat);
116
+ const formatter = labelFormatter ?? null;
117
117
 
118
118
  for (const mark of targetMarks) {
119
119
  // Get the original numeric value from the data row when possible,