@opendata-ai/openchart-engine 6.24.2 → 6.25.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.
- package/dist/index.js +5904 -5577
- 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 +471 -10
- package/src/layout/axes/ticks.ts +4 -3
- package/src/layout/axes.ts +6 -4
- package/src/layout/dimensions.ts +57 -15
package/src/compile.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type {
|
|
|
19
19
|
ChartSpec,
|
|
20
20
|
CompileOptions,
|
|
21
21
|
CompileTableOptions,
|
|
22
|
+
DataRow,
|
|
23
|
+
Encoding,
|
|
22
24
|
EncodingChannel,
|
|
23
25
|
LayerSpec,
|
|
24
26
|
Mark,
|
|
@@ -31,15 +33,23 @@ import type {
|
|
|
31
33
|
Transform,
|
|
32
34
|
} from '@opendata-ai/openchart-core';
|
|
33
35
|
import {
|
|
36
|
+
AXIS_TITLE_TRAILING_PAD,
|
|
34
37
|
adaptTheme,
|
|
38
|
+
BREAKPOINT_COMPACT_MAX,
|
|
35
39
|
computeLabelBounds,
|
|
40
|
+
estimateTextWidth,
|
|
36
41
|
generateAltText,
|
|
37
42
|
generateDataTable,
|
|
43
|
+
getAxisTitleOffset,
|
|
38
44
|
getBreakpoint,
|
|
39
45
|
getHeightClass,
|
|
40
46
|
getLayoutStrategy,
|
|
41
47
|
resolveTheme,
|
|
48
|
+
TICK_LABEL_OFFSET,
|
|
42
49
|
} from '@opendata-ai/openchart-core';
|
|
50
|
+
import { format as d3Format } from 'd3-format';
|
|
51
|
+
import { scaleLinear } from 'd3-scale';
|
|
52
|
+
import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
|
|
43
53
|
import { computeAnnotations } from './annotations/compute';
|
|
44
54
|
// Side-effect import: registers all built-in chart renderers with the
|
|
45
55
|
// registry on module load. Tests that clear the registry can import
|
|
@@ -338,8 +348,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
338
348
|
|
|
339
349
|
// INVARIANT 3 — post-hoc defaultColor: must run AFTER computeScales since resolution needs
|
|
340
350
|
// theme context. Do not move into computeScales (would require threading theme through).
|
|
341
|
-
//
|
|
342
|
-
scales.defaultColor =
|
|
351
|
+
// fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
|
|
352
|
+
scales.defaultColor =
|
|
353
|
+
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
343
354
|
|
|
344
355
|
// Arc charts (pie/donut) don't use axes or gridlines
|
|
345
356
|
const isRadial = chartSpec.markType === 'arc';
|
|
@@ -473,13 +484,15 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
473
484
|
return compileChart(singleSpec, options);
|
|
474
485
|
}
|
|
475
486
|
|
|
476
|
-
//
|
|
477
|
-
|
|
487
|
+
// Branch: independent y-scales produce dual-axis layout
|
|
488
|
+
if (spec.resolve?.scale?.y === 'independent') {
|
|
489
|
+
return compileLayerIndependent(leaves, spec, options);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Shared scales (default): union data and compile together
|
|
478
493
|
const primarySpec = buildPrimarySpec(leaves, spec);
|
|
479
494
|
const primaryLayout = compileChart(primarySpec, options);
|
|
480
495
|
|
|
481
|
-
// Compile each leaf layer independently but with the full unioned data
|
|
482
|
-
// so they all share the same scale domains.
|
|
483
496
|
const allMarks: Mark[] = [];
|
|
484
497
|
const seenLabels = new Set<string>();
|
|
485
498
|
const mergedLegendEntries = [...primaryLayout.legend.entries];
|
|
@@ -488,14 +501,10 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
488
501
|
}
|
|
489
502
|
|
|
490
503
|
for (const leaf of leaves) {
|
|
491
|
-
// Compile each leaf with its own data so marks correspond to its rows only.
|
|
492
|
-
// Scale domains may differ slightly between layers, but this prevents
|
|
493
|
-
// duplicate marks from feeding unioned data into every renderer.
|
|
494
504
|
const leafLayout = compileChart(leaf as unknown, options);
|
|
495
505
|
|
|
496
506
|
allMarks.push(...leafLayout.marks);
|
|
497
507
|
|
|
498
|
-
// Deduplicate legend entries across layers
|
|
499
508
|
for (const entry of leafLayout.legend.entries) {
|
|
500
509
|
if (!seenLabels.has(entry.label)) {
|
|
501
510
|
seenLabels.add(entry.label);
|
|
@@ -514,6 +523,457 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
514
523
|
};
|
|
515
524
|
}
|
|
516
525
|
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Independent y-scale compilation (dual-axis charts)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Estimate the pixel width needed for a right-side y-axis based on data values.
|
|
532
|
+
* Mirrors the left-margin estimation logic in computeDimensions.
|
|
533
|
+
*/
|
|
534
|
+
function estimateYAxisLabelWidth(
|
|
535
|
+
data: DataRow[],
|
|
536
|
+
encoding: Encoding | undefined,
|
|
537
|
+
baseFontSize: number,
|
|
538
|
+
): number {
|
|
539
|
+
if (!encoding?.y) return 40;
|
|
540
|
+
const yEnc = encoding.y;
|
|
541
|
+
const yField = yEnc.field;
|
|
542
|
+
if (!yField) return 40;
|
|
543
|
+
|
|
544
|
+
const yType = yEnc.type;
|
|
545
|
+
if (yType === 'nominal' || yType === 'ordinal') {
|
|
546
|
+
let maxWidth = 0;
|
|
547
|
+
for (const row of data) {
|
|
548
|
+
const label = String(row[yField] ?? '');
|
|
549
|
+
const w = estimateTextWidth(label, baseFontSize, 400);
|
|
550
|
+
if (w > maxWidth) maxWidth = w;
|
|
551
|
+
}
|
|
552
|
+
return maxWidth > 0 ? maxWidth + 10 : 40;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Quantitative/temporal: estimate from the largest value
|
|
556
|
+
const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
|
|
557
|
+
| string
|
|
558
|
+
| undefined;
|
|
559
|
+
let maxAbsVal = 0;
|
|
560
|
+
for (const row of data) {
|
|
561
|
+
const v = Number(row[yField]);
|
|
562
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
563
|
+
}
|
|
564
|
+
let sampleLabel: string;
|
|
565
|
+
if (yAxisFormat) {
|
|
566
|
+
try {
|
|
567
|
+
const fmt = d3Format(yAxisFormat);
|
|
568
|
+
sampleLabel = fmt(maxAbsVal);
|
|
569
|
+
} catch {
|
|
570
|
+
sampleLabel = String(maxAbsVal);
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
|
|
574
|
+
else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
|
|
575
|
+
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
|
|
576
|
+
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
577
|
+
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
578
|
+
else sampleLabel = '0.0';
|
|
579
|
+
}
|
|
580
|
+
const hasNeg = data.some((r) => Number(r[yField]) < 0);
|
|
581
|
+
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
|
|
582
|
+
return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Compile a LayerSpec with independent y-scales (dual-axis chart).
|
|
587
|
+
*
|
|
588
|
+
* Layer 0 gets the left y-axis, layer 1 gets the right y-axis.
|
|
589
|
+
* Both layers share the x-axis. Limited to exactly 2 layers.
|
|
590
|
+
*/
|
|
591
|
+
function compileLayerIndependent(
|
|
592
|
+
leaves: ChartSpec[],
|
|
593
|
+
layerSpec: LayerSpec,
|
|
594
|
+
options: CompileOptions,
|
|
595
|
+
): ChartLayout {
|
|
596
|
+
if (leaves.length > 2) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
'Independent y-scales support at most 2 layers (left and right y-axis). ' +
|
|
599
|
+
`Got ${leaves.length} layers.`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const leaf0 = leaves[0];
|
|
604
|
+
const leaf1 = leaves[1];
|
|
605
|
+
|
|
606
|
+
// Validate x-field types are compatible
|
|
607
|
+
const xType0 = leaf0.encoding?.x?.type;
|
|
608
|
+
const xType1 = leaf1.encoding?.x?.type;
|
|
609
|
+
if (xType0 && xType1 && xType0 !== xType1) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Dual-axis charts require matching x-field types across layers. ` +
|
|
612
|
+
`Layer 0 has '${xType0}', layer 1 has '${xType1}'.`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Estimate right-axis label width to reserve margin space.
|
|
617
|
+
// Tick labels sit at chartEdge+6 and extend rightward by their width.
|
|
618
|
+
// The rotated title sits at chartEdge+45 and extends by half the font height.
|
|
619
|
+
// These overlap spatially, so use max (not sum) to mirror the left-margin pattern.
|
|
620
|
+
const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
|
|
621
|
+
const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
|
|
622
|
+
const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
|
|
623
|
+
const hasRightAxisTitle = !!leaf1.encoding?.y?.axis?.title;
|
|
624
|
+
const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
|
|
625
|
+
const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
|
|
626
|
+
const axisTitleOffset = getAxisTitleOffset(options.width);
|
|
627
|
+
const halfGlyph = Math.ceil(bodyFontSize / 2);
|
|
628
|
+
const titleExtent = hasRightAxisTitle
|
|
629
|
+
? axisTitleOffset +
|
|
630
|
+
halfGlyph +
|
|
631
|
+
(options.width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD)
|
|
632
|
+
: 0;
|
|
633
|
+
const rightReserve = Math.max(tickExtent, titleExtent);
|
|
634
|
+
|
|
635
|
+
const optionsWithReserve: CompileOptions = {
|
|
636
|
+
...options,
|
|
637
|
+
rightAxisReserve: rightReserve,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Union x-data so both layers see the full x-domain.
|
|
641
|
+
// Each layer keeps its own y-data for independent y-scales.
|
|
642
|
+
const xField0 = leaf0.encoding?.x?.field;
|
|
643
|
+
const xField1 = leaf1.encoding?.x?.field;
|
|
644
|
+
const unionXValues = new Set<unknown>();
|
|
645
|
+
if (xField0) for (const row of leaf0.data) unionXValues.add(row[xField0]);
|
|
646
|
+
if (xField1) for (const row of leaf1.data) unionXValues.add(row[xField1]);
|
|
647
|
+
|
|
648
|
+
// Add missing x-values from leaf1 into leaf0's data as stub rows,
|
|
649
|
+
// and vice versa, so both scales see the full x-domain.
|
|
650
|
+
let leaf0WithUnionX = ensureXDomainCoverage(leaf0, xField0, unionXValues);
|
|
651
|
+
let leaf1WithUnionX = ensureXDomainCoverage(leaf1, xField1, unionXValues);
|
|
652
|
+
|
|
653
|
+
// Align y-domains so zero maps to the same pixel position on both axes
|
|
654
|
+
const aligned = alignYDomains(leaf0WithUnionX, leaf1WithUnionX);
|
|
655
|
+
if (aligned) {
|
|
656
|
+
leaf0WithUnionX = withYDomain(leaf0WithUnionX, aligned.domain0);
|
|
657
|
+
leaf1WithUnionX = withYDomain(leaf1WithUnionX, aligned.domain1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Compile layer 0 as the primary layout (chrome, x-axis, left y-axis)
|
|
661
|
+
const primary0 = buildPrimarySpec([leaf0WithUnionX], layerSpec);
|
|
662
|
+
const layout0 = compileChart(primary0, optionsWithReserve);
|
|
663
|
+
|
|
664
|
+
// Compile layer 1 independently for its own y-axis and marks.
|
|
665
|
+
// Keep chrome identical to layer 0 so both compile against the same chart area dimensions.
|
|
666
|
+
// layout1's chrome is never rendered -- we spread layout0 into the final return value.
|
|
667
|
+
const primary1 = buildPrimarySpec([leaf1WithUnionX], layerSpec);
|
|
668
|
+
primary1.annotations = [];
|
|
669
|
+
const layout1 = compileChart(primary1, optionsWithReserve);
|
|
670
|
+
|
|
671
|
+
// Extract layer 1's y-axis, reposition it to the right side
|
|
672
|
+
const y2Axis = layout1.axes.y
|
|
673
|
+
? {
|
|
674
|
+
...layout1.axes.y,
|
|
675
|
+
orient: 'right' as const,
|
|
676
|
+
gridlines: [], // Only left y-axis produces gridlines
|
|
677
|
+
start: {
|
|
678
|
+
x: layout0.area.x + layout0.area.width,
|
|
679
|
+
y: layout0.area.y,
|
|
680
|
+
},
|
|
681
|
+
end: {
|
|
682
|
+
x: layout0.area.x + layout0.area.width,
|
|
683
|
+
y: layout0.area.y + layout0.area.height,
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
: undefined;
|
|
687
|
+
|
|
688
|
+
// Build a per-category x-position map from whichever layer uses a band scale (bars).
|
|
689
|
+
// Band-scale tick positions are band centers -- the canonical x positions that both
|
|
690
|
+
// layers should align to. Line/area marks use a point scale and land at different pixels.
|
|
691
|
+
// We remap line/area mark x-coordinates by looking up each data row's x-field value
|
|
692
|
+
// in the band-center map, replacing point-scale positions with exact band centers.
|
|
693
|
+
const layer0HasBars = layout0.marks.some((m) => m.type === 'rect');
|
|
694
|
+
const layer1HasBars = layout1.marks.some((m) => m.type === 'rect');
|
|
695
|
+
|
|
696
|
+
// Build category → band-center-pixel map from the bar layer's x-axis ticks
|
|
697
|
+
const bandCenterByCategory = new Map<string, number>();
|
|
698
|
+
if (layer0HasBars && layout0.axes.x?.ticks) {
|
|
699
|
+
for (const tick of layout0.axes.x.ticks) {
|
|
700
|
+
bandCenterByCategory.set(String(tick.label), tick.position);
|
|
701
|
+
}
|
|
702
|
+
} else if (layer1HasBars && layout1.axes.x?.ticks) {
|
|
703
|
+
for (const tick of layout1.axes.x.ticks) {
|
|
704
|
+
bandCenterByCategory.set(String(tick.label), tick.position);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Remap line/area/point mark x-coordinates to band centers using data rows.
|
|
709
|
+
// The SVG path strings are kept as-is (the smooth curve is close enough to correct
|
|
710
|
+
// after a small x-shift). Only the discrete coordinate arrays and dot positions
|
|
711
|
+
// are remapped so tooltips and point markers land on bar centers.
|
|
712
|
+
const remapMarkX = (xField: string | undefined, mark: Mark): Mark => {
|
|
713
|
+
if (!xField || bandCenterByCategory.size === 0) return mark;
|
|
714
|
+
if (mark.type === 'line') {
|
|
715
|
+
const newPoints = mark.points.map((p, i) => {
|
|
716
|
+
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
717
|
+
return bx !== undefined ? { ...p, x: bx } : p;
|
|
718
|
+
});
|
|
719
|
+
const newDataPoints = mark.dataPoints?.map((dp, i) => {
|
|
720
|
+
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
721
|
+
return bx !== undefined ? { ...dp, x: bx } : dp;
|
|
722
|
+
});
|
|
723
|
+
// Regenerate the smooth monotone path from remapped points so the rendered
|
|
724
|
+
// line passes through bar centers with the same curve quality as the original.
|
|
725
|
+
// Uses curveMonotoneX regardless of the original interpolation -- preserving the
|
|
726
|
+
// user-specified curve across x-remapping would require re-resolving the mark's
|
|
727
|
+
// interpolation setting, which isn't stored on LineMark post-compilation.
|
|
728
|
+
const newPath =
|
|
729
|
+
d3line<{ x: number; y: number }>()
|
|
730
|
+
.x((p) => p.x)
|
|
731
|
+
.y((p) => p.y)
|
|
732
|
+
.curve(curveMonotoneX)(newPoints) ?? undefined;
|
|
733
|
+
return { ...mark, points: newPoints, dataPoints: newDataPoints, path: newPath };
|
|
734
|
+
}
|
|
735
|
+
if (mark.type === 'area') {
|
|
736
|
+
const newTopPoints = mark.topPoints.map((p, i) => {
|
|
737
|
+
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
738
|
+
return bx !== undefined ? { ...p, x: bx } : p;
|
|
739
|
+
});
|
|
740
|
+
const newBottomPoints = mark.bottomPoints.map((p, i) => {
|
|
741
|
+
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
742
|
+
return bx !== undefined ? { ...p, x: bx } : p;
|
|
743
|
+
});
|
|
744
|
+
const newDataPoints = mark.dataPoints?.map((dp, i) => {
|
|
745
|
+
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
746
|
+
return bx !== undefined ? { ...dp, x: bx } : dp;
|
|
747
|
+
});
|
|
748
|
+
// Regenerate area fill path and top-line stroke path from remapped points.
|
|
749
|
+
const areaGen = d3area<{ x: number; yTop: number; yBottom: number }>()
|
|
750
|
+
.x((p) => p.x)
|
|
751
|
+
.y0((p) => p.yBottom)
|
|
752
|
+
.y1((p) => p.yTop)
|
|
753
|
+
.curve(curveMonotoneX);
|
|
754
|
+
const topLineGen = d3line<{ x: number; yTop: number }>()
|
|
755
|
+
.x((p) => p.x)
|
|
756
|
+
.y((p) => p.yTop)
|
|
757
|
+
.curve(curveMonotoneX);
|
|
758
|
+
const combined = newTopPoints.map((tp, i) => ({
|
|
759
|
+
x: tp.x,
|
|
760
|
+
yTop: tp.y,
|
|
761
|
+
yBottom: newBottomPoints[i]?.y ?? tp.y,
|
|
762
|
+
}));
|
|
763
|
+
const newPath = areaGen(combined) ?? '';
|
|
764
|
+
const newTopPath = topLineGen(combined) ?? '';
|
|
765
|
+
return {
|
|
766
|
+
...mark,
|
|
767
|
+
topPoints: newTopPoints,
|
|
768
|
+
bottomPoints: newBottomPoints,
|
|
769
|
+
dataPoints: newDataPoints,
|
|
770
|
+
path: newPath,
|
|
771
|
+
topPath: newTopPath,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (mark.type === 'point') {
|
|
775
|
+
const bx = bandCenterByCategory.get(String(mark.data[xField] ?? ''));
|
|
776
|
+
return bx !== undefined ? { ...mark, cx: bx } : mark;
|
|
777
|
+
}
|
|
778
|
+
return mark;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// Apply remapping to whichever layer has line/area marks (point scale)
|
|
782
|
+
const adjustedMarks0 =
|
|
783
|
+
bandCenterByCategory.size > 0 && !layer0HasBars
|
|
784
|
+
? layout0.marks.map((m) => remapMarkX(xField0, m))
|
|
785
|
+
: layout0.marks;
|
|
786
|
+
|
|
787
|
+
// Tag layer 1 marks with yScale: 'y2' and remap x if needed
|
|
788
|
+
const taggedMarks1 = layout1.marks.map((mark) => {
|
|
789
|
+
const tagged = { ...mark, yScale: 'y2' as const };
|
|
790
|
+
if (bandCenterByCategory.size > 0 && !layer1HasBars) {
|
|
791
|
+
return remapMarkX(xField1, tagged) as typeof tagged;
|
|
792
|
+
}
|
|
793
|
+
return tagged;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Merge legend entries with deduplication
|
|
797
|
+
const seenLabels = new Set<string>();
|
|
798
|
+
const mergedLegendEntries = [...layout0.legend.entries];
|
|
799
|
+
for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
|
|
800
|
+
for (const entry of layout1.legend.entries) {
|
|
801
|
+
if (!seenLabels.has(entry.label)) {
|
|
802
|
+
seenLabels.add(entry.label);
|
|
803
|
+
mergedLegendEntries.push(entry);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Merge tooltip descriptors. Layer 1 marks are appended after layer 0's marks,
|
|
808
|
+
// so their render indices start at layout0.marks.length. The descriptor keys
|
|
809
|
+
// for discrete marks (rect, point, arc) are "type-${index}" where index is the
|
|
810
|
+
// mark's position in the final combined array. Re-key them with the correct offset.
|
|
811
|
+
const l0Count = layout0.marks.length;
|
|
812
|
+
const mergedTooltips = new Map(layout0.tooltipDescriptors);
|
|
813
|
+
for (const [key, value] of layout1.tooltipDescriptors) {
|
|
814
|
+
const match = /^(rect|point|arc)-(\d+)$/.exec(key);
|
|
815
|
+
if (match) {
|
|
816
|
+
const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
|
|
817
|
+
mergedTooltips.set(offsetKey, value);
|
|
818
|
+
} else {
|
|
819
|
+
// Line/area tooltips are keyed by series name, not index -- pass through as-is
|
|
820
|
+
mergedTooltips.set(key, value);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
...layout0,
|
|
826
|
+
axes: {
|
|
827
|
+
x: layout0.axes.x,
|
|
828
|
+
y: layout0.axes.y,
|
|
829
|
+
y2: y2Axis,
|
|
830
|
+
},
|
|
831
|
+
marks: [...adjustedMarks0, ...taggedMarks1],
|
|
832
|
+
legend: {
|
|
833
|
+
...layout0.legend,
|
|
834
|
+
entries: mergedLegendEntries,
|
|
835
|
+
},
|
|
836
|
+
tooltipDescriptors: mergedTooltips,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Ensure a leaf's data covers the full x-domain by adding stub rows for
|
|
842
|
+
* missing x-values. This keeps scales consistent across layers without
|
|
843
|
+
* injecting scale.domain directly.
|
|
844
|
+
*/
|
|
845
|
+
function ensureXDomainCoverage(
|
|
846
|
+
leaf: ChartSpec,
|
|
847
|
+
xField: string | undefined,
|
|
848
|
+
allXValues: Set<unknown>,
|
|
849
|
+
): ChartSpec {
|
|
850
|
+
if (!xField || allXValues.size === 0) return leaf;
|
|
851
|
+
|
|
852
|
+
const existingXValues = new Set<unknown>();
|
|
853
|
+
for (const row of leaf.data) existingXValues.add(row[xField]);
|
|
854
|
+
|
|
855
|
+
const missingRows: DataRow[] = [];
|
|
856
|
+
for (const xVal of allXValues) {
|
|
857
|
+
if (!existingXValues.has(xVal)) {
|
|
858
|
+
missingRows.push({ [xField]: xVal });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (missingRows.length === 0) return leaf;
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
...leaf,
|
|
866
|
+
data: [...leaf.data, ...missingRows],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Compute aligned y-domains for two layers so that zero maps to the same
|
|
872
|
+
* pixel position on both axes. Returns explicit [min, max] domains for each
|
|
873
|
+
* layer, or undefined if alignment isn't applicable (non-quantitative axes,
|
|
874
|
+
* or neither domain spans zero).
|
|
875
|
+
*/
|
|
876
|
+
function alignYDomains(
|
|
877
|
+
leaf0: ChartSpec,
|
|
878
|
+
leaf1: ChartSpec,
|
|
879
|
+
): { domain0: [number, number]; domain1: [number, number] } | undefined {
|
|
880
|
+
const yEnc0 = leaf0.encoding?.y;
|
|
881
|
+
const yEnc1 = leaf1.encoding?.y;
|
|
882
|
+
if (!yEnc0 || !yEnc1) return undefined;
|
|
883
|
+
if (yEnc0.type !== 'quantitative' || yEnc1.type !== 'quantitative') return undefined;
|
|
884
|
+
|
|
885
|
+
// Skip if either layer has an explicit domain already set by the user
|
|
886
|
+
if (yEnc0.scale?.domain || yEnc1.scale?.domain) return undefined;
|
|
887
|
+
|
|
888
|
+
const includeZero0 = yEnc0.scale?.zero !== false;
|
|
889
|
+
const includeZero1 = yEnc1.scale?.zero !== false;
|
|
890
|
+
|
|
891
|
+
const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
|
|
892
|
+
const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
|
|
893
|
+
if (vals0.length === 0 || vals1.length === 0) return undefined;
|
|
894
|
+
|
|
895
|
+
// Compute nice domains for each (mirroring buildLinearScale behavior)
|
|
896
|
+
const niced = (vals: number[], includeZero: boolean): [number, number] => {
|
|
897
|
+
let lo = Math.min(...vals);
|
|
898
|
+
let hi = Math.max(...vals);
|
|
899
|
+
if (includeZero) {
|
|
900
|
+
lo = Math.min(0, lo);
|
|
901
|
+
hi = Math.max(0, hi);
|
|
902
|
+
}
|
|
903
|
+
const s = scaleLinear().domain([lo, hi]);
|
|
904
|
+
s.nice();
|
|
905
|
+
const [dLo, dHi] = s.domain();
|
|
906
|
+
return [dLo, dHi];
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const [min0, max0] = niced(vals0, includeZero0);
|
|
910
|
+
const [min1, max1] = niced(vals1, includeZero1);
|
|
911
|
+
|
|
912
|
+
const span0 = max0 - min0;
|
|
913
|
+
const span1 = max1 - min1;
|
|
914
|
+
if (span0 === 0 || span1 === 0) return undefined;
|
|
915
|
+
|
|
916
|
+
// Zero fraction: how far up from the bottom zero sits (0 = bottom, 1 = top).
|
|
917
|
+
// Only align when BOTH domains naturally contain zero. If one axis is entirely
|
|
918
|
+
// positive or entirely negative (zero is outside the domain), forcing alignment
|
|
919
|
+
// would push the other axis into an unnatural range. In that case, let each
|
|
920
|
+
// axis render its natural domain independently.
|
|
921
|
+
const zf0 = (0 - min0) / span0;
|
|
922
|
+
const zf1 = (0 - min1) / span1;
|
|
923
|
+
|
|
924
|
+
const zeroInDomain0 = zf0 >= -0.001 && zf0 <= 1.001;
|
|
925
|
+
const zeroInDomain1 = zf1 >= -0.001 && zf1 <= 1.001;
|
|
926
|
+
if (!zeroInDomain0 || !zeroInDomain1) return undefined;
|
|
927
|
+
|
|
928
|
+
// If both zeros are at the same position (within tolerance), no adjustment needed
|
|
929
|
+
if (Math.abs(zf0 - zf1) < 0.001) {
|
|
930
|
+
return { domain0: [min0, max0], domain1: [min1, max1] };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Align by extending domains so zero sits at the same proportional position.
|
|
934
|
+
// Keep the niced boundaries on the side that doesn't need extending, and
|
|
935
|
+
// compute the exact extended boundary (no re-nicing) so zero stays locked.
|
|
936
|
+
const targetZf = Math.max(zf0, zf1);
|
|
937
|
+
|
|
938
|
+
const align = (dMin: number, dMax: number, currentZf: number): [number, number] => {
|
|
939
|
+
if (Math.abs(currentZf - targetZf) < 0.001) return [dMin, dMax];
|
|
940
|
+
|
|
941
|
+
if (targetZf > currentZf) {
|
|
942
|
+
// Need more negative range: newMin = -(targetZf / (1 - targetZf)) * dMax
|
|
943
|
+
const newMin = -(targetZf / (1 - targetZf)) * dMax;
|
|
944
|
+
return [newMin, dMax];
|
|
945
|
+
}
|
|
946
|
+
// Need more positive range: newMax = -dMin * (1 - targetZf) / targetZf
|
|
947
|
+
const newMax = (-dMin * (1 - targetZf)) / targetZf;
|
|
948
|
+
return [dMin, newMax];
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const domain0 = align(min0, max0, zf0);
|
|
952
|
+
const domain1 = align(min1, max1, zf1);
|
|
953
|
+
|
|
954
|
+
return { domain0, domain1 };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Inject an explicit y-scale domain override into a leaf spec.
|
|
959
|
+
*/
|
|
960
|
+
function withYDomain(leaf: ChartSpec, domain: [number, number]): ChartSpec {
|
|
961
|
+
if (!leaf.encoding?.y) return leaf;
|
|
962
|
+
return {
|
|
963
|
+
...leaf,
|
|
964
|
+
encoding: {
|
|
965
|
+
...leaf.encoding,
|
|
966
|
+
y: {
|
|
967
|
+
...leaf.encoding.y,
|
|
968
|
+
scale: {
|
|
969
|
+
...leaf.encoding.y.scale,
|
|
970
|
+
domain,
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
517
977
|
/**
|
|
518
978
|
* Build the primary ChartSpec from all leaves for shared compilation.
|
|
519
979
|
* Unions all data rows across layers so scales see the full domain.
|
|
@@ -528,6 +988,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
|
|
|
528
988
|
data: allData,
|
|
529
989
|
// Layer-level chrome overrides leaf chrome
|
|
530
990
|
chrome: layerSpec.chrome ?? leaves[0].chrome,
|
|
991
|
+
annotations: layerSpec.annotations ?? leaves[0].annotations,
|
|
531
992
|
labels: layerSpec.labels ?? leaves[0].labels,
|
|
532
993
|
legend: layerSpec.legend ?? leaves[0].legend,
|
|
533
994
|
responsive: layerSpec.responsive ?? leaves[0].responsive,
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -211,10 +211,11 @@ export function categoricalTicks(
|
|
|
211
211
|
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
212
212
|
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
213
213
|
|
|
214
|
-
// Band scales
|
|
215
|
-
//
|
|
214
|
+
// Band scales show all labels at full density but thin at reduced/minimal
|
|
215
|
+
// to prevent overlap on narrow containers (e.g. 17 bars on mobile).
|
|
216
216
|
let selectedValues = domain;
|
|
217
|
-
|
|
217
|
+
const shouldThinBand = resolvedScale.type === 'band' && (explicitTickCount || density !== 'full');
|
|
218
|
+
if ((resolvedScale.type !== 'band' || shouldThinBand) && domain.length > maxTicks) {
|
|
218
219
|
const step = Math.ceil(domain.length / maxTicks);
|
|
219
220
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
220
221
|
}
|
package/src/layout/axes.ts
CHANGED
|
@@ -314,13 +314,14 @@ export function computeAxes(
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
const axisTitle = axisConfig?.title;
|
|
317
|
+
const xLabelColor = axisConfig?.labelColor;
|
|
317
318
|
|
|
318
319
|
result.x = {
|
|
319
320
|
ticks,
|
|
320
321
|
gridlines: axisConfig?.grid ? gridlines : [],
|
|
321
322
|
label: axisTitle,
|
|
322
|
-
labelStyle: axisLabelStyle,
|
|
323
|
-
tickLabelStyle,
|
|
323
|
+
labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
|
|
324
|
+
tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
|
|
324
325
|
tickAngle,
|
|
325
326
|
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
326
327
|
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
|
|
@@ -387,14 +388,15 @@ export function computeAxes(
|
|
|
387
388
|
|
|
388
389
|
const axisTitle = axisConfig?.title;
|
|
389
390
|
const tickAngle = axisConfig?.labelAngle;
|
|
391
|
+
const yLabelColor = axisConfig?.labelColor;
|
|
390
392
|
|
|
391
393
|
result.y = {
|
|
392
394
|
ticks,
|
|
393
395
|
// Y-axis gridlines are shown by default (standard editorial practice)
|
|
394
396
|
gridlines,
|
|
395
397
|
label: axisTitle,
|
|
396
|
-
labelStyle: axisLabelStyle,
|
|
397
|
-
tickLabelStyle,
|
|
398
|
+
labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
|
|
399
|
+
tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
|
|
398
400
|
tickAngle,
|
|
399
401
|
start: { x: chartArea.x, y: chartArea.y },
|
|
400
402
|
end: { x: chartArea.x, y: chartArea.y + chartArea.height },
|