@opendata-ai/openchart-vanilla 6.3.0 → 6.5.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 +53 -5
- package/dist/index.js +897 -168
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -764
- package/package.json +3 -3
- package/src/__tests__/animation.test.ts +358 -0
- package/src/__tests__/edit-events.test.ts +35 -35
- package/src/__tests__/events.test.ts +7 -7
- package/src/__tests__/export.test.ts +1 -1
- package/src/__tests__/mount.test.ts +10 -10
- package/src/__tests__/selection-events.test.ts +869 -0
- package/src/__tests__/svg-renderer.test.ts +67 -67
- package/src/__tests__/table-keyboard.test.ts +18 -18
- package/src/__tests__/table-mount.test.ts +138 -17
- package/src/__tests__/tooltip.test.ts +12 -12
- package/src/animation.ts +75 -0
- package/src/graph/__tests__/graph-mount.test.ts +16 -16
- package/src/graph-mount.ts +18 -18
- package/src/index.ts +3 -1
- package/src/mount.ts +668 -30
- package/src/renderers/table-cells.ts +11 -9
- package/src/svg-renderer.ts +164 -54
- package/src/table-keyboard.ts +5 -5
- package/src/table-mount.ts +34 -11
- package/src/table-renderer.ts +70 -39
- package/src/text-edit-overlay.ts +255 -0
- package/src/tooltip.ts +8 -8
|
@@ -148,6 +148,7 @@ export function renderTextCell(cell: TextTableCell): HTMLTableCellElement {
|
|
|
148
148
|
/** Render a heatmap-colored cell. */
|
|
149
149
|
export function renderHeatmapCell(cell: HeatmapTableCell): HTMLTableCellElement {
|
|
150
150
|
const td = document.createElement('td');
|
|
151
|
+
td.className = 'oc-table-heatmap';
|
|
151
152
|
td.textContent = cell.formattedValue;
|
|
152
153
|
applyCellStyle(td, cell);
|
|
153
154
|
return td;
|
|
@@ -156,6 +157,7 @@ export function renderHeatmapCell(cell: HeatmapTableCell): HTMLTableCellElement
|
|
|
156
157
|
/** Render a category-colored cell. */
|
|
157
158
|
export function renderCategoryCell(cell: CategoryTableCell): HTMLTableCellElement {
|
|
158
159
|
const td = document.createElement('td');
|
|
160
|
+
td.className = 'oc-table-category';
|
|
159
161
|
td.textContent = cell.formattedValue;
|
|
160
162
|
applyCellStyle(td, cell);
|
|
161
163
|
return td;
|
|
@@ -164,18 +166,18 @@ export function renderCategoryCell(cell: CategoryTableCell): HTMLTableCellElemen
|
|
|
164
166
|
/** Render a cell with an inline bar visualization. */
|
|
165
167
|
export function renderBarCell(cell: BarTableCell): HTMLTableCellElement {
|
|
166
168
|
const td = document.createElement('td');
|
|
167
|
-
td.className = '
|
|
169
|
+
td.className = 'oc-table-bar';
|
|
168
170
|
applyCellStyle(td, cell);
|
|
169
171
|
|
|
170
172
|
const fill = document.createElement('div');
|
|
171
|
-
fill.className = '
|
|
173
|
+
fill.className = 'oc-table-bar-fill';
|
|
172
174
|
fill.style.width = `${Math.round(cell.barWidth * 100)}%`;
|
|
173
175
|
fill.style.left = `${Math.round(cell.barOffset * 100)}%`;
|
|
174
176
|
fill.style.background = cell.barColor;
|
|
175
177
|
td.appendChild(fill);
|
|
176
178
|
|
|
177
179
|
const valueSpan = document.createElement('span');
|
|
178
|
-
valueSpan.className = '
|
|
180
|
+
valueSpan.className = 'oc-table-bar-value';
|
|
179
181
|
valueSpan.textContent = cell.formattedValue;
|
|
180
182
|
td.appendChild(valueSpan);
|
|
181
183
|
|
|
@@ -214,7 +216,7 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
const wrapper = document.createElement('span');
|
|
217
|
-
wrapper.className = '
|
|
219
|
+
wrapper.className = 'oc-table-sparkline';
|
|
218
220
|
|
|
219
221
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
220
222
|
|
|
@@ -266,14 +268,14 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
|
|
|
266
268
|
const dotSize = 5;
|
|
267
269
|
|
|
268
270
|
const startDot = document.createElement('span');
|
|
269
|
-
startDot.className = '
|
|
271
|
+
startDot.className = 'oc-table-sparkline-dot';
|
|
270
272
|
startDot.style.left = '0';
|
|
271
273
|
startDot.style.top = `${firstY - dotSize / 2}px`;
|
|
272
274
|
startDot.style.background = sparklineData.color;
|
|
273
275
|
wrapper.appendChild(startDot);
|
|
274
276
|
|
|
275
277
|
const endDot = document.createElement('span');
|
|
276
|
-
endDot.className = '
|
|
278
|
+
endDot.className = 'oc-table-sparkline-dot';
|
|
277
279
|
endDot.style.right = '0';
|
|
278
280
|
endDot.style.top = `${lastY - dotSize / 2}px`;
|
|
279
281
|
endDot.style.background = sparklineData.color;
|
|
@@ -281,7 +283,7 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
|
|
|
281
283
|
|
|
282
284
|
// HTML labels below the SVG, positioned at left and right edges
|
|
283
285
|
const labelsRow = document.createElement('span');
|
|
284
|
-
labelsRow.className = '
|
|
286
|
+
labelsRow.className = 'oc-table-sparkline-labels';
|
|
285
287
|
labelsRow.style.color = sparklineData.color;
|
|
286
288
|
|
|
287
289
|
const startLabel = document.createElement('span');
|
|
@@ -378,7 +380,7 @@ export function renderImageCell(cell: ImageTableCell): HTMLTableCellElement {
|
|
|
378
380
|
applyCellStyle(td, cell);
|
|
379
381
|
|
|
380
382
|
const wrapper = document.createElement('span');
|
|
381
|
-
wrapper.className = `
|
|
383
|
+
wrapper.className = `oc-table-image${cell.rounded ? ' oc-table-image-rounded' : ''}`;
|
|
382
384
|
|
|
383
385
|
const img = document.createElement('img');
|
|
384
386
|
img.src = cell.src;
|
|
@@ -399,7 +401,7 @@ export function renderFlagCell(cell: FlagTableCell): HTMLTableCellElement {
|
|
|
399
401
|
applyCellStyle(td, cell);
|
|
400
402
|
|
|
401
403
|
const span = document.createElement('span');
|
|
402
|
-
span.className = '
|
|
404
|
+
span.className = 'oc-table-flag';
|
|
403
405
|
span.setAttribute('role', 'img');
|
|
404
406
|
|
|
405
407
|
if (cell.countryCode && cell.countryCode.length === 2) {
|
package/src/svg-renderer.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
Point,
|
|
21
21
|
PointMark,
|
|
22
22
|
RectMark,
|
|
23
|
+
ResolvedAnimation,
|
|
23
24
|
ResolvedAnnotation,
|
|
24
25
|
ResolvedChromeElement,
|
|
25
26
|
RuleMarkLayout,
|
|
@@ -28,9 +29,38 @@ import type {
|
|
|
28
29
|
TickMarkLayout,
|
|
29
30
|
} from '@opendata-ai/openchart-core';
|
|
30
31
|
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
32
|
+
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
31
33
|
|
|
32
34
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Module-level animation state. Set by renderChartSVG before rendering marks
|
|
38
|
+
* so mark renderers can read it without changing their function signatures.
|
|
39
|
+
*/
|
|
40
|
+
let currentAnimation: ResolvedAnimation | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stamp animation index attributes on a mark element when animation is enabled.
|
|
44
|
+
* Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
|
|
45
|
+
* (for CSS calc-based stagger delay).
|
|
46
|
+
*/
|
|
47
|
+
function stampAnimationAttrs(
|
|
48
|
+
el: SVGElement,
|
|
49
|
+
mark: { animationIndex?: number },
|
|
50
|
+
fallbackIndex: number,
|
|
51
|
+
): void {
|
|
52
|
+
if (!currentAnimation?.enabled) return;
|
|
53
|
+
const idx = mark.animationIndex ?? fallbackIndex;
|
|
54
|
+
el.setAttribute('data-animation-index', String(idx));
|
|
55
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** CSS easing preset map for inline style custom properties. */
|
|
59
|
+
const EASE_VAR_MAP: Record<string, string> = {
|
|
60
|
+
smooth: 'var(--oc-ease-smooth)',
|
|
61
|
+
snappy: 'var(--oc-ease-snappy)',
|
|
62
|
+
};
|
|
63
|
+
|
|
34
64
|
/**
|
|
35
65
|
* Compute the vertical extent of x-axis labels below the chart area.
|
|
36
66
|
* Accounts for rotated tick labels which need more vertical space.
|
|
@@ -77,7 +107,7 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
|
77
107
|
'font-weight': style.fontWeight,
|
|
78
108
|
});
|
|
79
109
|
// Use inline style for fill so it takes priority over CSS class defaults
|
|
80
|
-
// (e.g. .
|
|
110
|
+
// (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
|
|
81
111
|
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
|
|
82
112
|
if (style.textAnchor) {
|
|
83
113
|
el.setAttribute('text-anchor', style.textAnchor);
|
|
@@ -174,16 +204,16 @@ function renderChromeElement(
|
|
|
174
204
|
|
|
175
205
|
function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
176
206
|
const g = createSVGElement('g');
|
|
177
|
-
g.setAttribute('class', '
|
|
207
|
+
g.setAttribute('class', 'oc-chrome');
|
|
178
208
|
|
|
179
209
|
const { chrome } = layout;
|
|
180
210
|
|
|
181
211
|
// Top chrome: render at their stored y positions (already absolute)
|
|
182
212
|
if (chrome.title) {
|
|
183
|
-
renderChromeElement(g, chrome.title, '
|
|
213
|
+
renderChromeElement(g, chrome.title, 'oc-title', 'title');
|
|
184
214
|
}
|
|
185
215
|
if (chrome.subtitle) {
|
|
186
|
-
renderChromeElement(g, chrome.subtitle, '
|
|
216
|
+
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
// Bottom chrome starts below x-axis labels/title, not at chart area bottom.
|
|
@@ -194,7 +224,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
194
224
|
renderChromeElement(
|
|
195
225
|
g,
|
|
196
226
|
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
197
|
-
'
|
|
227
|
+
'oc-source',
|
|
198
228
|
'source',
|
|
199
229
|
);
|
|
200
230
|
}
|
|
@@ -202,7 +232,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
202
232
|
renderChromeElement(
|
|
203
233
|
g,
|
|
204
234
|
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
205
|
-
'
|
|
235
|
+
'oc-byline',
|
|
206
236
|
'byline',
|
|
207
237
|
);
|
|
208
238
|
}
|
|
@@ -210,7 +240,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
210
240
|
renderChromeElement(
|
|
211
241
|
g,
|
|
212
242
|
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
213
|
-
'
|
|
243
|
+
'oc-footer',
|
|
214
244
|
'footer',
|
|
215
245
|
);
|
|
216
246
|
}
|
|
@@ -229,7 +259,7 @@ function renderAxis(
|
|
|
229
259
|
layout: ChartLayout,
|
|
230
260
|
): void {
|
|
231
261
|
const g = createSVGElement('g');
|
|
232
|
-
g.setAttribute('class', `
|
|
262
|
+
g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
|
|
233
263
|
|
|
234
264
|
const { area } = layout;
|
|
235
265
|
|
|
@@ -237,7 +267,7 @@ function renderAxis(
|
|
|
237
267
|
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
|
|
238
268
|
if (orientation === 'x') {
|
|
239
269
|
const line = createSVGElement('line');
|
|
240
|
-
line.setAttribute('class', '
|
|
270
|
+
line.setAttribute('class', 'oc-axis-line');
|
|
241
271
|
setAttrs(line, {
|
|
242
272
|
x1: axis.start.x,
|
|
243
273
|
y1: axis.start.y,
|
|
@@ -257,7 +287,7 @@ function renderAxis(
|
|
|
257
287
|
if (orientation === 'x') {
|
|
258
288
|
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
259
289
|
const label = createSVGElement('text');
|
|
260
|
-
label.setAttribute('class', '
|
|
290
|
+
label.setAttribute('class', 'oc-axis-tick');
|
|
261
291
|
|
|
262
292
|
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
263
293
|
// Rotated labels: anchor at the rotation pivot point
|
|
@@ -284,7 +314,7 @@ function renderAxis(
|
|
|
284
314
|
} else {
|
|
285
315
|
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
286
316
|
const label = createSVGElement('text');
|
|
287
|
-
label.setAttribute('class', '
|
|
317
|
+
label.setAttribute('class', 'oc-axis-tick');
|
|
288
318
|
setAttrs(label, {
|
|
289
319
|
x: area.x - 6,
|
|
290
320
|
y: tick.position,
|
|
@@ -300,7 +330,7 @@ function renderAxis(
|
|
|
300
330
|
// Gridlines (positions are also absolute from the scales)
|
|
301
331
|
for (const gridline of axis.gridlines) {
|
|
302
332
|
const gl = createSVGElement('line');
|
|
303
|
-
gl.setAttribute('class', '
|
|
333
|
+
gl.setAttribute('class', 'oc-gridline');
|
|
304
334
|
if (orientation === 'y') {
|
|
305
335
|
setAttrs(gl, {
|
|
306
336
|
x1: area.x,
|
|
@@ -328,7 +358,7 @@ function renderAxis(
|
|
|
328
358
|
// Axis label
|
|
329
359
|
if (axis.label && axis.labelStyle) {
|
|
330
360
|
const axisLabel = createSVGElement('text');
|
|
331
|
-
axisLabel.setAttribute('class', '
|
|
361
|
+
axisLabel.setAttribute('class', 'oc-axis-title');
|
|
332
362
|
applyTextStyle(axisLabel, axis.labelStyle);
|
|
333
363
|
axisLabel.textContent = axis.label;
|
|
334
364
|
|
|
@@ -401,7 +431,8 @@ export function registerMarkRenderer<T extends Mark>(
|
|
|
401
431
|
function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
402
432
|
const g = createSVGElement('g');
|
|
403
433
|
g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
|
|
404
|
-
g.setAttribute('class', '
|
|
434
|
+
g.setAttribute('class', 'oc-mark oc-mark-line');
|
|
435
|
+
stampAnimationAttrs(g, mark, index);
|
|
405
436
|
|
|
406
437
|
if (mark.points.length > 1) {
|
|
407
438
|
const path = createSVGElement('path');
|
|
@@ -421,13 +452,15 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
|
421
452
|
if (mark.opacity != null) {
|
|
422
453
|
path.setAttribute('opacity', String(mark.opacity));
|
|
423
454
|
}
|
|
455
|
+
// Note: line drawing animation is handled via CSS clip-path on the group,
|
|
456
|
+
// no inline dasharray/dashoffset needed.
|
|
424
457
|
g.appendChild(path);
|
|
425
458
|
}
|
|
426
459
|
|
|
427
460
|
// Render end-of-line label if present and visible
|
|
428
461
|
if (mark.label?.visible) {
|
|
429
462
|
const label = createSVGElement('text');
|
|
430
|
-
label.setAttribute('class', '
|
|
463
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
431
464
|
if (mark.seriesKey) {
|
|
432
465
|
label.setAttribute('data-series', mark.seriesKey);
|
|
433
466
|
}
|
|
@@ -439,7 +472,7 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
|
439
472
|
// Render connector line if label was offset from anchor
|
|
440
473
|
if (mark.label.connector) {
|
|
441
474
|
const connector = createSVGElement('line');
|
|
442
|
-
connector.setAttribute('class', '
|
|
475
|
+
connector.setAttribute('class', 'oc-mark-connector');
|
|
443
476
|
setAttrs(connector, {
|
|
444
477
|
x1: mark.label.connector.from.x,
|
|
445
478
|
y1: mark.label.connector.from.y,
|
|
@@ -459,7 +492,8 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
|
|
|
459
492
|
function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
460
493
|
const g = createSVGElement('g');
|
|
461
494
|
g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
|
|
462
|
-
g.setAttribute('class', '
|
|
495
|
+
g.setAttribute('class', 'oc-mark oc-mark-area');
|
|
496
|
+
stampAnimationAttrs(g, mark, index);
|
|
463
497
|
|
|
464
498
|
if (mark.path) {
|
|
465
499
|
// Area fill: the full closed shape (top line + baseline), no stroke
|
|
@@ -475,12 +509,15 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
|
475
509
|
// Top-line stroke: only along the data points, not the baseline
|
|
476
510
|
if (mark.stroke && mark.topPath) {
|
|
477
511
|
const strokePath = createSVGElement('path');
|
|
512
|
+
strokePath.setAttribute('class', 'oc-area-top');
|
|
478
513
|
setAttrs(strokePath, {
|
|
479
514
|
d: mark.topPath,
|
|
480
515
|
fill: 'none',
|
|
481
516
|
stroke: mark.stroke,
|
|
482
517
|
'stroke-width': mark.strokeWidth ?? 1,
|
|
483
518
|
});
|
|
519
|
+
// Note: area drawing animation is handled via CSS clip-path on the group,
|
|
520
|
+
// no inline dasharray/dashoffset needed.
|
|
484
521
|
g.appendChild(strokePath);
|
|
485
522
|
}
|
|
486
523
|
}
|
|
@@ -491,7 +528,12 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
|
491
528
|
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
492
529
|
const g = createSVGElement('g');
|
|
493
530
|
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
494
|
-
g.setAttribute('class', '
|
|
531
|
+
g.setAttribute('class', 'oc-mark oc-mark-rect');
|
|
532
|
+
stampAnimationAttrs(g, mark, index);
|
|
533
|
+
// Use engine-provided orientation for animation direction
|
|
534
|
+
if (currentAnimation?.enabled && mark.orient === 'horizontal') {
|
|
535
|
+
g.setAttribute('data-orient', 'horizontal');
|
|
536
|
+
}
|
|
495
537
|
|
|
496
538
|
const rect = createSVGElement('rect');
|
|
497
539
|
setAttrs(rect, {
|
|
@@ -515,7 +557,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
|
515
557
|
// Render value label if present and visible
|
|
516
558
|
if (mark.label?.visible) {
|
|
517
559
|
const label = createSVGElement('text');
|
|
518
|
-
label.setAttribute('class', '
|
|
560
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
519
561
|
setAttrs(label, { x: mark.label.x, y: mark.label.y });
|
|
520
562
|
applyTextStyle(label, mark.label.style);
|
|
521
563
|
label.textContent = mark.label.text;
|
|
@@ -528,8 +570,9 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
|
528
570
|
function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
529
571
|
const g = createSVGElement('g');
|
|
530
572
|
g.setAttribute('data-mark-id', `arc-${index}`);
|
|
531
|
-
g.setAttribute('class', '
|
|
573
|
+
g.setAttribute('class', 'oc-mark oc-mark-arc');
|
|
532
574
|
g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
|
|
575
|
+
stampAnimationAttrs(g, mark, index);
|
|
533
576
|
|
|
534
577
|
const path = createSVGElement('path');
|
|
535
578
|
setAttrs(path, {
|
|
@@ -543,7 +586,7 @@ function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
|
543
586
|
// Render label if present and visible
|
|
544
587
|
if (mark.label?.visible) {
|
|
545
588
|
const label = createSVGElement('text');
|
|
546
|
-
label.setAttribute('class', '
|
|
589
|
+
label.setAttribute('class', 'oc-mark-label');
|
|
547
590
|
// Label position is in absolute coords, but we're in a translated group,
|
|
548
591
|
// so subtract the center offset
|
|
549
592
|
setAttrs(label, {
|
|
@@ -561,7 +604,9 @@ function renderArcMark(mark: ArcMark, index: number): SVGElement {
|
|
|
561
604
|
function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
562
605
|
const circle = createSVGElement('circle');
|
|
563
606
|
circle.setAttribute('data-mark-id', `point-${index}`);
|
|
564
|
-
circle.setAttribute('class', '
|
|
607
|
+
circle.setAttribute('class', 'oc-mark oc-mark-point');
|
|
608
|
+
stampAnimationAttrs(circle, mark, index);
|
|
609
|
+
|
|
565
610
|
setAttrs(circle, {
|
|
566
611
|
cx: mark.cx,
|
|
567
612
|
cy: mark.cy,
|
|
@@ -579,7 +624,9 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
|
579
624
|
function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
580
625
|
const text = createSVGElement('text');
|
|
581
626
|
text.setAttribute('data-mark-id', `textMark-${index}`);
|
|
582
|
-
text.setAttribute('class', '
|
|
627
|
+
text.setAttribute('class', 'oc-mark oc-mark-text');
|
|
628
|
+
stampAnimationAttrs(text, mark, index);
|
|
629
|
+
|
|
583
630
|
setAttrs(text, {
|
|
584
631
|
x: mark.x,
|
|
585
632
|
y: mark.y,
|
|
@@ -603,7 +650,9 @@ function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
|
603
650
|
function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
604
651
|
const line = createSVGElement('line');
|
|
605
652
|
line.setAttribute('data-mark-id', `rule-${index}`);
|
|
606
|
-
line.setAttribute('class', '
|
|
653
|
+
line.setAttribute('class', 'oc-mark oc-mark-rule');
|
|
654
|
+
stampAnimationAttrs(line, mark, index);
|
|
655
|
+
|
|
607
656
|
setAttrs(line, {
|
|
608
657
|
x1: mark.x1,
|
|
609
658
|
y1: mark.y1,
|
|
@@ -624,7 +673,8 @@ function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
|
624
673
|
function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
|
|
625
674
|
const line = createSVGElement('line');
|
|
626
675
|
line.setAttribute('data-mark-id', `tick-${index}`);
|
|
627
|
-
line.setAttribute('class', '
|
|
676
|
+
line.setAttribute('class', 'oc-mark oc-mark-tick');
|
|
677
|
+
stampAnimationAttrs(line, mark, index);
|
|
628
678
|
|
|
629
679
|
// Tick is a short line segment centered at (x, y)
|
|
630
680
|
const half = mark.length / 2;
|
|
@@ -685,24 +735,38 @@ function getMarkSeries(mark: Mark): string | undefined {
|
|
|
685
735
|
|
|
686
736
|
function renderMarks(parent: SVGElement, layout: ChartLayout): void {
|
|
687
737
|
const g = createSVGElement('g');
|
|
688
|
-
g.setAttribute('class', '
|
|
738
|
+
g.setAttribute('class', 'oc-marks');
|
|
689
739
|
|
|
690
740
|
for (let i = 0; i < layout.marks.length; i++) {
|
|
691
741
|
const mark = layout.marks[i];
|
|
692
742
|
const renderer = markRenderers[mark.type];
|
|
693
|
-
if (renderer)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
743
|
+
if (!renderer) continue;
|
|
744
|
+
|
|
745
|
+
const el = renderer(mark, i);
|
|
746
|
+
// Add ARIA label if present
|
|
747
|
+
if (mark.aria?.label) {
|
|
748
|
+
el.setAttribute('aria-label', mark.aria.label);
|
|
749
|
+
}
|
|
750
|
+
// Add data-series attribute for legend toggle matching
|
|
751
|
+
const series = getMarkSeries(mark);
|
|
752
|
+
if (series) {
|
|
753
|
+
el.setAttribute('data-series', series);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// For stacked segments, set stack position for sequential animation chaining.
|
|
757
|
+
// stackPos is computed by the engine on RectMark during compilation.
|
|
758
|
+
if (currentAnimation?.enabled && mark.type === 'rect') {
|
|
759
|
+
const rect = mark as RectMark;
|
|
760
|
+
if (rect.stackGroup && rect.stackPos !== undefined) {
|
|
761
|
+
el.setAttribute('data-stack-pos', String(rect.stackPos));
|
|
762
|
+
(el as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
763
|
+
'--oc-stack-pos',
|
|
764
|
+
String(rect.stackPos),
|
|
765
|
+
);
|
|
703
766
|
}
|
|
704
|
-
g.appendChild(el);
|
|
705
767
|
}
|
|
768
|
+
|
|
769
|
+
g.appendChild(el);
|
|
706
770
|
}
|
|
707
771
|
|
|
708
772
|
parent.appendChild(g);
|
|
@@ -716,7 +780,7 @@ function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
|
|
|
716
780
|
if (layout.annotations.length === 0) return;
|
|
717
781
|
|
|
718
782
|
const g = createSVGElement('g');
|
|
719
|
-
g.setAttribute('class', '
|
|
783
|
+
g.setAttribute('class', 'oc-annotations');
|
|
720
784
|
|
|
721
785
|
// Annotations are already sorted by zIndex from the engine, so render in order
|
|
722
786
|
for (let i = 0; i < layout.annotations.length; i++) {
|
|
@@ -763,7 +827,7 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
|
|
|
763
827
|
const baseY = tipY - uy * arrowLen;
|
|
764
828
|
|
|
765
829
|
const path = createSVGElement('path');
|
|
766
|
-
path.setAttribute('class', '
|
|
830
|
+
path.setAttribute('class', 'oc-annotation-connector');
|
|
767
831
|
setAttrs(path, {
|
|
768
832
|
d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
|
|
769
833
|
fill: 'none',
|
|
@@ -777,7 +841,7 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
|
|
|
777
841
|
const py = ux;
|
|
778
842
|
|
|
779
843
|
const arrow = createSVGElement('polygon');
|
|
780
|
-
arrow.setAttribute('class', '
|
|
844
|
+
arrow.setAttribute('class', 'oc-annotation-connector');
|
|
781
845
|
setAttrs(arrow, {
|
|
782
846
|
points: [
|
|
783
847
|
`${to.x},${tipY}`,
|
|
@@ -791,13 +855,16 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
|
|
|
791
855
|
|
|
792
856
|
function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, index: number): void {
|
|
793
857
|
const g = createSVGElement('g');
|
|
794
|
-
g.setAttribute('class', `
|
|
858
|
+
g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`);
|
|
795
859
|
g.setAttribute('data-annotation-index', String(index));
|
|
860
|
+
if (annotation.id) {
|
|
861
|
+
g.setAttribute('data-annotation-id', annotation.id);
|
|
862
|
+
}
|
|
796
863
|
|
|
797
864
|
// Range rect
|
|
798
865
|
if (annotation.rect) {
|
|
799
866
|
const rect = createSVGElement('rect');
|
|
800
|
-
rect.setAttribute('class', '
|
|
867
|
+
rect.setAttribute('class', 'oc-annotation-range');
|
|
801
868
|
setAttrs(rect, {
|
|
802
869
|
x: annotation.rect.x,
|
|
803
870
|
y: annotation.rect.y,
|
|
@@ -814,7 +881,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
|
|
|
814
881
|
// Reference line
|
|
815
882
|
if (annotation.line) {
|
|
816
883
|
const line = createSVGElement('line');
|
|
817
|
-
line.setAttribute('class', '
|
|
884
|
+
line.setAttribute('class', 'oc-annotation-line');
|
|
818
885
|
setAttrs(line, {
|
|
819
886
|
x1: annotation.line.start.x,
|
|
820
887
|
y1: annotation.line.start.y,
|
|
@@ -854,7 +921,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
|
|
|
854
921
|
const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
|
|
855
922
|
const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
|
|
856
923
|
const path = createSVGElement('path');
|
|
857
|
-
path.setAttribute('class', '
|
|
924
|
+
path.setAttribute('class', 'oc-annotation-connector');
|
|
858
925
|
setAttrs(path, {
|
|
859
926
|
d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
|
|
860
927
|
fill: 'none',
|
|
@@ -869,7 +936,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
|
|
|
869
936
|
renderCurvedArrow(g, c.from, c.to, c.stroke);
|
|
870
937
|
} else {
|
|
871
938
|
const connector = createSVGElement('line');
|
|
872
|
-
connector.setAttribute('class', '
|
|
939
|
+
connector.setAttribute('class', 'oc-annotation-connector');
|
|
873
940
|
setAttrs(connector, {
|
|
874
941
|
x1: c.from.x,
|
|
875
942
|
y1: c.from.y,
|
|
@@ -884,7 +951,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
|
|
|
884
951
|
}
|
|
885
952
|
|
|
886
953
|
const text = createSVGElement('text');
|
|
887
|
-
text.setAttribute('class', '
|
|
954
|
+
text.setAttribute('class', 'oc-annotation-label');
|
|
888
955
|
setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
|
|
889
956
|
applyTextStyle(text, annotation.label.style);
|
|
890
957
|
|
|
@@ -916,7 +983,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
|
|
|
916
983
|
? annotation.label.x - maxLineWidth / 2 - pad
|
|
917
984
|
: annotation.label.x - pad;
|
|
918
985
|
const bgRect = createSVGElement('rect');
|
|
919
|
-
bgRect.setAttribute('class', '
|
|
986
|
+
bgRect.setAttribute('class', 'oc-annotation-bg');
|
|
920
987
|
setAttrs(bgRect, {
|
|
921
988
|
x: bgX,
|
|
922
989
|
y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
|
|
@@ -942,7 +1009,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
942
1009
|
if (legend.entries.length === 0) return;
|
|
943
1010
|
|
|
944
1011
|
const g = createSVGElement('g');
|
|
945
|
-
g.setAttribute('class', '
|
|
1012
|
+
g.setAttribute('class', 'oc-legend');
|
|
946
1013
|
g.setAttribute('role', 'list');
|
|
947
1014
|
g.setAttribute('aria-label', 'Chart legend');
|
|
948
1015
|
|
|
@@ -967,7 +1034,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
967
1034
|
}
|
|
968
1035
|
}
|
|
969
1036
|
const entryG = createSVGElement('g');
|
|
970
|
-
entryG.setAttribute('class', '
|
|
1037
|
+
entryG.setAttribute('class', 'oc-legend-entry');
|
|
971
1038
|
entryG.setAttribute('role', 'listitem');
|
|
972
1039
|
entryG.setAttribute('data-legend-index', String(i));
|
|
973
1040
|
entryG.setAttribute('data-legend-label', entry.label);
|
|
@@ -1097,7 +1164,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1097
1164
|
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
|
|
1098
1165
|
a.setAttribute('target', '_blank');
|
|
1099
1166
|
a.setAttribute('rel', 'noopener');
|
|
1100
|
-
a.setAttribute('class', '
|
|
1167
|
+
a.setAttribute('class', 'oc-chrome-ref');
|
|
1101
1168
|
|
|
1102
1169
|
// "Open" in normal weight, "Data" in semibold, rendered as a single
|
|
1103
1170
|
// right-aligned text element with two tspans.
|
|
@@ -1138,8 +1205,16 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1138
1205
|
* @param container - DOM element to mount the SVG into.
|
|
1139
1206
|
* @returns The created SVG element.
|
|
1140
1207
|
*/
|
|
1141
|
-
export function renderChartSVG(
|
|
1208
|
+
export function renderChartSVG(
|
|
1209
|
+
layout: ChartLayout,
|
|
1210
|
+
container: HTMLElement,
|
|
1211
|
+
opts?: { animate?: boolean },
|
|
1212
|
+
): SVGElement {
|
|
1142
1213
|
const { width, height } = layout.dimensions;
|
|
1214
|
+
const animation = layout.animation;
|
|
1215
|
+
|
|
1216
|
+
// Set module-level animation state so mark renderers can access it
|
|
1217
|
+
currentAnimation = animation;
|
|
1143
1218
|
|
|
1144
1219
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
1145
1220
|
setAttrs(svg, {
|
|
@@ -1158,7 +1233,39 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1158
1233
|
svg.style.height = `${height}px`;
|
|
1159
1234
|
svg.setAttribute('role', layout.a11y.role);
|
|
1160
1235
|
svg.setAttribute('aria-label', layout.a11y.altText);
|
|
1161
|
-
|
|
1236
|
+
|
|
1237
|
+
// oc-animate must be set before the SVG enters the DOM to prevent a flash
|
|
1238
|
+
// of the final state. mount.ts passes animate: true only on genuine first render.
|
|
1239
|
+
const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
|
|
1240
|
+
svg.setAttribute('class', classes);
|
|
1241
|
+
|
|
1242
|
+
// Set animation CSS custom properties when enabled
|
|
1243
|
+
if (animation?.enabled) {
|
|
1244
|
+
const markCount = layout.marks.length;
|
|
1245
|
+
const stagger = clampStaggerDelay(animation.staggerDelay, markCount);
|
|
1246
|
+
svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
|
|
1247
|
+
svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
1248
|
+
svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
|
|
1249
|
+
const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
|
|
1250
|
+
svg.style.setProperty('--oc-animation-ease', easeVar);
|
|
1251
|
+
|
|
1252
|
+
// Compute per-segment duration for stacked bars so the total bar animation
|
|
1253
|
+
// time stays consistent regardless of segment count.
|
|
1254
|
+
// stackPos is set by the engine (0-indexed position within each stack group).
|
|
1255
|
+
let maxSegments = 0;
|
|
1256
|
+
for (const m of layout.marks) {
|
|
1257
|
+
if (m.type === 'rect') {
|
|
1258
|
+
const pos = (m as RectMark).stackPos;
|
|
1259
|
+
if (pos !== undefined && pos + 1 > maxSegments) {
|
|
1260
|
+
maxSegments = pos + 1;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (maxSegments > 0) {
|
|
1265
|
+
const segDuration = Math.round(animation.duration / maxSegments);
|
|
1266
|
+
svg.style.setProperty('--oc-stack-segment-duration', `${segDuration}ms`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1162
1269
|
|
|
1163
1270
|
// Background
|
|
1164
1271
|
const bg = createSVGElement('rect');
|
|
@@ -1174,7 +1281,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1174
1281
|
// Clip path to prevent marks (especially area fills) from overflowing
|
|
1175
1282
|
// into the chrome region (title/subtitle). Extends full width so
|
|
1176
1283
|
// end-of-line labels aren't clipped, but constrains vertically.
|
|
1177
|
-
const clipId = `
|
|
1284
|
+
const clipId = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
|
|
1178
1285
|
const defs = createSVGElement('defs');
|
|
1179
1286
|
const clipPath = createSVGElement('clipPath');
|
|
1180
1287
|
clipPath.setAttribute('id', clipId);
|
|
@@ -1214,7 +1321,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1214
1321
|
height: layout.area.height,
|
|
1215
1322
|
fill: 'transparent',
|
|
1216
1323
|
});
|
|
1217
|
-
overlay.setAttribute('class', '
|
|
1324
|
+
overlay.setAttribute('class', 'oc-voronoi-overlay');
|
|
1218
1325
|
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
1219
1326
|
clippedGroup.appendChild(overlay);
|
|
1220
1327
|
}
|
|
@@ -1230,6 +1337,9 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1230
1337
|
// Brand renders as a footer item, right-aligned on the source/footer row
|
|
1231
1338
|
renderBrand(svg, layout);
|
|
1232
1339
|
|
|
1340
|
+
// Reset module-level animation state after rendering
|
|
1341
|
+
currentAnimation = undefined;
|
|
1342
|
+
|
|
1233
1343
|
container.appendChild(svg);
|
|
1234
1344
|
return svg;
|
|
1235
1345
|
}
|
package/src/table-keyboard.ts
CHANGED
|
@@ -73,9 +73,9 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function clearFocusHighlight(): void {
|
|
76
|
-
const prev = wrapper.querySelector('.
|
|
76
|
+
const prev = wrapper.querySelector('.oc-table-cell-focus');
|
|
77
77
|
if (prev) {
|
|
78
|
-
prev.classList.remove('
|
|
78
|
+
prev.classList.remove('oc-table-cell-focus');
|
|
79
79
|
prev.removeAttribute('id');
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -99,9 +99,9 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
|
|
|
99
99
|
const cell = cells[col];
|
|
100
100
|
if (!cell) return;
|
|
101
101
|
|
|
102
|
-
const cellId = `
|
|
102
|
+
const cellId = `oc-cell-${row}-${col}`;
|
|
103
103
|
cell.id = cellId;
|
|
104
|
-
cell.classList.add('
|
|
104
|
+
cell.classList.add('oc-table-cell-focus');
|
|
105
105
|
cell.setAttribute('data-row', String(row));
|
|
106
106
|
cell.setAttribute('data-col', String(col));
|
|
107
107
|
|
|
@@ -219,7 +219,7 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
// Search escape handling
|
|
222
|
-
const searchInput = wrapper.querySelector('.
|
|
222
|
+
const searchInput = wrapper.querySelector('.oc-table-search input') as HTMLInputElement | null;
|
|
223
223
|
|
|
224
224
|
function handleSearchKeydown(e: KeyboardEvent): void {
|
|
225
225
|
if (e.key === 'Escape') {
|