@opendata-ai/openchart-engine 6.26.0 → 6.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.26.0",
3
+ "version": "6.27.2",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.26.0",
51
+ "@opendata-ai/openchart-core": "6.27.2",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -74,6 +74,17 @@ export function makeLineSpec(): NormalizedChartSpec {
74
74
  hiddenSeries: [],
75
75
  seriesStyles: {},
76
76
  watermark: true,
77
+ display: 'full',
78
+ userExplicit: {
79
+ chrome: false,
80
+ legend: false,
81
+ xAxis: false,
82
+ yAxis: false,
83
+ labels: false,
84
+ animation: false,
85
+ watermark: false,
86
+ crosshair: false,
87
+ },
77
88
  };
78
89
  }
79
90
 
@@ -104,6 +115,17 @@ export function makeBarSpec(): NormalizedChartSpec {
104
115
  hiddenSeries: [],
105
116
  seriesStyles: {},
106
117
  watermark: true,
118
+ display: 'full',
119
+ userExplicit: {
120
+ chrome: false,
121
+ legend: false,
122
+ xAxis: false,
123
+ yAxis: false,
124
+ labels: false,
125
+ animation: false,
126
+ watermark: false,
127
+ crosshair: false,
128
+ },
107
129
  };
108
130
  }
109
131
 
@@ -136,5 +158,16 @@ export function makeScatterSpec(): NormalizedChartSpec {
136
158
  hiddenSeries: [],
137
159
  seriesStyles: {},
138
160
  watermark: true,
161
+ display: 'full',
162
+ userExplicit: {
163
+ chrome: false,
164
+ legend: false,
165
+ xAxis: false,
166
+ yAxis: false,
167
+ labels: false,
168
+ animation: false,
169
+ watermark: false,
170
+ crosshair: false,
171
+ },
139
172
  };
140
173
  }
@@ -181,10 +181,12 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
181
181
  },
182
182
  "topHeight": 36.6,
183
183
  },
184
+ "crosshair": false,
184
185
  "dimensions": {
185
186
  "height": 400,
186
187
  "width": 600,
187
188
  },
189
+ "display": "full",
188
190
  "legend": {
189
191
  "bounds": {
190
192
  "height": 0,
@@ -731,10 +733,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
731
733
  },
732
734
  "topHeight": 61.599999999999994,
733
735
  },
736
+ "crosshair": false,
734
737
  "dimensions": {
735
738
  "height": 500,
736
739
  "width": 800,
737
740
  },
741
+ "display": "full",
738
742
  "legend": {
739
743
  "bounds": {
740
744
  "height": 85.2,
@@ -1574,10 +1578,12 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1574
1578
  },
1575
1579
  "topHeight": 36.6,
1576
1580
  },
1581
+ "crosshair": false,
1577
1582
  "dimensions": {
1578
1583
  "height": 400,
1579
1584
  "width": 600,
1580
1585
  },
