@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
package/src/mount.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
CompileOptions,
|
|
18
18
|
DarkMode,
|
|
19
19
|
ElementEdit,
|
|
20
|
+
ElementRef,
|
|
20
21
|
GraphSpec,
|
|
21
22
|
LayerSpec,
|
|
22
23
|
MeasureTextFn,
|
|
@@ -26,8 +27,9 @@ import type {
|
|
|
26
27
|
ThemeConfig,
|
|
27
28
|
TooltipContent,
|
|
28
29
|
} from '@opendata-ai/openchart-core';
|
|
29
|
-
import { isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
|
+
import { elementRef, isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
31
|
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
32
|
+
import { cancelAnimations, setupAnimationCleanup } from './animation';
|
|
31
33
|
import {
|
|
32
34
|
exportCSV,
|
|
33
35
|
exportJPG,
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
} from './export';
|
|
40
42
|
import { observeResize } from './resize-observer';
|
|
41
43
|
import { renderChartSVG } from './svg-renderer';
|
|
44
|
+
import { createTextEditOverlay } from './text-edit-overlay';
|
|
42
45
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
43
46
|
|
|
44
47
|
// ---------------------------------------------------------------------------
|
|
@@ -54,6 +57,13 @@ export interface MountOptions extends ChartEventHandlers {
|
|
|
54
57
|
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
55
58
|
/** Enable responsive resizing. Defaults to true. */
|
|
56
59
|
responsive?: boolean;
|
|
60
|
+
/** Initial selected element. */
|
|
61
|
+
selectedElement?: ElementRef;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UpdateOptions {
|
|
65
|
+
/** Override the selected element after update. When omitted, preserves current selection. */
|
|
66
|
+
selectedElement?: ElementRef;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export interface ExportOptions extends JPGExportOptions {
|
|
@@ -62,7 +72,7 @@ export interface ExportOptions extends JPGExportOptions {
|
|
|
62
72
|
|
|
63
73
|
export interface ChartInstance {
|
|
64
74
|
/** Re-compile and re-render with a new spec. */
|
|
65
|
-
update(spec: ChartSpec | LayerSpec | GraphSpec): void;
|
|
75
|
+
update(spec: ChartSpec | LayerSpec | GraphSpec, options?: UpdateOptions): void;
|
|
66
76
|
/** Re-compile at current container dimensions. */
|
|
67
77
|
resize(): void;
|
|
68
78
|
/** Export the chart. */
|
|
@@ -79,6 +89,14 @@ export interface ChartInstance {
|
|
|
79
89
|
destroy(): void;
|
|
80
90
|
/** The current compiled layout (for hooks / debugging). */
|
|
81
91
|
readonly layout: ChartLayout;
|
|
92
|
+
/** Get the currently selected element, or null if none. */
|
|
93
|
+
getSelectedElement(): ElementRef | null;
|
|
94
|
+
/** Programmatically select an element. Silent no-op if element not found. */
|
|
95
|
+
select(ref: ElementRef): void;
|
|
96
|
+
/** Deselect the current element. */
|
|
97
|
+
deselect(): void;
|
|
98
|
+
/** Whether inline text editing is active. */
|
|
99
|
+
readonly isEditing: boolean;
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
// ---------------------------------------------------------------------------
|
|
@@ -460,7 +478,7 @@ function wireChartEvents(
|
|
|
460
478
|
|
|
461
479
|
// Wire annotation click events
|
|
462
480
|
if (handlers.onAnnotationClick) {
|
|
463
|
-
const annotationElements = svg.querySelectorAll('.
|
|
481
|
+
const annotationElements = svg.querySelectorAll('.oc-annotation');
|
|
464
482
|
|
|
465
483
|
for (let i = 0; i < annotationElements.length; i++) {
|
|
466
484
|
const el = annotationElements[i];
|
|
@@ -696,7 +714,7 @@ function wireAnnotationDrag(
|
|
|
696
714
|
onEdit: ((edit: ElementEdit) => void) | undefined,
|
|
697
715
|
setDragging: (dragging: boolean) => void,
|
|
698
716
|
): () => void {
|
|
699
|
-
const annotationElements = svg.querySelectorAll('.
|
|
717
|
+
const annotationElements = svg.querySelectorAll('.oc-annotation-text');
|
|
700
718
|
const cleanups: Array<() => void> = [];
|
|
701
719
|
|
|
702
720
|
for (const el of annotationElements) {
|
|
@@ -714,13 +732,13 @@ function wireAnnotationDrag(
|
|
|
714
732
|
annotationG.style.cursor = 'grab';
|
|
715
733
|
|
|
716
734
|
// Stash connector info for real-time updates during drag
|
|
717
|
-
const connectorLine = annotationG.querySelector('line.
|
|
735
|
+
const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
|
|
718
736
|
const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
|
|
719
737
|
const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
|
|
720
738
|
|
|
721
739
|
// For curved connectors, stash path/polygon elements to hide during drag
|
|
722
|
-
const curvedPath = annotationG.querySelector('path.
|
|
723
|
-
const arrowhead = annotationG.querySelector('polygon.
|
|
740
|
+
const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
|
|
741
|
+
const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
|
|
724
742
|
const hasCurvedConnector = curvedPath !== null;
|
|
725
743
|
|
|
726
744
|
const origDx = textAnnotation.offset?.dx ?? 0;
|
|
@@ -807,7 +825,7 @@ function wireConnectorEndpointDrag(
|
|
|
807
825
|
): () => void {
|
|
808
826
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
809
827
|
const cleanups: Array<() => void> = [];
|
|
810
|
-
const annotationGroups = svg.querySelectorAll('.
|
|
828
|
+
const annotationGroups = svg.querySelectorAll('.oc-annotation-text');
|
|
811
829
|
|
|
812
830
|
for (const el of annotationGroups) {
|
|
813
831
|
const annotationG = el as SVGGElement;
|
|
@@ -821,8 +839,8 @@ function wireConnectorEndpointDrag(
|
|
|
821
839
|
const textAnnotation = specAnnotation as TextAnnotation;
|
|
822
840
|
|
|
823
841
|
// Find connector line or curved connector to determine endpoints
|
|
824
|
-
const connectorLine = annotationG.querySelector('line.
|
|
825
|
-
const curvedPath = annotationG.querySelector('path.
|
|
842
|
+
const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
|
|
843
|
+
const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
|
|
826
844
|
if (!connectorLine && !curvedPath) continue;
|
|
827
845
|
|
|
828
846
|
// Determine connector endpoint positions from the connector element
|
|
@@ -840,7 +858,7 @@ function wireConnectorEndpointDrag(
|
|
|
840
858
|
fromX = mMatch ? Number(mMatch[1]) : 0;
|
|
841
859
|
fromY = mMatch ? Number(mMatch[2]) : 0;
|
|
842
860
|
// For curved connectors, the arrow polygon has the target
|
|
843
|
-
const arrowhead = annotationG.querySelector('polygon.
|
|
861
|
+
const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
|
|
844
862
|
const points = arrowhead?.getAttribute('points') ?? '';
|
|
845
863
|
const firstPoint = points.split(' ')[0] ?? '0,0';
|
|
846
864
|
const [px, py] = firstPoint.split(',');
|
|
@@ -861,7 +879,7 @@ function wireConnectorEndpointDrag(
|
|
|
861
879
|
if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
|
|
862
880
|
|
|
863
881
|
const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
|
|
864
|
-
handleEl.setAttribute('class', '
|
|
882
|
+
handleEl.setAttribute('class', 'oc-connector-handle');
|
|
865
883
|
handleEl.setAttribute('data-endpoint', ep.name);
|
|
866
884
|
handleEl.setAttribute('cx', String(ep.cx));
|
|
867
885
|
handleEl.setAttribute('cy', String(ep.cy));
|
|
@@ -985,15 +1003,15 @@ function wireAnnotationLabelDrag(
|
|
|
985
1003
|
|
|
986
1004
|
// Target range and refline annotation labels
|
|
987
1005
|
const selectors = [
|
|
988
|
-
'.
|
|
989
|
-
'.
|
|
1006
|
+
'.oc-annotation-range .oc-annotation-label',
|
|
1007
|
+
'.oc-annotation-refline .oc-annotation-label',
|
|
990
1008
|
];
|
|
991
1009
|
|
|
992
1010
|
for (const selector of selectors) {
|
|
993
1011
|
const labels = svg.querySelectorAll(selector);
|
|
994
1012
|
|
|
995
1013
|
for (const label of labels) {
|
|
996
|
-
const annotationG = label.closest('.
|
|
1014
|
+
const annotationG = label.closest('.oc-annotation') as SVGGElement | null;
|
|
997
1015
|
if (!annotationG) continue;
|
|
998
1016
|
|
|
999
1017
|
const indexStr = annotationG.getAttribute('data-annotation-index');
|
|
@@ -1068,7 +1086,7 @@ function wireChromeDrag(
|
|
|
1068
1086
|
onEdit: (edit: ElementEdit) => void,
|
|
1069
1087
|
setDragging: (dragging: boolean) => void,
|
|
1070
1088
|
): () => void {
|
|
1071
|
-
const chromeTexts = svg.querySelectorAll('.
|
|
1089
|
+
const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]');
|
|
1072
1090
|
const cleanups: Array<() => void> = [];
|
|
1073
1091
|
|
|
1074
1092
|
// Read existing chrome offsets from the spec
|
|
@@ -1136,7 +1154,7 @@ function wireLegendDrag(
|
|
|
1136
1154
|
onEdit: (edit: ElementEdit) => void,
|
|
1137
1155
|
setDragging: (dragging: boolean) => void,
|
|
1138
1156
|
): () => void {
|
|
1139
|
-
const legendG = svg.querySelector('.
|
|
1157
|
+
const legendG = svg.querySelector('.oc-legend') as SVGGElement | null;
|
|
1140
1158
|
if (!legendG) return () => {};
|
|
1141
1159
|
|
|
1142
1160
|
const cleanups: Array<() => void> = [];
|
|
@@ -1180,7 +1198,7 @@ function wireLegendDrag(
|
|
|
1180
1198
|
// ---------------------------------------------------------------------------
|
|
1181
1199
|
|
|
1182
1200
|
/**
|
|
1183
|
-
* Wire drag on series label elements (.
|
|
1201
|
+
* Wire drag on series label elements (.oc-mark-label[data-series]).
|
|
1184
1202
|
* On drag end, fires onEdit with the series name and offset.
|
|
1185
1203
|
* Returns a cleanup function.
|
|
1186
1204
|
*/
|
|
@@ -1190,7 +1208,7 @@ function wireSeriesLabelDrag(
|
|
|
1190
1208
|
onEdit: (edit: ElementEdit) => void,
|
|
1191
1209
|
setDragging: (dragging: boolean) => void,
|
|
1192
1210
|
): () => void {
|
|
1193
|
-
const labels = svg.querySelectorAll('.
|
|
1211
|
+
const labels = svg.querySelectorAll('.oc-mark-label');
|
|
1194
1212
|
const cleanups: Array<() => void> = [];
|
|
1195
1213
|
|
|
1196
1214
|
// Read existing label offsets from the spec
|
|
@@ -1290,7 +1308,7 @@ function wireLegendInteraction(
|
|
|
1290
1308
|
// Toggle visibility of marks with matching series.
|
|
1291
1309
|
// Uses the data-series attribute set by the SVG renderer, which works
|
|
1292
1310
|
// for all mark types (line, area, rect, arc, point).
|
|
1293
|
-
const marks = svg.querySelectorAll('.
|
|
1311
|
+
const marks = svg.querySelectorAll('.oc-mark');
|
|
1294
1312
|
for (const mark of marks) {
|
|
1295
1313
|
const seriesName = mark.getAttribute('data-series');
|
|
1296
1314
|
if (!seriesName) continue;
|
|
@@ -1350,7 +1368,7 @@ function wireKeyboardNav(
|
|
|
1350
1368
|
function highlightMark(index: number): void {
|
|
1351
1369
|
// Remove previous highlight
|
|
1352
1370
|
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1353
|
-
markElements[focusIndex].classList.remove('
|
|
1371
|
+
markElements[focusIndex].classList.remove('oc-mark-focused');
|
|
1354
1372
|
markElements[focusIndex].removeAttribute('aria-selected');
|
|
1355
1373
|
}
|
|
1356
1374
|
|
|
@@ -1358,7 +1376,7 @@ function wireKeyboardNav(
|
|
|
1358
1376
|
|
|
1359
1377
|
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
1360
1378
|
const el = markElements[focusIndex];
|
|
1361
|
-
el.classList.add('
|
|
1379
|
+
el.classList.add('oc-mark-focused');
|
|
1362
1380
|
el.setAttribute('aria-selected', 'true');
|
|
1363
1381
|
}
|
|
1364
1382
|
}
|
|
@@ -1447,7 +1465,7 @@ function createScreenReaderTable(
|
|
|
1447
1465
|
if (!data || data.length === 0) return null;
|
|
1448
1466
|
|
|
1449
1467
|
const table = document.createElement('table');
|
|
1450
|
-
table.className = '
|
|
1468
|
+
table.className = 'oc-sr-only';
|
|
1451
1469
|
// Inline critical SR-only styles so the table stays hidden even when the
|
|
1452
1470
|
// external stylesheet isn't loaded (e.g. CDN / esm.sh usage).
|
|
1453
1471
|
table.style.position = 'absolute';
|
|
@@ -1497,6 +1515,252 @@ function createScreenReaderTable(
|
|
|
1497
1515
|
return table;
|
|
1498
1516
|
}
|
|
1499
1517
|
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
// Editable element helpers
|
|
1520
|
+
// ---------------------------------------------------------------------------
|
|
1521
|
+
|
|
1522
|
+
/** CSS for editable hover feedback, injected into the SVG as a <style> element. */
|
|
1523
|
+
const EDITABLE_HOVER_CSS = `
|
|
1524
|
+
.oc-editable-hover {
|
|
1525
|
+
outline: 1.5px solid rgba(79, 70, 229, 0.35);
|
|
1526
|
+
outline-offset: 2px;
|
|
1527
|
+
border-radius: 2px;
|
|
1528
|
+
}
|
|
1529
|
+
`;
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Inject editable styles into an SVG element and make it focusable.
|
|
1533
|
+
* Called when any editing callback is provided.
|
|
1534
|
+
*/
|
|
1535
|
+
function makeEditable(svg: SVGElement): void {
|
|
1536
|
+
svg.setAttribute('tabindex', '0');
|
|
1537
|
+
svg.style.outline = 'none';
|
|
1538
|
+
|
|
1539
|
+
// Inject hover style into SVG defs
|
|
1540
|
+
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
1541
|
+
style.textContent = EDITABLE_HOVER_CSS;
|
|
1542
|
+
svg.insertBefore(style, svg.firstChild);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Check whether any editing-related callback is provided in the options.
|
|
1547
|
+
*/
|
|
1548
|
+
function hasEditingCallbacks(opts?: MountOptions): boolean {
|
|
1549
|
+
return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Find a DOM element inside the SVG that matches the given ElementRef.
|
|
1554
|
+
*/
|
|
1555
|
+
function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
|
|
1556
|
+
switch (ref.type) {
|
|
1557
|
+
case 'annotation': {
|
|
1558
|
+
// Prefer id-based lookup when available
|
|
1559
|
+
if (ref.id) {
|
|
1560
|
+
const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
|
|
1561
|
+
if (byId) return byId as SVGElement;
|
|
1562
|
+
}
|
|
1563
|
+
return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
|
|
1564
|
+
}
|
|
1565
|
+
case 'chrome':
|
|
1566
|
+
return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
|
|
1567
|
+
case 'series-label':
|
|
1568
|
+
return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
|
|
1569
|
+
case 'legend':
|
|
1570
|
+
return svg.querySelector('.oc-legend') as SVGElement | null;
|
|
1571
|
+
case 'legend-entry':
|
|
1572
|
+
return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Build an ElementRef from a DOM element's data attributes.
|
|
1578
|
+
* Walks up the tree to find the closest editable ancestor if needed.
|
|
1579
|
+
*/
|
|
1580
|
+
function buildElementRef(element: Element, _specAnnotations: Annotation[]): ElementRef | null {
|
|
1581
|
+
// Check for annotation
|
|
1582
|
+
const annotationEl = element.closest('[data-annotation-index]');
|
|
1583
|
+
if (annotationEl) {
|
|
1584
|
+
const index = Number(annotationEl.getAttribute('data-annotation-index'));
|
|
1585
|
+
const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
|
|
1586
|
+
return elementRef.annotation(index, id);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Check for chrome
|
|
1590
|
+
const chromeEl = element.closest('[data-chrome-key]');
|
|
1591
|
+
if (chromeEl) {
|
|
1592
|
+
const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
1593
|
+
if (key) return elementRef.chrome(key);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Check for series label
|
|
1597
|
+
const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
|
|
1598
|
+
if (seriesLabelEl) {
|
|
1599
|
+
const series = seriesLabelEl.getAttribute('data-series');
|
|
1600
|
+
if (series) return elementRef.seriesLabel(series);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Check for legend entry
|
|
1604
|
+
const legendEntryEl = element.closest('[data-legend-index]');
|
|
1605
|
+
if (legendEntryEl) {
|
|
1606
|
+
const index = Number(legendEntryEl.getAttribute('data-legend-index'));
|
|
1607
|
+
const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
|
|
1608
|
+
return elementRef.legendEntry(series, index);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Check for legend group
|
|
1612
|
+
const legendEl = element.closest('.oc-legend');
|
|
1613
|
+
if (legendEl) return elementRef.legend();
|
|
1614
|
+
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Get an ordered list of all editable ElementRefs from the current spec and layout.
|
|
1620
|
+
* Order: chrome (title, subtitle, source, byline, footer), annotations by index,
|
|
1621
|
+
* series labels alphabetical, legend.
|
|
1622
|
+
*/
|
|
1623
|
+
function getEditableElements(
|
|
1624
|
+
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
1625
|
+
layout: ChartLayout,
|
|
1626
|
+
): ElementRef[] {
|
|
1627
|
+
const refs: ElementRef[] = [];
|
|
1628
|
+
|
|
1629
|
+
// Chrome keys in display order
|
|
1630
|
+
const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
|
|
1631
|
+
for (const key of chromeKeys) {
|
|
1632
|
+
if (layout.chrome[key]) {
|
|
1633
|
+
refs.push(elementRef.chrome(key));
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Annotations by index
|
|
1638
|
+
const annotations: Annotation[] =
|
|
1639
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1640
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
1641
|
+
refs.push(elementRef.annotation(i, annotations[i].id));
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Series labels (alphabetical)
|
|
1645
|
+
const seriesLabels: string[] = [];
|
|
1646
|
+
for (const mark of layout.marks) {
|
|
1647
|
+
if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
|
|
1648
|
+
seriesLabels.push(mark.seriesKey);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
seriesLabels.sort();
|
|
1652
|
+
for (const series of seriesLabels) {
|
|
1653
|
+
refs.push(elementRef.seriesLabel(series));
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Legend
|
|
1657
|
+
if (layout.legend.entries.length > 0) {
|
|
1658
|
+
refs.push(elementRef.legend());
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
return refs;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Check if an ElementRef points to a text-editable element (chrome text or text annotation).
|
|
1666
|
+
*/
|
|
1667
|
+
function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
|
|
1668
|
+
if (ref.type === 'chrome') return true;
|
|
1669
|
+
if (ref.type === 'annotation') {
|
|
1670
|
+
const annotation = specAnnotations[ref.index];
|
|
1671
|
+
return annotation?.type === 'text';
|
|
1672
|
+
}
|
|
1673
|
+
return false;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Get the current text content for an element ref.
|
|
1678
|
+
*/
|
|
1679
|
+
function getElementText(ref: ElementRef, spec: ChartSpec | LayerSpec | GraphSpec): string | null {
|
|
1680
|
+
if (ref.type === 'chrome') {
|
|
1681
|
+
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
1682
|
+
if (!chromeConfig) return null;
|
|
1683
|
+
const entry = chromeConfig[ref.key];
|
|
1684
|
+
if (typeof entry === 'string') return entry;
|
|
1685
|
+
if (typeof entry === 'object' && entry !== null && 'text' in entry) {
|
|
1686
|
+
return (entry as { text: string }).text;
|
|
1687
|
+
}
|
|
1688
|
+
return null;
|
|
1689
|
+
}
|
|
1690
|
+
if (ref.type === 'annotation') {
|
|
1691
|
+
const annotations: Annotation[] =
|
|
1692
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1693
|
+
const annotation = annotations[ref.index];
|
|
1694
|
+
if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
|
|
1695
|
+
if (annotation?.label) return annotation.label;
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
return null;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Compare two ElementRefs for equality.
|
|
1703
|
+
*/
|
|
1704
|
+
function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
|
|
1705
|
+
if (a === null || b === null) return a === b;
|
|
1706
|
+
if (a.type !== b.type) return false;
|
|
1707
|
+
switch (a.type) {
|
|
1708
|
+
case 'annotation': {
|
|
1709
|
+
const bAnno = b as typeof a;
|
|
1710
|
+
if (a.id && bAnno.id) return a.id === bAnno.id;
|
|
1711
|
+
return a.index === bAnno.index;
|
|
1712
|
+
}
|
|
1713
|
+
case 'chrome':
|
|
1714
|
+
return a.key === (b as typeof a).key;
|
|
1715
|
+
case 'series-label':
|
|
1716
|
+
return a.series === (b as typeof a).series;
|
|
1717
|
+
case 'legend':
|
|
1718
|
+
return true;
|
|
1719
|
+
case 'legend-entry': {
|
|
1720
|
+
const bEntry = b as typeof a;
|
|
1721
|
+
return a.index === bEntry.index && a.series === bEntry.series;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Render a selection overlay rectangle around a target element.
|
|
1728
|
+
* Returns the overlay group element.
|
|
1729
|
+
*/
|
|
1730
|
+
function renderSelectionOverlay(
|
|
1731
|
+
svg: SVGElement,
|
|
1732
|
+
ref: ElementRef,
|
|
1733
|
+
layout: ChartLayout,
|
|
1734
|
+
): SVGGElement | null {
|
|
1735
|
+
const target = findElementByRef(svg, ref);
|
|
1736
|
+
if (!target) return null;
|
|
1737
|
+
|
|
1738
|
+
const bbox = (target as SVGGraphicsElement).getBBox();
|
|
1739
|
+
const padding = 4;
|
|
1740
|
+
|
|
1741
|
+
// Resolve accent color from theme
|
|
1742
|
+
const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
|
|
1743
|
+
|
|
1744
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
1745
|
+
g.setAttribute('class', 'oc-selection-overlay');
|
|
1746
|
+
|
|
1747
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
1748
|
+
rect.setAttribute('x', String(bbox.x - padding));
|
|
1749
|
+
rect.setAttribute('y', String(bbox.y - padding));
|
|
1750
|
+
rect.setAttribute('width', String(bbox.width + padding * 2));
|
|
1751
|
+
rect.setAttribute('height', String(bbox.height + padding * 2));
|
|
1752
|
+
rect.setAttribute('rx', '3');
|
|
1753
|
+
rect.setAttribute('fill', 'transparent');
|
|
1754
|
+
rect.setAttribute('stroke', accentColor);
|
|
1755
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
1756
|
+
rect.setAttribute('pointer-events', 'none');
|
|
1757
|
+
|
|
1758
|
+
g.appendChild(rect);
|
|
1759
|
+
svg.appendChild(g);
|
|
1760
|
+
|
|
1761
|
+
return g;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1500
1764
|
// ---------------------------------------------------------------------------
|
|
1501
1765
|
// Main API
|
|
1502
1766
|
// ---------------------------------------------------------------------------
|
|
@@ -1526,12 +1790,24 @@ export function createChart(
|
|
|
1526
1790
|
let cleanupChartEvents: (() => void) | null = null;
|
|
1527
1791
|
let cleanupAnnotationDrag: (() => void) | null = null;
|
|
1528
1792
|
let cleanupEditDrags: (() => void) | null = null;
|
|
1793
|
+
let cleanupSelection: (() => void) | null = null;
|
|
1794
|
+
let cleanupKeyboardEdit: (() => void) | null = null;
|
|
1529
1795
|
let srTable: HTMLTableElement | null = null;
|
|
1530
1796
|
let destroyed = false;
|
|
1531
1797
|
let isDragging = false;
|
|
1532
1798
|
let pendingRender = false;
|
|
1533
1799
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1534
1800
|
|
|
1801
|
+
// Animation state
|
|
1802
|
+
let isFirstRender = true;
|
|
1803
|
+
let cleanupAnimations: (() => void) | null = null;
|
|
1804
|
+
|
|
1805
|
+
// Selection and text editing state
|
|
1806
|
+
let selectedElement: ElementRef | null = options?.selectedElement ?? null;
|
|
1807
|
+
let overlayElement: SVGGElement | null = null;
|
|
1808
|
+
let isTextEditingActive = false;
|
|
1809
|
+
let textEditCleanup: (() => void) | null = null;
|
|
1810
|
+
|
|
1535
1811
|
const measureText = createMeasureText();
|
|
1536
1812
|
|
|
1537
1813
|
function compile(): ChartLayout {
|
|
@@ -1560,6 +1836,274 @@ export function createChart(
|
|
|
1560
1836
|
};
|
|
1561
1837
|
}
|
|
1562
1838
|
|
|
1839
|
+
/** Get the current spec's annotations array. */
|
|
1840
|
+
function getSpecAnnotations(): Annotation[] {
|
|
1841
|
+
return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
1842
|
+
? currentSpec.annotations
|
|
1843
|
+
: [];
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** Select an element: render overlay, fire onSelect, update state. */
|
|
1847
|
+
function selectElement(ref: ElementRef): void {
|
|
1848
|
+
if (!svgElement) return;
|
|
1849
|
+
|
|
1850
|
+
// Confirm the target element exists before deselecting the previous one
|
|
1851
|
+
const target = findElementByRef(svgElement, ref);
|
|
1852
|
+
if (!target) return;
|
|
1853
|
+
|
|
1854
|
+
// Deselect previous if different
|
|
1855
|
+
if (selectedElement && !refsEqual(selectedElement, ref)) {
|
|
1856
|
+
deselectElement();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
selectedElement = ref;
|
|
1860
|
+
overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
|
|
1861
|
+
options?.onSelect?.(ref);
|
|
1862
|
+
|
|
1863
|
+
// Focus SVG for keyboard events
|
|
1864
|
+
(svgElement as SVGSVGElement).focus();
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** Deselect the current element: remove overlay, fire onDeselect, clear state. */
|
|
1868
|
+
function deselectElement(): void {
|
|
1869
|
+
if (!selectedElement) return;
|
|
1870
|
+
|
|
1871
|
+
// Cancel text editing if active
|
|
1872
|
+
if (isTextEditingActive && textEditCleanup) {
|
|
1873
|
+
textEditCleanup();
|
|
1874
|
+
textEditCleanup = null;
|
|
1875
|
+
isTextEditingActive = false;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const prev = selectedElement;
|
|
1879
|
+
selectedElement = null;
|
|
1880
|
+
|
|
1881
|
+
if (overlayElement?.parentNode) {
|
|
1882
|
+
overlayElement.parentNode.removeChild(overlayElement);
|
|
1883
|
+
}
|
|
1884
|
+
overlayElement = null;
|
|
1885
|
+
|
|
1886
|
+
options?.onDeselect?.(prev);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** Enter text editing mode for the currently selected element. */
|
|
1890
|
+
function enterTextEditing(): void {
|
|
1891
|
+
if (!svgElement || !selectedElement || isTextEditingActive) return;
|
|
1892
|
+
|
|
1893
|
+
const specAnnotations = getSpecAnnotations();
|
|
1894
|
+
if (!isTextEditable(selectedElement, specAnnotations)) return;
|
|
1895
|
+
|
|
1896
|
+
const currentText = getElementText(selectedElement, currentSpec);
|
|
1897
|
+
if (currentText === null) return;
|
|
1898
|
+
|
|
1899
|
+
// Find the text element within the selected element
|
|
1900
|
+
const target = findElementByRef(svgElement, selectedElement);
|
|
1901
|
+
if (!target) return;
|
|
1902
|
+
|
|
1903
|
+
// The target might be a group; find the actual text element
|
|
1904
|
+
const textEl = target.tagName === 'text' ? target : target.querySelector('text');
|
|
1905
|
+
if (!textEl) return;
|
|
1906
|
+
|
|
1907
|
+
isTextEditingActive = true;
|
|
1908
|
+
const editRef = selectedElement;
|
|
1909
|
+
|
|
1910
|
+
const overlay = createTextEditOverlay({
|
|
1911
|
+
container,
|
|
1912
|
+
svg: svgElement as SVGSVGElement,
|
|
1913
|
+
targetElement: textEl as SVGElement,
|
|
1914
|
+
currentText,
|
|
1915
|
+
onCommit: (newText: string) => {
|
|
1916
|
+
isTextEditingActive = false;
|
|
1917
|
+
textEditCleanup = null;
|
|
1918
|
+
|
|
1919
|
+
if (newText !== currentText) {
|
|
1920
|
+
// Fire text edit callbacks
|
|
1921
|
+
options?.onTextEdit?.(editRef, currentText, newText);
|
|
1922
|
+
options?.onEdit?.({
|
|
1923
|
+
type: 'text-edit',
|
|
1924
|
+
element: editRef,
|
|
1925
|
+
oldText: currentText,
|
|
1926
|
+
newText,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
},
|
|
1930
|
+
onCancel: () => {
|
|
1931
|
+
isTextEditingActive = false;
|
|
1932
|
+
textEditCleanup = null;
|
|
1933
|
+
},
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
textEditCleanup = overlay.destroy;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Wire click-based selection events on the SVG.
|
|
1941
|
+
* Uses event delegation for efficiency.
|
|
1942
|
+
*/
|
|
1943
|
+
function wireSelectionEvents(): () => void {
|
|
1944
|
+
if (!svgElement) return () => {};
|
|
1945
|
+
|
|
1946
|
+
const svg = svgElement;
|
|
1947
|
+
const cleanups: Array<() => void> = [];
|
|
1948
|
+
|
|
1949
|
+
// Click handler for selection
|
|
1950
|
+
const handleClick = (e: Event) => {
|
|
1951
|
+
const mouseEvent = e as MouseEvent;
|
|
1952
|
+
const target = mouseEvent.target as Element;
|
|
1953
|
+
|
|
1954
|
+
// Don't interfere with text editing
|
|
1955
|
+
if (isTextEditingActive) return;
|
|
1956
|
+
|
|
1957
|
+
const specAnnotations = getSpecAnnotations();
|
|
1958
|
+
const ref = buildElementRef(target, specAnnotations);
|
|
1959
|
+
|
|
1960
|
+
if (ref) {
|
|
1961
|
+
// Clicked on an editable element
|
|
1962
|
+
selectElement(ref);
|
|
1963
|
+
} else {
|
|
1964
|
+
// Clicked on empty area / non-editable element, deselect
|
|
1965
|
+
deselectElement();
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
svg.addEventListener('click', handleClick);
|
|
1970
|
+
cleanups.push(() => svg.removeEventListener('click', handleClick));
|
|
1971
|
+
|
|
1972
|
+
// Hover feedback on editable elements
|
|
1973
|
+
const handleMouseEnter = (e: Event) => {
|
|
1974
|
+
const target = (e.target as Element).closest(
|
|
1975
|
+
'[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend, [data-legend-index]',
|
|
1976
|
+
);
|
|
1977
|
+
if (target) {
|
|
1978
|
+
(target as SVGElement).classList.add('oc-editable-hover');
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
const handleMouseLeave = (e: Event) => {
|
|
1983
|
+
const target = (e.target as Element).closest('.oc-editable-hover');
|
|
1984
|
+
if (target) {
|
|
1985
|
+
(target as SVGElement).classList.remove('oc-editable-hover');
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
svg.addEventListener('mouseenter', handleMouseEnter, true);
|
|
1990
|
+
svg.addEventListener('mouseleave', handleMouseLeave, true);
|
|
1991
|
+
cleanups.push(() => {
|
|
1992
|
+
svg.removeEventListener('mouseenter', handleMouseEnter, true);
|
|
1993
|
+
svg.removeEventListener('mouseleave', handleMouseLeave, true);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
// Double-click to enter text editing
|
|
1997
|
+
const handleDblClick = (e: Event) => {
|
|
1998
|
+
const mouseEvent = e as MouseEvent;
|
|
1999
|
+
const target = mouseEvent.target as Element;
|
|
2000
|
+
const specAnnotations = getSpecAnnotations();
|
|
2001
|
+
const ref = buildElementRef(target, specAnnotations);
|
|
2002
|
+
|
|
2003
|
+
if (ref && isTextEditable(ref, specAnnotations)) {
|
|
2004
|
+
// Select first if not already selected
|
|
2005
|
+
if (!refsEqual(selectedElement, ref)) {
|
|
2006
|
+
selectElement(ref);
|
|
2007
|
+
}
|
|
2008
|
+
enterTextEditing();
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
svg.addEventListener('dblclick', handleDblClick);
|
|
2013
|
+
cleanups.push(() => svg.removeEventListener('dblclick', handleDblClick));
|
|
2014
|
+
|
|
2015
|
+
return () => {
|
|
2016
|
+
for (const cleanup of cleanups) {
|
|
2017
|
+
cleanup();
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Wire keyboard events for edit actions on the SVG.
|
|
2024
|
+
* Delete/Backspace -> delete, Escape -> cancel/deselect, Tab -> cycle, Enter -> text edit.
|
|
2025
|
+
*/
|
|
2026
|
+
function wireKeyboardEditEvents(): () => void {
|
|
2027
|
+
if (!svgElement) return () => {};
|
|
2028
|
+
|
|
2029
|
+
const svg = svgElement;
|
|
2030
|
+
|
|
2031
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
2032
|
+
const specAnnotations = getSpecAnnotations();
|
|
2033
|
+
|
|
2034
|
+
switch (e.key) {
|
|
2035
|
+
case 'Delete':
|
|
2036
|
+
case 'Backspace': {
|
|
2037
|
+
if (selectedElement && !isTextEditingActive) {
|
|
2038
|
+
e.preventDefault();
|
|
2039
|
+
options?.onEdit?.({ type: 'delete', element: selectedElement });
|
|
2040
|
+
// Stay selected (consumer decides whether to remove the element)
|
|
2041
|
+
}
|
|
2042
|
+
break;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
case 'Escape': {
|
|
2046
|
+
e.preventDefault();
|
|
2047
|
+
if (isTextEditingActive && textEditCleanup) {
|
|
2048
|
+
// Cancel text editing, remain selected
|
|
2049
|
+
textEditCleanup();
|
|
2050
|
+
textEditCleanup = null;
|
|
2051
|
+
isTextEditingActive = false;
|
|
2052
|
+
} else if (selectedElement) {
|
|
2053
|
+
deselectElement();
|
|
2054
|
+
}
|
|
2055
|
+
break;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
case 'ArrowDown':
|
|
2059
|
+
case 'ArrowRight': {
|
|
2060
|
+
if (!isTextEditingActive && selectedElement) {
|
|
2061
|
+
e.preventDefault();
|
|
2062
|
+
const editables = getEditableElements(currentSpec, currentLayout);
|
|
2063
|
+
if (editables.length === 0) break;
|
|
2064
|
+
|
|
2065
|
+
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
2066
|
+
const nextIndex = currentIndex >= editables.length - 1 ? 0 : currentIndex + 1;
|
|
2067
|
+
|
|
2068
|
+
selectElement(editables[nextIndex]);
|
|
2069
|
+
}
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
case 'ArrowUp':
|
|
2074
|
+
case 'ArrowLeft': {
|
|
2075
|
+
if (!isTextEditingActive && selectedElement) {
|
|
2076
|
+
e.preventDefault();
|
|
2077
|
+
const editables = getEditableElements(currentSpec, currentLayout);
|
|
2078
|
+
if (editables.length === 0) break;
|
|
2079
|
+
|
|
2080
|
+
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
2081
|
+
const nextIndex = currentIndex <= 0 ? editables.length - 1 : currentIndex - 1;
|
|
2082
|
+
|
|
2083
|
+
selectElement(editables[nextIndex]);
|
|
2084
|
+
}
|
|
2085
|
+
break;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
case 'Enter': {
|
|
2089
|
+
if (selectedElement && !isTextEditingActive) {
|
|
2090
|
+
if (isTextEditable(selectedElement, specAnnotations)) {
|
|
2091
|
+
e.preventDefault();
|
|
2092
|
+
enterTextEditing();
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
break;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
|
|
2100
|
+
svg.addEventListener('keydown', handleKeyDown);
|
|
2101
|
+
|
|
2102
|
+
return () => {
|
|
2103
|
+
svg.removeEventListener('keydown', handleKeyDown);
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1563
2107
|
function render(): void {
|
|
1564
2108
|
// Defer re-render if a drag is in progress to avoid destroying the dragged element
|
|
1565
2109
|
if (isDragging) {
|
|
@@ -1567,6 +2111,13 @@ export function createChart(
|
|
|
1567
2111
|
return;
|
|
1568
2112
|
}
|
|
1569
2113
|
|
|
2114
|
+
// Cancel any in-progress entrance animations before tearing down
|
|
2115
|
+
if (cleanupAnimations) {
|
|
2116
|
+
cleanupAnimations();
|
|
2117
|
+
cleanupAnimations = null;
|
|
2118
|
+
}
|
|
2119
|
+
cancelAnimations(svgElement);
|
|
2120
|
+
|
|
1570
2121
|
// Clean up previous render
|
|
1571
2122
|
if (cleanupTooltipEvents) {
|
|
1572
2123
|
cleanupTooltipEvents();
|
|
@@ -1596,6 +2147,20 @@ export function createChart(
|
|
|
1596
2147
|
cleanupEditDrags();
|
|
1597
2148
|
cleanupEditDrags = null;
|
|
1598
2149
|
}
|
|
2150
|
+
if (cleanupSelection) {
|
|
2151
|
+
cleanupSelection();
|
|
2152
|
+
cleanupSelection = null;
|
|
2153
|
+
}
|
|
2154
|
+
if (cleanupKeyboardEdit) {
|
|
2155
|
+
cleanupKeyboardEdit();
|
|
2156
|
+
cleanupKeyboardEdit = null;
|
|
2157
|
+
}
|
|
2158
|
+
if (textEditCleanup) {
|
|
2159
|
+
textEditCleanup();
|
|
2160
|
+
textEditCleanup = null;
|
|
2161
|
+
isTextEditingActive = false;
|
|
2162
|
+
}
|
|
2163
|
+
overlayElement = null;
|
|
1599
2164
|
if (svgElement?.parentNode) {
|
|
1600
2165
|
svgElement.parentNode.removeChild(svgElement);
|
|
1601
2166
|
}
|
|
@@ -1608,7 +2173,8 @@ export function createChart(
|
|
|
1608
2173
|
}
|
|
1609
2174
|
|
|
1610
2175
|
currentLayout = compile();
|
|
1611
|
-
|
|
2176
|
+
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
|
|
2177
|
+
svgElement = renderChartSVG(currentLayout, container, { animate: shouldAnimate });
|
|
1612
2178
|
tooltipManager = createTooltipManager(container);
|
|
1613
2179
|
|
|
1614
2180
|
// Wire tooltip events on mark elements
|
|
@@ -1709,27 +2275,63 @@ export function createChart(
|
|
|
1709
2275
|
};
|
|
1710
2276
|
}
|
|
1711
2277
|
|
|
2278
|
+
// Wire selection and keyboard edit events when editing callbacks are provided
|
|
2279
|
+
if (hasEditingCallbacks(options)) {
|
|
2280
|
+
makeEditable(svgElement);
|
|
2281
|
+
cleanupSelection = wireSelectionEvents();
|
|
2282
|
+
cleanupKeyboardEdit = wireKeyboardEditEvents();
|
|
2283
|
+
|
|
2284
|
+
// Restore selection overlay after re-render
|
|
2285
|
+
if (selectedElement) {
|
|
2286
|
+
const target = findElementByRef(svgElement, selectedElement);
|
|
2287
|
+
if (target) {
|
|
2288
|
+
overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
|
|
2289
|
+
} else {
|
|
2290
|
+
// Element no longer exists in DOM, clear selection silently
|
|
2291
|
+
selectedElement = null;
|
|
2292
|
+
overlayElement = null;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
1712
2297
|
// Create hidden data table for screen readers
|
|
1713
2298
|
srTable = createScreenReaderTable(currentLayout, container);
|
|
1714
2299
|
|
|
1715
2300
|
// Apply container classes for CSS variable scoping and dark mode
|
|
1716
|
-
container.classList.add('
|
|
2301
|
+
container.classList.add('oc-root');
|
|
1717
2302
|
const isDark = resolveDarkMode(options?.darkMode);
|
|
1718
2303
|
if (isDark) {
|
|
1719
|
-
container.classList.add('
|
|
2304
|
+
container.classList.add('oc-dark');
|
|
1720
2305
|
} else {
|
|
1721
|
-
container.classList.remove('
|
|
2306
|
+
container.classList.remove('oc-dark');
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Set up animation cleanup on first render only.
|
|
2310
|
+
if (shouldAnimate && svgElement) {
|
|
2311
|
+
cleanupAnimations = setupAnimationCleanup(svgElement);
|
|
2312
|
+
}
|
|
2313
|
+
if (isFirstRender) {
|
|
2314
|
+
isFirstRender = false;
|
|
1722
2315
|
}
|
|
1723
2316
|
}
|
|
1724
2317
|
|
|
1725
|
-
function update(newSpec: ChartSpec | GraphSpec): void {
|
|
2318
|
+
function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
|
|
1726
2319
|
if (destroyed) return;
|
|
1727
2320
|
currentSpec = newSpec;
|
|
2321
|
+
if (updateOpts && 'selectedElement' in updateOpts) {
|
|
2322
|
+
selectedElement = updateOpts.selectedElement ?? null;
|
|
2323
|
+
}
|
|
1728
2324
|
render();
|
|
1729
2325
|
}
|
|
1730
2326
|
|
|
1731
2327
|
function resize(): void {
|
|
1732
2328
|
if (destroyed) return;
|
|
2329
|
+
// Skip resize during entrance animation. The resize observer fires
|
|
2330
|
+
// immediately when the container first enters DOM layout, and re-rendering
|
|
2331
|
+
// would destroy the animated SVG. This also blocks genuine resizes during
|
|
2332
|
+
// the animation window (~1s), but catches up on the next resize event
|
|
2333
|
+
// after the cleanup timeout fires.
|
|
2334
|
+
if (cleanupAnimations) return;
|
|
1733
2335
|
render();
|
|
1734
2336
|
}
|
|
1735
2337
|
|
|
@@ -1768,6 +2370,13 @@ export function createChart(
|
|
|
1768
2370
|
if (destroyed) return;
|
|
1769
2371
|
destroyed = true;
|
|
1770
2372
|
|
|
2373
|
+
// Cancel entrance animations
|
|
2374
|
+
if (cleanupAnimations) {
|
|
2375
|
+
cleanupAnimations();
|
|
2376
|
+
cleanupAnimations = null;
|
|
2377
|
+
}
|
|
2378
|
+
cancelAnimations(svgElement);
|
|
2379
|
+
|
|
1771
2380
|
if (resizeTimer !== null) {
|
|
1772
2381
|
clearTimeout(resizeTimer);
|
|
1773
2382
|
resizeTimer = null;
|
|
@@ -1800,6 +2409,21 @@ export function createChart(
|
|
|
1800
2409
|
cleanupEditDrags();
|
|
1801
2410
|
cleanupEditDrags = null;
|
|
1802
2411
|
}
|
|
2412
|
+
if (cleanupSelection) {
|
|
2413
|
+
cleanupSelection();
|
|
2414
|
+
cleanupSelection = null;
|
|
2415
|
+
}
|
|
2416
|
+
if (cleanupKeyboardEdit) {
|
|
2417
|
+
cleanupKeyboardEdit();
|
|
2418
|
+
cleanupKeyboardEdit = null;
|
|
2419
|
+
}
|
|
2420
|
+
if (textEditCleanup) {
|
|
2421
|
+
textEditCleanup();
|
|
2422
|
+
textEditCleanup = null;
|
|
2423
|
+
isTextEditingActive = false;
|
|
2424
|
+
}
|
|
2425
|
+
selectedElement = null;
|
|
2426
|
+
overlayElement = null;
|
|
1803
2427
|
if (disconnectResize) {
|
|
1804
2428
|
disconnectResize();
|
|
1805
2429
|
disconnectResize = null;
|
|
@@ -1816,8 +2440,8 @@ export function createChart(
|
|
|
1816
2440
|
srTable.parentNode.removeChild(srTable);
|
|
1817
2441
|
srTable = null;
|
|
1818
2442
|
}
|
|
1819
|
-
container.classList.remove('
|
|
1820
|
-
container.classList.remove('
|
|
2443
|
+
container.classList.remove('oc-dark');
|
|
2444
|
+
container.classList.remove('oc-root');
|
|
1821
2445
|
}
|
|
1822
2446
|
|
|
1823
2447
|
// Initial render
|
|
@@ -1842,5 +2466,19 @@ export function createChart(
|
|
|
1842
2466
|
get layout() {
|
|
1843
2467
|
return currentLayout;
|
|
1844
2468
|
},
|
|
2469
|
+
getSelectedElement(): ElementRef | null {
|
|
2470
|
+
return selectedElement;
|
|
2471
|
+
},
|
|
2472
|
+
select(ref: ElementRef): void {
|
|
2473
|
+
if (destroyed) return;
|
|
2474
|
+
selectElement(ref);
|
|
2475
|
+
},
|
|
2476
|
+
deselect(): void {
|
|
2477
|
+
if (destroyed) return;
|
|
2478
|
+
deselectElement();
|
|
2479
|
+
},
|
|
2480
|
+
get isEditing(): boolean {
|
|
2481
|
+
return isTextEditingActive;
|
|
2482
|
+
},
|
|
1845
2483
|
};
|
|
1846
2484
|
}
|