@opendata-ai/openchart-engine 6.24.1 → 6.25.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.24.1",
3
+ "version": "6.25.0",
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.24.1",
51
+ "@opendata-ai/openchart-core": "6.25.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -346,6 +346,227 @@ describe('compileLayer', () => {
346
346
  expect(layout.marks.length).toBeGreaterThan(0);
347
347
  });
348
348
 
349
+ // -------------------------------------------------------------------------
350
+ // Independent y-scales (dual-axis)
351
+ // -------------------------------------------------------------------------
352
+
353
+ it('produces a y2 axis when resolve.scale.y is independent', () => {
354
+ const spec: LayerSpec = {
355
+ resolve: { scale: { y: 'independent' } },
356
+ layer: [
357
+ {
358
+ mark: 'bar' as const,
359
+ data: [
360
+ { year: '2025', revenue: 10_000_000 },
361
+ { year: '2026', revenue: 15_000_000 },
362
+ ],
363
+ encoding: {
364
+ x: { field: 'year', type: 'ordinal' as const },
365
+ y: { field: 'revenue', type: 'quantitative' as const, axis: { title: 'Revenue ($)' } },
366
+ },
367
+ },
368
+ {
369
+ mark: 'line' as const,
370
+ data: [
371
+ { year: '2025', enrollment: 30_000 },
372
+ { year: '2026', enrollment: 40_000 },
373
+ ],
374
+ encoding: {
375
+ x: { field: 'year', type: 'ordinal' as const },
376
+ y: {
377
+ field: 'enrollment',
378
+ type: 'quantitative' as const,
379
+ axis: { title: 'Enrollment' },
380
+ },
381
+ },
382
+ },
383
+ ],
384
+ };
385
+
386
+ const layout = compileLayer(spec, compileOpts);
387
+
388
+ expect(layout.axes.y2).toBeDefined();
389
+ expect(layout.axes.y2!.orient).toBe('right');
390
+ expect(layout.axes.y).toBeDefined();
391
+ expect(layout.axes.x).toBeDefined();
392
+ });
393
+
394
+ it('tags layer-1 marks with yScale y2', () => {
395
+ const spec: LayerSpec = {
396
+ resolve: { scale: { y: 'independent' } },
397
+ layer: [
398
+ {
399
+ mark: 'bar' as const,
400
+ data: [{ year: '2025', revenue: 10_000_000 }],
401
+ encoding: {
402
+ x: { field: 'year', type: 'ordinal' as const },
403
+ y: { field: 'revenue', type: 'quantitative' as const },
404
+ },
405
+ },
406
+ {
407
+ mark: 'line' as const,
408
+ data: [{ year: '2025', enrollment: 30_000 }],
409
+ encoding: {
410
+ x: { field: 'year', type: 'ordinal' as const },
411
+ y: { field: 'enrollment', type: 'quantitative' as const },
412
+ },
413
+ },
414
+ ],
415
+ };
416
+
417
+ const layout = compileLayer(spec, compileOpts);
418
+ const y2Marks = layout.marks.filter((m) => m.yScale === 'y2');
419
+ expect(y2Marks.length).toBeGreaterThan(0);
420
+ });
421
+
422
+ it('strips gridlines from y2 axis', () => {
423
+ const spec: LayerSpec = {
424
+ resolve: { scale: { y: 'independent' } },
425
+ layer: [
426
+ {
427
+ mark: 'bar' as const,
428
+ data: [{ year: '2025', revenue: 10_000_000 }],
429
+ encoding: {
430
+ x: { field: 'year', type: 'ordinal' as const },
431
+ y: { field: 'revenue', type: 'quantitative' as const },
432
+ },
433
+ },
434
+ {
435
+ mark: 'line' as const,
436
+ data: [{ year: '2025', enrollment: 30_000 }],
437
+ encoding: {
438
+ x: { field: 'year', type: 'ordinal' as const },
439
+ y: { field: 'enrollment', type: 'quantitative' as const },
440
+ },
441
+ },
442
+ ],
443
+ };
444
+
445
+ const layout = compileLayer(spec, compileOpts);
446
+ expect(layout.axes.y2!.gridlines).toEqual([]);
447
+ });
448
+
449
+ it('produces no y2 axis when resolve is absent', () => {
450
+ const spec: LayerSpec = {
451
+ layer: [
452
+ {
453
+ mark: 'bar' as const,
454
+ data: [{ name: 'A', value: 10 }],
455
+ encoding: {
456
+ x: { field: 'value', type: 'quantitative' as const },
457
+ y: { field: 'name', type: 'nominal' as const },
458
+ },
459
+ },
460
+ {
461
+ mark: 'bar' as const,
462
+ data: [{ name: 'A', value: 20 }],
463
+ encoding: {
464
+ x: { field: 'value', type: 'quantitative' as const },
465
+ y: { field: 'name', type: 'nominal' as const },
466
+ },
467
+ },
468
+ ],
469
+ };
470
+
471
+ const layout = compileLayer(spec, compileOpts);
472
+ expect(layout.axes.y2).toBeUndefined();
473
+ });
474
+
475
+ it('produces mixed mark types from bar + line layers', () => {
476
+ const spec: LayerSpec = {
477
+ resolve: { scale: { y: 'independent' } },
478
+ layer: [
479
+ {
480
+ mark: 'bar' as const,
481
+ data: [
482
+ { year: '2025', revenue: 10_000_000 },
483
+ { year: '2026', revenue: 15_000_000 },
484
+ ],
485
+ encoding: {
486
+ x: { field: 'year', type: 'ordinal' as const },
487
+ y: { field: 'revenue', type: 'quantitative' as const },
488
+ },
489
+ },
490
+ {
491
+ mark: 'line' as const,
492
+ data: [
493
+ { year: '2025', enrollment: 30_000 },
494
+ { year: '2026', enrollment: 40_000 },
495
+ ],
496
+ encoding: {
497
+ x: { field: 'year', type: 'ordinal' as const },
498
+ y: { field: 'enrollment', type: 'quantitative' as const },
499
+ },
500
+ },
501
+ ],
502
+ };
503
+
504
+ const layout = compileLayer(spec, compileOpts);
505
+ const markTypes = new Set(layout.marks.map((m) => m.type));
506
+ expect(markTypes.has('rect')).toBe(true);
507
+ expect(markTypes.has('line')).toBe(true);
508
+ });
509
+
510
+ it('throws on >2 layers with independent y-scales', () => {
511
+ const spec: LayerSpec = {
512
+ resolve: { scale: { y: 'independent' } },
513
+ layer: [
514
+ {
515
+ mark: 'bar' as const,
516
+ data: [{ year: '2025', a: 10 }],
517
+ encoding: {
518
+ x: { field: 'year', type: 'ordinal' as const },
519
+ y: { field: 'a', type: 'quantitative' as const },
520
+ },
521
+ },
522
+ {
523
+ mark: 'line' as const,
524
+ data: [{ year: '2025', b: 20 }],
525
+ encoding: {
526
+ x: { field: 'year', type: 'ordinal' as const },
527
+ y: { field: 'b', type: 'quantitative' as const },
528
+ },
529
+ },
530
+ {
531
+ mark: 'area' as const,
532
+ data: [{ year: '2025', c: 30 }],
533
+ encoding: {
534
+ x: { field: 'year', type: 'ordinal' as const },
535
+ y: { field: 'c', type: 'quantitative' as const },
536
+ },
537
+ },
538
+ ],
539
+ };
540
+
541
+ expect(() => compileLayer(spec, compileOpts)).toThrow(/at most 2 layers/);
542
+ });
543
+
544
+ it('throws on mismatched x-field types across layers', () => {
545
+ const spec: LayerSpec = {
546
+ resolve: { scale: { y: 'independent' } },
547
+ layer: [
548
+ {
549
+ mark: 'bar' as const,
550
+ data: [{ year: '2025', a: 10 }],
551
+ encoding: {
552
+ x: { field: 'year', type: 'nominal' as const },
553
+ y: { field: 'a', type: 'quantitative' as const },
554
+ },
555
+ },
556
+ {
557
+ mark: 'line' as const,
558
+ data: [{ year: '2025-01-01', b: 20 }],
559
+ encoding: {
560
+ x: { field: 'year', type: 'temporal' as const },
561
+ y: { field: 'b', type: 'quantitative' as const },
562
+ },
563
+ },
564
+ ],
565
+ };
566
+
567
+ expect(() => compileLayer(spec, compileOpts)).toThrow(/matching x-field types/);
568
+ });
569
+
349
570
  it('deduplicates legend entries when layers share a color field', () => {
350
571
  const spec: LayerSpec = {
351
572
  layer: [
@@ -383,4 +604,104 @@ describe('compileLayer', () => {
383
604
  expect(uniqueLabels.has('X')).toBe(true);
384
605
  expect(uniqueLabels.has('Y')).toBe(true);
385
606
  });
607
+
608
+ it('remaps x-coordinates when area is layer 0 and bars are layer 1', () => {
609
+ // Inverse ordering: area/line on left axis, bars on right. The x-remapping
610
+ // logic must detect that layer 1 has bars and remap layer 0's area mark instead.
611
+ const years = ['2020', '2021', '2022', '2023'];
612
+ const spec: LayerSpec = {
613
+ resolve: { scale: { y: 'independent' } },
614
+ layer: [
615
+ {
616
+ mark: 'area' as const,
617
+ data: years.map((y, i) => ({ year: y, temp: 60 + i * 3 })),
618
+ encoding: {
619
+ x: { field: 'year', type: 'ordinal' as const },
620
+ y: { field: 'temp', type: 'quantitative' as const },
621
+ },
622
+ },
623
+ {
624
+ mark: 'bar' as const,
625
+ data: years.map((y, i) => ({ year: y, precip: i * 0.5 })),
626
+ encoding: {
627
+ x: { field: 'year', type: 'ordinal' as const },
628
+ y: { field: 'precip', type: 'quantitative' as const },
629
+ },
630
+ },
631
+ ],
632
+ };
633
+
634
+ const layout = compileLayer(spec, compileOpts);
635
+
636
+ // y2 axis belongs to the bar layer (layer 1)
637
+ expect(layout.axes.y2).toBeDefined();
638
+ expect(layout.axes.y2!.orient).toBe('right');
639
+
640
+ // Area marks come from layer 0 (left axis, no yScale tag)
641
+ const areaMarks = layout.marks.filter((m) => m.type === 'area');
642
+ expect(areaMarks.length).toBeGreaterThan(0);
643
+ for (const m of areaMarks) {
644
+ expect(m.yScale).toBeUndefined();
645
+ }
646
+
647
+ // Rect marks come from layer 1 (right axis, yScale: 'y2')
648
+ const rectMarks = layout.marks.filter((m) => m.type === 'rect');
649
+ expect(rectMarks.length).toBeGreaterThan(0);
650
+ for (const m of rectMarks) {
651
+ expect(m.yScale).toBe('y2');
652
+ }
653
+ });
654
+
655
+ it('offsets layer-1 discrete mark tooltip keys by layer-0 mark count', () => {
656
+ // Bars in layer 0 and layer 1. Each bar layer has 2 bars.
657
+ // Layer 0 bar at index 0 -> key 'rect-0', layer 1 bar at index 0 -> key 'rect-2'
658
+ // (offset by 2, the number of marks in layer 0).
659
+ const spec: LayerSpec = {
660
+ resolve: { scale: { y: 'independent' } },
661
+ layer: [
662
+ {
663
+ mark: 'bar' as const,
664
+ data: [
665
+ { year: '2020', a: 10 },
666
+ { year: '2021', a: 20 },
667
+ ],
668
+ encoding: {
669
+ x: { field: 'year', type: 'ordinal' as const },
670
+ y: { field: 'a', type: 'quantitative' as const },
671
+ },
672
+ },
673
+ {
674
+ mark: 'bar' as const,
675
+ data: [
676
+ { year: '2020', b: 5 },
677
+ { year: '2021', b: 15 },
678
+ ],
679
+ encoding: {
680
+ x: { field: 'year', type: 'ordinal' as const },
681
+ y: { field: 'b', type: 'quantitative' as const },
682
+ },
683
+ },
684
+ ],
685
+ };
686
+
687
+ const layout = compileLayer(spec, compileOpts);
688
+ const l0RectCount = layout.marks
689
+ .slice(0, layout.marks.length)
690
+ .filter((m, i) => m.type === 'rect' && i < layout.marks.length / 2).length;
691
+
692
+ // Every rect mark in the combined array should have a tooltip descriptor
693
+ // under its actual combined-array index key.
694
+ const rectMarks = layout.marks.filter((m) => m.type === 'rect');
695
+ for (let i = 0; i < rectMarks.length; i++) {
696
+ const globalIndex = layout.marks.indexOf(rectMarks[i]);
697
+ expect(layout.tooltipDescriptors.has(`rect-${globalIndex}`)).toBe(true);
698
+ }
699
+
700
+ // No stale 'l1-rect-N' prefixed keys should exist
701
+ for (const key of layout.tooltipDescriptors.keys()) {
702
+ expect(key).not.toMatch(/^l1-/);
703
+ }
704
+
705
+ void l0RectCount; // used for conceptual clarity above
706
+ });
386
707
  });
@@ -121,6 +121,7 @@ export function resolveTextAnnotation(
121
121
  }
122
122
  : undefined,
123
123
  background: annotation.background,
124
+ halo: annotation.halo,
124
125
  };
125
126
 
126
127
  return {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
- import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor, isGradientDef } from '@opendata-ai/openchart-core';
11
11
  import type { ScaleLinear } from 'd3-scale';
12
12
  import {
13
13
  area,
@@ -135,7 +135,12 @@ function computeSingleArea(
135
135
 
136
136
  const aria: MarkAria = { label: ariaLabel };
137
137
 
138
- const fillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
138
+ // Allow markDef.fill to override color with a gradient.
139
+ // When a gradient is provided, set fillOpacity=1 so gradient stop-opacity controls the fade.
140
+ const markFill = spec.markDef.fill;
141
+ const fillValue = markFill != null ? markFill : color;
142
+ const defaultFillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
143
+ const fillOpacity = isGradientDef(fillValue) ? 1 : (spec.markDef.opacity ?? defaultFillOpacity);
139
144
 
140
145
  marks.push({
141
146
  type: 'area',
@@ -143,9 +148,9 @@ function computeSingleArea(
143
148
  bottomPoints,
144
149
  path: pathStr,
145
150
  topPath: topPathStr,
146
- fill: color,
151
+ fill: fillValue,
147
152
  fillOpacity: fillOpacity,
148
- stroke: getRepresentativeColor(color),
153
+ stroke: getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
149
154
  strokeWidth: 2,
150
155
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
151
156
  data: validPoints.map((p) => p.row),