@opendata-ai/openchart-engine 7.6.1 → 7.8.0

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 (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1037 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +379 -117
  42. package/src/tilemap/types.ts +1 -0
@@ -324,4 +324,214 @@ describe('compileTileMap', () => {
324
324
  expect(blueCa.fill).not.toBe(greenCa.fill);
325
325
  });
326
326
  });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Categorical mode
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe('categorical mode', () => {
333
+ const categoricalRecordSpec = {
334
+ type: 'tilemap' as const,
335
+ data: {
336
+ CA: 'medical_only',
337
+ TX: 'philosophical',
338
+ NY: 'religious',
339
+ FL: 'medical_only',
340
+ WA: 'philosophical',
341
+ } as Record<string, string>,
342
+ colors: {
343
+ medical_only: '#ee4a73',
344
+ religious: '#e07d00',
345
+ philosophical: '#06b6d4',
346
+ },
347
+ };
348
+
349
+ const categoricalTabularSpec = {
350
+ type: 'tilemap' as const,
351
+ data: [
352
+ { state_code: 'CA', exemption_category: 'medical_only' },
353
+ { state_code: 'TX', exemption_category: 'philosophical' },
354
+ { state_code: 'NY', exemption_category: 'religious' },
355
+ { state_code: 'MS', exemption_category: 'medical_only' },
356
+ ],
357
+ encoding: {
358
+ state: { field: 'state_code', type: 'nominal' as const },
359
+ value: { field: 'exemption_category', type: 'nominal' as const },
360
+ color: { field: 'exemption_category', type: 'nominal' as const },
361
+ },
362
+ colors: {
363
+ medical_only: '#ee4a73',
364
+ religious: '#e07d00',
365
+ philosophical: '#06b6d4',
366
+ },
367
+ };
368
+
369
+ it('renders 51 tiles with record-map string data', () => {
370
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
371
+ expect(result.tiles).toHaveLength(51);
372
+ });
373
+
374
+ it('assigns distinct fill colors per category', () => {
375
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
376
+
377
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
378
+ const txTile = result.tiles.find((t) => t.stateCode === 'TX')!;
379
+ const nyTile = result.tiles.find((t) => t.stateCode === 'NY')!;
380
+ const flTile = result.tiles.find((t) => t.stateCode === 'FL')!;
381
+
382
+ expect(caTile.fill).toBe('#ee4a73');
383
+ expect(txTile.fill).toBe('#06b6d4');
384
+ expect(nyTile.fill).toBe('#e07d00');
385
+ expect(flTile.fill).toBe('#ee4a73');
386
+ });
387
+
388
+ it('uses fillOpacity of 1 for all data tiles (no opacity encoding)', () => {
389
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
390
+ const dataTiles = result.tiles.filter((t) => t.hasData);
391
+ for (const tile of dataTiles) {
392
+ expect(tile.fillOpacity).toBe(1);
393
+ }
394
+ });
395
+
396
+ it('produces categoricalLegend (not gradientLegend)', () => {
397
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
398
+
399
+ expect(result.gradientLegend).toBeNull();
400
+ expect(result.categoricalLegend).not.toBeNull();
401
+ expect(result.categoricalLegend!.type).toBe('categorical');
402
+ });
403
+
404
+ it('legend entries match categories in data order', () => {
405
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
406
+
407
+ const labels = result.categoricalLegend!.entries.map((e) => e.label);
408
+ expect(labels).toEqual(['Medical Only', 'Philosophical', 'Religious']);
409
+ });
410
+
411
+ it('legend entry colors match the colors map', () => {
412
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
413
+
414
+ const entries = result.categoricalLegend!.entries;
415
+ expect(entries.find((e) => e.label === 'Medical Only')!.color).toBe('#ee4a73');
416
+ expect(entries.find((e) => e.label === 'Religious')!.color).toBe('#e07d00');
417
+ expect(entries.find((e) => e.label === 'Philosophical')!.color).toBe('#06b6d4');
418
+ });
419
+
420
+ it('works with tabular data and explicit encoding', () => {
421
+ const result = compileTileMap(categoricalTabularSpec, defaultOptions);
422
+
423
+ const dataTiles = result.tiles.filter((t) => t.hasData);
424
+ expect(dataTiles).toHaveLength(4);
425
+
426
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
427
+ expect(caTile.fill).toBe('#ee4a73');
428
+ expect(caTile.formattedValue).toBe('Medical Only');
429
+ });
430
+
431
+ it('uses default categorical palette when no colors map provided', () => {
432
+ const spec = {
433
+ type: 'tilemap' as const,
434
+ data: { CA: 'a', TX: 'b', NY: 'c' } as Record<string, string>,
435
+ };
436
+ const result = compileTileMap(spec, defaultOptions);
437
+
438
+ const dataTiles = result.tiles.filter((t) => t.hasData);
439
+ const fills = new Set(dataTiles.map((t) => t.fill));
440
+ expect(fills.size).toBe(3);
441
+
442
+ expect(result.categoricalLegend).not.toBeNull();
443
+ });
444
+
445
+ it('missing states get neutral fill in categorical mode', () => {
446
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
447
+
448
+ const akTile = result.tiles.find((t) => t.stateCode === 'AK')!;
449
+ expect(akTile.hasData).toBe(false);
450
+ expect(akTile.formattedValue).toBe('–');
451
+ });
452
+
453
+ it('a11y alt text lists categories', () => {
454
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
455
+
456
+ expect(result.a11y.altText).toContain('categories');
457
+ expect(result.a11y.altText).toContain('Medical Only');
458
+ });
459
+
460
+ it('hides categorical legend when legend.show is false', () => {
461
+ const spec = { ...categoricalRecordSpec, legend: { show: false } };
462
+ const result = compileTileMap(spec, defaultOptions);
463
+
464
+ expect(result.categoricalLegend).toBeNull();
465
+ });
466
+
467
+ it('stores category in tile.data for data-bearing tiles only', () => {
468
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
469
+
470
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
471
+ expect(caTile.data.category).toBe('medical_only');
472
+
473
+ const akTile = result.tiles.find((t) => t.stateCode === 'AK')!;
474
+ expect(akTile.data.category).toBeUndefined();
475
+ });
476
+
477
+ it('category colors are the same in dark and light mode', () => {
478
+ const lightResult = compileTileMap(categoricalRecordSpec, defaultOptions);
479
+ const darkResult = compileTileMap(categoricalRecordSpec, {
480
+ ...defaultOptions,
481
+ darkMode: true,
482
+ });
483
+
484
+ const lightCA = lightResult.tiles.find((t) => t.stateCode === 'CA')!;
485
+ const darkCA = darkResult.tiles.find((t) => t.stateCode === 'CA')!;
486
+ expect(lightCA.fill).toBe(darkCA.fill);
487
+ expect(lightCA.fill).toBe('#ee4a73');
488
+ });
489
+
490
+ it('colors map takes precedence over palette (categorical wins)', () => {
491
+ const spec = {
492
+ ...categoricalRecordSpec,
493
+ palette: 'green' as const,
494
+ };
495
+ const result = compileTileMap(spec, defaultOptions);
496
+
497
+ expect(result.categoricalLegend).not.toBeNull();
498
+ expect(result.gradientLegend).toBeNull();
499
+
500
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
501
+ expect(caTile.fill).toBe('#ee4a73');
502
+ });
503
+
504
+ it('wraps categorical palette colors when categories exceed palette length', () => {
505
+ const manyCategories: Record<string, string> = {};
506
+ const letters = 'ABCDEFGHIJKLM';
507
+ const states = ['AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'HI', 'ID', 'IL'];
508
+ for (let i = 0; i < states.length; i++) {
509
+ manyCategories[states[i]] = letters[i];
510
+ }
511
+ const spec = {
512
+ type: 'tilemap' as const,
513
+ data: manyCategories,
514
+ };
515
+ const result = compileTileMap(spec, defaultOptions);
516
+
517
+ // Category 'A' (index 0) and 'J' (index 9) share the same palette slot after wrapping
518
+ const alTile = result.tiles.find((t) => t.stateCode === 'AL')!; // category A, palette[0]
519
+ const gaTile = result.tiles.find((t) => t.stateCode === 'GA')!; // category J, palette[9 % 9 = 0]
520
+ expect(alTile.fill).toBe(gaTile.fill);
521
+ });
522
+
523
+ it('tooltip uses "Category" label for categorical tiles', () => {
524
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
525
+
526
+ const tooltip = result.tooltipDescriptors.get('CA')!;
527
+ expect(tooltip.fields[0].label).toBe('Category');
528
+ });
529
+
530
+ it('formattedValue shows formatted label, not raw key', () => {
531
+ const result = compileTileMap(categoricalRecordSpec, defaultOptions);
532
+
533
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
534
+ expect(caTile.formattedValue).toBe('Medical Only');
535
+ });
536
+ });
327
537
  });