@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.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table cell renderers: produce DOM elements for each cell type.
|
|
3
|
+
*
|
|
4
|
+
* Each renderer takes a resolved TableCell and returns an HTMLElement
|
|
5
|
+
* (typically a <td>) with appropriate content, styling, and classes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BarTableCell,
|
|
10
|
+
CategoryTableCell,
|
|
11
|
+
FlagTableCell,
|
|
12
|
+
HeatmapTableCell,
|
|
13
|
+
ImageTableCell,
|
|
14
|
+
SparklineData,
|
|
15
|
+
SparklineTableCell,
|
|
16
|
+
TableCell,
|
|
17
|
+
TextTableCell,
|
|
18
|
+
} from '@opendata-ai/openchart-core';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Utility
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Apply common cell styles (background, color, font weight, font variant). */
|
|
25
|
+
function applyCellStyle(td: HTMLTableCellElement, cell: TableCell): void {
|
|
26
|
+
if (cell.style.backgroundColor) {
|
|
27
|
+
td.style.background = cell.style.backgroundColor;
|
|
28
|
+
}
|
|
29
|
+
if (cell.style.color) {
|
|
30
|
+
td.style.color = cell.style.color;
|
|
31
|
+
}
|
|
32
|
+
if (cell.style.fontWeight) {
|
|
33
|
+
td.style.fontWeight = String(cell.style.fontWeight);
|
|
34
|
+
}
|
|
35
|
+
if (cell.style.fontVariant) {
|
|
36
|
+
td.style.fontVariant = cell.style.fontVariant;
|
|
37
|
+
}
|
|
38
|
+
if (cell.aria) {
|
|
39
|
+
td.setAttribute('aria-label', cell.aria);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert a 2-char ISO 3166-1 alpha-2 country code to a flag emoji.
|
|
45
|
+
* Each letter maps to a Regional Indicator Symbol (offset 0x1F1A5 from ASCII).
|
|
46
|
+
*/
|
|
47
|
+
function countryToEmoji(code: string): string {
|
|
48
|
+
return [...code.toUpperCase()]
|
|
49
|
+
.map((c) => String.fromCodePoint(c.charCodeAt(0) + 0x1f1a5))
|
|
50
|
+
.join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Lookup map for common country names by ISO 3166-1 alpha-2 code.
|
|
55
|
+
* Falls back to the raw code for unrecognized values.
|
|
56
|
+
*/
|
|
57
|
+
const COUNTRY_NAMES: Record<string, string> = {
|
|
58
|
+
US: 'United States',
|
|
59
|
+
CN: 'China',
|
|
60
|
+
IN: 'India',
|
|
61
|
+
ID: 'Indonesia',
|
|
62
|
+
BR: 'Brazil',
|
|
63
|
+
PK: 'Pakistan',
|
|
64
|
+
NG: 'Nigeria',
|
|
65
|
+
BD: 'Bangladesh',
|
|
66
|
+
RU: 'Russia',
|
|
67
|
+
MX: 'Mexico',
|
|
68
|
+
JP: 'Japan',
|
|
69
|
+
DE: 'Germany',
|
|
70
|
+
GB: 'United Kingdom',
|
|
71
|
+
FR: 'France',
|
|
72
|
+
IT: 'Italy',
|
|
73
|
+
CA: 'Canada',
|
|
74
|
+
AU: 'Australia',
|
|
75
|
+
KR: 'South Korea',
|
|
76
|
+
ES: 'Spain',
|
|
77
|
+
AR: 'Argentina',
|
|
78
|
+
CO: 'Colombia',
|
|
79
|
+
ZA: 'South Africa',
|
|
80
|
+
TR: 'Turkey',
|
|
81
|
+
SA: 'Saudi Arabia',
|
|
82
|
+
UA: 'Ukraine',
|
|
83
|
+
PL: 'Poland',
|
|
84
|
+
NL: 'Netherlands',
|
|
85
|
+
SE: 'Sweden',
|
|
86
|
+
NO: 'Norway',
|
|
87
|
+
DK: 'Denmark',
|
|
88
|
+
FI: 'Finland',
|
|
89
|
+
CH: 'Switzerland',
|
|
90
|
+
AT: 'Austria',
|
|
91
|
+
BE: 'Belgium',
|
|
92
|
+
PT: 'Portugal',
|
|
93
|
+
IE: 'Ireland',
|
|
94
|
+
NZ: 'New Zealand',
|
|
95
|
+
SG: 'Singapore',
|
|
96
|
+
IL: 'Israel',
|
|
97
|
+
AE: 'United Arab Emirates',
|
|
98
|
+
EG: 'Egypt',
|
|
99
|
+
TH: 'Thailand',
|
|
100
|
+
VN: 'Vietnam',
|
|
101
|
+
PH: 'Philippines',
|
|
102
|
+
MY: 'Malaysia',
|
|
103
|
+
CL: 'Chile',
|
|
104
|
+
PE: 'Peru',
|
|
105
|
+
CZ: 'Czech Republic',
|
|
106
|
+
GR: 'Greece',
|
|
107
|
+
HU: 'Hungary',
|
|
108
|
+
RO: 'Romania',
|
|
109
|
+
ET: 'Ethiopia',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Get a human-readable name for a country code. */
|
|
113
|
+
function getCountryName(code: string): string {
|
|
114
|
+
return COUNTRY_NAMES[code.toUpperCase()] || code.toUpperCase();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Describe a sparkline trend for screen readers.
|
|
119
|
+
* Compares first and last values to determine direction.
|
|
120
|
+
*/
|
|
121
|
+
function describeSparklineTrend(data: SparklineData): string {
|
|
122
|
+
if (data.type === 'line' && data.points.length >= 2) {
|
|
123
|
+
const first = data.points[0].y;
|
|
124
|
+
const last = data.points[data.points.length - 1].y;
|
|
125
|
+
const count = data.points.length;
|
|
126
|
+
if (last > first) return `Sparkline with ${count} points, trending upward`;
|
|
127
|
+
if (last < first) return `Sparkline with ${count} points, trending downward`;
|
|
128
|
+
return `Sparkline with ${count} points, roughly flat`;
|
|
129
|
+
}
|
|
130
|
+
if ((data.type === 'bar' || data.type === 'column') && data.bars.length > 0) {
|
|
131
|
+
return `${data.type === 'column' ? 'Column' : 'Bar'} sparkline with ${data.bars.length} values`;
|
|
132
|
+
}
|
|
133
|
+
return 'Sparkline';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Cell renderers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/** Render a plain text cell. */
|
|
141
|
+
export function renderTextCell(cell: TextTableCell): HTMLTableCellElement {
|
|
142
|
+
const td = document.createElement('td');
|
|
143
|
+
td.textContent = cell.formattedValue;
|
|
144
|
+
applyCellStyle(td, cell);
|
|
145
|
+
return td;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Render a heatmap-colored cell. */
|
|
149
|
+
export function renderHeatmapCell(cell: HeatmapTableCell): HTMLTableCellElement {
|
|
150
|
+
const td = document.createElement('td');
|
|
151
|
+
td.textContent = cell.formattedValue;
|
|
152
|
+
applyCellStyle(td, cell);
|
|
153
|
+
return td;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Render a category-colored cell. */
|
|
157
|
+
export function renderCategoryCell(cell: CategoryTableCell): HTMLTableCellElement {
|
|
158
|
+
const td = document.createElement('td');
|
|
159
|
+
td.textContent = cell.formattedValue;
|
|
160
|
+
applyCellStyle(td, cell);
|
|
161
|
+
return td;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Render a cell with an inline bar visualization. */
|
|
165
|
+
export function renderBarCell(cell: BarTableCell): HTMLTableCellElement {
|
|
166
|
+
const td = document.createElement('td');
|
|
167
|
+
td.className = 'viz-table-bar';
|
|
168
|
+
applyCellStyle(td, cell);
|
|
169
|
+
|
|
170
|
+
const fill = document.createElement('div');
|
|
171
|
+
fill.className = 'viz-table-bar-fill';
|
|
172
|
+
fill.style.width = `${Math.round(cell.barWidth * 100)}%`;
|
|
173
|
+
fill.style.left = `${Math.round(cell.barOffset * 100)}%`;
|
|
174
|
+
fill.style.background = cell.barColor;
|
|
175
|
+
td.appendChild(fill);
|
|
176
|
+
|
|
177
|
+
const valueSpan = document.createElement('span');
|
|
178
|
+
valueSpan.className = 'viz-table-bar-value';
|
|
179
|
+
valueSpan.textContent = cell.formattedValue;
|
|
180
|
+
td.appendChild(valueSpan);
|
|
181
|
+
|
|
182
|
+
return td;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format a sparkline endpoint value for display.
|
|
187
|
+
* Keeps it compact: no decimals for values >= 100, one decimal otherwise.
|
|
188
|
+
*/
|
|
189
|
+
function formatSparklineValue(v: number): string {
|
|
190
|
+
if (Math.abs(v) >= 1000) {
|
|
191
|
+
return v.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
|
192
|
+
}
|
|
193
|
+
if (Math.abs(v) >= 100) {
|
|
194
|
+
return v.toFixed(0);
|
|
195
|
+
}
|
|
196
|
+
return v.toFixed(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render a cell with an inline sparkline SVG. */
|
|
200
|
+
export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElement {
|
|
201
|
+
const td = document.createElement('td');
|
|
202
|
+
applyCellStyle(td, cell);
|
|
203
|
+
|
|
204
|
+
const sparklineData = cell.sparklineData;
|
|
205
|
+
if (!sparklineData || sparklineData.count === 0) {
|
|
206
|
+
td.textContent = cell.formattedValue || '';
|
|
207
|
+
return td;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Add aria-label describing the trend for screen readers
|
|
211
|
+
const trendDescription = describeSparklineTrend(sparklineData);
|
|
212
|
+
if (!td.getAttribute('aria-label')) {
|
|
213
|
+
td.setAttribute('aria-label', trendDescription);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const wrapper = document.createElement('span');
|
|
217
|
+
wrapper.className = 'viz-table-sparkline';
|
|
218
|
+
|
|
219
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
220
|
+
|
|
221
|
+
if (sparklineData.type === 'line') {
|
|
222
|
+
// Infrographic-style sparkline: SVG polyline fills cell width via percentage x-coords.
|
|
223
|
+
// Dots are HTML elements (not SVG circles) to avoid aspect-ratio distortion.
|
|
224
|
+
// Labels are HTML below the SVG.
|
|
225
|
+
const svgH = 28;
|
|
226
|
+
const padY = 4;
|
|
227
|
+
const lineH = svgH - padY * 2;
|
|
228
|
+
|
|
229
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
230
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
231
|
+
svg.setAttribute('xmlns', svgNS);
|
|
232
|
+
svg.style.height = `${svgH}px`;
|
|
233
|
+
|
|
234
|
+
// Compute y positions in pixel space (no viewBox scaling)
|
|
235
|
+
const yPositions = sparklineData.points.map((p) => padY + (1 - p.y) * lineH);
|
|
236
|
+
|
|
237
|
+
// SVG polyline doesn't support % in points attribute, so use a viewBox
|
|
238
|
+
// with a wide aspect ratio and map x to 0-1000 range. preserveAspectRatio="none"
|
|
239
|
+
// stretches the x-axis to fill the cell width. Y values stay in pixel space
|
|
240
|
+
// since viewBox height matches the SVG height. Only the polyline is in the SVG;
|
|
241
|
+
// dots are HTML elements to avoid circle distortion from the non-uniform scaling.
|
|
242
|
+
const viewW = 1000;
|
|
243
|
+
svg.setAttribute('viewBox', `0 0 ${viewW} ${svgH}`);
|
|
244
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
245
|
+
|
|
246
|
+
const ptsScaled = sparklineData.points.map((p, i) => ({
|
|
247
|
+
x: p.x * viewW,
|
|
248
|
+
y: yPositions[i],
|
|
249
|
+
}));
|
|
250
|
+
const scaledPointsStr = ptsScaled.map((p) => `${p.x},${p.y}`).join(' ');
|
|
251
|
+
|
|
252
|
+
const polyline = document.createElementNS(svgNS, 'polyline');
|
|
253
|
+
polyline.setAttribute('points', scaledPointsStr);
|
|
254
|
+
polyline.setAttribute('fill', 'none');
|
|
255
|
+
polyline.setAttribute('stroke', sparklineData.color);
|
|
256
|
+
polyline.setAttribute('stroke-width', '1.5');
|
|
257
|
+
polyline.setAttribute('stroke-linejoin', 'round');
|
|
258
|
+
polyline.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
259
|
+
svg.appendChild(polyline);
|
|
260
|
+
|
|
261
|
+
wrapper.appendChild(svg);
|
|
262
|
+
|
|
263
|
+
// HTML dots at start and end (positioned absolutely over the SVG)
|
|
264
|
+
const firstY = yPositions[0];
|
|
265
|
+
const lastY = yPositions[yPositions.length - 1];
|
|
266
|
+
const dotSize = 5;
|
|
267
|
+
|
|
268
|
+
const startDot = document.createElement('span');
|
|
269
|
+
startDot.className = 'viz-table-sparkline-dot';
|
|
270
|
+
startDot.style.left = '0';
|
|
271
|
+
startDot.style.top = `${firstY - dotSize / 2}px`;
|
|
272
|
+
startDot.style.background = sparklineData.color;
|
|
273
|
+
wrapper.appendChild(startDot);
|
|
274
|
+
|
|
275
|
+
const endDot = document.createElement('span');
|
|
276
|
+
endDot.className = 'viz-table-sparkline-dot';
|
|
277
|
+
endDot.style.right = '0';
|
|
278
|
+
endDot.style.top = `${lastY - dotSize / 2}px`;
|
|
279
|
+
endDot.style.background = sparklineData.color;
|
|
280
|
+
wrapper.appendChild(endDot);
|
|
281
|
+
|
|
282
|
+
// HTML labels below the SVG, positioned at left and right edges
|
|
283
|
+
const labelsRow = document.createElement('span');
|
|
284
|
+
labelsRow.className = 'viz-table-sparkline-labels';
|
|
285
|
+
labelsRow.style.color = sparklineData.color;
|
|
286
|
+
|
|
287
|
+
const startLabel = document.createElement('span');
|
|
288
|
+
startLabel.textContent = formatSparklineValue(sparklineData.startValue);
|
|
289
|
+
labelsRow.appendChild(startLabel);
|
|
290
|
+
|
|
291
|
+
const endLabel = document.createElement('span');
|
|
292
|
+
endLabel.textContent = formatSparklineValue(sparklineData.endValue);
|
|
293
|
+
labelsRow.appendChild(endLabel);
|
|
294
|
+
|
|
295
|
+
wrapper.appendChild(labelsRow);
|
|
296
|
+
} else if (sparklineData.type === 'column') {
|
|
297
|
+
// Vertical bars at proportional heights
|
|
298
|
+
const width = 80;
|
|
299
|
+
const height = 20;
|
|
300
|
+
const padding = 2;
|
|
301
|
+
const innerW = width - padding * 2;
|
|
302
|
+
const innerH = height - padding * 2;
|
|
303
|
+
|
|
304
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
305
|
+
svg.setAttribute('width', String(width));
|
|
306
|
+
svg.setAttribute('height', String(height));
|
|
307
|
+
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
308
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
309
|
+
|
|
310
|
+
const barCount = sparklineData.bars.length;
|
|
311
|
+
if (barCount > 0) {
|
|
312
|
+
const gap = 1;
|
|
313
|
+
const barW = Math.max(1, (innerW - gap * (barCount - 1)) / barCount);
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < barCount; i++) {
|
|
316
|
+
const barH = Math.max(1, sparklineData.bars[i] * innerH);
|
|
317
|
+
const x = padding + i * (barW + gap);
|
|
318
|
+
const y = padding + innerH - barH;
|
|
319
|
+
|
|
320
|
+
const rect = document.createElementNS(svgNS, 'rect');
|
|
321
|
+
rect.setAttribute('x', String(x));
|
|
322
|
+
rect.setAttribute('y', String(y));
|
|
323
|
+
rect.setAttribute('width', String(barW));
|
|
324
|
+
rect.setAttribute('height', String(barH));
|
|
325
|
+
rect.setAttribute('rx', '1.5');
|
|
326
|
+
rect.setAttribute('fill', sparklineData.color);
|
|
327
|
+
svg.appendChild(rect);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
wrapper.appendChild(svg);
|
|
332
|
+
} else {
|
|
333
|
+
// 'bar' type: horizontal bars at proportional widths
|
|
334
|
+
const width = 80;
|
|
335
|
+
const height = 20;
|
|
336
|
+
const padding = 2;
|
|
337
|
+
const innerW = width - padding * 2;
|
|
338
|
+
const innerH = height - padding * 2;
|
|
339
|
+
|
|
340
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
341
|
+
svg.setAttribute('width', String(width));
|
|
342
|
+
svg.setAttribute('height', String(height));
|
|
343
|
+
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
344
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
345
|
+
|
|
346
|
+
const barCount = sparklineData.bars.length;
|
|
347
|
+
if (barCount > 0) {
|
|
348
|
+
const gap = 1;
|
|
349
|
+
const barH = Math.max(1, (innerH - gap * (barCount - 1)) / barCount);
|
|
350
|
+
|
|
351
|
+
for (let i = 0; i < barCount; i++) {
|
|
352
|
+
const barW = Math.max(1, sparklineData.bars[i] * innerW);
|
|
353
|
+
const x = padding;
|
|
354
|
+
const y = padding + i * (barH + gap);
|
|
355
|
+
|
|
356
|
+
const rect = document.createElementNS(svgNS, 'rect');
|
|
357
|
+
rect.setAttribute('x', String(x));
|
|
358
|
+
rect.setAttribute('y', String(y));
|
|
359
|
+
rect.setAttribute('width', String(barW));
|
|
360
|
+
rect.setAttribute('height', String(barH));
|
|
361
|
+
rect.setAttribute('rx', '1.5');
|
|
362
|
+
rect.setAttribute('fill', sparklineData.color);
|
|
363
|
+
svg.appendChild(rect);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
wrapper.appendChild(svg);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
td.appendChild(wrapper);
|
|
371
|
+
|
|
372
|
+
return td;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Render a cell with an image. */
|
|
376
|
+
export function renderImageCell(cell: ImageTableCell): HTMLTableCellElement {
|
|
377
|
+
const td = document.createElement('td');
|
|
378
|
+
applyCellStyle(td, cell);
|
|
379
|
+
|
|
380
|
+
const wrapper = document.createElement('span');
|
|
381
|
+
wrapper.className = `viz-table-image${cell.rounded ? ' viz-table-image-rounded' : ''}`;
|
|
382
|
+
|
|
383
|
+
const img = document.createElement('img');
|
|
384
|
+
img.src = cell.src;
|
|
385
|
+
img.alt = cell.formattedValue || '';
|
|
386
|
+
img.width = cell.imageWidth;
|
|
387
|
+
img.height = cell.imageHeight;
|
|
388
|
+
img.loading = 'lazy';
|
|
389
|
+
|
|
390
|
+
wrapper.appendChild(img);
|
|
391
|
+
td.appendChild(wrapper);
|
|
392
|
+
|
|
393
|
+
return td;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Render a cell with a country flag emoji. */
|
|
397
|
+
export function renderFlagCell(cell: FlagTableCell): HTMLTableCellElement {
|
|
398
|
+
const td = document.createElement('td');
|
|
399
|
+
applyCellStyle(td, cell);
|
|
400
|
+
|
|
401
|
+
const span = document.createElement('span');
|
|
402
|
+
span.className = 'viz-table-flag';
|
|
403
|
+
span.setAttribute('role', 'img');
|
|
404
|
+
|
|
405
|
+
if (cell.countryCode && cell.countryCode.length === 2) {
|
|
406
|
+
const countryName = getCountryName(cell.countryCode);
|
|
407
|
+
span.textContent = countryToEmoji(cell.countryCode);
|
|
408
|
+
span.setAttribute('aria-label', `Flag: ${countryName}`);
|
|
409
|
+
} else {
|
|
410
|
+
span.textContent = cell.formattedValue;
|
|
411
|
+
span.setAttribute('aria-label', cell.formattedValue);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
td.appendChild(span);
|
|
415
|
+
|
|
416
|
+
return td;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Dispatcher
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/** Render any table cell by dispatching on its cellType. */
|
|
424
|
+
export function renderCell(cell: TableCell): HTMLTableCellElement {
|
|
425
|
+
switch (cell.cellType) {
|
|
426
|
+
case 'text':
|
|
427
|
+
return renderTextCell(cell);
|
|
428
|
+
case 'heatmap':
|
|
429
|
+
return renderHeatmapCell(cell);
|
|
430
|
+
case 'category':
|
|
431
|
+
return renderCategoryCell(cell);
|
|
432
|
+
case 'bar':
|
|
433
|
+
return renderBarCell(cell);
|
|
434
|
+
case 'sparkline':
|
|
435
|
+
return renderSparklineCell(cell);
|
|
436
|
+
case 'image':
|
|
437
|
+
return renderImageCell(cell);
|
|
438
|
+
case 'flag':
|
|
439
|
+
return renderFlagCell(cell);
|
|
440
|
+
default:
|
|
441
|
+
// Exhaustive check fallback
|
|
442
|
+
return renderTextCell(cell as TextTableCell);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resize observer: thin wrapper around ResizeObserver with debounce.
|
|
3
|
+
*
|
|
4
|
+
* Watches a container element for size changes and calls back with
|
|
5
|
+
* the new width and height. Debounced at ~60fps (16ms) to avoid
|
|
6
|
+
* excessive re-renders during drag resizes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEBOUNCE_MS = 16;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Observe a container element for size changes.
|
|
13
|
+
*
|
|
14
|
+
* @param container - The element to watch.
|
|
15
|
+
* @param callback - Called with (width, height) when size changes.
|
|
16
|
+
* @returns A cleanup function that disconnects the observer.
|
|
17
|
+
*/
|
|
18
|
+
export function observeResize(
|
|
19
|
+
container: HTMLElement,
|
|
20
|
+
callback: (width: number, height: number) => void,
|
|
21
|
+
): () => void {
|
|
22
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
|
|
24
|
+
const observer = new ResizeObserver((entries) => {
|
|
25
|
+
// Debounce to ~60fps
|
|
26
|
+
if (timeoutId !== null) {
|
|
27
|
+
clearTimeout(timeoutId);
|
|
28
|
+
}
|
|
29
|
+
timeoutId = setTimeout(() => {
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const { width, height } = entry.contentRect;
|
|
32
|
+
callback(width, height);
|
|
33
|
+
}
|
|
34
|
+
timeoutId = null;
|
|
35
|
+
}, DEBOUNCE_MS);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
observer.observe(container);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
if (timeoutId !== null) {
|
|
42
|
+
clearTimeout(timeoutId);
|
|
43
|
+
}
|
|
44
|
+
observer.disconnect();
|
|
45
|
+
};
|
|
46
|
+
}
|