@opendata-ai/openchart-core 2.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 +130 -0
- package/dist/index.d.ts +2030 -0
- package/dist/index.js +1176 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +757 -0
- package/package.json +61 -0
- package/src/accessibility/__tests__/alt-text.test.ts +110 -0
- package/src/accessibility/__tests__/aria.test.ts +125 -0
- package/src/accessibility/alt-text.ts +120 -0
- package/src/accessibility/aria.ts +73 -0
- package/src/accessibility/index.ts +6 -0
- package/src/colors/__tests__/colorblind.test.ts +63 -0
- package/src/colors/__tests__/contrast.test.ts +71 -0
- package/src/colors/__tests__/palettes.test.ts +54 -0
- package/src/colors/colorblind.ts +122 -0
- package/src/colors/contrast.ts +94 -0
- package/src/colors/index.ts +27 -0
- package/src/colors/palettes.ts +118 -0
- package/src/helpers/__tests__/spec-builders.test.ts +336 -0
- package/src/helpers/spec-builders.ts +410 -0
- package/src/index.ts +129 -0
- package/src/labels/__tests__/collision.test.ts +197 -0
- package/src/labels/collision.ts +154 -0
- package/src/labels/index.ts +6 -0
- package/src/layout/__tests__/chrome.test.ts +114 -0
- package/src/layout/__tests__/text-measure.test.ts +49 -0
- package/src/layout/chrome.ts +223 -0
- package/src/layout/index.ts +6 -0
- package/src/layout/text-measure.ts +54 -0
- package/src/locale/__tests__/format.test.ts +90 -0
- package/src/locale/format.ts +132 -0
- package/src/locale/index.ts +6 -0
- package/src/responsive/__tests__/breakpoints.test.ts +58 -0
- package/src/responsive/breakpoints.ts +92 -0
- package/src/responsive/index.ts +18 -0
- package/src/styles/viz.css +757 -0
- package/src/theme/__tests__/dark-mode.test.ts +68 -0
- package/src/theme/__tests__/defaults.test.ts +47 -0
- package/src/theme/__tests__/resolve.test.ts +61 -0
- package/src/theme/dark-mode.ts +123 -0
- package/src/theme/defaults.ts +85 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/resolve.ts +190 -0
- package/src/types/__tests__/spec.test.ts +387 -0
- package/src/types/encoding.ts +144 -0
- package/src/types/events.ts +96 -0
- package/src/types/index.ts +141 -0
- package/src/types/layout.ts +794 -0
- package/src/types/spec.ts +563 -0
- package/src/types/table.ts +105 -0
- package/src/types/theme.ts +159 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
Annotation,
|
|
4
|
+
ChartSpec,
|
|
5
|
+
GraphSpec,
|
|
6
|
+
RangeAnnotation,
|
|
7
|
+
TableSpec,
|
|
8
|
+
TextAnnotation,
|
|
9
|
+
VizSpec,
|
|
10
|
+
} from '../spec';
|
|
11
|
+
import {
|
|
12
|
+
CHART_TYPES,
|
|
13
|
+
isChartSpec,
|
|
14
|
+
isGraphSpec,
|
|
15
|
+
isRangeAnnotation,
|
|
16
|
+
isRefLineAnnotation,
|
|
17
|
+
isTableSpec,
|
|
18
|
+
isTextAnnotation,
|
|
19
|
+
} from '../spec';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Test data factories
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function makeChartSpec(overrides?: Partial<ChartSpec>): ChartSpec {
|
|
26
|
+
return {
|
|
27
|
+
type: 'line',
|
|
28
|
+
data: [
|
|
29
|
+
{ date: '2020-01', value: 42 },
|
|
30
|
+
{ date: '2020-02', value: 45 },
|
|
31
|
+
],
|
|
32
|
+
encoding: {
|
|
33
|
+
x: { field: 'date', type: 'temporal' },
|
|
34
|
+
y: { field: 'value', type: 'quantitative' },
|
|
35
|
+
},
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeTableSpec(overrides?: Partial<TableSpec>): TableSpec {
|
|
41
|
+
return {
|
|
42
|
+
type: 'table',
|
|
43
|
+
data: [
|
|
44
|
+
{ name: 'US', gdp: 21000 },
|
|
45
|
+
{ name: 'China', gdp: 14700 },
|
|
46
|
+
],
|
|
47
|
+
columns: [
|
|
48
|
+
{ key: 'name', label: 'Country' },
|
|
49
|
+
{ key: 'gdp', label: 'GDP (B$)', format: ',.0f' },
|
|
50
|
+
],
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeGraphSpec(overrides?: Partial<GraphSpec>): GraphSpec {
|
|
56
|
+
return {
|
|
57
|
+
type: 'graph',
|
|
58
|
+
nodes: [
|
|
59
|
+
{ id: 'a', label: 'Node A' },
|
|
60
|
+
{ id: 'b', label: 'Node B' },
|
|
61
|
+
],
|
|
62
|
+
edges: [{ source: 'a', target: 'b', weight: 1 }],
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Spec type guard tests
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('isChartSpec', () => {
|
|
72
|
+
it('returns true for all chart types', () => {
|
|
73
|
+
const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'donut', 'dot', 'scatter'] as const;
|
|
74
|
+
|
|
75
|
+
for (const chartType of chartTypes) {
|
|
76
|
+
const spec = makeChartSpec({ type: chartType });
|
|
77
|
+
expect(isChartSpec(spec)).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns false for table specs', () => {
|
|
82
|
+
const spec: VizSpec = makeTableSpec();
|
|
83
|
+
expect(isChartSpec(spec)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns false for graph specs', () => {
|
|
87
|
+
const spec: VizSpec = makeGraphSpec();
|
|
88
|
+
expect(isChartSpec(spec)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('isTableSpec', () => {
|
|
93
|
+
it('returns true for table specs', () => {
|
|
94
|
+
const spec: VizSpec = makeTableSpec();
|
|
95
|
+
expect(isTableSpec(spec)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns false for chart specs', () => {
|
|
99
|
+
const spec: VizSpec = makeChartSpec();
|
|
100
|
+
expect(isTableSpec(spec)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns false for graph specs', () => {
|
|
104
|
+
const spec: VizSpec = makeGraphSpec();
|
|
105
|
+
expect(isTableSpec(spec)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('isGraphSpec', () => {
|
|
110
|
+
it('returns true for graph specs', () => {
|
|
111
|
+
const spec: VizSpec = makeGraphSpec();
|
|
112
|
+
expect(isGraphSpec(spec)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns false for chart specs', () => {
|
|
116
|
+
const spec: VizSpec = makeChartSpec();
|
|
117
|
+
expect(isGraphSpec(spec)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns false for table specs', () => {
|
|
121
|
+
const spec: VizSpec = makeTableSpec();
|
|
122
|
+
expect(isGraphSpec(spec)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('type guard mutual exclusivity', () => {
|
|
127
|
+
it('exactly one guard returns true for each spec type', () => {
|
|
128
|
+
const specs: VizSpec[] = [makeChartSpec(), makeTableSpec(), makeGraphSpec()];
|
|
129
|
+
|
|
130
|
+
for (const spec of specs) {
|
|
131
|
+
const guards = [isChartSpec(spec), isTableSpec(spec), isGraphSpec(spec)];
|
|
132
|
+
const trueCount = guards.filter(Boolean).length;
|
|
133
|
+
expect(trueCount).toBe(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// CHART_TYPES constant tests
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('CHART_TYPES', () => {
|
|
143
|
+
it('contains all 8 chart types', () => {
|
|
144
|
+
expect(CHART_TYPES.size).toBe(8);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('contains expected types', () => {
|
|
148
|
+
const expected = ['line', 'area', 'bar', 'column', 'pie', 'donut', 'dot', 'scatter'];
|
|
149
|
+
for (const t of expected) {
|
|
150
|
+
expect(CHART_TYPES.has(t)).toBe(true);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not contain non-chart types', () => {
|
|
155
|
+
expect(CHART_TYPES.has('table')).toBe(false);
|
|
156
|
+
expect(CHART_TYPES.has('graph')).toBe(false);
|
|
157
|
+
expect(CHART_TYPES.has('map')).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Annotation type guard tests
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('isTextAnnotation', () => {
|
|
166
|
+
it('returns true for text annotations', () => {
|
|
167
|
+
const annotation: Annotation = {
|
|
168
|
+
type: 'text',
|
|
169
|
+
x: '2020-06',
|
|
170
|
+
y: 42,
|
|
171
|
+
text: 'Peak value',
|
|
172
|
+
};
|
|
173
|
+
expect(isTextAnnotation(annotation)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('returns false for range annotations', () => {
|
|
177
|
+
const annotation: Annotation = {
|
|
178
|
+
type: 'range',
|
|
179
|
+
x1: '2020-03',
|
|
180
|
+
x2: '2020-09',
|
|
181
|
+
label: 'Recession',
|
|
182
|
+
};
|
|
183
|
+
expect(isTextAnnotation(annotation)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns false for refline annotations', () => {
|
|
187
|
+
const annotation: Annotation = {
|
|
188
|
+
type: 'refline',
|
|
189
|
+
y: 0,
|
|
190
|
+
label: 'Zero',
|
|
191
|
+
};
|
|
192
|
+
expect(isTextAnnotation(annotation)).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('isRangeAnnotation', () => {
|
|
197
|
+
it('returns true for range annotations', () => {
|
|
198
|
+
const annotation: Annotation = {
|
|
199
|
+
type: 'range',
|
|
200
|
+
x1: '2020-03',
|
|
201
|
+
x2: '2020-09',
|
|
202
|
+
fill: '#fee2e2',
|
|
203
|
+
};
|
|
204
|
+
expect(isRangeAnnotation(annotation)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns false for text annotations', () => {
|
|
208
|
+
const annotation: TextAnnotation = {
|
|
209
|
+
type: 'text',
|
|
210
|
+
x: 10,
|
|
211
|
+
y: 20,
|
|
212
|
+
text: 'Hello',
|
|
213
|
+
};
|
|
214
|
+
expect(isRangeAnnotation(annotation)).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('isRefLineAnnotation', () => {
|
|
219
|
+
it('returns true for refline annotations', () => {
|
|
220
|
+
const annotation: Annotation = {
|
|
221
|
+
type: 'refline',
|
|
222
|
+
y: 0,
|
|
223
|
+
label: 'Baseline',
|
|
224
|
+
style: 'dashed',
|
|
225
|
+
};
|
|
226
|
+
expect(isRefLineAnnotation(annotation)).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns false for text annotations', () => {
|
|
230
|
+
const annotation: TextAnnotation = {
|
|
231
|
+
type: 'text',
|
|
232
|
+
x: 10,
|
|
233
|
+
y: 20,
|
|
234
|
+
text: 'Hello',
|
|
235
|
+
};
|
|
236
|
+
expect(isRefLineAnnotation(annotation)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('returns false for range annotations', () => {
|
|
240
|
+
const annotation: RangeAnnotation = {
|
|
241
|
+
type: 'range',
|
|
242
|
+
y1: 0,
|
|
243
|
+
y2: 100,
|
|
244
|
+
};
|
|
245
|
+
expect(isRefLineAnnotation(annotation)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('annotation type guard mutual exclusivity', () => {
|
|
250
|
+
it('exactly one annotation guard returns true for each annotation type', () => {
|
|
251
|
+
const annotations: Annotation[] = [
|
|
252
|
+
{ type: 'text', x: 0, y: 0, text: 'test' },
|
|
253
|
+
{ type: 'range', x1: 0, x2: 10 },
|
|
254
|
+
{ type: 'refline', y: 0 },
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const annotation of annotations) {
|
|
258
|
+
const guards = [
|
|
259
|
+
isTextAnnotation(annotation),
|
|
260
|
+
isRangeAnnotation(annotation),
|
|
261
|
+
isRefLineAnnotation(annotation),
|
|
262
|
+
];
|
|
263
|
+
const trueCount = guards.filter(Boolean).length;
|
|
264
|
+
expect(trueCount).toBe(1);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Type-level tests (compile-time verification)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
describe('type-level spec construction', () => {
|
|
274
|
+
it('allows a fully featured chart spec', () => {
|
|
275
|
+
const spec: ChartSpec = {
|
|
276
|
+
type: 'line',
|
|
277
|
+
data: [{ date: '2020-01', value: 42, series: 'US' }],
|
|
278
|
+
encoding: {
|
|
279
|
+
x: { field: 'date', type: 'temporal' },
|
|
280
|
+
y: {
|
|
281
|
+
field: 'value',
|
|
282
|
+
type: 'quantitative',
|
|
283
|
+
axis: { label: 'GDP Growth (%)' },
|
|
284
|
+
scale: { zero: true, nice: true },
|
|
285
|
+
},
|
|
286
|
+
color: { field: 'series', type: 'nominal' },
|
|
287
|
+
},
|
|
288
|
+
chrome: {
|
|
289
|
+
title: 'GDP Growth Rate',
|
|
290
|
+
subtitle: { text: 'Quarterly, seasonally adjusted', style: { fontSize: 14 } },
|
|
291
|
+
source: 'World Bank',
|
|
292
|
+
byline: 'OpenData',
|
|
293
|
+
footer: 'Last updated: Jan 2024',
|
|
294
|
+
},
|
|
295
|
+
annotations: [
|
|
296
|
+
{
|
|
297
|
+
type: 'range',
|
|
298
|
+
x1: '2020-03',
|
|
299
|
+
x2: '2020-09',
|
|
300
|
+
label: 'COVID-19',
|
|
301
|
+
fill: '#fee2e2',
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
type: 'text',
|
|
305
|
+
x: '2021-06',
|
|
306
|
+
y: 45,
|
|
307
|
+
text: 'Recovery begins',
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
type: 'refline',
|
|
311
|
+
y: 0,
|
|
312
|
+
label: 'Zero growth',
|
|
313
|
+
style: 'dashed',
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
responsive: true,
|
|
317
|
+
theme: {
|
|
318
|
+
colors: { categorical: ['#2563eb', '#dc2626'] },
|
|
319
|
+
fonts: { family: 'Inter' },
|
|
320
|
+
},
|
|
321
|
+
darkMode: 'auto',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// If this compiles and type-checks, the types are correct.
|
|
325
|
+
expect(spec.type).toBe('line');
|
|
326
|
+
expect(spec.data).toHaveLength(1);
|
|
327
|
+
expect(spec.encoding.x?.field).toBe('date');
|
|
328
|
+
expect(spec.annotations).toHaveLength(3);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('allows a table spec with column configs', () => {
|
|
332
|
+
const spec: TableSpec = {
|
|
333
|
+
type: 'table',
|
|
334
|
+
data: [{ country: 'US', gdp: 21000, trend: [20000, 20500, 21000] }],
|
|
335
|
+
columns: [
|
|
336
|
+
{ key: 'country', label: 'Country', sortable: true },
|
|
337
|
+
{ key: 'gdp', label: 'GDP (B$)', format: ',.0f', align: 'right' },
|
|
338
|
+
{
|
|
339
|
+
key: 'trend',
|
|
340
|
+
label: 'Trend',
|
|
341
|
+
sparkline: { type: 'line', valuesField: 'trend', color: '#2563eb' },
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
chrome: { title: 'GDP by Country' },
|
|
345
|
+
search: true,
|
|
346
|
+
pagination: { pageSize: 25 },
|
|
347
|
+
stickyFirstColumn: true,
|
|
348
|
+
compact: false,
|
|
349
|
+
darkMode: 'off',
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
expect(spec.type).toBe('table');
|
|
353
|
+
expect(spec.columns).toHaveLength(3);
|
|
354
|
+
expect(spec.search).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('allows a graph spec with encoding and layout', () => {
|
|
358
|
+
const spec: GraphSpec = {
|
|
359
|
+
type: 'graph',
|
|
360
|
+
nodes: [
|
|
361
|
+
{ id: 'a', name: 'Alice', dept: 'Engineering' },
|
|
362
|
+
{ id: 'b', name: 'Bob', dept: 'Design' },
|
|
363
|
+
],
|
|
364
|
+
edges: [{ source: 'a', target: 'b', weight: 5, type: 'collaboration' }],
|
|
365
|
+
encoding: {
|
|
366
|
+
nodeColor: { field: 'dept', type: 'nominal' },
|
|
367
|
+
nodeLabel: { field: 'name' },
|
|
368
|
+
edgeWidth: { field: 'weight', type: 'quantitative' },
|
|
369
|
+
},
|
|
370
|
+
layout: {
|
|
371
|
+
type: 'force',
|
|
372
|
+
clustering: { field: 'dept' },
|
|
373
|
+
chargeStrength: -100,
|
|
374
|
+
linkDistance: 50,
|
|
375
|
+
},
|
|
376
|
+
chrome: {
|
|
377
|
+
title: 'Team Collaboration Network',
|
|
378
|
+
source: 'HR Data',
|
|
379
|
+
},
|
|
380
|
+
darkMode: 'auto',
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
expect(spec.type).toBe('graph');
|
|
384
|
+
expect(spec.nodes).toHaveLength(2);
|
|
385
|
+
expect(spec.edges).toHaveLength(1);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chart-type encoding validation rules.
|
|
3
|
+
*
|
|
4
|
+
* Defines which encoding channels are required vs optional for each chart type.
|
|
5
|
+
* The engine compiler uses these rules to validate specs at runtime (TypeScript
|
|
6
|
+
* catches compile-time errors; these catch runtime JSON from Claude or APIs).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChartType, FieldType } from './spec';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Encoding rule types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Constraint on what field types are valid for an encoding channel. */
|
|
16
|
+
export interface ChannelRule {
|
|
17
|
+
/** Whether this channel is required for the chart type. */
|
|
18
|
+
required: boolean;
|
|
19
|
+
/** Allowed field types. If empty, any field type is accepted. */
|
|
20
|
+
allowedTypes: FieldType[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Encoding rules for a single chart type: which channels are required/optional. */
|
|
24
|
+
export interface EncodingRule {
|
|
25
|
+
x: ChannelRule;
|
|
26
|
+
y: ChannelRule;
|
|
27
|
+
color: ChannelRule;
|
|
28
|
+
size: ChannelRule;
|
|
29
|
+
detail: ChannelRule;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Chart encoding rules
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** Helper to create a required channel rule. */
|
|
37
|
+
function required(...types: FieldType[]): ChannelRule {
|
|
38
|
+
return { required: true, allowedTypes: types };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Helper to create an optional channel rule. */
|
|
42
|
+
function optional(...types: FieldType[]): ChannelRule {
|
|
43
|
+
return { required: false, allowedTypes: types };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encoding rules per chart type.
|
|
48
|
+
*
|
|
49
|
+
* Defines which channels are required and what field types they accept.
|
|
50
|
+
* The compiler uses this map to validate user specs at runtime.
|
|
51
|
+
*
|
|
52
|
+
* Key design decisions:
|
|
53
|
+
* - line/area: x is temporal/ordinal (the axis), y is quantitative (the value)
|
|
54
|
+
* - bar: horizontal bars, so y is the category axis, x is the value
|
|
55
|
+
* - column: vertical columns, so x is the category axis, y is the value
|
|
56
|
+
* - pie/donut: no x axis; y is the value (quantitative), color is the category
|
|
57
|
+
* - dot: y is the category, x is quantitative
|
|
58
|
+
* - scatter: both axes are quantitative
|
|
59
|
+
*/
|
|
60
|
+
export const CHART_ENCODING_RULES: Record<ChartType, EncodingRule> = {
|
|
61
|
+
line: {
|
|
62
|
+
x: required('temporal', 'ordinal'),
|
|
63
|
+
y: required('quantitative'),
|
|
64
|
+
color: optional('nominal', 'ordinal'),
|
|
65
|
+
size: optional('quantitative'),
|
|
66
|
+
detail: optional('nominal'),
|
|
67
|
+
},
|
|
68
|
+
area: {
|
|
69
|
+
x: required('temporal', 'ordinal'),
|
|
70
|
+
y: required('quantitative'),
|
|
71
|
+
color: optional('nominal', 'ordinal'),
|
|
72
|
+
size: optional('quantitative'),
|
|
73
|
+
detail: optional('nominal'),
|
|
74
|
+
},
|
|
75
|
+
bar: {
|
|
76
|
+
x: required('quantitative'),
|
|
77
|
+
y: required('nominal', 'ordinal'),
|
|
78
|
+
color: optional('nominal', 'ordinal', 'quantitative'),
|
|
79
|
+
size: optional('quantitative'),
|
|
80
|
+
detail: optional('nominal'),
|
|
81
|
+
},
|
|
82
|
+
column: {
|
|
83
|
+
x: required('nominal', 'ordinal', 'temporal'),
|
|
84
|
+
y: required('quantitative'),
|
|
85
|
+
color: optional('nominal', 'ordinal', 'quantitative'),
|
|
86
|
+
size: optional('quantitative'),
|
|
87
|
+
detail: optional('nominal'),
|
|
88
|
+
},
|
|
89
|
+
pie: {
|
|
90
|
+
x: optional(),
|
|
91
|
+
y: required('quantitative'),
|
|
92
|
+
color: required('nominal', 'ordinal'),
|
|
93
|
+
size: optional('quantitative'),
|
|
94
|
+
detail: optional('nominal'),
|
|
95
|
+
},
|
|
96
|
+
donut: {
|
|
97
|
+
x: optional(),
|
|
98
|
+
y: required('quantitative'),
|
|
99
|
+
color: required('nominal', 'ordinal'),
|
|
100
|
+
size: optional('quantitative'),
|
|
101
|
+
detail: optional('nominal'),
|
|
102
|
+
},
|
|
103
|
+
dot: {
|
|
104
|
+
x: required('quantitative'),
|
|
105
|
+
y: required('nominal', 'ordinal'),
|
|
106
|
+
color: optional('nominal', 'ordinal'),
|
|
107
|
+
size: optional('quantitative'),
|
|
108
|
+
detail: optional('nominal'),
|
|
109
|
+
},
|
|
110
|
+
scatter: {
|
|
111
|
+
x: required('quantitative'),
|
|
112
|
+
y: required('quantitative'),
|
|
113
|
+
color: optional('nominal', 'ordinal'),
|
|
114
|
+
size: optional('quantitative'),
|
|
115
|
+
detail: optional('nominal'),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Graph encoding rules
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** Encoding rule for a single graph visual channel. */
|
|
124
|
+
export interface GraphChannelRule {
|
|
125
|
+
/** Whether this channel is required. */
|
|
126
|
+
required: boolean;
|
|
127
|
+
/** Allowed field types. Empty means any type. */
|
|
128
|
+
allowedTypes: FieldType[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Encoding rules for graph visualizations.
|
|
133
|
+
*
|
|
134
|
+
* All graph encoding channels are optional since a graph can be rendered
|
|
135
|
+
* with just nodes and edges (uniform appearance). Encoding channels add
|
|
136
|
+
* visual differentiation based on data fields.
|
|
137
|
+
*/
|
|
138
|
+
export const GRAPH_ENCODING_RULES: Record<string, GraphChannelRule> = {
|
|
139
|
+
nodeColor: { required: false, allowedTypes: ['nominal', 'ordinal'] },
|
|
140
|
+
nodeSize: { required: false, allowedTypes: ['quantitative'] },
|
|
141
|
+
edgeColor: { required: false, allowedTypes: ['nominal', 'ordinal'] },
|
|
142
|
+
edgeWidth: { required: false, allowedTypes: ['quantitative'] },
|
|
143
|
+
nodeLabel: { required: false, allowedTypes: [] },
|
|
144
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart interaction event types.
|
|
3
|
+
*
|
|
4
|
+
* These types define the callback signatures for user interactions with
|
|
5
|
+
* chart elements: clicking marks, hovering, legend toggles, and annotation clicks.
|
|
6
|
+
*
|
|
7
|
+
* Event handlers are optional and passed through MountOptions (vanilla) or
|
|
8
|
+
* ChartProps (React). The vanilla adapter wires DOM event listeners to
|
|
9
|
+
* mark elements and constructs these typed events from the raw browser events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
Annotation,
|
|
14
|
+
AnnotationOffset,
|
|
15
|
+
DataRow,
|
|
16
|
+
RangeAnnotation,
|
|
17
|
+
RefLineAnnotation,
|
|
18
|
+
TextAnnotation,
|
|
19
|
+
} from './spec';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Chrome key identifiers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Identifies a specific chrome text element (title, subtitle, source, byline, footer). */
|
|
26
|
+
export type ChromeKey = 'title' | 'subtitle' | 'source' | 'byline' | 'footer';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Element edit events
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Discriminated union of all element edit events.
|
|
34
|
+
* Fired by the `onEdit` callback when any editable chart element is repositioned.
|
|
35
|
+
*/
|
|
36
|
+
export type ElementEdit =
|
|
37
|
+
| { type: 'annotation'; annotation: TextAnnotation; offset: AnnotationOffset }
|
|
38
|
+
| {
|
|
39
|
+
type: 'annotation-connector';
|
|
40
|
+
annotation: TextAnnotation;
|
|
41
|
+
endpoint: 'from' | 'to';
|
|
42
|
+
offset: AnnotationOffset;
|
|
43
|
+
}
|
|
44
|
+
| { type: 'range-label'; annotation: RangeAnnotation; labelOffset: AnnotationOffset }
|
|
45
|
+
| { type: 'refline-label'; annotation: RefLineAnnotation; labelOffset: AnnotationOffset }
|
|
46
|
+
| { type: 'chrome'; key: ChromeKey; text: string; offset: AnnotationOffset }
|
|
47
|
+
| { type: 'series-label'; series: string; offset: AnnotationOffset }
|
|
48
|
+
| { type: 'legend'; offset: AnnotationOffset };
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Mark events
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Event fired when a user interacts with a data mark (bar, point, line segment, etc.).
|
|
56
|
+
*
|
|
57
|
+
* Contains the underlying data row, the series it belongs to (if multi-series),
|
|
58
|
+
* the position within the chart container, and the raw browser MouseEvent.
|
|
59
|
+
*/
|
|
60
|
+
export interface MarkEvent {
|
|
61
|
+
/** The data row associated with the mark that was interacted with. */
|
|
62
|
+
datum: DataRow;
|
|
63
|
+
/** Series identifier, if the chart has multiple series (e.g. multi-line). */
|
|
64
|
+
series?: string;
|
|
65
|
+
/** Position of the interaction relative to the chart container. */
|
|
66
|
+
position: { x: number; y: number };
|
|
67
|
+
/** The raw browser MouseEvent. */
|
|
68
|
+
event: MouseEvent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Chart event handler interface
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Event handler callbacks for chart interactions.
|
|
77
|
+
*
|
|
78
|
+
* All handlers are optional. Pass these through MountOptions (vanilla adapter)
|
|
79
|
+
* or ChartProps (React component) to receive interaction callbacks.
|
|
80
|
+
*/
|
|
81
|
+
export interface ChartEventHandlers {
|
|
82
|
+
/** Called when a data mark is clicked. */
|
|
83
|
+
onMarkClick?: (event: MarkEvent) => void;
|
|
84
|
+
/** Called when the mouse enters a data mark. */
|
|
85
|
+
onMarkHover?: (event: MarkEvent) => void;
|
|
86
|
+
/** Called when the mouse leaves a data mark. */
|
|
87
|
+
onMarkLeave?: () => void;
|
|
88
|
+
/** Called when a legend entry is toggled (clicked to show/hide a series). */
|
|
89
|
+
onLegendToggle?: (series: string, visible: boolean) => void;
|
|
90
|
+
/** Called when an annotation element is clicked. */
|
|
91
|
+
onAnnotationClick?: (annotation: Annotation, event: MouseEvent) => void;
|
|
92
|
+
/** Called when a text annotation label is dragged to a new position. */
|
|
93
|
+
onAnnotationEdit?: (annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void;
|
|
94
|
+
/** Unified edit callback. Fires for any editable chart element (annotations, chrome, legend, series labels). */
|
|
95
|
+
onEdit?: (edit: ElementEdit) => void;
|
|
96
|
+
}
|