@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/mount.ts
DELETED
|
@@ -1,1382 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mount API: the main entry point for vanilla JS usage.
|
|
3
|
-
*
|
|
4
|
-
* createChart() takes a container, spec, and options, compiles the chart,
|
|
5
|
-
* renders it as SVG, sets up responsive resizing, tooltip interaction
|
|
6
|
-
* (mouse/touch/keyboard), keyboard navigation between data points,
|
|
7
|
-
* and returns a ChartInstance with update/resize/export/destroy methods.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type {
|
|
11
|
-
Annotation,
|
|
12
|
-
ChartEventHandlers,
|
|
13
|
-
ChartLayout,
|
|
14
|
-
ChartSpec,
|
|
15
|
-
CompileOptions,
|
|
16
|
-
DarkMode,
|
|
17
|
-
DataRow,
|
|
18
|
-
ElementRef,
|
|
19
|
-
GraphSpec,
|
|
20
|
-
LayerSpec,
|
|
21
|
-
ThemeConfig,
|
|
22
|
-
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import { cssTokenDefault, isGraphSpec, isLayerSpec } from '@opendata-ai/openchart-core';
|
|
24
|
-
import {
|
|
25
|
-
compileChart,
|
|
26
|
-
compileLayer,
|
|
27
|
-
facetMinHeight,
|
|
28
|
-
legendGap,
|
|
29
|
-
} from '@opendata-ai/openchart-engine';
|
|
30
|
-
import { cancelAnimations, setupAnimationCleanup } from './animation';
|
|
31
|
-
import {
|
|
32
|
-
exportCSV,
|
|
33
|
-
exportJPG,
|
|
34
|
-
exportPNG,
|
|
35
|
-
exportSVG,
|
|
36
|
-
exportSVGWithFonts,
|
|
37
|
-
type JPGExportOptions,
|
|
38
|
-
type SVGExportOptions,
|
|
39
|
-
} from './export';
|
|
40
|
-
import {
|
|
41
|
-
buildElementRef,
|
|
42
|
-
createScreenReaderTable,
|
|
43
|
-
findElementByRef,
|
|
44
|
-
getEditableElements,
|
|
45
|
-
getElementText,
|
|
46
|
-
isTextEditable,
|
|
47
|
-
refsEqual,
|
|
48
|
-
renderSelectionOverlay,
|
|
49
|
-
wireAnnotationDrag,
|
|
50
|
-
wireAnnotationLabelDrag,
|
|
51
|
-
wireChartEvents,
|
|
52
|
-
wireChromeDrag,
|
|
53
|
-
wireConnectorEndpointDrag,
|
|
54
|
-
wireKeyboardNav,
|
|
55
|
-
wireLegendDrag,
|
|
56
|
-
wireLegendInteraction,
|
|
57
|
-
wireSeriesLabelDrag,
|
|
58
|
-
wireTooltipEvents,
|
|
59
|
-
wireVoronoiTooltipEvents,
|
|
60
|
-
} from './interactions';
|
|
61
|
-
import { createMeasureText, resolveFontFamily, scheduleFontReload } from './measure-text';
|
|
62
|
-
import { observeResize } from './resize-observer';
|
|
63
|
-
import { createSeriesSearch, type SeriesSearchController } from './series-search';
|
|
64
|
-
import { renderChartSVG } from './svg-renderer';
|
|
65
|
-
import { createTextEditOverlay } from './text-edit-overlay';
|
|
66
|
-
import { stampThemeProperties } from './theme-tokens';
|
|
67
|
-
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
68
|
-
import { canTransition, type GeometrySnapshot, runTransition } from './transition';
|
|
69
|
-
import { createYouDrawIt, type YouDrawItController } from './you-draw-it';
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Types
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
export interface MountOptions extends ChartEventHandlers {
|
|
76
|
-
/** Theme overrides. */
|
|
77
|
-
theme?: ThemeConfig;
|
|
78
|
-
/** Dark mode setting: "auto" (system pref), "force", or "off". */
|
|
79
|
-
darkMode?: DarkMode;
|
|
80
|
-
/** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */
|
|
81
|
-
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
82
|
-
/** Enable responsive resizing. Defaults to true. */
|
|
83
|
-
responsive?: boolean;
|
|
84
|
-
/** Show the tryOpenData.ai watermark. Defaults to true. */
|
|
85
|
-
watermark?: boolean;
|
|
86
|
-
/** Initial selected element. */
|
|
87
|
-
selectedElement?: ElementRef;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface UpdateOptions {
|
|
91
|
-
/** Override the selected element after update. When omitted, preserves current selection. */
|
|
92
|
-
selectedElement?: ElementRef;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface ExportOptions extends JPGExportOptions {
|
|
96
|
-
// Extensible for future formats (extends JPGExportOptions which extends PNGExportOptions)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface ChartInstance {
|
|
100
|
-
/** Re-compile and re-render with a new spec. */
|
|
101
|
-
update(spec: ChartSpec | LayerSpec | GraphSpec, options?: UpdateOptions): void;
|
|
102
|
-
/** Re-compile at current container dimensions. */
|
|
103
|
-
resize(): void;
|
|
104
|
-
/** Export the chart. */
|
|
105
|
-
export(format: 'svg'): string;
|
|
106
|
-
export(format: 'svg-with-fonts', options?: SVGExportOptions): Promise<string>;
|
|
107
|
-
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
108
|
-
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
109
|
-
export(format: 'csv'): string;
|
|
110
|
-
export(
|
|
111
|
-
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv',
|
|
112
|
-
options?: ExportOptions,
|
|
113
|
-
): string | Promise<Blob> | Promise<string>;
|
|
114
|
-
/** Remove all DOM elements and disconnect observers. */
|
|
115
|
-
destroy(): void;
|
|
116
|
-
/** The current compiled layout (for hooks / debugging). */
|
|
117
|
-
readonly layout: ChartLayout;
|
|
118
|
-
/** Get the currently selected element, or null if none. */
|
|
119
|
-
getSelectedElement(): ElementRef | null;
|
|
120
|
-
/** Programmatically select an element. Silent no-op if element not found. */
|
|
121
|
-
select(ref: ElementRef): void;
|
|
122
|
-
/** Deselect the current element. */
|
|
123
|
-
deselect(): void;
|
|
124
|
-
/** Whether inline text editing is active. */
|
|
125
|
-
readonly isEditing: boolean;
|
|
126
|
-
/** Set highlight values on the color encoding and re-render. Pass null to clear. */
|
|
127
|
-
setHighlight(values: string[] | null): void;
|
|
128
|
-
/**
|
|
129
|
-
* Reset a "you draw it" (`youDrawIt`) drawing: clears the reader's guess and
|
|
130
|
-
* returns to the drawing state. No-op when youDrawIt isn't enabled.
|
|
131
|
-
*/
|
|
132
|
-
resetDrawing(): void;
|
|
133
|
-
/**
|
|
134
|
-
* Reveal a "you draw it" drawing programmatically (equivalent to the
|
|
135
|
-
* skip-to-reveal button). No-op when youDrawIt isn't enabled.
|
|
136
|
-
*/
|
|
137
|
-
revealDrawing(): void;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// Container sizing
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Fallback height when the container doesn't constrain height. For
|
|
146
|
-
* auto-height ChartSpec mounts this is the fixed budget for the
|
|
147
|
-
* visualization itself (padding + axes + plot); chrome, the top legend
|
|
148
|
-
* block, and the metrics bar grow the figure on top of it.
|
|
149
|
-
*/
|
|
150
|
-
const FALLBACK_HEIGHT = 400;
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
// Dark mode resolution
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
|
|
156
|
-
function resolveDarkMode(mode?: DarkMode): boolean {
|
|
157
|
-
if (mode === 'force') return true;
|
|
158
|
-
if (mode === 'off' || mode === undefined) return false;
|
|
159
|
-
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
160
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
161
|
-
}
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
// Editable element helpers
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
|
|
169
|
-
const EDITABLE_HOVER_CSS = `
|
|
170
|
-
.oc-editable-hover {
|
|
171
|
-
outline: 1.5px solid ${cssTokenDefault('--oc-editable-hover', 'light')};
|
|
172
|
-
outline-offset: 2px;
|
|
173
|
-
border-radius: 2px;
|
|
174
|
-
}
|
|
175
|
-
`;
|
|
176
|
-
|
|
177
|
-
function makeEditable(svg: SVGElement): void {
|
|
178
|
-
svg.setAttribute('tabindex', '0');
|
|
179
|
-
svg.style.outline = 'none';
|
|
180
|
-
|
|
181
|
-
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
182
|
-
style.textContent = EDITABLE_HOVER_CSS;
|
|
183
|
-
svg.insertBefore(style, svg.firstChild);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function hasEditingCallbacks(opts?: MountOptions): boolean {
|
|
187
|
-
return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ---------------------------------------------------------------------------
|
|
191
|
-
// Series search helpers
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
|
|
194
|
-
/** The authored encoding.color.highlight as an array (empty when unset). */
|
|
195
|
-
function authoredHighlight(spec: ChartSpec | LayerSpec | GraphSpec): string[] {
|
|
196
|
-
if (isLayerSpec(spec) || isGraphSpec(spec as unknown as Record<string, unknown>)) return [];
|
|
197
|
-
const colorEnc = (spec as ChartSpec).encoding?.color;
|
|
198
|
-
if (!colorEnc || typeof colorEnc !== 'object' || !('field' in colorEnc)) return [];
|
|
199
|
-
const raw = (colorEnc as { highlight?: string | string[] }).highlight;
|
|
200
|
-
if (raw == null) return [];
|
|
201
|
-
return Array.isArray(raw) ? [...raw] : [raw];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** True when the spec enables the series search input. */
|
|
205
|
-
function wantsSeriesSearch(spec: ChartSpec | LayerSpec | GraphSpec): boolean {
|
|
206
|
-
if (isLayerSpec(spec) || isGraphSpec(spec as unknown as Record<string, unknown>)) return false;
|
|
207
|
-
return !!(spec as ChartSpec).seriesSearch;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** True when the spec enables the "you draw it" interactive format. */
|
|
211
|
-
function wantsYouDrawIt(spec: ChartSpec | LayerSpec | GraphSpec): boolean {
|
|
212
|
-
if (isLayerSpec(spec) || isGraphSpec(spec as unknown as Record<string, unknown>)) return false;
|
|
213
|
-
return !!(spec as ChartSpec).youDrawIt;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
// Main API
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Create a chart instance from a spec and mount it into a container.
|
|
222
|
-
*/
|
|
223
|
-
export function createChart<TData extends DataRow = DataRow>(
|
|
224
|
-
container: HTMLElement,
|
|
225
|
-
spec: ChartSpec<TData> | LayerSpec<TData> | GraphSpec,
|
|
226
|
-
options?: MountOptions,
|
|
227
|
-
): ChartInstance {
|
|
228
|
-
let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec as ChartSpec | LayerSpec | GraphSpec;
|
|
229
|
-
let currentLayout: ChartLayout;
|
|
230
|
-
let svgElement: SVGElement | null = null;
|
|
231
|
-
let tooltipManager: TooltipManager | null = null;
|
|
232
|
-
let disconnectResize: (() => void) | null = null;
|
|
233
|
-
let cleanupTooltipEvents: (() => void) | null = null;
|
|
234
|
-
let cleanupVoronoiEvents: (() => void) | null = null;
|
|
235
|
-
let cleanupKeyboardNav: (() => void) | null = null;
|
|
236
|
-
let cleanupLegend: (() => void) | null = null;
|
|
237
|
-
let cleanupChartEvents: (() => void) | null = null;
|
|
238
|
-
let cleanupAnnotationDrag: (() => void) | null = null;
|
|
239
|
-
let cleanupEditDrags: (() => void) | null = null;
|
|
240
|
-
let cleanupSelection: (() => void) | null = null;
|
|
241
|
-
let cleanupKeyboardEdit: (() => void) | null = null;
|
|
242
|
-
let srTable: HTMLTableElement | null = null;
|
|
243
|
-
let destroyed = false;
|
|
244
|
-
let isDragging = false;
|
|
245
|
-
let pendingRender = false;
|
|
246
|
-
|
|
247
|
-
// Animation state
|
|
248
|
-
let isFirstRender = true;
|
|
249
|
-
let cleanupAnimations: (() => void) | null = null;
|
|
250
|
-
let pendingResize = false;
|
|
251
|
-
|
|
252
|
-
// Auto-height sizing state. `isAutoHeight` latches whether the host
|
|
253
|
-
// constrains the container's height:
|
|
254
|
-
// null = unknown — every measurement so far was an all-zero rect
|
|
255
|
-
// (display:none / detached); keep re-checking each measure.
|
|
256
|
-
// true = auto-height — the figure grows with chrome (ChartSpec only).
|
|
257
|
-
// false = explicit height — fit inside the host's box (today's behavior).
|
|
258
|
-
// The latch matters because the SVG's own style.height gives the container
|
|
259
|
-
// a nonzero rect after the first render, so auto-height cannot be
|
|
260
|
-
// re-derived per measure.
|
|
261
|
-
let isAutoHeight: boolean | null = null;
|
|
262
|
-
let lastRenderedWidth: number | null = null;
|
|
263
|
-
let lastRenderedSvgHeight: number | null = null;
|
|
264
|
-
|
|
265
|
-
// Data-update transition state
|
|
266
|
-
let transitionHandle: import('./transition').TransitionHandle | null = null;
|
|
267
|
-
let transitionSnapshot: GeometrySnapshot | null = null;
|
|
268
|
-
|
|
269
|
-
// Set when webfonts have loaded and a recompile is owed to reflect final font
|
|
270
|
-
// metrics. The next render() that actually recompiles flips
|
|
271
|
-
// data-oc-fonts-state to 'ready' and clears this. Deferring the flip (rather
|
|
272
|
-
// than setting it right after resize()) keeps the attribute honest when the
|
|
273
|
-
// entrance animation makes resize() defer to pendingResize.
|
|
274
|
-
let fontsReloadPending = false;
|
|
275
|
-
|
|
276
|
-
// Selection and text editing state
|
|
277
|
-
let selectedElement: ElementRef | null = options?.selectedElement ?? null;
|
|
278
|
-
let overlayElement: SVGGElement | null = null;
|
|
279
|
-
let isTextEditingActive = false;
|
|
280
|
-
|
|
281
|
-
// Runtime legend-toggle state
|
|
282
|
-
const runtimeHiddenSeries = new Set<string>();
|
|
283
|
-
const runtimeShownSeries = new Set<string>();
|
|
284
|
-
let textEditCleanup: (() => void) | null = null;
|
|
285
|
-
|
|
286
|
-
// Series search state. The control persists across renders (so focus and
|
|
287
|
-
// chips survive the re-render each selection triggers); the baseline is the
|
|
288
|
-
// authored encoding.color.highlight, captured before setHighlight mutates
|
|
289
|
-
// currentSpec, so clearing the search restores it exactly.
|
|
290
|
-
let seriesSearch: SeriesSearchController | null = null;
|
|
291
|
-
let searchBaseline: string[] = authoredHighlight(currentSpec);
|
|
292
|
-
let editSuppressWarned = false;
|
|
293
|
-
|
|
294
|
-
// "You draw it" state. The controller persists across renders so the reader's
|
|
295
|
-
// in-progress guess (and revealed state) survive the re-render each resize
|
|
296
|
-
// triggers; update() re-syncs geometry and re-wires pointer capture.
|
|
297
|
-
let youDrawIt: YouDrawItController | null = null;
|
|
298
|
-
let ydiEditSuppressWarned = false;
|
|
299
|
-
|
|
300
|
-
// Apply the root class up front so getComputedStyle can read --oc-font-family
|
|
301
|
-
// before we build the text measurer.
|
|
302
|
-
container.classList.add('oc-root');
|
|
303
|
-
|
|
304
|
-
// Resolve the effective font family the way compile() will. compile merges
|
|
305
|
-
// { ...spec.theme, ...options.theme }, so options.theme wins over the
|
|
306
|
-
// spec-level theme; fall back to the container's computed font. Measuring
|
|
307
|
-
// against a different font than compile renders (e.g. a spec that sets
|
|
308
|
-
// theme.fonts.family) desyncs layout metrics and the font-reload watcher.
|
|
309
|
-
function resolveEffectiveFont(): string {
|
|
310
|
-
return (
|
|
311
|
-
options?.theme?.fonts?.family ??
|
|
312
|
-
currentSpec.theme?.fonts?.family ??
|
|
313
|
-
resolveFontFamily(container)
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
let fontFamily = resolveEffectiveFont();
|
|
317
|
-
let measureText = createMeasureText(fontFamily);
|
|
318
|
-
let renderGen = 0;
|
|
319
|
-
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
// Compilation
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
|
|
324
|
-
function compileAt(width: number, height: number): ChartLayout {
|
|
325
|
-
const darkMode = resolveDarkMode(options?.darkMode);
|
|
326
|
-
|
|
327
|
-
const compileOpts: CompileOptions = {
|
|
328
|
-
width,
|
|
329
|
-
height,
|
|
330
|
-
theme: options?.theme,
|
|
331
|
-
darkMode,
|
|
332
|
-
watermark: options?.watermark,
|
|
333
|
-
measureText,
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
if (isLayerSpec(currentSpec)) {
|
|
337
|
-
return compileLayer(withRuntimeHidden(currentSpec as LayerSpec) as LayerSpec, compileOpts);
|
|
338
|
-
}
|
|
339
|
-
return compileChart(withRuntimeHidden(currentSpec) as ChartSpec | GraphSpec, compileOpts);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Vertical overheads that grow an auto-height figure beyond the
|
|
344
|
-
* FALLBACK_HEIGHT budget: top+bottom chrome (bottom chrome already includes
|
|
345
|
-
* a bottom legend's reservation), the top legend block (height + gap), and
|
|
346
|
-
* the KPI metrics bar. topPad and the axis gap are plot clearance, not
|
|
347
|
-
* chrome — they stay inside the budget.
|
|
348
|
-
*
|
|
349
|
-
* For faceted charts, extra rows beyond what the base budget can fit
|
|
350
|
-
* also contribute: each additional row needs space for the panel itself
|
|
351
|
-
* plus its header and gap.
|
|
352
|
-
*/
|
|
353
|
-
function verticalOverheads(layout: ChartLayout, width: number): number {
|
|
354
|
-
// Sum EVERY top legend, not just the primary. A chart can carry a color
|
|
355
|
-
// legend and a size legend; counting one and growing the container by its
|
|
356
|
-
// height alone leaves the other squeezing the plot.
|
|
357
|
-
let topLegendBlock = 0;
|
|
358
|
-
for (const legend of layout.legends ?? [layout.legend]) {
|
|
359
|
-
if (legend.position !== 'top') continue;
|
|
360
|
-
const hasContent =
|
|
361
|
-
legend.type === 'continuous' || legend.type === 'size'
|
|
362
|
-
? legend.bounds.height > 0
|
|
363
|
-
: 'entries' in legend && legend.entries.length > 0;
|
|
364
|
-
if (hasContent) topLegendBlock += legend.bounds.height + legendGap(width);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
let facetGrowth = 0;
|
|
368
|
-
if (layout.facet) {
|
|
369
|
-
const needed = facetMinHeight(layout.facet.panels.length, layout.facet.columns);
|
|
370
|
-
const budgetForPanels =
|
|
371
|
-
FALLBACK_HEIGHT - layout.chrome.topHeight - layout.chrome.bottomHeight - topLegendBlock;
|
|
372
|
-
if (needed > budgetForPanels) {
|
|
373
|
-
facetGrowth = needed - budgetForPanels;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return (
|
|
378
|
-
layout.chrome.topHeight +
|
|
379
|
-
layout.chrome.bottomHeight +
|
|
380
|
-
topLegendBlock +
|
|
381
|
-
(layout.metrics?.height ?? 0) +
|
|
382
|
-
facetGrowth
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function compile(): ChartLayout {
|
|
387
|
-
const { width, height } = getContainerDimensions();
|
|
388
|
-
|
|
389
|
-
// Auto-height growth applies to ChartSpec mounts only. GraphSpec,
|
|
390
|
-
// LayerSpec, and sparkline mounts keep the fixed-fallback behavior.
|
|
391
|
-
const growsWithChrome =
|
|
392
|
-
isAutoHeight === true &&
|
|
393
|
-
!isLayerSpec(currentSpec) &&
|
|
394
|
-
!isGraphSpec(currentSpec as unknown as Record<string, unknown>) &&
|
|
395
|
-
(currentSpec as ChartSpec).display !== 'sparkline';
|
|
396
|
-
if (!growsWithChrome) {
|
|
397
|
-
return compileAt(width, height);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Bounded convergence loop, max 3 compiles. Chrome height depends on
|
|
401
|
-
// width (text wrap), not height — but the engine's chrome-strip guardrail
|
|
402
|
-
// strips chrome when the residual plot is too small, which makes chrome
|
|
403
|
-
// height-dependent at the budget boundary: growing the figure can
|
|
404
|
-
// un-strip chrome, changing the overheads once more. (Height classes are
|
|
405
|
-
// not the issue — they only strip below 200/350px, and growing from 400
|
|
406
|
-
// moves away from those thresholds.) Pass 3 is accepted unconditionally.
|
|
407
|
-
let layout = compileAt(width, height);
|
|
408
|
-
const overheads = verticalOverheads(layout, width);
|
|
409
|
-
if (overheads > 0) {
|
|
410
|
-
layout = compileAt(width, height + overheads);
|
|
411
|
-
const overheads2 = verticalOverheads(layout, width);
|
|
412
|
-
if (overheads2 !== overheads) {
|
|
413
|
-
layout = compileAt(width, height + overheads2);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
return layout;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function withRuntimeHidden<T extends { hiddenSeries?: string[]; annotations?: Annotation[] }>(
|
|
420
|
-
spec: T,
|
|
421
|
-
): T {
|
|
422
|
-
if (runtimeHiddenSeries.size === 0 && runtimeShownSeries.size === 0) return spec;
|
|
423
|
-
const userHidden = spec.hiddenSeries ?? [];
|
|
424
|
-
const finalHidden = new Set<string>(userHidden);
|
|
425
|
-
for (const s of runtimeHiddenSeries) finalHidden.add(s);
|
|
426
|
-
for (const s of runtimeShownSeries) finalHidden.delete(s);
|
|
427
|
-
const out: T = { ...spec, hiddenSeries: Array.from(finalHidden) };
|
|
428
|
-
if (finalHidden.size > 0 && spec.annotations) {
|
|
429
|
-
const filtered = spec.annotations.filter((a) => a.type !== 'text');
|
|
430
|
-
out.annotations = filtered.length > 0 ? filtered : undefined;
|
|
431
|
-
}
|
|
432
|
-
return out;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// ---------------------------------------------------------------------------
|
|
436
|
-
// Legend toggle
|
|
437
|
-
// ---------------------------------------------------------------------------
|
|
438
|
-
|
|
439
|
-
function countSeries(spec: ChartSpec | GraphSpec | LayerSpec): number {
|
|
440
|
-
if (isLayerSpec(spec)) return Infinity;
|
|
441
|
-
const enc = (spec as ChartSpec).encoding;
|
|
442
|
-
const colorEnc = enc?.color;
|
|
443
|
-
if (!colorEnc || 'condition' in colorEnc) return Infinity;
|
|
444
|
-
if (!('field' in colorEnc) || !colorEnc.field) return Infinity;
|
|
445
|
-
if (colorEnc.type === 'quantitative') return Infinity;
|
|
446
|
-
const field = colorEnc.field;
|
|
447
|
-
const seen = new Set<string>();
|
|
448
|
-
for (const row of (spec as ChartSpec).data ?? []) {
|
|
449
|
-
seen.add(String((row as Record<string, unknown>)[field]));
|
|
450
|
-
}
|
|
451
|
-
return seen.size;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function isSeriesHidden(series: string): boolean {
|
|
455
|
-
if (runtimeShownSeries.has(series)) return false;
|
|
456
|
-
if (runtimeHiddenSeries.has(series)) return true;
|
|
457
|
-
const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
|
|
458
|
-
return userHidden.includes(series);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function toggleSeriesVisibility(series: string): boolean {
|
|
462
|
-
const wasHidden = isSeriesHidden(series);
|
|
463
|
-
if (!wasHidden) {
|
|
464
|
-
const total = countSeries(currentSpec);
|
|
465
|
-
if (Number.isFinite(total)) {
|
|
466
|
-
const userHidden = new Set((currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? []);
|
|
467
|
-
let visibleAfter = 0;
|
|
468
|
-
const seriesField = getColorField(currentSpec);
|
|
469
|
-
if (seriesField) {
|
|
470
|
-
const allSeries = new Set<string>();
|
|
471
|
-
for (const row of (currentSpec as ChartSpec).data ?? []) {
|
|
472
|
-
allSeries.add(String((row as Record<string, unknown>)[seriesField]));
|
|
473
|
-
}
|
|
474
|
-
for (const s of allSeries) {
|
|
475
|
-
if (s === series) continue;
|
|
476
|
-
if (runtimeShownSeries.has(s)) {
|
|
477
|
-
visibleAfter++;
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (runtimeHiddenSeries.has(s)) continue;
|
|
481
|
-
if (!userHidden.has(s)) visibleAfter++;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
if (visibleAfter === 0) return false;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
if (wasHidden) {
|
|
488
|
-
runtimeHiddenSeries.delete(series);
|
|
489
|
-
const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
|
|
490
|
-
if (userHidden.includes(series)) runtimeShownSeries.add(series);
|
|
491
|
-
} else {
|
|
492
|
-
runtimeShownSeries.delete(series);
|
|
493
|
-
runtimeHiddenSeries.add(series);
|
|
494
|
-
}
|
|
495
|
-
render();
|
|
496
|
-
return !wasHidden;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function getColorField(spec: ChartSpec | GraphSpec | LayerSpec): string | undefined {
|
|
500
|
-
if (isLayerSpec(spec)) return undefined;
|
|
501
|
-
const colorEnc = (spec as ChartSpec).encoding?.color;
|
|
502
|
-
if (!colorEnc || 'condition' in colorEnc) return undefined;
|
|
503
|
-
if (!('field' in colorEnc) || !colorEnc.field) return undefined;
|
|
504
|
-
return colorEnc.field;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// ---------------------------------------------------------------------------
|
|
508
|
-
// Container dimensions
|
|
509
|
-
// ---------------------------------------------------------------------------
|
|
510
|
-
|
|
511
|
-
function getContainerDimensions(): { width: number; height: number } {
|
|
512
|
-
const rect = container.getBoundingClientRect();
|
|
513
|
-
const isSparkline =
|
|
514
|
-
'display' in currentSpec && (currentSpec as ChartSpec).display === 'sparkline';
|
|
515
|
-
if (isSparkline) {
|
|
516
|
-
let width = rect.width;
|
|
517
|
-
let height = rect.height;
|
|
518
|
-
if (!height && container.parentElement) {
|
|
519
|
-
const parentRect = container.parentElement.getBoundingClientRect();
|
|
520
|
-
height = parentRect.height;
|
|
521
|
-
if (!width) width = parentRect.width;
|
|
522
|
-
}
|
|
523
|
-
return {
|
|
524
|
-
width: Math.max(width || 200, 30),
|
|
525
|
-
height: Math.max(height || 40, 20),
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
// Latch auto-height on the first NONZERO measurement: height 0 with a
|
|
529
|
-
// real width means the host doesn't constrain height. An all-zero rect
|
|
530
|
-
// (display:none / detached) stays "unknown" — keep re-checking until the
|
|
531
|
-
// container produces a real measurement.
|
|
532
|
-
if (isAutoHeight === null && (rect.width > 0 || rect.height > 0)) {
|
|
533
|
-
isAutoHeight = rect.height === 0 && rect.width > 0;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
width: Math.max(rect.width || 600, 100),
|
|
538
|
-
// Latched-auto mounts always compile against the fixed budget: after
|
|
539
|
-
// the first render the container's measured height is just our own
|
|
540
|
-
// SVG, so reading it back would re-pin whatever we rendered last.
|
|
541
|
-
height:
|
|
542
|
-
isAutoHeight === true ? FALLBACK_HEIGHT : Math.max(rect.height || FALLBACK_HEIGHT, 100),
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ---------------------------------------------------------------------------
|
|
547
|
-
// Selection & text editing
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
function getSpecAnnotations(): Annotation[] {
|
|
551
|
-
return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
552
|
-
? currentSpec.annotations
|
|
553
|
-
: [];
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function selectElement(ref: ElementRef): void {
|
|
557
|
-
if (!svgElement) return;
|
|
558
|
-
|
|
559
|
-
const target = findElementByRef(svgElement, ref);
|
|
560
|
-
if (!target) return;
|
|
561
|
-
|
|
562
|
-
if (selectedElement && !refsEqual(selectedElement, ref)) {
|
|
563
|
-
deselectElement();
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
selectedElement = ref;
|
|
567
|
-
overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
|
|
568
|
-
options?.onSelect?.(ref);
|
|
569
|
-
|
|
570
|
-
(svgElement as SVGSVGElement).focus();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function deselectElement(): void {
|
|
574
|
-
if (!selectedElement) return;
|
|
575
|
-
|
|
576
|
-
if (isTextEditingActive && textEditCleanup) {
|
|
577
|
-
textEditCleanup();
|
|
578
|
-
textEditCleanup = null;
|
|
579
|
-
isTextEditingActive = false;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const prev = selectedElement;
|
|
583
|
-
selectedElement = null;
|
|
584
|
-
|
|
585
|
-
if (overlayElement?.parentNode) {
|
|
586
|
-
overlayElement.parentNode.removeChild(overlayElement);
|
|
587
|
-
}
|
|
588
|
-
overlayElement = null;
|
|
589
|
-
|
|
590
|
-
options?.onDeselect?.(prev);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function enterTextEditing(): void {
|
|
594
|
-
if (!svgElement || !selectedElement || isTextEditingActive) return;
|
|
595
|
-
|
|
596
|
-
const specAnnotations = getSpecAnnotations();
|
|
597
|
-
if (!isTextEditable(selectedElement, specAnnotations)) return;
|
|
598
|
-
|
|
599
|
-
const currentText = getElementText(selectedElement, currentSpec);
|
|
600
|
-
if (currentText === null) return;
|
|
601
|
-
|
|
602
|
-
const target = findElementByRef(svgElement, selectedElement);
|
|
603
|
-
if (!target) return;
|
|
604
|
-
|
|
605
|
-
const textEl = target.tagName === 'text' ? target : target.querySelector('text');
|
|
606
|
-
if (!textEl) return;
|
|
607
|
-
|
|
608
|
-
isTextEditingActive = true;
|
|
609
|
-
const editRef = selectedElement;
|
|
610
|
-
|
|
611
|
-
const overlay = createTextEditOverlay({
|
|
612
|
-
container,
|
|
613
|
-
svg: svgElement as SVGSVGElement,
|
|
614
|
-
targetElement: textEl as SVGElement,
|
|
615
|
-
currentText,
|
|
616
|
-
onCommit: (newText: string) => {
|
|
617
|
-
isTextEditingActive = false;
|
|
618
|
-
textEditCleanup = null;
|
|
619
|
-
|
|
620
|
-
if (newText !== currentText) {
|
|
621
|
-
options?.onTextEdit?.(editRef, currentText, newText);
|
|
622
|
-
options?.onEdit?.({
|
|
623
|
-
type: 'text-edit',
|
|
624
|
-
element: editRef,
|
|
625
|
-
oldText: currentText,
|
|
626
|
-
newText,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
},
|
|
630
|
-
onCancel: () => {
|
|
631
|
-
isTextEditingActive = false;
|
|
632
|
-
textEditCleanup = null;
|
|
633
|
-
},
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
textEditCleanup = overlay.destroy;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function wireSelectionEvents(): () => void {
|
|
640
|
-
if (!svgElement) return () => {};
|
|
641
|
-
|
|
642
|
-
const svg = svgElement;
|
|
643
|
-
const cleanups: Array<() => void> = [];
|
|
644
|
-
|
|
645
|
-
const handleClick = (e: Event) => {
|
|
646
|
-
const mouseEvent = e as MouseEvent;
|
|
647
|
-
const target = mouseEvent.target as Element;
|
|
648
|
-
|
|
649
|
-
if (isTextEditingActive) return;
|
|
650
|
-
|
|
651
|
-
const specAnnotations = getSpecAnnotations();
|
|
652
|
-
const ref = buildElementRef(target, specAnnotations);
|
|
653
|
-
|
|
654
|
-
if (ref) {
|
|
655
|
-
selectElement(ref);
|
|
656
|
-
} else {
|
|
657
|
-
deselectElement();
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
svg.addEventListener('click', handleClick);
|
|
662
|
-
cleanups.push(() => svg.removeEventListener('click', handleClick));
|
|
663
|
-
|
|
664
|
-
const handleMouseEnter = (e: Event) => {
|
|
665
|
-
const target = (e.target as Element).closest(
|
|
666
|
-
'[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend:not(.oc-legend--size), [data-legend-index]',
|
|
667
|
-
);
|
|
668
|
-
if (target) {
|
|
669
|
-
(target as SVGElement).classList.add('oc-editable-hover');
|
|
670
|
-
}
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
const handleMouseLeave = (e: Event) => {
|
|
674
|
-
const target = (e.target as Element).closest('.oc-editable-hover');
|
|
675
|
-
if (target) {
|
|
676
|
-
(target as SVGElement).classList.remove('oc-editable-hover');
|
|
677
|
-
}
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
svg.addEventListener('mouseenter', handleMouseEnter, true);
|
|
681
|
-
svg.addEventListener('mouseleave', handleMouseLeave, true);
|
|
682
|
-
cleanups.push(() => {
|
|
683
|
-
svg.removeEventListener('mouseenter', handleMouseEnter, true);
|
|
684
|
-
svg.removeEventListener('mouseleave', handleMouseLeave, true);
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
const handleDblClick = (e: Event) => {
|
|
688
|
-
const mouseEvent = e as MouseEvent;
|
|
689
|
-
const target = mouseEvent.target as Element;
|
|
690
|
-
const specAnnotations = getSpecAnnotations();
|
|
691
|
-
const ref = buildElementRef(target, specAnnotations);
|
|
692
|
-
|
|
693
|
-
if (ref && isTextEditable(ref, specAnnotations)) {
|
|
694
|
-
if (!refsEqual(selectedElement, ref)) {
|
|
695
|
-
selectElement(ref);
|
|
696
|
-
}
|
|
697
|
-
enterTextEditing();
|
|
698
|
-
}
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
svg.addEventListener('dblclick', handleDblClick);
|
|
702
|
-
cleanups.push(() => svg.removeEventListener('dblclick', handleDblClick));
|
|
703
|
-
|
|
704
|
-
return () => {
|
|
705
|
-
for (const cleanup of cleanups) {
|
|
706
|
-
cleanup();
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function wireKeyboardEditEvents(): () => void {
|
|
712
|
-
if (!svgElement) return () => {};
|
|
713
|
-
|
|
714
|
-
const svg = svgElement;
|
|
715
|
-
|
|
716
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
717
|
-
const specAnnotations = getSpecAnnotations();
|
|
718
|
-
|
|
719
|
-
switch (e.key) {
|
|
720
|
-
case 'Delete':
|
|
721
|
-
case 'Backspace': {
|
|
722
|
-
if (selectedElement && !isTextEditingActive) {
|
|
723
|
-
e.preventDefault();
|
|
724
|
-
options?.onEdit?.({ type: 'delete', element: selectedElement });
|
|
725
|
-
}
|
|
726
|
-
break;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
case 'Escape': {
|
|
730
|
-
e.preventDefault();
|
|
731
|
-
if (isTextEditingActive && textEditCleanup) {
|
|
732
|
-
textEditCleanup();
|
|
733
|
-
textEditCleanup = null;
|
|
734
|
-
isTextEditingActive = false;
|
|
735
|
-
} else if (selectedElement) {
|
|
736
|
-
deselectElement();
|
|
737
|
-
}
|
|
738
|
-
break;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
case 'ArrowDown':
|
|
742
|
-
case 'ArrowRight': {
|
|
743
|
-
if (!isTextEditingActive && selectedElement) {
|
|
744
|
-
e.preventDefault();
|
|
745
|
-
const editables = getEditableElements(currentSpec, currentLayout);
|
|
746
|
-
if (editables.length === 0) break;
|
|
747
|
-
|
|
748
|
-
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
749
|
-
const nextIndex = currentIndex >= editables.length - 1 ? 0 : currentIndex + 1;
|
|
750
|
-
|
|
751
|
-
selectElement(editables[nextIndex]);
|
|
752
|
-
}
|
|
753
|
-
break;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
case 'ArrowUp':
|
|
757
|
-
case 'ArrowLeft': {
|
|
758
|
-
if (!isTextEditingActive && selectedElement) {
|
|
759
|
-
e.preventDefault();
|
|
760
|
-
const editables = getEditableElements(currentSpec, currentLayout);
|
|
761
|
-
if (editables.length === 0) break;
|
|
762
|
-
|
|
763
|
-
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
764
|
-
const nextIndex = currentIndex <= 0 ? editables.length - 1 : currentIndex - 1;
|
|
765
|
-
|
|
766
|
-
selectElement(editables[nextIndex]);
|
|
767
|
-
}
|
|
768
|
-
break;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
case 'Enter': {
|
|
772
|
-
if (selectedElement && !isTextEditingActive) {
|
|
773
|
-
if (isTextEditable(selectedElement, specAnnotations)) {
|
|
774
|
-
e.preventDefault();
|
|
775
|
-
enterTextEditing();
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
break;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
|
|
783
|
-
svg.addEventListener('keydown', handleKeyDown);
|
|
784
|
-
|
|
785
|
-
return () => {
|
|
786
|
-
svg.removeEventListener('keydown', handleKeyDown);
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// ---------------------------------------------------------------------------
|
|
791
|
-
// Render cycle
|
|
792
|
-
// ---------------------------------------------------------------------------
|
|
793
|
-
|
|
794
|
-
function render(): void {
|
|
795
|
-
if (isDragging) {
|
|
796
|
-
pendingRender = true;
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Cancel any in-progress data-update transition
|
|
801
|
-
if (transitionHandle) {
|
|
802
|
-
transitionHandle.cancel();
|
|
803
|
-
transitionHandle = null;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// Cancel any in-progress entrance animations before tearing down
|
|
807
|
-
if (cleanupAnimations) {
|
|
808
|
-
cleanupAnimations();
|
|
809
|
-
cleanupAnimations = null;
|
|
810
|
-
}
|
|
811
|
-
cancelAnimations(svgElement);
|
|
812
|
-
|
|
813
|
-
// Clean up previous render
|
|
814
|
-
cleanupTooltipEvents?.();
|
|
815
|
-
cleanupTooltipEvents = null;
|
|
816
|
-
cleanupVoronoiEvents?.();
|
|
817
|
-
cleanupVoronoiEvents = null;
|
|
818
|
-
cleanupKeyboardNav?.();
|
|
819
|
-
cleanupKeyboardNav = null;
|
|
820
|
-
cleanupLegend?.();
|
|
821
|
-
cleanupLegend = null;
|
|
822
|
-
cleanupChartEvents?.();
|
|
823
|
-
cleanupChartEvents = null;
|
|
824
|
-
cleanupAnnotationDrag?.();
|
|
825
|
-
cleanupAnnotationDrag = null;
|
|
826
|
-
cleanupEditDrags?.();
|
|
827
|
-
cleanupEditDrags = null;
|
|
828
|
-
cleanupSelection?.();
|
|
829
|
-
cleanupSelection = null;
|
|
830
|
-
cleanupKeyboardEdit?.();
|
|
831
|
-
cleanupKeyboardEdit = null;
|
|
832
|
-
if (textEditCleanup) {
|
|
833
|
-
textEditCleanup();
|
|
834
|
-
textEditCleanup = null;
|
|
835
|
-
isTextEditingActive = false;
|
|
836
|
-
}
|
|
837
|
-
overlayElement = null;
|
|
838
|
-
if (svgElement?.parentNode) {
|
|
839
|
-
svgElement.parentNode.removeChild(svgElement);
|
|
840
|
-
}
|
|
841
|
-
if (tooltipManager) {
|
|
842
|
-
tooltipManager.destroy();
|
|
843
|
-
}
|
|
844
|
-
if (srTable?.parentNode) {
|
|
845
|
-
srTable.parentNode.removeChild(srTable);
|
|
846
|
-
srTable = null;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
currentLayout = compile();
|
|
850
|
-
// Recorded for the ResizeObserver's self-induced-resize guard.
|
|
851
|
-
lastRenderedWidth = currentLayout.dimensions.width;
|
|
852
|
-
lastRenderedSvgHeight = currentLayout.dimensions.height;
|
|
853
|
-
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
|
|
854
|
-
const crosshair = !!currentLayout.crosshair;
|
|
855
|
-
svgElement = renderChartSVG(currentLayout, container, {
|
|
856
|
-
animate: shouldAnimate,
|
|
857
|
-
crosshair,
|
|
858
|
-
});
|
|
859
|
-
tooltipManager = createTooltipManager(container);
|
|
860
|
-
|
|
861
|
-
// Wire tooltip events on mark elements
|
|
862
|
-
cleanupTooltipEvents = wireTooltipEvents(
|
|
863
|
-
svgElement,
|
|
864
|
-
currentLayout.tooltipDescriptors,
|
|
865
|
-
tooltipManager,
|
|
866
|
-
);
|
|
867
|
-
|
|
868
|
-
// Wire voronoi overlay tooltip events for line/area charts
|
|
869
|
-
cleanupVoronoiEvents = wireVoronoiTooltipEvents(svgElement, currentLayout, tooltipManager);
|
|
870
|
-
|
|
871
|
-
// Wire keyboard navigation. Skipped when the author hid the chart from
|
|
872
|
-
// assistive technology (a11y.hidden): a hidden chart must not be a tab stop.
|
|
873
|
-
if (!currentLayout.a11y.hidden) {
|
|
874
|
-
cleanupKeyboardNav = wireKeyboardNav(
|
|
875
|
-
svgElement,
|
|
876
|
-
container,
|
|
877
|
-
currentLayout.tooltipDescriptors,
|
|
878
|
-
tooltipManager,
|
|
879
|
-
currentLayout,
|
|
880
|
-
);
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// seriesSearch and edit mode are mutually exclusive per instance: when
|
|
884
|
-
// the spec enables seriesSearch, edit interactions are suppressed
|
|
885
|
-
// (search wins -- the engine already reserved the search band).
|
|
886
|
-
const editSuppressed = wantsSeriesSearch(currentSpec);
|
|
887
|
-
if (
|
|
888
|
-
editSuppressed &&
|
|
889
|
-
!editSuppressWarned &&
|
|
890
|
-
(hasEditingCallbacks(options) || options?.onAnnotationEdit)
|
|
891
|
-
) {
|
|
892
|
-
editSuppressWarned = true;
|
|
893
|
-
console.warn(
|
|
894
|
-
'[openchart] seriesSearch and edit mode are mutually exclusive; edit interactions are disabled while seriesSearch is enabled.',
|
|
895
|
-
);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Wire legend interactivity
|
|
899
|
-
cleanupLegend = wireLegendInteraction(
|
|
900
|
-
svgElement,
|
|
901
|
-
currentLayout,
|
|
902
|
-
toggleSeriesVisibility,
|
|
903
|
-
options?.onLegendToggle,
|
|
904
|
-
editSuppressed ? undefined : options?.onEdit,
|
|
905
|
-
);
|
|
906
|
-
|
|
907
|
-
// Wire chart event handlers (mark click/hover/leave, annotation click)
|
|
908
|
-
if (
|
|
909
|
-
options?.onMarkClick ||
|
|
910
|
-
options?.onMarkHover ||
|
|
911
|
-
options?.onMarkLeave ||
|
|
912
|
-
options?.onAnnotationClick
|
|
913
|
-
) {
|
|
914
|
-
const specAnnotations: Annotation[] =
|
|
915
|
-
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
916
|
-
? currentSpec.annotations
|
|
917
|
-
: [];
|
|
918
|
-
cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Shared setDragging callback for all drag handlers
|
|
922
|
-
const setDragging = (dragging: boolean) => {
|
|
923
|
-
isDragging = dragging;
|
|
924
|
-
if (!dragging && pendingRender) {
|
|
925
|
-
pendingRender = false;
|
|
926
|
-
render();
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
|
|
930
|
-
const dragAnnotations: Annotation[] =
|
|
931
|
-
'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
932
|
-
? currentSpec.annotations
|
|
933
|
-
: [];
|
|
934
|
-
|
|
935
|
-
// Wire annotation drag editing
|
|
936
|
-
if (!editSuppressed && (options?.onAnnotationEdit || options?.onEdit)) {
|
|
937
|
-
cleanupAnnotationDrag = wireAnnotationDrag(
|
|
938
|
-
svgElement,
|
|
939
|
-
dragAnnotations,
|
|
940
|
-
options?.onAnnotationEdit,
|
|
941
|
-
options?.onEdit,
|
|
942
|
-
setDragging,
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// Wire all edit drag handlers when onEdit is provided
|
|
947
|
-
if (!editSuppressed && options?.onEdit) {
|
|
948
|
-
const editCleanups: Array<() => void> = [];
|
|
949
|
-
|
|
950
|
-
editCleanups.push(
|
|
951
|
-
wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
952
|
-
);
|
|
953
|
-
editCleanups.push(
|
|
954
|
-
wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
|
|
955
|
-
);
|
|
956
|
-
|
|
957
|
-
const editSpec = currentSpec as ChartSpec | GraphSpec;
|
|
958
|
-
editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
959
|
-
editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
960
|
-
editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
961
|
-
|
|
962
|
-
cleanupEditDrags = () => {
|
|
963
|
-
for (const cleanup of editCleanups) {
|
|
964
|
-
cleanup();
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// Wire selection and keyboard edit events when editing callbacks are provided
|
|
970
|
-
if (!editSuppressed && hasEditingCallbacks(options)) {
|
|
971
|
-
makeEditable(svgElement);
|
|
972
|
-
cleanupSelection = wireSelectionEvents();
|
|
973
|
-
cleanupKeyboardEdit = wireKeyboardEditEvents();
|
|
974
|
-
|
|
975
|
-
if (selectedElement) {
|
|
976
|
-
const target = findElementByRef(svgElement, selectedElement);
|
|
977
|
-
if (target) {
|
|
978
|
-
overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
|
|
979
|
-
} else {
|
|
980
|
-
selectedElement = null;
|
|
981
|
-
overlayElement = null;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Create hidden data table for screen readers (not when the author hid
|
|
987
|
-
// the chart from assistive technology)
|
|
988
|
-
if (!currentLayout.a11y.hidden) {
|
|
989
|
-
srTable = createScreenReaderTable(currentLayout, container);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// Mount/refresh the series search overlay on its reserved band. Created
|
|
993
|
-
// once and kept across renders so typing focus and chips survive the
|
|
994
|
-
// re-render each selection triggers.
|
|
995
|
-
if (currentLayout.seriesSearch) {
|
|
996
|
-
if (!seriesSearch) {
|
|
997
|
-
seriesSearch = createSeriesSearch({ container, onChange: handleSearchChange });
|
|
998
|
-
}
|
|
999
|
-
seriesSearch.update(currentLayout.seriesSearch, svgElement);
|
|
1000
|
-
} else if (seriesSearch) {
|
|
1001
|
-
if (wantsSeriesSearch(currentSpec)) {
|
|
1002
|
-
// The layout stripped the band (e.g. hidden chrome mode at cramped
|
|
1003
|
-
// sizes) but the spec still wants search: keep the control and its
|
|
1004
|
-
// chips, just hide it until the band comes back.
|
|
1005
|
-
seriesSearch.hide();
|
|
1006
|
-
} else {
|
|
1007
|
-
seriesSearch.destroy();
|
|
1008
|
-
seriesSearch = null;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// Mount/refresh the "you draw it" overlay. Created once and kept across
|
|
1013
|
-
// renders so the reader's in-progress guess survives each resize's
|
|
1014
|
-
// re-render. Mutually exclusive with edit mode: when editing callbacks are
|
|
1015
|
-
// present, youDrawIt is disabled (warn once) per plans 11/17's rule.
|
|
1016
|
-
const ydiEditConflict = wantsYouDrawIt(currentSpec) && hasEditingCallbacks(options);
|
|
1017
|
-
if (ydiEditConflict && !ydiEditSuppressWarned) {
|
|
1018
|
-
ydiEditSuppressWarned = true;
|
|
1019
|
-
console.warn(
|
|
1020
|
-
'[openchart] youDrawIt and edit mode are mutually exclusive; youDrawIt is disabled while editing callbacks are provided.',
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
1023
|
-
if (currentLayout.youDrawIt && !ydiEditConflict) {
|
|
1024
|
-
if (!youDrawIt) {
|
|
1025
|
-
youDrawIt = createYouDrawIt({ container, onReveal: options?.onReveal });
|
|
1026
|
-
}
|
|
1027
|
-
youDrawIt.update(currentLayout.youDrawIt, svgElement as SVGSVGElement);
|
|
1028
|
-
} else if (youDrawIt) {
|
|
1029
|
-
if (wantsYouDrawIt(currentSpec) && !ydiEditConflict) {
|
|
1030
|
-
// Layout stripped the config (e.g. empty data at this size) but the
|
|
1031
|
-
// spec still wants it: keep the controller (and its guess), just hide.
|
|
1032
|
-
youDrawIt.hide();
|
|
1033
|
-
} else {
|
|
1034
|
-
youDrawIt.destroy();
|
|
1035
|
-
youDrawIt = null;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// Apply container classes for CSS variable scoping and dark mode
|
|
1040
|
-
container.classList.add('oc-root');
|
|
1041
|
-
const isDark = resolveDarkMode(options?.darkMode);
|
|
1042
|
-
if (isDark) {
|
|
1043
|
-
container.classList.add('oc-dark');
|
|
1044
|
-
} else {
|
|
1045
|
-
container.classList.remove('oc-dark');
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// Stamp resolved theme as CSS custom properties on the container
|
|
1049
|
-
// so CSS consumers read from the same source of truth as the JS engine.
|
|
1050
|
-
stampThemeProperties(container, currentLayout.theme);
|
|
1051
|
-
|
|
1052
|
-
// Set up animation cleanup on first render only
|
|
1053
|
-
if (shouldAnimate && svgElement) {
|
|
1054
|
-
cleanupAnimations = setupAnimationCleanup(svgElement, () => {
|
|
1055
|
-
cleanupAnimations = null;
|
|
1056
|
-
if (pendingResize) {
|
|
1057
|
-
pendingResize = false;
|
|
1058
|
-
resize();
|
|
1059
|
-
}
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
// Bump the render generation so tests (and consumers) can observe every
|
|
1063
|
-
// full recompile, including the post-font-load one.
|
|
1064
|
-
renderGen += 1;
|
|
1065
|
-
container.dataset.ocRenderGen = String(renderGen);
|
|
1066
|
-
|
|
1067
|
-
// This render recompiled with the loaded webfonts, so the layout now
|
|
1068
|
-
// reflects final font metrics. Publish 'ready' only now (not right after
|
|
1069
|
-
// the fonts-ready resize(), which may have deferred to pendingResize while
|
|
1070
|
-
// the entrance animation was still running).
|
|
1071
|
-
if (fontsReloadPending) {
|
|
1072
|
-
fontsReloadPending = false;
|
|
1073
|
-
container.dataset.ocFontsState = 'ready';
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
if (isFirstRender) {
|
|
1077
|
-
isFirstRender = false;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// ---------------------------------------------------------------------------
|
|
1082
|
-
// Public API methods
|
|
1083
|
-
// ---------------------------------------------------------------------------
|
|
1084
|
-
|
|
1085
|
-
function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
|
|
1086
|
-
if (destroyed) return;
|
|
1087
|
-
|
|
1088
|
-
// Capture pre-update state for transition gating
|
|
1089
|
-
const prevSpec = currentSpec;
|
|
1090
|
-
const prevLayout = currentLayout;
|
|
1091
|
-
const entranceWasRunning = cleanupAnimations != null;
|
|
1092
|
-
|
|
1093
|
-
// Snapshot in-flight transition geometry before render() cancels it.
|
|
1094
|
-
// This enables retargeting: the next transition starts from the
|
|
1095
|
-
// interrupted position instead of snapping to the previous final state.
|
|
1096
|
-
if (transitionHandle?.running) {
|
|
1097
|
-
transitionSnapshot = transitionHandle.snapshot();
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
currentSpec = newSpec;
|
|
1101
|
-
// A new spec can change theme.fonts.family; rebuild the measurer so layout
|
|
1102
|
-
// measures the font compile will actually render with.
|
|
1103
|
-
const nextFont = resolveEffectiveFont();
|
|
1104
|
-
if (nextFont !== fontFamily) {
|
|
1105
|
-
fontFamily = nextFont;
|
|
1106
|
-
measureText = createMeasureText(fontFamily);
|
|
1107
|
-
}
|
|
1108
|
-
runtimeHiddenSeries.clear();
|
|
1109
|
-
runtimeShownSeries.clear();
|
|
1110
|
-
// The new spec is the source of truth: re-capture the authored highlight
|
|
1111
|
-
// baseline and drop any search selections layered on the previous spec.
|
|
1112
|
-
searchBaseline = authoredHighlight(newSpec);
|
|
1113
|
-
seriesSearch?.setSelected([]);
|
|
1114
|
-
// A new spec re-arms the conflict warnings (youDrawIt/edit and
|
|
1115
|
-
// seriesSearch/edit) so a newly-introduced conflict warns once again.
|
|
1116
|
-
ydiEditSuppressWarned = false;
|
|
1117
|
-
editSuppressWarned = false;
|
|
1118
|
-
if (updateOpts && 'selectedElement' in updateOpts) {
|
|
1119
|
-
selectedElement = updateOpts.selectedElement ?? null;
|
|
1120
|
-
}
|
|
1121
|
-
render();
|
|
1122
|
-
|
|
1123
|
-
// After render, check if we can run a smooth transition instead of the
|
|
1124
|
-
// instant swap that render() already performed.
|
|
1125
|
-
if (
|
|
1126
|
-
svgElement &&
|
|
1127
|
-
canTransition({
|
|
1128
|
-
prevLayout,
|
|
1129
|
-
nextLayout: currentLayout,
|
|
1130
|
-
prevSpec,
|
|
1131
|
-
nextSpec: newSpec,
|
|
1132
|
-
isFirstRender: false,
|
|
1133
|
-
entranceInFlight: entranceWasRunning,
|
|
1134
|
-
})
|
|
1135
|
-
) {
|
|
1136
|
-
// Consume snapshot (if any) for retargeting, then clear
|
|
1137
|
-
const snapshot = transitionSnapshot;
|
|
1138
|
-
transitionSnapshot = null;
|
|
1139
|
-
|
|
1140
|
-
transitionHandle = runTransition({
|
|
1141
|
-
svg: svgElement as SVGSVGElement,
|
|
1142
|
-
prevLayout,
|
|
1143
|
-
nextLayout: currentLayout,
|
|
1144
|
-
animation: currentLayout.animation!,
|
|
1145
|
-
fromSnapshot: snapshot ?? undefined,
|
|
1146
|
-
onComplete: () => {
|
|
1147
|
-
transitionHandle = null;
|
|
1148
|
-
},
|
|
1149
|
-
});
|
|
1150
|
-
} else {
|
|
1151
|
-
// No transition started; clear any stale snapshot
|
|
1152
|
-
transitionSnapshot = null;
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
function resize(): void {
|
|
1157
|
-
if (destroyed) return;
|
|
1158
|
-
if (cleanupAnimations) {
|
|
1159
|
-
pendingResize = true;
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
render();
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Apply a search selection change: the effective highlight is the authored
|
|
1167
|
-
* baseline plus the selected values, so clearing every chip restores the
|
|
1168
|
-
* authored highlight exactly. Fires onHighlightChange on every change.
|
|
1169
|
-
*/
|
|
1170
|
-
function handleSearchChange(selected: string[]): void {
|
|
1171
|
-
const merged = [...searchBaseline];
|
|
1172
|
-
for (const v of selected) {
|
|
1173
|
-
if (!merged.includes(v)) merged.push(v);
|
|
1174
|
-
}
|
|
1175
|
-
setHighlight(merged.length > 0 ? merged : null);
|
|
1176
|
-
options?.onHighlightChange?.(merged);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function setHighlight(values: string[] | null): void {
|
|
1180
|
-
if (destroyed) return;
|
|
1181
|
-
if (isLayerSpec(currentSpec) || isGraphSpec(currentSpec as unknown as Record<string, unknown>))
|
|
1182
|
-
return;
|
|
1183
|
-
const spec = currentSpec as ChartSpec;
|
|
1184
|
-
const colorEnc = spec.encoding?.color;
|
|
1185
|
-
if (!colorEnc || typeof colorEnc !== 'object' || !('field' in colorEnc)) return;
|
|
1186
|
-
const current = (colorEnc as { highlight?: string | string[] }).highlight;
|
|
1187
|
-
if (!values?.length && !current) return;
|
|
1188
|
-
const updatedColor = { ...colorEnc };
|
|
1189
|
-
if (values && values.length > 0) {
|
|
1190
|
-
updatedColor.highlight = values;
|
|
1191
|
-
} else {
|
|
1192
|
-
delete updatedColor.highlight;
|
|
1193
|
-
}
|
|
1194
|
-
currentSpec = {
|
|
1195
|
-
...spec,
|
|
1196
|
-
encoding: { ...spec.encoding, color: updatedColor },
|
|
1197
|
-
} as ChartSpec;
|
|
1198
|
-
render();
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function doExport(format: 'svg'): string;
|
|
1202
|
-
function doExport(format: 'svg-with-fonts', exportOptions?: SVGExportOptions): Promise<string>;
|
|
1203
|
-
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1204
|
-
function doExport(format: 'jpg', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1205
|
-
function doExport(format: 'csv'): string;
|
|
1206
|
-
function doExport(
|
|
1207
|
-
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv',
|
|
1208
|
-
exportOptions?: ExportOptions,
|
|
1209
|
-
): string | Promise<Blob> | Promise<string> {
|
|
1210
|
-
if (!svgElement) {
|
|
1211
|
-
throw new Error('Chart is not rendered yet');
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
switch (format) {
|
|
1215
|
-
case 'svg':
|
|
1216
|
-
return exportSVG(svgElement);
|
|
1217
|
-
case 'svg-with-fonts':
|
|
1218
|
-
return exportSVGWithFonts(svgElement, exportOptions);
|
|
1219
|
-
case 'png':
|
|
1220
|
-
return exportPNG(svgElement, exportOptions);
|
|
1221
|
-
case 'jpg':
|
|
1222
|
-
return exportJPG(svgElement, exportOptions);
|
|
1223
|
-
case 'csv':
|
|
1224
|
-
return exportCSV(
|
|
1225
|
-
'data' in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : [],
|
|
1226
|
-
);
|
|
1227
|
-
default:
|
|
1228
|
-
throw new Error(`Unsupported export format: ${format}`);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
function destroy(): void {
|
|
1233
|
-
if (destroyed) return;
|
|
1234
|
-
destroyed = true;
|
|
1235
|
-
|
|
1236
|
-
// Cancel any in-progress data-update transition
|
|
1237
|
-
if (transitionHandle) {
|
|
1238
|
-
transitionHandle.cancel();
|
|
1239
|
-
transitionHandle = null;
|
|
1240
|
-
}
|
|
1241
|
-
transitionSnapshot = null;
|
|
1242
|
-
|
|
1243
|
-
if (cleanupAnimations) {
|
|
1244
|
-
cleanupAnimations();
|
|
1245
|
-
cleanupAnimations = null;
|
|
1246
|
-
pendingResize = false;
|
|
1247
|
-
}
|
|
1248
|
-
cancelAnimations(svgElement);
|
|
1249
|
-
|
|
1250
|
-
cleanupTooltipEvents?.();
|
|
1251
|
-
cleanupTooltipEvents = null;
|
|
1252
|
-
cleanupVoronoiEvents?.();
|
|
1253
|
-
cleanupVoronoiEvents = null;
|
|
1254
|
-
cleanupKeyboardNav?.();
|
|
1255
|
-
cleanupKeyboardNav = null;
|
|
1256
|
-
cleanupLegend?.();
|
|
1257
|
-
cleanupLegend = null;
|
|
1258
|
-
cleanupChartEvents?.();
|
|
1259
|
-
cleanupChartEvents = null;
|
|
1260
|
-
cleanupAnnotationDrag?.();
|
|
1261
|
-
cleanupAnnotationDrag = null;
|
|
1262
|
-
cleanupEditDrags?.();
|
|
1263
|
-
cleanupEditDrags = null;
|
|
1264
|
-
cleanupSelection?.();
|
|
1265
|
-
cleanupSelection = null;
|
|
1266
|
-
cleanupKeyboardEdit?.();
|
|
1267
|
-
cleanupKeyboardEdit = null;
|
|
1268
|
-
if (textEditCleanup) {
|
|
1269
|
-
textEditCleanup();
|
|
1270
|
-
textEditCleanup = null;
|
|
1271
|
-
isTextEditingActive = false;
|
|
1272
|
-
}
|
|
1273
|
-
selectedElement = null;
|
|
1274
|
-
overlayElement = null;
|
|
1275
|
-
if (seriesSearch) {
|
|
1276
|
-
seriesSearch.destroy();
|
|
1277
|
-
seriesSearch = null;
|
|
1278
|
-
}
|
|
1279
|
-
if (youDrawIt) {
|
|
1280
|
-
youDrawIt.destroy();
|
|
1281
|
-
youDrawIt = null;
|
|
1282
|
-
}
|
|
1283
|
-
if (disconnectResize) {
|
|
1284
|
-
disconnectResize();
|
|
1285
|
-
disconnectResize = null;
|
|
1286
|
-
}
|
|
1287
|
-
if (tooltipManager) {
|
|
1288
|
-
tooltipManager.destroy();
|
|
1289
|
-
tooltipManager = null;
|
|
1290
|
-
}
|
|
1291
|
-
if (svgElement?.parentNode) {
|
|
1292
|
-
svgElement.parentNode.removeChild(svgElement);
|
|
1293
|
-
svgElement = null;
|
|
1294
|
-
}
|
|
1295
|
-
if (srTable?.parentNode) {
|
|
1296
|
-
srTable.parentNode.removeChild(srTable);
|
|
1297
|
-
srTable = null;
|
|
1298
|
-
}
|
|
1299
|
-
container.classList.remove('oc-dark');
|
|
1300
|
-
container.classList.remove('oc-root');
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Initial render
|
|
1304
|
-
render();
|
|
1305
|
-
|
|
1306
|
-
// Recompile once after webfonts load. On real devices the primary font
|
|
1307
|
-
// (e.g. Inter via display=swap) often swaps in after first paint, changing
|
|
1308
|
-
// text metrics; without a re-measure, titles wrap wrong and labels collide.
|
|
1309
|
-
// Desktop Chrome has the font cached, so it renders 'ready' immediately.
|
|
1310
|
-
const pending = scheduleFontReload(
|
|
1311
|
-
fontFamily,
|
|
1312
|
-
() => !destroyed,
|
|
1313
|
-
() => {
|
|
1314
|
-
// Mark the reload owed, then trigger a recompile. render() flips the
|
|
1315
|
-
// attribute to 'ready' once it actually recompiles — including the
|
|
1316
|
-
// pendingResize replay after the entrance animation finishes.
|
|
1317
|
-
fontsReloadPending = true;
|
|
1318
|
-
resize();
|
|
1319
|
-
},
|
|
1320
|
-
);
|
|
1321
|
-
container.dataset.ocFontsState = pending ? 'pending' : 'ready';
|
|
1322
|
-
|
|
1323
|
-
// Set up responsive resize
|
|
1324
|
-
if (options?.responsive !== false) {
|
|
1325
|
-
disconnectResize = observeResize(container, (width, height) => {
|
|
1326
|
-
// Self-induced-resize guard, scoped to latched-auto mounts: growing
|
|
1327
|
-
// the SVG grows the container, which re-fires this observer. A fire
|
|
1328
|
-
// whose height matches the SVG we just rendered (and whose width is
|
|
1329
|
-
// unchanged) is our own echo — re-rendering on it would loop; the
|
|
1330
|
-
// 16ms debounce alone doesn't break the cycle.
|
|
1331
|
-
if (isAutoHeight === true) {
|
|
1332
|
-
const widthChanged = width !== lastRenderedWidth;
|
|
1333
|
-
const selfInducedHeight = height === lastRenderedSvgHeight;
|
|
1334
|
-
if (!widthChanged && selfInducedHeight) return;
|
|
1335
|
-
if (!selfInducedHeight) {
|
|
1336
|
-
// Any other height delta is host-driven: un-latch so the next
|
|
1337
|
-
// measure re-derives (and re-latches explicit if the container
|
|
1338
|
-
// now reports a height that isn't ours).
|
|
1339
|
-
isAutoHeight = null;
|
|
1340
|
-
}
|
|
1341
|
-
resize();
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
// Explicit-height (and not-yet-latched) mounts keep today's
|
|
1345
|
-
// re-render-on-every-fire behavior.
|
|
1346
|
-
resize();
|
|
1347
|
-
});
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
return {
|
|
1351
|
-
update,
|
|
1352
|
-
resize,
|
|
1353
|
-
export: doExport,
|
|
1354
|
-
destroy,
|
|
1355
|
-
get layout() {
|
|
1356
|
-
return currentLayout;
|
|
1357
|
-
},
|
|
1358
|
-
getSelectedElement(): ElementRef | null {
|
|
1359
|
-
return selectedElement;
|
|
1360
|
-
},
|
|
1361
|
-
select(ref: ElementRef): void {
|
|
1362
|
-
if (destroyed) return;
|
|
1363
|
-
selectElement(ref);
|
|
1364
|
-
},
|
|
1365
|
-
deselect(): void {
|
|
1366
|
-
if (destroyed) return;
|
|
1367
|
-
deselectElement();
|
|
1368
|
-
},
|
|
1369
|
-
get isEditing(): boolean {
|
|
1370
|
-
return isTextEditingActive;
|
|
1371
|
-
},
|
|
1372
|
-
setHighlight,
|
|
1373
|
-
resetDrawing(): void {
|
|
1374
|
-
if (destroyed) return;
|
|
1375
|
-
youDrawIt?.reset();
|
|
1376
|
-
},
|
|
1377
|
-
revealDrawing(): void {
|
|
1378
|
-
if (destroyed) return;
|
|
1379
|
-
youDrawIt?.reveal();
|
|
1380
|
-
},
|
|
1381
|
-
};
|
|
1382
|
-
}
|