@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/renderers/marks.ts
DELETED
|
@@ -1,561 +0,0 @@
|
|
|
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
|
-
ResolvedFillPattern,
|
|
21
|
-
RuleMarkLayout,
|
|
22
|
-
TextMarkLayout,
|
|
23
|
-
TickMarkLayout,
|
|
24
|
-
} from '@opendata-ai/openchart-core';
|
|
25
|
-
import { resolveMarkFill } from '../gradient-utils';
|
|
26
|
-
import { resolvePatternFill } from '../pattern-utils';
|
|
27
|
-
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Module-level animation state. Set by the orchestrator before rendering marks
|
|
31
|
-
* so mark renderers can read it without changing their function signatures.
|
|
32
|
-
*/
|
|
33
|
-
let currentAnimation: ResolvedAnimation | undefined;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Module-level gradient map. Set by the orchestrator after building gradient defs
|
|
37
|
-
* so mark renderers can resolve gradient fills without signature changes.
|
|
38
|
-
*/
|
|
39
|
-
let currentGradientMap: Map<string, string> = new Map();
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Module-level pattern map. Same lifecycle as the gradient map, built from
|
|
43
|
-
* pattern defs (see pattern-utils.ts).
|
|
44
|
-
*/
|
|
45
|
-
let currentPatternMap: Map<string, string> = new Map();
|
|
46
|
-
|
|
47
|
-
/** Set animation + gradient + pattern state before rendering marks. */
|
|
48
|
-
export function setMarkRenderState(state: {
|
|
49
|
-
animation: ResolvedAnimation | undefined;
|
|
50
|
-
gradientMap: Map<string, string>;
|
|
51
|
-
patternMap?: Map<string, string>;
|
|
52
|
-
}): void {
|
|
53
|
-
currentAnimation = state.animation;
|
|
54
|
-
currentGradientMap = state.gradientMap;
|
|
55
|
-
currentPatternMap = state.patternMap ?? new Map();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Reset animation + gradient + pattern state after rendering. */
|
|
59
|
-
export function resetMarkRenderState(): void {
|
|
60
|
-
currentAnimation = undefined;
|
|
61
|
-
currentGradientMap = new Map();
|
|
62
|
-
currentPatternMap = new Map();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Resolve a filled mark's paint: the pattern url when the engine assigned a
|
|
67
|
-
* fill pattern, the plain/gradient fill otherwise.
|
|
68
|
-
*/
|
|
69
|
-
function resolveFillOrPattern(
|
|
70
|
-
fill: Parameters<typeof resolveMarkFill>[0],
|
|
71
|
-
pattern: ResolvedFillPattern | undefined,
|
|
72
|
-
): string {
|
|
73
|
-
if (pattern) return resolvePatternFill(pattern, currentPatternMap);
|
|
74
|
-
return resolveMarkFill(fill, currentGradientMap);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Stamp animation index attributes on a mark element when animation is enabled.
|
|
79
|
-
* Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
|
|
80
|
-
* (for CSS calc-based stagger delay).
|
|
81
|
-
*/
|
|
82
|
-
function stampAnimationAttrs(
|
|
83
|
-
el: SVGElement,
|
|
84
|
-
mark: { animationIndex?: number; key?: string },
|
|
85
|
-
fallbackIndex: number,
|
|
86
|
-
): void {
|
|
87
|
-
// Stamp stable identity key for data-update transitions (always, not just enter)
|
|
88
|
-
if (mark.key) {
|
|
89
|
-
el.setAttribute('data-key', mark.key);
|
|
90
|
-
}
|
|
91
|
-
if (!currentAnimation?.enter) return;
|
|
92
|
-
const idx = mark.animationIndex ?? fallbackIndex;
|
|
93
|
-
el.setAttribute('data-animation-index', String(idx));
|
|
94
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
|
|
98
|
-
|
|
99
|
-
const markRenderers: Record<string, MarkRenderer<Mark>> = {};
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Register a mark renderer for a specific mark type.
|
|
103
|
-
* Built-in renderers are registered below for all chart types.
|
|
104
|
-
*/
|
|
105
|
-
export function registerMarkRenderer<T extends Mark>(
|
|
106
|
-
type: T['type'],
|
|
107
|
-
renderer: MarkRenderer<T>,
|
|
108
|
-
): void {
|
|
109
|
-
markRenderers[type] = renderer as MarkRenderer<Mark>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
113
|
-
const g = createSVGElement('g');
|
|
114
|
-
g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
|
|
115
|
-
g.setAttribute('class', 'oc-mark oc-mark-line');
|
|
116
|
-
stampAnimationAttrs(g, mark, index);
|
|
117
|
-
|
|
118
|
-
if (mark.points.length > 1) {
|
|
119
|
-
const path = createSVGElement('path');
|
|
120
|
-
// Use the pre-computed D3 curve path when available (smooth monotone),
|
|
121
|
-
// otherwise fall back to straight M/L segments.
|
|
122
|
-
const d =
|
|
123
|
-
mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
124
|
-
setAttrs(path, {
|
|
125
|
-
d,
|
|
126
|
-
fill: 'none',
|
|
127
|
-
stroke: mark.stroke,
|
|
128
|
-
'stroke-width': mark.strokeWidth,
|
|
129
|
-
});
|
|
130
|
-
if (mark.strokeDasharray) {
|
|
131
|
-
path.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
132
|
-
}
|
|
133
|
-
if (mark.opacity != null) {
|
|
134
|
-
path.setAttribute('opacity', String(mark.opacity));
|
|
135
|
-
}
|
|
136
|
-
// Note: line drawing animation is handled via CSS clip-path on the group,
|
|
137
|
-
// no inline dasharray/dashoffset needed.
|
|
138
|
-
g.appendChild(path);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Render end-of-line label if present and visible
|
|
142
|
-
if (mark.label?.visible) {
|
|
143
|
-
const label = createSVGElement('text');
|
|
144
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
145
|
-
if (mark.seriesKey) {
|
|
146
|
-
label.setAttribute('data-series', mark.seriesKey);
|
|
147
|
-
}
|
|
148
|
-
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
149
|
-
applyTextStyle(label, mark.label.style);
|
|
150
|
-
label.textContent = mark.label.text;
|
|
151
|
-
g.appendChild(label);
|
|
152
|
-
|
|
153
|
-
// Render connector line if label was offset from anchor
|
|
154
|
-
if (mark.label.connector) {
|
|
155
|
-
const connector = createSVGElement('line');
|
|
156
|
-
connector.setAttribute('class', 'oc-mark-connector');
|
|
157
|
-
setAttrs(connector, {
|
|
158
|
-
x1: mark.label.connector.from.x,
|
|
159
|
-
y1: mark.label.connector.from.y,
|
|
160
|
-
x2: mark.label.connector.to.x,
|
|
161
|
-
y2: mark.label.connector.to.y,
|
|
162
|
-
stroke: mark.label.connector.stroke,
|
|
163
|
-
'stroke-width': 1,
|
|
164
|
-
'stroke-opacity': 0.5,
|
|
165
|
-
});
|
|
166
|
-
g.appendChild(connector);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return g;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
174
|
-
const g = createSVGElement('g');
|
|
175
|
-
g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
|
|
176
|
-
g.setAttribute('class', 'oc-mark oc-mark-area');
|
|
177
|
-
stampAnimationAttrs(g, mark, index);
|
|
178
|
-
|
|
179
|
-
if (mark.path) {
|
|
180
|
-
// Area fill: the full closed shape (top line + baseline), no stroke
|
|
181
|
-
const fill = createSVGElement('path');
|
|
182
|
-
setAttrs(fill, {
|
|
183
|
-
d: mark.path,
|
|
184
|
-
fill: resolveFillOrPattern(mark.fill, mark.pattern),
|
|
185
|
-
'fill-opacity': mark.fillOpacity,
|
|
186
|
-
stroke: 'none',
|
|
187
|
-
});
|
|
188
|
-
g.appendChild(fill);
|
|
189
|
-
|
|
190
|
-
// Top-line stroke: only along the data points, not the baseline
|
|
191
|
-
if (mark.stroke && mark.topPath) {
|
|
192
|
-
const strokePath = createSVGElement('path');
|
|
193
|
-
strokePath.setAttribute('class', 'oc-area-top');
|
|
194
|
-
setAttrs(strokePath, {
|
|
195
|
-
d: mark.topPath,
|
|
196
|
-
fill: 'none',
|
|
197
|
-
stroke: mark.stroke,
|
|
198
|
-
'stroke-width': mark.strokeWidth ?? 1,
|
|
199
|
-
});
|
|
200
|
-
// Note: area drawing animation is handled via CSS clip-path on the group,
|
|
201
|
-
// no inline dasharray/dashoffset needed.
|
|
202
|
-
g.appendChild(strokePath);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return g;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Build an SVG path describing a rectangle with selectively rounded corners.
|
|
211
|
-
* Used by stacked segments where only the leading edge (top of a vertical
|
|
212
|
-
* stack, right of a horizontal stack) should round so the seams between
|
|
213
|
-
* adjacent segments stay flush.
|
|
214
|
-
*/
|
|
215
|
-
export function rectPathWithCorners(
|
|
216
|
-
mark: RectMark,
|
|
217
|
-
sides: NonNullable<RectMark['cornerRadiusSides']>,
|
|
218
|
-
): string {
|
|
219
|
-
const { x, y, width: w, height: h } = mark;
|
|
220
|
-
// Clamp the radius so it never exceeds half of the shorter side, otherwise
|
|
221
|
-
// the arcs would overlap and the path would render as a degenerate shape.
|
|
222
|
-
const r = Math.max(0, Math.min(mark.cornerRadius ?? 0, w / 2, h / 2));
|
|
223
|
-
const tl = sides.tl ? r : 0;
|
|
224
|
-
const tr = sides.tr ? r : 0;
|
|
225
|
-
const br = sides.br ? r : 0;
|
|
226
|
-
const bl = sides.bl ? r : 0;
|
|
227
|
-
return [
|
|
228
|
-
`M${x + tl},${y}`,
|
|
229
|
-
`H${x + w - tr}`,
|
|
230
|
-
tr ? `A${tr},${tr} 0 0 1 ${x + w},${y + tr}` : '',
|
|
231
|
-
`V${y + h - br}`,
|
|
232
|
-
br ? `A${br},${br} 0 0 1 ${x + w - br},${y + h}` : '',
|
|
233
|
-
`H${x + bl}`,
|
|
234
|
-
bl ? `A${bl},${bl} 0 0 1 ${x},${y + h - bl}` : '',
|
|
235
|
-
`V${y + tl}`,
|
|
236
|
-
tl ? `A${tl},${tl} 0 0 1 ${x + tl},${y}` : '',
|
|
237
|
-
'Z',
|
|
238
|
-
]
|
|
239
|
-
.filter(Boolean)
|
|
240
|
-
.join(' ');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
244
|
-
const g = createSVGElement('g');
|
|
245
|
-
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
246
|
-
g.setAttribute('class', 'oc-mark oc-mark-rect');
|
|
247
|
-
stampAnimationAttrs(g, mark, index);
|
|
248
|
-
// Use engine-provided orientation for animation direction
|
|
249
|
-
if (currentAnimation?.enter && mark.orient === 'horizontal') {
|
|
250
|
-
g.setAttribute('data-orient', 'horizontal');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// When `cornerRadiusSides` selects a subset of corners (e.g. top-only for
|
|
254
|
-
// the topmost segment of a stacked column), SVG's `rx`/`ry` won't do —
|
|
255
|
-
// it rounds all four corners or none. Emit a `<path>` with per-corner
|
|
256
|
-
// arcs in that case so the stacked segments stay flush at the seam.
|
|
257
|
-
const sides = mark.cornerRadiusSides;
|
|
258
|
-
const partialCorners =
|
|
259
|
-
!!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
|
|
260
|
-
const shapeEl = partialCorners ? createSVGElement('path') : createSVGElement('rect');
|
|
261
|
-
if (partialCorners) {
|
|
262
|
-
shapeEl.setAttribute('d', rectPathWithCorners(mark, sides));
|
|
263
|
-
} else {
|
|
264
|
-
setAttrs(shapeEl, {
|
|
265
|
-
x: mark.x,
|
|
266
|
-
y: mark.y,
|
|
267
|
-
width: mark.width,
|
|
268
|
-
height: mark.height,
|
|
269
|
-
});
|
|
270
|
-
if (mark.cornerRadius) {
|
|
271
|
-
setAttrs(shapeEl, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
shapeEl.setAttribute('fill', resolveFillOrPattern(mark.fill, mark.pattern));
|
|
275
|
-
if (mark.stroke) {
|
|
276
|
-
shapeEl.setAttribute('stroke', mark.stroke);
|
|
277
|
-
}
|
|
278
|
-
if (mark.strokeWidth) {
|
|
279
|
-
shapeEl.setAttribute('stroke-width', String(mark.strokeWidth));
|
|
280
|
-
}
|
|
281
|
-
g.appendChild(shapeEl);
|
|
282
|
-
|
|
283
|
-
// Labels for rect marks are rendered in a dedicated overlay group by
|
|
284
|
-
// renderMarks() so they always paint above all bars in SVG z-order.
|
|
285
|
-
// See the two-pass logic in renderMarks() below.
|
|
286
|
-
|
|
287
|
-
return g;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
291
|
-
const g = createSVGElement('g');
|
|
292
|
-
g.setAttribute('data-mark-id', `arc-${index}`);
|
|
293
|
-
g.setAttribute('class', 'oc-mark oc-mark-arc');
|
|
294
|
-
g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
|
|
295
|
-
stampAnimationAttrs(g, mark, index);
|
|
296
|
-
|
|
297
|
-
const path = createSVGElement('path');
|
|
298
|
-
setAttrs(path, {
|
|
299
|
-
d: mark.path,
|
|
300
|
-
fill: resolveFillOrPattern(mark.fill, mark.pattern),
|
|
301
|
-
stroke: mark.stroke,
|
|
302
|
-
'stroke-width': mark.strokeWidth,
|
|
303
|
-
});
|
|
304
|
-
g.appendChild(path);
|
|
305
|
-
|
|
306
|
-
// Render label if present and visible
|
|
307
|
-
if (mark.label?.visible) {
|
|
308
|
-
const label = createSVGElement('text');
|
|
309
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
310
|
-
// Label position is in absolute coords, but we're in a translated group,
|
|
311
|
-
// so subtract the center offset
|
|
312
|
-
setAttrs(label, {
|
|
313
|
-
x: mark.label.x - mark.center.x,
|
|
314
|
-
y: mark.label.y - mark.center.y,
|
|
315
|
-
});
|
|
316
|
-
applyTextStyle(label, mark.label.style);
|
|
317
|
-
label.textContent = mark.label.text;
|
|
318
|
-
g.appendChild(label);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return g;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
325
|
-
const circle = createSVGElement('circle');
|
|
326
|
-
circle.setAttribute('data-mark-id', `point-${index}`);
|
|
327
|
-
circle.setAttribute('class', 'oc-mark oc-mark-point');
|
|
328
|
-
stampAnimationAttrs(circle, mark, index);
|
|
329
|
-
|
|
330
|
-
setAttrs(circle, {
|
|
331
|
-
cx: mark.cx,
|
|
332
|
-
cy: mark.cy,
|
|
333
|
-
r: mark.r,
|
|
334
|
-
fill: resolveMarkFill(mark.fill, currentGradientMap),
|
|
335
|
-
stroke: mark.stroke,
|
|
336
|
-
'stroke-width': mark.strokeWidth,
|
|
337
|
-
});
|
|
338
|
-
if (mark.fillOpacity !== undefined) {
|
|
339
|
-
circle.setAttribute('fill-opacity', String(mark.fillOpacity));
|
|
340
|
-
}
|
|
341
|
-
return circle;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
345
|
-
const text = createSVGElement('text');
|
|
346
|
-
text.setAttribute('data-mark-id', `textMark-${index}`);
|
|
347
|
-
text.setAttribute('class', 'oc-mark oc-mark-text');
|
|
348
|
-
stampAnimationAttrs(text, mark, index);
|
|
349
|
-
|
|
350
|
-
setAttrs(text, {
|
|
351
|
-
x: mark.x,
|
|
352
|
-
y: mark.y,
|
|
353
|
-
'font-size': mark.fontSize,
|
|
354
|
-
'text-anchor': mark.textAnchor,
|
|
355
|
-
});
|
|
356
|
-
// Only when set: calendar and parliament text marks hand-compute their y
|
|
357
|
-
// against the default alphabetic baseline, so stamping one would shift them.
|
|
358
|
-
if (mark.dominantBaseline) {
|
|
359
|
-
text.setAttribute('dominant-baseline', mark.dominantBaseline);
|
|
360
|
-
}
|
|
361
|
-
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
|
|
362
|
-
if (mark.fontWeight) {
|
|
363
|
-
text.setAttribute('font-weight', String(mark.fontWeight));
|
|
364
|
-
}
|
|
365
|
-
if (mark.fontFamily) {
|
|
366
|
-
text.setAttribute('font-family', mark.fontFamily);
|
|
367
|
-
}
|
|
368
|
-
if (mark.angle) {
|
|
369
|
-
text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
|
|
370
|
-
}
|
|
371
|
-
text.textContent = mark.text;
|
|
372
|
-
return text;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
376
|
-
const line = createSVGElement('line');
|
|
377
|
-
line.setAttribute('data-mark-id', `rule-${index}`);
|
|
378
|
-
line.setAttribute('class', 'oc-mark oc-mark-rule');
|
|
379
|
-
stampAnimationAttrs(line, mark, index);
|
|
380
|
-
|
|
381
|
-
setAttrs(line, {
|
|
382
|
-
x1: mark.x1,
|
|
383
|
-
y1: mark.y1,
|
|
384
|
-
x2: mark.x2,
|
|
385
|
-
y2: mark.y2,
|
|
386
|
-
stroke: mark.stroke,
|
|
387
|
-
'stroke-width': mark.strokeWidth,
|
|
388
|
-
});
|
|
389
|
-
if (mark.strokeDasharray) {
|
|
390
|
-
line.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
391
|
-
}
|
|
392
|
-
if (mark.opacity != null) {
|
|
393
|
-
line.setAttribute('opacity', String(mark.opacity));
|
|
394
|
-
}
|
|
395
|
-
return line;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
|
|
399
|
-
const line = createSVGElement('line');
|
|
400
|
-
line.setAttribute('data-mark-id', `tick-${index}`);
|
|
401
|
-
line.setAttribute('class', 'oc-mark oc-mark-tick');
|
|
402
|
-
stampAnimationAttrs(line, mark, index);
|
|
403
|
-
|
|
404
|
-
// Tick is a short line segment centered at (x, y)
|
|
405
|
-
const half = mark.length / 2;
|
|
406
|
-
if (mark.orient === 'vertical') {
|
|
407
|
-
setAttrs(line, {
|
|
408
|
-
x1: mark.x,
|
|
409
|
-
y1: mark.y - half,
|
|
410
|
-
x2: mark.x,
|
|
411
|
-
y2: mark.y + half,
|
|
412
|
-
stroke: mark.stroke,
|
|
413
|
-
'stroke-width': mark.strokeWidth,
|
|
414
|
-
});
|
|
415
|
-
} else {
|
|
416
|
-
setAttrs(line, {
|
|
417
|
-
x1: mark.x - half,
|
|
418
|
-
y1: mark.y,
|
|
419
|
-
x2: mark.x + half,
|
|
420
|
-
y2: mark.y,
|
|
421
|
-
stroke: mark.stroke,
|
|
422
|
-
'stroke-width': mark.strokeWidth,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (mark.opacity != null) {
|
|
427
|
-
line.setAttribute('opacity', String(mark.opacity));
|
|
428
|
-
}
|
|
429
|
-
return line;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Register built-in renderers
|
|
433
|
-
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
434
|
-
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
435
|
-
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
436
|
-
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
437
|
-
registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
|
|
438
|
-
registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
|
|
439
|
-
registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
|
|
440
|
-
registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Render a single mark to an SVG element without appending it anywhere.
|
|
444
|
-
* Used by the transition module to create ghost elements for exiting marks.
|
|
445
|
-
*/
|
|
446
|
-
export function renderSingleMark(mark: Mark, index: number): SVGElement | undefined {
|
|
447
|
-
const renderer = markRenderers[mark.type];
|
|
448
|
-
if (!renderer) return undefined;
|
|
449
|
-
return renderer(mark, index);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/** Extract series name from a mark for legend toggle matching. */
|
|
453
|
-
function getMarkSeries(mark: Mark): string | undefined {
|
|
454
|
-
// Line and area marks have an explicit seriesKey
|
|
455
|
-
if (mark.type === 'line' || mark.type === 'area') {
|
|
456
|
-
return mark.seriesKey;
|
|
457
|
-
}
|
|
458
|
-
// For arc marks, the category name is the first part of the aria label (before ':')
|
|
459
|
-
if (mark.type === 'arc') {
|
|
460
|
-
return mark.aria.label?.split(':')[0]?.trim();
|
|
461
|
-
}
|
|
462
|
-
// For rect/point, the aria label may be "category: value" or "category, group: value".
|
|
463
|
-
// The series name is the category part (before the colon).
|
|
464
|
-
if (mark.aria?.label) {
|
|
465
|
-
const beforeColon = mark.aria.label.split(':')[0]?.trim();
|
|
466
|
-
if (beforeColon) return beforeColon;
|
|
467
|
-
}
|
|
468
|
-
return undefined;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Render chart marks into `parent`.
|
|
473
|
-
*
|
|
474
|
-
* Returns an optional overlay group containing rect mark value labels.
|
|
475
|
-
* The caller should append this group to the outer SVG (outside any clip path)
|
|
476
|
-
* so tall labels above near-full-height bars are not clipped by the chart-area
|
|
477
|
-
* clip path that constrains the marks themselves.
|
|
478
|
-
*/
|
|
479
|
-
export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement | undefined {
|
|
480
|
-
const g = createSVGElement('g');
|
|
481
|
-
g.setAttribute('class', 'oc-marks');
|
|
482
|
-
|
|
483
|
-
for (let i = 0; i < layout.marks.length; i++) {
|
|
484
|
-
const mark = layout.marks[i];
|
|
485
|
-
const renderer = markRenderers[mark.type];
|
|
486
|
-
if (!renderer) continue;
|
|
487
|
-
|
|
488
|
-
const el = renderer(mark, i);
|
|
489
|
-
// Decorative marks (e.g. sparkline endpoint dots) are hidden from
|
|
490
|
-
// assistive tech because they duplicate an existing data point.
|
|
491
|
-
if (mark.aria?.decorative) {
|
|
492
|
-
el.setAttribute('aria-hidden', 'true');
|
|
493
|
-
} else if (mark.aria?.label) {
|
|
494
|
-
el.setAttribute('aria-label', mark.aria.label);
|
|
495
|
-
}
|
|
496
|
-
// Add data-series attribute for legend toggle matching
|
|
497
|
-
const series = getMarkSeries(mark);
|
|
498
|
-
if (series) {
|
|
499
|
-
el.setAttribute('data-series', series);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// For stacked segments, set stack position for sequential animation chaining.
|
|
503
|
-
// stackPos is computed by the engine on RectMark during compilation.
|
|
504
|
-
if (currentAnimation?.enter && mark.type === 'rect') {
|
|
505
|
-
const rect = mark as RectMark;
|
|
506
|
-
if (rect.stackGroup && rect.stackPos !== undefined) {
|
|
507
|
-
el.setAttribute('data-stack-pos', String(rect.stackPos));
|
|
508
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
509
|
-
'--oc-stack-pos',
|
|
510
|
-
String(rect.stackPos),
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
g.appendChild(el);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
parent.appendChild(g);
|
|
519
|
-
|
|
520
|
-
// Second pass: render rect mark labels in a dedicated overlay group so they
|
|
521
|
-
// always paint above all bars in SVG z-order. On grouped column charts, a
|
|
522
|
-
// later-rendered bar can otherwise paint over a label from an adjacent column
|
|
523
|
-
// when the label extends into a neighbor's airspace.
|
|
524
|
-
//
|
|
525
|
-
// Each overlay label gets --oc-mark-index stamped directly on it so the CSS
|
|
526
|
-
// animation delay calc (which reads that property) works without inheriting
|
|
527
|
-
// from a parent group.
|
|
528
|
-
let labelsGroup: SVGElement | undefined;
|
|
529
|
-
for (let i = 0; i < layout.marks.length; i++) {
|
|
530
|
-
const mark = layout.marks[i];
|
|
531
|
-
if (mark.type !== 'rect') continue;
|
|
532
|
-
const rect = mark as RectMark;
|
|
533
|
-
if (!rect.label?.visible) continue;
|
|
534
|
-
|
|
535
|
-
if (!labelsGroup) {
|
|
536
|
-
labelsGroup = createSVGElement('g');
|
|
537
|
-
labelsGroup.setAttribute('class', 'oc-mark-labels');
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const label = createSVGElement('text');
|
|
541
|
-
label.setAttribute('class', 'oc-mark-label');
|
|
542
|
-
setAttrs(label, { x: rect.label.x, y: rect.label.y });
|
|
543
|
-
applyTextStyle(label, rect.label.style);
|
|
544
|
-
label.textContent = rect.label.text;
|
|
545
|
-
|
|
546
|
-
// Stamp animation index so the CSS stagger delay works for overlay labels
|
|
547
|
-
// (they're not children of a .oc-mark-rect group that carries --oc-mark-index).
|
|
548
|
-
if (currentAnimation?.enter) {
|
|
549
|
-
const idx = rect.animationIndex ?? i;
|
|
550
|
-
label.setAttribute('data-animation-index', String(idx));
|
|
551
|
-
(label as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
552
|
-
'--oc-mark-index',
|
|
553
|
-
String(idx),
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
labelsGroup.appendChild(label);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
return labelsGroup;
|
|
561
|
-
}
|
package/src/renderers/metrics.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* KPI metric bar rendering. Emits a `<g class="oc-metrics">` group containing
|
|
3
|
-
* a label/value pair per cell. Visual styling lives in `chrome.css`
|
|
4
|
-
* (`.oc-metric-*` classes); this renderer only sets positions and structure.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
8
|
-
import { createSVGElement, setAttrs } from './svg-dom';
|
|
9
|
-
|
|
10
|
-
// Delta and secondary text run at a fraction of the primary value size. This
|
|
11
|
-
// preserves the editorial mock's proportion (12px delta against a 22px value)
|
|
12
|
-
// at any theme-driven value size, so scaling metricValue scales the whole cell.
|
|
13
|
-
const DELTA_SIZE_RATIO = 12 / 22;
|
|
14
|
-
|
|
15
|
-
export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
|
|
16
|
-
const bar = layout.metrics;
|
|
17
|
-
if (!bar || bar.cells.length === 0) return;
|
|
18
|
-
|
|
19
|
-
// Font sizes flow from the theme. Set them inline so they override the CSS
|
|
20
|
-
// defaults (.oc-metric-* in chrome.css) when a chart customizes the sizes.
|
|
21
|
-
const labelSize = layout.theme.fonts.sizes.metricLabel;
|
|
22
|
-
const valueSize = layout.theme.fonts.sizes.metricValue;
|
|
23
|
-
const deltaSize = Math.round(valueSize * DELTA_SIZE_RATIO);
|
|
24
|
-
|
|
25
|
-
const g = createSVGElement('g');
|
|
26
|
-
g.setAttribute('class', 'oc-metrics');
|
|
27
|
-
|
|
28
|
-
for (const cell of bar.cells) {
|
|
29
|
-
const label = createSVGElement('text');
|
|
30
|
-
label.setAttribute('class', 'oc-metric-label');
|
|
31
|
-
setAttrs(label, { x: cell.x, y: cell.labelY, 'font-size': labelSize });
|
|
32
|
-
label.textContent = cell.metric.label.toUpperCase();
|
|
33
|
-
g.appendChild(label);
|
|
34
|
-
|
|
35
|
-
const value = createSVGElement('text');
|
|
36
|
-
value.setAttribute('class', 'oc-metric-value');
|
|
37
|
-
setAttrs(value, { x: cell.x, y: cell.valueY, 'font-size': valueSize });
|
|
38
|
-
value.textContent = cell.metric.value;
|
|
39
|
-
|
|
40
|
-
if (cell.metric.delta) {
|
|
41
|
-
const delta = createSVGElement('tspan');
|
|
42
|
-
const tone = cell.metric.deltaTone ?? 'up';
|
|
43
|
-
delta.setAttribute('class', tone === 'down' ? 'oc-metric-delta-down' : 'oc-metric-delta-up');
|
|
44
|
-
delta.setAttribute('dx', '8');
|
|
45
|
-
delta.setAttribute('font-size', String(deltaSize));
|
|
46
|
-
delta.textContent = cell.metric.delta;
|
|
47
|
-
value.appendChild(delta);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (cell.metric.secondary) {
|
|
51
|
-
const secondary = createSVGElement('tspan');
|
|
52
|
-
secondary.setAttribute('class', 'oc-metric-secondary');
|
|
53
|
-
secondary.setAttribute('dx', '6');
|
|
54
|
-
secondary.setAttribute('font-size', String(deltaSize));
|
|
55
|
-
secondary.textContent = cell.metric.secondary;
|
|
56
|
-
value.appendChild(secondary);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
g.appendChild(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
parent.appendChild(g);
|
|
63
|
-
}
|
package/src/renderers/svg-dom.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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 { TextStyle } from '@opendata-ai/openchart-core';
|
|
8
|
-
|
|
9
|
-
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
10
|
-
export const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
11
|
-
|
|
12
|
-
export function createSVGElement(tag: string): SVGElement {
|
|
13
|
-
return document.createElementNS(SVG_NS, tag);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
|
|
17
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
18
|
-
el.setAttribute(key, String(value));
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
23
|
-
// Use inline styles so engine-computed values take priority over CSS class
|
|
24
|
-
// defaults (e.g. .oc-title { font-size: var(--oc-title-size) } would otherwise
|
|
25
|
-
// override the responsive scaling applied by the chrome layout).
|
|
26
|
-
const inline = (el as SVGElement & ElementCSSInlineStyle).style;
|
|
27
|
-
inline.setProperty('fill', style.fill);
|
|
28
|
-
inline.setProperty('font-size', `${style.fontSize}px`);
|
|
29
|
-
inline.setProperty('font-weight', String(style.fontWeight));
|
|
30
|
-
inline.setProperty('font-family', style.fontFamily);
|
|
31
|
-
if (style.textAnchor) {
|
|
32
|
-
el.setAttribute('text-anchor', style.textAnchor);
|
|
33
|
-
}
|
|
34
|
-
if (style.dominantBaseline) {
|
|
35
|
-
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
36
|
-
}
|
|
37
|
-
if (style.fontVariant) {
|
|
38
|
-
el.setAttribute('font-variant', style.fontVariant);
|
|
39
|
-
}
|
|
40
|
-
}
|