@opendata-ai/openchart-core 7.11.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
@@ -1,10 +1,19 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  abbreviateNumber,
4
+ buildCompactStepFormatter,
4
5
  buildD3Formatter,
5
6
  buildTemporalFormatter,
7
+ computeFieldFormatContext,
8
+ defaultNumberFormatter,
9
+ formatCurrency,
6
10
  formatDate,
7
11
  formatNumber,
12
+ formatOrdinal,
13
+ formatPercent,
14
+ isYearContext,
15
+ isYearLikeValues,
16
+ resolveNumberFormatter,
8
17
  } from '../format';
9
18
 
10
19
  describe('formatNumber', () => {
@@ -22,7 +31,7 @@ describe('formatNumber', () => {
22
31
 
23
32
  it('handles negative numbers', () => {
24
33
  // d3-format uses unicode minus sign (U+2212), not ASCII hyphen-minus
25
- expect(formatNumber(-1234)).toBe('\u22121,234');
34
+ expect(formatNumber(-1234)).toBe('−1,234');
26
35
  });
27
36
 
28
37
  it('handles Infinity', () => {
@@ -32,6 +41,16 @@ describe('formatNumber', () => {
32
41
  it('handles NaN', () => {
33
42
  expect(formatNumber(NaN)).toBe('NaN');
34
43
  });
44
+
45
+ it('preserves small decimals instead of rounding to 0.00', () => {
46
+ expect(formatNumber(0.0034)).toBe('0.0034');
47
+ expect(formatNumber(0.000012)).toBe('0.000012');
48
+ });
49
+
50
+ it('still uses ,.2f for normal-sized decimals', () => {
51
+ expect(formatNumber(0.5)).toBe('0.50');
52
+ expect(formatNumber(3.14567)).toBe('3.15');
53
+ });
35
54
  });
36
55
 
37
56
  describe('abbreviateNumber', () => {
@@ -43,13 +62,13 @@ describe('abbreviateNumber', () => {
43
62
  expect(abbreviateNumber(2300000000)).toBe('2.3B');
44
63
  });
45
64
 
46
- it('abbreviates thousands', () => {
47
- expect(abbreviateNumber(2300)).toBe('2.3K');
65
+ it('abbreviates thousands with lowercase k', () => {
66
+ expect(abbreviateNumber(2300)).toBe('2.3k');
48
67
  });
49
68
 
50
69
  it('drops trailing .0', () => {
51
70
  expect(abbreviateNumber(1000000)).toBe('1M');
52
- expect(abbreviateNumber(2000)).toBe('2K');
71
+ expect(abbreviateNumber(2000)).toBe('2k');
53
72
  });
54
73
 
55
74
  it('does not abbreviate small numbers', () => {
@@ -63,6 +82,286 @@ describe('abbreviateNumber', () => {
63
82
  it('abbreviates trillions', () => {
64
83
  expect(abbreviateNumber(1_200_000_000_000)).toBe('1.2T');
65
84
  });
85
+
86
+ it('trims 1020000 to 1M', () => {
87
+ expect(abbreviateNumber(1020000)).toBe('1M');
88
+ });
89
+
90
+ it('renders 18900 as 18.9k', () => {
91
+ expect(abbreviateNumber(18900)).toBe('18.9k');
92
+ });
93
+
94
+ it('renders 1000 as 1k', () => {
95
+ expect(abbreviateNumber(1000)).toBe('1k');
96
+ });
97
+
98
+ it('rolls up 999999 to 1M', () => {
99
+ expect(abbreviateNumber(999999)).toBe('1M');
100
+ });
101
+
102
+ it('rolls up 999999999 to 1B', () => {
103
+ expect(abbreviateNumber(999999999)).toBe('1B');
104
+ });
105
+
106
+ it('rolls up 999950 to 1M (rounding boundary)', () => {
107
+ expect(abbreviateNumber(999950)).toBe('1M');
108
+ });
109
+
110
+ it('keeps 999949 as 999.9k (below roll-up boundary)', () => {
111
+ expect(abbreviateNumber(999949)).toBe('999.9k');
112
+ });
113
+
114
+ it('handles -1500000', () => {
115
+ expect(abbreviateNumber(-1500000)).toBe('-1.5M');
116
+ });
117
+ });
118
+
119
+ describe('formatPercent', () => {
120
+ it('formats fractions (default) with .1~%', () => {
121
+ expect(formatPercent(0.847)).toBe('84.7%');
122
+ expect(formatPercent(0.5)).toBe('50%');
123
+ expect(formatPercent(1)).toBe('100%');
124
+ });
125
+
126
+ it('formats pre-scaled values with fraction: false', () => {
127
+ expect(formatPercent(84.7, { fraction: false })).toBe('84.7%');
128
+ expect(formatPercent(1200, { fraction: false })).toBe('1,200%');
129
+ });
130
+ });
131
+
132
+ describe('formatCurrency', () => {
133
+ it('compact mode abbreviates >= 1000', () => {
134
+ expect(formatCurrency(1900, { compact: true })).toBe('$1.9k');
135
+ expect(formatCurrency(12666, { compact: true })).toBe('$12.7k');
136
+ });
137
+
138
+ it('compact mode uses ASCII minus for negatives', () => {
139
+ expect(formatCurrency(-1900, { compact: true })).toBe('-$1.9k');
140
+ });
141
+
142
+ it('compact mode keeps small numbers full', () => {
143
+ expect(formatCurrency(42, { compact: true })).toBe('$42');
144
+ });
145
+
146
+ it('full mode (default) formats integers without cents', () => {
147
+ expect(formatCurrency(12666)).toBe('$12,666');
148
+ });
149
+
150
+ it('full mode uses ASCII minus for negatives', () => {
151
+ expect(formatCurrency(-12666)).toBe('-$12,666');
152
+ });
153
+
154
+ it('full mode includes cents for non-integers', () => {
155
+ expect(formatCurrency(12666.5)).toBe('$12,666.50');
156
+ });
157
+
158
+ it('handles negative non-integer in full mode', () => {
159
+ expect(formatCurrency(-42.5)).toBe('-$42.50');
160
+ });
161
+
162
+ it('handles negative non-integer in compact mode', () => {
163
+ expect(formatCurrency(-42.5, { compact: true })).toBe('-$42.50');
164
+ });
165
+ });
166
+
167
+ describe('isYearLikeValues', () => {
168
+ it('returns true for year-like integer arrays', () => {
169
+ expect(isYearLikeValues([1500, 2024, 2500])).toBe(true);
170
+ });
171
+
172
+ it('returns false for empty arrays', () => {
173
+ expect(isYearLikeValues([])).toBe(false);
174
+ });
175
+
176
+ it('returns false when values are below 1500', () => {
177
+ expect(isYearLikeValues([1499])).toBe(false);
178
+ });
179
+
180
+ it('returns false when values are above 2500', () => {
181
+ expect(isYearLikeValues([2501])).toBe(false);
182
+ });
183
+
184
+ it('returns false for non-integer values', () => {
185
+ expect(isYearLikeValues([2024.5])).toBe(false);
186
+ });
187
+
188
+ it('returns false when range includes zero', () => {
189
+ expect(isYearLikeValues([0, 2024])).toBe(false);
190
+ });
191
+
192
+ it('returns false for negative values', () => {
193
+ expect(isYearLikeValues([-2000])).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe('computeFieldFormatContext', () => {
198
+ it('computes extent and allIntegers from numeric values', () => {
199
+ const ctx = computeFieldFormatContext([10, 20, 30]);
200
+ expect(ctx.extent).toEqual([10, 30]);
201
+ expect(ctx.allIntegers).toBe(true);
202
+ });
203
+
204
+ it('detects non-integers', () => {
205
+ const ctx = computeFieldFormatContext([10, 20.5, 30]);
206
+ expect(ctx.allIntegers).toBe(false);
207
+ });
208
+
209
+ it('coerces numeric strings', () => {
210
+ const ctx = computeFieldFormatContext(['10', '20', '30']);
211
+ expect(ctx.extent).toEqual([10, 30]);
212
+ });
213
+
214
+ it('skips non-finite values', () => {
215
+ const ctx = computeFieldFormatContext([10, NaN, null, undefined, 30]);
216
+ expect(ctx.extent).toEqual([10, 30]);
217
+ });
218
+
219
+ it('returns empty context for empty input', () => {
220
+ const ctx = computeFieldFormatContext([]);
221
+ expect(ctx.extent).toBeUndefined();
222
+ expect(ctx.allIntegers).toBeUndefined();
223
+ });
224
+
225
+ it('sets surface when provided', () => {
226
+ const ctx = computeFieldFormatContext([1, 2], 'table');
227
+ expect(ctx.surface).toBe('table');
228
+ });
229
+ });
230
+
231
+ describe('isYearContext', () => {
232
+ it('returns true for year-like integer ranges', () => {
233
+ expect(isYearContext({ extent: [1990, 2024], allIntegers: true })).toBe(true);
234
+ });
235
+
236
+ it('returns false when non-integers', () => {
237
+ expect(isYearContext({ extent: [1990, 2024], allIntegers: false })).toBe(false);
238
+ });
239
+
240
+ it('returns false when out of range', () => {
241
+ expect(isYearContext({ extent: [0, 2024], allIntegers: true })).toBe(false);
242
+ });
243
+
244
+ it('returns false for undefined ctx', () => {
245
+ expect(isYearContext(undefined)).toBe(false);
246
+ });
247
+ });
248
+
249
+ describe('resolveNumberFormatter', () => {
250
+ it('returns null for empty string', () => {
251
+ expect(resolveNumberFormatter('')).toBeNull();
252
+ });
253
+
254
+ it('returns null for undefined', () => {
255
+ expect(resolveNumberFormatter(undefined)).toBeNull();
256
+ });
257
+
258
+ it('resolves ordinal keyword', () => {
259
+ const fmt = resolveNumberFormatter('ordinal');
260
+ expect(fmt!(1)).toBe('1st');
261
+ });
262
+
263
+ it('resolves percent keyword with fraction detection (extent <= 1)', () => {
264
+ const fmt = resolveNumberFormatter('percent', { extent: [0, 0.9] });
265
+ expect(fmt!(0.847)).toBe('84.7%');
266
+ });
267
+
268
+ it('resolves percent keyword with pre-scaled detection (extent > 1)', () => {
269
+ const fmt = resolveNumberFormatter('percent', { extent: [0, 98] });
270
+ expect(fmt!(84.7)).toBe('84.7%');
271
+ });
272
+
273
+ it('resolves currency keyword for tables (full precision)', () => {
274
+ const fmt = resolveNumberFormatter('currency', { surface: 'table' });
275
+ expect(fmt!(12666)).toBe('$12,666');
276
+ });
277
+
278
+ it('resolves currency keyword for charts (compact)', () => {
279
+ const fmt = resolveNumberFormatter('currency');
280
+ expect(fmt!(1900)).toBe('$1.9k');
281
+ });
282
+
283
+ it('resolves d3 format strings', () => {
284
+ const fmt = resolveNumberFormatter('$,.0f');
285
+ expect(fmt!(1234)).toBe('$1,234');
286
+ });
287
+
288
+ it('currency with step uses step-derived decimals', () => {
289
+ const fmt = resolveNumberFormatter('currency', {
290
+ step: 500000,
291
+ stepReference: 2000000,
292
+ });
293
+ expect(fmt!(1500000)).toBe('$1.5M');
294
+ });
295
+ });
296
+
297
+ describe('defaultNumberFormatter', () => {
298
+ it('contextless: per-value year check (ARIA path)', () => {
299
+ const fmt = defaultNumberFormatter();
300
+ expect(fmt(2024)).toBe('2024');
301
+ expect(fmt(2600)).toBe('2.6k');
302
+ });
303
+
304
+ it('year context returns bare year string', () => {
305
+ const fmt = defaultNumberFormatter({ extent: [1990, 2024], allIntegers: true });
306
+ expect(fmt(2024)).toBe('2024');
307
+ });
308
+
309
+ it('table surface returns full precision', () => {
310
+ const fmt = defaultNumberFormatter({ surface: 'table' });
311
+ expect(fmt(1020000)).toBe('1,020,000');
312
+ });
313
+
314
+ it('step-aware compact when step and reference >= 1000', () => {
315
+ const fmt = defaultNumberFormatter({ step: 500000, stepReference: 2000000 });
316
+ expect(fmt(500000)).toBe('500k');
317
+ expect(fmt(1000000)).toBe('1M');
318
+ expect(fmt(1500000)).toBe('1.5M');
319
+ });
320
+
321
+ it('per-value compact/formatNumber for chart surface', () => {
322
+ const fmt = defaultNumberFormatter({ extent: [0, 2000000] });
323
+ expect(fmt(1020000)).toBe('1M');
324
+ expect(fmt(42)).toBe('42');
325
+ });
326
+
327
+ it('does not apply per-value year check when context has extent', () => {
328
+ const fmt = defaultNumberFormatter({ extent: [-2000, 2000], allIntegers: true });
329
+ expect(fmt(2000)).toBe('2k');
330
+ });
331
+ });
332
+
333
+ describe('buildCompactStepFormatter', () => {
334
+ it('formats per-value units with step-derived decimals', () => {
335
+ const fmt = buildCompactStepFormatter(500000);
336
+ expect(fmt(0)).toBe('0');
337
+ expect(fmt(500000)).toBe('500k');
338
+ expect(fmt(1000000)).toBe('1M');
339
+ expect(fmt(1500000)).toBe('1.5M');
340
+ });
341
+
342
+ it('step-derived decimals prevent misleading rounding', () => {
343
+ const fmt = buildCompactStepFormatter(1250000);
344
+ expect(fmt(1250000)).toBe('1.25M');
345
+ expect(fmt(2500000)).toBe('2.5M');
346
+ });
347
+
348
+ it('degenerate narrow domain falls back to comma-grouped', () => {
349
+ const fmt = buildCompactStepFormatter(1);
350
+ expect(fmt(2021)).toBe('2,021');
351
+ });
352
+
353
+ it('handles non-finite values', () => {
354
+ const fmt = buildCompactStepFormatter(500000);
355
+ expect(fmt(Infinity)).toBe('Infinity');
356
+ expect(fmt(NaN)).toBe('NaN');
357
+ });
358
+
359
+ it('sub-1k ticks render plain', () => {
360
+ const fmt = buildCompactStepFormatter(200);
361
+ expect(fmt(200)).toBe('200');
362
+ expect(fmt(400)).toBe('400');
363
+ expect(fmt(1000)).toBe('1k');
364
+ });
66
365
  });
67
366
 
68
367
  describe('buildD3Formatter', () => {
@@ -117,6 +416,50 @@ describe('buildD3Formatter', () => {
117
416
  expect(fmt!(1_500_000_000)).toBe('$1.5B');
118
417
  expect(fmt!(2_000_000_000)).toBe('$2B');
119
418
  });
419
+
420
+ it("'ordinal' returns the rank-axis ordinal formatter", () => {
421
+ const fmt = buildD3Formatter('ordinal');
422
+ expect(fmt).not.toBeNull();
423
+ expect(fmt!(1)).toBe('1st');
424
+ expect(fmt!(6)).toBe('6th');
425
+ });
426
+ });
427
+
428
+ describe('formatOrdinal', () => {
429
+ it('formats 1, 2, 3 with st/nd/rd suffixes', () => {
430
+ expect(formatOrdinal(1)).toBe('1st');
431
+ expect(formatOrdinal(2)).toBe('2nd');
432
+ expect(formatOrdinal(3)).toBe('3rd');
433
+ });
434
+
435
+ it('formats 4 through 10 with th', () => {
436
+ expect(formatOrdinal(4)).toBe('4th');
437
+ expect(formatOrdinal(10)).toBe('10th');
438
+ });
439
+
440
+ it('uses th for the 11-13 teens, including 111-113', () => {
441
+ expect(formatOrdinal(11)).toBe('11th');
442
+ expect(formatOrdinal(12)).toBe('12th');
443
+ expect(formatOrdinal(13)).toBe('13th');
444
+ expect(formatOrdinal(111)).toBe('111th');
445
+ expect(formatOrdinal(112)).toBe('112th');
446
+ });
447
+
448
+ it('applies st/nd/rd past the teens', () => {
449
+ expect(formatOrdinal(21)).toBe('21st');
450
+ expect(formatOrdinal(22)).toBe('22nd');
451
+ expect(formatOrdinal(23)).toBe('23rd');
452
+ expect(formatOrdinal(101)).toBe('101st');
453
+ });
454
+
455
+ it('rounds non-integers to the nearest rank', () => {
456
+ expect(formatOrdinal(2.4)).toBe('2nd');
457
+ });
458
+
459
+ it('falls back to String() for non-finite values', () => {
460
+ expect(formatOrdinal(Number.NaN)).toBe('NaN');
461
+ expect(formatOrdinal(Number.POSITIVE_INFINITY)).toBe('Infinity');
462
+ });
120
463
  });
121
464
 
122
465
  describe('formatDate', () => {
@@ -149,8 +492,6 @@ describe('formatDate', () => {
149
492
  });
150
493
 
151
494
  it('infers year granularity for bare year strings regardless of timezone', () => {
152
- // "2020" parses as 2020-01-01T00:00:00Z. In negative-offset timezones,
153
- // local getHours()/getDate() would see Dec 31 2019 — the UTC fix prevents that.
154
495
  const result = formatDate('2020');
155
496
  expect(result).toBe('2020');
156
497
  });
@@ -190,8 +531,6 @@ describe('formatDate', () => {
190
531
  });
191
532
 
192
533
  it('formats quarters with local-time year when useUtc is false', () => {
193
- // Mid-quarter local date: quarter and year agree between local and UTC,
194
- // exercising the getFullYear() arm of the compact branch.
195
534
  expect(formatDate(new Date(2025, 4, 15), undefined, 'quarter', false, true)).toBe("Q2 '25");
196
535
  });
197
536
 
@@ -214,8 +553,6 @@ describe('formatDate', () => {
214
553
  });
215
554
 
216
555
  it('full quarter year follows useUtc at year boundaries', () => {
217
- // In negative-offset timezones this instant is still Dec 31 2025 locally;
218
- // the UTC quarter (Q1) must pair with the UTC year (2026), not the local one.
219
556
  expect(formatDate(new Date('2026-01-01T00:00:00Z'), undefined, 'quarter')).toBe('Q1 2026');
220
557
  });
221
558
  });
@@ -264,7 +601,6 @@ describe('buildTemporalFormatter', () => {
264
601
  it('handles numeric timestamps', () => {
265
602
  const fmt = buildTemporalFormatter('%Y');
266
603
  expect(fmt).not.toBeNull();
267
- // 2020-01-01T00:00:00Z
268
604
  expect(fmt!(1577836800000)).toBe('2020');
269
605
  });
270
606
  });