@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.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12307 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +498 -0
  28. package/src/compile.ts +221 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +12 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +282 -34
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. 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
+ }
@@ -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;