@opendata-ai/openchart-engine 6.28.5 → 7.0.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/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12297 -11338
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
- package/src/tables/__tests__/heatmap.test.ts +4 -27
- package/src/tables/heatmap.ts +6 -2
package/src/compile.ts
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
ChartSpec,
|
|
20
20
|
CompileOptions,
|
|
21
21
|
CompileTableOptions,
|
|
22
|
-
DataRow,
|
|
23
22
|
Encoding,
|
|
24
23
|
EncodingChannel,
|
|
25
24
|
LayerSpec,
|
|
@@ -33,24 +32,19 @@ import type {
|
|
|
33
32
|
Transform,
|
|
34
33
|
} from '@opendata-ai/openchart-core';
|
|
35
34
|
import {
|
|
36
|
-
AXIS_TITLE_TRAILING_PAD,
|
|
37
35
|
adaptTheme,
|
|
38
|
-
BREAKPOINT_COMPACT_MAX,
|
|
39
36
|
computeLabelBounds,
|
|
40
37
|
estimateTextWidth,
|
|
41
38
|
generateAltText,
|
|
42
39
|
generateDataTable,
|
|
43
|
-
getAxisTitleOffset,
|
|
44
40
|
getBreakpoint,
|
|
45
41
|
getHeightClass,
|
|
46
42
|
getLayoutStrategy,
|
|
47
43
|
resolveTheme,
|
|
48
|
-
TICK_LABEL_OFFSET,
|
|
49
44
|
} from '@opendata-ai/openchart-core';
|
|
50
45
|
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';
|
|
53
46
|
import { computeAnnotations } from './annotations/compute';
|
|
47
|
+
import { computeEndpointLabels } from './endpoint-labels/compute';
|
|
54
48
|
// Side-effect import: registers all built-in chart renderers with the
|
|
55
49
|
// registry on module load. Tests that clear the registry can import
|
|
56
50
|
// `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
|
|
@@ -64,9 +58,16 @@ import {
|
|
|
64
58
|
import { getChartRenderer } from './charts/registry';
|
|
65
59
|
import { applyColorScaleRange } from './compile/color-scale-range';
|
|
66
60
|
import { filterClippedDomains } from './compile/data-clip';
|
|
61
|
+
import { compileLayer as compileLayerImpl } from './compile/layer';
|
|
67
62
|
import { computeWatermarkObstacle } from './compile/watermark-obstacle';
|
|
68
63
|
import { resolveAnimation } from './compiler/animation';
|
|
69
|
-
import { compile as compileSpec
|
|
64
|
+
import { compile as compileSpec } from './compiler/index';
|
|
65
|
+
import {
|
|
66
|
+
buildSparklineAreaGradient,
|
|
67
|
+
computeTrendFromData,
|
|
68
|
+
hasExplicitColor,
|
|
69
|
+
trendColor,
|
|
70
|
+
} from './compiler/sparkline-defaults';
|
|
70
71
|
import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
|
|
71
72
|
import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
|
|
72
73
|
import type { GraphCompilation } from './graphs/types';
|
|
@@ -160,6 +161,63 @@ export function expandEncodingSugar(spec: Record<string, unknown>): Record<strin
|
|
|
160
161
|
// Chart compilation
|
|
161
162
|
// ---------------------------------------------------------------------------
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Inject sparkline-mode visual defaults that depend on the resolved theme.
|
|
166
|
+
*
|
|
167
|
+
* Trend-aware color (positive/negative/neutral), default endpoint dot,
|
|
168
|
+
* default area gradient, and default pill cornerRadius for bars. Each
|
|
169
|
+
* default backs off when the user has set the corresponding markDef
|
|
170
|
+
* field. Bar marks do NOT get trend coloring — the design intent is a
|
|
171
|
+
* single palette color across all bars.
|
|
172
|
+
*/
|
|
173
|
+
function applySparklineDefaults(
|
|
174
|
+
spec: NormalizedChartSpec,
|
|
175
|
+
theme: ResolvedTheme,
|
|
176
|
+
): NormalizedChartSpec {
|
|
177
|
+
const markType = spec.markType;
|
|
178
|
+
const isLineFamily = markType === 'line' || markType === 'area';
|
|
179
|
+
const isBar = markType === 'bar';
|
|
180
|
+
if (!isLineFamily && !isBar) return spec;
|
|
181
|
+
|
|
182
|
+
const yField = spec.encoding.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
|
|
183
|
+
const trend = computeTrendFromData(spec.data, yField);
|
|
184
|
+
const color = trendColor(trend, theme);
|
|
185
|
+
|
|
186
|
+
const encodingHasColor =
|
|
187
|
+
!!spec.encoding.color && (spec.encoding.color as { field?: unknown }).field !== undefined;
|
|
188
|
+
const explicit = hasExplicitColor(spec.markDef, encodingHasColor);
|
|
189
|
+
|
|
190
|
+
const newMarkDef = { ...spec.markDef };
|
|
191
|
+
|
|
192
|
+
if (isLineFamily) {
|
|
193
|
+
if (!explicit.stroke) newMarkDef.stroke = color;
|
|
194
|
+
if (markType === 'area' && !explicit.fill) {
|
|
195
|
+
newMarkDef.fill = buildSparklineAreaGradient(color);
|
|
196
|
+
}
|
|
197
|
+
// Endpoint dot is wired in the line compute path. We skip the default
|
|
198
|
+
// when the encoding has a color field, because the multi-series area
|
|
199
|
+
// path (linesFromAreas()) ignores markDef.point — the dot would be set
|
|
200
|
+
// on the spec but never rendered, and the line compute path's dot logic
|
|
201
|
+
// is per-series, not "one dot for the chart."
|
|
202
|
+
if (newMarkDef.point === undefined && !encodingHasColor) {
|
|
203
|
+
newMarkDef.point = 'last';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isBar) {
|
|
208
|
+
// Pill cornerRadius is safe on stacked bars too — the column compute
|
|
209
|
+
// path applies the radius only to the topmost segment via
|
|
210
|
+
// `cornerRadiusSides`, leaving the seams between segments square so
|
|
211
|
+
// they stay flush. Single-series bars get all four corners rounded
|
|
212
|
+
// since there's nothing below them to align against.
|
|
213
|
+
if (newMarkDef.cornerRadius === undefined) {
|
|
214
|
+
newMarkDef.cornerRadius = 'pill';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { ...spec, markDef: newMarkDef };
|
|
219
|
+
}
|
|
220
|
+
|
|
163
221
|
/**
|
|
164
222
|
* Compile a chart spec into a ChartLayout.
|
|
165
223
|
*
|
|
@@ -258,8 +316,19 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
258
316
|
const userExplicit = {
|
|
259
317
|
chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
|
|
260
318
|
legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
|
|
261
|
-
|
|
262
|
-
|
|
319
|
+
endpointLabels:
|
|
320
|
+
rawSpec.endpointLabels !== undefined ||
|
|
321
|
+
(bpForExplicit as Record<string, unknown> | undefined)?.endpointLabels !== undefined,
|
|
322
|
+
// Axis explicitness tracks "user opted IN to render the axis" — `axis: false`
|
|
323
|
+
// is an explicit opt-OUT and must not flip these flags on. Sparkline mode
|
|
324
|
+
// reads them to decide whether to reserve axis space; treating `false` as
|
|
325
|
+
// explicit would leave a phantom gutter for an axis that won't render.
|
|
326
|
+
xAxis:
|
|
327
|
+
(rawEncoding?.x?.axis !== undefined && rawEncoding.x.axis !== false) ||
|
|
328
|
+
(bpEncoding?.x?.axis !== undefined && bpEncoding.x.axis !== false),
|
|
329
|
+
yAxis:
|
|
330
|
+
(rawEncoding?.y?.axis !== undefined && rawEncoding.y.axis !== false) ||
|
|
331
|
+
(bpEncoding?.y?.axis !== undefined && bpEncoding.y.axis !== false),
|
|
263
332
|
labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
|
|
264
333
|
animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
|
|
265
334
|
watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
|
|
@@ -430,15 +499,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
430
499
|
}
|
|
431
500
|
const resolvedAnimation = resolveAnimation(rawAnimationSpec);
|
|
432
501
|
|
|
433
|
-
// Crosshair: explicit user value at any level wins.
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
502
|
+
// Crosshair: explicit user value at any level wins. Default is ON for line
|
|
503
|
+
// and area marks in full mode (the hover guideline + per-series tooltip is
|
|
504
|
+
// the standard interaction pattern for time-series charts). Sparkline mode
|
|
505
|
+
// defaults off. The value is plumbed through ChartLayout so the renderer
|
|
506
|
+
// doesn't need to re-inspect the raw spec.
|
|
437
507
|
const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
: rawCrosshair === true;
|
|
508
|
+
const isLineOrAreaMark = chartSpec.markType === 'line' || chartSpec.markType === 'area';
|
|
509
|
+
const crosshairDefault = chartSpec.display === 'sparkline' ? false : isLineOrAreaMark;
|
|
510
|
+
const crosshair = chartSpec.userExplicit.crosshair ? rawCrosshair === true : crosshairDefault;
|
|
442
511
|
|
|
443
512
|
// Watermark default-off in sparkline mode unless user-explicit.
|
|
444
513
|
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
|
|
@@ -455,6 +524,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
455
524
|
theme = adaptTheme(theme);
|
|
456
525
|
}
|
|
457
526
|
|
|
527
|
+
// Sparkline mode: inject smart defaults that depend on the resolved theme
|
|
528
|
+
// (trend-aware color, area gradient, default endpoint dot, pill cornerRadius
|
|
529
|
+
// for bars). Each default backs off when the user has set the corresponding
|
|
530
|
+
// field explicitly. Must run AFTER theme resolution (color depends on theme)
|
|
531
|
+
// and BEFORE computeDimensions (which reads markDef.point for sparkline padding).
|
|
532
|
+
if (chartSpec.display === 'sparkline') {
|
|
533
|
+
chartSpec = applySparklineDefaults(chartSpec, theme);
|
|
534
|
+
}
|
|
535
|
+
|
|
458
536
|
// INVARIANT 1 — double legend pass: preliminaryArea → computeDimensions → legendArea → final
|
|
459
537
|
// legend. Breaks a dims/legend dependency cycle. Do not collapse into one call.
|
|
460
538
|
// Compute legend first (needs to reserve space)
|
|
@@ -483,7 +561,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
483
561
|
legendArea.height += legendLayout.bounds.height + gap;
|
|
484
562
|
break;
|
|
485
563
|
case 'bottom':
|
|
486
|
-
|
|
564
|
+
// Bottom legend sits below the x-axis tick row, not over it. Expand
|
|
565
|
+
// legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
|
|
566
|
+
// legend lands beneath the axis. Mirrors dimensions.ts which reserved
|
|
567
|
+
// the same xAxisHeight in margins.bottom.
|
|
568
|
+
legendArea.height += legendLayout.bounds.height + gap + dims.xAxisHeight;
|
|
487
569
|
break;
|
|
488
570
|
case 'right':
|
|
489
571
|
case 'bottom-right':
|
|
@@ -512,10 +594,107 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
512
594
|
renderData = filterClippedDomains(renderData, chartSpec.encoding);
|
|
513
595
|
|
|
514
596
|
// Build a filtered spec for scales and marks, keeping all other properties intact
|
|
515
|
-
|
|
597
|
+
let renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
|
|
598
|
+
|
|
599
|
+
// Lock the color scale domain to the unfiltered series list so palette
|
|
600
|
+
// assignments stay stable across legend toggles. Without this, hiding a
|
|
601
|
+
// series shrinks the ordinal scale's domain and shifts every remaining
|
|
602
|
+
// series down one palette index — the visible lines would mismatch the
|
|
603
|
+
// legend swatches that still represent the original assignment.
|
|
604
|
+
// Skipped when the user already supplied an explicit domain.
|
|
605
|
+
const colorEnc = chartSpec.encoding.color;
|
|
606
|
+
if (
|
|
607
|
+
chartSpec.hiddenSeries.length > 0 &&
|
|
608
|
+
colorEnc &&
|
|
609
|
+
'field' in colorEnc &&
|
|
610
|
+
colorEnc.type !== 'quantitative' &&
|
|
611
|
+
colorEnc.scale?.domain == null
|
|
612
|
+
) {
|
|
613
|
+
const colorField = colorEnc.field;
|
|
614
|
+
const stableDomain = Array.from(new Set(chartSpec.data.map((row) => String(row[colorField]))));
|
|
615
|
+
renderSpec = {
|
|
616
|
+
...renderSpec,
|
|
617
|
+
encoding: {
|
|
618
|
+
...renderSpec.encoding,
|
|
619
|
+
color: {
|
|
620
|
+
...colorEnc,
|
|
621
|
+
scale: { ...(colorEnc.scale ?? {}), domain: stableDomain },
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Inline y-axis labels render at chartArea.x with text-anchor=start. Without
|
|
628
|
+
// an inset the leftmost data point on a line/area would clip through the
|
|
629
|
+
// label glyphs (since the line begins at chartArea.x too). Inset the x-scale
|
|
630
|
+
// range start by the widest tick label width so the data starts to the right
|
|
631
|
+
// of the labels. Axes/gridlines still span the full chart area, so inline
|
|
632
|
+
// labels stay flush left where the design intends.
|
|
633
|
+
const yEnc = renderSpec.encoding?.y;
|
|
634
|
+
const yAxisCfgInline =
|
|
635
|
+
typeof yEnc?.axis === 'object' && yEnc.axis !== null
|
|
636
|
+
? (yEnc.axis as Record<string, unknown>)
|
|
637
|
+
: undefined;
|
|
638
|
+
const yIsContinuous = yEnc?.type === 'quantitative' || yEnc?.type === 'temporal';
|
|
639
|
+
const isLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
|
|
640
|
+
const yInlineExplicit = yAxisCfgInline?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
641
|
+
// Sparkline mode hides the y-axis by default, so the inline-label inset
|
|
642
|
+
// would shrink the data range without any labels actually being drawn —
|
|
643
|
+
// the line ends up starting ~30px from the left edge for no visible
|
|
644
|
+
// reason. Skip the inset unless the user explicitly opted into the
|
|
645
|
+
// y-axis (in which case the labels do render and the inset is needed).
|
|
646
|
+
const sparklineSuppressesYAxis =
|
|
647
|
+
chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
|
|
648
|
+
const yIsInline =
|
|
649
|
+
!sparklineSuppressesYAxis &&
|
|
650
|
+
(yInlineExplicit === 'inline' ||
|
|
651
|
+
(yInlineExplicit === undefined &&
|
|
652
|
+
isLineOrArea &&
|
|
653
|
+
yIsContinuous &&
|
|
654
|
+
yAxisCfgInline?.orient !== 'right'));
|
|
655
|
+
let scaleArea = chartArea;
|
|
656
|
+
if (yIsInline && yEnc && yIsContinuous && yEnc.axis !== false) {
|
|
657
|
+
const yField = yEnc.field;
|
|
658
|
+
const yFmt = yAxisCfgInline?.format as string | undefined;
|
|
659
|
+
let maxAbsVal = 0;
|
|
660
|
+
for (const row of renderSpec.data) {
|
|
661
|
+
const v = Number(row[yField]);
|
|
662
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
663
|
+
}
|
|
664
|
+
let sample: string;
|
|
665
|
+
if (yFmt) {
|
|
666
|
+
try {
|
|
667
|
+
sample = d3Format(yFmt)(maxAbsVal);
|
|
668
|
+
} catch {
|
|
669
|
+
sample = String(maxAbsVal);
|
|
670
|
+
}
|
|
671
|
+
} else if (maxAbsVal >= 1_000_000_000) sample = '1.5B';
|
|
672
|
+
else if (maxAbsVal >= 1_000_000) sample = '1.5M';
|
|
673
|
+
else if (maxAbsVal >= 1_000) sample = '1.5K';
|
|
674
|
+
else if (maxAbsVal >= 100) sample = '100';
|
|
675
|
+
else if (maxAbsVal >= 10) sample = '10';
|
|
676
|
+
else sample = '0.0';
|
|
677
|
+
const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
678
|
+
const labelEst = negPrefix + sample;
|
|
679
|
+
const labelWidth = estimateTextWidth(
|
|
680
|
+
labelEst,
|
|
681
|
+
theme.fonts.sizes.axisTick,
|
|
682
|
+
theme.fonts.weights.normal,
|
|
683
|
+
);
|
|
684
|
+
const INLINE_LABEL_BREATHING_ROOM = 8;
|
|
685
|
+
const inset = Math.ceil(labelWidth + INLINE_LABEL_BREATHING_ROOM);
|
|
686
|
+
if (inset > 0 && inset < chartArea.width) {
|
|
687
|
+
scaleArea = {
|
|
688
|
+
x: chartArea.x + inset,
|
|
689
|
+
y: chartArea.y,
|
|
690
|
+
width: chartArea.width - inset,
|
|
691
|
+
height: chartArea.height,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
516
695
|
|
|
517
696
|
// Compute scales
|
|
518
|
-
const scales = computeScales(renderSpec,
|
|
697
|
+
const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
|
|
519
698
|
|
|
520
699
|
// Update color scale to use theme palette (only when user hasn't provided an explicit range)
|
|
521
700
|
applyColorScaleRange(scales, renderSpec.encoding, theme);
|
|
@@ -541,6 +720,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
541
720
|
encoding: renderSpec.encoding as Encoding,
|
|
542
721
|
skipX,
|
|
543
722
|
skipY,
|
|
723
|
+
markType: chartSpec.markType,
|
|
544
724
|
});
|
|
545
725
|
|
|
546
726
|
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
@@ -558,6 +738,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
558
738
|
const renderer = getChartRenderer(rendererKey);
|
|
559
739
|
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
560
740
|
|
|
741
|
+
// Compute the right-side endpoint labels column for multi-series line/area
|
|
742
|
+
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
743
|
+
// dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
|
|
744
|
+
const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
|
|
745
|
+
|
|
561
746
|
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
562
747
|
const obstacles: Rect[] = [];
|
|
563
748
|
if (finalLegend.bounds.width > 0) {
|
|
@@ -595,7 +780,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
595
780
|
data: chartSpec.data,
|
|
596
781
|
encoding: chartSpec.encoding,
|
|
597
782
|
chrome: chartSpec.chrome,
|
|
598
|
-
},
|
|
783
|
+
} as ChartSpec,
|
|
599
784
|
chartSpec.data,
|
|
600
785
|
);
|
|
601
786
|
const dataTableFallback = generateDataTable(
|
|
@@ -603,7 +788,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
603
788
|
mark: chartSpec.markType,
|
|
604
789
|
data: chartSpec.data,
|
|
605
790
|
encoding: chartSpec.encoding,
|
|
606
|
-
},
|
|
791
|
+
} as ChartSpec,
|
|
607
792
|
chartSpec.data,
|
|
608
793
|
);
|
|
609
794
|
|
|
@@ -613,6 +798,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
613
798
|
return {
|
|
614
799
|
area: chartArea,
|
|
615
800
|
chrome: dims.chrome,
|
|
801
|
+
metrics: dims.metrics,
|
|
616
802
|
axes: {
|
|
617
803
|
x: axes.x,
|
|
618
804
|
y: axes.y,
|
|
@@ -620,6 +806,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
620
806
|
marks,
|
|
621
807
|
annotations,
|
|
622
808
|
legend: finalLegend,
|
|
809
|
+
...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
|
|
623
810
|
tooltipDescriptors,
|
|
624
811
|
a11y: {
|
|
625
812
|
altText,
|
|
@@ -644,570 +831,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
644
831
|
// Layer compilation
|
|
645
832
|
// ---------------------------------------------------------------------------
|
|
646
833
|
|
|
647
|
-
/**
|
|
648
|
-
* Compile a LayerSpec into a single ChartLayout.
|
|
649
|
-
*
|
|
650
|
-
* Flattens nested layers, merges inherited data/encoding/transforms,
|
|
651
|
-
* compiles each leaf layer independently, unions scale domains (shared
|
|
652
|
-
* by default), and concatenates marks in layer order.
|
|
653
|
-
*
|
|
654
|
-
* @param spec - A LayerSpec with child layers.
|
|
655
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
656
|
-
* @returns A single ChartLayout with combined marks from all layers.
|
|
657
|
-
*/
|
|
658
834
|
export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
|
|
659
|
-
|
|
660
|
-
const leaves = flattenLayers(spec);
|
|
661
|
-
|
|
662
|
-
if (leaves.length === 0) {
|
|
663
|
-
throw new Error('LayerSpec has no leaf chart specs after flattening');
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// If there's only one layer, just compile it directly
|
|
667
|
-
if (leaves.length === 1) {
|
|
668
|
-
const singleSpec = buildPrimarySpec(leaves, spec);
|
|
669
|
-
return compileChart(singleSpec, options);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Branch: independent y-scales produce dual-axis layout
|
|
673
|
-
if (spec.resolve?.scale?.y === 'independent') {
|
|
674
|
-
return compileLayerIndependent(leaves, spec, options);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Shared scales (default): union data and compile together
|
|
678
|
-
const primarySpec = buildPrimarySpec(leaves, spec);
|
|
679
|
-
const primaryLayout = compileChart(primarySpec, options);
|
|
680
|
-
|
|
681
|
-
const allMarks: Mark[] = [];
|
|
682
|
-
const seenLabels = new Set<string>();
|
|
683
|
-
const pLegend = primaryLayout.legend;
|
|
684
|
-
const mergedLegendEntries = 'entries' in pLegend ? [...pLegend.entries] : [];
|
|
685
|
-
for (const entry of mergedLegendEntries) {
|
|
686
|
-
seenLabels.add(entry.label);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Sort leaves by zIndex for render order while preserving original indices
|
|
690
|
-
// for axis assignment. Default zIndex is the array position.
|
|
691
|
-
const indexedLeaves = leaves.map((leaf, i) => ({
|
|
692
|
-
leaf,
|
|
693
|
-
zIndex: (leaf as ChartSpec).zIndex ?? i,
|
|
694
|
-
}));
|
|
695
|
-
indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
|
|
696
|
-
|
|
697
|
-
for (const { leaf } of indexedLeaves) {
|
|
698
|
-
const leafLayout = compileChart(leaf as unknown, options);
|
|
699
|
-
|
|
700
|
-
allMarks.push(...leafLayout.marks);
|
|
701
|
-
|
|
702
|
-
const leafLeg = leafLayout.legend;
|
|
703
|
-
if ('entries' in leafLeg) {
|
|
704
|
-
for (const entry of leafLeg.entries) {
|
|
705
|
-
if (!seenLabels.has(entry.label)) {
|
|
706
|
-
seenLabels.add(entry.label);
|
|
707
|
-
mergedLegendEntries.push(entry);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
return {
|
|
714
|
-
...primaryLayout,
|
|
715
|
-
marks: allMarks,
|
|
716
|
-
legend: {
|
|
717
|
-
...primaryLayout.legend,
|
|
718
|
-
...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
|
|
719
|
-
} as typeof primaryLayout.legend,
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// ---------------------------------------------------------------------------
|
|
724
|
-
// Independent y-scale compilation (dual-axis charts)
|
|
725
|
-
// ---------------------------------------------------------------------------
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Estimate the pixel width needed for a right-side y-axis based on data values.
|
|
729
|
-
* Mirrors the left-margin estimation logic in computeDimensions.
|
|
730
|
-
*/
|
|
731
|
-
function estimateYAxisLabelWidth(
|
|
732
|
-
data: DataRow[],
|
|
733
|
-
encoding: Encoding | undefined,
|
|
734
|
-
baseFontSize: number,
|
|
735
|
-
): number {
|
|
736
|
-
if (!encoding?.y) return 40;
|
|
737
|
-
const yEnc = encoding.y;
|
|
738
|
-
const yField = yEnc.field;
|
|
739
|
-
if (!yField) return 40;
|
|
740
|
-
|
|
741
|
-
const yType = yEnc.type;
|
|
742
|
-
if (yType === 'nominal' || yType === 'ordinal') {
|
|
743
|
-
let maxWidth = 0;
|
|
744
|
-
for (const row of data) {
|
|
745
|
-
const label = String(row[yField] ?? '');
|
|
746
|
-
const w = estimateTextWidth(label, baseFontSize, 400);
|
|
747
|
-
if (w > maxWidth) maxWidth = w;
|
|
748
|
-
}
|
|
749
|
-
return maxWidth > 0 ? maxWidth + 10 : 40;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Quantitative/temporal: estimate from the largest value
|
|
753
|
-
const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
|
|
754
|
-
| string
|
|
755
|
-
| undefined;
|
|
756
|
-
let maxAbsVal = 0;
|
|
757
|
-
for (const row of data) {
|
|
758
|
-
const v = Number(row[yField]);
|
|
759
|
-
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
760
|
-
}
|
|
761
|
-
let sampleLabel: string;
|
|
762
|
-
if (yAxisFormat) {
|
|
763
|
-
try {
|
|
764
|
-
const fmt = d3Format(yAxisFormat);
|
|
765
|
-
sampleLabel = fmt(maxAbsVal);
|
|
766
|
-
} catch {
|
|
767
|
-
sampleLabel = String(maxAbsVal);
|
|
768
|
-
}
|
|
769
|
-
} else {
|
|
770
|
-
if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
|
|
771
|
-
else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
|
|
772
|
-
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
|
|
773
|
-
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
774
|
-
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
775
|
-
else sampleLabel = '0.0';
|
|
776
|
-
}
|
|
777
|
-
const hasNeg = data.some((r) => Number(r[yField]) < 0);
|
|
778
|
-
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
|
|
779
|
-
return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Compile a LayerSpec with independent y-scales (dual-axis chart).
|
|
784
|
-
*
|
|
785
|
-
* Layer 0 gets the left y-axis, layer 1 gets the right y-axis.
|
|
786
|
-
* Both layers share the x-axis. Limited to exactly 2 layers.
|
|
787
|
-
*/
|
|
788
|
-
function compileLayerIndependent(
|
|
789
|
-
leaves: ChartSpec[],
|
|
790
|
-
layerSpec: LayerSpec,
|
|
791
|
-
options: CompileOptions,
|
|
792
|
-
): ChartLayout {
|
|
793
|
-
if (leaves.length > 2) {
|
|
794
|
-
throw new Error(
|
|
795
|
-
'Independent y-scales support at most 2 layers (left and right y-axis). ' +
|
|
796
|
-
`Got ${leaves.length} layers.`,
|
|
797
|
-
);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const leaf0 = leaves[0];
|
|
801
|
-
const leaf1 = leaves[1];
|
|
802
|
-
|
|
803
|
-
// Validate x-field types are compatible
|
|
804
|
-
const xType0 = leaf0.encoding?.x?.type;
|
|
805
|
-
const xType1 = leaf1.encoding?.x?.type;
|
|
806
|
-
if (xType0 && xType1 && xType0 !== xType1) {
|
|
807
|
-
throw new Error(
|
|
808
|
-
`Dual-axis charts require matching x-field types across layers. ` +
|
|
809
|
-
`Layer 0 has '${xType0}', layer 1 has '${xType1}'.`,
|
|
810
|
-
);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Estimate right-axis label width to reserve margin space.
|
|
814
|
-
// Tick labels sit at chartEdge+6 and extend rightward by their width.
|
|
815
|
-
// The rotated title sits at chartEdge+45 and extends by half the font height.
|
|
816
|
-
// These overlap spatially, so use max (not sum) to mirror the left-margin pattern.
|
|
817
|
-
const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
|
|
818
|
-
const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
|
|
819
|
-
const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
|
|
820
|
-
const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
|
|
821
|
-
const hasRightAxisTitle = !!yAxisConfig?.title;
|
|
822
|
-
const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
|
|
823
|
-
const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
|
|
824
|
-
const axisTitleOffset = getAxisTitleOffset(options.width);
|
|
825
|
-
const halfGlyph = Math.ceil(bodyFontSize / 2);
|
|
826
|
-
const titleExtent = hasRightAxisTitle
|
|
827
|
-
? axisTitleOffset +
|
|
828
|
-
halfGlyph +
|
|
829
|
-
(options.width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD)
|
|
830
|
-
: 0;
|
|
831
|
-
const rightReserve = Math.max(tickExtent, titleExtent);
|
|
832
|
-
|
|
833
|
-
const optionsWithReserve: CompileOptions = {
|
|
834
|
-
...options,
|
|
835
|
-
rightAxisReserve: rightReserve,
|
|
836
|
-
};
|
|
837
|
-
|
|
838
|
-
// Union x-data so both layers see the full x-domain.
|
|
839
|
-
// Each layer keeps its own y-data for independent y-scales.
|
|
840
|
-
const xField0 = leaf0.encoding?.x?.field;
|
|
841
|
-
const xField1 = leaf1.encoding?.x?.field;
|
|
842
|
-
const unionXValues = new Set<unknown>();
|
|
843
|
-
if (xField0) for (const row of leaf0.data) unionXValues.add(row[xField0]);
|
|
844
|
-
if (xField1) for (const row of leaf1.data) unionXValues.add(row[xField1]);
|
|
845
|
-
|
|
846
|
-
// Add missing x-values from leaf1 into leaf0's data as stub rows,
|
|
847
|
-
// and vice versa, so both scales see the full x-domain.
|
|
848
|
-
let leaf0WithUnionX = ensureXDomainCoverage(leaf0, xField0, unionXValues);
|
|
849
|
-
let leaf1WithUnionX = ensureXDomainCoverage(leaf1, xField1, unionXValues);
|
|
850
|
-
|
|
851
|
-
// Align y-domains so zero maps to the same pixel position on both axes
|
|
852
|
-
const aligned = alignYDomains(leaf0WithUnionX, leaf1WithUnionX);
|
|
853
|
-
if (aligned) {
|
|
854
|
-
leaf0WithUnionX = withYDomain(leaf0WithUnionX, aligned.domain0);
|
|
855
|
-
leaf1WithUnionX = withYDomain(leaf1WithUnionX, aligned.domain1);
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Compile layer 0 as the primary layout (chrome, x-axis, left y-axis)
|
|
859
|
-
const primary0 = buildPrimarySpec([leaf0WithUnionX], layerSpec);
|
|
860
|
-
const layout0 = compileChart(primary0, optionsWithReserve);
|
|
861
|
-
|
|
862
|
-
// Compile layer 1 independently for its own y-axis and marks.
|
|
863
|
-
// Keep chrome identical to layer 0 so both compile against the same chart area dimensions.
|
|
864
|
-
// layout1's chrome is never rendered -- we spread layout0 into the final return value.
|
|
865
|
-
const primary1 = buildPrimarySpec([leaf1WithUnionX], layerSpec);
|
|
866
|
-
primary1.annotations = [];
|
|
867
|
-
const layout1 = compileChart(primary1, optionsWithReserve);
|
|
868
|
-
|
|
869
|
-
// Extract layer 1's y-axis, reposition it to the right side
|
|
870
|
-
const y2Axis = layout1.axes.y
|
|
871
|
-
? {
|
|
872
|
-
...layout1.axes.y,
|
|
873
|
-
orient: 'right' as const,
|
|
874
|
-
gridlines: [], // Only left y-axis produces gridlines
|
|
875
|
-
start: {
|
|
876
|
-
x: layout0.area.x + layout0.area.width,
|
|
877
|
-
y: layout0.area.y,
|
|
878
|
-
},
|
|
879
|
-
end: {
|
|
880
|
-
x: layout0.area.x + layout0.area.width,
|
|
881
|
-
y: layout0.area.y + layout0.area.height,
|
|
882
|
-
},
|
|
883
|
-
}
|
|
884
|
-
: undefined;
|
|
885
|
-
|
|
886
|
-
// Build a per-category x-position map from whichever layer uses a band scale (bars).
|
|
887
|
-
// Band-scale tick positions are band centers -- the canonical x positions that both
|
|
888
|
-
// layers should align to. Line/area marks use a point scale and land at different pixels.
|
|
889
|
-
// We remap line/area mark x-coordinates by looking up each data row's x-field value
|
|
890
|
-
// in the band-center map, replacing point-scale positions with exact band centers.
|
|
891
|
-
const layer0HasBars = layout0.marks.some((m) => m.type === 'rect');
|
|
892
|
-
const layer1HasBars = layout1.marks.some((m) => m.type === 'rect');
|
|
893
|
-
|
|
894
|
-
// Build category → band-center-pixel map from the bar layer's x-axis ticks
|
|
895
|
-
const bandCenterByCategory = new Map<string, number>();
|
|
896
|
-
if (layer0HasBars && layout0.axes.x?.ticks) {
|
|
897
|
-
for (const tick of layout0.axes.x.ticks) {
|
|
898
|
-
bandCenterByCategory.set(String(tick.label), tick.position);
|
|
899
|
-
}
|
|
900
|
-
} else if (layer1HasBars && layout1.axes.x?.ticks) {
|
|
901
|
-
for (const tick of layout1.axes.x.ticks) {
|
|
902
|
-
bandCenterByCategory.set(String(tick.label), tick.position);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// Remap line/area/point mark x-coordinates to band centers using data rows.
|
|
907
|
-
// The SVG path strings are kept as-is (the smooth curve is close enough to correct
|
|
908
|
-
// after a small x-shift). Only the discrete coordinate arrays and dot positions
|
|
909
|
-
// are remapped so tooltips and point markers land on bar centers.
|
|
910
|
-
const remapMarkX = (xField: string | undefined, mark: Mark): Mark => {
|
|
911
|
-
if (!xField || bandCenterByCategory.size === 0) return mark;
|
|
912
|
-
if (mark.type === 'line') {
|
|
913
|
-
const newPoints = mark.points.map((p, i) => {
|
|
914
|
-
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
915
|
-
return bx !== undefined ? { ...p, x: bx } : p;
|
|
916
|
-
});
|
|
917
|
-
const newDataPoints = mark.dataPoints?.map((dp, i) => {
|
|
918
|
-
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
919
|
-
return bx !== undefined ? { ...dp, x: bx } : dp;
|
|
920
|
-
});
|
|
921
|
-
// Regenerate the smooth monotone path from remapped points so the rendered
|
|
922
|
-
// line passes through bar centers with the same curve quality as the original.
|
|
923
|
-
// Uses curveMonotoneX regardless of the original interpolation -- preserving the
|
|
924
|
-
// user-specified curve across x-remapping would require re-resolving the mark's
|
|
925
|
-
// interpolation setting, which isn't stored on LineMark post-compilation.
|
|
926
|
-
const newPath =
|
|
927
|
-
d3line<{ x: number; y: number }>()
|
|
928
|
-
.x((p) => p.x)
|
|
929
|
-
.y((p) => p.y)
|
|
930
|
-
.curve(curveMonotoneX)(newPoints) ?? undefined;
|
|
931
|
-
return { ...mark, points: newPoints, dataPoints: newDataPoints, path: newPath };
|
|
932
|
-
}
|
|
933
|
-
if (mark.type === 'area') {
|
|
934
|
-
const newTopPoints = mark.topPoints.map((p, i) => {
|
|
935
|
-
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
936
|
-
return bx !== undefined ? { ...p, x: bx } : p;
|
|
937
|
-
});
|
|
938
|
-
const newBottomPoints = mark.bottomPoints.map((p, i) => {
|
|
939
|
-
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
940
|
-
return bx !== undefined ? { ...p, x: bx } : p;
|
|
941
|
-
});
|
|
942
|
-
const newDataPoints = mark.dataPoints?.map((dp, i) => {
|
|
943
|
-
const bx = bandCenterByCategory.get(String(mark.data[i]?.[xField] ?? ''));
|
|
944
|
-
return bx !== undefined ? { ...dp, x: bx } : dp;
|
|
945
|
-
});
|
|
946
|
-
// Regenerate area fill path and top-line stroke path from remapped points.
|
|
947
|
-
const areaGen = d3area<{ x: number; yTop: number; yBottom: number }>()
|
|
948
|
-
.x((p) => p.x)
|
|
949
|
-
.y0((p) => p.yBottom)
|
|
950
|
-
.y1((p) => p.yTop)
|
|
951
|
-
.curve(curveMonotoneX);
|
|
952
|
-
const topLineGen = d3line<{ x: number; yTop: number }>()
|
|
953
|
-
.x((p) => p.x)
|
|
954
|
-
.y((p) => p.yTop)
|
|
955
|
-
.curve(curveMonotoneX);
|
|
956
|
-
const combined = newTopPoints.map((tp, i) => ({
|
|
957
|
-
x: tp.x,
|
|
958
|
-
yTop: tp.y,
|
|
959
|
-
yBottom: newBottomPoints[i]?.y ?? tp.y,
|
|
960
|
-
}));
|
|
961
|
-
const newPath = areaGen(combined) ?? '';
|
|
962
|
-
const newTopPath = topLineGen(combined) ?? '';
|
|
963
|
-
return {
|
|
964
|
-
...mark,
|
|
965
|
-
topPoints: newTopPoints,
|
|
966
|
-
bottomPoints: newBottomPoints,
|
|
967
|
-
dataPoints: newDataPoints,
|
|
968
|
-
path: newPath,
|
|
969
|
-
topPath: newTopPath,
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
if (mark.type === 'point') {
|
|
973
|
-
const bx = bandCenterByCategory.get(String(mark.data[xField] ?? ''));
|
|
974
|
-
return bx !== undefined ? { ...mark, cx: bx } : mark;
|
|
975
|
-
}
|
|
976
|
-
return mark;
|
|
977
|
-
};
|
|
978
|
-
|
|
979
|
-
// Apply remapping to whichever layer has line/area marks (point scale)
|
|
980
|
-
const adjustedMarks0 =
|
|
981
|
-
bandCenterByCategory.size > 0 && !layer0HasBars
|
|
982
|
-
? layout0.marks.map((m) => remapMarkX(xField0, m))
|
|
983
|
-
: layout0.marks;
|
|
984
|
-
|
|
985
|
-
// Tag layer 1 marks with yScale: 'y2' and remap x if needed
|
|
986
|
-
const taggedMarks1 = layout1.marks.map((mark) => {
|
|
987
|
-
const tagged = { ...mark, yScale: 'y2' as const };
|
|
988
|
-
if (bandCenterByCategory.size > 0 && !layer1HasBars) {
|
|
989
|
-
return remapMarkX(xField1, tagged) as typeof tagged;
|
|
990
|
-
}
|
|
991
|
-
return tagged;
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
// Merge legend entries with deduplication
|
|
995
|
-
const seenLabels = new Set<string>();
|
|
996
|
-
const l0Legend = layout0.legend;
|
|
997
|
-
const l1Legend = layout1.legend;
|
|
998
|
-
const mergedLegendEntries = 'entries' in l0Legend ? [...l0Legend.entries] : [];
|
|
999
|
-
for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
|
|
1000
|
-
const l1Entries = 'entries' in l1Legend ? l1Legend.entries : [];
|
|
1001
|
-
for (const entry of l1Entries) {
|
|
1002
|
-
if (!seenLabels.has(entry.label)) {
|
|
1003
|
-
seenLabels.add(entry.label);
|
|
1004
|
-
mergedLegendEntries.push(entry);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// Merge tooltip descriptors. Layer 1 marks are appended after layer 0's marks,
|
|
1009
|
-
// so their render indices start at layout0.marks.length. The descriptor keys
|
|
1010
|
-
// for discrete marks (rect, point, arc) are "type-${index}" where index is the
|
|
1011
|
-
// mark's position in the final combined array. Re-key them with the correct offset.
|
|
1012
|
-
const l0Count = layout0.marks.length;
|
|
1013
|
-
const mergedTooltips = new Map(layout0.tooltipDescriptors);
|
|
1014
|
-
for (const [key, value] of layout1.tooltipDescriptors) {
|
|
1015
|
-
const match = /^(rect|point|arc)-(\d+)$/.exec(key);
|
|
1016
|
-
if (match) {
|
|
1017
|
-
const offsetKey = `${match[1]}-${Number(match[2]) + l0Count}`;
|
|
1018
|
-
mergedTooltips.set(offsetKey, value);
|
|
1019
|
-
} else {
|
|
1020
|
-
// Line/area tooltips are keyed by series name, not index -- pass through as-is
|
|
1021
|
-
mergedTooltips.set(key, value);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Determine mark render order. By default, layer 0 paints first (behind),
|
|
1026
|
-
// layer 1 paints second (on top). zIndex on the original leaf specs can
|
|
1027
|
-
// reverse this so e.g. a line in layer 0 renders on top of bars in layer 1.
|
|
1028
|
-
const z0 = leaf0.zIndex ?? 0;
|
|
1029
|
-
const z1 = leaf1.zIndex ?? 1;
|
|
1030
|
-
const marks =
|
|
1031
|
-
z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
|
|
1032
|
-
|
|
1033
|
-
return {
|
|
1034
|
-
...layout0,
|
|
1035
|
-
axes: {
|
|
1036
|
-
x: layout0.axes.x,
|
|
1037
|
-
y: layout0.axes.y,
|
|
1038
|
-
y2: y2Axis,
|
|
1039
|
-
},
|
|
1040
|
-
marks,
|
|
1041
|
-
legend: {
|
|
1042
|
-
...layout0.legend,
|
|
1043
|
-
...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
|
|
1044
|
-
} as typeof layout0.legend,
|
|
1045
|
-
tooltipDescriptors: mergedTooltips,
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
/**
|
|
1050
|
-
* Ensure a leaf's data covers the full x-domain by adding stub rows for
|
|
1051
|
-
* missing x-values. This keeps scales consistent across layers without
|
|
1052
|
-
* injecting scale.domain directly.
|
|
1053
|
-
*/
|
|
1054
|
-
function ensureXDomainCoverage(
|
|
1055
|
-
leaf: ChartSpec,
|
|
1056
|
-
xField: string | undefined,
|
|
1057
|
-
allXValues: Set<unknown>,
|
|
1058
|
-
): ChartSpec {
|
|
1059
|
-
if (!xField || allXValues.size === 0) return leaf;
|
|
1060
|
-
|
|
1061
|
-
const existingXValues = new Set<unknown>();
|
|
1062
|
-
for (const row of leaf.data) existingXValues.add(row[xField]);
|
|
1063
|
-
|
|
1064
|
-
const missingRows: DataRow[] = [];
|
|
1065
|
-
for (const xVal of allXValues) {
|
|
1066
|
-
if (!existingXValues.has(xVal)) {
|
|
1067
|
-
missingRows.push({ [xField]: xVal });
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
if (missingRows.length === 0) return leaf;
|
|
1072
|
-
|
|
1073
|
-
return {
|
|
1074
|
-
...leaf,
|
|
1075
|
-
data: [...leaf.data, ...missingRows],
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Compute aligned y-domains for two layers so that zero maps to the same
|
|
1081
|
-
* pixel position on both axes. Returns explicit [min, max] domains for each
|
|
1082
|
-
* layer, or undefined if alignment isn't applicable (non-quantitative axes,
|
|
1083
|
-
* or neither domain spans zero).
|
|
1084
|
-
*/
|
|
1085
|
-
function alignYDomains(
|
|
1086
|
-
leaf0: ChartSpec,
|
|
1087
|
-
leaf1: ChartSpec,
|
|
1088
|
-
): { domain0: [number, number]; domain1: [number, number] } | undefined {
|
|
1089
|
-
const yEnc0 = leaf0.encoding?.y;
|
|
1090
|
-
const yEnc1 = leaf1.encoding?.y;
|
|
1091
|
-
if (!yEnc0 || !yEnc1) return undefined;
|
|
1092
|
-
if (yEnc0.type !== 'quantitative' || yEnc1.type !== 'quantitative') return undefined;
|
|
1093
|
-
|
|
1094
|
-
// Skip if either layer has an explicit domain already set by the user
|
|
1095
|
-
if (yEnc0.scale?.domain || yEnc1.scale?.domain) return undefined;
|
|
1096
|
-
|
|
1097
|
-
const includeZero0 = yEnc0.scale?.zero !== false;
|
|
1098
|
-
const includeZero1 = yEnc1.scale?.zero !== false;
|
|
1099
|
-
|
|
1100
|
-
const vals0 = leaf0.data.map((r) => Number(r[yEnc0.field])).filter(Number.isFinite);
|
|
1101
|
-
const vals1 = leaf1.data.map((r) => Number(r[yEnc1.field])).filter(Number.isFinite);
|
|
1102
|
-
if (vals0.length === 0 || vals1.length === 0) return undefined;
|
|
1103
|
-
|
|
1104
|
-
// Compute nice domains for each (mirroring buildLinearScale behavior)
|
|
1105
|
-
const niced = (vals: number[], includeZero: boolean): [number, number] => {
|
|
1106
|
-
let lo = Math.min(...vals);
|
|
1107
|
-
let hi = Math.max(...vals);
|
|
1108
|
-
if (includeZero) {
|
|
1109
|
-
lo = Math.min(0, lo);
|
|
1110
|
-
hi = Math.max(0, hi);
|
|
1111
|
-
}
|
|
1112
|
-
const s = scaleLinear().domain([lo, hi]);
|
|
1113
|
-
s.nice();
|
|
1114
|
-
const [dLo, dHi] = s.domain();
|
|
1115
|
-
return [dLo, dHi];
|
|
1116
|
-
};
|
|
1117
|
-
|
|
1118
|
-
const [min0, max0] = niced(vals0, includeZero0);
|
|
1119
|
-
const [min1, max1] = niced(vals1, includeZero1);
|
|
1120
|
-
|
|
1121
|
-
const span0 = max0 - min0;
|
|
1122
|
-
const span1 = max1 - min1;
|
|
1123
|
-
if (span0 === 0 || span1 === 0) return undefined;
|
|
1124
|
-
|
|
1125
|
-
// Zero fraction: how far up from the bottom zero sits (0 = bottom, 1 = top).
|
|
1126
|
-
// Only align when BOTH domains naturally contain zero. If one axis is entirely
|
|
1127
|
-
// positive or entirely negative (zero is outside the domain), forcing alignment
|
|
1128
|
-
// would push the other axis into an unnatural range. In that case, let each
|
|
1129
|
-
// axis render its natural domain independently.
|
|
1130
|
-
const zf0 = (0 - min0) / span0;
|
|
1131
|
-
const zf1 = (0 - min1) / span1;
|
|
1132
|
-
|
|
1133
|
-
const zeroInDomain0 = zf0 >= -0.001 && zf0 <= 1.001;
|
|
1134
|
-
const zeroInDomain1 = zf1 >= -0.001 && zf1 <= 1.001;
|
|
1135
|
-
if (!zeroInDomain0 || !zeroInDomain1) return undefined;
|
|
1136
|
-
|
|
1137
|
-
// If both zeros are at the same position (within tolerance), no adjustment needed
|
|
1138
|
-
if (Math.abs(zf0 - zf1) < 0.001) {
|
|
1139
|
-
return { domain0: [min0, max0], domain1: [min1, max1] };
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// Align by extending domains so zero sits at the same proportional position.
|
|
1143
|
-
// Keep the niced boundaries on the side that doesn't need extending, and
|
|
1144
|
-
// compute the exact extended boundary (no re-nicing) so zero stays locked.
|
|
1145
|
-
const targetZf = Math.max(zf0, zf1);
|
|
1146
|
-
|
|
1147
|
-
const align = (dMin: number, dMax: number, currentZf: number): [number, number] => {
|
|
1148
|
-
if (Math.abs(currentZf - targetZf) < 0.001) return [dMin, dMax];
|
|
1149
|
-
|
|
1150
|
-
if (targetZf > currentZf) {
|
|
1151
|
-
// Need more negative range: newMin = -(targetZf / (1 - targetZf)) * dMax
|
|
1152
|
-
const newMin = -(targetZf / (1 - targetZf)) * dMax;
|
|
1153
|
-
return [newMin, dMax];
|
|
1154
|
-
}
|
|
1155
|
-
// Need more positive range: newMax = -dMin * (1 - targetZf) / targetZf
|
|
1156
|
-
const newMax = (-dMin * (1 - targetZf)) / targetZf;
|
|
1157
|
-
return [dMin, newMax];
|
|
1158
|
-
};
|
|
1159
|
-
|
|
1160
|
-
const domain0 = align(min0, max0, zf0);
|
|
1161
|
-
const domain1 = align(min1, max1, zf1);
|
|
1162
|
-
|
|
1163
|
-
return { domain0, domain1 };
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
/**
|
|
1167
|
-
* Inject an explicit y-scale domain override into a leaf spec.
|
|
1168
|
-
*/
|
|
1169
|
-
function withYDomain(leaf: ChartSpec, domain: [number, number]): ChartSpec {
|
|
1170
|
-
if (!leaf.encoding?.y) return leaf;
|
|
1171
|
-
return {
|
|
1172
|
-
...leaf,
|
|
1173
|
-
encoding: {
|
|
1174
|
-
...leaf.encoding,
|
|
1175
|
-
y: {
|
|
1176
|
-
...leaf.encoding.y,
|
|
1177
|
-
scale: {
|
|
1178
|
-
...leaf.encoding.y.scale,
|
|
1179
|
-
domain,
|
|
1180
|
-
},
|
|
1181
|
-
},
|
|
1182
|
-
},
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* Build the primary ChartSpec from all leaves for shared compilation.
|
|
1188
|
-
* Unions all data rows across layers so scales see the full domain.
|
|
1189
|
-
* Uses the first leaf's mark/encoding as the base, with layer-level chrome.
|
|
1190
|
-
*/
|
|
1191
|
-
function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec {
|
|
1192
|
-
// Union all data across layers for domain computation
|
|
1193
|
-
const allData = leaves.flatMap((leaf) => leaf.data);
|
|
1194
|
-
|
|
1195
|
-
const primary = {
|
|
1196
|
-
...leaves[0],
|
|
1197
|
-
data: allData,
|
|
1198
|
-
// Layer-level chrome overrides leaf chrome
|
|
1199
|
-
chrome: layerSpec.chrome ?? leaves[0].chrome,
|
|
1200
|
-
annotations: layerSpec.annotations ?? leaves[0].annotations,
|
|
1201
|
-
labels: layerSpec.labels ?? leaves[0].labels,
|
|
1202
|
-
legend: layerSpec.legend ?? leaves[0].legend,
|
|
1203
|
-
responsive: layerSpec.responsive ?? leaves[0].responsive,
|
|
1204
|
-
theme: layerSpec.theme ?? leaves[0].theme,
|
|
1205
|
-
darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
|
|
1206
|
-
watermark: layerSpec.watermark ?? leaves[0].watermark,
|
|
1207
|
-
hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
|
|
1208
|
-
};
|
|
1209
|
-
|
|
1210
|
-
return primary;
|
|
835
|
+
return compileLayerImpl(spec, options, compileChart);
|
|
1211
836
|
}
|
|
1212
837
|
|
|
1213
838
|
// ---------------------------------------------------------------------------
|