1586
+ "display": "full",
1581
1587
  "legend": {
1582
1588
  "bounds": {
1583
1589
  "height": 0,
@@ -1,8 +1,11 @@
1
1
  import type { AxisTick, LayoutStrategy } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { scaleLinear, scaleLog } from 'd3-scale';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import type { NormalizedChartSpec } from '../compiler/types';
5
6
  import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
7
+ import { buildContinuousTicks } from '../layout/axes/ticks';
8
+ import type { ResolvedScale } from '../layout/scales';
6
9
  import { computeScales } from '../layout/scales';
7
10
 
8
11
  const lineSpec: NormalizedChartSpec = {
@@ -121,16 +124,16 @@ describe('computeAxes', () => {
121
124
  // Height-aware y-axis tick reduction
122
125
  // -------------------------------------------------------------------------
123
126
 
124
- it('reduces y-axis ticks for very short chart areas (< 120px)', () => {
127
+ it('reduces y-axis ticks for very short chart areas (< 80px)', () => {
125
128
  const shortArea = { x: 50, y: 50, width: 500, height: 80 };
126
129
  const scales = computeScales(lineSpec, shortArea, lineSpec.data);
127
130
  const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
128
131
 
129
- // Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
132
+ // Very short chart area -- tick count clamped to at most 4
130
133
  expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(4);
131
134
  });
132
135
 
133
- it('reduces y-axis ticks for medium-short chart areas (120-200px)', () => {
136
+ it('reduces y-axis ticks for medium-short chart areas (80-100px)', () => {
134
137
  const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
135
138
  const tallArea = { x: 50, y: 50, width: 500, height: 400 };
136
139
 
@@ -884,3 +887,98 @@ describe('horizontal bar y-axis label thinning regression', () => {
884
887
  expect(axes.x!.ticks.length).toBeLessThan(countries.length);
885
888
  });
886
889
  });
890
+
891
+ // ---------------------------------------------------------------------------
892
+ // Log scale tick filtering — buildContinuousTicks
893
+ // D3 log scales ignore the count hint and return ticks at every sub-power
894
+ // position. The engine must filter these down to powers of the base only.
895
+ // ---------------------------------------------------------------------------
896
+
897
+ /**
898
+ * Build a ResolvedScale backed by a D3 log scale, matching what buildLogScale produces.
899
+ * Using a single `base` parameter keeps the D3 scale and channel config in sync,
900
+ * mirroring how they're always derived from the same spec field.
901
+ */
902
+ function makeLogScale(domain: [number, number], base = 10): ResolvedScale {
903
+ const scale = scaleLog().domain(domain).range([400, 0]);
904
+ scale.base(base);
905
+ return {
906
+ scale,
907
+ type: 'log',
908
+ channel: {
909
+ field: 'value',
910
+ type: 'quantitative',
911
+ scale: base !== 10 ? { base } : undefined,
912
+ },
913
+ } as ResolvedScale;
914
+ }
915
+
916
+ describe('buildContinuousTicks — log scale power filtering', () => {
917
+ it('returns only power-of-10 ticks for [5, 25000] at tickCount 5', () => {
918
+ const resolved = makeLogScale([5, 25000]);
919
+ const ticks = buildContinuousTicks(resolved, 5);
920
+ const values = ticks.map((t) => t.value as number);
921
+ // Should be exactly the powers of 10 in domain: 10, 100, 1000, 10000
922
+ expect(values).toEqual([10, 100, 1000, 10000]);
923
+ });
924
+
925
+ it('returns only power-of-10 ticks for [1, 1000000] at tickCount 5', () => {
926
+ const resolved = makeLogScale([1, 1_000_000]);
927
+ const ticks = buildContinuousTicks(resolved, 5);
928
+ const values = ticks.map((t) => t.value as number);
929
+ // Assert invariants rather than exact output: every tick is a power of 10,
930
+ // and we get at least 5 (the domain spans 6 decades).
931
+ for (const v of values) {
932
+ const exp = Math.log10(v);
933
+ expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-9);
934
+ }
935
+ expect(values.length).toBeGreaterThanOrEqual(5);
936
+ });
937
+
938
+ it('returns only powers-of-2 for [1, 64] base-2 at tickCount 5', () => {
939
+ const resolved = makeLogScale([1, 64], 2);
940
+ const ticks = buildContinuousTicks(resolved, 5);
941
+ const values = ticks.map((t) => t.value as number);
942
+ expect(values).toEqual([1, 2, 4, 8, 16, 32, 64]);
943
+ });
944
+
945
+ it('handles fractional powers for [0.001, 100] at tickCount 5', () => {
946
+ const resolved = makeLogScale([0.001, 100]);
947
+ const ticks = buildContinuousTicks(resolved, 5);
948
+ const values = ticks.map((t) => t.value as number);
949
+ // Tolerance check prevents floating-point false negatives on 0.001, 0.01, 0.1
950
+ expect(values).toEqual([0.001, 0.01, 0.1, 1, 10, 100]);
951
+ });
952
+
953
+ it('still produces ticks at tickCount 3 (regression guard)', () => {
954
+ const resolved = makeLogScale([1, 1000]);
955
+ const ticks = buildContinuousTicks(resolved, 3);
956
+ expect(ticks.length).toBeGreaterThanOrEqual(2);
957
+ const values = ticks.map((t) => t.value as number);
958
+ // Every value must be a power of 10
959
+ for (const v of values) {
960
+ const exp = Math.log10(v);
961
+ expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-6);
962
+ }
963
+ });
964
+
965
+ it('does not over-filter linear scales with the same domain', () => {
966
+ const scale = scaleLinear().domain([5, 25000]).range([400, 0]);
967
+ const resolved: ResolvedScale = {
968
+ scale,
969
+ type: 'linear',
970
+ channel: { field: 'value', type: 'quantitative' },
971
+ } as ResolvedScale;
972
+ const ticks = buildContinuousTicks(resolved, 5);
973
+ // Linear scale should return normal D3 ticks — not just power-of-10 values
974
+ expect(ticks.length).toBeGreaterThanOrEqual(4);
975
+ // At least one tick should NOT be a power of 10 (e.g. 5000, 10000, 15000, 20000, 25000)
976
+ const values = ticks.map((t) => t.value as number);
977
+ const nonPowerOf10 = values.filter((v) => {
978
+ if (v <= 0) return true;
979
+ const exp = Math.log10(v);
980
+ return Math.abs(exp - Math.round(exp)) >= 0.01;
981
+ });
982
+ expect(nonPowerOf10.length).toBeGreaterThan(0);
983
+ });
984
+ });
@@ -398,6 +398,307 @@ describe('compileChart', () => {
398
398
  const pointMarks = layout.marks.filter((m) => m.type === 'point');
399
399
  expect(pointMarks.length).toBe(3);
400
400
  });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // display field + breakpoint override
404
+ // ---------------------------------------------------------------------------
405
+
406
+ it('display defaults to "full" when not specified', () => {
407
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
408
+ expect(layout.display).toBe('full');
409
+ });
410
+
411
+ it('display: "sparkline" propagates through to ChartLayout', () => {
412
+ const layout = compileChart(
413
+ { ...lineSpec, display: 'sparkline' as const },
414
+ { width: 400, height: 80 },
415
+ );
416
+ expect(layout.display).toBe('sparkline');
417
+ });
418
+
419
+ it('breakpoint override flips display at compact width', () => {
420
+ const spec = {
421
+ ...lineSpec,
422
+ overrides: {
423
+ compact: { display: 'sparkline' as const },
424
+ },
425
+ };
426
+
427
+ const compactLayout = compileChart(spec, { width: 320, height: 200 });
428
+ expect(compactLayout.display).toBe('sparkline');
429
+
430
+ const desktopLayout = compileChart(spec, { width: 1200, height: 600 });
431
+ expect(desktopLayout.display).toBe('full');
432
+ });
433
+
434
+ it('sparkline mode forces watermark off when not user-explicit', () => {
435
+ const layout = compileChart(
436
+ { ...lineSpec, display: 'sparkline' as const },
437
+ { width: 400, height: 80 },
438
+ );
439
+ expect(layout.watermark).toBe(false);
440
+ });
441
+
442
+ it('sparkline mode respects explicit watermark: true', () => {
443
+ const layout = compileChart(
444
+ { ...lineSpec, display: 'sparkline' as const, watermark: true },
445
+ { width: 400, height: 80 },
446
+ );
447
+ expect(layout.watermark).toBe(true);
448
+ });
449
+
450
+ it('sparkline mode forces crosshair off when not user-explicit', () => {
451
+ const layout = compileChart(
452
+ { ...lineSpec, display: 'sparkline' as const },
453
+ { width: 400, height: 80 },
454
+ );
455
+ expect(layout.crosshair).toBe(false);
456
+ });
457
+
458
+ it('sparkline mode respects explicit crosshair: true', () => {
459
+ const layout = compileChart(
460
+ { ...lineSpec, display: 'sparkline' as const, crosshair: true },
461
+ { width: 400, height: 80 },
462
+ );
463
+ expect(layout.crosshair).toBe(true);
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Sparkline layout profile (dimensions, axes, legend)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ it('sparkline produces near-edge-to-edge chart area (margins <= 4px per side)', () => {
471
+ const layout = compileChart(
472
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
473
+ { width: 400, height: 80 },
474
+ );
475
+
476
+ // No chrome, no axes, no legend by default. Mark area should be tight.
477
+ expect(layout.area.x).toBeLessThanOrEqual(4);
478
+ expect(layout.area.y).toBeLessThanOrEqual(4);
479
+ const rightMargin = 400 - (layout.area.x + layout.area.width);
480
+ const bottomMargin = 80 - (layout.area.y + layout.area.height);
481
+ expect(rightMargin).toBeLessThanOrEqual(4);
482
+ expect(bottomMargin).toBeLessThanOrEqual(4);
483
+ });
484
+
485
+ it('sparkline returns no axes by default', () => {
486
+ const layout = compileChart(
487
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
488
+ { width: 400, height: 80 },
489
+ );
490
+ expect(layout.axes.x).toBeUndefined();
491
+ expect(layout.axes.y).toBeUndefined();
492
+ });
493
+
494
+ it('sparkline + explicit encoding.x.axis still reserves x-axis', () => {
495
+ const layout = compileChart(
496
+ {
497
+ ...lineSpec,
498
+ chrome: undefined,
499
+ display: 'sparkline' as const,
500
+ encoding: {
501
+ ...lineSpec.encoding,
502
+ x: { ...lineSpec.encoding.x, axis: { title: 'date' } },
503
+ },
504
+ },
505
+ { width: 400, height: 200 },
506
+ );
507
+ expect(layout.axes.x).toBeDefined();
508
+ });
509
+
510
+ it('sparkline + explicit chrome.title still renders chrome', () => {
511
+ const layout = compileChart(
512
+ {
513
+ ...lineSpec,
514
+ chrome: { title: 'Q4 Revenue' },
515
+ display: 'sparkline' as const,
516
+ },
517
+ { width: 400, height: 200 },
518
+ );
519
+ expect(layout.chrome.title).toBeDefined();
520
+ expect(layout.chrome.title?.text).toBe('Q4 Revenue');
521
+ });
522
+
523
+ it('sparkline hides legend by default even with color encoding', () => {
524
+ const layout = compileChart(
525
+ {
526
+ ...lineSpec,
527
+ chrome: undefined,
528
+ display: 'sparkline' as const,
529
+ },
530
+ { width: 400, height: 80 },
531
+ );
532
+ expect('entries' in layout.legend && layout.legend.entries.length).toBe(0);
533
+ });
534
+
535
+ it('sparkline + explicit legend.show: true renders legend', () => {
536
+ const layout = compileChart(
537
+ {
538
+ ...lineSpec,
539
+ chrome: undefined,
540
+ display: 'sparkline' as const,
541
+ legend: { show: true },
542
+ },
543
+ { width: 400, height: 200 },
544
+ );
545
+ expect('entries' in layout.legend && layout.legend.entries.length).toBeGreaterThan(0);
546
+ });
547
+
548
+ it('sparkline works at heights as low as 30px', () => {
549
+ const layout = compileChart(
550
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
551
+ { width: 200, height: 30 },
552
+ );
553
+ expect(layout.area.height).toBeGreaterThan(0);
554
+ expect(layout.area.width).toBeGreaterThan(0);
555
+ });
556
+
557
+ it('chrome: {} does not count as user-explicit chrome (still stripped in sparkline)', () => {
558
+ // Empty chrome object is the idiom for "silence defaults" — should not
559
+ // opt-in to chrome rendering in sparkline mode.
560
+ const layout = compileChart(
561
+ { ...lineSpec, chrome: {}, display: 'sparkline' as const },
562
+ { width: 400, height: 80 },
563
+ );
564
+ expect(layout.chrome.title).toBeUndefined();
565
+ expect(layout.chrome.topHeight).toBe(0);
566
+ });
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Explicit-at-any-level wins (precedence matrix)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ it('top-level animation: true wins even when breakpoint flips to sparkline', () => {
573
+ const spec = {
574
+ ...lineSpec,
575
+ animation: true as const,
576
+ overrides: { compact: { display: 'sparkline' as const } },
577
+ };
578
+ const layout = compileChart(spec, { width: 320, height: 200 });
579
+ expect(layout.display).toBe('sparkline');
580
+ expect(layout.animation?.enabled).toBe(true);
581
+ });
582
+
583
+ it('breakpoint chrome wins when top-level is sparkline', () => {
584
+ const spec = {
585
+ ...lineSpec,
586
+ chrome: undefined,
587
+ display: 'sparkline' as const,
588
+ overrides: { full: { chrome: { title: 'Q4 revenue' } } },
589
+ };
590
+ const layout = compileChart(spec, { width: 1200, height: 600 });
591
+ // At full breakpoint, chrome.title from override should render even
592
+ // though display is sparkline at top-level.
593
+ expect(layout.chrome.title?.text).toBe('Q4 revenue');
594
+ });
595
+
596
+ it('top-level display: sparkline + breakpoint display: full restores all defaults', () => {
597
+ const spec = {
598
+ ...lineSpec,
599
+ display: 'sparkline' as const,
600
+ overrides: { full: { display: 'full' as const } },
601
+ };
602
+ const layout = compileChart(spec, { width: 1200, height: 600 });
603
+ expect(layout.display).toBe('full');
604
+ // Watermark default is true in full mode.
605
+ expect(layout.watermark).toBe(true);
606
+ });
607
+
608
+ it('explicit watermark: true in sparkline mode actually paints the watermark', () => {
609
+ const layout = compileChart(
610
+ {
611
+ ...lineSpec,
612
+ chrome: undefined,
613
+ display: 'sparkline' as const,
614
+ watermark: true,
615
+ },
616
+ { width: 400, height: 200 },
617
+ );
618
+ expect(layout.watermark).toBe(true);
619
+ });
620
+
621
+ it('breakpoint encoding.x.axis opts back into x-axis at that breakpoint', () => {
622
+ const spec = {
623
+ ...lineSpec,
624
+ chrome: undefined,
625
+ display: 'sparkline' as const,
626
+ overrides: {
627
+ full: {
628
+ encoding: {
629
+ x: { field: 'date', type: 'temporal' as const, axis: { title: 'date' } },
630
+ },
631
+ },
632
+ },
633
+ };
634
+ const layoutFull = compileChart(spec, { width: 1200, height: 600 });
635
+ expect(layoutFull.axes.x).toBeDefined();
636
+ const layoutCompact = compileChart(spec, { width: 320, height: 200 });
637
+ expect(layoutCompact.axes.x).toBeUndefined();
638
+ });
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Animation duration: sparkline mode bumps the entrance to 1100ms when on
642
+ // ---------------------------------------------------------------------------
643
+
644
+ it('sparkline + animation: true bumps entrance duration to 1100ms', () => {
645
+ const layout = compileChart(
646
+ { ...lineSpec, display: 'sparkline' as const, animation: true },
647
+ { width: 400, height: 80 },
648
+ );
649
+ expect(layout.animation?.enabled).toBe(true);
650
+ expect(layout.animation?.duration).toBe(1100);
651
+ });
652
+
653
+ it('sparkline + animation: { enter: { duration: 500 } } respects user duration', () => {
654
+ const layout = compileChart(
655
+ {
656
+ ...lineSpec,
657
+ display: 'sparkline' as const,
658
+ animation: { enter: { duration: 500 } },
659
+ },
660
+ { width: 400, height: 80 },
661
+ );
662
+ expect(layout.animation?.duration).toBe(500);
663
+ });
664
+
665
+ it('full mode + animation: true uses default 500ms (sparkline bump does not leak)', () => {
666
+ const layout = compileChart({ ...lineSpec, animation: true }, { width: 600, height: 400 });
667
+ expect(layout.animation?.duration).toBe(500);
668
+ });
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Breakpoint encoding deep-merge: nested axis state survives an override that
672
+ // only touches one axis property.
673
+ // ---------------------------------------------------------------------------
674
+
675
+ it('breakpoint encoding.x.axis deep-merges with base axis config', () => {
676
+ const spec = {
677
+ ...lineSpec,
678
+ encoding: {
679
+ ...lineSpec.encoding,
680
+ x: {
681
+ field: 'date',
682
+ type: 'temporal' as const,
683
+ axis: { title: 'date', tickCount: 8, format: '%b' },
684
+ },
685
+ },
686
+ overrides: {
687
+ compact: {
688
+ encoding: {
689
+ x: { axis: { title: 'd' } },
690
+ },
691
+ },
692
+ },
693
+ };
694
+ const layout = compileChart(spec, { width: 320, height: 200 });
695
+ // The compact override only touched axis.title; tickCount and format
696
+ // should survive the deep merge.
697
+ expect(layout.axes.x).toBeDefined();
698
+ expect(layout.axes.x?.label).toBe('d');
699
+ // Tick count flows from base spec — if shallow-merged, tickCount would be lost.
700
+ expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
701
+ });
401
702
  });
402
703
 
403
704
  describe('compileTable', () => {