@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.
Files changed (51) hide show
  1. package/README.md +130 -0
  2. package/dist/index.d.ts +2030 -0
  3. package/dist/index.js +1176 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/styles.css +757 -0
  6. package/package.json +61 -0
  7. package/src/accessibility/__tests__/alt-text.test.ts +110 -0
  8. package/src/accessibility/__tests__/aria.test.ts +125 -0
  9. package/src/accessibility/alt-text.ts +120 -0
  10. package/src/accessibility/aria.ts +73 -0
  11. package/src/accessibility/index.ts +6 -0
  12. package/src/colors/__tests__/colorblind.test.ts +63 -0
  13. package/src/colors/__tests__/contrast.test.ts +71 -0
  14. package/src/colors/__tests__/palettes.test.ts +54 -0
  15. package/src/colors/colorblind.ts +122 -0
  16. package/src/colors/contrast.ts +94 -0
  17. package/src/colors/index.ts +27 -0
  18. package/src/colors/palettes.ts +118 -0
  19. package/src/helpers/__tests__/spec-builders.test.ts +336 -0
  20. package/src/helpers/spec-builders.ts +410 -0
  21. package/src/index.ts +129 -0
  22. package/src/labels/__tests__/collision.test.ts +197 -0
  23. package/src/labels/collision.ts +154 -0
  24. package/src/labels/index.ts +6 -0
  25. package/src/layout/__tests__/chrome.test.ts +114 -0
  26. package/src/layout/__tests__/text-measure.test.ts +49 -0
  27. package/src/layout/chrome.ts +223 -0
  28. package/src/layout/index.ts +6 -0
  29. package/src/layout/text-measure.ts +54 -0
  30. package/src/locale/__tests__/format.test.ts +90 -0
  31. package/src/locale/format.ts +132 -0
  32. package/src/locale/index.ts +6 -0
  33. package/src/responsive/__tests__/breakpoints.test.ts +58 -0
  34. package/src/responsive/breakpoints.ts +92 -0
  35. package/src/responsive/index.ts +18 -0
  36. package/src/styles/viz.css +757 -0
  37. package/src/theme/__tests__/dark-mode.test.ts +68 -0
  38. package/src/theme/__tests__/defaults.test.ts +47 -0
  39. package/src/theme/__tests__/resolve.test.ts +61 -0
  40. package/src/theme/dark-mode.ts +123 -0
  41. package/src/theme/defaults.ts +85 -0
  42. package/src/theme/index.ts +7 -0
  43. package/src/theme/resolve.ts +190 -0
  44. package/src/types/__tests__/spec.test.ts +387 -0
  45. package/src/types/encoding.ts +144 -0
  46. package/src/types/events.ts +96 -0
  47. package/src/types/index.ts +141 -0
  48. package/src/types/layout.ts +794 -0
  49. package/src/types/spec.ts +563 -0
  50. package/src/types/table.ts +105 -0
  51. package/src/types/theme.ts +159 -0
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { TextStyle } from '../../types/layout';
3
+ import type { LabelCandidate } from '../collision';
4
+ import { detectCollision, resolveCollisions } from '../collision';
5
+
6
+ const defaultStyle: TextStyle = {
7
+ fontFamily: 'Inter',
8
+ fontSize: 12,
9
+ fontWeight: 400,
10
+ fill: '#333',
11
+ lineHeight: 1.3,
12
+ };
13
+
14
+ describe('detectCollision', () => {
15
+ it('detects overlapping rectangles', () => {
16
+ expect(
17
+ detectCollision(
18
+ { x: 0, y: 0, width: 100, height: 50 },
19
+ { x: 50, y: 25, width: 100, height: 50 },
20
+ ),
21
+ ).toBe(true);
22
+ });
23
+
24
+ it('returns false for non-overlapping rectangles', () => {
25
+ expect(
26
+ detectCollision(
27
+ { x: 0, y: 0, width: 50, height: 50 },
28
+ { x: 100, y: 100, width: 50, height: 50 },
29
+ ),
30
+ ).toBe(false);
31
+ });
32
+
33
+ it('returns false for adjacent rectangles (no overlap)', () => {
34
+ expect(
35
+ detectCollision(
36
+ { x: 0, y: 0, width: 50, height: 50 },
37
+ { x: 50, y: 0, width: 50, height: 50 },
38
+ ),
39
+ ).toBe(false);
40
+ });
41
+
42
+ it('detects containment (one inside the other)', () => {
43
+ expect(
44
+ detectCollision(
45
+ { x: 0, y: 0, width: 100, height: 100 },
46
+ { x: 25, y: 25, width: 50, height: 50 },
47
+ ),
48
+ ).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('resolveCollisions', () => {
53
+ it('places non-overlapping labels at their anchors', () => {
54
+ const labels: LabelCandidate[] = [
55
+ {
56
+ text: 'A',
57
+ anchorX: 0,
58
+ anchorY: 0,
59
+ width: 20,
60
+ height: 14,
61
+ priority: 'data',
62
+ style: defaultStyle,
63
+ },
64
+ {
65
+ text: 'B',
66
+ anchorX: 100,
67
+ anchorY: 100,
68
+ width: 20,
69
+ height: 14,
70
+ priority: 'data',
71
+ style: defaultStyle,
72
+ },
73
+ ];
74
+
75
+ const results = resolveCollisions(labels);
76
+ expect(results).toHaveLength(2);
77
+ expect(results[0].visible).toBe(true);
78
+ expect(results[1].visible).toBe(true);
79
+ expect(results[0].x).toBe(0);
80
+ expect(results[0].y).toBe(0);
81
+ expect(results[1].x).toBe(100);
82
+ expect(results[1].y).toBe(100);
83
+ });
84
+
85
+ it('resolves overlapping labels by offsetting', () => {
86
+ const labels: LabelCandidate[] = [
87
+ {
88
+ text: 'A',
89
+ anchorX: 50,
90
+ anchorY: 50,
91
+ width: 40,
92
+ height: 14,
93
+ priority: 'data',
94
+ style: defaultStyle,
95
+ },
96
+ {
97
+ text: 'B',
98
+ anchorX: 55,
99
+ anchorY: 52,
100
+ width: 40,
101
+ height: 14,
102
+ priority: 'data',
103
+ style: defaultStyle,
104
+ },
105
+ ];
106
+
107
+ const results = resolveCollisions(labels);
108
+ expect(results).toHaveLength(2);
109
+ // First label should be at anchor
110
+ expect(results[0].x).toBe(50);
111
+ // Second label should be offset
112
+ expect(results[1].x !== 55 || results[1].y !== 52).toBe(true);
113
+ });
114
+
115
+ it('prioritizes data labels over annotation labels', () => {
116
+ const labels: LabelCandidate[] = [
117
+ {
118
+ text: 'Annotation',
119
+ anchorX: 50,
120
+ anchorY: 50,
121
+ width: 60,
122
+ height: 14,
123
+ priority: 'annotation',
124
+ style: defaultStyle,
125
+ },
126
+ {
127
+ text: 'Data',
128
+ anchorX: 50,
129
+ anchorY: 50,
130
+ width: 40,
131
+ height: 14,
132
+ priority: 'data',
133
+ style: defaultStyle,
134
+ },
135
+ ];
136
+
137
+ const results = resolveCollisions(labels);
138
+ // Data label (higher priority) should get its preferred position
139
+ const dataResult = results.find((r) => r.text === 'Data');
140
+ expect(dataResult!.x).toBe(50);
141
+ expect(dataResult!.y).toBe(50);
142
+ expect(dataResult!.visible).toBe(true);
143
+ });
144
+
145
+ it('demotes labels to tooltip-only when no position works', () => {
146
+ // Create many overlapping labels in a tiny area
147
+ const labels: LabelCandidate[] = Array.from({ length: 20 }, (_, i) => ({
148
+ text: `Label ${i}`,
149
+ anchorX: 50,
150
+ anchorY: 50,
151
+ width: 80,
152
+ height: 14,
153
+ priority: 'data' as const,
154
+ style: defaultStyle,
155
+ }));
156
+
157
+ const results = resolveCollisions(labels);
158
+ // Some should be demoted to tooltip-only
159
+ const hidden = results.filter((r) => !r.visible);
160
+ expect(hidden.length).toBeGreaterThan(0);
161
+ });
162
+
163
+ it('adds connector when label is offset from anchor', () => {
164
+ const labels: LabelCandidate[] = [
165
+ {
166
+ text: 'A',
167
+ anchorX: 50,
168
+ anchorY: 50,
169
+ width: 40,
170
+ height: 14,
171
+ priority: 'data',
172
+ style: defaultStyle,
173
+ },
174
+ {
175
+ text: 'B',
176
+ anchorX: 55,
177
+ anchorY: 52,
178
+ width: 40,
179
+ height: 14,
180
+ priority: 'data',
181
+ style: defaultStyle,
182
+ },
183
+ ];
184
+
185
+ const results = resolveCollisions(labels);
186
+ const offsetLabel = results.find((r) => r.connector !== undefined);
187
+ if (offsetLabel) {
188
+ expect(offsetLabel.connector!.to.x).toBe(
189
+ labels.find((l) => l.text === offsetLabel.text)!.anchorX,
190
+ );
191
+ }
192
+ });
193
+
194
+ it('handles empty input', () => {
195
+ expect(resolveCollisions([])).toEqual([]);
196
+ });
197
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Label collision detection and resolution.
3
+ *
4
+ * Greedy algorithm: sort by priority, place in order, try offset
5
+ * positions for conflicts, demote to tooltip-only if no position works.
6
+ * Targeting ~60% of Infrographic quality for Phase 0.
7
+ */
8
+
9
+ import type { Rect, ResolvedLabel, TextStyle } from '../types/layout';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Priority levels for label placement. Data labels win over annotations which win over axes. */
16
+ export type LabelPriority = 'data' | 'annotation' | 'axis';
17
+
18
+ /** Priority sort order (lower = higher priority). */
19
+ const PRIORITY_ORDER: Record<LabelPriority, number> = {
20
+ data: 0,
21
+ annotation: 1,
22
+ axis: 2,
23
+ };
24
+
25
+ /**
26
+ * A label candidate for collision resolution.
27
+ * The collision engine decides its final position and visibility.
28
+ */
29
+ export interface LabelCandidate {
30
+ /** The label text. */
31
+ text: string;
32
+ /** Preferred anchor position (before collision resolution). */
33
+ anchorX: number;
34
+ /** Preferred anchor position (before collision resolution). */
35
+ anchorY: number;
36
+ /** Estimated width of the label text. */
37
+ width: number;
38
+ /** Estimated height of the label text. */
39
+ height: number;
40
+ /** Label priority for collision resolution. */
41
+ priority: LabelPriority;
42
+ /** Text style to apply. */
43
+ style: TextStyle;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Collision detection
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Detect AABB (axis-aligned bounding box) overlap between two rectangles.
52
+ */
53
+ export function detectCollision(a: Rect, b: Rect): boolean {
54
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Offset strategies
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** Offsets to try when a label collides with an existing placement. */
62
+ const OFFSET_STRATEGIES = [
63
+ { dx: 0, dy: 0 }, // original position
64
+ { dx: 0, dy: -1.2 }, // above (factor of height)
65
+ { dx: 0, dy: 1.2 }, // below
66
+ { dx: 1.1, dy: 0 }, // right
67
+ { dx: -1.1, dy: 0 }, // left
68
+ { dx: 1.1, dy: -1.2 }, // upper-right
69
+ { dx: -1.1, dy: -1.2 }, // upper-left
70
+ ];
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Public API
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Resolve label collisions using a greedy placement algorithm.
78
+ *
79
+ * Sorts labels by priority (data > annotation > axis), then places
80
+ * each label at its preferred position. If it collides with an already-placed
81
+ * label, tries offset positions. If no position works, the label is
82
+ * demoted to tooltip-only (visible: false).
83
+ *
84
+ * @param labels - Array of label candidates to position.
85
+ * @returns Array of resolved labels with computed positions and visibility.
86
+ */
87
+ export function resolveCollisions(labels: LabelCandidate[]): ResolvedLabel[] {
88
+ // Sort by priority (highest first)
89
+ const sorted = [...labels].sort(
90
+ (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
91
+ );
92
+
93
+ const placed: Rect[] = [];
94
+ const results: ResolvedLabel[] = [];
95
+
96
+ for (const label of sorted) {
97
+ let bestRect: Rect | null = null;
98
+ let bestX = label.anchorX;
99
+ let bestY = label.anchorY;
100
+
101
+ // Try each offset strategy
102
+ for (const offset of OFFSET_STRATEGIES) {
103
+ const candidateX = label.anchorX + offset.dx * label.width;
104
+ const candidateY = label.anchorY + offset.dy * label.height;
105
+ const candidateRect: Rect = {
106
+ x: candidateX,
107
+ y: candidateY,
108
+ width: label.width,
109
+ height: label.height,
110
+ };
111
+
112
+ const hasCollision = placed.some((p) => detectCollision(candidateRect, p));
113
+
114
+ if (!hasCollision) {
115
+ bestRect = candidateRect;
116
+ bestX = candidateX;
117
+ bestY = candidateY;
118
+ break;
119
+ }
120
+ }
121
+
122
+ if (bestRect) {
123
+ placed.push(bestRect);
124
+ const needsConnector = bestX !== label.anchorX || bestY !== label.anchorY;
125
+
126
+ results.push({
127
+ text: label.text,
128
+ x: bestX,
129
+ y: bestY,
130
+ style: label.style,
131
+ visible: true,
132
+ connector: needsConnector
133
+ ? {
134
+ from: { x: bestX, y: bestY },
135
+ to: { x: label.anchorX, y: label.anchorY },
136
+ stroke: label.style.fill,
137
+ style: 'straight' as const,
138
+ }
139
+ : undefined,
140
+ });
141
+ } else {
142
+ // No position works, demote to tooltip-only
143
+ results.push({
144
+ text: label.text,
145
+ x: label.anchorX,
146
+ y: label.anchorY,
147
+ style: label.style,
148
+ visible: false,
149
+ });
150
+ }
151
+ }
152
+
153
+ return results;
154
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Labels module barrel export.
3
+ */
4
+
5
+ export type { LabelCandidate, LabelPriority } from './collision';
6
+ export { detectCollision, resolveCollisions } from './collision';
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveTheme } from '../../theme/resolve';
3
+ import type { Chrome } from '../../types/spec';
4
+ import { computeChrome } from '../chrome';
5
+
6
+ const theme = resolveTheme();
7
+
8
+ describe('computeChrome', () => {
9
+ it('returns zero heights when chrome is undefined', () => {
10
+ const result = computeChrome(undefined, theme, 600);
11
+ expect(result.topHeight).toBe(0);
12
+ expect(result.bottomHeight).toBe(0);
13
+ expect(result.title).toBeUndefined();
14
+ expect(result.subtitle).toBeUndefined();
15
+ });
16
+
17
+ it('returns zero heights when chrome is empty', () => {
18
+ const result = computeChrome({}, theme, 600);
19
+ expect(result.topHeight).toBe(0);
20
+ expect(result.bottomHeight).toBe(0);
21
+ });
22
+
23
+ it('positions title correctly', () => {
24
+ const chrome: Chrome = { title: 'GDP Growth Rate' };
25
+ const result = computeChrome(chrome, theme, 600);
26
+
27
+ expect(result.title).toBeDefined();
28
+ expect(result.title!.text).toBe('GDP Growth Rate');
29
+ expect(result.title!.x).toBe(theme.spacing.padding);
30
+ expect(result.title!.y).toBe(theme.spacing.padding);
31
+ expect(result.title!.style.fontSize).toBe(theme.chrome.title.fontSize);
32
+ expect(result.title!.style.fontWeight).toBe(theme.chrome.title.fontWeight);
33
+ expect(result.topHeight).toBeGreaterThan(0);
34
+ });
35
+
36
+ it('positions subtitle below title', () => {
37
+ const chrome: Chrome = { title: 'Title', subtitle: 'Subtitle text' };
38
+ const result = computeChrome(chrome, theme, 600);
39
+
40
+ expect(result.title).toBeDefined();
41
+ expect(result.subtitle).toBeDefined();
42
+ expect(result.subtitle!.y).toBeGreaterThan(result.title!.y);
43
+ });
44
+
45
+ it('computes top height from title + subtitle + gaps', () => {
46
+ const chrome: Chrome = { title: 'Title', subtitle: 'Subtitle' };
47
+ const result = computeChrome(chrome, theme, 600);
48
+
49
+ // Top height should account for title, gap, subtitle, and chromeToChart
50
+ expect(result.topHeight).toBeGreaterThan(30);
51
+ });
52
+
53
+ it('positions source in bottom chrome', () => {
54
+ const chrome: Chrome = { source: 'Source: World Bank' };
55
+ const result = computeChrome(chrome, theme, 600);
56
+
57
+ expect(result.source).toBeDefined();
58
+ expect(result.source!.text).toBe('Source: World Bank');
59
+ expect(result.bottomHeight).toBeGreaterThan(0);
60
+ });
61
+
62
+ it('handles ChromeText objects with style overrides', () => {
63
+ const chrome: Chrome = {
64
+ title: {
65
+ text: 'Custom Title',
66
+ style: { fontSize: 24, fontWeight: 700, color: '#ff0000' },
67
+ },
68
+ };
69
+ const result = computeChrome(chrome, theme, 600);
70
+
71
+ expect(result.title!.style.fontSize).toBe(24);
72
+ expect(result.title!.style.fontWeight).toBe(700);
73
+ expect(result.title!.style.fill).toBe('#ff0000');
74
+ });
75
+
76
+ it('sets maxWidth based on width minus padding', () => {
77
+ const chrome: Chrome = { title: 'Title' };
78
+ const result = computeChrome(chrome, theme, 600);
79
+
80
+ const expectedMaxWidth = 600 - theme.spacing.padding * 2;
81
+ expect(result.title!.maxWidth).toBe(expectedMaxWidth);
82
+ });
83
+
84
+ it('handles full chrome with all elements', () => {
85
+ const chrome: Chrome = {
86
+ title: 'Title',
87
+ subtitle: 'Subtitle',
88
+ source: 'Source',
89
+ byline: 'By Author',
90
+ footer: 'Footer note',
91
+ };
92
+ const result = computeChrome(chrome, theme, 600);
93
+
94
+ expect(result.title).toBeDefined();
95
+ expect(result.subtitle).toBeDefined();
96
+ expect(result.source).toBeDefined();
97
+ expect(result.byline).toBeDefined();
98
+ expect(result.footer).toBeDefined();
99
+ expect(result.topHeight).toBeGreaterThan(0);
100
+ expect(result.bottomHeight).toBeGreaterThan(0);
101
+ });
102
+
103
+ it('uses measureText function when provided', () => {
104
+ const measureText = (text: string, fontSize: number) => ({
105
+ width: text.length * fontSize * 0.6,
106
+ height: fontSize * 1.2,
107
+ });
108
+
109
+ const chrome: Chrome = { title: 'Title' };
110
+ const result = computeChrome(chrome, theme, 600, measureText);
111
+ expect(result.title).toBeDefined();
112
+ // Just verify it runs without error; exact values depend on measure fn
113
+ });
114
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { estimateTextHeight, estimateTextWidth } from '../text-measure';
3
+
4
+ describe('estimateTextWidth', () => {
5
+ it('returns 0 for empty string', () => {
6
+ expect(estimateTextWidth('', 14)).toBe(0);
7
+ });
8
+
9
+ it('scales linearly with text length', () => {
10
+ const w5 = estimateTextWidth('hello', 14);
11
+ const w10 = estimateTextWidth('helloworld', 14);
12
+ expect(w10).toBeCloseTo(w5 * 2, 1);
13
+ });
14
+
15
+ it('scales with font size', () => {
16
+ const w14 = estimateTextWidth('test', 14);
17
+ const w28 = estimateTextWidth('test', 28);
18
+ expect(w28).toBeCloseTo(w14 * 2, 1);
19
+ });
20
+
21
+ it('heavier weights produce wider estimates', () => {
22
+ const normal = estimateTextWidth('test', 14, 400);
23
+ const bold = estimateTextWidth('test', 14, 700);
24
+ expect(bold).toBeGreaterThan(normal);
25
+ });
26
+
27
+ it('produces reasonable values for common cases', () => {
28
+ // "GDP Growth Rate" at 18px should be roughly 120-180px wide
29
+ const width = estimateTextWidth('GDP Growth Rate', 18);
30
+ expect(width).toBeGreaterThan(100);
31
+ expect(width).toBeLessThan(250);
32
+ });
33
+ });
34
+
35
+ describe('estimateTextHeight', () => {
36
+ it('single line at 14px with 1.3 lineHeight is 18.2', () => {
37
+ expect(estimateTextHeight(14, 1, 1.3)).toBeCloseTo(18.2, 1);
38
+ });
39
+
40
+ it('two lines are double the height', () => {
41
+ const h1 = estimateTextHeight(14, 1);
42
+ const h2 = estimateTextHeight(14, 2);
43
+ expect(h2).toBeCloseTo(h1 * 2, 1);
44
+ });
45
+
46
+ it('defaults to 1 line and 1.3 lineHeight', () => {
47
+ expect(estimateTextHeight(10)).toBeCloseTo(13, 1);
48
+ });
49
+ });