@opendata-ai/openchart-engine 1.2.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.
Files changed (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Spec normalization: fill in defaults and infer types.
3
+ *
4
+ * Takes a validated VizSpec and produces a NormalizedSpec where:
5
+ * - All optional fields have sensible defaults
6
+ * - Chrome strings are converted to ChromeText objects
7
+ * - Encoding types are inferred from data if not specified
8
+ * - Annotations have default styles
9
+ */
10
+
11
+ import type {
12
+ Annotation,
13
+ ChartSpec,
14
+ Chrome,
15
+ ChromeText,
16
+ DataRow,
17
+ Encoding,
18
+ FieldType,
19
+ GraphSpec,
20
+ TableSpec,
21
+ VizSpec,
22
+ } from '@opendata-ai/openchart-core';
23
+ import { isChartSpec, isGraphSpec, isTableSpec } from '@opendata-ai/openchart-core';
24
+
25
+ import type {
26
+ NormalizedChartSpec,
27
+ NormalizedChrome,
28
+ NormalizedGraphSpec,
29
+ NormalizedSpec,
30
+ NormalizedTableSpec,
31
+ } from './types';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Chrome normalization
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Convert a string | ChromeText | undefined to ChromeText | undefined. */
38
+ function normalizeChromeField(value: string | ChromeText | undefined): ChromeText | undefined {
39
+ if (value === undefined) return undefined;
40
+ if (typeof value === 'string') return { text: value };
41
+ return value;
42
+ }
43
+
44
+ /** Normalize all chrome fields from strings to ChromeText objects. */
45
+ function normalizeChrome(chrome: Chrome | undefined): NormalizedChrome {
46
+ if (!chrome) return {};
47
+ return {
48
+ title: normalizeChromeField(chrome.title),
49
+ subtitle: normalizeChromeField(chrome.subtitle),
50
+ source: normalizeChromeField(chrome.source),
51
+ byline: normalizeChromeField(chrome.byline),
52
+ footer: normalizeChromeField(chrome.footer),
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Type inference
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Sample values from a data column and infer the field type. */
61
+ function inferFieldType(data: DataRow[], field: string): FieldType {
62
+ // Sample up to 10 rows
63
+ const sampleSize = Math.min(10, data.length);
64
+ let numericCount = 0;
65
+ let dateCount = 0;
66
+ let totalNonNull = 0;
67
+
68
+ for (let i = 0; i < sampleSize; i++) {
69
+ const value = data[i][field];
70
+ if (value == null) continue;
71
+ totalNonNull++;
72
+
73
+ // Check numeric
74
+ if (typeof value === 'number' && Number.isFinite(value)) {
75
+ numericCount++;
76
+ continue;
77
+ }
78
+
79
+ // Check date-like strings
80
+ if (typeof value === 'string') {
81
+ // Try as number first
82
+ const num = Number(value);
83
+ if (!Number.isNaN(num) && Number.isFinite(num) && value.trim() !== '') {
84
+ numericCount++;
85
+ continue;
86
+ }
87
+
88
+ // Try as date
89
+ const date = new Date(value);
90
+ if (!Number.isNaN(date.getTime())) {
91
+ dateCount++;
92
+ continue;
93
+ }
94
+ }
95
+
96
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
97
+ dateCount++;
98
+ }
99
+ }
100
+
101
+ if (totalNonNull === 0) return 'nominal';
102
+
103
+ // If >80% of sampled values are dates, it's temporal
104
+ if (dateCount / totalNonNull > 0.8) return 'temporal';
105
+ // If >80% are numeric, it's quantitative
106
+ if (numericCount / totalNonNull > 0.8) return 'quantitative';
107
+ // Otherwise it's nominal
108
+ return 'nominal';
109
+ }
110
+
111
+ /** Infer types for encoding channels that don't have one specified. */
112
+ function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: string[]): Encoding {
113
+ const result = { ...encoding };
114
+
115
+ for (const channel of ['x', 'y', 'color', 'size', 'detail'] as const) {
116
+ const spec = result[channel];
117
+ if (!spec) continue;
118
+
119
+ if (!spec.type) {
120
+ const inferred = inferFieldType(data, spec.field);
121
+ result[channel] = { ...spec, type: inferred };
122
+ warnings.push(
123
+ `Inferred encoding.${channel}.type as "${inferred}" from data values for field "${spec.field}"`,
124
+ );
125
+ } else {
126
+ // Check for potential mismatches and warn
127
+ const actualType = inferFieldType(data, spec.field);
128
+ if (spec.type === 'nominal' && actualType === 'temporal') {
129
+ warnings.push(`Field "${spec.field}" looks temporal but was declared as nominal`);
130
+ }
131
+ if (spec.type === 'nominal' && actualType === 'quantitative') {
132
+ warnings.push(`Field "${spec.field}" looks quantitative but was declared as nominal`);
133
+ }
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Annotation normalization
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** Apply default styles to annotations that don't have them. */
145
+ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation[] {
146
+ if (!annotations || annotations.length === 0) return [];
147
+
148
+ return annotations.map((ann) => {
149
+ switch (ann.type) {
150
+ case 'text':
151
+ return {
152
+ ...ann,
153
+ fontSize: ann.fontSize ?? 12,
154
+ fontWeight: ann.fontWeight ?? 400,
155
+ opacity: ann.opacity ?? 1,
156
+ };
157
+ case 'range':
158
+ return {
159
+ ...ann,
160
+ opacity: ann.opacity ?? 0.1,
161
+ fill: ann.fill ?? '#000000',
162
+ };
163
+ case 'refline':
164
+ return {
165
+ ...ann,
166
+ style: ann.style ?? 'dashed',
167
+ strokeWidth: ann.strokeWidth ?? 1,
168
+ stroke: ann.stroke ?? '#666666',
169
+ opacity: ann.opacity ?? 0.8,
170
+ };
171
+ default:
172
+ return ann;
173
+ }
174
+ });
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Spec-level normalization
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChartSpec {
182
+ const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
183
+
184
+ return {
185
+ type: spec.type,
186
+ data: spec.data,
187
+ encoding,
188
+ chrome: normalizeChrome(spec.chrome),
189
+ annotations: normalizeAnnotations(spec.annotations),
190
+ labels: {
191
+ density: spec.labels?.density ?? 'auto',
192
+ format: spec.labels?.format ?? '',
193
+ offsets: spec.labels?.offsets,
194
+ },
195
+ legend: spec.legend,
196
+ responsive: spec.responsive ?? true,
197
+ theme: spec.theme ?? {},
198
+ darkMode: spec.darkMode ?? 'off',
199
+ };
200
+ }
201
+
202
+ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTableSpec {
203
+ return {
204
+ type: 'table',
205
+ data: spec.data,
206
+ columns: spec.columns,
207
+ rowKey: spec.rowKey,
208
+ chrome: normalizeChrome(spec.chrome),
209
+ theme: spec.theme ?? {},
210
+ darkMode: spec.darkMode ?? 'off',
211
+ search: spec.search ?? false,
212
+ pagination: spec.pagination ?? false,
213
+ stickyFirstColumn: spec.stickyFirstColumn ?? false,
214
+ compact: spec.compact ?? false,
215
+ responsive: spec.responsive ?? true,
216
+ };
217
+ }
218
+
219
+ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGraphSpec {
220
+ // Default layout with chargeStrength and linkDistance
221
+ const defaultLayout = {
222
+ type: 'force' as const,
223
+ chargeStrength: -300,
224
+ linkDistance: 30,
225
+ };
226
+ const layout = spec.layout
227
+ ? {
228
+ ...defaultLayout,
229
+ ...spec.layout,
230
+ }
231
+ : defaultLayout;
232
+
233
+ return {
234
+ type: 'graph',
235
+ nodes: spec.nodes,
236
+ edges: spec.edges,
237
+ encoding: spec.encoding ?? {},
238
+ layout,
239
+ chrome: normalizeChrome(spec.chrome),
240
+ annotations: normalizeAnnotations(spec.annotations),
241
+ theme: spec.theme ?? {},
242
+ darkMode: spec.darkMode ?? 'off',
243
+ };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Public API
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Normalize a validated VizSpec, filling in all defaults.
252
+ *
253
+ * @param spec - A validated VizSpec (must pass validateSpec first).
254
+ * @param warnings - Mutable array to collect non-fatal warnings.
255
+ * @returns A NormalizedSpec with all optionals filled.
256
+ */
257
+ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): NormalizedSpec {
258
+ if (isChartSpec(spec)) {
259
+ return normalizeChartSpec(spec, warnings);
260
+ }
261
+ if (isTableSpec(spec)) {
262
+ return normalizeTableSpec(spec, warnings);
263
+ }
264
+ if (isGraphSpec(spec)) {
265
+ return normalizeGraphSpec(spec, warnings);
266
+ }
267
+ // Should never happen after validation
268
+ throw new Error(`Unknown spec type: ${(spec as Record<string, unknown>).type}`);
269
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Internal engine types for the compilation pipeline.
3
+ *
4
+ * NormalizedSpec is the engine's internal representation where all optionals
5
+ * have been filled with defaults. It's intentionally NOT in the core package
6
+ * since it's an engine implementation detail, not a public contract.
7
+ */
8
+
9
+ import type {
10
+ AggregateOp,
11
+ Annotation,
12
+ AxisConfig,
13
+ ChartType,
14
+ ChromeText,
15
+ ColumnConfig,
16
+ DarkMode,
17
+ DataRow,
18
+ Encoding,
19
+ FieldType,
20
+ GraphEncoding,
21
+ GraphLayoutConfig,
22
+ GraphSpec,
23
+ LabelConfig,
24
+ LegendConfig,
25
+ ScaleConfig,
26
+ ThemeConfig,
27
+ } from '@opendata-ai/openchart-core';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // NormalizedChrome: all fields are ChromeText objects (not plain strings)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Chrome with all string values normalized to ChromeText objects. */
34
+ export interface NormalizedChrome {
35
+ title?: ChromeText;
36
+ subtitle?: ChromeText;
37
+ source?: ChromeText;
38
+ byline?: ChromeText;
39
+ footer?: ChromeText;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // NormalizedEncoding: all encoding channels have their type filled in
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** An encoding channel with a guaranteed type (inferred if not provided). */
47
+ export interface NormalizedEncodingChannel {
48
+ field: string;
49
+ type: FieldType;
50
+ aggregate?: AggregateOp;
51
+ axis?: AxisConfig;
52
+ scale?: ScaleConfig;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // NormalizedSpec types
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** A ChartSpec with all optional fields filled with sensible defaults. */
60
+ export interface NormalizedChartSpec {
61
+ type: ChartType;
62
+ data: DataRow[];
63
+ encoding: Encoding;
64
+ chrome: NormalizedChrome;
65
+ annotations: Annotation[];
66
+ /** Normalized label configuration with defaults applied. density and format are always set; offsets stays optional. */
67
+ labels: Required<Pick<LabelConfig, 'density' | 'format'>> & Pick<LabelConfig, 'offsets'>;
68
+ /** Legend configuration (position override). */
69
+ legend?: LegendConfig;
70
+ responsive: boolean;
71
+ theme: ThemeConfig;
72
+ darkMode: DarkMode;
73
+ }
74
+
75
+ /** A TableSpec with all optional fields filled with sensible defaults. */
76
+ export interface NormalizedTableSpec {
77
+ type: 'table';
78
+ data: DataRow[];
79
+ columns: ColumnConfig[];
80
+ rowKey?: string;
81
+ chrome: NormalizedChrome;
82
+ theme: ThemeConfig;
83
+ darkMode: DarkMode;
84
+ search: boolean;
85
+ pagination: boolean | { pageSize: number };
86
+ stickyFirstColumn: boolean;
87
+ compact: boolean;
88
+ responsive: boolean;
89
+ }
90
+
91
+ /** A GraphSpec with all optional fields filled with sensible defaults. */
92
+ export interface NormalizedGraphSpec {
93
+ type: 'graph';
94
+ nodes: GraphSpec['nodes'];
95
+ edges: GraphSpec['edges'];
96
+ encoding: GraphEncoding;
97
+ layout: GraphLayoutConfig;
98
+ chrome: NormalizedChrome;
99
+ annotations: Annotation[];
100
+ theme: ThemeConfig;
101
+ darkMode: DarkMode;
102
+ }
103
+
104
+ /** Discriminated union of all normalized spec types. */
105
+ export type NormalizedSpec = NormalizedChartSpec | NormalizedTableSpec | NormalizedGraphSpec;
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Validation types
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** Machine-readable error code for programmatic handling. */
112
+ export type ValidationErrorCode =
113
+ | 'MISSING_FIELD'
114
+ | 'INVALID_TYPE'
115
+ | 'INVALID_VALUE'
116
+ | 'ENCODING_MISMATCH'
117
+ | 'DATA_FIELD_MISSING'
118
+ | 'EMPTY_DATA';
119
+
120
+ /** A single validation error with context. */
121
+ export interface ValidationError {
122
+ /** Error message describing what's wrong. */
123
+ message: string;
124
+ /** The path to the problematic value (e.g. "encoding.x.field"). */
125
+ path?: string;
126
+ /** Machine-readable error code for programmatic handling. */
127
+ code: ValidationErrorCode;
128
+ /** Actionable suggestion for fixing the error. */
129
+ suggestion: string;
130
+ }
131
+
132
+ /** Result of spec validation. */
133
+ export interface ValidationResult {
134
+ /** Whether the spec is valid. */
135
+ valid: boolean;
136
+ /** Validation errors (empty if valid). */
137
+ errors: ValidationError[];
138
+ /** The validated spec cast to VizSpec, or null if invalid. */
139
+ normalized: import('@opendata-ai/openchart-core').VizSpec | null;
140
+ }
141
+
142
+ /** Result of the compile pipeline (validate + normalize). */
143
+ export interface CompileResult {
144
+ /** The normalized spec with all defaults applied. */
145
+ spec: NormalizedSpec;
146
+ /** Non-fatal warnings (e.g. type mismatches that were auto-corrected). */
147
+ warnings: string[];
148
+ }