@opendata-ai/openchart-vanilla 2.0.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 +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG renderer: converts a ChartLayout into SVG DOM elements.
|
|
3
|
+
*
|
|
4
|
+
* Creates an <svg> element with viewBox matching layout dimensions,
|
|
5
|
+
* renders chrome (title/subtitle/source), axes, marks, annotations,
|
|
6
|
+
* and legend. All styling via inline SVG attributes from layout data.
|
|
7
|
+
*
|
|
8
|
+
* Mark rendering dispatches per mark type with dedicated renderers
|
|
9
|
+
* for line, area, rect, arc, and point marks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ArcMark,
|
|
14
|
+
AreaMark,
|
|
15
|
+
AxisLayout,
|
|
16
|
+
ChartLayout,
|
|
17
|
+
LegendLayout,
|
|
18
|
+
LineMark,
|
|
19
|
+
Mark,
|
|
20
|
+
Point,
|
|
21
|
+
PointMark,
|
|
22
|
+
RectMark,
|
|
23
|
+
ResolvedAnnotation,
|
|
24
|
+
ResolvedChromeElement,
|
|
25
|
+
TextStyle,
|
|
26
|
+
} from '@opendata-ai/openchart-core';
|
|
27
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
28
|
+
|
|
29
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function createSVGElement(tag: string): SVGElement {
|
|
36
|
+
return document.createElementNS(SVG_NS, tag);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
|
|
40
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
41
|
+
el.setAttribute(key, String(value));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
46
|
+
setAttrs(el, {
|
|
47
|
+
'font-family': style.fontFamily,
|
|
48
|
+
'font-size': style.fontSize,
|
|
49
|
+
'font-weight': style.fontWeight,
|
|
50
|
+
});
|
|
51
|
+
// Use inline style for fill so it takes priority over CSS class defaults
|
|
52
|
+
// (e.g. .viz-title { fill: var(--viz-text) } which would override attributes)
|
|
53
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
|
|
54
|
+
if (style.textAnchor) {
|
|
55
|
+
el.setAttribute('text-anchor', style.textAnchor);
|
|
56
|
+
}
|
|
57
|
+
if (style.dominantBaseline) {
|
|
58
|
+
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
59
|
+
}
|
|
60
|
+
if (style.fontVariant) {
|
|
61
|
+
el.setAttribute('font-variant', style.fontVariant);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Chrome rendering
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function renderChromeElement(
|
|
70
|
+
parent: SVGElement,
|
|
71
|
+
element: ResolvedChromeElement,
|
|
72
|
+
className: string,
|
|
73
|
+
chromeKey: string,
|
|
74
|
+
): void {
|
|
75
|
+
const text = createSVGElement('text');
|
|
76
|
+
setAttrs(text, { x: element.x, y: element.y });
|
|
77
|
+
applyTextStyle(text, element.style);
|
|
78
|
+
text.setAttribute('class', className);
|
|
79
|
+
text.setAttribute('data-chrome-key', chromeKey);
|
|
80
|
+
text.textContent = element.text;
|
|
81
|
+
parent.appendChild(text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
85
|
+
const g = createSVGElement('g');
|
|
86
|
+
g.setAttribute('class', 'viz-chrome');
|
|
87
|
+
|
|
88
|
+
const { chrome } = layout;
|
|
89
|
+
|
|
90
|
+
// Top chrome: render at their stored y positions (already absolute)
|
|
91
|
+
if (chrome.title) {
|
|
92
|
+
renderChromeElement(g, chrome.title, 'viz-title', 'title');
|
|
93
|
+
}
|
|
94
|
+
if (chrome.subtitle) {
|
|
95
|
+
renderChromeElement(g, chrome.subtitle, 'viz-subtitle', 'subtitle');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Bottom chrome starts below x-axis labels/title, not at chart area bottom.
|
|
99
|
+
// X-axis tick labels render at +14, axis title at +35. Account for that
|
|
100
|
+
// so source/byline/footer don't overlap axis content.
|
|
101
|
+
const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
|
|
102
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
103
|
+
if (chrome.source) {
|
|
104
|
+
renderChromeElement(
|
|
105
|
+
g,
|
|
106
|
+
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
107
|
+
'viz-source',
|
|
108
|
+
'source',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (chrome.byline) {
|
|
112
|
+
renderChromeElement(
|
|
113
|
+
g,
|
|
114
|
+
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
115
|
+
'viz-byline',
|
|
116
|
+
'byline',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (chrome.footer) {
|
|
120
|
+
renderChromeElement(
|
|
121
|
+
g,
|
|
122
|
+
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
123
|
+
'viz-footer',
|
|
124
|
+
'footer',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
parent.appendChild(g);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Axis rendering
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function renderAxis(
|
|
136
|
+
parent: SVGElement,
|
|
137
|
+
axis: AxisLayout,
|
|
138
|
+
orientation: 'x' | 'y',
|
|
139
|
+
layout: ChartLayout,
|
|
140
|
+
): void {
|
|
141
|
+
const g = createSVGElement('g');
|
|
142
|
+
g.setAttribute('class', `viz-axis viz-axis-${orientation}`);
|
|
143
|
+
|
|
144
|
+
const { area } = layout;
|
|
145
|
+
|
|
146
|
+
// Only draw axis line for x-axis (bottom baseline).
|
|
147
|
+
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
|
|
148
|
+
if (orientation === 'x') {
|
|
149
|
+
const line = createSVGElement('line');
|
|
150
|
+
line.setAttribute('class', 'viz-axis-line');
|
|
151
|
+
setAttrs(line, {
|
|
152
|
+
x1: axis.start.x,
|
|
153
|
+
y1: axis.start.y,
|
|
154
|
+
x2: axis.end.x,
|
|
155
|
+
y2: axis.end.y,
|
|
156
|
+
stroke: layout.theme.colors.axis,
|
|
157
|
+
'stroke-width': 1,
|
|
158
|
+
});
|
|
159
|
+
g.appendChild(line);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Ticks and labels
|
|
163
|
+
// Tick positions are absolute pixel coordinates from D3 scales whose range
|
|
164
|
+
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
|
|
165
|
+
// Don't add area.x/area.y again or you'll double-offset everything.
|
|
166
|
+
for (const tick of axis.ticks) {
|
|
167
|
+
if (orientation === 'x') {
|
|
168
|
+
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
169
|
+
const label = createSVGElement('text');
|
|
170
|
+
label.setAttribute('class', 'viz-axis-tick');
|
|
171
|
+
setAttrs(label, {
|
|
172
|
+
x: tick.position,
|
|
173
|
+
y: area.y + area.height + 14,
|
|
174
|
+
'text-anchor': 'middle',
|
|
175
|
+
});
|
|
176
|
+
applyTextStyle(label, axis.tickLabelStyle);
|
|
177
|
+
label.textContent = tick.label;
|
|
178
|
+
g.appendChild(label);
|
|
179
|
+
} else {
|
|
180
|
+
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
181
|
+
const label = createSVGElement('text');
|
|
182
|
+
label.setAttribute('class', 'viz-axis-tick');
|
|
183
|
+
setAttrs(label, {
|
|
184
|
+
x: area.x - 6,
|
|
185
|
+
y: tick.position,
|
|
186
|
+
'text-anchor': 'end',
|
|
187
|
+
'dominant-baseline': 'central',
|
|
188
|
+
});
|
|
189
|
+
applyTextStyle(label, axis.tickLabelStyle);
|
|
190
|
+
label.textContent = tick.label;
|
|
191
|
+
g.appendChild(label);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Gridlines (positions are also absolute from the scales)
|
|
196
|
+
for (const gridline of axis.gridlines) {
|
|
197
|
+
const gl = createSVGElement('line');
|
|
198
|
+
gl.setAttribute('class', 'viz-gridline');
|
|
199
|
+
if (orientation === 'y') {
|
|
200
|
+
setAttrs(gl, {
|
|
201
|
+
x1: area.x,
|
|
202
|
+
y1: gridline.position,
|
|
203
|
+
x2: area.x + area.width,
|
|
204
|
+
y2: gridline.position,
|
|
205
|
+
stroke: layout.theme.colors.gridline,
|
|
206
|
+
'stroke-width': 1,
|
|
207
|
+
'stroke-opacity': 0.35,
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
setAttrs(gl, {
|
|
211
|
+
x1: gridline.position,
|
|
212
|
+
y1: area.y,
|
|
213
|
+
x2: gridline.position,
|
|
214
|
+
y2: area.y + area.height,
|
|
215
|
+
stroke: layout.theme.colors.gridline,
|
|
216
|
+
'stroke-width': 1,
|
|
217
|
+
'stroke-opacity': 0.35,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
g.appendChild(gl);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Axis label
|
|
224
|
+
if (axis.label && axis.labelStyle) {
|
|
225
|
+
const axisLabel = createSVGElement('text');
|
|
226
|
+
axisLabel.setAttribute('class', 'viz-axis-title');
|
|
227
|
+
applyTextStyle(axisLabel, axis.labelStyle);
|
|
228
|
+
axisLabel.textContent = axis.label;
|
|
229
|
+
|
|
230
|
+
if (orientation === 'x') {
|
|
231
|
+
setAttrs(axisLabel, {
|
|
232
|
+
x: area.x + area.width / 2,
|
|
233
|
+
y: area.y + area.height + 35,
|
|
234
|
+
'text-anchor': 'middle',
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
// Rotated y-axis label
|
|
238
|
+
setAttrs(axisLabel, {
|
|
239
|
+
x: area.x - 45,
|
|
240
|
+
y: area.y + area.height / 2,
|
|
241
|
+
'text-anchor': 'middle',
|
|
242
|
+
transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
g.appendChild(axisLabel);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
parent.appendChild(g);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderAxes(parent: SVGElement, layout: ChartLayout): void {
|
|
252
|
+
if (layout.axes.x) {
|
|
253
|
+
renderAxis(parent, layout.axes.x, 'x', layout);
|
|
254
|
+
}
|
|
255
|
+
if (layout.axes.y) {
|
|
256
|
+
renderAxis(parent, layout.axes.y, 'y', layout);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Mark rendering (dispatch per mark type)
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
|
|
265
|
+
|
|
266
|
+
const markRenderers: Record<string, MarkRenderer<Mark>> = {};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Register a mark renderer for a specific mark type.
|
|
270
|
+
* Built-in renderers are registered below for all chart types.
|
|
271
|
+
*/
|
|
272
|
+
export function registerMarkRenderer<T extends Mark>(
|
|
273
|
+
type: T['type'],
|
|
274
|
+
renderer: MarkRenderer<T>,
|
|
275
|
+
): void {
|
|
276
|
+
markRenderers[type] = renderer as MarkRenderer<Mark>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
280
|
+
const g = createSVGElement('g');
|
|
281
|
+
g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
|
|
282
|
+
g.setAttribute('class', 'viz-mark viz-mark-line');
|
|
283
|
+
|
|
284
|
+
if (mark.points.length > 1) {
|
|
285
|
+
const path = createSVGElement('path');
|
|
286
|
+
// Use the pre-computed D3 curve path when available (smooth monotone),
|
|
287
|
+
// otherwise fall back to straight M/L segments.
|
|
288
|
+
const d =
|
|
289
|
+
mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
290
|
+
setAttrs(path, {
|
|
291
|
+
d,
|
|
292
|
+
fill: 'none',
|
|
293
|
+
stroke: mark.stroke,
|
|
294
|
+
'stroke-width': mark.strokeWidth,
|
|
295
|
+
});
|
|
296
|
+
if (mark.strokeDasharray) {
|
|
297
|
+
path.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
298
|
+
}
|
|
299
|
+
g.appendChild(path);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Render end-of-line label if present and visible
|
|
303
|
+
if (mark.label?.visible) {
|
|
304
|
+
const label = createSVGElement('text');
|
|
305
|
+
label.setAttribute('class', 'viz-mark-label');
|
|
306
|
+
if (mark.seriesKey) {
|
|
307
|
+
label.setAttribute('data-series', mark.seriesKey);
|
|
308
|
+
}
|
|
309
|
+
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
310
|
+
applyTextStyle(label, mark.label.style);
|
|
311
|
+
label.textContent = mark.label.text;
|
|
312
|
+
g.appendChild(label);
|
|
313
|
+
|
|
314
|
+
// Render connector line if label was offset from anchor
|
|
315
|
+
if (mark.label.connector) {
|
|
316
|
+
const connector = createSVGElement('line');
|
|
317
|
+
connector.setAttribute('class', 'viz-mark-connector');
|
|
318
|
+
setAttrs(connector, {
|
|
319
|
+
x1: mark.label.connector.from.x,
|
|
320
|
+
y1: mark.label.connector.from.y,
|
|
321
|
+
x2: mark.label.connector.to.x,
|
|
322
|
+
y2: mark.label.connector.to.y,
|
|
323
|
+
stroke: mark.label.connector.stroke,
|
|
324
|
+
'stroke-width': 1,
|
|
325
|
+
'stroke-opacity': 0.5,
|
|
326
|
+
});
|
|
327
|
+
g.appendChild(connector);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return g;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
335
|
+
const g = createSVGElement('g');
|
|
336
|
+
g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
|
|
337
|
+
g.setAttribute('class', 'viz-mark viz-mark-area');
|
|
338
|
+
|
|
339
|
+
if (mark.path) {
|
|
340
|
+
// Area fill: the full closed shape (top line + baseline), no stroke
|
|
341
|
+
const fill = createSVGElement('path');
|
|
342
|
+
setAttrs(fill, {
|
|
343
|
+
d: mark.path,
|
|
344
|
+
fill: mark.fill,
|
|
345
|
+
'fill-opacity': mark.fillOpacity,
|
|
346
|
+
stroke: 'none',
|
|
347
|
+
});
|
|
348
|
+
g.appendChild(fill);
|
|
349
|
+
|
|
350
|
+
// Top-line stroke: only along the data points, not the baseline
|
|
351
|
+
if (mark.stroke && mark.topPath) {
|
|
352
|
+
const strokePath = createSVGElement('path');
|
|
353
|
+
setAttrs(strokePath, {
|
|
354
|
+
d: mark.topPath,
|
|
355
|
+
fill: 'none',
|
|
356
|
+
stroke: mark.stroke,
|
|
357
|
+
'stroke-width': mark.strokeWidth ?? 1,
|
|
358
|
+
});
|
|
359
|
+
g.appendChild(strokePath);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return g;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
367
|
+
const g = createSVGElement('g');
|
|
368
|
+
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
369
|
+
g.setAttribute('class', 'viz-mark viz-mark-rect');
|
|
370
|
+
|
|
371
|
+
const rect = createSVGElement('rect');
|
|
372
|
+
setAttrs(rect, {
|
|
373
|
+
x: mark.x,
|
|
374
|
+
y: mark.y,
|
|
375
|
+
width: mark.width,
|
|
376
|
+
height: mark.height,
|
|
377
|
+
fill: mark.fill,
|
|
378
|
+
});
|
|
379
|
+
if (mark.stroke) {
|
|
380
|
+
rect.setAttribute('stroke', mark.stroke);
|
|
381
|
+
}
|
|
382
|
+
if (mark.strokeWidth) {
|
|
383
|
+
rect.setAttribute('stroke-width', String(mark.strokeWidth));
|
|
384
|
+
}
|
|
385
|
+
if (mark.cornerRadius) {
|
|
386
|
+
setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
387
|
+
}
|
|
388
|
+
g.appendChild(rect);
|
|
389
|
+
|
|
390
|
+
// Render value label if present and visible
|
|
391
|
+
if (mark.label?.visible) {
|
|
392
|
+
const label = createSVGElement('text');
|
|
393
|
+
label.setAttribute('class', 'viz-mark-label');
|
|
394
|
+
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
395
|
+
applyTextStyle(label, mark.label.style);
|
|
396
|
+
label.textContent = mark.label.text;
|
|
397
|
+
g.appendChild(label);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return g;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
404
|
+
const g = createSVGElement('g');
|
|
405
|
+
g.setAttribute('data-mark-id', `arc-${index}`);
|
|
406
|
+
g.setAttribute('class', 'viz-mark viz-mark-arc');
|
|
407
|
+
g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
|
|
408
|
+
|
|
409
|
+
const path = createSVGElement('path');
|
|
410
|
+
setAttrs(path, {
|
|
411
|
+
d: mark.path,
|
|
412
|
+
fill: mark.fill,
|
|
413
|
+
stroke: mark.stroke,
|
|
414
|
+
'stroke-width': mark.strokeWidth,
|
|
415
|
+
});
|
|
416
|
+
g.appendChild(path);
|
|
417
|
+
|
|
418
|
+
// Render label if present and visible
|
|
419
|
+
if (mark.label?.visible) {
|
|
420
|
+
const label = createSVGElement('text');
|
|
421
|
+
label.setAttribute('class', 'viz-mark-label');
|
|
422
|
+
// Label position is in absolute coords, but we're in a translated group,
|
|
423
|
+
// so subtract the center offset
|
|
424
|
+
setAttrs(label, {
|
|
425
|
+
x: mark.label.x - mark.center.x,
|
|
426
|
+
y: mark.label.y - mark.center.y,
|
|
427
|
+
});
|
|
428
|
+
applyTextStyle(label, mark.label.style);
|
|
429
|
+
label.textContent = mark.label.text;
|
|
430
|
+
g.appendChild(label);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return g;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
437
|
+
const circle = createSVGElement('circle');
|
|
438
|
+
circle.setAttribute('data-mark-id', `point-${index}`);
|
|
439
|
+
circle.setAttribute('class', 'viz-mark viz-mark-point');
|
|
440
|
+
setAttrs(circle, {
|
|
441
|
+
cx: mark.cx,
|
|
442
|
+
cy: mark.cy,
|
|
443
|
+
r: mark.r,
|
|
444
|
+
fill: mark.fill,
|
|
445
|
+
stroke: mark.stroke,
|
|
446
|
+
'stroke-width': mark.strokeWidth,
|
|
447
|
+
});
|
|
448
|
+
if (mark.fillOpacity !== undefined) {
|
|
449
|
+
circle.setAttribute('fill-opacity', String(mark.fillOpacity));
|
|
450
|
+
}
|
|
451
|
+
return circle;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Register built-in renderers
|
|
455
|
+
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
456
|
+
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
457
|
+
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
458
|
+
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
459
|
+
registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
|
|
460
|
+
|
|
461
|
+
/** Extract series name from a mark for legend toggle matching. */
|
|
462
|
+
function getMarkSeries(mark: Mark): string | undefined {
|
|
463
|
+
// Line and area marks have an explicit seriesKey
|
|
464
|
+
if (mark.type === 'line' || mark.type === 'area') {
|
|
465
|
+
return mark.seriesKey;
|
|
466
|
+
}
|
|
467
|
+
// For arc marks, the category name is the first part of the aria label (before ':')
|
|
468
|
+
if (mark.type === 'arc') {
|
|
469
|
+
return mark.aria.label.split(':')[0]?.trim();
|
|
470
|
+
}
|
|
471
|
+
// For rect/point, the aria label may be "category: value" or "category, group: value".
|
|
472
|
+
// The series name is the category part (before the colon).
|
|
473
|
+
if (mark.aria?.label) {
|
|
474
|
+
const beforeColon = mark.aria.label.split(':')[0]?.trim();
|
|
475
|
+
if (beforeColon) return beforeColon;
|
|
476
|
+
}
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function renderMarks(parent: SVGElement, layout: ChartLayout): void {
|
|
481
|
+
const g = createSVGElement('g');
|
|
482
|
+
g.setAttribute('class', 'viz-marks');
|
|
483
|
+
|
|
484
|
+
for (let i = 0; i < layout.marks.length; i++) {
|
|
485
|
+
const mark = layout.marks[i];
|
|
486
|
+
const renderer = markRenderers[mark.type];
|
|
487
|
+
if (renderer) {
|
|
488
|
+
const el = renderer(mark, i);
|
|
489
|
+
// Add ARIA label if present
|
|
490
|
+
if (mark.aria?.label) {
|
|
491
|
+
el.setAttribute('aria-label', mark.aria.label);
|
|
492
|
+
}
|
|
493
|
+
// Add data-series attribute for legend toggle matching
|
|
494
|
+
const series = getMarkSeries(mark);
|
|
495
|
+
if (series) {
|
|
496
|
+
el.setAttribute('data-series', series);
|
|
497
|
+
}
|
|
498
|
+
g.appendChild(el);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
parent.appendChild(g);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Annotation rendering
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
|
|
510
|
+
if (layout.annotations.length === 0) return;
|
|
511
|
+
|
|
512
|
+
const g = createSVGElement('g');
|
|
513
|
+
g.setAttribute('class', 'viz-annotations');
|
|
514
|
+
|
|
515
|
+
// Annotations are already sorted by zIndex from the engine, so render in order
|
|
516
|
+
for (let i = 0; i < layout.annotations.length; i++) {
|
|
517
|
+
renderAnnotation(g, layout.annotations[i], i);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
parent.appendChild(g);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Render a curved arrow connector from a label to a data point.
|
|
525
|
+
* Uses a cubic bezier that sweeps outward then curves toward the
|
|
526
|
+
* target, with a triangular arrowhead at the tip.
|
|
527
|
+
*/
|
|
528
|
+
function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: string): void {
|
|
529
|
+
// Pad above the target so the arrow doesn't sit right on the element.
|
|
530
|
+
const pad = 6;
|
|
531
|
+
const tipY = to.y - pad;
|
|
532
|
+
|
|
533
|
+
const dy = tipY - from.y;
|
|
534
|
+
const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
|
|
535
|
+
|
|
536
|
+
// Arrowhead geometry
|
|
537
|
+
const arrowLen = 8;
|
|
538
|
+
const arrowWidth = 4;
|
|
539
|
+
|
|
540
|
+
// cp2 directly above target so arrow arrives pointing straight down.
|
|
541
|
+
const bulge = Math.max(dist * 0.4, 35);
|
|
542
|
+
const cp1x = from.x + bulge;
|
|
543
|
+
const cp1y = from.y + dy * 0.35;
|
|
544
|
+
const cp2x = to.x;
|
|
545
|
+
const cp2y = tipY - Math.abs(dy) * 0.25;
|
|
546
|
+
|
|
547
|
+
// Tangent at the tip (from cp2 → tip), used for arrowhead direction.
|
|
548
|
+
const tx = to.x - cp2x;
|
|
549
|
+
const ty = tipY - cp2y;
|
|
550
|
+
const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
|
|
551
|
+
const ux = tx / tLen;
|
|
552
|
+
const uy = ty / tLen;
|
|
553
|
+
|
|
554
|
+
// End the curve path at the arrowhead BASE so the stroke doesn't
|
|
555
|
+
// poke through the filled triangle.
|
|
556
|
+
const baseX = to.x - ux * arrowLen;
|
|
557
|
+
const baseY = tipY - uy * arrowLen;
|
|
558
|
+
|
|
559
|
+
const path = createSVGElement('path');
|
|
560
|
+
path.setAttribute('class', 'viz-annotation-connector');
|
|
561
|
+
setAttrs(path, {
|
|
562
|
+
d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
|
|
563
|
+
fill: 'none',
|
|
564
|
+
stroke,
|
|
565
|
+
'stroke-width': 1.5,
|
|
566
|
+
});
|
|
567
|
+
parent.appendChild(path);
|
|
568
|
+
|
|
569
|
+
// Arrowhead triangle: perpendicular to tangent direction.
|
|
570
|
+
const px = -uy;
|
|
571
|
+
const py = ux;
|
|
572
|
+
|
|
573
|
+
const arrow = createSVGElement('polygon');
|
|
574
|
+
arrow.setAttribute('class', 'viz-annotation-connector');
|
|
575
|
+
setAttrs(arrow, {
|
|
576
|
+
points: [
|
|
577
|
+
`${to.x},${tipY}`,
|
|
578
|
+
`${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
|
|
579
|
+
`${baseX - px * arrowWidth},${baseY - py * arrowWidth}`,
|
|
580
|
+
].join(' '),
|
|
581
|
+
fill: stroke,
|
|
582
|
+
});
|
|
583
|
+
parent.appendChild(arrow);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, index: number): void {
|
|
587
|
+
const g = createSVGElement('g');
|
|
588
|
+
g.setAttribute('class', `viz-annotation viz-annotation-${annotation.type}`);
|
|
589
|
+
g.setAttribute('data-annotation-index', String(index));
|
|
590
|
+
|
|
591
|
+
// Range rect
|
|
592
|
+
if (annotation.rect) {
|
|
593
|
+
const rect = createSVGElement('rect');
|
|
594
|
+
rect.setAttribute('class', 'viz-annotation-range');
|
|
595
|
+
setAttrs(rect, {
|
|
596
|
+
x: annotation.rect.x,
|
|
597
|
+
y: annotation.rect.y,
|
|
598
|
+
width: annotation.rect.width,
|
|
599
|
+
height: annotation.rect.height,
|
|
600
|
+
});
|
|
601
|
+
if (annotation.fill) rect.setAttribute('fill', annotation.fill);
|
|
602
|
+
if (annotation.opacity !== undefined) {
|
|
603
|
+
rect.setAttribute('fill-opacity', String(annotation.opacity));
|
|
604
|
+
}
|
|
605
|
+
g.appendChild(rect);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Reference line
|
|
609
|
+
if (annotation.line) {
|
|
610
|
+
const line = createSVGElement('line');
|
|
611
|
+
line.setAttribute('class', 'viz-annotation-line');
|
|
612
|
+
setAttrs(line, {
|
|
613
|
+
x1: annotation.line.start.x,
|
|
614
|
+
y1: annotation.line.start.y,
|
|
615
|
+
x2: annotation.line.end.x,
|
|
616
|
+
y2: annotation.line.end.y,
|
|
617
|
+
'stroke-width': annotation.strokeWidth ?? 1,
|
|
618
|
+
});
|
|
619
|
+
if (annotation.stroke) line.setAttribute('stroke', annotation.stroke);
|
|
620
|
+
if (annotation.strokeDasharray) {
|
|
621
|
+
line.setAttribute('stroke-dasharray', annotation.strokeDasharray);
|
|
622
|
+
}
|
|
623
|
+
g.appendChild(line);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Label with optional connector line
|
|
627
|
+
if (annotation.label?.visible) {
|
|
628
|
+
// Render connector first (behind the label text)
|
|
629
|
+
if (annotation.label.connector) {
|
|
630
|
+
const c = annotation.label.connector;
|
|
631
|
+
if (c.style === 'caret') {
|
|
632
|
+
// Small directional chevron centered in the gap between the label
|
|
633
|
+
// text and the data mark, pointing toward the data.
|
|
634
|
+
const pointsDown = c.to.y > c.from.y;
|
|
635
|
+
const caretSize = 4;
|
|
636
|
+
// c.from.y is near the text baseline, not the visual bottom.
|
|
637
|
+
// Estimate the text bottom from the label's line count and font size.
|
|
638
|
+
const labelLines = annotation.label.text.split('\n');
|
|
639
|
+
const labelFontSize = annotation.label.style.fontSize ?? 12;
|
|
640
|
+
const labelLineHeight = labelFontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
641
|
+
const textBottom =
|
|
642
|
+
annotation.label.y + (labelLines.length - 1) * labelLineHeight + labelFontSize * 0.25;
|
|
643
|
+
const textTop = annotation.label.y - labelFontSize;
|
|
644
|
+
// Center caret in the gap between text edge and data point
|
|
645
|
+
const gapEdge = pointsDown ? textBottom : textTop;
|
|
646
|
+
const midY = (gapEdge + c.to.y) / 2;
|
|
647
|
+
const tipX = c.to.x;
|
|
648
|
+
const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
|
|
649
|
+
const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
|
|
650
|
+
const path = createSVGElement('path');
|
|
651
|
+
path.setAttribute('class', 'viz-annotation-connector');
|
|
652
|
+
setAttrs(path, {
|
|
653
|
+
d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
|
|
654
|
+
fill: 'none',
|
|
655
|
+
stroke: c.stroke,
|
|
656
|
+
'stroke-width': 1.5,
|
|
657
|
+
'stroke-opacity': 0.4,
|
|
658
|
+
'stroke-linecap': 'round',
|
|
659
|
+
'stroke-linejoin': 'round',
|
|
660
|
+
});
|
|
661
|
+
g.appendChild(path);
|
|
662
|
+
} else if (c.style === 'curve') {
|
|
663
|
+
renderCurvedArrow(g, c.from, c.to, c.stroke);
|
|
664
|
+
} else {
|
|
665
|
+
const connector = createSVGElement('line');
|
|
666
|
+
connector.setAttribute('class', 'viz-annotation-connector');
|
|
667
|
+
setAttrs(connector, {
|
|
668
|
+
x1: c.from.x,
|
|
669
|
+
y1: c.from.y,
|
|
670
|
+
x2: c.to.x,
|
|
671
|
+
y2: c.to.y,
|
|
672
|
+
stroke: c.stroke,
|
|
673
|
+
'stroke-width': 1,
|
|
674
|
+
'stroke-opacity': 0.5,
|
|
675
|
+
});
|
|
676
|
+
g.appendChild(connector);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const text = createSVGElement('text');
|
|
681
|
+
text.setAttribute('class', 'viz-annotation-label');
|
|
682
|
+
setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
|
|
683
|
+
applyTextStyle(text, annotation.label.style);
|
|
684
|
+
|
|
685
|
+
const lines = annotation.label.text.split('\n');
|
|
686
|
+
const fontSize = annotation.label.style.fontSize ?? 12;
|
|
687
|
+
const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
688
|
+
const isMultiLine = lines.length > 1;
|
|
689
|
+
|
|
690
|
+
// Multi-line text uses center alignment for a cleaner look
|
|
691
|
+
if (isMultiLine) {
|
|
692
|
+
text.setAttribute('text-anchor', 'middle');
|
|
693
|
+
for (let i = 0; i < lines.length; i++) {
|
|
694
|
+
const tspan = createSVGElement('tspan');
|
|
695
|
+
setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
|
|
696
|
+
tspan.textContent = lines[i];
|
|
697
|
+
text.appendChild(tspan);
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
text.textContent = annotation.label.text;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Render background rect behind text if specified
|
|
704
|
+
if (annotation.label.background) {
|
|
705
|
+
const charWidth = fontSize * 0.55;
|
|
706
|
+
const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
|
|
707
|
+
const totalHeight = lines.length * lineHeight;
|
|
708
|
+
const pad = 3;
|
|
709
|
+
const bgX = isMultiLine
|
|
710
|
+
? annotation.label.x - maxLineWidth / 2 - pad
|
|
711
|
+
: annotation.label.x - pad;
|
|
712
|
+
const bgRect = createSVGElement('rect');
|
|
713
|
+
bgRect.setAttribute('class', 'viz-annotation-bg');
|
|
714
|
+
setAttrs(bgRect, {
|
|
715
|
+
x: bgX,
|
|
716
|
+
y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
|
|
717
|
+
width: maxLineWidth + pad * 2,
|
|
718
|
+
height: totalHeight + pad * 2,
|
|
719
|
+
fill: annotation.label.background,
|
|
720
|
+
rx: 2,
|
|
721
|
+
});
|
|
722
|
+
g.appendChild(bgRect);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
g.appendChild(text);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
parent.appendChild(g);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// Legend rendering
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
736
|
+
if (legend.entries.length === 0) return;
|
|
737
|
+
|
|
738
|
+
const g = createSVGElement('g');
|
|
739
|
+
g.setAttribute('class', 'viz-legend');
|
|
740
|
+
g.setAttribute('role', 'list');
|
|
741
|
+
g.setAttribute('aria-label', 'Chart legend');
|
|
742
|
+
|
|
743
|
+
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
|
|
744
|
+
let offsetX = legend.bounds.x;
|
|
745
|
+
let offsetY = legend.bounds.y;
|
|
746
|
+
|
|
747
|
+
for (let i = 0; i < legend.entries.length; i++) {
|
|
748
|
+
const entry = legend.entries[i];
|
|
749
|
+
const entryG = createSVGElement('g');
|
|
750
|
+
entryG.setAttribute('class', 'viz-legend-entry');
|
|
751
|
+
entryG.setAttribute('role', 'listitem');
|
|
752
|
+
entryG.setAttribute('data-legend-index', String(i));
|
|
753
|
+
entryG.setAttribute('data-legend-label', entry.label);
|
|
754
|
+
entryG.setAttribute(
|
|
755
|
+
'aria-label',
|
|
756
|
+
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
|
|
757
|
+
);
|
|
758
|
+
entryG.setAttribute('style', 'cursor: pointer');
|
|
759
|
+
|
|
760
|
+
// Apply dimming for inactive entries
|
|
761
|
+
if (entry.active === false) {
|
|
762
|
+
entryG.setAttribute('opacity', '0.3');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Swatch
|
|
766
|
+
if (entry.shape === 'circle') {
|
|
767
|
+
const circle = createSVGElement('circle');
|
|
768
|
+
setAttrs(circle, {
|
|
769
|
+
cx: offsetX + legend.swatchSize / 2,
|
|
770
|
+
cy: offsetY + legend.swatchSize / 2,
|
|
771
|
+
r: legend.swatchSize / 2,
|
|
772
|
+
fill: entry.color,
|
|
773
|
+
});
|
|
774
|
+
entryG.appendChild(circle);
|
|
775
|
+
} else if (entry.shape === 'line') {
|
|
776
|
+
// Line swatch: a short line segment with a dot in the middle
|
|
777
|
+
const line = createSVGElement('line');
|
|
778
|
+
setAttrs(line, {
|
|
779
|
+
x1: offsetX,
|
|
780
|
+
y1: offsetY + legend.swatchSize / 2,
|
|
781
|
+
x2: offsetX + legend.swatchSize,
|
|
782
|
+
y2: offsetY + legend.swatchSize / 2,
|
|
783
|
+
stroke: entry.color,
|
|
784
|
+
'stroke-width': 2,
|
|
785
|
+
});
|
|
786
|
+
entryG.appendChild(line);
|
|
787
|
+
// Small dot at center
|
|
788
|
+
const dot = createSVGElement('circle');
|
|
789
|
+
setAttrs(dot, {
|
|
790
|
+
cx: offsetX + legend.swatchSize / 2,
|
|
791
|
+
cy: offsetY + legend.swatchSize / 2,
|
|
792
|
+
r: 2.5,
|
|
793
|
+
fill: entry.color,
|
|
794
|
+
});
|
|
795
|
+
entryG.appendChild(dot);
|
|
796
|
+
} else {
|
|
797
|
+
const rect = createSVGElement('rect');
|
|
798
|
+
setAttrs(rect, {
|
|
799
|
+
x: offsetX,
|
|
800
|
+
y: offsetY,
|
|
801
|
+
width: legend.swatchSize,
|
|
802
|
+
height: legend.swatchSize,
|
|
803
|
+
fill: entry.color,
|
|
804
|
+
rx: 2,
|
|
805
|
+
});
|
|
806
|
+
entryG.appendChild(rect);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Label
|
|
810
|
+
const label = createSVGElement('text');
|
|
811
|
+
setAttrs(label, {
|
|
812
|
+
x: offsetX + legend.swatchSize + legend.swatchGap,
|
|
813
|
+
y: offsetY + legend.swatchSize / 2,
|
|
814
|
+
'dominant-baseline': 'central',
|
|
815
|
+
});
|
|
816
|
+
applyTextStyle(label, legend.labelStyle);
|
|
817
|
+
label.textContent = entry.label;
|
|
818
|
+
entryG.appendChild(label);
|
|
819
|
+
|
|
820
|
+
g.appendChild(entryG);
|
|
821
|
+
|
|
822
|
+
// Advance position for next entry
|
|
823
|
+
if (isHorizontal) {
|
|
824
|
+
const labelWidth = estimateTextWidth(
|
|
825
|
+
entry.label,
|
|
826
|
+
legend.labelStyle.fontSize,
|
|
827
|
+
legend.labelStyle.fontWeight,
|
|
828
|
+
);
|
|
829
|
+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
830
|
+
offsetX += entryWidth;
|
|
831
|
+
// Wrap to next line if exceeding bounds
|
|
832
|
+
if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
|
|
833
|
+
offsetX = legend.bounds.x;
|
|
834
|
+
offsetY += legend.swatchSize + 6;
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
offsetY += legend.swatchSize + legend.entryGap;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
parent.appendChild(g);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Main render function
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Render a compiled ChartLayout into an SVG element and append it to a container.
|
|
850
|
+
*
|
|
851
|
+
* @param layout - Compiled ChartLayout from compileChart().
|
|
852
|
+
* @param container - DOM element to mount the SVG into.
|
|
853
|
+
* @returns The created SVG element.
|
|
854
|
+
*/
|
|
855
|
+
export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVGElement {
|
|
856
|
+
const { width, height } = layout.dimensions;
|
|
857
|
+
|
|
858
|
+
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
859
|
+
setAttrs(svg, {
|
|
860
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
861
|
+
xmlns: SVG_NS,
|
|
862
|
+
});
|
|
863
|
+
svg.setAttribute('role', layout.a11y.role);
|
|
864
|
+
svg.setAttribute('aria-label', layout.a11y.altText);
|
|
865
|
+
svg.setAttribute('class', 'viz-chart');
|
|
866
|
+
|
|
867
|
+
// Background
|
|
868
|
+
const bg = createSVGElement('rect');
|
|
869
|
+
setAttrs(bg, {
|
|
870
|
+
x: 0,
|
|
871
|
+
y: 0,
|
|
872
|
+
width,
|
|
873
|
+
height,
|
|
874
|
+
fill: layout.theme.colors.background,
|
|
875
|
+
});
|
|
876
|
+
svg.appendChild(bg);
|
|
877
|
+
|
|
878
|
+
// Clip path to prevent marks (especially area fills) from overflowing
|
|
879
|
+
// into the chrome region (title/subtitle). Extends full width so
|
|
880
|
+
// end-of-line labels aren't clipped, but constrains vertically.
|
|
881
|
+
const clipId = `viz-clip-${Math.random().toString(36).slice(2, 8)}`;
|
|
882
|
+
const defs = createSVGElement('defs');
|
|
883
|
+
const clipPath = createSVGElement('clipPath');
|
|
884
|
+
clipPath.setAttribute('id', clipId);
|
|
885
|
+
const clipRect = createSVGElement('rect');
|
|
886
|
+
setAttrs(clipRect, {
|
|
887
|
+
x: 0,
|
|
888
|
+
y: layout.area.y,
|
|
889
|
+
width,
|
|
890
|
+
height: layout.area.height + 2,
|
|
891
|
+
});
|
|
892
|
+
clipPath.appendChild(clipRect);
|
|
893
|
+
defs.appendChild(clipPath);
|
|
894
|
+
svg.appendChild(defs);
|
|
895
|
+
|
|
896
|
+
// Render layers in order (back to front)
|
|
897
|
+
// Axes render outside clip (labels extend beyond chart area)
|
|
898
|
+
renderAxes(svg, layout);
|
|
899
|
+
|
|
900
|
+
// Marks are clipped to chart area so area fills don't cover chrome
|
|
901
|
+
const clippedGroup = createSVGElement('g');
|
|
902
|
+
clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
|
|
903
|
+
renderMarks(clippedGroup, layout);
|
|
904
|
+
svg.appendChild(clippedGroup);
|
|
905
|
+
|
|
906
|
+
renderAnnotations(svg, layout);
|
|
907
|
+
renderLegend(svg, layout.legend);
|
|
908
|
+
|
|
909
|
+
// Chrome renders on top so titles are never obscured by chart elements
|
|
910
|
+
renderChrome(svg, layout);
|
|
911
|
+
|
|
912
|
+
container.appendChild(svg);
|
|
913
|
+
return svg;
|
|
914
|
+
}
|