@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.
- package/dist/index.d.ts +45 -2
- package/dist/index.js +757 -30
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/sankey.test.ts +133 -0
- package/src/__tests__/svg-renderer.test.ts +6 -5
- package/src/graph/canvas-renderer.ts +1 -1
- package/src/index.ts +3 -0
- package/src/sankey-mount.ts +532 -0
- package/src/sankey-renderer.ts +602 -0
- package/src/svg-renderer.ts +15 -10
- package/src/table-renderer.ts +1 -1
|
@@ -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
|
+
}
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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
|
-
// "
|
|
1139
|
-
// right-aligned text element with
|
|
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
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
text.appendChild(
|
|
1152
|
+
const trySpan = createSVGElement('tspan');
|
|
1153
|
+
trySpan.setAttribute('font-weight', '500');
|
|
1154
|
+
trySpan.textContent = 'try';
|
|
1155
|
+
text.appendChild(trySpan);
|
|
1156
1156
|
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
text.appendChild(
|
|
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);
|
package/src/table-renderer.ts
CHANGED
|
@@ -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 = '
|
|
402
|
+
brandLink.textContent = 'tryOpenData.ai';
|
|
403
403
|
brand.appendChild(brandLink);
|
|
404
404
|
wrapper.appendChild(brand);
|
|
405
405
|
|