@opendata-ai/openchart-engine 6.24.2 → 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/dist/index.js +5874 -5588
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compile-layer.test.ts +321 -0
- package/src/charts/line/area.ts +9 -4
- package/src/compile.ts +442 -10
- package/src/layout/axes.ts +6 -4
- package/src/layout/dimensions.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
});
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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),
|