@opendata-ai/openchart-core 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 (58) hide show
  1. package/dist/index.d.ts +1154 -119
  2. package/dist/index.js +530 -41
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +2 -1
  5. package/dist/styles.css.map +1 -0
  6. package/package.json +9 -3
  7. package/schema/chart.schema.json +8957 -0
  8. package/schema/table.schema.json +861 -0
  9. package/schema/vizspec.schema.json +17694 -0
  10. package/src/accessibility/alt-text.ts +39 -1
  11. package/src/accessibility/aria.ts +4 -2
  12. package/src/colors/__tests__/colorblind.test.ts +12 -0
  13. package/src/colors/colorblind.ts +11 -2
  14. package/src/colors/contrast.ts +11 -0
  15. package/src/colors/index.ts +9 -1
  16. package/src/colors/palettes.ts +47 -0
  17. package/src/helpers/__tests__/deep-merge.test.ts +54 -0
  18. package/src/helpers/__tests__/spec-builders.test.ts +3 -3
  19. package/src/helpers/deep-merge.ts +29 -0
  20. package/src/helpers/spec-builders.ts +7 -7
  21. package/src/index.ts +29 -1
  22. package/src/labels/collision.ts +1 -0
  23. package/src/layout/chrome.ts +24 -0
  24. package/src/layout/index.ts +1 -1
  25. package/src/locale/__tests__/format.test.ts +347 -11
  26. package/src/locale/format.ts +243 -18
  27. package/src/locale/index.ts +10 -1
  28. package/src/responsive/index.ts +2 -0
  29. package/src/responsive/metrics.ts +74 -9
  30. package/src/schema/__tests__/generated-artifacts.test.ts +42 -0
  31. package/src/schema/__tests__/schema.test.ts +179 -0
  32. package/src/styles/__tests__/reduced-motion-selectors.test.ts +28 -0
  33. package/src/styles/animation.css +197 -168
  34. package/src/styles/base.css +89 -62
  35. package/src/styles/chrome.css +173 -179
  36. package/src/styles/dark.css +38 -35
  37. package/src/styles/graph.css +116 -111
  38. package/src/styles/index.css +5 -0
  39. package/src/styles/keyframes.css +115 -100
  40. package/src/styles/legend.css +14 -12
  41. package/src/styles/reduced-motion.css +61 -40
  42. package/src/styles/series-search.css +153 -0
  43. package/src/styles/sparkline.css +14 -12
  44. package/src/styles/table-animation.css +82 -80
  45. package/src/styles/table.css +314 -302
  46. package/src/styles/token-definitions.ts +199 -0
  47. package/src/styles/tokens.css +132 -128
  48. package/src/styles/tooltip.css +109 -115
  49. package/src/styles/you-draw-it.css +126 -0
  50. package/src/types/__tests__/encoding.test.ts +86 -17
  51. package/src/types/__tests__/spec.test.ts +16 -12
  52. package/src/types/encoding.ts +69 -25
  53. package/src/types/events.ts +12 -0
  54. package/src/types/index.ts +39 -3
  55. package/src/types/layout.ts +414 -8
  56. package/src/types/schema-roots.ts +32 -0
  57. package/src/types/spec.ts +643 -84
  58. package/src/types/table.ts +3 -2
@@ -23,10 +23,12 @@ import { timeFormat, utcFormat } from 'd3-time-format';
23
23
  */
24
24
  export function formatNumber(value: number, _locale?: string): string {
25
25
  if (!Number.isFinite(value)) return String(value);
26
- // Auto-detect: use integer format for whole numbers, 2dp for decimals
27
26
  if (Number.isInteger(value)) {
28
27
  return d3Format(',')(value);
29
28
  }
29
+ if (Math.abs(value) < 0.005) {
30
+ return d3Format('.2~r')(value);
31
+ }
30
32
  return d3Format(',.2f')(value);
31
33
  }
32
34
 
