@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.
- package/dist/index.d.ts +54 -2
- package/dist/index.js +661 -29
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/compound-labels.test.ts +122 -0
- package/src/__tests__/crosshair.test.ts +121 -0
- package/src/__tests__/mount.test.ts +19 -0
- package/src/__tests__/tilemap.test.ts +158 -0
- package/src/graph-mount.ts +1 -1
- package/src/index.ts +3 -0
- package/src/mount.ts +45 -2
- package/src/renderers/axes.ts +81 -20
- package/src/renderers/legend.ts +6 -2
- package/src/sankey-renderer.ts +4 -2
- package/src/svg-renderer.ts +28 -1
- package/src/tilemap-mount.ts +394 -0
- package/src/tilemap-renderer.ts +425 -0
|
@@ -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
|
+
}
|