@opendata-ai/openchart-vanilla 6.5.2 → 6.7.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,602 @@
1
+ /**
2
+ * Sankey SVG renderer: converts a SankeyLayout into SVG DOM elements.
3
+ *
4
+ * Creates an <svg> with gradient defs, links (behind), nodes, labels,
5
+ * legend, and chrome. All styling via inline SVG attributes from layout data.
6
+ * Animation is pure CSS, driven by data attributes and CSS custom properties.
7
+ */
8
+
9
+ import type {
10
+ LegendLayout,
11
+ ResolvedAnimation,
12
+ ResolvedChromeElement,
13
+ SankeyLayout,
14
+ SankeyLinkMark,
15
+ SankeyNodeMark,
16
+ TextStyle,
17
+ } from '@opendata-ai/openchart-core';
18
+ import { BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
19
+ import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
20
+
21
+ const SVG_NS = 'http://www.w3.org/2000/svg';
22
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
23
+ const BRAND_URL = 'https://opendata.ai';
24
+
25
+ /** CSS easing preset map for inline style custom properties. */
26
+ const EASE_VAR_MAP: Record<string, string> = {
27
+ smooth: 'var(--oc-ease-smooth)',
28
+ snappy: 'var(--oc-ease-snappy)',
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function createSVGElement(tag: string): SVGElement {
36
+ return document.createElementNS(SVG_NS, tag);
37
+ }
38
+
39
+ function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
40
+ for (const [key, value] of Object.entries(attrs)) {
41
+ el.setAttribute(key, String(value));
42
+ }
43
+ }
44
+
45
+ function applyTextStyle(el: SVGElement, style: TextStyle): void {
46
+ setAttrs(el, {
47
+ 'font-family': style.fontFamily,
48
+ 'font-size': style.fontSize,
49
+ 'font-weight': style.fontWeight,
50
+ });
51
+ (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
52
+ if (style.textAnchor) {
53
+ el.setAttribute('text-anchor', style.textAnchor);
54
+ }
55
+ if (style.dominantBaseline) {
56
+ el.setAttribute('dominant-baseline', style.dominantBaseline);
57
+ }
58
+ if (style.fontVariant) {
59
+ el.setAttribute('font-variant', style.fontVariant);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Stamp animation index attributes on a mark element when animation is enabled.
65
+ */
66
+ function stampAnimationAttrs(
67
+ el: SVGElement,
68
+ mark: { animationIndex?: number },
69
+ fallbackIndex: number,
70
+ animation?: ResolvedAnimation,
71
+ ): void {
72
+ if (!animation?.enabled) return;
73
+ const idx = mark.animationIndex ?? fallbackIndex;
74
+ el.setAttribute('data-animation-index', String(idx));
75
+ (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Chrome rendering
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Break text into lines that fit within maxWidth using word wrapping.
84
+ */
85
+ function wrapText(text: string, fontSize: number, fontWeight: number, maxWidth: number): string[] {
86
+ if (maxWidth <= 0) return [text];
87
+
88
+ const AVG_CHAR_WIDTH = 0.55;
89
+ const WEIGHT_FACTORS: Record<number, number> = {
90
+ 100: 0.9,
91
+ 200: 0.92,
92
+ 300: 0.95,
93
+ 400: 1.0,
94
+ 500: 1.02,
95
+ 600: 1.05,
96
+ 700: 1.08,
97
+ 800: 1.1,
98
+ 900: 1.12,
99
+ };
100
+ const weightFactor = WEIGHT_FACTORS[fontWeight] ?? 1.0;
101
+ const charWidth = fontSize * AVG_CHAR_WIDTH * weightFactor;
102
+ const maxChars = Math.floor(maxWidth / charWidth);
103
+
104
+ if (text.length <= maxChars) return [text];
105
+
106
+ const words = text.split(' ');
107
+ const lines: string[] = [];
108
+ let current = '';
109
+
110
+ for (const word of words) {
111
+ const candidate = current ? `${current} ${word}` : word;
112
+ if (candidate.length > maxChars && current) {
113
+ lines.push(current);
114
+ current = word;
115
+ } else {
116
+ current = candidate;
117
+ }
118
+ }
119
+ if (current) lines.push(current);
120
+
121
+ return lines;
122
+ }
123
+
124
+ function renderChromeElement(
125
+ parent: SVGElement,
126
+ element: ResolvedChromeElement,
127
+ className: string,
128
+ chromeKey: string,
129
+ ): void {
130
+ const text = createSVGElement('text');
131
+ setAttrs(text, { x: element.x, y: element.y });
132
+ applyTextStyle(text, element.style);
133
+ text.setAttribute('class', className);
134
+ text.setAttribute('data-chrome-key', chromeKey);
135
+
136
+ const lines = wrapText(
137
+ element.text,
138
+ element.style.fontSize,
139
+ element.style.fontWeight,
140
+ element.maxWidth,
141
+ );
142
+
143
+ if (lines.length === 1) {
144
+ text.textContent = element.text;
145
+ } else {
146
+ const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
147
+ for (let i = 0; i < lines.length; i++) {
148
+ const tspan = createSVGElement('tspan');
149
+ setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
150
+ tspan.textContent = lines[i];
151
+ text.appendChild(tspan);
152
+ }
153
+ }
154
+
155
+ parent.appendChild(text);
156
+ }
157
+
158
+ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
159
+ const g = createSVGElement('g');
160
+ g.setAttribute('class', 'oc-chrome');
161
+
162
+ const { chrome } = layout;
163
+
164
+ if (chrome.title) {
165
+ renderChromeElement(g, chrome.title, 'oc-title', 'title');
166
+ }
167
+ if (chrome.subtitle) {
168
+ renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
169
+ }
170
+
171
+ // Bottom chrome: positioned below the sankey drawing area.
172
+ // Sankey has no x-axis, so no extra axis extent to account for.
173
+ const bottomOffset = layout.area.y + layout.area.height;
174
+ if (chrome.source) {
175
+ renderChromeElement(
176
+ g,
177
+ { ...chrome.source, y: bottomOffset + chrome.source.y },
178
+ 'oc-source',
179
+ 'source',
180
+ );
181
+ }
182
+ if (chrome.byline) {
183
+ renderChromeElement(
184
+ g,
185
+ { ...chrome.byline, y: bottomOffset + chrome.byline.y },
186
+ 'oc-byline',
187
+ 'byline',
188
+ );
189
+ }
190
+ if (chrome.footer) {
191
+ renderChromeElement(
192
+ g,
193
+ { ...chrome.footer, y: bottomOffset + chrome.footer.y },
194
+ 'oc-footer',
195
+ 'footer',
196
+ );
197
+ }
198
+
199
+ parent.appendChild(g);
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Brand rendering
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function renderBrand(parent: SVGElement, layout: SankeyLayout): void {
207
+ if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
208
+
209
+ const { width } = layout.dimensions;
210
+ const padding = layout.theme.spacing.padding;
211
+ const rightEdge = width - padding;
212
+ const fill = layout.theme.colors.axis;
213
+
214
+ const bottomOffset = layout.area.y + layout.area.height;
215
+ const { chrome } = layout;
216
+ const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
217
+ const chromeY = firstBottom
218
+ ? bottomOffset + firstBottom.y
219
+ : bottomOffset + layout.theme.spacing.chartToFooter;
220
+
221
+ const a = createSVGElement('a');
222
+ a.setAttribute('href', BRAND_URL);
223
+ a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
224
+ a.setAttribute('target', '_blank');
225
+ a.setAttribute('rel', 'noopener');
226
+ a.setAttribute('class', 'oc-chrome-ref');
227
+
228
+ const text = createSVGElement('text');
229
+ setAttrs(text, {
230
+ x: rightEdge,
231
+ y: chromeY,
232
+ 'dominant-baseline': 'hanging',
233
+ 'text-anchor': 'end',
234
+ 'font-family': layout.theme.fonts.family,
235
+ 'font-size': 20,
236
+ 'fill-opacity': 0.55,
237
+ });
238
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
239
+
240
+ const openSpan = createSVGElement('tspan');
241
+ setAttrs(openSpan, { 'font-weight': 500 });
242
+ openSpan.textContent = 'Open';
243
+
244
+ const dataSpan = createSVGElement('tspan');
245
+ setAttrs(dataSpan, { 'font-weight': 600 });
246
+ dataSpan.textContent = 'Data';
247
+
248
+ text.appendChild(openSpan);
249
+ text.appendChild(dataSpan);
250
+ a.appendChild(text);
251
+ parent.appendChild(a);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Legend rendering
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
259
+ if (legend.entries.length === 0) return;
260
+
261
+ const g = createSVGElement('g');
262
+ g.setAttribute('class', 'oc-legend');
263
+ g.setAttribute('role', 'list');
264
+ g.setAttribute('aria-label', 'Chart legend');
265
+
266
+ const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
267
+ let offsetX = legend.bounds.x;
268
+ let offsetY = legend.bounds.y;
269
+
270
+ for (let i = 0; i < legend.entries.length; i++) {
271
+ const entry = legend.entries[i];
272
+
273
+ // Wrap to next line if this entry would overflow bounds
274
+ if (isHorizontal && i > 0) {
275
+ const labelWidth = estimateTextWidth(
276
+ entry.label,
277
+ legend.labelStyle.fontSize,
278
+ legend.labelStyle.fontWeight,
279
+ );
280
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
281
+ if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
282
+ offsetX = legend.bounds.x;
283
+ offsetY += legend.swatchSize + 6;
284
+ }
285
+ }
286
+
287
+ const entryG = createSVGElement('g');
288
+ entryG.setAttribute('class', 'oc-legend-entry');
289
+ entryG.setAttribute('role', 'listitem');
290
+ entryG.setAttribute('data-legend-index', String(i));
291
+ entryG.setAttribute('data-legend-label', entry.label);
292
+
293
+ if (entry.overflow) {
294
+ entryG.setAttribute('data-legend-overflow', 'true');
295
+ entryG.setAttribute('aria-label', entry.label);
296
+ entryG.setAttribute('opacity', '0.5');
297
+ } else {
298
+ entryG.setAttribute(
299
+ 'aria-label',
300
+ `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
301
+ );
302
+ entryG.setAttribute('style', 'cursor: pointer');
303
+ if (entry.active === false) {
304
+ entryG.setAttribute('opacity', '0.3');
305
+ }
306
+ }
307
+
308
+ // Swatch (always rect for sankey)
309
+ const rect = createSVGElement('rect');
310
+ setAttrs(rect, {
311
+ x: offsetX,
312
+ y: offsetY,
313
+ width: legend.swatchSize,
314
+ height: legend.swatchSize,
315
+ fill: entry.color,
316
+ rx: 2,
317
+ });
318
+ entryG.appendChild(rect);
319
+
320
+ // Label
321
+ const label = createSVGElement('text');
322
+ setAttrs(label, {
323
+ x: offsetX + legend.swatchSize + legend.swatchGap,
324
+ y: offsetY + legend.swatchSize / 2,
325
+ 'dominant-baseline': 'central',
326
+ });
327
+ applyTextStyle(label, legend.labelStyle);
328
+ label.textContent = entry.label;
329
+ entryG.appendChild(label);
330
+
331
+ g.appendChild(entryG);
332
+
333
+ // Advance position for next entry
334
+ if (isHorizontal) {
335
+ const labelWidth = estimateTextWidth(
336
+ entry.label,
337
+ legend.labelStyle.fontSize,
338
+ legend.labelStyle.fontWeight,
339
+ );
340
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
341
+ offsetX += entryWidth;
342
+ } else {
343
+ offsetY += legend.swatchSize + legend.entryGap;
344
+ }
345
+ }
346
+
347
+ parent.appendChild(g);
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Gradient defs
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Build a node position lookup for gradient x1/x2 coordinates.
356
+ * Maps nodeId to { x, width } so we can compute gradient endpoints.
357
+ */
358
+ function buildNodePositionMap(nodes: SankeyNodeMark[]): Map<string, { x: number; width: number }> {
359
+ const map = new Map<string, { x: number; width: number }>();
360
+ for (const node of nodes) {
361
+ map.set(node.nodeId, { x: node.x, width: node.width });
362
+ }
363
+ return map;
364
+ }
365
+
366
+ function renderGradientDefs(
367
+ defs: SVGElement,
368
+ links: SankeyLinkMark[],
369
+ nodePositions: Map<string, { x: number; width: number }>,
370
+ ): void {
371
+ for (let i = 0; i < links.length; i++) {
372
+ const link = links[i];
373
+ // Only create gradients when source and target colors differ
374
+ if (link.sourceColor === link.targetColor) continue;
375
+
376
+ const gradId = `oc-sg-${link.sourceId}-${link.targetId}-${i}`;
377
+ const gradient = createSVGElement('linearGradient');
378
+ gradient.setAttribute('id', gradId);
379
+ gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
380
+
381
+ // x1 = right edge of source node, x2 = left edge of target node
382
+ const sourcePos = nodePositions.get(link.sourceId);
383
+ const targetPos = nodePositions.get(link.targetId);
384
+ const x1 = sourcePos ? sourcePos.x + sourcePos.width : 0;
385
+ const x2 = targetPos ? targetPos.x : 0;
386
+
387
+ gradient.setAttribute('x1', String(x1));
388
+ gradient.setAttribute('x2', String(x2));
389
+
390
+ const stop0 = createSVGElement('stop');
391
+ setAttrs(stop0, { offset: '0%', 'stop-color': link.sourceColor });
392
+ gradient.appendChild(stop0);
393
+
394
+ const stop1 = createSVGElement('stop');
395
+ setAttrs(stop1, { offset: '100%', 'stop-color': link.targetColor });
396
+ gradient.appendChild(stop1);
397
+
398
+ defs.appendChild(gradient);
399
+ }
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Links rendering
404
+ // ---------------------------------------------------------------------------
405
+
406
+ function renderLinks(
407
+ parent: SVGElement,
408
+ links: SankeyLinkMark[],
409
+ _nodePositions: Map<string, { x: number; width: number }>,
410
+ animation?: ResolvedAnimation,
411
+ ): void {
412
+ const g = createSVGElement('g');
413
+ g.setAttribute('class', 'oc-sankey-links');
414
+
415
+ for (let i = 0; i < links.length; i++) {
416
+ const link = links[i];
417
+
418
+ const linkG = createSVGElement('g');
419
+ linkG.setAttribute('class', 'oc-sankey-link');
420
+ linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}`);
421
+ linkG.setAttribute('data-source', link.sourceId);
422
+ linkG.setAttribute('data-target', link.targetId);
423
+
424
+ if (link.aria?.label) {
425
+ linkG.setAttribute('aria-label', link.aria.label);
426
+ }
427
+
428
+ stampAnimationAttrs(linkG, link, i, animation);
429
+
430
+ const path = createSVGElement('path');
431
+ path.setAttribute('d', link.path);
432
+ path.setAttribute('stroke', 'none');
433
+ path.setAttribute('fill-opacity', String(link.fillOpacity));
434
+
435
+ // Use gradient fill when colors differ, otherwise solid fill
436
+ if (link.sourceColor !== link.targetColor) {
437
+ const gradId = `oc-sg-${link.sourceId}-${link.targetId}-${i}`;
438
+ path.setAttribute('fill', `url(#${gradId})`);
439
+ } else {
440
+ path.setAttribute('fill', link.sourceColor);
441
+ }
442
+
443
+ linkG.appendChild(path);
444
+ g.appendChild(linkG);
445
+ }
446
+
447
+ parent.appendChild(g);
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Nodes rendering
452
+ // ---------------------------------------------------------------------------
453
+
454
+ function renderNodes(
455
+ parent: SVGElement,
456
+ nodes: SankeyNodeMark[],
457
+ animation?: ResolvedAnimation,
458
+ ): void {
459
+ const g = createSVGElement('g');
460
+ g.setAttribute('class', 'oc-sankey-nodes');
461
+
462
+ for (let i = 0; i < nodes.length; i++) {
463
+ const node = nodes[i];
464
+
465
+ const nodeG = createSVGElement('g');
466
+ nodeG.setAttribute('class', 'oc-sankey-node');
467
+ nodeG.setAttribute('data-mark-id', `node-${node.nodeId}`);
468
+ nodeG.setAttribute('data-node-id', node.nodeId);
469
+
470
+ if (node.aria?.label) {
471
+ nodeG.setAttribute('aria-label', node.aria.label);
472
+ }
473
+
474
+ stampAnimationAttrs(nodeG, node, i, animation);
475
+
476
+ const rect = createSVGElement('rect');
477
+ setAttrs(rect, {
478
+ x: node.x,
479
+ y: node.y,
480
+ width: node.width,
481
+ height: Math.max(node.height, 1), // Ensure at least 1px visibility
482
+ fill: node.fill,
483
+ rx: node.cornerRadius,
484
+ });
485
+
486
+ if (node.stroke) {
487
+ rect.setAttribute('stroke', node.stroke);
488
+ }
489
+ if (node.strokeWidth) {
490
+ rect.setAttribute('stroke-width', String(node.strokeWidth));
491
+ }
492
+
493
+ nodeG.appendChild(rect);
494
+ g.appendChild(nodeG);
495
+ }
496
+
497
+ parent.appendChild(g);
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Labels rendering
502
+ // ---------------------------------------------------------------------------
503
+
504
+ function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
505
+ const g = createSVGElement('g');
506
+ g.setAttribute('class', 'oc-sankey-labels');
507
+
508
+ for (const node of nodes) {
509
+ const { label } = node;
510
+ if (!label.visible) continue;
511
+
512
+ const text = createSVGElement('text');
513
+ setAttrs(text, { x: label.x, y: label.y });
514
+ applyTextStyle(text, label.style);
515
+ text.textContent = label.text;
516
+
517
+ g.appendChild(text);
518
+ }
519
+
520
+ parent.appendChild(g);
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Main render function
525
+ // ---------------------------------------------------------------------------
526
+
527
+ /**
528
+ * Render a SankeyLayout into an SVG element.
529
+ *
530
+ * The SVG structure is: background > defs (gradients) > links > nodes > labels > legend > chrome > brand.
531
+ * Links render before nodes so they appear visually behind.
532
+ */
533
+ export function renderSankeySVG(
534
+ layout: SankeyLayout,
535
+ animation?: ResolvedAnimation,
536
+ ): SVGSVGElement {
537
+ const { width, height } = layout.dimensions;
538
+
539
+ const svg = createSVGElement('svg') as SVGSVGElement;
540
+ setAttrs(svg, {
541
+ viewBox: `0 0 ${width} ${height}`,
542
+ xmlns: SVG_NS,
543
+ overflow: 'visible',
544
+ });
545
+ svg.style.height = `${height}px`;
546
+ svg.setAttribute('role', layout.a11y.role);
547
+ svg.setAttribute('aria-label', layout.a11y.altText);
548
+
549
+ // Classes: oc-chart oc-sankey, plus oc-animate if animated
550
+ const animate = animation?.enabled;
551
+ const classes = animate ? 'oc-chart oc-sankey oc-animate' : 'oc-chart oc-sankey';
552
+ svg.setAttribute('class', classes);
553
+
554
+ // Set animation CSS custom properties when enabled
555
+ if (animation?.enabled) {
556
+ const totalMarks = layout.nodes.length + layout.links.length;
557
+ const stagger = clampStaggerDelay(animation.staggerDelay, totalMarks);
558
+ svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
559
+ svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
560
+ svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
561
+ const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
562
+ svg.style.setProperty('--oc-animation-ease', easeVar);
563
+ }
564
+
565
+ // Background
566
+ const bg = createSVGElement('rect');
567
+ bg.setAttribute('class', 'oc-background');
568
+ setAttrs(bg, {
569
+ x: 0,
570
+ y: 0,
571
+ width,
572
+ height,
573
+ fill: layout.theme.colors.background,
574
+ });
575
+ svg.appendChild(bg);
576
+
577
+ // Defs: gradient definitions for links
578
+ const nodePositions = buildNodePositionMap(layout.nodes);
579
+ const defs = createSVGElement('defs');
580
+ renderGradientDefs(defs, layout.links, nodePositions);
581
+ svg.appendChild(defs);
582
+
583
+ // Links (behind nodes)
584
+ renderLinks(svg, layout.links, nodePositions, animation);
585
+
586
+ // Nodes
587
+ renderNodes(svg, layout.nodes, animation);
588
+
589
+ // Labels
590
+ renderLabels(svg, layout.nodes);
591
+
592
+ // Legend
593
+ renderLegend(svg, layout.legend);
594
+
595
+ // Chrome (title, subtitle, source, byline, footer) renders on top
596
+ renderChrome(svg, layout);
597
+
598
+ // Brand
599
+ renderBrand(svg, layout);
600
+
601
+ return svg;
602
+ }
@@ -1135,8 +1135,8 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1135
1135
  a.setAttribute('rel', 'noopener');
1136
1136
  a.setAttribute('class', 'oc-chrome-ref');
1137
1137
 
1138
- // "Open" in normal weight, "Data" in semibold, rendered as a single
1139
- // right-aligned text element with two tspans.
1138
+ // "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
1139
+ // rendered as a single right-aligned text element with three tspans.
1140
1140
  const text = createSVGElement('text');
1141
1141
  setAttrs(text, {
1142
1142
  x: rightEdge,
@@ -1149,15 +1149,20 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1149
1149
  });
1150
1150
  (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
1151
1151
 
1152
- const openSpan = createSVGElement('tspan');
1153
- openSpan.setAttribute('font-weight', '500');
1154
- openSpan.textContent = 'Open';
1155
- text.appendChild(openSpan);
1152
+ const trySpan = createSVGElement('tspan');
1153
+ trySpan.setAttribute('font-weight', '500');
1154
+ trySpan.textContent = 'try';
1155
+ text.appendChild(trySpan);
1156
1156
 
1157
- const dataSpan = createSVGElement('tspan');
1158
- dataSpan.setAttribute('font-weight', '600');
1159
- dataSpan.textContent = 'Data';
1160
- text.appendChild(dataSpan);
1157
+ const openDataSpan = createSVGElement('tspan');
1158
+ openDataSpan.setAttribute('font-weight', '600');
1159
+ openDataSpan.textContent = 'OpenData';
1160
+ text.appendChild(openDataSpan);
1161
+
1162
+ const aiSpan = createSVGElement('tspan');
1163
+ aiSpan.setAttribute('font-weight', '500');
1164
+ aiSpan.textContent = '.ai';
1165
+ text.appendChild(aiSpan);
1161
1166
 
1162
1167
  a.appendChild(text);
1163
1168
  parent.appendChild(a);
@@ -399,7 +399,7 @@ export function renderTable(
399
399
  brandLink.target = '_blank';
400
400
  brandLink.rel = 'noopener';
401
401
  brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
402
- brandLink.textContent = 'OpenData';
402
+ brandLink.textContent = 'tryOpenData.ai';
403
403
  brand.appendChild(brandLink);
404
404
  wrapper.appendChild(brand);
405
405