@opendata-ai/openchart-vanilla 6.19.3 → 6.21.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 +18 -7
- package/dist/index.js +718 -778
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/gradient-ids.test.ts +159 -0
- package/src/__tests__/resize-timing.test.ts +184 -0
- package/src/gradient-utils.ts +6 -8
- package/src/mount.ts +4 -11
- package/src/renderers/annotations.ts +212 -0
- package/src/renderers/axes.ts +164 -0
- package/src/renderers/brand.ts +75 -0
- package/src/renderers/chrome.ts +96 -0
- package/src/renderers/legend.ts +131 -0
- package/src/renderers/marks.ts +427 -0
- package/src/renderers/svg-dom.ts +66 -0
- package/src/sankey-renderer.ts +6 -43
- package/src/svg-ids.ts +18 -0
- package/src/svg-renderer.ts +64 -1269
package/src/svg-renderer.ts
CHANGED
|
@@ -5,63 +5,25 @@
|
|
|
5
5
|
* renders chrome (title/subtitle/source), axes, marks, annotations,
|
|
6
6
|
* and legend. All styling via inline SVG attributes from layout data.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* This file is the orchestrator only. Each rendering concern lives in its
|
|
9
|
+
* own module under `./renderers/`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
13
|
-
ArcMark,
|
|
14
|
-
AreaMark,
|
|
15
|
-
AxisLayout,
|
|
16
|
-
ChartLayout,
|
|
17
|
-
LegendLayout,
|
|
18
|
-
LineMark,
|
|
19
|
-
Mark,
|
|
20
|
-
MeasureTextFn,
|
|
21
|
-
Point,
|
|
22
|
-
PointMark,
|
|
23
|
-
RectMark,
|
|
24
|
-
ResolvedAnimation,
|
|
25
|
-
ResolvedAnnotation,
|
|
26
|
-
ResolvedChromeElement,
|
|
27
|
-
RuleMarkLayout,
|
|
28
|
-
TextMarkLayout,
|
|
29
|
-
TextStyle,
|
|
30
|
-
TickMarkLayout,
|
|
31
|
-
} from '@opendata-ai/openchart-core';
|
|
32
|
-
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
12
|
+
import type { ChartLayout, RectMark } from '@opendata-ai/openchart-core';
|
|
33
13
|
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
34
|
-
import { buildGradientDefs
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*/
|
|
48
|
-
let currentGradientMap: Map<string, string> = new Map();
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Stamp animation index attributes on a mark element when animation is enabled.
|
|
52
|
-
* Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
|
|
53
|
-
* (for CSS calc-based stagger delay).
|
|
54
|
-
*/
|
|
55
|
-
function stampAnimationAttrs(
|
|
56
|
-
el: SVGElement,
|
|
57
|
-
mark: { animationIndex?: number },
|
|
58
|
-
fallbackIndex: number,
|
|
59
|
-
): void {
|
|
60
|
-
if (!currentAnimation?.enabled) return;
|
|
61
|
-
const idx = mark.animationIndex ?? fallbackIndex;
|
|
62
|
-
el.setAttribute('data-animation-index', String(idx));
|
|
63
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
64
|
-
}
|
|
14
|
+
import { buildGradientDefs } from './gradient-utils';
|
|
15
|
+
import { renderAnnotations } from './renderers/annotations';
|
|
16
|
+
import { renderAxes } from './renderers/axes';
|
|
17
|
+
import { renderBrand } from './renderers/brand';
|
|
18
|
+
import { renderChrome } from './renderers/chrome';
|
|
19
|
+
import { renderLegend } from './renderers/legend';
|
|
20
|
+
import { renderMarks, resetMarkRenderState, setMarkRenderState } from './renderers/marks';
|
|
21
|
+
import { createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
|
|
22
|
+
import { nextSvgId } from './svg-ids';
|
|
23
|
+
|
|
24
|
+
// Re-export registerMarkRenderer so external consumers can still register
|
|
25
|
+
// custom mark renderers via the vanilla package entry point.
|
|
26
|
+
export { registerMarkRenderer } from './renderers/marks';
|
|
65
27
|
|
|
66
28
|
/** CSS easing preset map for inline style custom properties. */
|
|
67
29
|
const EASE_VAR_MAP: Record<string, string> = {
|
|
@@ -69,1175 +31,6 @@ const EASE_VAR_MAP: Record<string, string> = {
|
|
|
69
31
|
snappy: 'var(--oc-ease-snappy)',
|
|
70
32
|
};
|
|
71
33
|
|
|
72
|
-
/**
|
|
73
|
-
* Compute the vertical extent of x-axis labels below the chart area.
|
|
74
|
-
* Accounts for rotated tick labels which need more vertical space.
|
|
75
|
-
*/
|
|
76
|
-
function computeXAxisExtent(layout: ChartLayout): number {
|
|
77
|
-
const xAxis = layout.axes.x;
|
|
78
|
-
if (!xAxis) return 0;
|
|
79
|
-
|
|
80
|
-
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
|
|
81
|
-
// Rotated labels: estimate height from the longest tick label.
|
|
82
|
-
const fontSize = xAxis.tickLabelStyle.fontSize;
|
|
83
|
-
const fontWeight = xAxis.tickLabelStyle.fontWeight;
|
|
84
|
-
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
|
|
85
|
-
let maxLabelWidth = 40;
|
|
86
|
-
for (const tick of xAxis.ticks) {
|
|
87
|
-
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
88
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
89
|
-
}
|
|
90
|
-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
91
|
-
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return xAxis.label ? 48 : 26;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Helpers
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
function createSVGElement(tag: string): SVGElement {
|
|
102
|
-
return document.createElementNS(SVG_NS, tag);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
|
|
106
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
107
|
-
el.setAttribute(key, String(value));
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
112
|
-
setAttrs(el, {
|
|
113
|
-
'font-family': style.fontFamily,
|
|
114
|
-
'font-size': style.fontSize,
|
|
115
|
-
'font-weight': style.fontWeight,
|
|
116
|
-
});
|
|
117
|
-
// Use inline style for fill so it takes priority over CSS class defaults
|
|
118
|
-
// (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
|
|
119
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
|
|
120
|
-
if (style.textAnchor) {
|
|
121
|
-
el.setAttribute('text-anchor', style.textAnchor);
|
|
122
|
-
}
|
|
123
|
-
if (style.dominantBaseline) {
|
|
124
|
-
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
125
|
-
}
|
|
126
|
-
if (style.fontVariant) {
|
|
127
|
-
el.setAttribute('font-variant', style.fontVariant);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Chrome rendering
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Break text into lines that fit within maxWidth using word wrapping.
|
|
137
|
-
* Uses a character-width heuristic (same as text-measure.ts).
|
|
138
|
-
*/
|
|
139
|
-
function wrapText(
|
|
140
|
-
text: string,
|
|
141
|
-
fontSize: number,
|
|
142
|
-
fontWeight: number,
|
|
143
|
-
maxWidth: number,
|
|
144
|
-
measureText?: MeasureTextFn,
|
|
145
|
-
): string[] {
|
|
146
|
-
if (maxWidth <= 0) return [text];
|
|
147
|
-
|
|
148
|
-
// Split on explicit newlines first
|
|
149
|
-
const segments = text.split('\n');
|
|
150
|
-
if (segments.length > 1) {
|
|
151
|
-
return segments.flatMap((segment) =>
|
|
152
|
-
segment.length === 0 ? [''] : wrapText(segment, fontSize, fontWeight, maxWidth, measureText),
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Use real text measurement when available
|
|
157
|
-
if (measureText) {
|
|
158
|
-
const textWidth = measureText(text, fontSize, fontWeight).width;
|
|
159
|
-
if (textWidth <= maxWidth) return [text];
|
|
160
|
-
|
|
161
|
-
const words = text.split(' ');
|
|
162
|
-
const lines: string[] = [];
|
|
163
|
-
let current = '';
|
|
164
|
-
|
|
165
|
-
for (const word of words) {
|
|
166
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
167
|
-
const candidateWidth = measureText(candidate, fontSize, fontWeight).width;
|
|
168
|
-
if (candidateWidth > maxWidth && current) {
|
|
169
|
-
lines.push(current);
|
|
170
|
-
current = word;
|
|
171
|
-
} else {
|
|
172
|
-
current = candidate;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (current) lines.push(current);
|
|
176
|
-
|
|
177
|
-
return lines;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Heuristic character width matching text-measure.ts
|
|
181
|
-
const AVG_CHAR_WIDTH = 0.57;
|
|
182
|
-
const WEIGHT_FACTORS: Record<number, number> = {
|
|
183
|
-
100: 0.9,
|
|
184
|
-
200: 0.92,
|
|
185
|
-
300: 0.95,
|
|
186
|
-
400: 1.0,
|
|
187
|
-
500: 1.02,
|
|
188
|
-
600: 1.05,
|
|
189
|
-
700: 1.08,
|
|
190
|
-
800: 1.1,
|
|
191
|
-
900: 1.12,
|
|
192
|
-
};
|
|
193
|
-
const weightFactor = WEIGHT_FACTORS[fontWeight] ?? 1.0;
|
|
194
|
-
const charWidth = fontSize * AVG_CHAR_WIDTH * weightFactor;
|
|
195
|
-
const maxChars = Math.floor(maxWidth / charWidth);
|
|
196
|
-
|
|
197
|
-
if (text.length <= maxChars) return [text];
|
|
198
|
-
|
|
199
|
-
const words = text.split(' ');
|
|
200
|
-
const lines: string[] = [];
|
|
201
|
-
let current = '';
|
|
202
|
-
|
|
203
|
-
for (const word of words) {
|
|
204
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
205
|
-
if (candidate.length > maxChars && current) {
|
|
206
|
-
lines.push(current);
|
|
207
|
-
current = word;
|
|
208
|
-
} else {
|
|
209
|
-
current = candidate;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (current) lines.push(current);
|
|
213
|
-
|
|
214
|
-
return lines;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function renderChromeElement(
|
|
218
|
-
parent: SVGElement,
|
|
219
|
-
element: ResolvedChromeElement,
|
|
220
|
-
className: string,
|
|
221
|
-
chromeKey: string,
|
|
222
|
-
measureText?: MeasureTextFn,
|
|
223
|
-
): void {
|
|
224
|
-
const text = createSVGElement('text');
|
|
225
|
-
setAttrs(text, { x: element.x, y: element.y });
|
|
226
|
-
applyTextStyle(text, element.style);
|
|
227
|
-
text.setAttribute('class', className);
|
|
228
|
-
text.setAttribute('data-chrome-key', chromeKey);
|
|
229
|
-
|
|
230
|
-
const lines = wrapText(
|
|
231
|
-
element.text,
|
|
232
|
-
element.style.fontSize,
|
|
233
|
-
element.style.fontWeight,
|
|
234
|
-
element.maxWidth,
|
|
235
|
-
measureText,
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
if (lines.length === 1) {
|
|
239
|
-
text.textContent = element.text;
|
|
240
|
-
} else {
|
|
241
|
-
const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
|
|
242
|
-
for (let i = 0; i < lines.length; i++) {
|
|
243
|
-
const tspan = createSVGElement('tspan');
|
|
244
|
-
setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
|
|
245
|
-
tspan.textContent = lines[i];
|
|
246
|
-
text.appendChild(tspan);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
parent.appendChild(text);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
254
|
-
const g = createSVGElement('g');
|
|
255
|
-
g.setAttribute('class', 'oc-chrome');
|
|
256
|
-
|
|
257
|
-
const { chrome, measureText } = layout;
|
|
258
|
-
|
|
259
|
-
// Top chrome: render at their stored y positions (already absolute)
|
|
260
|
-
if (chrome.title) {
|
|
261
|
-
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
|
|
262
|
-
}
|
|
263
|
-
if (chrome.subtitle) {
|
|
264
|
-
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Bottom chrome starts below x-axis labels/title, not at chart area bottom.
|
|
268
|
-
// Accounts for rotated tick labels which need more vertical space.
|
|
269
|
-
const xAxisExtent = computeXAxisExtent(layout);
|
|
270
|
-
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
271
|
-
if (chrome.source) {
|
|
272
|
-
renderChromeElement(
|
|
273
|
-
g,
|
|
274
|
-
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
275
|
-
'oc-source',
|
|
276
|
-
'source',
|
|
277
|
-
measureText,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
if (chrome.byline) {
|
|
281
|
-
renderChromeElement(
|
|
282
|
-
g,
|
|
283
|
-
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
284
|
-
'oc-byline',
|
|
285
|
-
'byline',
|
|
286
|
-
measureText,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
if (chrome.footer) {
|
|
290
|
-
renderChromeElement(
|
|
291
|
-
g,
|
|
292
|
-
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
293
|
-
'oc-footer',
|
|
294
|
-
'footer',
|
|
295
|
-
measureText,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
parent.appendChild(g);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
// Axis rendering
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
|
|
306
|
-
function renderAxis(
|
|
307
|
-
parent: SVGElement,
|
|
308
|
-
axis: AxisLayout,
|
|
309
|
-
orientation: 'x' | 'y',
|
|
310
|
-
layout: ChartLayout,
|
|
311
|
-
): void {
|
|
312
|
-
const g = createSVGElement('g');
|
|
313
|
-
g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
|
|
314
|
-
|
|
315
|
-
const { area } = layout;
|
|
316
|
-
|
|
317
|
-
// Only draw axis line for x-axis (bottom baseline).
|
|
318
|
-
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
|
|
319
|
-
if (orientation === 'x') {
|
|
320
|
-
const line = createSVGElement('line');
|
|
321
|
-
line.setAttribute('class', 'oc-axis-line');
|
|
322
|
-
setAttrs(line, {
|
|
323
|
-
x1: axis.start.x,
|
|
324
|
-
y1: axis.start.y,
|
|
325
|
-
x2: axis.end.x,
|
|
326
|
-
y2: axis.end.y,
|
|
327
|
-
stroke: layout.theme.colors.axis,
|
|
328
|
-
'stroke-width': 1,
|
|
329
|
-
});
|
|
330
|
-
g.appendChild(line);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Ticks and labels
|
|
334
|
-
// Tick positions are absolute pixel coordinates from D3 scales whose range
|
|
335
|
-
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
|
|
336
|
-
// Don't add area.x/area.y again or you'll double-offset everything.
|
|
337
|
-
for (const tick of axis.ticks) {
|
|
338
|
-
if (orientation === 'x') {
|
|
339
|
-
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
340
|
-
const label = createSVGElement('text');
|
|
341
|
-
label.setAttribute('class', 'oc-axis-tick');
|
|
342
|
-
|
|
343
|
-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
344
|
-
// Rotated labels: anchor at the rotation pivot point
|
|
345
|
-
const labelX = tick.position;
|
|
346
|
-
const labelY = area.y + area.height + 6;
|
|
347
|
-
setAttrs(label, {
|
|
348
|
-
x: labelX,
|
|
349
|
-
y: labelY,
|
|
350
|
-
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
|
|
351
|
-
'dominant-baseline': 'central',
|
|
352
|
-
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
|
|
353
|
-
});
|
|
354
|
-
} else {
|
|
355
|
-
setAttrs(label, {
|
|
356
|
-
x: tick.position,
|
|
357
|
-
y: area.y + area.height + 14,
|
|
358
|
-
'text-anchor': 'middle',
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
applyTextStyle(label, axis.tickLabelStyle);
|
|
363
|
-
label.textContent = tick.label;
|
|
364
|
-
g.appendChild(label);
|
|
365
|
-
} else {
|
|
366
|
-
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
367
|
-
const label = createSVGElement('text');
|
|
368
|
-
label.setAttribute('class', 'oc-axis-tick');
|
|
369
|
-
setAttrs(label, {
|
|
370
|
-
x: area.x - 6,
|
|
371
|
-
y: tick.position,
|
|
372
|
-
'text-anchor': 'end',
|
|
373
|
-
'dominant-baseline': 'central',
|
|
374
|
-
});
|
|
375
|
-
applyTextStyle(label, axis.tickLabelStyle);
|
|
376
|
-
label.textContent = tick.label;
|
|
377
|
-
g.appendChild(label);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Gridlines (positions are also absolute from the scales)
|
|
382
|
-
for (const gridline of axis.gridlines) {
|
|
383
|
-
const gl = createSVGElement('line');
|
|
384
|
-
gl.setAttribute('class', 'oc-gridline');
|
|
385
|
-
if (orientation === 'y') {
|
|
386
|
-
setAttrs(gl, {
|
|
387
|
-
x1: area.x,
|
|
388
|
-
y1: gridline.position,
|
|
389
|
-
x2: area.x + area.width,
|
|
390
|
-
y2: gridline.position,
|
|
391
|
-
stroke: layout.theme.colors.gridline,
|
|
392
|
-
'stroke-width': 1,
|
|
393
|
-
'stroke-opacity': 0.6,
|
|
394
|
-
});
|
|
395
|
-
} else {
|
|
396
|
-
setAttrs(gl, {
|
|
397
|
-
x1: gridline.position,
|
|
398
|
-
y1: area.y,
|
|
399
|
-
x2: gridline.position,
|
|
400
|
-
y2: area.y + area.height,
|
|
401
|
-
stroke: layout.theme.colors.gridline,
|
|
402
|
-
'stroke-width': 1,
|
|
403
|
-
'stroke-opacity': 0.6,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
g.appendChild(gl);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Axis label
|
|
410
|
-
if (axis.label && axis.labelStyle) {
|
|
411
|
-
const axisLabel = createSVGElement('text');
|
|
412
|
-
axisLabel.setAttribute('class', 'oc-axis-title');
|
|
413
|
-
applyTextStyle(axisLabel, axis.labelStyle);
|
|
414
|
-
axisLabel.textContent = axis.label;
|
|
415
|
-
|
|
416
|
-
if (orientation === 'x') {
|
|
417
|
-
// Position axis title below tick labels. For rotated labels, compute
|
|
418
|
-
// the vertical extent of the rotated ticks and place the title below.
|
|
419
|
-
let titleY = area.y + area.height + 35;
|
|
420
|
-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
421
|
-
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
|
|
422
|
-
let maxLabelWidth = 40;
|
|
423
|
-
for (const tick of axis.ticks) {
|
|
424
|
-
const w = estimateTextWidth(
|
|
425
|
-
tick.label,
|
|
426
|
-
axis.tickLabelStyle.fontSize,
|
|
427
|
-
axis.tickLabelStyle.fontWeight,
|
|
428
|
-
);
|
|
429
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
430
|
-
}
|
|
431
|
-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
432
|
-
titleY = area.y + area.height + rotatedHeight + 14;
|
|
433
|
-
}
|
|
434
|
-
setAttrs(axisLabel, {
|
|
435
|
-
x: area.x + area.width / 2,
|
|
436
|
-
y: titleY,
|
|
437
|
-
'text-anchor': 'middle',
|
|
438
|
-
});
|
|
439
|
-
} else {
|
|
440
|
-
// Rotated y-axis label
|
|
441
|
-
setAttrs(axisLabel, {
|
|
442
|
-
x: area.x - 45,
|
|
443
|
-
y: area.y + area.height / 2,
|
|
444
|
-
'text-anchor': 'middle',
|
|
445
|
-
transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
g.appendChild(axisLabel);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
parent.appendChild(g);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function renderAxes(parent: SVGElement, layout: ChartLayout): void {
|
|
455
|
-
if (layout.axes.x) {
|
|
456
|
-
renderAxis(parent, layout.axes.x, 'x', layout);
|
|
457
|
-
}
|
|
458
|
-
if (layout.axes.y) {
|
|
459
|
-
renderAxis(parent, layout.axes.y, 'y', layout);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// ---------------------------------------------------------------------------
|
|
464
|
-
// Mark rendering (dispatch per mark type)
|
|
465
|
-
// ---------------------------------------------------------------------------
|
|
466
|
-
|
|
467
|
-
type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
|
|
468
|
-
|
|
469
|
-
const markRenderers: Record<string, MarkRenderer<Mark>> = {};
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Register a mark renderer for a specific mark type.
|
|
473
|
-
* Built-in renderers are registered below for all chart types.
|
|
474
|
-
*/
|
|
475
|
-
export function registerMarkRenderer<T extends Mark>(
|
|
476
|
-
type: T['type'],
|
|
477
|
-
renderer: MarkRenderer<T>,
|
|
478
|
-
): void {
|
|
479
|
-
markRenderers[type] = renderer as MarkRenderer<Mark>;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
483
|
-
const g = createSVGElement('g');
|
|
484
|
-
g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
|
|
485
|
-
g.setAttribute('class', 'oc-mark oc-mark-line');
|
|
486
|
-
stampAnimationAttrs(g, mark, index);
|
|
487
|
-
|
|
488
|
-
if (mark.points.length > 1) {
|
|
489
|
-
const path = createSVGElement('path');
|
|
490
|
-
// Use the pre-computed D3 curve path when available (smooth monotone),
|
|
491
|
-
// otherwise fall back to straight M/L segments.
|
|
492
|
-
const d =
|
|
493
|
-
mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
494
|
-
setAttrs(path, {
|
|
495
|
-
d,
|
|
496
|
-
fill: 'none',
|
|
497
|
-
stroke: mark.stroke,
|
|
498
|
-
'stroke-width': mark.strokeWidth,
|
|
499
|
-
});
|
|
500
|
-
if (mark.strokeDasharray) {
|
|
501
|
-
path.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
502
|
-
}
|
|
503
|
-
if (mark.opacity != null) {
|
|
504
|
-
path.setAttribute('opacity', String(mark.opacity));
|
|
505
|
-
}
|
|
506
|
-
// Note: line drawing animation is handled via CSS clip-path on the group,
|
|
507
|
-
// no inline dasharray/dashoffset needed.
|
|
508
|
-
g.appendChild(path);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Render end-of-line label if present and visible
|
|
512
|
-
if (mark.label?.visible) {
|
|
513
|
-
const label = createSVGElement('text');
|
|
514
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
515
|
-
if (mark.seriesKey) {
|
|
516
|
-
label.setAttribute('data-series', mark.seriesKey);
|
|
517
|
-
}
|
|
518
|
-
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
519
|
-
applyTextStyle(label, mark.label.style);
|
|
520
|
-
label.textContent = mark.label.text;
|
|
521
|
-
g.appendChild(label);
|
|
522
|
-
|
|
523
|
-
// Render connector line if label was offset from anchor
|
|
524
|
-
if (mark.label.connector) {
|
|
525
|
-
const connector = createSVGElement('line');
|
|
526
|
-
connector.setAttribute('class', 'oc-mark-connector');
|
|
527
|
-
setAttrs(connector, {
|
|
528
|
-
x1: mark.label.connector.from.x,
|
|
529
|
-
y1: mark.label.connector.from.y,
|
|
530
|
-
x2: mark.label.connector.to.x,
|
|
531
|
-
y2: mark.label.connector.to.y,
|
|
532
|
-
stroke: mark.label.connector.stroke,
|
|
533
|
-
'stroke-width': 1,
|
|
534
|
-
'stroke-opacity': 0.5,
|
|
535
|
-
});
|
|
536
|
-
g.appendChild(connector);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
return g;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
544
|
-
const g = createSVGElement('g');
|
|
545
|
-
g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
|
|
546
|
-
g.setAttribute('class', 'oc-mark oc-mark-area');
|
|
547
|
-
stampAnimationAttrs(g, mark, index);
|
|
548
|
-
|
|
549
|
-
if (mark.path) {
|
|
550
|
-
// Area fill: the full closed shape (top line + baseline), no stroke
|
|
551
|
-
const fill = createSVGElement('path');
|
|
552
|
-
setAttrs(fill, {
|
|
553
|
-
d: mark.path,
|
|
554
|
-
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
555
|
-
'fill-opacity': mark.fillOpacity,
|
|
556
|
-
stroke: 'none',
|
|
557
|
-
});
|
|
558
|
-
g.appendChild(fill);
|
|
559
|
-
|
|
560
|
-
// Top-line stroke: only along the data points, not the baseline
|
|
561
|
-
if (mark.stroke && mark.topPath) {
|
|
562
|
-
const strokePath = createSVGElement('path');
|
|
563
|
-
strokePath.setAttribute('class', 'oc-area-top');
|
|
564
|
-
setAttrs(strokePath, {
|
|
565
|
-
d: mark.topPath,
|
|
566
|
-
fill: 'none',
|
|
567
|
-
stroke: mark.stroke,
|
|
568
|
-
'stroke-width': mark.strokeWidth ?? 1,
|
|
569
|
-
});
|
|
570
|
-
// Note: area drawing animation is handled via CSS clip-path on the group,
|
|
571
|
-
// no inline dasharray/dashoffset needed.
|
|
572
|
-
g.appendChild(strokePath);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
return g;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
580
|
-
const g = createSVGElement('g');
|
|
581
|
-
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
582
|
-
g.setAttribute('class', 'oc-mark oc-mark-rect');
|
|
583
|
-
stampAnimationAttrs(g, mark, index);
|
|
584
|
-
// Use engine-provided orientation for animation direction
|
|
585
|
-
if (currentAnimation?.enabled && mark.orient === 'horizontal') {
|
|
586
|
-
g.setAttribute('data-orient', 'horizontal');
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const rect = createSVGElement('rect');
|
|
590
|
-
setAttrs(rect, {
|
|
591
|
-
x: mark.x,
|
|
592
|
-
y: mark.y,
|
|
593
|
-
width: mark.width,
|
|
594
|
-
height: mark.height,
|
|
595
|
-
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
596
|
-
});
|
|
597
|
-
if (mark.stroke) {
|
|
598
|
-
rect.setAttribute('stroke', mark.stroke);
|
|
599
|
-
}
|
|
600
|
-
if (mark.strokeWidth) {
|
|
601
|
-
rect.setAttribute('stroke-width', String(mark.strokeWidth));
|
|
602
|
-
}
|
|
603
|
-
if (mark.cornerRadius) {
|
|
604
|
-
setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
605
|
-
}
|
|
606
|
-
g.appendChild(rect);
|
|
607
|
-
|
|
608
|
-
// Render value label if present and visible
|
|
609
|
-
if (mark.label?.visible) {
|
|
610
|
-
const label = createSVGElement('text');
|
|
611
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
612
|
-
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
613
|
-
applyTextStyle(label, mark.label.style);
|
|
614
|
-
label.textContent = mark.label.text;
|
|
615
|
-
g.appendChild(label);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return g;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
622
|
-
const g = createSVGElement('g');
|
|
623
|
-
g.setAttribute('data-mark-id', `arc-${index}`);
|
|
624
|
-
g.setAttribute('class', 'oc-mark oc-mark-arc');
|
|
625
|
-
g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
|
|
626
|
-
stampAnimationAttrs(g, mark, index);
|
|
627
|
-
|
|
628
|
-
const path = createSVGElement('path');
|
|
629
|
-
setAttrs(path, {
|
|
630
|
-
d: mark.path,
|
|
631
|
-
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
632
|
-
stroke: mark.stroke,
|
|
633
|
-
'stroke-width': mark.strokeWidth,
|
|
634
|
-
});
|
|
635
|
-
g.appendChild(path);
|
|
636
|
-
|
|
637
|
-
// Render label if present and visible
|
|
638
|
-
if (mark.label?.visible) {
|
|
639
|
-
const label = createSVGElement('text');
|
|
640
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
641
|
-
// Label position is in absolute coords, but we're in a translated group,
|
|
642
|
-
// so subtract the center offset
|
|
643
|
-
setAttrs(label, {
|
|
644
|
-
x: mark.label.x - mark.center.x,
|
|
645
|
-
y: mark.label.y - mark.center.y,
|
|
646
|
-
});
|
|
647
|
-
applyTextStyle(label, mark.label.style);
|
|
648
|
-
label.textContent = mark.label.text;
|
|
649
|
-
g.appendChild(label);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
return g;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
656
|
-
const circle = createSVGElement('circle');
|
|
657
|
-
circle.setAttribute('data-mark-id', `point-${index}`);
|
|
658
|
-
circle.setAttribute('class', 'oc-mark oc-mark-point');
|
|
659
|
-
stampAnimationAttrs(circle, mark, index);
|
|
660
|
-
|
|
661
|
-
setAttrs(circle, {
|
|
662
|
-
cx: mark.cx,
|
|
663
|
-
cy: mark.cy,
|
|
664
|
-
r: mark.r,
|
|
665
|
-
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
666
|
-
stroke: mark.stroke,
|
|
667
|
-
'stroke-width': mark.strokeWidth,
|
|
668
|
-
});
|
|
669
|
-
if (mark.fillOpacity !== undefined) {
|
|
670
|
-
circle.setAttribute('fill-opacity', String(mark.fillOpacity));
|
|
671
|
-
}
|
|
672
|
-
return circle;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
676
|
-
const text = createSVGElement('text');
|
|
677
|
-
text.setAttribute('data-mark-id', `textMark-${index}`);
|
|
678
|
-
text.setAttribute('class', 'oc-mark oc-mark-text');
|
|
679
|
-
stampAnimationAttrs(text, mark, index);
|
|
680
|
-
|
|
681
|
-
setAttrs(text, {
|
|
682
|
-
x: mark.x,
|
|
683
|
-
y: mark.y,
|
|
684
|
-
'font-size': mark.fontSize,
|
|
685
|
-
'text-anchor': mark.textAnchor,
|
|
686
|
-
});
|
|
687
|
-
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
|
|
688
|
-
if (mark.fontWeight) {
|
|
689
|
-
text.setAttribute('font-weight', String(mark.fontWeight));
|
|
690
|
-
}
|
|
691
|
-
if (mark.fontFamily) {
|
|
692
|
-
text.setAttribute('font-family', mark.fontFamily);
|
|
693
|
-
}
|
|
694
|
-
if (mark.angle) {
|
|
695
|
-
text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
|
|
696
|
-
}
|
|
697
|
-
text.textContent = mark.text;
|
|
698
|
-
return text;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
702
|
-
const line = createSVGElement('line');
|
|
703
|
-
line.setAttribute('data-mark-id', `rule-${index}`);
|
|
704
|
-
line.setAttribute('class', 'oc-mark oc-mark-rule');
|
|
705
|
-
stampAnimationAttrs(line, mark, index);
|
|
706
|
-
|
|
707
|
-
setAttrs(line, {
|
|
708
|
-
x1: mark.x1,
|
|
709
|
-
y1: mark.y1,
|
|
710
|
-
x2: mark.x2,
|
|
711
|
-
y2: mark.y2,
|
|
712
|
-
stroke: mark.stroke,
|
|
713
|
-
'stroke-width': mark.strokeWidth,
|
|
714
|
-
});
|
|
715
|
-
if (mark.strokeDasharray) {
|
|
716
|
-
line.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
717
|
-
}
|
|
718
|
-
if (mark.opacity != null) {
|
|
719
|
-
line.setAttribute('opacity', String(mark.opacity));
|
|
720
|
-
}
|
|
721
|
-
return line;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
|
|
725
|
-
const line = createSVGElement('line');
|
|
726
|
-
line.setAttribute('data-mark-id', `tick-${index}`);
|
|
727
|
-
line.setAttribute('class', 'oc-mark oc-mark-tick');
|
|
728
|
-
stampAnimationAttrs(line, mark, index);
|
|
729
|
-
|
|
730
|
-
// Tick is a short line segment centered at (x, y)
|
|
731
|
-
const half = mark.length / 2;
|
|
732
|
-
if (mark.orient === 'vertical') {
|
|
733
|
-
setAttrs(line, {
|
|
734
|
-
x1: mark.x,
|
|
735
|
-
y1: mark.y - half,
|
|
736
|
-
x2: mark.x,
|
|
737
|
-
y2: mark.y + half,
|
|
738
|
-
stroke: mark.stroke,
|
|
739
|
-
'stroke-width': mark.strokeWidth,
|
|
740
|
-
});
|
|
741
|
-
} else {
|
|
742
|
-
setAttrs(line, {
|
|
743
|
-
x1: mark.x - half,
|
|
744
|
-
y1: mark.y,
|
|
745
|
-
x2: mark.x + half,
|
|
746
|
-
y2: mark.y,
|
|
747
|
-
stroke: mark.stroke,
|
|
748
|
-
'stroke-width': mark.strokeWidth,
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
if (mark.opacity != null) {
|
|
753
|
-
line.setAttribute('opacity', String(mark.opacity));
|
|
754
|
-
}
|
|
755
|
-
return line;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// Register built-in renderers
|
|
759
|
-
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
760
|
-
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
761
|
-
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
762
|
-
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
763
|
-
registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
|
|
764
|
-
registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
|
|
765
|
-
registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
|
|
766
|
-
registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
|
|
767
|
-
|
|
768
|
-
/** Extract series name from a mark for legend toggle matching. */
|
|
769
|
-
function getMarkSeries(mark: Mark): string | undefined {
|
|
770
|
-
// Line and area marks have an explicit seriesKey
|
|
771
|
-
if (mark.type === 'line' || mark.type === 'area') {
|
|
772
|
-
return mark.seriesKey;
|
|
773
|
-
}
|
|
774
|
-
// For arc marks, the category name is the first part of the aria label (before ':')
|
|
775
|
-
if (mark.type === 'arc') {
|
|
776
|
-
return mark.aria.label.split(':')[0]?.trim();
|
|
777
|
-
}
|
|
778
|
-
// For rect/point, the aria label may be "category: value" or "category, group: value".
|
|
779
|
-
// The series name is the category part (before the colon).
|
|
780
|
-
if (mark.aria?.label) {
|
|
781
|
-
const beforeColon = mark.aria.label.split(':')[0]?.trim();
|
|
782
|
-
if (beforeColon) return beforeColon;
|
|
783
|
-
}
|
|
784
|
-
return undefined;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function renderMarks(parent: SVGElement, layout: ChartLayout): void {
|
|
788
|
-
const g = createSVGElement('g');
|
|
789
|
-
g.setAttribute('class', 'oc-marks');
|
|
790
|
-
|
|
791
|
-
for (let i = 0; i < layout.marks.length; i++) {
|
|
792
|
-
const mark = layout.marks[i];
|
|
793
|
-
const renderer = markRenderers[mark.type];
|
|
794
|
-
if (!renderer) continue;
|
|
795
|
-
|
|
796
|
-
const el = renderer(mark, i);
|
|
797
|
-
// Add ARIA label if present
|
|
798
|
-
if (mark.aria?.label) {
|
|
799
|
-
el.setAttribute('aria-label', mark.aria.label);
|
|
800
|
-
}
|
|
801
|
-
// Add data-series attribute for legend toggle matching
|
|
802
|
-
const series = getMarkSeries(mark);
|
|
803
|
-
if (series) {
|
|
804
|
-
el.setAttribute('data-series', series);
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// For stacked segments, set stack position for sequential animation chaining.
|
|
808
|
-
// stackPos is computed by the engine on RectMark during compilation.
|
|
809
|
-
if (currentAnimation?.enabled && mark.type === 'rect') {
|
|
810
|
-
const rect = mark as RectMark;
|
|
811
|
-
if (rect.stackGroup && rect.stackPos !== undefined) {
|
|
812
|
-
el.setAttribute('data-stack-pos', String(rect.stackPos));
|
|
813
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
814
|
-
'--oc-stack-pos',
|
|
815
|
-
String(rect.stackPos),
|
|
816
|
-
);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
g.appendChild(el);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
parent.appendChild(g);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// ---------------------------------------------------------------------------
|
|
827
|
-
// Annotation rendering
|
|
828
|
-
// ---------------------------------------------------------------------------
|
|
829
|
-
|
|
830
|
-
function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
|
|
831
|
-
if (layout.annotations.length === 0) return;
|
|
832
|
-
|
|
833
|
-
const g = createSVGElement('g');
|
|
834
|
-
g.setAttribute('class', 'oc-annotations');
|
|
835
|
-
|
|
836
|
-
// Annotations are already sorted by zIndex from the engine, so render in order
|
|
837
|
-
const bgColor = layout.theme.colors.background;
|
|
838
|
-
for (let i = 0; i < layout.annotations.length; i++) {
|
|
839
|
-
renderAnnotation(g, layout.annotations[i], i, bgColor);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
parent.appendChild(g);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* Render a curved arrow connector from a label to a data point.
|
|
847
|
-
* Uses a cubic bezier that sweeps outward then curves toward the
|
|
848
|
-
* target, with a triangular arrowhead at the tip.
|
|
849
|
-
*/
|
|
850
|
-
function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: string): void {
|
|
851
|
-
// Pad above the target so the arrow doesn't sit right on the element.
|
|
852
|
-
const pad = 6;
|
|
853
|
-
const tipY = to.y - pad;
|
|
854
|
-
|
|
855
|
-
const dy = tipY - from.y;
|
|
856
|
-
const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
|
|
857
|
-
|
|
858
|
-
// Arrowhead geometry
|
|
859
|
-
const arrowLen = 8;
|
|
860
|
-
const arrowWidth = 4;
|
|
861
|
-
|
|
862
|
-
// cp2 directly above target so arrow arrives pointing straight down.
|
|
863
|
-
const bulge = Math.max(dist * 0.4, 35);
|
|
864
|
-
const cp1x = from.x + bulge;
|
|
865
|
-
const cp1y = from.y + dy * 0.35;
|
|
866
|
-
const cp2x = to.x;
|
|
867
|
-
const cp2y = tipY - Math.abs(dy) * 0.25;
|
|
868
|
-
|
|
869
|
-
// Tangent at the tip (from cp2 → tip), used for arrowhead direction.
|
|
870
|
-
const tx = to.x - cp2x;
|
|
871
|
-
const ty = tipY - cp2y;
|
|
872
|
-
const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
|
|
873
|
-
const ux = tx / tLen;
|
|
874
|
-
const uy = ty / tLen;
|
|
875
|
-
|
|
876
|
-
// End the curve path at the arrowhead BASE so the stroke doesn't
|
|
877
|
-
// poke through the filled triangle.
|
|
878
|
-
const baseX = to.x - ux * arrowLen;
|
|
879
|
-
const baseY = tipY - uy * arrowLen;
|
|
880
|
-
|
|
881
|
-
const path = createSVGElement('path');
|
|
882
|
-
path.setAttribute('class', 'oc-annotation-connector');
|
|
883
|
-
setAttrs(path, {
|
|
884
|
-
d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
|
|
885
|
-
fill: 'none',
|
|
886
|
-
stroke,
|
|
887
|
-
'stroke-width': 1.5,
|
|
888
|
-
});
|
|
889
|
-
parent.appendChild(path);
|
|
890
|
-
|
|
891
|
-
// Arrowhead triangle: perpendicular to tangent direction.
|
|
892
|
-
const px = -uy;
|
|
893
|
-
const py = ux;
|
|
894
|
-
|
|
895
|
-
const arrow = createSVGElement('polygon');
|
|
896
|
-
arrow.setAttribute('class', 'oc-annotation-connector');
|
|
897
|
-
setAttrs(arrow, {
|
|
898
|
-
points: [
|
|
899
|
-
`${to.x},${tipY}`,
|
|
900
|
-
`${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
|
|
901
|
-
`${baseX - px * arrowWidth},${baseY - py * arrowWidth}`,
|
|
902
|
-
].join(' '),
|
|
903
|
-
fill: stroke,
|
|
904
|
-
});
|
|
905
|
-
parent.appendChild(arrow);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function renderAnnotation(
|
|
909
|
-
parent: SVGElement,
|
|
910
|
-
annotation: ResolvedAnnotation,
|
|
911
|
-
index: number,
|
|
912
|
-
bgColor?: string,
|
|
913
|
-
): void {
|
|
914
|
-
const g = createSVGElement('g');
|
|
915
|
-
g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`);
|
|
916
|
-
g.setAttribute('data-annotation-index', String(index));
|
|
917
|
-
if (annotation.id) {
|
|
918
|
-
g.setAttribute('data-annotation-id', annotation.id);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Range rect
|
|
922
|
-
if (annotation.rect) {
|
|
923
|
-
const rect = createSVGElement('rect');
|
|
924
|
-
rect.setAttribute('class', 'oc-annotation-range');
|
|
925
|
-
setAttrs(rect, {
|
|
926
|
-
x: annotation.rect.x,
|
|
927
|
-
y: annotation.rect.y,
|
|
928
|
-
width: annotation.rect.width,
|
|
929
|
-
height: annotation.rect.height,
|
|
930
|
-
});
|
|
931
|
-
if (annotation.fill) rect.setAttribute('fill', annotation.fill);
|
|
932
|
-
if (annotation.opacity !== undefined) {
|
|
933
|
-
rect.setAttribute('fill-opacity', String(annotation.opacity));
|
|
934
|
-
}
|
|
935
|
-
g.appendChild(rect);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// Reference line
|
|
939
|
-
if (annotation.line) {
|
|
940
|
-
const line = createSVGElement('line');
|
|
941
|
-
line.setAttribute('class', 'oc-annotation-line');
|
|
942
|
-
setAttrs(line, {
|
|
943
|
-
x1: annotation.line.start.x,
|
|
944
|
-
y1: annotation.line.start.y,
|
|
945
|
-
x2: annotation.line.end.x,
|
|
946
|
-
y2: annotation.line.end.y,
|
|
947
|
-
'stroke-width': annotation.strokeWidth ?? 1,
|
|
948
|
-
});
|
|
949
|
-
if (annotation.stroke) line.setAttribute('stroke', annotation.stroke);
|
|
950
|
-
if (annotation.strokeDasharray) {
|
|
951
|
-
line.setAttribute('stroke-dasharray', annotation.strokeDasharray);
|
|
952
|
-
}
|
|
953
|
-
g.appendChild(line);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Label with optional connector line
|
|
957
|
-
if (annotation.label?.visible) {
|
|
958
|
-
// Render connector first (behind the label text)
|
|
959
|
-
if (annotation.label.connector) {
|
|
960
|
-
const c = annotation.label.connector;
|
|
961
|
-
if (c.style === 'curve') {
|
|
962
|
-
renderCurvedArrow(g, c.from, c.to, c.stroke);
|
|
963
|
-
} else {
|
|
964
|
-
const connector = createSVGElement('line');
|
|
965
|
-
connector.setAttribute('class', 'oc-annotation-connector');
|
|
966
|
-
setAttrs(connector, {
|
|
967
|
-
x1: c.from.x,
|
|
968
|
-
y1: c.from.y,
|
|
969
|
-
x2: c.to.x,
|
|
970
|
-
y2: c.to.y,
|
|
971
|
-
stroke: c.stroke,
|
|
972
|
-
'stroke-width': 1,
|
|
973
|
-
'stroke-opacity': 0.5,
|
|
974
|
-
});
|
|
975
|
-
g.appendChild(connector);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const text = createSVGElement('text');
|
|
980
|
-
text.setAttribute('class', 'oc-annotation-label');
|
|
981
|
-
setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
|
|
982
|
-
applyTextStyle(text, annotation.label.style);
|
|
983
|
-
|
|
984
|
-
const lines = annotation.label.text.split('\n');
|
|
985
|
-
const fontSize = annotation.label.style.fontSize ?? 12;
|
|
986
|
-
const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
987
|
-
const isMultiLine = lines.length > 1;
|
|
988
|
-
|
|
989
|
-
// Multi-line text uses center alignment for a cleaner look
|
|
990
|
-
if (isMultiLine) {
|
|
991
|
-
text.setAttribute('text-anchor', 'middle');
|
|
992
|
-
for (let i = 0; i < lines.length; i++) {
|
|
993
|
-
const tspan = createSVGElement('tspan');
|
|
994
|
-
setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
|
|
995
|
-
tspan.textContent = lines[i];
|
|
996
|
-
text.appendChild(tspan);
|
|
997
|
-
}
|
|
998
|
-
} else {
|
|
999
|
-
text.textContent = annotation.label.text;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// Render background rect behind text if specified, otherwise use
|
|
1003
|
-
// paint-order stroke halo to knock out lines behind text
|
|
1004
|
-
if (annotation.label.background) {
|
|
1005
|
-
const charWidth = fontSize * 0.55;
|
|
1006
|
-
const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
|
|
1007
|
-
const totalHeight = lines.length * lineHeight;
|
|
1008
|
-
const pad = 3;
|
|
1009
|
-
const bgX = isMultiLine
|
|
1010
|
-
? annotation.label.x - maxLineWidth / 2 - pad
|
|
1011
|
-
: annotation.label.x - pad;
|
|
1012
|
-
const bgRect = createSVGElement('rect');
|
|
1013
|
-
bgRect.setAttribute('class', 'oc-annotation-bg');
|
|
1014
|
-
setAttrs(bgRect, {
|
|
1015
|
-
x: bgX,
|
|
1016
|
-
y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
|
|
1017
|
-
width: maxLineWidth + pad * 2,
|
|
1018
|
-
height: totalHeight + pad * 2,
|
|
1019
|
-
fill: annotation.label.background,
|
|
1020
|
-
rx: 2,
|
|
1021
|
-
});
|
|
1022
|
-
g.appendChild(bgRect);
|
|
1023
|
-
} else if (bgColor) {
|
|
1024
|
-
text.style.paintOrder = 'stroke';
|
|
1025
|
-
text.style.stroke = bgColor;
|
|
1026
|
-
text.style.strokeWidth = `${Math.round(fontSize * 0.3)}px`;
|
|
1027
|
-
text.style.strokeLinejoin = 'round';
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
g.appendChild(text);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
parent.appendChild(g);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// ---------------------------------------------------------------------------
|
|
1037
|
-
// Legend rendering
|
|
1038
|
-
// ---------------------------------------------------------------------------
|
|
1039
|
-
|
|
1040
|
-
function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
1041
|
-
if (legend.entries.length === 0) return;
|
|
1042
|
-
|
|
1043
|
-
const g = createSVGElement('g');
|
|
1044
|
-
g.setAttribute('class', 'oc-legend');
|
|
1045
|
-
g.setAttribute('role', 'list');
|
|
1046
|
-
g.setAttribute('aria-label', 'Chart legend');
|
|
1047
|
-
|
|
1048
|
-
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
|
|
1049
|
-
let offsetX = legend.bounds.x;
|
|
1050
|
-
let offsetY = legend.bounds.y;
|
|
1051
|
-
|
|
1052
|
-
for (let i = 0; i < legend.entries.length; i++) {
|
|
1053
|
-
const entry = legend.entries[i];
|
|
1054
|
-
|
|
1055
|
-
// Pre-check: wrap to next line if this entry would overflow bounds
|
|
1056
|
-
if (isHorizontal && i > 0) {
|
|
1057
|
-
const labelWidth = estimateTextWidth(
|
|
1058
|
-
entry.label,
|
|
1059
|
-
legend.labelStyle.fontSize,
|
|
1060
|
-
legend.labelStyle.fontWeight,
|
|
1061
|
-
);
|
|
1062
|
-
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
1063
|
-
if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
|
|
1064
|
-
offsetX = legend.bounds.x;
|
|
1065
|
-
offsetY += legend.swatchSize + 6;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
const entryG = createSVGElement('g');
|
|
1069
|
-
entryG.setAttribute('class', 'oc-legend-entry');
|
|
1070
|
-
entryG.setAttribute('role', 'listitem');
|
|
1071
|
-
entryG.setAttribute('data-legend-index', String(i));
|
|
1072
|
-
entryG.setAttribute('data-legend-label', entry.label);
|
|
1073
|
-
if (entry.overflow) {
|
|
1074
|
-
entryG.setAttribute('data-legend-overflow', 'true');
|
|
1075
|
-
entryG.setAttribute('aria-label', entry.label);
|
|
1076
|
-
entryG.setAttribute('opacity', '0.5');
|
|
1077
|
-
} else {
|
|
1078
|
-
entryG.setAttribute(
|
|
1079
|
-
'aria-label',
|
|
1080
|
-
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
|
|
1081
|
-
);
|
|
1082
|
-
entryG.setAttribute('style', 'cursor: pointer');
|
|
1083
|
-
|
|
1084
|
-
// Apply dimming for inactive entries
|
|
1085
|
-
if (entry.active === false) {
|
|
1086
|
-
entryG.setAttribute('opacity', '0.3');
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// Swatch
|
|
1091
|
-
if (entry.shape === 'circle') {
|
|
1092
|
-
const circle = createSVGElement('circle');
|
|
1093
|
-
setAttrs(circle, {
|
|
1094
|
-
cx: offsetX + legend.swatchSize / 2,
|
|
1095
|
-
cy: offsetY + legend.swatchSize / 2,
|
|
1096
|
-
r: legend.swatchSize / 2,
|
|
1097
|
-
fill: entry.color,
|
|
1098
|
-
});
|
|
1099
|
-
entryG.appendChild(circle);
|
|
1100
|
-
} else if (entry.shape === 'line') {
|
|
1101
|
-
// Line swatch: a short line segment with a dot in the middle
|
|
1102
|
-
const line = createSVGElement('line');
|
|
1103
|
-
setAttrs(line, {
|
|
1104
|
-
x1: offsetX,
|
|
1105
|
-
y1: offsetY + legend.swatchSize / 2,
|
|
1106
|
-
x2: offsetX + legend.swatchSize,
|
|
1107
|
-
y2: offsetY + legend.swatchSize / 2,
|
|
1108
|
-
stroke: entry.color,
|
|
1109
|
-
'stroke-width': 2,
|
|
1110
|
-
});
|
|
1111
|
-
entryG.appendChild(line);
|
|
1112
|
-
// Small dot at center
|
|
1113
|
-
const dot = createSVGElement('circle');
|
|
1114
|
-
setAttrs(dot, {
|
|
1115
|
-
cx: offsetX + legend.swatchSize / 2,
|
|
1116
|
-
cy: offsetY + legend.swatchSize / 2,
|
|
1117
|
-
r: 2.5,
|
|
1118
|
-
fill: entry.color,
|
|
1119
|
-
});
|
|
1120
|
-
entryG.appendChild(dot);
|
|
1121
|
-
} else {
|
|
1122
|
-
const rect = createSVGElement('rect');
|
|
1123
|
-
setAttrs(rect, {
|
|
1124
|
-
x: offsetX,
|
|
1125
|
-
y: offsetY,
|
|
1126
|
-
width: legend.swatchSize,
|
|
1127
|
-
height: legend.swatchSize,
|
|
1128
|
-
fill: entry.color,
|
|
1129
|
-
rx: 2,
|
|
1130
|
-
});
|
|
1131
|
-
entryG.appendChild(rect);
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Label
|
|
1135
|
-
const label = createSVGElement('text');
|
|
1136
|
-
setAttrs(label, {
|
|
1137
|
-
x: offsetX + legend.swatchSize + legend.swatchGap,
|
|
1138
|
-
y: offsetY + legend.swatchSize / 2,
|
|
1139
|
-
'dominant-baseline': 'central',
|
|
1140
|
-
});
|
|
1141
|
-
applyTextStyle(label, legend.labelStyle);
|
|
1142
|
-
label.textContent = entry.label;
|
|
1143
|
-
entryG.appendChild(label);
|
|
1144
|
-
|
|
1145
|
-
g.appendChild(entryG);
|
|
1146
|
-
|
|
1147
|
-
// Advance position for next entry
|
|
1148
|
-
if (isHorizontal) {
|
|
1149
|
-
const labelWidth = estimateTextWidth(
|
|
1150
|
-
entry.label,
|
|
1151
|
-
legend.labelStyle.fontSize,
|
|
1152
|
-
legend.labelStyle.fontWeight,
|
|
1153
|
-
);
|
|
1154
|
-
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
1155
|
-
offsetX += entryWidth;
|
|
1156
|
-
} else {
|
|
1157
|
-
offsetY += legend.swatchSize + legend.entryGap;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
parent.appendChild(g);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// ---------------------------------------------------------------------------
|
|
1165
|
-
// Brand rendering
|
|
1166
|
-
// ---------------------------------------------------------------------------
|
|
1167
|
-
|
|
1168
|
-
const BRAND_URL = 'https://tryopendata.ai';
|
|
1169
|
-
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Render the "OpenData" brand as a footer-row element, right-aligned on the
|
|
1173
|
-
* same baseline as the first bottom chrome text (source/byline/footer).
|
|
1174
|
-
* Uses the same font size as chrome source text so it blends in as a subtle
|
|
1175
|
-
* footer item rather than occupying independent visual space.
|
|
1176
|
-
*/
|
|
1177
|
-
function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
1178
|
-
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
1179
|
-
|
|
1180
|
-
const { width } = layout.dimensions;
|
|
1181
|
-
const padding = layout.theme.spacing.padding;
|
|
1182
|
-
const rightEdge = width - padding;
|
|
1183
|
-
const fill = layout.theme.colors.axis;
|
|
1184
|
-
|
|
1185
|
-
// Vertically align with the first bottom chrome element.
|
|
1186
|
-
const { chrome } = layout;
|
|
1187
|
-
const xAxisExtent = computeXAxisExtent(layout);
|
|
1188
|
-
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
1189
|
-
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
1190
|
-
const chromeY = firstBottom
|
|
1191
|
-
? bottomOffset + firstBottom.y
|
|
1192
|
-
: bottomOffset + layout.theme.spacing.chartToFooter;
|
|
1193
|
-
|
|
1194
|
-
const a = createSVGElement('a');
|
|
1195
|
-
a.setAttribute('href', BRAND_URL);
|
|
1196
|
-
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
|
|
1197
|
-
a.setAttribute('target', '_blank');
|
|
1198
|
-
a.setAttribute('rel', 'noopener');
|
|
1199
|
-
a.setAttribute('class', 'oc-chrome-ref');
|
|
1200
|
-
|
|
1201
|
-
// "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
|
|
1202
|
-
// rendered as a single right-aligned text element with three tspans.
|
|
1203
|
-
// Use alphabetic baseline so mixed-size tspans share a common bottom line.
|
|
1204
|
-
const BRAND_LARGE = 16;
|
|
1205
|
-
const text = createSVGElement('text');
|
|
1206
|
-
setAttrs(text, {
|
|
1207
|
-
x: rightEdge,
|
|
1208
|
-
y: chromeY + BRAND_LARGE,
|
|
1209
|
-
'dominant-baseline': 'alphabetic',
|
|
1210
|
-
'font-family': layout.theme.fonts.family,
|
|
1211
|
-
'font-size': BRAND_FONT_SIZE,
|
|
1212
|
-
'text-anchor': 'end',
|
|
1213
|
-
'fill-opacity': 0.55,
|
|
1214
|
-
});
|
|
1215
|
-
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
1216
|
-
|
|
1217
|
-
const trySpan = createSVGElement('tspan');
|
|
1218
|
-
trySpan.setAttribute('font-weight', '500');
|
|
1219
|
-
trySpan.textContent = 'try';
|
|
1220
|
-
text.appendChild(trySpan);
|
|
1221
|
-
|
|
1222
|
-
const openDataSpan = createSVGElement('tspan');
|
|
1223
|
-
openDataSpan.setAttribute('font-weight', '600');
|
|
1224
|
-
openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
|
|
1225
|
-
openDataSpan.textContent = 'OpenData';
|
|
1226
|
-
text.appendChild(openDataSpan);
|
|
1227
|
-
|
|
1228
|
-
const aiSpan = createSVGElement('tspan');
|
|
1229
|
-
aiSpan.setAttribute('font-weight', '500');
|
|
1230
|
-
aiSpan.textContent = '.ai';
|
|
1231
|
-
text.appendChild(aiSpan);
|
|
1232
|
-
|
|
1233
|
-
a.appendChild(text);
|
|
1234
|
-
parent.appendChild(a);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
// ---------------------------------------------------------------------------
|
|
1238
|
-
// Main render function
|
|
1239
|
-
// ---------------------------------------------------------------------------
|
|
1240
|
-
|
|
1241
34
|
/**
|
|
1242
35
|
* Render a compiled ChartLayout into an SVG element and append it to a container.
|
|
1243
36
|
*
|
|
@@ -1253,9 +46,6 @@ export function renderChartSVG(
|
|
|
1253
46
|
const { width, height } = layout.dimensions;
|
|
1254
47
|
const animation = layout.animation;
|
|
1255
48
|
|
|
1256
|
-
// Set module-level animation state so mark renderers can access it
|
|
1257
|
-
currentAnimation = animation;
|
|
1258
|
-
|
|
1259
49
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
1260
50
|
setAttrs(svg, {
|
|
1261
51
|
viewBox: `0 0 ${width} ${height}`,
|
|
@@ -1321,7 +111,7 @@ export function renderChartSVG(
|
|
|
1321
111
|
// Clip path to prevent marks (especially area fills) from overflowing
|
|
1322
112
|
// into the chrome region (title/subtitle). Extends full width so
|
|
1323
113
|
// end-of-line labels aren't clipped, but constrains vertically.
|
|
1324
|
-
const clipId =
|
|
114
|
+
const clipId = nextSvgId('oc-clip');
|
|
1325
115
|
const defs = createSVGElement('defs');
|
|
1326
116
|
const clipPath = createSVGElement('clipPath');
|
|
1327
117
|
clipPath.setAttribute('id', clipId);
|
|
@@ -1336,57 +126,62 @@ export function renderChartSVG(
|
|
|
1336
126
|
defs.appendChild(clipPath);
|
|
1337
127
|
|
|
1338
128
|
// Build gradient defs for marks with gradient fills
|
|
1339
|
-
|
|
129
|
+
const gradientMap = buildGradientDefs(layout.marks as Array<{ fill?: unknown }>, defs);
|
|
1340
130
|
|
|
1341
131
|
svg.appendChild(defs);
|
|
1342
132
|
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
(
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
133
|
+
// Prime mark-renderer module-level state so mark sub-renderers can resolve
|
|
134
|
+
// animation + gradient fills without signature changes. try/finally guarantees
|
|
135
|
+
// the reset fires even if any downstream renderer throws, so the next render
|
|
136
|
+
// starts with a clean slate.
|
|
137
|
+
setMarkRenderState({ animation, gradientMap });
|
|
138
|
+
try {
|
|
139
|
+
// Render layers in order (back to front)
|
|
140
|
+
// Axes render outside clip (labels extend beyond chart area)
|
|
141
|
+
renderAxes(svg, layout);
|
|
142
|
+
|
|
143
|
+
// Marks are clipped to chart area so area fills don't cover chrome
|
|
144
|
+
const clippedGroup = createSVGElement('g');
|
|
145
|
+
clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
|
|
146
|
+
renderMarks(clippedGroup, layout);
|
|
147
|
+
|
|
148
|
+
// Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
|
|
149
|
+
// Only added when there are line or area marks with dataPoints, and no explicit
|
|
150
|
+
// PointMark objects (which use per-element event handling instead).
|
|
151
|
+
const hasLineOrAreaWithDataPoints = layout.marks.some(
|
|
152
|
+
(m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
|
|
153
|
+
);
|
|
154
|
+
const hasPointMarks = layout.marks.some((m) => m.type === 'point');
|
|
155
|
+
if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
|
|
156
|
+
const overlay = createSVGElement('rect');
|
|
157
|
+
setAttrs(overlay, {
|
|
158
|
+
x: layout.area.x,
|
|
159
|
+
y: layout.area.y,
|
|
160
|
+
width: layout.area.width,
|
|
161
|
+
height: layout.area.height,
|
|
162
|
+
fill: 'transparent',
|
|
163
|
+
});
|
|
164
|
+
overlay.setAttribute('class', 'oc-voronoi-overlay');
|
|
165
|
+
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
166
|
+
clippedGroup.appendChild(overlay);
|
|
167
|
+
}
|
|
1372
168
|
|
|
1373
|
-
|
|
169
|
+
svg.appendChild(clippedGroup);
|
|
1374
170
|
|
|
1375
|
-
|
|
1376
|
-
|
|
171
|
+
renderAnnotations(svg, layout);
|
|
172
|
+
renderLegend(svg, layout.legend);
|
|
1377
173
|
|
|
1378
|
-
|
|
1379
|
-
|
|
174
|
+
// Chrome renders on top so titles are never obscured by chart elements
|
|
175
|
+
renderChrome(svg, layout);
|
|
1380
176
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
177
|
+
// Brand renders as a footer item, right-aligned on the source/footer row
|
|
178
|
+
if (layout.watermark) {
|
|
179
|
+
renderBrand(svg, layout);
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
resetMarkRenderState();
|
|
1384
183
|
}
|
|
1385
184
|
|
|
1386
|
-
// Reset module-level state after rendering
|
|
1387
|
-
currentAnimation = undefined;
|
|
1388
|
-
currentGradientMap = new Map();
|
|
1389
|
-
|
|
1390
185
|
container.appendChild(svg);
|
|
1391
186
|
return svg;
|
|
1392
187
|
}
|