@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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark rendering: dispatches to per-mark-type sub-renderers (line, area, rect,
|
|
3
|
+
* arc, point, text, rule, tick).
|
|
4
|
+
*
|
|
5
|
+
* Mark renderers read module-level animation and gradient state so their
|
|
6
|
+
* signatures stay `(mark, index) => SVGElement`. Callers must invoke
|
|
7
|
+
* `setMarkRenderState()` before `renderMarks()` and `resetMarkRenderState()`
|
|
8
|
+
* after.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ArcMark,
|
|
13
|
+
AreaMark,
|
|
14
|
+
ChartLayout,
|
|
15
|
+
LineMark,
|
|
16
|
+
Mark,
|
|
17
|
+
PointMark,
|
|
18
|
+
RectMark,
|
|
19
|
+
ResolvedAnimation,
|
|
20
|
+
RuleMarkLayout,
|
|
21
|
+
TextMarkLayout,
|
|
22
|
+
TickMarkLayout,
|
|
23
|
+
} from '@opendata-ai/openchart-core';
|
|
24
|
+
import { resolveMarkFill } from '../gradient-utils';
|
|
25
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Module-level animation state. Set by the orchestrator before rendering marks
|
|
29
|
+
* so mark renderers can read it without changing their function signatures.
|
|
30
|
+
*/
|
|
31
|
+
let currentAnimation: ResolvedAnimation | undefined;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Module-level gradient map. Set by the orchestrator after building gradient defs
|
|
35
|
+
* so mark renderers can resolve gradient fills without signature changes.
|
|
36
|
+
*/
|
|
37
|
+
let currentGradientMap: Map<string, string> = new Map();
|
|
38
|
+
|
|
39
|
+
/** Set animation + gradient state before rendering marks. */
|
|
40
|
+
export function setMarkRenderState(state: {
|
|
41
|
+
animation: ResolvedAnimation | undefined;
|
|
42
|
+
gradientMap: Map<string, string>;
|
|
43
|
+
}): void {
|
|
44
|
+
currentAnimation = state.animation;
|
|
45
|
+
currentGradientMap = state.gradientMap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reset animation + gradient state after rendering. */
|
|
49
|
+
export function resetMarkRenderState(): void {
|
|
50
|
+
currentAnimation = undefined;
|
|
51
|
+
currentGradientMap = new Map();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stamp animation index attributes on a mark element when animation is enabled.
|
|
56
|
+
* Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
|
|
57
|
+
* (for CSS calc-based stagger delay).
|
|
58
|
+
*/
|
|
59
|
+
function stampAnimationAttrs(
|
|
60
|
+
el: SVGElement,
|
|
61
|
+
mark: { animationIndex?: number },
|
|
62
|
+
fallbackIndex: number,
|
|
63
|
+
): void {
|
|
64
|
+
if (!currentAnimation?.enabled) return;
|
|
65
|
+
const idx = mark.animationIndex ?? fallbackIndex;
|
|
66
|
+
el.setAttribute('data-animation-index', String(idx));
|
|
67
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
|
|
71
|
+
|
|
72
|
+
const markRenderers: Record<string, MarkRenderer<Mark>> = {};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a mark renderer for a specific mark type.
|
|
76
|
+
* Built-in renderers are registered below for all chart types.
|
|
77
|
+
*/
|
|
78
|
+
export function registerMarkRenderer<T extends Mark>(
|
|
79
|
+
type: T['type'],
|
|
80
|
+
renderer: MarkRenderer<T>,
|
|
81
|
+
): void {
|
|
82
|
+
markRenderers[type] = renderer as MarkRenderer<Mark>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
86
|
+
const g = createSVGElement('g');
|
|
87
|
+
g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
|
|
88
|
+
g.setAttribute('class', 'oc-mark oc-mark-line');
|
|
89
|
+
stampAnimationAttrs(g, mark, index);
|
|
90
|
+
|
|
91
|
+
if (mark.points.length > 1) {
|
|
92
|
+
const path = createSVGElement('path');
|
|
93
|
+
// Use the pre-computed D3 curve path when available (smooth monotone),
|
|
94
|
+
// otherwise fall back to straight M/L segments.
|
|
95
|
+
const d =
|
|
96
|
+
mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
97
|
+
setAttrs(path, {
|
|
98
|
+
d,
|
|
99
|
+
fill: 'none',
|
|
100
|
+
stroke: mark.stroke,
|
|
101
|
+
'stroke-width': mark.strokeWidth,
|
|
102
|
+
});
|
|
103
|
+
if (mark.strokeDasharray) {
|
|
104
|
+
path.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
105
|
+
}
|
|
106
|
+
if (mark.opacity != null) {
|
|
107
|
+
path.setAttribute('opacity', String(mark.opacity));
|
|
108
|
+
}
|
|
109
|
+
// Note: line drawing animation is handled via CSS clip-path on the group,
|
|
110
|
+
// no inline dasharray/dashoffset needed.
|
|
111
|
+
g.appendChild(path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Render end-of-line label if present and visible
|
|
115
|
+
if (mark.label?.visible) {
|
|
116
|
+
const label = createSVGElement('text');
|
|
117
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
118
|
+
if (mark.seriesKey) {
|
|
119
|
+
label.setAttribute('data-series', mark.seriesKey);
|
|
120
|
+
}
|
|
121
|
+
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
122
|
+
applyTextStyle(label, mark.label.style);
|
|
123
|
+
label.textContent = mark.label.text;
|
|
124
|
+
g.appendChild(label);
|
|
125
|
+
|
|
126
|
+
// Render connector line if label was offset from anchor
|
|
127
|
+
if (mark.label.connector) {
|
|
128
|
+
const connector = createSVGElement('line');
|
|
129
|
+
connector.setAttribute('class', 'oc-mark-connector');
|
|
130
|
+
setAttrs(connector, {
|
|
131
|
+
x1: mark.label.connector.from.x,
|
|
132
|
+
y1: mark.label.connector.from.y,
|
|
133
|
+
x2: mark.label.connector.to.x,
|
|
134
|
+
y2: mark.label.connector.to.y,
|
|
135
|
+
stroke: mark.label.connector.stroke,
|
|
136
|
+
'stroke-width': 1,
|
|
137
|
+
'stroke-opacity': 0.5,
|
|
138
|
+
});
|
|
139
|
+
g.appendChild(connector);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return g;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
147
|
+
const g = createSVGElement('g');
|
|
148
|
+
g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
|
|
149
|
+
g.setAttribute('class', 'oc-mark oc-mark-area');
|
|
150
|
+
stampAnimationAttrs(g, mark, index);
|
|
151
|
+
|
|
152
|
+
if (mark.path) {
|
|
153
|
+
// Area fill: the full closed shape (top line + baseline), no stroke
|
|
154
|
+
const fill = createSVGElement('path');
|
|
155
|
+
setAttrs(fill, {
|
|
156
|
+
d: mark.path,
|
|
157
|
+
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
158
|
+
'fill-opacity': mark.fillOpacity,
|
|
159
|
+
stroke: 'none',
|
|
160
|
+
});
|
|
161
|
+
g.appendChild(fill);
|
|
162
|
+
|
|
163
|
+
// Top-line stroke: only along the data points, not the baseline
|
|
164
|
+
if (mark.stroke && mark.topPath) {
|
|
165
|
+
const strokePath = createSVGElement('path');
|
|
166
|
+
strokePath.setAttribute('class', 'oc-area-top');
|
|
167
|
+
setAttrs(strokePath, {
|
|
168
|
+
d: mark.topPath,
|
|
169
|
+
fill: 'none',
|
|
170
|
+
stroke: mark.stroke,
|
|
171
|
+
'stroke-width': mark.strokeWidth ?? 1,
|
|
172
|
+
});
|
|
173
|
+
// Note: area drawing animation is handled via CSS clip-path on the group,
|
|
174
|
+
// no inline dasharray/dashoffset needed.
|
|
175
|
+
g.appendChild(strokePath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return g;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
183
|
+
const g = createSVGElement('g');
|
|
184
|
+
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
185
|
+
g.setAttribute('class', 'oc-mark oc-mark-rect');
|
|
186
|
+
stampAnimationAttrs(g, mark, index);
|
|
187
|
+
// Use engine-provided orientation for animation direction
|
|
188
|
+
if (currentAnimation?.enabled && mark.orient === 'horizontal') {
|
|
189
|
+
g.setAttribute('data-orient', 'horizontal');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const rect = createSVGElement('rect');
|
|
193
|
+
setAttrs(rect, {
|
|
194
|
+
x: mark.x,
|
|
195
|
+
y: mark.y,
|
|
196
|
+
width: mark.width,
|
|
197
|
+
height: mark.height,
|
|
198
|
+
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
199
|
+
});
|
|
200
|
+
if (mark.stroke) {
|
|
201
|
+
rect.setAttribute('stroke', mark.stroke);
|
|
202
|
+
}
|
|
203
|
+
if (mark.strokeWidth) {
|
|
204
|
+
rect.setAttribute('stroke-width', String(mark.strokeWidth));
|
|
205
|
+
}
|
|
206
|
+
if (mark.cornerRadius) {
|
|
207
|
+
setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
208
|
+
}
|
|
209
|
+
g.appendChild(rect);
|
|
210
|
+
|
|
211
|
+
// Render value label if present and visible
|
|
212
|
+
if (mark.label?.visible) {
|
|
213
|
+
const label = createSVGElement('text');
|
|
214
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
215
|
+
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
216
|
+
applyTextStyle(label, mark.label.style);
|
|
217
|
+
label.textContent = mark.label.text;
|
|
218
|
+
g.appendChild(label);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return g;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
225
|
+
const g = createSVGElement('g');
|
|
226
|
+
g.setAttribute('data-mark-id', `arc-${index}`);
|
|
227
|
+
g.setAttribute('class', 'oc-mark oc-mark-arc');
|
|
228
|
+
g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
|
|
229
|
+
stampAnimationAttrs(g, mark, index);
|
|
230
|
+
|
|
231
|
+
const path = createSVGElement('path');
|
|
232
|
+
setAttrs(path, {
|
|
233
|
+
d: mark.path,
|
|
234
|
+
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
235
|
+
stroke: mark.stroke,
|
|
236
|
+
'stroke-width': mark.strokeWidth,
|
|
237
|
+
});
|
|
238
|
+
g.appendChild(path);
|
|
239
|
+
|
|
240
|
+
// Render label if present and visible
|
|
241
|
+
if (mark.label?.visible) {
|
|
242
|
+
const label = createSVGElement('text');
|
|
243
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
244
|
+
// Label position is in absolute coords, but we're in a translated group,
|
|
245
|
+
// so subtract the center offset
|
|
246
|
+
setAttrs(label, {
|
|
247
|
+
x: mark.label.x - mark.center.x,
|
|
248
|
+
y: mark.label.y - mark.center.y,
|
|
249
|
+
});
|
|
250
|
+
applyTextStyle(label, mark.label.style);
|
|
251
|
+
label.textContent = mark.label.text;
|
|
252
|
+
g.appendChild(label);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return g;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
259
|
+
const circle = createSVGElement('circle');
|
|
260
|
+
circle.setAttribute('data-mark-id', `point-${index}`);
|
|
261
|
+
circle.setAttribute('class', 'oc-mark oc-mark-point');
|
|
262
|
+
stampAnimationAttrs(circle, mark, index);
|
|
263
|
+
|
|
264
|
+
setAttrs(circle, {
|
|
265
|
+
cx: mark.cx,
|
|
266
|
+
cy: mark.cy,
|
|
267
|
+
r: mark.r,
|
|
268
|
+
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
269
|
+
stroke: mark.stroke,
|
|
270
|
+
'stroke-width': mark.strokeWidth,
|
|
271
|
+
});
|
|
272
|
+
if (mark.fillOpacity !== undefined) {
|
|
273
|
+
circle.setAttribute('fill-opacity', String(mark.fillOpacity));
|
|
274
|
+
}
|
|
275
|
+
return circle;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
279
|
+
const text = createSVGElement('text');
|
|
280
|
+
text.setAttribute('data-mark-id', `textMark-${index}`);
|
|
281
|
+
text.setAttribute('class', 'oc-mark oc-mark-text');
|
|
282
|
+
stampAnimationAttrs(text, mark, index);
|
|
283
|
+
|
|
284
|
+
setAttrs(text, {
|
|
285
|
+
x: mark.x,
|
|
286
|
+
y: mark.y,
|
|
287
|
+
'font-size': mark.fontSize,
|
|
288
|
+
'text-anchor': mark.textAnchor,
|
|
289
|
+
});
|
|
290
|
+
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
|
|
291
|
+
if (mark.fontWeight) {
|
|
292
|
+
text.setAttribute('font-weight', String(mark.fontWeight));
|
|
293
|
+
}
|
|
294
|
+
if (mark.fontFamily) {
|
|
295
|
+
text.setAttribute('font-family', mark.fontFamily);
|
|
296
|
+
}
|
|
297
|
+
if (mark.angle) {
|
|
298
|
+
text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
|
|
299
|
+
}
|
|
300
|
+
text.textContent = mark.text;
|
|
301
|
+
return text;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
305
|
+
const line = createSVGElement('line');
|
|
306
|
+
line.setAttribute('data-mark-id', `rule-${index}`);
|
|
307
|
+
line.setAttribute('class', 'oc-mark oc-mark-rule');
|
|
308
|
+
stampAnimationAttrs(line, mark, index);
|
|
309
|
+
|
|
310
|
+
setAttrs(line, {
|
|
311
|
+
x1: mark.x1,
|
|
312
|
+
y1: mark.y1,
|
|
313
|
+
x2: mark.x2,
|
|
314
|
+
y2: mark.y2,
|
|
315
|
+
stroke: mark.stroke,
|
|
316
|
+
'stroke-width': mark.strokeWidth,
|
|
317
|
+
});
|
|
318
|
+
if (mark.strokeDasharray) {
|
|
319
|
+
line.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
320
|
+
}
|
|
321
|
+
if (mark.opacity != null) {
|
|
322
|
+
line.setAttribute('opacity', String(mark.opacity));
|
|
323
|
+
}
|
|
324
|
+
return line;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
|
|
328
|
+
const line = createSVGElement('line');
|
|
329
|
+
line.setAttribute('data-mark-id', `tick-${index}`);
|
|
330
|
+
line.setAttribute('class', 'oc-mark oc-mark-tick');
|
|
331
|
+
stampAnimationAttrs(line, mark, index);
|
|
332
|
+
|
|
333
|
+
// Tick is a short line segment centered at (x, y)
|
|
334
|
+
const half = mark.length / 2;
|
|
335
|
+
if (mark.orient === 'vertical') {
|
|
336
|
+
setAttrs(line, {
|
|
337
|
+
x1: mark.x,
|
|
338
|
+
y1: mark.y - half,
|
|
339
|
+
x2: mark.x,
|
|
340
|
+
y2: mark.y + half,
|
|
341
|
+
stroke: mark.stroke,
|
|
342
|
+
'stroke-width': mark.strokeWidth,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
setAttrs(line, {
|
|
346
|
+
x1: mark.x - half,
|
|
347
|
+
y1: mark.y,
|
|
348
|
+
x2: mark.x + half,
|
|
349
|
+
y2: mark.y,
|
|
350
|
+
stroke: mark.stroke,
|
|
351
|
+
'stroke-width': mark.strokeWidth,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (mark.opacity != null) {
|
|
356
|
+
line.setAttribute('opacity', String(mark.opacity));
|
|
357
|
+
}
|
|
358
|
+
return line;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Register built-in renderers
|
|
362
|
+
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
363
|
+
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
364
|
+
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
365
|
+
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
366
|
+
registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
|
|
367
|
+
registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
|
|
368
|
+
registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
|
|
369
|
+
registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
|
|
370
|
+
|
|
371
|
+
/** Extract series name from a mark for legend toggle matching. */
|
|
372
|
+
function getMarkSeries(mark: Mark): string | undefined {
|
|
373
|
+
// Line and area marks have an explicit seriesKey
|
|
374
|
+
if (mark.type === 'line' || mark.type === 'area') {
|
|
375
|
+
return mark.seriesKey;
|
|
376
|
+
}
|
|
377
|
+
// For arc marks, the category name is the first part of the aria label (before ':')
|
|
378
|
+
if (mark.type === 'arc') {
|
|
379
|
+
return mark.aria.label.split(':')[0]?.trim();
|
|
380
|
+
}
|
|
381
|
+
// For rect/point, the aria label may be "category: value" or "category, group: value".
|
|
382
|
+
// The series name is the category part (before the colon).
|
|
383
|
+
if (mark.aria?.label) {
|
|
384
|
+
const beforeColon = mark.aria.label.split(':')[0]?.trim();
|
|
385
|
+
if (beforeColon) return beforeColon;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function renderMarks(parent: SVGElement, layout: ChartLayout): void {
|
|
391
|
+
const g = createSVGElement('g');
|
|
392
|
+
g.setAttribute('class', 'oc-marks');
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < layout.marks.length; i++) {
|
|
395
|
+
const mark = layout.marks[i];
|
|
396
|
+
const renderer = markRenderers[mark.type];
|
|
397
|
+
if (!renderer) continue;
|
|
398
|
+
|
|
399
|
+
const el = renderer(mark, i);
|
|
400
|
+
// Add ARIA label if present
|
|
401
|
+
if (mark.aria?.label) {
|
|
402
|
+
el.setAttribute('aria-label', mark.aria.label);
|
|
403
|
+
}
|
|
404
|
+
// Add data-series attribute for legend toggle matching
|
|
405
|
+
const series = getMarkSeries(mark);
|
|
406
|
+
if (series) {
|
|
407
|
+
el.setAttribute('data-series', series);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// For stacked segments, set stack position for sequential animation chaining.
|
|
411
|
+
// stackPos is computed by the engine on RectMark during compilation.
|
|
412
|
+
if (currentAnimation?.enabled && mark.type === 'rect') {
|
|
413
|
+
const rect = mark as RectMark;
|
|
414
|
+
if (rect.stackGroup && rect.stackPos !== undefined) {
|
|
415
|
+
el.setAttribute('data-stack-pos', String(rect.stackPos));
|
|
416
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
417
|
+
'--oc-stack-pos',
|
|
418
|
+
String(rect.stackPos),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
g.appendChild(el);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
parent.appendChild(g);
|
|
427
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SVG DOM helpers used across the per-concern renderers.
|
|
3
|
+
*
|
|
4
|
+
* Pure, stateless utilities. No layout/theme knowledge.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChartLayout, TextStyle } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
9
|
+
|
|
10
|
+
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11
|
+
export const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
12
|
+
|
|
13
|
+
export function createSVGElement(tag: string): SVGElement {
|
|
14
|
+
return document.createElementNS(SVG_NS, tag);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
|
|
18
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
19
|
+
el.setAttribute(key, String(value));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
24
|
+
setAttrs(el, {
|
|
25
|
+
'font-family': style.fontFamily,
|
|
26
|
+
'font-size': style.fontSize,
|
|
27
|
+
'font-weight': style.fontWeight,
|
|
28
|
+
});
|
|
29
|
+
// Use inline style for fill so it takes priority over CSS class defaults
|
|
30
|
+
// (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
|
|
31
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
|
|
32
|
+
if (style.textAnchor) {
|
|
33
|
+
el.setAttribute('text-anchor', style.textAnchor);
|
|
34
|
+
}
|
|
35
|
+
if (style.dominantBaseline) {
|
|
36
|
+
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
37
|
+
}
|
|
38
|
+
if (style.fontVariant) {
|
|
39
|
+
el.setAttribute('font-variant', style.fontVariant);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute the vertical extent of x-axis labels below the chart area.
|
|
45
|
+
* Accounts for rotated tick labels which need more vertical space.
|
|
46
|
+
*/
|
|
47
|
+
export function computeXAxisExtent(layout: ChartLayout): number {
|
|
48
|
+
const xAxis = layout.axes.x;
|
|
49
|
+
if (!xAxis) return 0;
|
|
50
|
+
|
|
51
|
+
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
|
|
52
|
+
// Rotated labels: estimate height from the longest tick label.
|
|
53
|
+
const fontSize = xAxis.tickLabelStyle.fontSize;
|
|
54
|
+
const fontWeight = xAxis.tickLabelStyle.fontWeight;
|
|
55
|
+
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
|
|
56
|
+
let maxLabelWidth = 40;
|
|
57
|
+
for (const tick of xAxis.ticks) {
|
|
58
|
+
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
59
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
60
|
+
}
|
|
61
|
+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
62
|
+
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return xAxis.label ? 48 : 26;
|
|
66
|
+
}
|