@@ -34,41 +36,75 @@ export function formatNumber(value: number, _locale?: string): string {
34
36
  // Number abbreviation
35
37
  // ---------------------------------------------------------------------------
36
38
 
37
- /** Suffixes for abbreviated numbers. */
38
- const ABBREVIATIONS: Array<{ threshold: number; suffix: string; divisor: number }> = [
39
+ const UNIT_TABLE: Array<{ threshold: number; suffix: string; divisor: number }> = [
39
40
  { threshold: 1_000_000_000_000, suffix: 'T', divisor: 1_000_000_000_000 },
40
41
  { threshold: 1_000_000_000, suffix: 'B', divisor: 1_000_000_000 },
41
42
  { threshold: 1_000_000, suffix: 'M', divisor: 1_000_000 },
42
- { threshold: 1_000, suffix: 'K', divisor: 1_000 },
43
+ { threshold: 1_000, suffix: 'k', divisor: 1_000 },
43
44
  ];
44
45
 
45
- /**
46
- * Abbreviate a large number with a suffix.
47
- *
48
- * Examples:
49
- * - 1500000 -> "1.5M"
50
- * - 2300 -> "2.3K"
51
- * - 42 -> "42"
52
- * - 1000000000 -> "1B"
53
- */
46
+ function unitFor(abs: number): { suffix: string; divisor: number } {
47
+ for (const entry of UNIT_TABLE) {
48
+ if (abs >= entry.threshold) return entry;
49
+ }
50
+ return { suffix: '', divisor: 1 };
51
+ }
52
+
53
+ function fractionDigits(x: number): number {
54
+ const a = Math.abs(x);
55
+ for (let p = 0; p <= 3; p++) {
56
+ const scaled = a * 10 ** p;
57
+ if (Math.abs(scaled - Math.round(scaled)) < 1e-9) return p;
58
+ }
59
+ return 4;
60
+ }
61
+
54
62
  export function abbreviateNumber(value: number): string {
55
63
  if (!Number.isFinite(value)) return String(value);
56
64
 
57
65
  const absValue = Math.abs(value);
58
66
  const sign = value < 0 ? '-' : '';
59
67
 
60
- for (const { threshold, suffix, divisor } of ABBREVIATIONS) {
68
+ for (let i = 0; i < UNIT_TABLE.length; i++) {
69
+ const { threshold, suffix, divisor } = UNIT_TABLE[i];
61
70
  if (absValue >= threshold) {
62
- const abbreviated = absValue / divisor;
63
- // Use .1f precision, drop trailing .0
64
- const formatted = abbreviated % 1 === 0 ? String(abbreviated) : d3Format('.1f')(abbreviated);
65
- return `${sign}${formatted}${suffix}`;
71
+ const mantissa = d3Format('.1~f')(absValue / divisor);
72
+ // Roll-up: if mantissa rounds to 1000, use the next unit up.
73
+ // When i === 0 (T), there's no larger unit so we emit "1000T".
74
+ if (mantissa === '1000' && i > 0) {
75
+ return `${sign}1${UNIT_TABLE[i - 1].suffix}`;
76
+ }
77
+ return `${sign}${mantissa}${suffix}`;
66
78
  }
67
79
  }
68
80
 
69
81
  return formatNumber(value);
70
82
  }
71
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Ordinal formatting
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Format a number as an English ordinal: 1 -> "1st", 2 -> "2nd", 3 -> "3rd",
90
+ * 11 -> "11th", 22 -> "22nd". Values are rounded to the nearest integer first;
91
+ * intended for rank axes where the domain is integer positions.
92
+ */
93
+ export function formatOrdinal(value: number): string {
94
+ if (!Number.isFinite(value)) return String(value);
95
+ const n = Math.round(value);
96
+ const abs = Math.abs(n);
97
+ const rem100 = abs % 100;
98
+ const rem10 = abs % 10;
99
+ let suffix: string;
100
+ if (rem100 >= 11 && rem100 <= 13) suffix = 'th';
101
+ else if (rem10 === 1) suffix = 'st';
102
+ else if (rem10 === 2) suffix = 'nd';
103
+ else if (rem10 === 3) suffix = 'rd';
104
+ else suffix = 'th';
105
+ return `${n}${suffix}`;
106
+ }
107
+
72
108
  // ---------------------------------------------------------------------------
73
109
  // d3-format with suffix support
74
110
  // ---------------------------------------------------------------------------
@@ -89,11 +125,17 @@ const D3_FORMAT_SUFFIX_RE = /^(.*~?[efgsrdxXobcnp%])(.+)$/;
89
125
  * Build a number formatter from a d3-format string, with support for a
90
126
  * trailing literal suffix that d3-format itself would reject.
91
127
  *
128
+ * Also accepts the OpenChart extension `'ordinal'`, which renders English
129
+ * ordinals ("1st", "2nd", "3rd") for rank axes.
130
+ *
92
131
  * Returns null if the format string is falsy or unparseable.
93
132
  */
94
133
  export function buildD3Formatter(formatStr: string | undefined): ((v: number) => string) | null {
95
134
  if (!formatStr) return null;
96
135
 
136
+ // OpenChart extension: rank-axis ordinals ("1st", "2nd", "3rd").
137
+ if (formatStr === 'ordinal') return formatOrdinal;
138
+
97
139
  try {
98
140
  const fmt = d3Format(formatStr);
99
141
  // Replace SI prefix "G" (giga) with "B" (billion) for financial readability
@@ -117,6 +159,189 @@ export function buildD3Formatter(formatStr: string | undefined): ((v: number) =>
117
159
  return null;
118
160
  }
119
161
 
162
+ // ---------------------------------------------------------------------------
163
+ // Number formatter type + semantic formatters
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export type NumberFormatter = (value: number) => string;
167
+
168
+ export function formatPercent(value: number, options?: { fraction?: boolean }): string {
169
+ const fraction = options?.fraction ?? true;
170
+ if (fraction) {
171
+ return d3Format('.1~%')(value);
172
+ }
173
+ return `${d3Format(',.1~f')(value)}%`;
174
+ }
175
+
176
+ export function formatCurrency(value: number, options?: { compact?: boolean }): string {
177
+ const compact = options?.compact ?? false;
178
+ const abs = Math.abs(value);
179
+ const sign = value < 0 ? '-' : '';
180
+
181
+ if (compact) {
182
+ if (abs >= 1000) {
183
+ return `${sign}$${abbreviateNumber(abs)}`;
184
+ }
185
+ return `${sign}$${formatNumber(abs)}`;
186
+ }
187
+ if (Number.isInteger(value)) {
188
+ return sign + d3Format('$,')(abs);
189
+ }
190
+ return sign + d3Format('$,.2f')(abs);
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Years guard
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export function isYearLikeValues(values: readonly number[]): boolean {
198
+ if (values.length === 0) return false;
199
+ for (const v of values) {
200
+ if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1500 || v > 2500) return false;
201
+ }
202
+ return true;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Field format context
207
+ // ---------------------------------------------------------------------------
208
+
209
+ export interface FieldFormatContext {
210
+ extent?: [number, number];
211
+ allIntegers?: boolean;
212
+ surface?: 'chart' | 'table';
213
+ step?: number;
214
+ stepReference?: number;
215
+ }
216
+
217
+ export function computeFieldFormatContext(
218
+ values: Iterable<unknown>,
219
+ surface?: 'chart' | 'table',
220
+ ): FieldFormatContext {
221
+ let min = Infinity;
222
+ let max = -Infinity;
223
+ let allIntegers = true;
224
+ let hasFinite = false;
225
+
226
+ for (const raw of values) {
227
+ const v = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
228
+ if (!Number.isFinite(v)) continue;
229
+ hasFinite = true;
230
+ if (v < min) min = v;
231
+ if (v > max) max = v;
232
+ if (allIntegers && !Number.isInteger(v)) allIntegers = false;
233
+ }
234
+
235
+ const ctx: FieldFormatContext = {};
236
+ if (hasFinite) {
237
+ ctx.extent = [min, max];
238
+ ctx.allIntegers = allIntegers;
239
+ }
240
+ if (surface) ctx.surface = surface;
241
+ return ctx;
242
+ }
243
+
244
+ export function isYearContext(ctx: FieldFormatContext | undefined): boolean {
245
+ if (!ctx?.extent || !ctx.allIntegers) return false;
246
+ const [min, max] = ctx.extent;
247
+ return min >= 1500 && max <= 2500;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Keyword-aware formatter resolution
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export function resolveNumberFormatter(
255
+ formatStr: string | undefined,
256
+ ctx?: FieldFormatContext,
257
+ ): NumberFormatter | null {
258
+ if (!formatStr) return null;
259
+
260
+ if (formatStr === 'ordinal') return formatOrdinal;
261
+
262
+ if (formatStr === 'percent') {
263
+ // Heuristic: if all values fit in [0,1] (maxAbs <= 1), treat them as
264
+ // fractions (0.45 -> "45%"). Values > 1 are treated as pre-scaled
265
+ // (45 -> "45%"). Datasets like [0, 0.5, 1.1] are ambiguous; the <= 1
266
+ // threshold errs on the side of the most common convention (fractions).
267
+ const fraction = ctx?.extent
268
+ ? Math.max(Math.abs(ctx.extent[0]), Math.abs(ctx.extent[1])) <= 1
269
+ : true;
270
+ return (v: number) => formatPercent(v, { fraction });
271
+ }
272
+
273
+ if (formatStr === 'currency') {
274
+ const isTable = ctx?.surface === 'table';
275
+ if (!isTable && ctx?.step != null && (ctx.stepReference ?? 0) >= 1000) {
276
+ const stepFmt = buildCompactStepFormatter(ctx.step);
277
+ return (v: number) => {
278
+ const sign = v < 0 ? '-' : '';
279
+ return `${sign}$${stepFmt(Math.abs(v))}`;
280
+ };
281
+ }
282
+ return (v: number) => formatCurrency(v, { compact: !isTable });
283
+ }
284
+
285
+ return buildD3Formatter(formatStr);
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Default number formatter
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export function defaultNumberFormatter(ctx?: FieldFormatContext): NumberFormatter {
293
+ if (isYearContext(ctx)) {
294
+ return (v: number) => String(Math.round(v));
295
+ }
296
+
297
+ if (ctx?.surface === 'table') {
298
+ return formatNumber;
299
+ }
300
+
301
+ if (ctx?.step != null && (ctx.stepReference ?? maxAbs(ctx.extent)) >= 1000) {
302
+ return buildCompactStepFormatter(ctx.step);
303
+ }
304
+
305
+ return (v: number) => {
306
+ if (!Number.isFinite(v)) return String(v);
307
+ // When no context is provided (ARIA marks, standalone helpers) we
308
+ // fall back to a per-value year heuristic: integers in [1500, 2500]
309
+ // render bare. This trades false positives on non-year integers
310
+ // (e.g. "connections: 2024") for avoiding "2k" on actual year axes.
311
+ // Callers with field context should always pass it to get the
312
+ // field-level isYearContext check instead.
313
+ if (ctx?.extent === undefined && ctx?.allIntegers === undefined) {
314
+ if (Number.isInteger(v) && v >= 1500 && v <= 2500) return String(v);
315
+ }
316
+ return Math.abs(v) >= 1000 ? abbreviateNumber(v) : formatNumber(v);
317
+ };
318
+ }
319
+
320
+ function maxAbs(extent: [number, number] | undefined): number {
321
+ if (!extent) return 0;
322
+ return Math.max(Math.abs(extent[0]), Math.abs(extent[1]));
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Step-aware compact tick formatter
327
+ // ---------------------------------------------------------------------------
328
+
329
+ export function buildCompactStepFormatter(step: number): NumberFormatter {
330
+ return (v: number) => {
331
+ if (v === 0) return '0';
332
+ if (!Number.isFinite(v)) return String(v);
333
+ const abs = Math.abs(v);
334
+ if (abs < 1000) return formatNumber(v);
335
+ const { suffix, divisor } = unitFor(abs);
336
+ const p = fractionDigits(step / divisor);
337
+ if (p > 2) {
338
+ return Number.isInteger(v) ? d3Format(',')(v) : d3Format(',.2f')(v);
339
+ }
340
+ const sign = v < 0 ? '-' : '';
341
+ return sign + d3Format(`.${p}~f`)(abs / divisor) + suffix;
342
+ };
343
+ }
344
+
120
345
  // ---------------------------------------------------------------------------
121
346
  // Date formatting
122
347
  // ---------------------------------------------------------------------------
@@ -2,11 +2,20 @@
2
2
  * Locale module barrel export.
3
3
  */
4
4
 
5
- export type { DateGranularity } from './format';
5
+ export type { DateGranularity, FieldFormatContext, NumberFormatter } from './format';
6
6
  export {
7
7
  abbreviateNumber,
8
+ buildCompactStepFormatter,
8
9
  buildD3Formatter,
9
10
  buildTemporalFormatter,
11
+ computeFieldFormatContext,
12
+ defaultNumberFormatter,
13
+ formatCurrency,
10
14
  formatDate,
11
15
  formatNumber,
16
+ formatOrdinal,
17
+ formatPercent,
18
+ isYearContext,
19
+ isYearLikeValues,
20
+ resolveNumberFormatter,
12
21
  } from './format';
@@ -39,11 +39,13 @@ export {
39
39
  MAX_LEFT_LABEL_FRACTION_DEFAULT,
40
40
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
41
41
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
42
+ maxRotatedLabelWidth,
42
43
  NARROW_VIEWPORT_MAX,
43
44
  TICK_LABEL_OFFSET,
44
45
  TICK_LINE_HEIGHT_FACTOR,
45
46
  TOP_PAD_EXTRA_NARROW,
46
47
  TOP_PAD_NARROW_MAX,
48
+ truncateRotatedLabel,
47
49
  X_AXIS_BAND_HEIGHT,
48
50
  X_AXIS_ROTATED_EXTENT_CAP,
49
51
  X_AXIS_TITLE_BAND,
@@ -174,10 +174,13 @@ export const X_AXIS_TITLE_BAND = 22;
174
174
  export const X_AXIS_TITLE_BAND_ROTATED = 20;
175
175
 
176
176
  /**
177
- * Ceiling on the vertical extent reserved for rotated x tick labels. Labels
178
- * whose rotated projection exceeds this clip at the reservation edge, so the
179
- * angle ladder (engine layout/axes/rotation) avoids -90° for labels longer
180
- * than this.
177
+ * Ceiling on the vertical extent reserved for rotated x tick labels. The angle
178
+ * ladder (engine layout/axes/rotation) avoids -90° for labels longer than this.
179
+ *
180
+ * The cap bounds the RESERVATION, so a label whose rotated projection exceeds it
181
+ * must be truncated to fit — see `truncateRotatedLabel`. Reserving a capped band
182
+ * while drawing the full string is what let long ticks spill past the axis and
183
+ * collide with the source line.
181
184
  */
182
185
  export const X_AXIS_ROTATED_EXTENT_CAP = 120;
183
186
 
@@ -189,6 +192,15 @@ export const X_AXIS_ROTATED_EXTENT_CAP = 120;
189
192
  */
190
193
  export const TICK_LINE_HEIGHT_FACTOR = 1.2;
191
194
 
195
+ /**
196
+ * Gap between the axis line and the top of the rotated label band. Mirrors the
197
+ * tick-mark-to-label offset used for flat labels; keeps the reservation matched
198
+ * to the drawn footprint across Blink and WebKit, whose rotated-glyph metrics
199
+ * differ by a couple of pixels. Shared by the extent reservation and the
200
+ * truncation budget so they stay in lockstep.
201
+ */
202
+ const AXIS_TO_LABEL_GAP = 3;
203
+
192
204
  export interface XAxisExtentInput {
193
205
  labels: string[];
194
206
  tickAngle?: number;
@@ -216,11 +228,6 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
216
228
  // unless the text is fully vertical). Measuring only the sin term
217
229
  // under-reserved space, letting rotated ticks spill into the source line.
218
230
  const lineHeight = input.tickFontSize * TICK_LINE_HEIGHT_FACTOR;
219
- // Small gap between the axis line and the top of the rotated label band.
220
- // Mirrors the tick-mark-to-label offset used for flat labels; keeps the
221
- // reservation matched to the drawn footprint across Blink and WebKit,
222
- // whose rotated-glyph metrics differ by a couple of pixels.
223
- const AXIS_TO_LABEL_GAP = 3;
224
231
  const rotatedHeight = Math.min(
225
232
  maxLabelWidth * Math.sin(angleRad) + lineHeight * Math.cos(angleRad) + AXIS_TO_LABEL_GAP,
226
233
  X_AXIS_ROTATED_EXTENT_CAP,
@@ -230,3 +237,61 @@ export function computeXAxisExtentFromLabels(input: XAxisExtentInput): number {
230
237
 
231
238
  return input.hasTitle ? baseHeight + X_AXIS_TITLE_BAND : baseHeight;
232
239
  }
240
+
241
+ /**
242
+ * The widest a rotated tick label may be before its vertical projection exceeds
243
+ * `X_AXIS_ROTATED_EXTENT_CAP`.
244
+ *
245
+ * Inverts the rotated-extent formula in `computeXAxisExtentFromLabels`:
246
+ *
247
+ * extent = width*sin(θ) + lineHeight*cos(θ) + gap
248
+ * width = (cap - lineHeight*cos(θ) - gap) / sin(θ)
249
+ *
250
+ * Deriving it from the same terms (rather than hardcoding a second number) keeps
251
+ * the truncation budget and the space reservation locked together — if one
252
+ * changes, the other follows.
253
+ */
254
+ export function maxRotatedLabelWidth(tickAngle: number, tickFontSize: number): number {
255
+ const angleRad = Math.abs(tickAngle) * (Math.PI / 180);
256
+ const sin = Math.sin(angleRad);
257
+ // A flat (or near-flat) label has no vertical projection to bound.
258
+ if (sin <= 0.001) return Number.POSITIVE_INFINITY;
259
+ const lineHeight = tickFontSize * TICK_LINE_HEIGHT_FACTOR;
260
+ const usable = X_AXIS_ROTATED_EXTENT_CAP - lineHeight * Math.cos(angleRad) - AXIS_TO_LABEL_GAP;
261
+ return Math.max(0, usable / sin);
262
+ }
263
+
264
+ /**
265
+ * Truncate a rotated tick label with an ellipsis so it fits inside the capped
266
+ * reservation. Returns the label unchanged when it already fits.
267
+ *
268
+ * Binary-search on character count against the real measure function, so it
269
+ * respects proportional fonts rather than assuming a fixed character width.
270
+ */
271
+ export function truncateRotatedLabel(
272
+ label: string,
273
+ tickAngle: number,
274
+ tickFontSize: number,
275
+ tickFontWeight: number,
276
+ measure: (text: string, fontSize: number, fontWeight?: number) => number = estimateTextWidth,
277
+ ): string {
278
+ const budget = maxRotatedLabelWidth(tickAngle, tickFontSize);
279
+ if (!Number.isFinite(budget)) return label;
280
+ if (measure(label, tickFontSize, tickFontWeight) <= budget) return label;
281
+
282
+ const ELLIPSIS = '…';
283
+ // Nothing sensible fits: keep a single character + ellipsis rather than
284
+ // returning an empty tick.
285
+ let lo = 0;
286
+ let hi = label.length;
287
+ while (lo < hi) {
288
+ const mid = Math.ceil((lo + hi) / 2);
289
+ const candidate = label.slice(0, mid) + ELLIPSIS;
290
+ if (measure(candidate, tickFontSize, tickFontWeight) <= budget) {
291
+ lo = mid;
292
+ } else {
293
+ hi = mid - 1;
294
+ }
295
+ }
296
+ return lo > 0 ? label.slice(0, lo).trimEnd() + ELLIPSIS : ELLIPSIS;
297
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Smoke test that the generated LLM-facing artifacts cover every mark type.
3
+ *
4
+ * `scripts/generate-schema.mjs` and `scripts/generate-llms.mjs` emit the
5
+ * committed `packages/core/schema/*.json` and root `llms.txt`. This test
6
+ * asserts the two artifacts reference all 16 declared mark types, so adding a
7
+ * mark to MARK_TYPES without regenerating (or a generator regression that drops
8
+ * a mark) is caught here rather than shipping a schema/doc that silently omits
9
+ * it. CI's `check:generated` step guarantees the artifacts are fresh; this test
10
+ * guarantees they are complete.
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { describe, expect, it } from 'vitest';
17
+ import { MARK_TYPES } from '../../types/spec';
18
+
19
+ const coreRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
20
+ const repoRoot = join(coreRoot, '..', '..');
21
+
22
+ const chartSchema = readFileSync(join(coreRoot, 'schema', 'chart.schema.json'), 'utf8');
23
+ const llmsTxt = readFileSync(join(repoRoot, 'llms.txt'), 'utf8');
24
+
25
+ describe('generated artifacts cover every mark type', () => {
26
+ it('chart.schema.json references all 16 mark types', () => {
27
+ for (const mark of MARK_TYPES) {
28
+ expect(chartSchema, `chart.schema.json is missing mark "${mark}"`).toContain(`"${mark}"`);
29
+ }
30
+ });
31
+
32
+ it('llms.txt documents all 16 mark types in the grammar table', () => {
33
+ for (const mark of MARK_TYPES) {
34
+ // The generated mark table renders each mark as an inline-code cell.
35
+ expect(llmsTxt, `llms.txt is missing mark "${mark}"`).toContain(`\`${mark}\``);
36
+ }
37
+ });
38
+
39
+ it('MARK_TYPES has exactly 16 entries (the surface this plan targets)', () => {
40
+ expect(MARK_TYPES.size).toBe(16);
41
+ });
42
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Behavior tests for the published JSON Schema files.
3
+ *
4
+ * These validate the SHIPPED artifacts (`packages/core/schema/*.json`) against
5
+ * ajv, the same draft-07 validator an LLM tool-use harness would use. They
6
+ * prove three properties the plan requires:
7
+ *
8
+ * 1. Every one of the 16 mark types has a minimal valid spec the schema
9
+ * accepts (guards against a future mark being added to the types but not
10
+ * reflected in the generated schema).
11
+ * 2. Hallucinated fields (unknown top-level keys, unknown marks, unknown
12
+ * encoding channels) are rejected — the mechanic that kills empty-chart
13
+ * generation failures.
14
+ * 3. Representative realistic specs across chart and table validate.
15
+ *
16
+ * The per-mark specs are built from `MARK_ENCODING_RULES`, the same source the
17
+ * engine validates against, so the coverage tracks the real required-channel
18
+ * matrix rather than a hand-maintained copy.
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import Ajv, { type ValidateFunction } from 'ajv';
25
+ import { describe, expect, it } from 'vitest';
26
+ import { MARK_ENCODING_RULES } from '../../types/encoding';
27
+ import { MARK_TYPES } from '../../types/spec';
28
+
29
+ const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'schema');
30
+
31
+ function loadValidator(filename: string): ValidateFunction {
32
+ const schema = JSON.parse(readFileSync(join(schemaDir, filename), 'utf8'));
33
+ // strict:false — the generated schema uses draft-07 with union types that
34
+ // ajv's strict mode flags; allowUnionTypes matches the tool-use posture.
35
+ const ajv = new Ajv({ strict: false, allowUnionTypes: true });
36
+ return ajv.compile(schema);
37
+ }
38
+
39
+ const validateChart = loadValidator('chart.schema.json');
40
+ const validateViz = loadValidator('vizspec.schema.json');
41
+ const validateTable = loadValidator('table.schema.json');
42
+
43
+ /** A data row carrying one column of each field type. */
44
+ const sampleData = [
45
+ { cat: 'A', amount: 10, when: '2020-01-01' },
46
+ { cat: 'B', amount: 20, when: '2021-01-01' },
47
+ ];
48
+
49
+ /** Pick a field of an allowed type for a required channel. */
50
+ function fieldForTypes(allowedTypes: string[]): { field: string; type: string } {
51
+ if (allowedTypes.includes('temporal')) return { field: 'when', type: 'temporal' };
52
+ if (allowedTypes.includes('quantitative')) return { field: 'amount', type: 'quantitative' };
53
+ if (allowedTypes.includes('nominal')) return { field: 'cat', type: 'nominal' };
54
+ if (allowedTypes.includes('ordinal')) return { field: 'cat', type: 'ordinal' };
55
+ return { field: 'cat', type: 'nominal' };
56
+ }
57
+
58
+ /**
59
+ * Build a minimal spec the schema should accept for a given mark, from its
60
+ * required channels in MARK_ENCODING_RULES.
61
+ */
62
+ function minimalSpecForMark(mark: string): Record<string, unknown> {
63
+ const rules = MARK_ENCODING_RULES[mark as keyof typeof MARK_ENCODING_RULES] ?? {};
64
+ const encoding: Record<string, unknown> = {};
65
+ for (const [channel, rule] of Object.entries(rules)) {
66
+ if (rule?.required) {
67
+ const types = rule.allowedTypes.length ? rule.allowedTypes : ['quantitative'];
68
+ encoding[channel] = fieldForTypes(types);
69
+ }
70
+ }
71
+ // Ensure at least one positional channel so the spec is meaningful.
72
+ if (encoding.x === undefined && encoding.y === undefined) {
73
+ const yTypes = rules.y?.allowedTypes?.length ? rules.y.allowedTypes : ['quantitative'];
74
+ encoding.y = fieldForTypes(yTypes);
75
+ }
76
+ return { mark, data: sampleData, encoding };
77
+ }
78
+
79
+ describe('chart.schema.json', () => {
80
+ it('has a schema definition for exactly the 16 declared mark types', () => {
81
+ // Every MARK_TYPES entry must produce a spec the schema accepts.
82
+ for (const mark of MARK_TYPES) {
83
+ const spec = minimalSpecForMark(mark);
84
+ const valid = validateChart(spec);
85
+ expect(valid, `mark "${mark}" should validate: ${JSON.stringify(validateChart.errors)}`).toBe(
86
+ true,
87
+ );
88
+ }
89
+ });
90
+
91
+ it('rejects an unknown mark type', () => {
92
+ const spec = {
93
+ mark: 'notarealmark',
94
+ data: sampleData,
95
+ encoding: { y: fieldForTypes(['quantitative']) },
96
+ };
97
+ expect(validateChart(spec)).toBe(false);
98
+ });
99
+
100
+ it('rejects a hallucinated top-level field', () => {
101
+ const spec = {
102
+ mark: 'bar',
103
+ data: sampleData,
104
+ encoding: { y: { field: 'amount', type: 'quantitative' } },
105
+ totallyMadeUpField: 42,
106
+ };
107
+ expect(validateChart(spec)).toBe(false);
108
+ });
109
+
110
+ it('rejects a hallucinated encoding channel', () => {
111
+ const spec = {
112
+ mark: 'bar',
113
+ data: sampleData,
114
+ encoding: { notAChannel: { field: 'amount', type: 'quantitative' } },
115
+ };
116
+ expect(validateChart(spec)).toBe(false);
117
+ });
118
+
119
+ it('accepts a realistic bar chart with chrome and a color legend', () => {
120
+ const spec = {
121
+ mark: 'bar',
122
+ data: [
123
+ { language: 'Python', popularity: 29 },
124
+ { language: 'JavaScript', popularity: 24 },
125
+ ],
126
+ encoding: {
127
+ x: { field: 'popularity', type: 'quantitative' },
128
+ y: { field: 'language', type: 'nominal' },
129
+ color: { field: 'popularity', type: 'quantitative' },
130
+ },
131
+ chrome: { title: 'Python leads', source: 'Source: survey' },
132
+ };
133
+ expect(validateChart(spec), JSON.stringify(validateChart.errors)).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe('vizspec.schema.json', () => {
138
+ it('accepts a chart spec (mark-discriminated union member)', () => {
139
+ expect(validateViz(minimalSpecForMark('line'))).toBe(true);
140
+ });
141
+
142
+ it('accepts a table spec (type-discriminated union member)', () => {
143
+ const spec = {
144
+ type: 'table',
145
+ data: sampleData,
146
+ columns: [{ key: 'cat' }, { key: 'amount' }],
147
+ };
148
+ expect(validateViz(spec), JSON.stringify(validateViz.errors)).toBe(true);
149
+ });
150
+
151
+ it('rejects a spec that is neither a chart nor any known type', () => {
152
+ expect(validateViz({ nonsense: true })).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('table.schema.json', () => {
157
+ it('accepts a table with formatted columns', () => {
158
+ const spec = {
159
+ type: 'table',
160
+ data: sampleData,
161
+ columns: [
162
+ { key: 'cat', label: 'Category' },
163
+ { key: 'amount', format: '.0f', align: 'right' },
164
+ ],
165
+ search: true,
166
+ };
167
+ expect(validateTable(spec), JSON.stringify(validateTable.errors)).toBe(true);
168
+ });
169
+
170
+ it('rejects a hallucinated top-level field on a table', () => {
171
+ const spec = {
172
+ type: 'table',
173
+ data: sampleData,
174
+ columns: [{ key: 'cat' }],
175
+ madeUp: true,
176
+ };
177
+ expect(validateTable(spec)).toBe(false);
178
+ });
179
+ });