@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,614 +0,0 @@
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
- CategoricalLegendLayout,
11
- LegendLayout,
12
- MeasureTextFn,
13
- ResolvedAnimation,
14
- ResolvedChromeElement,
15
- SankeyLayout,
16
- SankeyLinkMark,
17
- SankeyNodeMark,
18
- TextStyle,
19
- } from '@opendata-ai/openchart-core';
20
- import {
21
- BRAND_FONT_SIZE,
22
- BRAND_MIN_WIDTH,
23
- estimateTextWidth,
24
- textAscent,
25
- wrapText,
26
- } from '@opendata-ai/openchart-core';
27
- import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
28
- import { stampAnimationVars } from './animation-vars';
29
-
30
- const SVG_NS = 'http://www.w3.org/2000/svg';
31
- const XLINK_NS = 'http://www.w3.org/1999/xlink';
32
- const BRAND_URL = 'https://tryopendata.ai';
33
-
34
- // ---------------------------------------------------------------------------
35
- // Helpers
36
- // ---------------------------------------------------------------------------
37
-
38
- function createSVGElement(tag: string): SVGElement {
39
- return document.createElementNS(SVG_NS, tag);
40
- }
41
-
42
- function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
43
- for (const [key, value] of Object.entries(attrs)) {
44
- el.setAttribute(key, String(value));
45
- }
46
- }
47
-
48
- function applyTextStyle(el: SVGElement, style: TextStyle): void {
49
- setAttrs(el, {
50
- 'font-family': style.fontFamily,
51
- 'font-size': style.fontSize,
52
- 'font-weight': style.fontWeight,
53
- });
54
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
55
- if (style.textAnchor) {
56
- el.setAttribute('text-anchor', style.textAnchor);
57
- }
58
- if (style.dominantBaseline) {
59
- el.setAttribute('dominant-baseline', style.dominantBaseline);
60
- }
61
- if (style.fontVariant) {
62
- el.setAttribute('font-variant', style.fontVariant);
63
- }
64
- }
65
-
66
- /**
67
- * Stamp animation index attributes on a mark element when animation is enabled.
68
- */
69
- function stampAnimationAttrs(
70
- el: SVGElement,
71
- mark: { animationIndex?: number },
72
- fallbackIndex: number,
73
- animation?: ResolvedAnimation,
74
- ): void {
75
- if (!animation?.enter) return;
76
- const idx = mark.animationIndex ?? fallbackIndex;
77
- el.setAttribute('data-animation-index', String(idx));
78
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
79
- }
80
-
81
- // ---------------------------------------------------------------------------
82
- // Chrome rendering
83
- // ---------------------------------------------------------------------------
84
-
85
- function renderChromeElement(
86
- parent: SVGElement,
87
- element: ResolvedChromeElement,
88
- className: string,
89
- chromeKey: string,
90
- measureText?: MeasureTextFn,
91
- ): void {
92
- const text = createSVGElement('text');
93
- // element.y is the TOP of the text box; convert to the alphabetic baseline
94
- // (see renderers/chrome.ts — WebKit mishandles dominant-baseline:hanging).
95
- setAttrs(text, { x: element.x, y: element.y + textAscent(element.style.fontSize) });
96
- applyTextStyle(text, element.style);
97
- text.setAttribute('class', className);
98
- text.setAttribute('data-chrome-key', chromeKey);
99
-
100
- const lines = wrapText(
101
- element.text,
102
- element.style.fontSize,
103
- element.style.fontWeight,
104
- element.maxWidth,
105
- measureText,
106
- );
107
-
108
- if (lines.length === 1) {
109
- text.textContent = element.text;
110
- } else {
111
- const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
112
- for (let i = 0; i < lines.length; i++) {
113
- const tspan = createSVGElement('tspan');
114
- setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
115
- tspan.textContent = lines[i];
116
- text.appendChild(tspan);
117
- }
118
- }
119
-
120
- parent.appendChild(text);
121
- }
122
-
123
- function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
124
- const g = createSVGElement('g');
125
- g.setAttribute('class', 'oc-chrome');
126
-
127
- const { chrome, measureText } = layout;
128
-
129
- if (chrome.title) {
130
- renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
131
- }
132
- if (chrome.subtitle) {
133
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
134
- }
135
-
136
- // Bottom chrome: positioned below the sankey drawing area.
137
- // Sankey has no x-axis, so no extra axis extent to account for.
138
- const bottomOffset = layout.area.y + layout.area.height;
139
- if (chrome.source) {
140
- renderChromeElement(
141
- g,
142
- { ...chrome.source, y: bottomOffset + chrome.source.y },
143
- 'oc-source',
144
- 'source',
145
- measureText,
146
- );
147
- }
148
- if (chrome.byline) {
149
- renderChromeElement(
150
- g,
151
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
152
- 'oc-byline',
153
- 'byline',
154
- measureText,
155
- );
156
- }
157
- if (chrome.footer) {
158
- renderChromeElement(
159
- g,
160
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
161
- 'oc-footer',
162
- 'footer',
163
- measureText,
164
- );
165
- }
166
-
167
- parent.appendChild(g);
168
- }
169
-
170
- // ---------------------------------------------------------------------------
171
- // Brand rendering
172
- // ---------------------------------------------------------------------------
173
-
174
- function renderBrand(parent: SVGElement, layout: SankeyLayout): void {
175
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
176
-
177
- const { width } = layout.dimensions;
178
- const padding = layout.theme.spacing.padding;
179
- const rightEdge = width - padding;
180
- const fill = layout.theme.colors.axis;
181
-
182
- const bottomOffset = layout.area.y + layout.area.height;
183
- const { chrome } = layout;
184
- const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
185
- const chromeY = firstBottom
186
- ? bottomOffset + firstBottom.y
187
- : bottomOffset + layout.theme.spacing.chartToFooter;
188
-
189
- const a = createSVGElement('a');
190
- a.setAttribute('href', BRAND_URL);
191
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
192
- a.setAttribute('target', '_blank');
193
- a.setAttribute('rel', 'noopener');
194
- a.setAttribute('class', 'oc-chrome-ref');
195
-
196
- const BRAND_LARGE = 16;
197
- const text = createSVGElement('text');
198
- setAttrs(text, {
199
- x: rightEdge,
200
- y: chromeY + BRAND_LARGE,
201
- 'dominant-baseline': 'alphabetic',
202
- 'text-anchor': 'end',
203
- 'font-family': layout.theme.fonts.family,
204
- 'font-size': BRAND_FONT_SIZE,
205
- 'fill-opacity': 0.55,
206
- });
207
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
208
-
209
- const trySpan = createSVGElement('tspan');
210
- setAttrs(trySpan, { 'font-weight': 500 });
211
- trySpan.textContent = 'try';
212
-
213
- const openDataSpan = createSVGElement('tspan');
214
- setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': BRAND_LARGE });
215
- openDataSpan.textContent = 'OpenData';
216
-
217
- const aiSpan = createSVGElement('tspan');
218
- setAttrs(aiSpan, { 'font-weight': 500 });
219
- aiSpan.textContent = '.ai';
220
-
221
- text.appendChild(trySpan);
222
- text.appendChild(openDataSpan);
223
- text.appendChild(aiSpan);
224
- a.appendChild(text);
225
- parent.appendChild(a);
226
- }
227
-
228
- // ---------------------------------------------------------------------------
229
- // Legend rendering
230
- // ---------------------------------------------------------------------------
231
-
232
- function renderLegend(parent: SVGElement, legendLayout: LegendLayout): void {
233
- if (!('entries' in legendLayout) || legendLayout.entries.length === 0) return;
234
- const legend = legendLayout as CategoricalLegendLayout;
235
-
236
- const g = createSVGElement('g');
237
- g.setAttribute('class', 'oc-legend');
238
- g.setAttribute('role', 'list');
239
- g.setAttribute('aria-label', 'Chart legend');
240
-
241
- const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
242
- let offsetX = legend.bounds.x;
243
- let offsetY = legend.bounds.y;
244
-
245
- for (let i = 0; i < legend.entries.length; i++) {
246
- const entry = legend.entries[i];
247
-
248
- // Wrap to next line if this entry would overflow bounds
249
- if (isHorizontal && i > 0) {
250
- const labelWidth = estimateTextWidth(
251
- entry.label,
252
- legend.labelStyle.fontSize,
253
- legend.labelStyle.fontWeight,
254
- );
255
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
256
- if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
257
- offsetX = legend.bounds.x;
258
- offsetY += legend.swatchSize + 6;
259
- }
260
- }
261
-
262
- const entryG = createSVGElement('g');
263
- entryG.setAttribute('class', 'oc-legend-entry');
264
- entryG.setAttribute('role', 'listitem');
265
- entryG.setAttribute('data-legend-index', String(i));
266
- entryG.setAttribute('data-legend-label', entry.label);
267
-
268
- if (entry.overflow) {
269
- entryG.setAttribute('data-legend-overflow', 'true');
270
- entryG.setAttribute('aria-label', entry.label);
271
- entryG.setAttribute('opacity', '0.5');
272
- } else {
273
- entryG.setAttribute(
274
- 'aria-label',
275
- `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
276
- );
277
- entryG.setAttribute('style', 'cursor: pointer');
278
- if (entry.active === false) {
279
- entryG.setAttribute('opacity', '0.3');
280
- }
281
- }
282
-
283
- // Swatch (always rect for sankey)
284
- const rect = createSVGElement('rect');
285
- setAttrs(rect, {
286
- x: offsetX,
287
- y: offsetY,
288
- width: legend.swatchSize,
289
- height: legend.swatchSize,
290
- fill: entry.color,
291
- rx: 2,
292
- });
293
- entryG.appendChild(rect);
294
-
295
- // Label
296
- const label = createSVGElement('text');
297
- setAttrs(label, {
298
- x: offsetX + legend.swatchSize + legend.swatchGap,
299
- y: offsetY + legend.swatchSize / 2,
300
- 'dominant-baseline': 'central',
301
- });
302
- applyTextStyle(label, legend.labelStyle);
303
- label.textContent = entry.label;
304
- entryG.appendChild(label);
305
-
306
- g.appendChild(entryG);
307
-
308
- // Advance position for next entry
309
- if (isHorizontal) {
310
- const labelWidth = estimateTextWidth(
311
- entry.label,
312
- legend.labelStyle.fontSize,
313
- legend.labelStyle.fontWeight,
314
- );
315
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
316
- offsetX += entryWidth;
317
- } else {
318
- offsetY += legend.swatchSize + legend.entryGap;
319
- }
320
- }
321
-
322
- parent.appendChild(g);
323
- }
324
-
325
- // ---------------------------------------------------------------------------
326
- // Gradient defs
327
- // ---------------------------------------------------------------------------
328
-
329
- /**
330
- * Build a node position lookup for gradient x1/x2 coordinates.
331
- * Maps nodeId to { x, width } so we can compute gradient endpoints.
332
- */
333
- function buildNodePositionMap(nodes: SankeyNodeMark[]): Map<string, { x: number; width: number }> {
334
- const map = new Map<string, { x: number; width: number }>();
335
- for (const node of nodes) {
336
- map.set(node.nodeId, { x: node.x, width: node.width });
337
- }
338
- return map;
339
- }
340
-
341
- /** Sanitize a string for use as an SVG element ID (no spaces, $, or other invalid chars). */
342
- function sanitizeId(s: string): string {
343
- return s.replace(/[^a-zA-Z0-9_-]/g, '_');
344
- }
345
-
346
- function renderGradientDefs(
347
- defs: SVGElement,
348
- links: SankeyLinkMark[],
349
- nodePositions: Map<string, { x: number; width: number }>,
350
- ): void {
351
- for (let i = 0; i < links.length; i++) {
352
- const link = links[i];
353
- // Only create gradients when source and target colors differ
354
- if (link.sourceColor === link.targetColor) continue;
355
-
356
- // Use sanitized node names for debuggability, with index suffix for uniqueness
357
- const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
358
- const gradient = createSVGElement('linearGradient');
359
- gradient.setAttribute('id', gradId);
360
- gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
361
-
362
- // x1 = right edge of source node, x2 = left edge of target node
363
- const sourcePos = nodePositions.get(link.sourceId);
364
- const targetPos = nodePositions.get(link.targetId);
365
- const x1 = sourcePos ? sourcePos.x + sourcePos.width : 0;
366
- const x2 = targetPos ? targetPos.x : 0;
367
-
368
- gradient.setAttribute('x1', String(x1));
369
- gradient.setAttribute('x2', String(x2));
370
-
371
- const stop0 = createSVGElement('stop');
372
- setAttrs(stop0, { offset: '0%', 'stop-color': link.sourceColor });
373
- gradient.appendChild(stop0);
374
-
375
- const stop1 = createSVGElement('stop');
376
- setAttrs(stop1, { offset: '100%', 'stop-color': link.targetColor });
377
- gradient.appendChild(stop1);
378
-
379
- defs.appendChild(gradient);
380
- }
381
- }
382
-
383
- // ---------------------------------------------------------------------------
384
- // Links rendering
385
- // ---------------------------------------------------------------------------
386
-
387
- function renderLinks(
388
- parent: SVGElement,
389
- links: SankeyLinkMark[],
390
- _nodePositions: Map<string, { x: number; width: number }>,
391
- animation?: ResolvedAnimation,
392
- ): void {
393
- const g = createSVGElement('g');
394
- g.setAttribute('class', 'oc-sankey-links');
395
-
396
- for (let i = 0; i < links.length; i++) {
397
- const link = links[i];
398
-
399
- const linkG = createSVGElement('g');
400
- linkG.setAttribute('class', 'oc-sankey-link');
401
- linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}-${i}`);
402
- linkG.setAttribute('data-source', link.sourceId);
403
- linkG.setAttribute('data-target', link.targetId);
404
-
405
- if (link.aria?.label) {
406
- linkG.setAttribute('aria-label', link.aria.label);
407
- }
408
-
409
- stampAnimationAttrs(linkG, link, i, animation);
410
-
411
- const path = createSVGElement('path');
412
- path.setAttribute('d', link.path);
413
- path.setAttribute('stroke', 'none');
414
- path.setAttribute('fill-opacity', String(link.fillOpacity));
415
-
416
- // Use gradient fill when colors differ, otherwise solid fill
417
- if (link.sourceColor !== link.targetColor) {
418
- const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
419
- path.setAttribute('fill', `url(#${gradId})`);
420
- } else {
421
- path.setAttribute('fill', link.sourceColor);
422
- }
423
-
424
- linkG.appendChild(path);
425
- g.appendChild(linkG);
426
- }
427
-
428
- parent.appendChild(g);
429
- }
430
-
431
- // ---------------------------------------------------------------------------
432
- // Nodes rendering
433
- // ---------------------------------------------------------------------------
434
-
435
- function renderNodes(
436
- parent: SVGElement,
437
- nodes: SankeyNodeMark[],
438
- animation?: ResolvedAnimation,
439
- ): void {
440
- const g = createSVGElement('g');
441
- g.setAttribute('class', 'oc-sankey-nodes');
442
-
443
- for (let i = 0; i < nodes.length; i++) {
444
- const node = nodes[i];
445
-
446
- const nodeG = createSVGElement('g');
447
- nodeG.setAttribute('class', 'oc-sankey-node');
448
- nodeG.setAttribute('data-mark-id', `node-${node.nodeId}`);
449
- nodeG.setAttribute('data-node-id', node.nodeId);
450
-
451
- if (node.aria?.label) {
452
- nodeG.setAttribute('aria-label', node.aria.label);
453
- }
454
-
455
- stampAnimationAttrs(nodeG, node, i, animation);
456
-
457
- const rect = createSVGElement('rect');
458
- setAttrs(rect, {
459
- x: node.x,
460
- y: node.y,
461
- width: node.width,
462
- height: Math.max(node.height, 1), // Ensure at least 1px visibility
463
- fill: node.fill,
464
- rx: node.cornerRadius,
465
- });
466
-
467
- if (node.stroke) {
468
- rect.setAttribute('stroke', node.stroke);
469
- }
470
- if (node.strokeWidth) {
471
- rect.setAttribute('stroke-width', String(node.strokeWidth));
472
- }
473
-
474
- nodeG.appendChild(rect);
475
- g.appendChild(nodeG);
476
- }
477
-
478
- parent.appendChild(g);
479
- }
480
-
481
- // ---------------------------------------------------------------------------
482
- // Labels rendering
483
- // ---------------------------------------------------------------------------
484
-
485
- function renderLabels(
486
- parent: SVGElement,
487
- nodes: SankeyNodeMark[],
488
- measureText?: MeasureTextFn,
489
- ): void {
490
- const g = createSVGElement('g');
491
- g.setAttribute('class', 'oc-sankey-labels');
492
-
493
- for (const node of nodes) {
494
- const { label } = node;
495
- if (!label.visible) continue;
496
-
497
- const text = createSVGElement('text');
498
- setAttrs(text, { x: label.x, y: label.y });
499
- applyTextStyle(text, label.style);
500
-
501
- // Wrap label text when maxWidth is set and text would overflow
502
- if (label.maxWidth !== undefined && label.maxWidth > 0) {
503
- const fontSize = label.style.fontSize ?? 12;
504
- const fontWeight = label.style.fontWeight ?? 400;
505
- const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth, measureText);
506
- if (lines.length > 1) {
507
- const lineHeight = fontSize * (label.style.lineHeight ?? 1.3);
508
- // Center the multi-line block vertically around the label y position
509
- const totalHeight = (lines.length - 1) * lineHeight;
510
- const startY = label.y - totalHeight / 2;
511
- for (let i = 0; i < lines.length; i++) {
512
- const tspan = createSVGElement('tspan');
513
- tspan.setAttribute('x', String(label.x));
514
- tspan.setAttribute('y', String(startY + i * lineHeight));
515
- tspan.textContent = lines[i];
516
- text.appendChild(tspan);
517
- }
518
- } else {
519
- text.textContent = label.text;
520
- }
521
- } else {
522
- text.textContent = label.text;
523
- }
524
-
525
- g.appendChild(text);
526
- }
527
-
528
- parent.appendChild(g);
529
- }
530
-
531
- // ---------------------------------------------------------------------------
532
- // Main render function
533
- // ---------------------------------------------------------------------------
534
-
535
- /**
536
- * Render a SankeyLayout into an SVG element.
537
- *
538
- * The SVG structure is: background > defs (gradients) > links > nodes > labels > legend > chrome > brand.
539
- * Links render before nodes so they appear visually behind.
540
- */
541
- export function renderSankeySVG(
542
- layout: SankeyLayout,
543
- animation?: ResolvedAnimation,
544
- ): SVGSVGElement {
545
- const { width, height } = layout.dimensions;
546
-
547
- const svg = createSVGElement('svg') as SVGSVGElement;
548
- setAttrs(svg, {
549
- viewBox: `0 0 ${width} ${height}`,
550
- xmlns: SVG_NS,
551
- overflow: 'visible',
552
- });
553
- svg.style.height = `${height}px`;
554
- svg.setAttribute('role', layout.a11y.role);
555
- svg.setAttribute('aria-label', layout.a11y.altText);
556
-
557
- // Classes: oc-chart oc-sankey, plus oc-animate if animated
558
- const enterPhase = animation?.enter;
559
- const animate = !!enterPhase;
560
- const classes = animate ? 'oc-chart oc-sankey oc-animate' : 'oc-chart oc-sankey';
561
- svg.setAttribute('class', classes);
562
-
563
- // Set animation CSS custom properties when the enter phase is enabled
564
- if (enterPhase) {
565
- const totalMarks = layout.nodes.length + layout.links.length;
566
- const stagger = clampStaggerDelay(enterPhase.staggerDelay, totalMarks);
567
- stampAnimationVars(svg, {
568
- duration: enterPhase.duration,
569
- stagger,
570
- annotationDelay: animation!.annotationDelay,
571
- ease: enterPhase.ease,
572
- });
573
- }
574
-
575
- // Background
576
- const bg = createSVGElement('rect');
577
- bg.setAttribute('class', 'oc-background');
578
- setAttrs(bg, {
579
- x: 0,
580
- y: 0,
581
- width,
582
- height,
583
- fill: layout.theme.colors.background,
584
- });
585
- svg.appendChild(bg);
586
-
587
- // Defs: gradient definitions for links
588
- const nodePositions = buildNodePositionMap(layout.nodes);
589
- const defs = createSVGElement('defs');
590
- renderGradientDefs(defs, layout.links, nodePositions);
591
- svg.appendChild(defs);
592
-
593
- // Links (behind nodes)
594
- renderLinks(svg, layout.links, nodePositions, animation);
595
-
596
- // Nodes
597
- renderNodes(svg, layout.nodes, animation);
598
-
599
- // Labels
600
- renderLabels(svg, layout.nodes, layout.measureText);
601
-
602
- // Legend
603
- renderLegend(svg, layout.legend);
604
-
605
- // Chrome (title, subtitle, source, byline, footer) renders on top
606
- renderChrome(svg, layout);
607
-
608
- // Brand
609
- if (layout.watermark) {
610
- renderBrand(svg, layout);
611
- }
612
-
613
- return svg;
614
- }