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