@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10
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.
- package/dist/index.d.ts +124 -14
- package/dist/index.js +16254 -9417
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
- package/src/__tests__/annotation-behavior.test.ts +852 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/facet.test.ts +144 -0
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +228 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +318 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +121 -21
- package/src/compile/size-scale.ts +141 -0
- package/src/compile/spec-sugar.ts +767 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +415 -110
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
- package/src/compiler/__tests__/validate.test.ts +258 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +151 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +961 -32
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +1128 -0
- package/src/geo/compile-map.ts +1376 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +65 -0
- package/src/geo/types.ts +26 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +123 -16
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- 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';
|
package/src/charts/builtin.ts
CHANGED
|
@@ -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:
|
|
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
|
+
});
|