@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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /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
- }