@opendata-ai/openchart-vanilla 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 (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Table renderer: produces semantic HTML from a TableLayout.
3
+ *
4
+ * renderTable() creates the full DOM structure: chrome, search bar,
5
+ * scrollable table with sticky column support, pagination, and footer.
6
+ * The returned element replaces or appends to the given container.
7
+ */
8
+
9
+ import type { ResolvedColumn, TableLayout, TableRow } from '@opendata-ai/openchart-core';
10
+ import { renderCell } from './renderers/table-cells';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Chrome rendering
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Create chrome (title/subtitle or source/footer) block. */
17
+ function renderChromeBlock(
18
+ layout: TableLayout,
19
+ position: 'header' | 'footer',
20
+ ): HTMLDivElement | null {
21
+ const chrome = layout.chrome;
22
+
23
+ if (position === 'header') {
24
+ if (!chrome.title && !chrome.subtitle) return null;
25
+
26
+ const div = document.createElement('div');
27
+ div.className = 'viz-chrome';
28
+
29
+ if (chrome.title) {
30
+ const h = document.createElement('div');
31
+ h.className = 'viz-table-title';
32
+ h.textContent = chrome.title.text;
33
+ div.appendChild(h);
34
+ }
35
+ if (chrome.subtitle) {
36
+ const sub = document.createElement('div');
37
+ sub.className = 'viz-table-subtitle';
38
+ sub.textContent = chrome.subtitle.text;
39
+ div.appendChild(sub);
40
+ }
41
+
42
+ return div;
43
+ }
44
+
45
+ // Footer position
46
+ if (!chrome.source && !chrome.footer) return null;
47
+
48
+ const div = document.createElement('div');
49
+ div.className = 'viz-chrome viz-chrome-footer';
50
+
51
+ if (chrome.source) {
52
+ const src = document.createElement('div');
53
+ src.className = 'viz-table-source';
54
+ src.textContent = chrome.source.text;
55
+ div.appendChild(src);
56
+ }
57
+ if (chrome.footer) {
58
+ const foot = document.createElement('div');
59
+ foot.className = 'viz-table-footer-text';
60
+ foot.textContent = chrome.footer.text;
61
+ div.appendChild(foot);
62
+ }
63
+
64
+ return div;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Table head
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function renderThead(
72
+ columns: ResolvedColumn[],
73
+ sort: TableLayout['sort'],
74
+ ): HTMLTableSectionElement {
75
+ const thead = document.createElement('thead');
76
+ const tr = document.createElement('tr');
77
+ tr.setAttribute('role', 'row');
78
+
79
+ for (const col of columns) {
80
+ const th = document.createElement('th');
81
+ th.setAttribute('scope', 'col');
82
+ th.setAttribute('role', 'columnheader');
83
+ th.style.textAlign = col.align;
84
+ th.style.width = `${col.width}px`;
85
+
86
+ // Sort state: use 'ascending'/'descending' per WAI-ARIA spec
87
+ let ariaSortValue: string = 'none';
88
+ if (sort && sort.column === col.key) {
89
+ ariaSortValue = sort.direction === 'asc' ? 'ascending' : 'descending';
90
+ }
91
+ th.setAttribute('aria-sort', ariaSortValue);
92
+ th.setAttribute('data-column', col.key);
93
+
94
+ // Label
95
+ const labelSpan = document.createTextNode(col.label);
96
+ th.appendChild(labelSpan);
97
+
98
+ // Sort button
99
+ if (col.sortable) {
100
+ const btn = document.createElement('button');
101
+ btn.className = 'viz-table-sort-btn';
102
+ btn.setAttribute('aria-label', `Sort by ${col.label}`);
103
+ btn.setAttribute('data-sort-column', col.key);
104
+ btn.type = 'button';
105
+ th.appendChild(btn);
106
+ }
107
+
108
+ tr.appendChild(th);
109
+ }
110
+
111
+ thead.appendChild(tr);
112
+ return thead;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Table body
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function renderTbody(rows: TableRow[], columns: ResolvedColumn[]): HTMLTableSectionElement {
120
+ const tbody = document.createElement('tbody');
121
+
122
+ for (let r = 0; r < rows.length; r++) {
123
+ const row = rows[r];
124
+ const tr = document.createElement('tr');
125
+ tr.setAttribute('role', 'row');
126
+ tr.setAttribute('data-row-id', row.id);
127
+
128
+ for (let c = 0; c < columns.length; c++) {
129
+ const cell = row.cells[c];
130
+ if (!cell) continue;
131
+
132
+ const td = renderCell(cell);
133
+ td.setAttribute('role', 'gridcell');
134
+ td.style.textAlign = columns[c].align;
135
+ tr.appendChild(td);
136
+ }
137
+
138
+ tbody.appendChild(tr);
139
+ }
140
+
141
+ return tbody;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Search bar
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function renderSearchBar(layout: TableLayout): HTMLDivElement | null {
149
+ if (!layout.search.enabled) return null;
150
+
151
+ const div = document.createElement('div');
152
+ div.className = 'viz-table-search';
153
+
154
+ const input = document.createElement('input');
155
+ input.type = 'search';
156
+ input.placeholder = layout.search.placeholder;
157
+ input.setAttribute('aria-label', 'Search table');
158
+ input.value = layout.search.query;
159
+
160
+ div.appendChild(input);
161
+ return div;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Pagination
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function renderPagination(layout: TableLayout): HTMLDivElement | null {
169
+ if (!layout.pagination) return null;
170
+
171
+ const { page, pageSize, totalRows, totalPages } = layout.pagination;
172
+
173
+ const div = document.createElement('div');
174
+ div.className = 'viz-table-pagination';
175
+
176
+ const info = document.createElement('span');
177
+ info.className = 'viz-table-pagination-info';
178
+
179
+ if (totalRows === 0) {
180
+ info.textContent = 'No results';
181
+ } else {
182
+ const start = page * pageSize + 1;
183
+ const end = Math.min((page + 1) * pageSize, totalRows);
184
+ info.textContent = `Showing ${start}-${end} of ${totalRows}`;
185
+ }
186
+
187
+ div.appendChild(info);
188
+
189
+ const btnGroup = document.createElement('span');
190
+ btnGroup.className = 'viz-table-pagination-btns';
191
+
192
+ const prevBtn = document.createElement('button');
193
+ prevBtn.setAttribute('aria-label', 'Previous page');
194
+ prevBtn.setAttribute('data-page-action', 'prev');
195
+ prevBtn.textContent = 'Prev';
196
+ prevBtn.disabled = page <= 0;
197
+ btnGroup.appendChild(prevBtn);
198
+
199
+ const nextBtn = document.createElement('button');
200
+ nextBtn.setAttribute('aria-label', 'Next page');
201
+ nextBtn.setAttribute('data-page-action', 'next');
202
+ nextBtn.textContent = 'Next';
203
+ nextBtn.disabled = page >= totalPages - 1;
204
+ btnGroup.appendChild(nextBtn);
205
+
206
+ div.appendChild(btnGroup);
207
+ return div;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Empty state
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function renderEmptyState(message: string): HTMLDivElement {
215
+ const div = document.createElement('div');
216
+ div.className = 'viz-table-empty';
217
+ div.setAttribute('aria-live', 'polite');
218
+ div.textContent = message;
219
+ return div;
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Main render
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Render a TableLayout into a full DOM structure.
228
+ *
229
+ * @param layout - The compiled table layout.
230
+ * @param container - The container element to render into.
231
+ * @returns The wrapper element that was created.
232
+ */
233
+ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLElement {
234
+ const wrapper = document.createElement('div');
235
+ wrapper.className = 'viz-table-wrapper';
236
+
237
+ // Apply theme colors as CSS custom properties so table CSS picks them up.
238
+ // Without this, dark-background themes show invisible text since the
239
+ // CSS defaults (--viz-text etc.) are light-mode values.
240
+ const { theme, chrome } = layout;
241
+ if (theme) {
242
+ const s = wrapper.style;
243
+ s.setProperty('--viz-bg', theme.colors.background);
244
+ s.setProperty('--viz-text', theme.colors.text);
245
+ s.setProperty('--viz-text-secondary', theme.colors.axis ?? theme.colors.text);
246
+ s.setProperty('--viz-text-muted', theme.colors.axis ?? theme.colors.text);
247
+ s.setProperty('--viz-gridline', theme.colors.gridline);
248
+ s.setProperty('--viz-border', theme.colors.gridline);
249
+ s.setProperty('--viz-font-family', theme.fonts.family);
250
+ s.fontFamily = theme.fonts.family;
251
+ }
252
+
253
+ // Set computed chrome CSS custom properties so chrome elements pick up
254
+ // theme-resolved values via CSS fallbacks (e.g. --viz-title-computed-size).
255
+ {
256
+ const s = wrapper.style;
257
+ if (chrome.title) {
258
+ s.setProperty('--viz-title-computed-size', `${chrome.title.style.fontSize}px`);
259
+ s.setProperty('--viz-title-computed-weight', String(chrome.title.style.fontWeight));
260
+ s.setProperty('--viz-title-computed-color', chrome.title.style.fill);
261
+ }
262
+ if (chrome.subtitle) {
263
+ s.setProperty('--viz-subtitle-computed-size', `${chrome.subtitle.style.fontSize}px`);
264
+ s.setProperty('--viz-subtitle-computed-weight', String(chrome.subtitle.style.fontWeight));
265
+ s.setProperty('--viz-subtitle-computed-color', chrome.subtitle.style.fill);
266
+ }
267
+ if (chrome.source) {
268
+ s.setProperty('--viz-source-computed-size', `${chrome.source.style.fontSize}px`);
269
+ s.setProperty('--viz-source-computed-color', chrome.source.style.fill);
270
+ }
271
+ if (chrome.footer) {
272
+ s.setProperty('--viz-footer-computed-size', `${chrome.footer.style.fontSize}px`);
273
+ s.setProperty('--viz-footer-computed-color', chrome.footer.style.fill);
274
+ }
275
+ }
276
+
277
+ // Apply class modifiers
278
+ if (layout.compact) {
279
+ wrapper.classList.add('viz-table--compact');
280
+ }
281
+
282
+ // Header chrome
283
+ const headerChrome = renderChromeBlock(layout, 'header');
284
+ if (headerChrome) {
285
+ wrapper.appendChild(headerChrome);
286
+ }
287
+
288
+ // Search bar
289
+ const searchBar = renderSearchBar(layout);
290
+ if (searchBar) {
291
+ wrapper.appendChild(searchBar);
292
+ }
293
+
294
+ // Handle empty data
295
+ if (layout.rows.length === 0) {
296
+ const message = layout.search.query ? 'No results found' : 'No data';
297
+ wrapper.appendChild(renderEmptyState(message));
298
+ } else {
299
+ // Scroll container
300
+ const scroll = document.createElement('div');
301
+ scroll.className = 'viz-table-scroll';
302
+
303
+ // Table
304
+ const table = document.createElement('table');
305
+ table.setAttribute('role', 'grid');
306
+ table.setAttribute('aria-label', layout.a11y.caption);
307
+
308
+ if (layout.stickyFirstColumn) {
309
+ table.classList.add('viz-table--sticky');
310
+ }
311
+
312
+ // Caption (screen reader only)
313
+ const caption = document.createElement('caption');
314
+ caption.className = 'viz-sr-only';
315
+ caption.textContent = layout.a11y.summary;
316
+ table.appendChild(caption);
317
+
318
+ // Thead
319
+ table.appendChild(renderThead(layout.columns, layout.sort));
320
+
321
+ // Tbody
322
+ table.appendChild(renderTbody(layout.rows, layout.columns));
323
+
324
+ scroll.appendChild(table);
325
+ wrapper.appendChild(scroll);
326
+ }
327
+
328
+ // Pagination
329
+ const pagination = renderPagination(layout);
330
+ if (pagination) {
331
+ wrapper.appendChild(pagination);
332
+ }
333
+
334
+ // Footer chrome
335
+ const footerChrome = renderChromeBlock(layout, 'footer');
336
+ if (footerChrome) {
337
+ wrapper.appendChild(footerChrome);
338
+ }
339
+
340
+ // Live region for screen reader announcements (sort changes, search results)
341
+ const liveRegion = document.createElement('div');
342
+ liveRegion.className = 'viz-table-live-region viz-sr-only';
343
+ liveRegion.setAttribute('aria-live', 'polite');
344
+ liveRegion.setAttribute('aria-atomic', 'true');
345
+ liveRegion.setAttribute('role', 'status');
346
+ wrapper.appendChild(liveRegion);
347
+
348
+ container.appendChild(wrapper);
349
+ return wrapper;
350
+ }
package/src/tooltip.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tooltip manager: creates and positions a floating tooltip element.
3
+ *
4
+ * Shows tooltip content near the mouse/touch position with viewport
5
+ * edge avoidance. Touch support via tap-to-show, tap-outside-to-hide.
6
+ */
7
+
8
+ import type { TooltipContent } from '@opendata-ai/openchart-core';
9
+
10
+ export interface TooltipManager {
11
+ /** Show the tooltip with content at a given position. */
12
+ show(content: TooltipContent, x: number, y: number): void;
13
+ /** Hide the tooltip. */
14
+ hide(): void;
15
+ /** Remove the tooltip element and clean up event listeners. */
16
+ destroy(): void;
17
+ }
18
+
19
+ const TOOLTIP_OFFSET = 12;
20
+
21
+ /**
22
+ * Create a tooltip manager attached to a container element.
23
+ *
24
+ * The manager creates a floating div positioned relative to the container.
25
+ * Content is rendered as a title line with optional color indicator,
26
+ * followed by a compact list of field-value pairs.
27
+ *
28
+ * @param container - The parent element for the tooltip.
29
+ * @returns TooltipManager with show/hide/destroy methods.
30
+ */
31
+ export function createTooltipManager(container: HTMLElement): TooltipManager {
32
+ const tooltip = document.createElement('div');
33
+ tooltip.className = 'viz-tooltip';
34
+ tooltip.setAttribute('role', 'tooltip');
35
+
36
+ container.style.position = container.style.position || 'relative';
37
+ container.appendChild(tooltip);
38
+
39
+ // Hide on tap-outside for touch devices
40
+ const handleDocumentTouch = (e: Event): void => {
41
+ if (!container.contains(e.target as Node)) {
42
+ hide();
43
+ }
44
+ };
45
+ document.addEventListener('touchstart', handleDocumentTouch);
46
+
47
+ function show(content: TooltipContent, x: number, y: number): void {
48
+ let html = '';
49
+
50
+ // Title row: optional color dot + title text
51
+ if (content.title) {
52
+ const titleColor = content.fields.find((f) => f.color)?.color;
53
+ html += '<div class="viz-tooltip-header">';
54
+ if (titleColor) {
55
+ html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
56
+ }
57
+ html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
58
+ html += '</div>';
59
+ }
60
+
61
+ // Field rows
62
+ if (content.fields.length > 0) {
63
+ html += '<div class="viz-tooltip-body">';
64
+ for (const field of content.fields) {
65
+ html += '<div class="viz-tooltip-row">';
66
+ html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
67
+ html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
68
+ html += '</div>';
69
+ }
70
+ html += '</div>';
71
+ }
72
+
73
+ tooltip.innerHTML = html;
74
+ tooltip.style.display = 'block';
75
+
76
+ // Position with viewport edge avoidance
77
+ const containerRect = container.getBoundingClientRect();
78
+ const tooltipRect = tooltip.getBoundingClientRect();
79
+
80
+ let left = x + TOOLTIP_OFFSET;
81
+ let top = y + TOOLTIP_OFFSET;
82
+
83
+ // Flip horizontal if overflowing right
84
+ if (left + tooltipRect.width > containerRect.width) {
85
+ left = x - tooltipRect.width - TOOLTIP_OFFSET;
86
+ }
87
+ // Flip vertical if overflowing bottom
88
+ if (top + tooltipRect.height > containerRect.height) {
89
+ top = y - tooltipRect.height - TOOLTIP_OFFSET;
90
+ }
91
+
92
+ // Clamp to container bounds
93
+ left = Math.max(0, Math.min(left, containerRect.width - tooltipRect.width));
94
+ top = Math.max(0, Math.min(top, containerRect.height - tooltipRect.height));
95
+
96
+ tooltip.style.left = `${left}px`;
97
+ tooltip.style.top = `${top}px`;
98
+ }
99
+
100
+ function hide(): void {
101
+ tooltip.style.display = 'none';
102
+ }
103
+
104
+ function destroy(): void {
105
+ document.removeEventListener('touchstart', handleDocumentTouch);
106
+ if (tooltip.parentNode) {
107
+ tooltip.parentNode.removeChild(tooltip);
108
+ }
109
+ }
110
+
111
+ return { show, hide, destroy };
112
+ }
113
+
114
+ function esc(str: string): string {
115
+ return str
116
+ .replace(/&/g, '&amp;')
117
+ .replace(/</g, '&lt;')
118
+ .replace(/>/g, '&gt;')
119
+ .replace(/"/g, '&quot;');
120
+ }