@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
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Calendar heatmap mark computation.
3
+ *
4
+ * GitHub-style layout: weeks as columns, weekdays as rows, month labels
5
+ * above each band, Mon/Wed/Fri weekday labels on the left. Multi-year data
6
+ * partitions into one full-year band per calendar year, stacked vertically
7
+ * with a year label per band; every band shares the figure's single color
8
+ * scale (built once from the full dataset by computeScales).
9
+ *
10
+ * The calendar owns its geometry: no positional scales or axes. All date
11
+ * math runs in UTC (d3-time utc* intervals) so the layout is deterministic
12
+ * regardless of the host machine's timezone -- ISO date strings like
13
+ * "2024-01-15" parse as UTC midnight, and a local-time week interval would
14
+ * shift them across day boundaries in western timezones.
15
+ *
16
+ * Days inside a band's year with no data row (or a non-numeric value)
17
+ * render as empty cells filled with the theme's achromatic surface tint
18
+ * (colors.annotationFill), visually distinct from the chromatic scale
19
+ * minimum in both light and dark modes. Days padding the first/last week
20
+ * but belonging to a neighboring year render nothing.
21
+ */
22
+
23
+ import type {
24
+ Encoding,
25
+ Mark,
26
+ MarkAria,
27
+ Rect,
28
+ RectMark,
29
+ ResolvedTheme,
30
+ TextMarkLayout,
31
+ } from '@opendata-ai/openchart-core';
32
+ import { defaultNumberFormatter, formatDate } from '@opendata-ai/openchart-core';
33
+ import { type CountableTimeInterval, utcDay, utcMonday, utcSunday } from 'd3-time';
34
+
35
+ import type { NormalizedChartSpec } from '../../compiler/types';
36
+ import type { ResolvedScales } from '../../layout/scales';
37
+ import { getSequentialColor } from '../utils';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Constants
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Minimum day-cell size in pixels (the "never shrink marks below legibility"
45
+ * floor, extending the never-shrink-text house rule to marks). When the
46
+ * container is too narrow for 53 legible columns the grid keeps this floor
47
+ * and overflows the chart area instead of shrinking further; hosts that need
48
+ * every column visible at phone widths wrap the figure in a horizontal
49
+ * scroll container.
50
+ */
51
+ export const MIN_CELL_SIZE = 7;
52
+
53
+ /** Maximum grid step (cell + gap) so huge containers stay dense. */
54
+ const MAX_STEP = 20;
55
+
56
+ /** Minimum grid step: the cell floor plus a 1px gap. */
57
+ const MIN_STEP = MIN_CELL_SIZE + 1;
58
+
59
+ /** Vertical gap between stacked year bands. */
60
+ const BAND_GAP = 18;
61
+
62
+ /** Gap between the weekday labels and the first week column. */
63
+ const WEEKDAY_LABEL_GAP = 6;
64
+
65
+ /** Default corner radius for day cells (markDef.cellRadius overrides). */
66
+ const DEFAULT_CELL_RADIUS = 1;
67
+
68
+ /** Weekday labels shown on the left (day-of-week numbers, 0 = Sunday). */
69
+ const WEEKDAY_LABELS: Array<{ dow: number; label: string }> = [
70
+ { dow: 1, label: 'Mon' },
71
+ { dow: 3, label: 'Wed' },
72
+ { dow: 5, label: 'Fri' },
73
+ ];
74
+
75
+ /** Abbreviated month labels, January first. */
76
+ const MONTH_LABELS = [
77
+ 'Jan',
78
+ 'Feb',
79
+ 'Mar',
80
+ 'Apr',
81
+ 'May',
82
+ 'Jun',
83
+ 'Jul',
84
+ 'Aug',
85
+ 'Sep',
86
+ 'Oct',
87
+ 'Nov',
88
+ 'Dec',
89
+ ];
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Date helpers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** Parse a raw field value to a UTC-floored day, or null when unparseable. */
96
+ export function parseUtcDay(value: unknown): Date | null {
97
+ if (value == null) return null;
98
+ const date = value instanceof Date ? value : new Date(value as string | number);
99
+ if (Number.isNaN(date.getTime())) return null;
100
+ return utcDay.floor(date);
101
+ }
102
+
103
+ /** ISO yyyy-mm-dd key for a UTC day. */
104
+ function isoDayKey(day: Date): string {
105
+ return day.toISOString().slice(0, 10);
106
+ }
107
+
108
+ /** Week interval for a weekStart option. */
109
+ function weekInterval(weekStart: 'monday' | 'sunday'): CountableTimeInterval {
110
+ return weekStart === 'sunday' ? utcSunday : utcMonday;
111
+ }
112
+
113
+ /** Weekday row index (0..6) for a UTC day under a weekStart option. */
114
+ export function weekdayRow(day: Date, weekStart: 'monday' | 'sunday'): number {
115
+ const startDow = weekStart === 'sunday' ? 0 : 1;
116
+ return (day.getUTCDay() - startDow + 7) % 7;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public API
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Compute calendar heatmap marks from a normalized chart spec.
125
+ *
126
+ * Emits one RectMark per day of every calendar year present in the data
127
+ * (days without data get the empty-cell surface tint) plus TextMarkLayout
128
+ * marks for month, weekday, and (multi-year only) year labels.
129
+ */
130
+ export function computeCalendarMarks(
131
+ spec: NormalizedChartSpec,
132
+ scales: ResolvedScales,
133
+ chartArea: Rect,
134
+ theme: ResolvedTheme,
135
+ ): Mark[] {
136
+ const encoding = spec.encoding as Encoding;
137
+ const xEnc = encoding.x;
138
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
139
+ if (!xEnc || !colorEnc) return [];
140
+
141
+ const weekStart = spec.markDef.weekStart ?? 'monday';
142
+ const week = weekInterval(weekStart);
143
+ const cellRadius = spec.markDef.cellRadius ?? DEFAULT_CELL_RADIUS;
144
+
145
+ // Index rows by UTC day. Unparseable dates and non-numeric values were
146
+ // rejected (or warned about) at validation; anything that still slips
147
+ // through renders as an empty cell rather than corrupting the grid.
148
+ const dayData = new Map<string, { row: Record<string, unknown>; value: number }>();
149
+ const years = new Set<number>();
150
+ for (const row of spec.data) {
151
+ const day = parseUtcDay(row[xEnc.field]);
152
+ if (!day) continue;
153
+ years.add(day.getUTCFullYear());
154
+ const value = Number(row[colorEnc.field]);
155
+ if (!Number.isFinite(value)) continue;
156
+ dayData.set(isoDayKey(day), { row: row as Record<string, unknown>, value });
157
+ }
158
+ if (years.size === 0) return [];
159
+
160
+ const sortedYears = Array.from(years).sort((a, b) => a - b);
161
+
162
+ // Per-band week-column counts. A band always spans its full calendar year
163
+ // (Jan 1 through Dec 31), so partial-year data keeps the familiar 53-column
164
+ // silhouette and missing days read as gaps rather than a shifted grid.
165
+ const bandColumns = sortedYears.map((year) => {
166
+ const firstWeek = week.floor(new Date(Date.UTC(year, 0, 1)));
167
+ const lastDay = new Date(Date.UTC(year, 11, 31));
168
+ return week.count(firstWeek, lastDay) + 1;
169
+ });
170
+ const maxColumns = Math.max(...bandColumns);
171
+
172
+ // -------------------------------------------------------------------------
173
+ // Geometry
174
+ // -------------------------------------------------------------------------
175
+ const labelFont = theme.fonts.sizes.small;
176
+ const gutter = Math.ceil(labelFont * 2.6) + WEEKDAY_LABEL_GAP;
177
+ const monthRow = Math.ceil(labelFont * 1.3) + 2;
178
+ const bands = sortedYears.length;
179
+ const showYearLabels = bands > 1;
180
+
181
+ const availWidth = Math.max(0, chartArea.width - gutter);
182
+ const stepFromWidth = availWidth / maxColumns;
183
+ const availHeight = Math.max(0, chartArea.height - bands * monthRow - (bands - 1) * BAND_GAP);
184
+ const stepFromHeight = availHeight / (7 * bands);
185
+ // Square cells only: the calendar idiom reads as a grid of days, and
186
+ // wide cells would misalign weekday rows across stacked year bands. At
187
+ // narrow widths the step clamps to the MIN_CELL_SIZE floor instead of
188
+ // going sub-legible (the grid then overflows to the right).
189
+ const step = Math.min(
190
+ MAX_STEP,
191
+ Math.max(MIN_STEP, Math.floor(Math.min(stepFromWidth, stepFromHeight))),
192
+ );
193
+ const cellGap = step >= 14 ? 3 : step >= 10 ? 2 : 1;
194
+ const cell = step - cellGap;
195
+
196
+ const gridWidth = maxColumns * step - cellGap;
197
+ const bandHeight = monthRow + 7 * step - cellGap;
198
+ const totalHeight = bands * bandHeight + (bands - 1) * BAND_GAP;
199
+ const originX = chartArea.x + gutter + Math.max(0, (availWidth - gridWidth) / 2);
200
+ const originY = chartArea.y + Math.max(0, (chartArea.height - totalHeight) / 2);
201
+
202
+ // Month label thinning: when four week columns can't fit a three-letter
203
+ // label, keep every other month so labels never collide.
204
+ const monthLabelWidth = labelFont * 2.2;
205
+ const monthLabelEvery = step * 4.34 < monthLabelWidth + 4 ? 2 : 1;
206
+
207
+ const labelBaseline = labelFont; // baseline offset from the band top
208
+
209
+ const cellMarks: RectMark[] = [];
210
+ const labelMarks: TextMarkLayout[] = [];
211
+
212
+ const labelAria: MarkAria = { decorative: true };
213
+ const labelStyle = {
214
+ fill: theme.colors.axis,
215
+ fontSize: labelFont,
216
+ fontWeight: theme.fonts.weights.normal,
217
+ fontFamily: theme.fonts.family,
218
+ };
219
+
220
+ // -------------------------------------------------------------------------
221
+ // Bands
222
+ // -------------------------------------------------------------------------
223
+ const calendarFmt = defaultNumberFormatter();
224
+ for (let b = 0; b < bands; b++) {
225
+ const year = sortedYears[b];
226
+ const bandTop = originY + b * (bandHeight + BAND_GAP);
227
+ const gridTop = bandTop + monthRow;
228
+ const yearStart = new Date(Date.UTC(year, 0, 1));
229
+ const yearEnd = new Date(Date.UTC(year + 1, 0, 1));
230
+ const firstWeek = week.floor(yearStart);
231
+
232
+ // Year label (multi-year only), in the weekday gutter column.
233
+ if (showYearLabels) {
234
+ labelMarks.push({
235
+ type: 'textMark',
236
+ x: chartArea.x,
237
+ y: bandTop + labelBaseline,
238
+ text: String(year),
239
+ ...labelStyle,
240
+ fill: theme.colors.text,
241
+ fontWeight: theme.fonts.weights.semibold,
242
+ textAnchor: 'start',
243
+ data: {},
244
+ aria: labelAria,
245
+ });
246
+ }
247
+
248
+ // Month labels above the grid, at the column containing the 1st.
249
+ for (let m = 0; m < 12; m += monthLabelEvery) {
250
+ const col = week.count(firstWeek, new Date(Date.UTC(year, m, 1)));
251
+ labelMarks.push({
252
+ type: 'textMark',
253
+ x: originX + col * step,
254
+ y: bandTop + labelBaseline,
255
+ text: MONTH_LABELS[m],
256
+ ...labelStyle,
257
+ textAnchor: 'start',
258
+ data: {},
259
+ aria: labelAria,
260
+ });
261
+ }
262
+
263
+ // Weekday labels (Mon/Wed/Fri) left of the grid, centered on their row.
264
+ for (const { dow, label } of WEEKDAY_LABELS) {
265
+ const row = (dow - (weekStart === 'sunday' ? 0 : 1) + 7) % 7;
266
+ labelMarks.push({
267
+ type: 'textMark',
268
+ x: originX - WEEKDAY_LABEL_GAP,
269
+ y: gridTop + row * step + cell / 2 + labelFont * 0.35,
270
+ text: label,
271
+ ...labelStyle,
272
+ textAnchor: 'end',
273
+ data: {},
274
+ aria: labelAria,
275
+ });
276
+ }
277
+
278
+ // Day cells: every day of the calendar year.
279
+ for (const day of utcDay.range(yearStart, yearEnd)) {
280
+ const col = week.count(firstWeek, day);
281
+ const row = weekdayRow(day, weekStart);
282
+ const key = isoDayKey(day);
283
+ const datum = dayData.get(key);
284
+ const dateLabel = formatDate(day, undefined, 'day');
285
+
286
+ if (datum) {
287
+ cellMarks.push({
288
+ type: 'rect',
289
+ key,
290
+ x: originX + col * step,
291
+ y: gridTop + row * step,
292
+ width: cell,
293
+ height: cell,
294
+ fill: getSequentialColor(scales, datum.value),
295
+ cornerRadius: cellRadius,
296
+ data: datum.row,
297
+ aria: {
298
+ label: `${dateLabel}, ${colorEnc.field}: ${calendarFmt(datum.value)}`,
299
+ },
300
+ });
301
+ } else {
302
+ cellMarks.push({
303
+ type: 'rect',
304
+ key,
305
+ x: originX + col * step,
306
+ y: gridTop + row * step,
307
+ width: cell,
308
+ height: cell,
309
+ fill: theme.colors.annotationFill,
310
+ cornerRadius: cellRadius,
311
+ data: {},
312
+ aria: { decorative: true },
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ return [...cellMarks, ...labelMarks];
319
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Calendar heatmap chart module.
3
+ *
4
+ * Exports the calendar renderer and computation function. Calendars emit
5
+ * RectMark day cells plus decorative TextMarkLayout month/weekday/year
6
+ * labels; there are no positional scales or axes (the compile pipeline
7
+ * suppresses them, arc precedent). The quantitative color encoding drives
8
+ * both the cell fills and the default-on continuous color legend.
9
+ */
10
+
11
+ import type { ChartRenderer } from '../registry';
12
+ import { computeCalendarMarks } from './compute';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Calendar chart renderer
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Calendar heatmap renderer.
20
+ *
21
+ * Produces one rect per day of each calendar year in the data, plus text
22
+ * marks for the month/weekday/year labels.
23
+ */
24
+ export const calendarRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, theme) => {
25
+ return computeCalendarMarks(spec, scales, chartArea, theme);
26
+ };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Public exports
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export { computeCalendarMarks, MIN_CELL_SIZE, parseUtcDay, weekdayRow } from './compute';
@@ -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';
@@ -55,7 +56,9 @@ function makeGroupedColumnSpec(): NormalizedChartSpec {
55
56
  ],
56
57
  encoding: {
57
58
  x: { field: 'month', type: 'nominal' },
58
- y: { field: 'sales', type: 'quantitative' },
59
+ // Grouped is opt-out since v8 (stacked is now the default): stamp
60
+ // stack: null explicitly so this fixture stays grouped.
61
+ y: { field: 'sales', type: 'quantitative', stack: null },
59
62
  color: { field: 'region', type: 'nominal' },
60
63
  },
61
64
  chrome: {},
@@ -193,7 +196,7 @@ describe('computeColumnMarks', () => {
193
196
  });
194
197
  });
195
198
 
196
- describe('grouped columns (default)', () => {
199
+ describe('grouped columns (stack: null)', () => {
197
200
  it('produces marks for all data rows', () => {
198
201
  const spec = makeGroupedColumnSpec();
199
202
  const scales = computeScales(spec, chartArea, spec.data);
@@ -351,7 +354,7 @@ describe('computeColumnLabels', () => {
351
354
  const spec = makeSimpleColumnSpec();
352
355
  const scales = computeScales(spec, chartArea, spec.data);
353
356
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
354
- const labels = computeColumnLabels(marks, chartArea, 'auto', '$,.0f');
357
+ const labels = computeColumnLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
355
358
 
356
359
  const texts = labels.map((l) => l.text);
357
360
  expect(texts).toContain('$120');
@@ -379,7 +382,7 @@ describe('computeColumnLabels', () => {
379
382
  };
380
383
  const scales = computeScales(spec, chartArea, spec.data);
381
384
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
382
- const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~f');
385
+ const labels = computeColumnLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~f'));
383
386
 
384
387
  const texts = labels.map((l) => l.text);
385
388
  expect(texts).toContain('$3.1');
@@ -407,7 +410,7 @@ describe('computeColumnLabels', () => {
407
410
  };
408
411
  const scales = computeScales(spec, chartArea, spec.data);
409
412
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
410
- const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~fT');
413
+ const labels = computeColumnLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
411
414
 
412
415
  const texts = labels.map((l) => l.text);
413
416
  expect(texts).toContain('$3.75T');
@@ -418,7 +421,7 @@ describe('computeColumnLabels', () => {
418
421
  const spec = makeSimpleColumnSpec();
419
422
  const scales = computeScales(spec, chartArea, spec.data);
420
423
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
421
- const labels = computeColumnLabels(marks, chartArea, 'auto', '.0f%');
424
+ const labels = computeColumnLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
422
425
 
423
426
  const texts = labels.map((l) => l.text);
424
427
  expect(texts).toContain('120%');
@@ -432,7 +435,7 @@ describe('computeColumnLabels', () => {
432
435
 
433
436
  describe('stack modes', () => {
434
437
  function makeStackedSpec(
435
- stackMode: boolean | 'zero' | 'normalize' | 'center' | null,
438
+ stackMode: boolean | 'zero' | 'normalize' | 'center' | null | undefined,
436
439
  ): NormalizedChartSpec {
437
440
  return {
438
441
  markType: 'bar',
@@ -523,4 +526,15 @@ describe('stack modes', () => {
523
526
  expect(mark.stackGroup).toBeUndefined();
524
527
  }
525
528
  });
529
+
530
+ it('undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
531
+ const spec = makeStackedSpec(undefined);
532
+ const scales = computeScales(spec, chartArea, spec.data);
533
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
534
+
535
+ expect(marks.length).toBe(4);
536
+ for (const mark of marks) {
537
+ expect(mark.stackGroup).toBeDefined();
538
+ }
539
+ });
526
540
  });
@@ -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 { computeColumnLabels } from '../labels';
4
5
 
@@ -98,7 +99,81 @@ describe('computeColumnLabels positioning', () => {
98
99
  });
99
100
 
100
101
  it('applies labelFormat to numeric values', () => {
101
- const labels = computeColumnLabels([makeMark(0, 1234)], chartArea, 'all', ',.0f');
102
+ const labels = computeColumnLabels(
103
+ [makeMark(0, 1234)],
104
+ chartArea,
105
+ 'all',
106
+ buildD3Formatter(',.0f'),
107
+ );
102
108
  expect(labels[0].text).toBe('1,234');
103
109
  });
104
110
  });
111
+
112
+ describe('computeColumnLabels stacked segments', () => {
113
+ /** One segment of a stacked column, positioned by its top edge. */
114
+ function makeStackedMark(y: number, height: number, fill: string, value: number): RectMark {
115
+ return {
116
+ type: 'rect',
117
+ x: 0,
118
+ y,
119
+ width: 60,
120
+ height,
121
+ fill,
122
+ stackGroup: 'Cat0',
123
+ data: { category: 'Cat0', value },
124
+ aria: { label: `Cat0: ${value}` },
125
+ };
126
+ }
127
+
128
+ it('centers the label inside its own segment, not above it', () => {
129
+ // Above a stacked segment's top edge is the next segment up, not the
130
+ // background, so the label must stay within its own band.
131
+ const mark = makeStackedMark(100, 80, '#0e7490', 33);
132
+ const labels = computeColumnLabels([mark], chartArea, 'all');
133
+
134
+ expect(labels).toHaveLength(1);
135
+ expect(labels[0].y).toBeGreaterThan(mark.y);
136
+ expect(labels[0].y).toBeLessThan(mark.y + mark.height);
137
+ });
138
+
139
+ it('picks a fill that contrasts with its own segment', () => {
140
+ // A dark segment takes white text; a light segment takes near-black. The
141
+ // old code tinted the text with the segment's own series color, which put
142
+ // colored text on a colored fill.
143
+ const dark = computeColumnLabels([makeStackedMark(100, 80, '#0e7490', 33)], chartArea, 'all');
144
+ const light = computeColumnLabels([makeStackedMark(100, 80, '#e2e8f0', 33)], chartArea, 'all');
145
+
146
+ expect(dark[0].style.fill).toBe('#ffffff');
147
+ expect(light[0].style.fill).toBe('#111111');
148
+ });
149
+
150
+ it('hides the label when the segment is too short to hold it', () => {
151
+ // A ~4% segment (Nuclear in the energy-mix demo) can't fit a 10px label
152
+ // without spilling into its neighbours.
153
+ const labels = computeColumnLabels([makeStackedMark(100, 6, '#0e7490', 4)], chartArea, 'all');
154
+
155
+ expect(labels).toHaveLength(1);
156
+ expect(labels[0].visible).toBe(false);
157
+ });
158
+
159
+ it('an explicit labels.color still wins over the contrast pick', () => {
160
+ const labels = computeColumnLabels(
161
+ [makeStackedMark(100, 80, '#0e7490', 33)],
162
+ chartArea,
163
+ 'all',
164
+ undefined,
165
+ undefined,
166
+ undefined,
167
+ '#ff00ff',
168
+ );
169
+
170
+ expect(labels[0].style.fill).toBe('#ff00ff');
171
+ });
172
+
173
+ it('leaves unstacked columns labeling above the bar', () => {
174
+ const mark = makeMark(0, 20);
175
+ const labels = computeColumnLabels([mark], chartArea, 'all');
176
+
177
+ expect(labels[0].y).toBeLessThan(mark.y);
178
+ });
179
+ });
@@ -95,12 +95,8 @@ export function computeColumnMarks(
95
95
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
96
96
 
97
97
  if (needsStacking) {
98
- // stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
99
- const stackEnabled =
100
- yChannel.stack === true ||
101
- yChannel.stack === 'zero' ||
102
- yChannel.stack === 'normalize' ||
103
- yChannel.stack === 'center';
98
+ // stack: undefined/true/'zero'/'normalize'/'center' -> stacked (default); null/false -> grouped
99
+ const stackEnabled = yChannel.stack !== null && yChannel.stack !== false;
104
100
 
105
101
  if (!stackEnabled) {
106
102
  marks = computeGroupedColumns(
@@ -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 { computeColumnMarks } from './compute';
10
11
  import { computeColumnLabels } from './labels';
@@ -13,20 +14,27 @@ import { computeColumnLabels } from './labels';
13
14
  // Column chart renderer
14
15
  // ---------------------------------------------------------------------------
15
16
 
16
- export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
17
+ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
17
18
  const marks = computeColumnMarks(spec, scales, chartArea, strategy);
18
19
 
19
20
  // Compute and attach value labels (respects spec.labels.density)
20
21
  const valueField =
21
22
  spec.encoding?.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
23
+ const labelFormatter = resolveFieldFormatter({
24
+ surfaceFormat: spec.labels.format,
25
+ channelFormat:
26
+ spec.encoding?.y && 'format' in spec.encoding.y ? spec.encoding.y.format : undefined,
27
+ values: valueField ? spec.data.map((r) => r[valueField]) : [],
28
+ });
22
29
  const labels = computeColumnLabels(
23
30
  marks,
24
31
  chartArea,
25
32
  spec.labels.density,
26
- spec.labels.format,
33
+ labelFormatter,
27
34
  spec.labels.prefix,
28
35
  valueField,
29
36
  spec.labels.color,
37
+ theme.isDark,
30
38
  spec.labels.fontSize,
31
39
  spec.labels.suffix,
32
40
  );