@opendata-ai/openchart-vanilla 6.25.4 → 6.27.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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * TileMap SVG renderer: converts a TileMapLayout into SVG DOM elements.
3
+ *
4
+ * Creates an <svg> with tile rectangles, state code labels, value labels,
5
+ * gradient legend, and chrome. All styling via inline SVG attributes from
6
+ * layout data. Animation is pure CSS, driven by data attributes.
7
+ */
8
+
9
+ import type {
10
+ ResolvedAnimation,
11
+ TileMapLayout,
12
+ TileMapTileMark,
13
+ } from '@opendata-ai/openchart-core';
14
+
15
+ const SVG_NS = 'http://www.w3.org/2000/svg';
16
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
17
+ const BRAND_URL = 'https://tryopendata.ai';
18
+
19
+ const EASE_VAR_MAP: Record<string, string> = {
20
+ smooth: 'var(--oc-ease-smooth)',
21
+ snappy: 'var(--oc-ease-snappy)',
22
+ };
23
+
24
+ let gradientIdCounter = 0;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function createSVGElement(tag: string): SVGElement {
31
+ return document.createElementNS(SVG_NS, tag);
32
+ }
33
+
34
+ function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
35
+ for (const [key, value] of Object.entries(attrs)) {
36
+ el.setAttribute(key, String(value));
37
+ }
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Chrome rendering
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
45
+ const g = createSVGElement('g');
46
+ g.setAttribute('class', 'oc-chrome');
47
+
48
+ const { chrome } = layout;
49
+ const bottomOffset = layout.area.y + layout.area.height;
50
+
51
+ if (chrome.title) {
52
+ const text = createSVGElement('text');
53
+ setAttrs(text, { x: chrome.title.x, y: chrome.title.y });
54
+ text.setAttribute('class', 'oc-title');
55
+ text.setAttribute('font-family', chrome.title.style.fontFamily);
56
+ text.setAttribute('font-size', String(chrome.title.style.fontSize));
57
+ text.setAttribute('font-weight', String(chrome.title.style.fontWeight));
58
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', chrome.title.style.fill);
59
+ text.textContent = chrome.title.text;
60
+ g.appendChild(text);
61
+ }
62
+
63
+ if (chrome.subtitle) {
64
+ const text = createSVGElement('text');
65
+ setAttrs(text, { x: chrome.subtitle.x, y: chrome.subtitle.y });
66
+ text.setAttribute('class', 'oc-subtitle');
67
+ text.setAttribute('font-family', chrome.subtitle.style.fontFamily);
68
+ text.setAttribute('font-size', String(chrome.subtitle.style.fontSize));
69
+ text.setAttribute('font-weight', String(chrome.subtitle.style.fontWeight));
70
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty(
71
+ 'fill',
72
+ chrome.subtitle.style.fill,
73
+ );
74
+ text.textContent = chrome.subtitle.text;
75
+ g.appendChild(text);
76
+ }
77
+
78
+ if (chrome.source) {
79
+ const text = createSVGElement('text');
80
+ setAttrs(text, { x: chrome.source.x, y: bottomOffset + chrome.source.y });
81
+ text.setAttribute('class', 'oc-source');
82
+ text.setAttribute('font-family', chrome.source.style.fontFamily);
83
+ text.setAttribute('font-size', String(chrome.source.style.fontSize));
84
+ text.setAttribute('font-weight', String(chrome.source.style.fontWeight));
85
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty(
86
+ 'fill',
87
+ chrome.source.style.fill,
88
+ );
89
+ text.textContent = chrome.source.text;
90
+ g.appendChild(text);
91
+ }
92
+
93
+ if (chrome.byline) {
94
+ const text = createSVGElement('text');
95
+ setAttrs(text, { x: chrome.byline.x, y: bottomOffset + chrome.byline.y });
96
+ text.setAttribute('class', 'oc-byline');
97
+ text.setAttribute('font-family', chrome.byline.style.fontFamily);
98
+ text.setAttribute('font-size', String(chrome.byline.style.fontSize));
99
+ text.setAttribute('font-weight', String(chrome.byline.style.fontWeight));
100
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty(
101
+ 'fill',
102
+ chrome.byline.style.fill,
103
+ );
104
+ text.textContent = chrome.byline.text;
105
+ g.appendChild(text);
106
+ }
107
+
108
+ if (chrome.footer) {
109
+ const text = createSVGElement('text');
110
+ setAttrs(text, { x: chrome.footer.x, y: bottomOffset + chrome.footer.y });
111
+ text.setAttribute('class', 'oc-footer');
112
+ text.setAttribute('font-family', chrome.footer.style.fontFamily);
113
+ text.setAttribute('font-size', String(chrome.footer.style.fontSize));
114
+ text.setAttribute('font-weight', String(chrome.footer.style.fontWeight));
115
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty(
116
+ 'fill',
117
+ chrome.footer.style.fill,
118
+ );
119
+ text.textContent = chrome.footer.text;
120
+ g.appendChild(text);
121
+ }
122
+
123
+ parent.appendChild(g);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Watermark rendering
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function renderWatermark(parent: SVGElement, layout: TileMapLayout): void {
131
+ if (layout.width < 480) return; // Don't render if too narrow
132
+
133
+ const { width, height } = layout;
134
+ const { theme } = layout;
135
+ const padding = theme.spacing.padding;
136
+ const rightEdge = width - padding;
137
+ const bottomEdge = height - padding;
138
+ const fill = theme.colors.axis;
139
+
140
+ const a = createSVGElement('a');
141
+ a.setAttribute('href', BRAND_URL);
142
+ a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
143
+ a.setAttribute('target', '_blank');
144
+ a.setAttribute('rel', 'noopener');
145
+ a.setAttribute('class', 'oc-chrome-ref');
146
+
147
+ const text = createSVGElement('text');
148
+ setAttrs(text, {
149
+ x: rightEdge,
150
+ y: bottomEdge,
151
+ 'dominant-baseline': 'alphabetic',
152
+ 'text-anchor': 'end',
153
+ 'font-family': theme.fonts.family,
154
+ 'font-size': 12,
155
+ 'fill-opacity': 0.55,
156
+ });
157
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
158
+
159
+ const trySpan = createSVGElement('tspan');
160
+ setAttrs(trySpan, { 'font-weight': 500 });
161
+ trySpan.textContent = 'try';
162
+
163
+ const openDataSpan = createSVGElement('tspan');
164
+ setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });
165
+ openDataSpan.textContent = 'OpenData';
166
+
167
+ const aiSpan = createSVGElement('tspan');
168
+ setAttrs(aiSpan, { 'font-weight': 500 });
169
+ aiSpan.textContent = '.ai';
170
+
171
+ text.appendChild(trySpan);
172
+ text.appendChild(openDataSpan);
173
+ text.appendChild(aiSpan);
174
+ a.appendChild(text);
175
+ parent.appendChild(a);
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Tiles rendering
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function renderTiles(
183
+ parent: SVGElement,
184
+ tiles: TileMapTileMark[],
185
+ animation?: ResolvedAnimation,
186
+ ): void {
187
+ const g = createSVGElement('g');
188
+ g.setAttribute('class', 'oc-tilemap-tiles');
189
+ g.setAttribute('role', 'list');
190
+
191
+ // Compute per-tile delays with jitter for organic feel.
192
+ // Base delay spreads tiles across ~800ms window, jitter adds +-40% variation
193
+ // so some tiles pop in clusters while others have longer gaps.
194
+ const tileDelays: number[] = [];
195
+ if (animation?.enabled) {
196
+ const baseStagger = 800 / Math.max(tiles.length, 1);
197
+ let seed = 17;
198
+ for (let i = 0; i < tiles.length; i++) {
199
+ const idx = tiles[i].animationIndex ?? i;
200
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
201
+ const jitter = ((seed % 1000) / 1000 - 0.5) * 0.8;
202
+ tileDelays.push(Math.max(0, Math.round(idx * baseStagger * (1 + jitter))));
203
+ }
204
+ }
205
+
206
+ for (let i = 0; i < tiles.length; i++) {
207
+ const tile = tiles[i];
208
+ const tileGroup = createSVGElement('g');
209
+ tileGroup.setAttribute('class', 'oc-tilemap-tile');
210
+ tileGroup.setAttribute('data-state', tile.stateCode);
211
+ tileGroup.setAttribute('role', 'listitem');
212
+ if (tile.aria?.label) {
213
+ tileGroup.setAttribute('aria-label', tile.aria.label);
214
+ }
215
+
216
+ if (animation?.enabled) {
217
+ const idx = tile.animationIndex ?? i;
218
+ tileGroup.setAttribute('data-animation-index', String(idx));
219
+ (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
220
+ '--oc-mark-index',
221
+ String(idx),
222
+ );
223
+ (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
224
+ '--oc-tile-delay',
225
+ `${tileDelays[i]}ms`,
226
+ );
227
+ }
228
+
229
+ // Tile background rect
230
+ const rect = createSVGElement('rect');
231
+ setAttrs(rect, {
232
+ x: tile.x,
233
+ y: tile.y,
234
+ width: tile.size,
235
+ height: tile.size,
236
+ rx: tile.cornerRadius,
237
+ fill: tile.fill,
238
+ stroke: tile.stroke,
239
+ 'stroke-width': tile.strokeWidth,
240
+ });
241
+ tileGroup.appendChild(rect);
242
+
243
+ // State code label
244
+ const codeLabel = createSVGElement('text');
245
+ setAttrs(codeLabel, {
246
+ x: tile.label.x,
247
+ y: tile.label.y,
248
+ 'text-anchor': 'middle',
249
+ 'dominant-baseline': 'central',
250
+ 'font-family': tile.label.style.fontFamily,
251
+ 'font-size': tile.label.style.fontSize,
252
+ 'font-weight': tile.label.style.fontWeight,
253
+ });
254
+ (codeLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(
255
+ 'fill',
256
+ tile.label.style.fill,
257
+ );
258
+ codeLabel.textContent = tile.label.text;
259
+ tileGroup.appendChild(codeLabel);
260
+
261
+ // Value label (if visible)
262
+ if (tile.valueLabel.visible && tile.valueLabel.text) {
263
+ const valueLabel = createSVGElement('text');
264
+ setAttrs(valueLabel, {
265
+ x: tile.valueLabel.x,
266
+ y: tile.valueLabel.y,
267
+ 'text-anchor': 'middle',
268
+ 'dominant-baseline': 'central',
269
+ 'font-family': tile.valueLabel.style.fontFamily,
270
+ 'font-size': tile.valueLabel.style.fontSize,
271
+ 'font-weight': tile.valueLabel.style.fontWeight,
272
+ });
273
+ (valueLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(
274
+ 'fill',
275
+ tile.valueLabel.style.fill,
276
+ );
277
+ valueLabel.textContent = tile.valueLabel.text;
278
+ tileGroup.appendChild(valueLabel);
279
+ }
280
+
281
+ g.appendChild(tileGroup);
282
+ }
283
+
284
+ parent.appendChild(g);
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Gradient legend rendering
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {
292
+ if (!layout.gradientLegend) return;
293
+
294
+ const { gradientLegend } = layout;
295
+ const g = createSVGElement('g');
296
+ g.setAttribute('class', 'oc-tilemap-legend');
297
+
298
+ // Build linear gradient in defs
299
+ const defs = parent.querySelector('defs') || createSVGElement('defs');
300
+ const exists = parent.querySelector('defs');
301
+ if (!exists) {
302
+ parent.insertBefore(defs, parent.firstChild);
303
+ }
304
+
305
+ const gradientId = `oc-tilemap-legend-gradient-${gradientIdCounter++}`;
306
+ const grad = createSVGElement('linearGradient');
307
+ grad.id = gradientId;
308
+ grad.setAttribute('x1', '0%');
309
+ grad.setAttribute('y1', '0%');
310
+ grad.setAttribute('x2', '100%');
311
+ grad.setAttribute('y2', '0%');
312
+
313
+ for (const stop of gradientLegend.colorStops) {
314
+ const s = createSVGElement('stop');
315
+ setAttrs(s, { offset: `${stop.offset * 100}%`, 'stop-color': stop.color });
316
+ grad.appendChild(s);
317
+ }
318
+
319
+ (defs as SVGElement).appendChild(grad);
320
+
321
+ // Gradient bar
322
+ const bar = createSVGElement('rect');
323
+ setAttrs(bar, {
324
+ x: gradientLegend.bounds.x,
325
+ y: gradientLegend.bounds.y,
326
+ width: gradientLegend.bounds.width,
327
+ height: gradientLegend.bounds.height,
328
+ rx: 3,
329
+ fill: `url(#${gradientId})`,
330
+ });
331
+ g.appendChild(bar);
332
+
333
+ // Min label
334
+ const minText = createSVGElement('text');
335
+ setAttrs(minText, {
336
+ x: gradientLegend.bounds.x,
337
+ y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,
338
+ 'text-anchor': 'start',
339
+ 'font-family': gradientLegend.labelStyle.fontFamily,
340
+ 'font-size': gradientLegend.labelStyle.fontSize,
341
+ 'font-weight': gradientLegend.labelStyle.fontWeight,
342
+ });
343
+ (minText as SVGElement & ElementCSSInlineStyle).style.setProperty(
344
+ 'fill',
345
+ gradientLegend.labelStyle.fill,
346
+ );
347
+ minText.textContent = gradientLegend.minLabel;
348
+ g.appendChild(minText);
349
+
350
+ // Max label
351
+ const maxText = createSVGElement('text');
352
+ setAttrs(maxText, {
353
+ x: gradientLegend.bounds.x + gradientLegend.bounds.width,
354
+ y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,
355
+ 'text-anchor': 'end',
356
+ 'font-family': gradientLegend.labelStyle.fontFamily,
357
+ 'font-size': gradientLegend.labelStyle.fontSize,
358
+ 'font-weight': gradientLegend.labelStyle.fontWeight,
359
+ });
360
+ (maxText as SVGElement & ElementCSSInlineStyle).style.setProperty(
361
+ 'fill',
362
+ gradientLegend.labelStyle.fill,
363
+ );
364
+ maxText.textContent = gradientLegend.maxLabel;
365
+ g.appendChild(maxText);
366
+
367
+ parent.appendChild(g);
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Public API
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Render a TileMapLayout to an SVG element.
376
+ */
377
+ export function renderTileMapSVG(
378
+ layout: TileMapLayout,
379
+ opts?: { animate?: boolean },
380
+ ): SVGSVGElement {
381
+ const { width, height, tiles, a11y, watermark, animation } = layout;
382
+ const animate = opts?.animate && animation?.enabled;
383
+
384
+ const svg = createSVGElement('svg') as SVGSVGElement;
385
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
386
+ svg.setAttribute('width', String(width));
387
+ svg.setAttribute('height', String(height));
388
+ svg.setAttribute('role', 'img');
389
+ if (a11y.altText) {
390
+ svg.setAttribute('aria-label', a11y.altText);
391
+ }
392
+
393
+ const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';
394
+ svg.setAttribute('class', classes);
395
+
396
+ if (animate && animation) {
397
+ // Target ~1s total: stagger window ~800ms + per-tile pop ~200ms
398
+ const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
399
+ svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
400
+ svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
401
+ svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
402
+ const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
403
+ svg.style.setProperty('--oc-animation-ease', easeVar);
404
+ }
405
+
406
+ // Empty defs element (will be filled by gradient legend)
407
+ const defs = createSVGElement('defs');
408
+ svg.appendChild(defs);
409
+
410
+ // Render chrome
411
+ renderChrome(svg, layout);
412
+
413
+ // Render tiles
414
+ renderTiles(svg, tiles, animate ? animation : undefined);
415
+
416
+ // Render gradient legend
417
+ renderGradientLegend(svg, layout);
418
+
419
+ // Render watermark
420
+ if (watermark) {
421
+ renderWatermark(svg, layout);
422
+ }
423
+
424
+ return svg;
425
+ }