@opendata-ai/openchart-engine 6.28.6 → 7.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12307 -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 +498 -0
- package/src/compile.ts +221 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +12 -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 +282 -34
- 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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for sparkline default resolution.
|
|
3
|
+
*
|
|
4
|
+
* Focus: the trend signal must be honest about noisy series and short
|
|
5
|
+
* series. Naive `last - first` would mislabel `[100, 50, 200, 60, 101]`
|
|
6
|
+
* as up; a regression slope with deadband correctly reads it as neutral.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MarkDef, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
buildSparklineAreaGradient,
|
|
13
|
+
computeTrend,
|
|
14
|
+
computeTrendFromData,
|
|
15
|
+
hasExplicitColor,
|
|
16
|
+
trendColor,
|
|
17
|
+
} from '../sparkline-defaults';
|
|
18
|
+
|
|
19
|
+
const baseTheme = {
|
|
20
|
+
colors: {
|
|
21
|
+
categorical: ['#1b7fa3', '#aaa'],
|
|
22
|
+
positive: '#16a34a',
|
|
23
|
+
negative: '#dc2626',
|
|
24
|
+
},
|
|
25
|
+
} as unknown as ResolvedTheme;
|
|
26
|
+
|
|
27
|
+
describe('computeTrend', () => {
|
|
28
|
+
it('classifies clear up-trend as "up"', () => {
|
|
29
|
+
expect(computeTrend([1, 2, 3, 4, 5])).toBe('up');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('classifies clear down-trend as "down"', () => {
|
|
33
|
+
expect(computeTrend([5, 4, 3, 2, 1])).toBe('down');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('classifies flat series as "neutral"', () => {
|
|
37
|
+
expect(computeTrend([10, 10, 10, 10])).toBe('neutral');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('classifies noisy non-monotonic series with no meaningful net trend as "neutral"', () => {
|
|
41
|
+
// Symmetric oscillation around the mean — the regression slope works
|
|
42
|
+
// out to (almost) zero. Naive `last - first` heuristics would classify
|
|
43
|
+
// this incorrectly; the regression sees no net direction.
|
|
44
|
+
// Palindromic series — mirrors around the midpoint, so the regression
|
|
45
|
+
// slope is mathematically zero regardless of the noise amplitude.
|
|
46
|
+
expect(computeTrend([100, 105, 95, 102, 95, 105, 100])).toBe('neutral');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('classifies a real trend with noise as the net direction', () => {
|
|
50
|
+
// ~10% net rise over 20 steps with realistic chop — should still read up.
|
|
51
|
+
const series = [
|
|
52
|
+
100, 102, 99, 103, 101, 104, 103, 106, 104, 107, 105, 108, 107, 109, 108, 110, 109, 111, 110,
|
|
53
|
+
112,
|
|
54
|
+
];
|
|
55
|
+
expect(computeTrend(series)).toBe('up');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns "neutral" for empty series', () => {
|
|
59
|
+
expect(computeTrend([])).toBe('neutral');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns "neutral" for single-point series', () => {
|
|
63
|
+
expect(computeTrend([42])).toBe('neutral');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns "neutral" when all values are non-finite', () => {
|
|
67
|
+
expect(computeTrend([NaN, NaN, Infinity])).toBe('neutral');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('skips non-finite values and classifies remaining', () => {
|
|
71
|
+
expect(computeTrend([NaN, 1, 2, 3, 4, NaN])).toBe('up');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats very small relative slope as "neutral" (deadband)', () => {
|
|
75
|
+
// Slope is real but tiny relative to the mean.
|
|
76
|
+
expect(computeTrend([1000, 1000.5, 1001, 1001.2])).toBe('neutral');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('computeTrendFromData', () => {
|
|
81
|
+
it('reads y values from data rows', () => {
|
|
82
|
+
const data = [
|
|
83
|
+
{ date: 'a', value: 1 },
|
|
84
|
+
{ date: 'b', value: 2 },
|
|
85
|
+
{ date: 'c', value: 3 },
|
|
86
|
+
];
|
|
87
|
+
expect(computeTrendFromData(data, 'value')).toBe('up');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns "neutral" when yField is missing', () => {
|
|
91
|
+
expect(computeTrendFromData([{ a: 1 }, { a: 2 }], undefined)).toBe('neutral');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('coerces string numbers and skips non-numeric values', () => {
|
|
95
|
+
const data = [{ v: '5' }, { v: 'not a number' }, { v: '4' }, { v: '3' }];
|
|
96
|
+
expect(computeTrendFromData(data, 'v')).toBe('down');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('trendColor', () => {
|
|
101
|
+
it('returns positive for up', () => {
|
|
102
|
+
expect(trendColor('up', baseTheme)).toBe('#16a34a');
|
|
103
|
+
});
|
|
104
|
+
it('returns negative for down', () => {
|
|
105
|
+
expect(trendColor('down', baseTheme)).toBe('#dc2626');
|
|
106
|
+
});
|
|
107
|
+
it('returns palette[0] for neutral', () => {
|
|
108
|
+
expect(trendColor('neutral', baseTheme)).toBe('#1b7fa3');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('hasExplicitColor', () => {
|
|
113
|
+
it('returns both false when markDef has no fill/stroke and no color encoding', () => {
|
|
114
|
+
expect(hasExplicitColor({ type: 'line' }, false)).toEqual({ fill: false, stroke: false });
|
|
115
|
+
});
|
|
116
|
+
it('returns fill=true when markDef.fill is set, leaves stroke false', () => {
|
|
117
|
+
expect(hasExplicitColor({ type: 'line', fill: '#ff00ff' } as MarkDef, false)).toEqual({
|
|
118
|
+
fill: true,
|
|
119
|
+
stroke: false,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
it('returns stroke=true when markDef.stroke is set, leaves fill false', () => {
|
|
123
|
+
expect(hasExplicitColor({ type: 'line', stroke: '#ff00ff' } as MarkDef, false)).toEqual({
|
|
124
|
+
fill: false,
|
|
125
|
+
stroke: true,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it('returns both true when both markDef.fill and markDef.stroke are set', () => {
|
|
129
|
+
expect(
|
|
130
|
+
hasExplicitColor({ type: 'line', fill: '#ff00ff', stroke: '#00ffff' } as MarkDef, false),
|
|
131
|
+
).toEqual({ fill: true, stroke: true });
|
|
132
|
+
});
|
|
133
|
+
it('returns both true when encoding.color is present (color scale drives both)', () => {
|
|
134
|
+
expect(hasExplicitColor({ type: 'line' }, true)).toEqual({ fill: true, stroke: true });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('buildSparklineAreaGradient', () => {
|
|
139
|
+
it('builds a top-to-bottom gradient with 0.35 -> 0 opacity in the trend color', () => {
|
|
140
|
+
const grad = buildSparklineAreaGradient('#16a34a');
|
|
141
|
+
expect(grad).toEqual({
|
|
142
|
+
gradient: 'linear',
|
|
143
|
+
x1: 0,
|
|
144
|
+
y1: 0,
|
|
145
|
+
x2: 0,
|
|
146
|
+
y2: 1,
|
|
147
|
+
stops: [
|
|
148
|
+
{ offset: 0, color: '#16a34a', opacity: 0.35 },
|
|
149
|
+
{ offset: 1, color: '#16a34a', opacity: 0 },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -63,11 +63,13 @@ function normalizeChromeField(value: string | ChromeText | undefined): ChromeTex
|
|
|
63
63
|
function normalizeChrome(chrome: Chrome | undefined): NormalizedChrome {
|
|
64
64
|
if (!chrome) return {};
|
|
65
65
|
return {
|
|
66
|
+
eyebrow: normalizeChromeField(chrome.eyebrow),
|
|
66
67
|
title: normalizeChromeField(chrome.title),
|
|
67
68
|
subtitle: normalizeChromeField(chrome.subtitle),
|
|
68
69
|
source: normalizeChromeField(chrome.source),
|
|
69
70
|
byline: normalizeChromeField(chrome.byline),
|
|
70
71
|
footer: normalizeChromeField(chrome.footer),
|
|
72
|
+
brand: normalizeChromeField(chrome.brand),
|
|
71
73
|
};
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -241,9 +243,11 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
241
243
|
data: spec.data,
|
|
242
244
|
encoding,
|
|
243
245
|
chrome: normalizeChrome(spec.chrome),
|
|
246
|
+
metrics: spec.metrics,
|
|
244
247
|
annotations: normalizeAnnotations(spec.annotations),
|
|
245
248
|
labels: normalizeLabels(spec.labels),
|
|
246
249
|
legend: spec.legend,
|
|
250
|
+
endpointLabels: spec.endpointLabels,
|
|
247
251
|
responsive: spec.responsive ?? true,
|
|
248
252
|
theme: spec.theme ?? {},
|
|
249
253
|
darkMode: spec.darkMode ?? 'off',
|
|
@@ -256,6 +260,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
256
260
|
userExplicit: {
|
|
257
261
|
chrome: false,
|
|
258
262
|
legend: false,
|
|
263
|
+
endpointLabels: false,
|
|
259
264
|
xAxis: false,
|
|
260
265
|
yAxis: false,
|
|
261
266
|
labels: false,
|
|
@@ -463,6 +468,7 @@ export function flattenLayers(
|
|
|
463
468
|
parentEncoding?: Encoding,
|
|
464
469
|
parentTransforms?: import('@opendata-ai/openchart-core').Transform[],
|
|
465
470
|
parentWatermark?: boolean,
|
|
471
|
+
parentEndpointLabels?: boolean | import('@opendata-ai/openchart-core').EndpointLabelsConfig,
|
|
466
472
|
): ChartSpec[] {
|
|
467
473
|
const resolvedData = spec.data ?? parentData;
|
|
468
474
|
const resolvedEncoding: Encoding | undefined =
|
|
@@ -472,6 +478,7 @@ export function flattenLayers(
|
|
|
472
478
|
const resolvedTransforms = [...(parentTransforms ?? []), ...(spec.transform ?? [])];
|
|
473
479
|
// Layer-level watermark propagates to children (child can still override)
|
|
474
480
|
const resolvedWatermark = spec.watermark ?? parentWatermark;
|
|
481
|
+
const resolvedEndpointLabels = spec.endpointLabels ?? parentEndpointLabels;
|
|
475
482
|
|
|
476
483
|
const leaves: ChartSpec[] = [];
|
|
477
484
|
|
|
@@ -485,6 +492,7 @@ export function flattenLayers(
|
|
|
485
492
|
resolvedEncoding,
|
|
486
493
|
resolvedTransforms,
|
|
487
494
|
resolvedWatermark,
|
|
495
|
+
resolvedEndpointLabels,
|
|
488
496
|
),
|
|
489
497
|
);
|
|
490
498
|
} else {
|
|
@@ -504,7 +512,10 @@ export function flattenLayers(
|
|
|
504
512
|
...(child.watermark === undefined && resolvedWatermark !== undefined
|
|
505
513
|
? { watermark: resolvedWatermark }
|
|
506
514
|
: {}),
|
|
507
|
-
|
|
515
|
+
...(child.endpointLabels === undefined && resolvedEndpointLabels !== undefined
|
|
516
|
+
? { endpointLabels: resolvedEndpointLabels }
|
|
517
|
+
: {}),
|
|
518
|
+
} as ChartSpec);
|
|
508
519
|
}
|
|
509
520
|
}
|
|
510
521
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparkline default resolution.
|
|
3
|
+
*
|
|
4
|
+
* When `display: 'sparkline'` is set on a chart spec, the engine fills in
|
|
5
|
+
* "smart" defaults so a minimal spec (mark + data + encoding) renders the
|
|
6
|
+
* polished mock-quality output without manual color, gradient, or endpoint
|
|
7
|
+
* configuration. Every default backs off when the user has set the
|
|
8
|
+
* corresponding field explicitly.
|
|
9
|
+
*
|
|
10
|
+
* Two public helpers:
|
|
11
|
+
* - {@link computeTrend} reads a value series and decides up/down/neutral
|
|
12
|
+
* using a least-squares slope with a relative deadband. Naive
|
|
13
|
+
* `last - first` would mislabel noisy series; this is more honest.
|
|
14
|
+
* - {@link resolveSparklineFill} picks the trend color, respecting the
|
|
15
|
+
* precedence ladder: explicit `markDef.fill` > `encoding.color` >
|
|
16
|
+
* theme override > sparkline trend default.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DataRow, GradientDef, MarkDef, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
20
|
+
|
|
21
|
+
/** Trend classification for a single value series. */
|
|
22
|
+
export type Trend = 'up' | 'down' | 'neutral';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Relative slope below this magnitude reads as "no meaningful trend" and
|
|
26
|
+
* falls into the neutral bucket. 0.0005 = 0.05% of the series mean per step,
|
|
27
|
+
* which means a 20-point series needs roughly a 1% net change to register
|
|
28
|
+
* as a real trend. Tuned so financial sparklines (which often show 2-10%
|
|
29
|
+
* swings) reliably pick up trend color, while heavy noise around a flat
|
|
30
|
+
* mean still classifies as neutral.
|
|
31
|
+
*/
|
|
32
|
+
const TREND_DEADBAND = 0.0005;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether a numeric series trends up, down, or neutral.
|
|
36
|
+
*
|
|
37
|
+
* Uses ordinary least-squares slope across the series, normalized by the
|
|
38
|
+
* absolute mean so the deadband applies consistently to series of any
|
|
39
|
+
* magnitude. Non-finite values are dropped before fitting; series with
|
|
40
|
+
* fewer than two finite points return 'neutral'.
|
|
41
|
+
*/
|
|
42
|
+
export function computeTrend(values: readonly number[]): Trend {
|
|
43
|
+
const finite: number[] = [];
|
|
44
|
+
for (const v of values) {
|
|
45
|
+
if (Number.isFinite(v)) finite.push(v);
|
|
46
|
+
}
|
|
47
|
+
if (finite.length < 2) return 'neutral';
|
|
48
|
+
|
|
49
|
+
const n = finite.length;
|
|
50
|
+
let sumX = 0;
|
|
51
|
+
let sumY = 0;
|
|
52
|
+
for (let i = 0; i < n; i++) {
|
|
53
|
+
sumX += i;
|
|
54
|
+
sumY += finite[i];
|
|
55
|
+
}
|
|
56
|
+
const meanX = sumX / n;
|
|
57
|
+
const meanY = sumY / n;
|
|
58
|
+
|
|
59
|
+
let num = 0;
|
|
60
|
+
let den = 0;
|
|
61
|
+
for (let i = 0; i < n; i++) {
|
|
62
|
+
const dx = i - meanX;
|
|
63
|
+
num += dx * (finite[i] - meanY);
|
|
64
|
+
den += dx * dx;
|
|
65
|
+
}
|
|
66
|
+
if (den === 0) return 'neutral';
|
|
67
|
+
|
|
68
|
+
const slope = num / den;
|
|
69
|
+
const scale = Math.abs(meanY) || 1;
|
|
70
|
+
const relSlope = slope / scale;
|
|
71
|
+
if (Math.abs(relSlope) < TREND_DEADBAND) return 'neutral';
|
|
72
|
+
return relSlope > 0 ? 'up' : 'down';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Map a trend classification to a color from the resolved theme. */
|
|
76
|
+
export function trendColor(trend: Trend, theme: ResolvedTheme): string {
|
|
77
|
+
if (trend === 'up') return theme.colors.positive;
|
|
78
|
+
if (trend === 'down') return theme.colors.negative;
|
|
79
|
+
return theme.colors.categorical[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pull the y-channel values out of a data array and classify the trend.
|
|
84
|
+
* Returns 'neutral' if the field is missing or the series is too short.
|
|
85
|
+
*/
|
|
86
|
+
export function computeTrendFromData(data: readonly DataRow[], yField: string | undefined): Trend {
|
|
87
|
+
if (!yField) return 'neutral';
|
|
88
|
+
const values: number[] = [];
|
|
89
|
+
for (const row of data) {
|
|
90
|
+
const v = Number(row[yField]);
|
|
91
|
+
if (Number.isFinite(v)) values.push(v);
|
|
92
|
+
}
|
|
93
|
+
return computeTrend(values);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Channel-by-channel report of whether a spec's color is "user-explicit"
|
|
98
|
+
* for that channel — the trend default backs off only for the channels
|
|
99
|
+
* the user has set.
|
|
100
|
+
*
|
|
101
|
+
* Precedence ladder (most explicit first):
|
|
102
|
+
* 1. `markDef.fill` / `markDef.stroke` set on the spec — channel-specific
|
|
103
|
+
* 2. `encoding.color` field encoding (the data drives color, not trend)
|
|
104
|
+
* backs off both channels because the color scale produces both
|
|
105
|
+
* 3. Sparkline trend default (returned when both flags are false)
|
|
106
|
+
*/
|
|
107
|
+
export function hasExplicitColor(
|
|
108
|
+
markDef: MarkDef | undefined,
|
|
109
|
+
encodingHasColor: boolean,
|
|
110
|
+
): { fill: boolean; stroke: boolean } {
|
|
111
|
+
if (encodingHasColor) return { fill: true, stroke: true };
|
|
112
|
+
return {
|
|
113
|
+
fill: markDef?.fill !== undefined,
|
|
114
|
+
stroke: markDef?.stroke !== undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the default area gradient for a sparkline area mark. Uses the
|
|
120
|
+
* trend-derived base color and fades top→bottom from 35% to 0% opacity.
|
|
121
|
+
*
|
|
122
|
+
* Coordinate system is normalized [0,1] relative to the mark bounding box,
|
|
123
|
+
* matching the existing `LinearGradient` shape used by user-authored
|
|
124
|
+
* gradients (see `examples/src/sparkline.stories.tsx` for prior art).
|
|
125
|
+
*/
|
|
126
|
+
export function buildSparklineAreaGradient(baseColor: string): GradientDef {
|
|
127
|
+
return {
|
|
128
|
+
gradient: 'linear',
|
|
129
|
+
x1: 0,
|
|
130
|
+
y1: 0,
|
|
131
|
+
x2: 0,
|
|
132
|
+
y2: 1,
|
|
133
|
+
stops: [
|
|
134
|
+
{ offset: 0, color: baseColor, opacity: 0.35 },
|
|
135
|
+
{ offset: 1, color: baseColor, opacity: 0 },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/compiler/types.ts
CHANGED
|
@@ -39,11 +39,13 @@ import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
|
39
39
|
|
|
40
40
|
/** Chrome with all string values normalized to ChromeText objects. */
|
|
41
41
|
export interface NormalizedChrome {
|
|
42
|
+
eyebrow?: ChromeText;
|
|
42
43
|
title?: ChromeText;
|
|
43
44
|
subtitle?: ChromeText;
|
|
44
45
|
source?: ChromeText;
|
|
45
46
|
byline?: ChromeText;
|
|
46
47
|
footer?: ChromeText;
|
|
48
|
+
brand?: ChromeText;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
// ---------------------------------------------------------------------------
|
|
@@ -78,6 +80,8 @@ export interface UserExplicit {
|
|
|
78
80
|
chrome: boolean;
|
|
79
81
|
/** True if user wrote `legend`. */
|
|
80
82
|
legend: boolean;
|
|
83
|
+
/** True if user wrote `endpointLabels`. */
|
|
84
|
+
endpointLabels: boolean;
|
|
81
85
|
/** True if user wrote `encoding.x.axis`. */
|
|
82
86
|
xAxis: boolean;
|
|
83
87
|
/** True if user wrote `encoding.y.axis`. */
|
|
@@ -101,12 +105,16 @@ export interface NormalizedChartSpec {
|
|
|
101
105
|
data: DataRow[];
|
|
102
106
|
encoding: Encoding;
|
|
103
107
|
chrome: NormalizedChrome;
|
|
108
|
+
/** Optional KPI metric cells, passed through unchanged. */
|
|
109
|
+
metrics?: import('@opendata-ai/openchart-core').Metric[];
|
|
104
110
|
annotations: Annotation[];
|
|
105
111
|
/** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets and color stay optional. */
|
|
106
112
|
labels: Required<Pick<LabelConfig, 'density' | 'format' | 'prefix'>> &
|
|
107
113
|
Pick<LabelConfig, 'offsets' | 'color'>;
|
|
108
114
|
/** Legend configuration (position override). */
|
|
109
115
|
legend?: LegendConfig;
|
|
116
|
+
/** Right-side endpoint labels column config (multi-series line/area only). */
|
|
117
|
+
endpointLabels?: boolean | import('@opendata-ai/openchart-core').EndpointLabelsConfig;
|
|
110
118
|
responsive: boolean;
|
|
111
119
|
theme: ThemeConfig;
|
|
112
120
|
darkMode: DarkMode;
|