@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.
- package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/sankey-renderer.ts
DELETED
|
@@ -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
|
-
}
|