@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
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Deterministic sweep-line dodge for beeswarm layouts.
3
+ *
4
+ * Port of Observable Plot's dodge transform (anchor "middle"): dots are
5
+ * placed in value order, each at the smallest signed cross-axis offset that
6
+ * avoids every previously placed circle. Pure array math with stable sorts
7
+ * and explicit tie-breaking, so the same input always produces the same
8
+ * output: no force simulation, no iteration tuning, no animation frames.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Default extra gap (px) enforced between circle edges. */
16
+ export const DEFAULT_DODGE_PADDING = 1;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public API
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Compute cross-axis offsets that resolve circle collisions along a value axis.
24
+ *
25
+ * Dots are processed in ascending value-axis order (ties broken by input
26
+ * index, so the layout is deterministic). For each dot, every already-placed
27
+ * dot that overlaps it horizontally contributes a blocked interval of
28
+ * cross-axis offsets; the dot takes the smallest-magnitude offset outside all
29
+ * blocked intervals. Candidates are 0 and the blocked-interval edges; when a
30
+ * positive and a negative candidate have equal magnitude, the negative side
31
+ * wins (up in a horizontal swarm, left in a vertical one).
32
+ *
33
+ * When `lanes` is provided, dots dodge only against dots in the same lane
34
+ * (one independent swarm per category).
35
+ *
36
+ * @param positions - Value-axis pixel positions, one per dot.
37
+ * @param radii - Per-dot radii in pixels, parallel to `positions`.
38
+ * @param lanes - Optional lane key per dot; dots in different lanes never collide.
39
+ * @param padding - Extra pixel gap between circle edges. Defaults to 1.
40
+ * @returns Signed cross-axis pixel offsets (centered on 0), parallel to `positions`.
41
+ */
42
+ export function dodgeOffsets(
43
+ positions: readonly number[],
44
+ radii: readonly number[],
45
+ lanes?: readonly string[],
46
+ padding: number = DEFAULT_DODGE_PADDING,
47
+ ): number[] {
48
+ const n = positions.length;
49
+ const offsets = new Array<number>(n).fill(0);
50
+
51
+ // Group indices by lane (single implicit lane when none provided)
52
+ const groups = new Map<string, number[]>();
53
+ for (let i = 0; i < n; i++) {
54
+ const lane = lanes?.[i] ?? '__default__';
55
+ const group = groups.get(lane);
56
+ if (group) {
57
+ group.push(i);
58
+ } else {
59
+ groups.set(lane, [i]);
60
+ }
61
+ }
62
+
63
+ for (const group of groups.values()) {
64
+ dodgeLane(group, positions, radii, padding, offsets);
65
+ }
66
+
67
+ return offsets;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Per-lane sweep
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Place one lane's dots, writing results into the shared offsets array.
76
+ *
77
+ * The backward scan's early `break` (line ~101) keeps this near-linear when
78
+ * value positions are spread out: once the horizontal gap to an earlier dot
79
+ * exceeds any possible collision distance, no earlier dot can collide either.
80
+ * The degenerate case is many dots sharing one identical value position — the
81
+ * gap stays 0, the break never fires, and the scan touches every placed dot,
82
+ * so a lane of N coincident points is O(N^2). The overplotting guard caps the
83
+ * swarm at ~2000 points, which bounds the worst case; real data spreads out
84
+ * enough that this stays linear in practice.
85
+ */
86
+ function dodgeLane(
87
+ indices: number[],
88
+ positions: readonly number[],
89
+ radii: readonly number[],
90
+ padding: number,
91
+ offsets: number[],
92
+ ): void {
93
+ // Sweep order: ascending value position, ties broken by input index so the
94
+ // sort (and therefore the layout) is fully deterministic.
95
+ const order = [...indices].sort((a, b) => positions[a] - positions[b] || a - b);
96
+
97
+ // Already-placed dots in sweep order. Because placement follows ascending
98
+ // positions, the backward scan below can stop as soon as the horizontal gap
99
+ // exceeds the largest possible collision distance.
100
+ const placed: number[] = [];
101
+ let maxPlacedRadius = 0;
102
+
103
+ for (const i of order) {
104
+ const p = positions[i];
105
+ const r = radii[i];
106
+
107
+ // Blocked cross-axis intervals from placed dots that overlap horizontally.
108
+ const intervals: Array<[number, number]> = [];
109
+ for (let k = placed.length - 1; k >= 0; k--) {
110
+ const j = placed[k];
111
+ const dx = p - positions[j];
112
+ if (dx > r + maxPlacedRadius + padding) break; // no earlier dot can reach
113
+ const sep = r + radii[j] + padding;
114
+ if (dx * dx >= sep * sep) continue;
115
+ const dy = Math.sqrt(sep * sep - dx * dx);
116
+ intervals.push([offsets[j] - dy, offsets[j] + dy]);
117
+ }
118
+
119
+ offsets[i] = minimalFreeOffset(intervals);
120
+ placed.push(i);
121
+ if (r > maxPlacedRadius) maxPlacedRadius = r;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Smallest-magnitude offset outside all blocked open intervals.
127
+ *
128
+ * The feasible set is the complement of a union of open intervals, so the
129
+ * minimal-|offset| point is either 0 or an interval edge. Candidates sort by
130
+ * (|value|, value): equal magnitudes resolve to the negative side.
131
+ */
132
+ function minimalFreeOffset(intervals: Array<[number, number]>): number {
133
+ if (intervals.length === 0) return 0;
134
+
135
+ const candidates: number[] = [0];
136
+ for (const [lo, hi] of intervals) {
137
+ candidates.push(lo, hi);
138
+ }
139
+ candidates.sort((a, b) => Math.abs(a) - Math.abs(b) || a - b);
140
+
141
+ for (const candidate of candidates) {
142
+ let blocked = false;
143
+ for (const [lo, hi] of intervals) {
144
+ if (candidate > lo && candidate < hi) {
145
+ blocked = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!blocked) return candidate;
150
+ }
151
+
152
+ // Unreachable: interval edges are never strictly inside their own interval,
153
+ // so at least one candidate is always free. Kept for type safety.
154
+ return 0;
155
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Beeswarm chart module.
3
+ *
4
+ * Exports the beeswarm renderer and computation functions. Beeswarms emit
5
+ * plain PointMark[] (rendered by the existing point mark path) with no
6
+ * per-dot value labels: a swarm carries hundreds of observations, so labels
7
+ * would only recreate the overplotting the layout exists to solve.
8
+ */
9
+
10
+ import type { Mark } from '@opendata-ai/openchart-core';
11
+ import type { ChartRenderer } from '../registry';
12
+ import { computeBeeswarmMarks } from './compute';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Beeswarm chart renderer
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Beeswarm chart renderer.
20
+ *
21
+ * Produces one dodged PointMark per observation.
22
+ */
23
+ export const beeswarmRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
24
+ return computeBeeswarmMarks(spec, scales, chartArea, strategy) as Mark[];
25
+ };
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public exports
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export { computeBeeswarmMarks } from './compute';
32
+ export { DEFAULT_DODGE_PADDING, dodgeOffsets } from './dodge';
@@ -12,15 +12,21 @@
12
12
  */
13
13
 
14
14
  import { barRenderer } from './bar';
15
+ import { beeswarmRenderer } from './beeswarm';
16
+ import { calendarRenderer } from './calendar';
15
17
  import { columnRenderer } from './column';
16
18
  import { dotRenderer } from './dot';
17
19
  import { areaRenderer, lineRenderer } from './line';
20
+ import { parliamentRenderer } from './parliament';
18
21
  import { donutRenderer, pieRenderer } from './pie';
22
+ import { rangeRenderer } from './range';
23
+ import { rectRenderer } from './rect';
19
24
  import { type ChartRenderer, registerChartRenderer } from './registry';
20
25
  import { ruleRenderer } from './rule';
21
26
  import { scatterRenderer } from './scatter';
22
27
  import { textRenderer } from './text';
23
28
  import { tickRenderer } from './tick';
29
+ import { waffleRenderer } from './waffle';
24
30
 
25
31
  // Mark type mapping from old chart types:
26
32
  // - 'bar' -> barRenderer (horizontal bars, old 'bar')
@@ -44,10 +50,15 @@ const builtinRenderers: Record<string, ChartRenderer> = {
44
50
  'arc:donut': donutRenderer, // old 'donut'
45
51
  circle: dotRenderer, // old 'dot'
46
52
  lollipop: dotRenderer, // semantic alias for dot/circle
53
+ beeswarm: beeswarmRenderer, // dodged distribution dots
47
54
  text: textRenderer,
48
55
  rule: ruleRenderer,
49
56
  tick: tickRenderer,
50
- rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
57
+ rect: rectRenderer, // heatmap cells (band x band); see charts/rect
58
+ range: rangeRenderer, // dumbbell / arrow / range bar (orientation resolved internally)
59
+ waffle: waffleRenderer, // unit grid for part-to-whole counts
60
+ calendar: calendarRenderer, // weeks x weekdays daily heatmap (no positional scales)
61
+ parliament: parliamentRenderer, // hemicycle seat dots (no positional scales)
51
62
  };
52
63
 
53
64
  /**
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Calendar heatmap tests.
3
+ *
4
+ * Integration-first through compileChart: assert the compiled layout
5
+ * (marks, labels, legend, tooltips, axes) rather than internal helpers.
6
+ * Date math is all UTC, so expectations hold on any host timezone.
7
+ */
8
+
9
+ import type { RectMark, TextMarkLayout } from '@opendata-ai/openchart-core';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { compileChart } from '../../../compile';
12
+ import { validateSpec } from '../../../compiler/index';
13
+ import { weekdayRow } from '../compute';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const DAY_MS = 86400000;
20
+
21
+ /** One row per day starting at `start` (ISO date), deterministic values. */
22
+ function dailyRows(start: string, count: number): Array<{ date: string; value: number }> {
23
+ const rows: Array<{ date: string; value: number }> = [];
24
+ const t0 = Date.parse(start);
25
+ for (let i = 0; i < count; i++) {
26
+ rows.push({
27
+ date: new Date(t0 + i * DAY_MS).toISOString().slice(0, 10),
28
+ value: (i % 30) - 10,
29
+ });
30
+ }
31
+ return rows;
32
+ }
33
+
34
+ function calendarSpec(data: Array<Record<string, unknown>>, mark: unknown = 'calendar') {
35
+ return {
36
+ mark,
37
+ data,
38
+ encoding: {
39
+ x: { field: 'date', type: 'temporal' },
40
+ color: { field: 'value', type: 'quantitative' },
41
+ },
42
+ };
43
+ }
44
+
45
+ function compile(spec: unknown, options: Partial<Parameters<typeof compileChart>[1]> = {}) {
46
+ return compileChart(spec, { width: 700, height: 360, ...options });
47
+ }
48
+
49
+ function rectsOf(layout: ReturnType<typeof compileChart>): RectMark[] {
50
+ return layout.marks.filter((m): m is RectMark => m.type === 'rect');
51
+ }
52
+
53
+ function textsOf(layout: ReturnType<typeof compileChart>): TextMarkLayout[] {
54
+ return layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
55
+ }
56
+
57
+ function dataCells(layout: ReturnType<typeof compileChart>): RectMark[] {
58
+ return rectsOf(layout).filter((r) => !r.aria.decorative);
59
+ }
60
+
61
+ function emptyCells(layout: ReturnType<typeof compileChart>): RectMark[] {
62
+ return rectsOf(layout).filter((r) => r.aria.decorative);
63
+ }
64
+
65
+ /** Distinct sorted x positions (week columns) of the day cells. */
66
+ function columnsOf(rects: RectMark[]): number[] {
67
+ return Array.from(new Set(rects.map((r) => r.x))).sort((a, b) => a - b);
68
+ }
69
+
70
+ /** Distinct sorted y positions (weekday rows) of the day cells. */
71
+ function rowsOf(rects: RectMark[]): number[] {
72
+ return Array.from(new Set(rects.map((r) => r.y))).sort((a, b) => a - b);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Grid shape
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('calendar grid shape', () => {
80
+ // 2023: Jan 1 falls on a Sunday, so under the default monday weekStart the
81
+ // first column starts on Dec 26, 2022 (a non-January-1st column start).
82
+ const layout = compile(calendarSpec(dailyRows('2023-01-01', 365)));
83
+ const rects = rectsOf(layout);
84
+
85
+ it('renders 53 week columns x 7 weekday rows for a 365-day year', () => {
86
+ expect(rects).toHaveLength(365);
87
+ expect(columnsOf(rects)).toHaveLength(53);
88
+ expect(rowsOf(rects)).toHaveLength(7);
89
+ });
90
+
91
+ it('places month labels at the column containing the 1st of each month', () => {
92
+ const texts = textsOf(layout);
93
+ const cols = columnsOf(rects);
94
+ const step = cols[1] - cols[0];
95
+ const gridLeft = cols[0];
96
+
97
+ // Feb 1, 2023 is a Wednesday in the 6th week column (index 5): five
98
+ // Mondays (Jan 2/9/16/23/30) sit between the Dec 26 grid start and Feb 1.
99
+ const feb = texts.find((t) => t.text === 'Feb');
100
+ expect(feb).toBeDefined();
101
+ expect(feb!.x).toBeCloseTo(gridLeft + 5 * step, 6);
102
+
103
+ // Jan 1 is in the first column even though the column starts in 2022.
104
+ const jan = texts.find((t) => t.text === 'Jan');
105
+ expect(jan!.x).toBeCloseTo(gridLeft, 6);
106
+
107
+ // All 12 months present at this width.
108
+ const monthTexts = texts.filter((t) =>
109
+ ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].includes(
110
+ t.text,
111
+ ),
112
+ );
113
+ expect(monthTexts).toHaveLength(12);
114
+ });
115
+
116
+ it('renders Mon/Wed/Fri weekday labels left of the grid', () => {
117
+ const texts = textsOf(layout);
118
+ const gridLeft = columnsOf(rects)[0];
119
+ for (const label of ['Mon', 'Wed', 'Fri']) {
120
+ const mark = texts.find((t) => t.text === label);
121
+ expect(mark).toBeDefined();
122
+ expect(mark!.textAnchor).toBe('end');
123
+ expect(mark!.x).toBeLessThan(gridLeft);
124
+ }
125
+ });
126
+
127
+ it('suppresses axes and gridlines', () => {
128
+ expect(layout.axes.x).toBeUndefined();
129
+ expect(layout.axes.y).toBeUndefined();
130
+ });
131
+
132
+ it('keys every cell by its ISO date', () => {
133
+ expect(rects.find((r) => r.key === '2023-06-15')).toBeDefined();
134
+ const keys = new Set(rects.map((r) => r.key));
135
+ expect(keys.size).toBe(rects.length);
136
+ });
137
+
138
+ // Calendar labels hand-compute their y against the SVG default (alphabetic)
139
+ // baseline — see the `+ labelFont * 0.35` centering in compute.ts. Setting a
140
+ // dominantBaseline here would double-correct them and shift every label.
141
+ it('leaves dominantBaseline unset so its hand-computed baselines still hold', () => {
142
+ const texts = textsOf(layout);
143
+ expect(texts.length).toBeGreaterThan(0);
144
+ for (const text of texts) {
145
+ expect(text.dominantBaseline).toBeUndefined();
146
+ }
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Leap years and missing days
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('calendar leap years and missing days', () => {
155
+ // 60 days from Feb 1, 2024 cover Feb 29 in a leap year; the rest of the
156
+ // year renders as empty cells.
157
+ const spec = calendarSpec(dailyRows('2024-02-01', 60));
158
+ const layout = compile(spec);
159
+
160
+ it('renders Feb 29 in a leap year as a data cell', () => {
161
+ expect(rectsOf(layout)).toHaveLength(366);
162
+ const feb29 = rectsOf(layout).find((r) => r.key === '2024-02-29');
163
+ expect(feb29).toBeDefined();
164
+ expect(feb29!.aria.decorative).toBeUndefined();
165
+ expect(feb29!.aria.label).toContain('Feb 29, 2024');
166
+ });
167
+
168
+ it('renders missing days as surface-tinted empty cells, distinct from the scale minimum', () => {
169
+ const empty = emptyCells(layout);
170
+ const data = dataCells(layout);
171
+ expect(empty).toHaveLength(306);
172
+ expect(data).toHaveLength(60);
173
+
174
+ // Empty cells carry the theme's achromatic surface tint...
175
+ for (const cell of empty.slice(0, 5)) {
176
+ expect(cell.fill).toBe(layout.theme.colors.annotationFill);
177
+ }
178
+
179
+ // ...which differs from the chromatic fill of the lowest data value.
180
+ const minValue = Math.min(...data.map((r) => Number(r.data.value)));
181
+ const minCell = data.find((r) => Number(r.data.value) === minValue)!;
182
+ expect(minCell.fill).not.toBe(empty[0].fill);
183
+ });
184
+
185
+ it('keeps the empty/minimum distinction in dark mode', () => {
186
+ const dark = compile(spec, { darkMode: true });
187
+ const empty = emptyCells(dark);
188
+ const data = dataCells(dark);
189
+ const minValue = Math.min(...data.map((r) => Number(r.data.value)));
190
+ const minCell = data.find((r) => Number(r.data.value) === minValue)!;
191
+
192
+ expect(empty[0].fill).toBe(dark.theme.colors.annotationFill);
193
+ expect(empty[0].fill).not.toBe(minCell.fill);
194
+ // Dark mode swaps in its own tint (not the light-mode one).
195
+ expect(empty[0].fill).not.toBe(layout.theme.colors.annotationFill);
196
+ });
197
+
198
+ it('gives data cells a date-titled tooltip and empty cells none', () => {
199
+ expect(layout.tooltipDescriptors.size).toBe(60);
200
+ const firstDataIndex = layout.marks.findIndex((m) => m.type === 'rect' && !m.aria.decorative);
201
+ const descriptor = layout.tooltipDescriptors.get(`rect-${firstDataIndex}`);
202
+ expect(descriptor).toBeDefined();
203
+ expect(descriptor!.title).toContain('2024');
204
+ expect(descriptor!.fields[0].label).toBe('value');
205
+ });
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Multi-year stacking
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe('calendar multi-year stacking', () => {
213
+ const layout = compile(calendarSpec(dailyRows('2023-01-01', 730)), { height: 500 });
214
+ const rects = rectsOf(layout);
215
+
216
+ it('renders one labeled band per year', () => {
217
+ // 2023 (365) + 2024 leap (366)
218
+ expect(rects).toHaveLength(731);
219
+ const yearLabels = textsOf(layout)
220
+ .filter((t) => /^\d{4}$/.test(t.text))
221
+ .map((t) => t.text);
222
+ expect(yearLabels).toEqual(['2023', '2024']);
223
+
224
+ // Two vertically separated bands: 14 distinct row positions, and the
225
+ // top of the 2024 band sits below the bottom of the 2023 band.
226
+ const rows = rowsOf(rects);
227
+ expect(rows).toHaveLength(14);
228
+ const band2023Bottom = Math.max(
229
+ ...rects.filter((r) => r.key!.startsWith('2023')).map((r) => r.y),
230
+ );
231
+ const band2024Top = Math.min(...rects.filter((r) => r.key!.startsWith('2024')).map((r) => r.y));
232
+ expect(band2024Top).toBeGreaterThan(band2023Bottom);
233
+ });
234
+
235
+ it('shares one color scale and one legend across years', () => {
236
+ // dailyRows cycles values every 30 days, so day 9 of each January
237
+ // differs while equal values across years must share a fill.
238
+ const sameValuePairs = rects.filter((r) => !r.aria.decorative && Number(r.data.value) === 5);
239
+ const fills = new Set(sameValuePairs.map((r) => String(r.fill)));
240
+ expect(sameValuePairs.length).toBeGreaterThan(2);
241
+ expect(fills.size).toBe(1);
242
+
243
+ // Continuous legend, on by default, exactly one.
244
+ expect(layout.legend.type).toBe('continuous');
245
+ expect(layout.legend.bounds.height).toBeGreaterThan(0);
246
+ });
247
+
248
+ it('hides the year label for single-year data', () => {
249
+ const single = compile(calendarSpec(dailyRows('2023-01-01', 365)));
250
+ const yearLabels = textsOf(single).filter((t) => /^\d{4}$/.test(t.text));
251
+ expect(yearLabels).toHaveLength(0);
252
+ });
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // weekStart
257
+ // ---------------------------------------------------------------------------
258
+
259
+ describe('calendar weekStart', () => {
260
+ it('computes weekday rows for known dates', () => {
261
+ const wed = new Date(Date.UTC(2024, 1, 29)); // Feb 29, 2024: Thursday
262
+ const sun = new Date(Date.UTC(2024, 1, 4)); // Feb 4, 2024: Sunday
263
+ const mon = new Date(Date.UTC(2024, 0, 1)); // Jan 1, 2024: Monday
264
+
265
+ expect(weekdayRow(mon, 'monday')).toBe(0);
266
+ expect(weekdayRow(mon, 'sunday')).toBe(1);
267
+ expect(weekdayRow(wed, 'monday')).toBe(3);
268
+ expect(weekdayRow(wed, 'sunday')).toBe(4);
269
+ expect(weekdayRow(sun, 'monday')).toBe(6);
270
+ expect(weekdayRow(sun, 'sunday')).toBe(0);
271
+ });
272
+
273
+ it('holds the shift property: sunday rows = (monday rows + 1) mod 7', () => {
274
+ // Property over every day of a month that spans a leap boundary.
275
+ for (let d = 1; d <= 29; d++) {
276
+ const day = new Date(Date.UTC(2024, 1, d));
277
+ expect(weekdayRow(day, 'sunday')).toBe((weekdayRow(day, 'monday') + 1) % 7);
278
+ }
279
+ });
280
+
281
+ it('shifts compiled rows when weekStart is sunday', () => {
282
+ const data = dailyRows('2024-02-01', 60);
283
+ const monday = compile(calendarSpec(data));
284
+ const sunday = compile(calendarSpec(data, { type: 'calendar', weekStart: 'sunday' }));
285
+
286
+ const rowIndex = (layout: ReturnType<typeof compileChart>, key: string) => {
287
+ const rects = rectsOf(layout);
288
+ return rowsOf(rects).indexOf(rects.find((r) => r.key === key)!.y);
289
+ };
290
+
291
+ // Feb 29, 2024 (Thursday): row 3 under monday start, row 4 under sunday.
292
+ expect(rowIndex(monday, '2024-02-29')).toBe(3);
293
+ expect(rowIndex(sunday, '2024-02-29')).toBe(4);
294
+ // Feb 4, 2024 (Sunday): last row under monday start, first under sunday.
295
+ expect(rowIndex(monday, '2024-02-04')).toBe(6);
296
+ expect(rowIndex(sunday, '2024-02-04')).toBe(0);
297
+ });
298
+ });
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Mark options and responsive floor
302
+ // ---------------------------------------------------------------------------
303
+
304
+ describe('calendar mark options and responsive floor', () => {
305
+ it('applies cellRadius to day cells (default 1)', () => {
306
+ const data = dailyRows('2024-02-01', 30);
307
+ const defaulted = compile(calendarSpec(data));
308
+ expect(rectsOf(defaulted)[0].cornerRadius).toBe(1);
309
+
310
+ const rounded = compile(calendarSpec(data, { type: 'calendar', cellRadius: 3 }));
311
+ expect(rectsOf(rounded)[0].cornerRadius).toBe(3);
312
+ });
313
+
314
+ it('floors cell size at 7px on narrow containers instead of shrinking further', () => {
315
+ const layout = compile(calendarSpec(dailyRows('2023-01-01', 365)), {
316
+ width: 320,
317
+ height: 400,
318
+ });
319
+ for (const rect of rectsOf(layout)) {
320
+ expect(rect.width).toBeGreaterThanOrEqual(7);
321
+ expect(rect.height).toBeGreaterThanOrEqual(7);
322
+ }
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Validation
328
+ // ---------------------------------------------------------------------------
329
+
330
+ describe('calendar validation', () => {
331
+ const rows = dailyRows('2024-01-01', 10);
332
+
333
+ it('rejects a declared non-temporal x with the allowed types', () => {
334
+ const spec = {
335
+ mark: 'calendar',
336
+ data: [{ cat: 'a', value: 1 }],
337
+ encoding: {
338
+ x: { field: 'cat', type: 'nominal' },
339
+ color: { field: 'value', type: 'quantitative' },
340
+ },
341
+ };
342
+ expect(() => compile(spec)).toThrow(/does not accept type "nominal".*temporal/s);
343
+ });
344
+
345
+ it('rejects an inferred non-temporal x with a fix suggestion', () => {
346
+ const spec = {
347
+ mark: 'calendar',
348
+ data: [{ cat: 'apple', value: 1 }],
349
+ encoding: { x: { field: 'cat' }, color: { field: 'value', type: 'quantitative' } },
350
+ };
351
+ expect(() => compile(spec)).toThrow(/requires temporal encoding\.x.*inferred as nominal/s);
352
+ });
353
+
354
+ it('rejects unparseable dates naming the offending row', () => {
355
+ const spec = calendarSpec([...rows, { date: 'not-a-date', value: 3 }]);
356
+ expect(() => compile(spec)).toThrow(/unparseable date at data\[10\].*"not-a-date"/s);
357
+ });
358
+
359
+ it('rejects sub-daily granularity with a timeUnit aggregation hint', () => {
360
+ const spec = calendarSpec([
361
+ { date: '2024-01-01T02:00:00Z', value: 1 },
362
+ { date: '2024-01-01T14:00:00Z', value: 2 },
363
+ ]);
364
+ expect(() => compile(spec)).toThrow(/daily granularity.*same day/s);
365
+ // The fix lives in the structured suggestion (compile joins messages only).
366
+ const result = validateSpec(spec);
367
+ const granularityError = result.errors.find((e) => /daily granularity/.test(e.message));
368
+ expect(granularityError?.suggestion).toMatch(/timeUnit/);
369
+ });
370
+
371
+ it('rejects encoding.y', () => {
372
+ const spec = {
373
+ mark: 'calendar',
374
+ data: rows,
375
+ encoding: {
376
+ x: { field: 'date', type: 'temporal' },
377
+ y: { field: 'value', type: 'quantitative' },
378
+ color: { field: 'value', type: 'quantitative' },
379
+ },
380
+ };
381
+ expect(() => compile(spec)).toThrow(/does not accept encoding\.y/);
382
+ });
383
+
384
+ it('rejects an invalid weekStart listing the valid options', () => {
385
+ const spec = calendarSpec(rows, { type: 'calendar', weekStart: 'tuesday' });
386
+ expect(() => compile(spec)).toThrow(/weekStart "tuesday"/);
387
+ const result = validateSpec(spec);
388
+ const weekStartError = result.errors.find((e) => /weekStart/.test(e.message));
389
+ expect(weekStartError?.suggestion).toMatch(/monday.*sunday/s);
390
+ });
391
+
392
+ it('requires the color channel', () => {
393
+ const spec = {
394
+ mark: 'calendar',
395
+ data: rows,
396
+ encoding: { x: { field: 'date', type: 'temporal' } },
397
+ };
398
+ expect(() => compile(spec)).toThrow(/requires encoding\.color/);
399
+ });
400
+
401
+ it('skips per-row date checks when transforms will reshape the data', () => {
402
+ const spec = {
403
+ ...calendarSpec([
404
+ { date: '2024-01-01T02:00:00Z', value: 1 },
405
+ { date: '2024-01-01T14:00:00Z', value: 2 },
406
+ ]),
407
+ transform: [{ timeUnit: 'yearmonthdate' as const, field: 'date', as: 'day' }],
408
+ encoding: {
409
+ x: { field: 'day', type: 'temporal' as const },
410
+ color: { field: 'value', type: 'quantitative' as const },
411
+ },
412
+ };
413
+ // Sub-daily rows plus an aggregating transform must not trip the
414
+ // granularity check at validation time.
415
+ expect(() => compile(spec)).not.toThrow(/daily granularity/);
416
+ });
417
+ });