@opendata-ai/openchart-vanilla 8.7.0 → 8.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,21 +3,22 @@ import {
3
3
  paletteFromCanvas,
4
4
  readCanvasSRGB,
5
5
  resolveRepeat
6
- } from "./chunk-DFKLAB67.js";
6
+ } from "./chunk-MGFXAROR.js";
7
7
  import {
8
8
  renderTileMapSVG
9
- } from "./chunk-EZQDCLZN.js";
9
+ } from "./chunk-YHZAH6EN.js";
10
10
  import {
11
11
  GRAPH_3D_NOT_REGISTERED_ERROR,
12
12
  createGraph,
13
13
  registerGraphRenderer
14
- } from "./chunk-5IMVUHVC.js";
14
+ } from "./chunk-YBVDBY6P.js";
15
15
  import {
16
16
  CANVAS_DEFAULT_UPDATE_MAX_MARKS,
17
17
  DEFAULT_UPDATE_MAX_MARKS,
18
18
  VECTOR_EXPORT_MAX_POINTS,
19
19
  applySrOnlyStyles,
20
20
  cancelAnimations,
21
+ captureMarkFocus,
21
22
  createChart,
22
23
  createGeoMap,
23
24
  createMeasureText,
@@ -25,10 +26,12 @@ import {
25
26
  createYouDrawIt,
26
27
  materializeCanvasModeSVG,
27
28
  resolveFontFamily,
29
+ restoreMarkFocus,
28
30
  scheduleFontReload,
29
31
  setupAnimationCleanup,
30
- setupTableAnimationCleanup
31
- } from "./chunk-5O624XON.js";
32
+ setupTableAnimationCleanup,
33
+ wireMarkNavigation
34
+ } from "./chunk-6TD7GWR4.js";
32
35
  import {
33
36
  embedFonts,
34
37
  ensureSVGDimensions,
@@ -40,14 +43,16 @@ import {
40
43
  getSVGBackgroundColor,
41
44
  getSVGDimensions,
42
45
  rasterizeSVGToCanvas
43
- } from "./chunk-I73B3DJI.js";
46
+ } from "./chunk-R5TZHNTG.js";
44
47
  import {
45
48
  rectPathWithCorners,
46
49
  registerMarkRenderer,
47
50
  renderChartSVG,
51
+ renderChromeElement,
48
52
  stampAnimationVars
49
- } from "./chunk-SXTHPXG6.js";
53
+ } from "./chunk-TP3GCWCO.js";
50
54
  import {
55
+ applyTextStyle,
51
56
  injectThemeStyleBlock
52
57
  } from "./chunk-SDJHUELU.js";
53
58
  import {
@@ -63,7 +68,6 @@ import {
63
68
  import { compileBarList } from "@opendata-ai/openchart-engine";
64
69
 
65
70
  // src/barlist-renderer.ts
66
- import { textAscent } from "@opendata-ai/openchart-core";
67
71
  var SVG_NS = "http://www.w3.org/2000/svg";
68
72
  var XLINK_NS = "http://www.w3.org/1999/xlink";
69
73
  var BRAND_URL = "https://tryopendata.ai";
@@ -84,18 +88,13 @@ function renderChrome(parent, layout) {
84
88
  const el = chrome[key];
85
89
  if (!el) continue;
86
90
  const isBottom = key === "source" || key === "byline" || key === "footer";
87
- const text = createSVGElement("text");
88
- setAttrs(text, {
89
- x: el.x,
90
- y: (isBottom ? bottomOffset + el.y : el.y) + textAscent(el.style.fontSize)
91
- });
92
- text.setAttribute("class", `oc-${key}`);
93
- text.setAttribute("font-family", el.style.fontFamily);
94
- text.setAttribute("font-size", String(el.style.fontSize));
95
- text.setAttribute("font-weight", String(el.style.fontWeight));
96
- text.style.setProperty("fill", el.style.fill);
97
- text.textContent = el.text;
98
- g.appendChild(text);
91
+ renderChromeElement(
92
+ g,
93
+ isBottom ? { ...el, y: bottomOffset + el.y } : el,
94
+ `oc-${key}`,
95
+ key,
96
+ layout.measureText
97
+ );
99
98
  }
100
99
  parent.appendChild(g);
101
100
  }
@@ -233,7 +232,11 @@ function renderBarListSVG(layout, opts) {
233
232
  const classes = animate ? "oc-barlist oc-animate" : "oc-barlist";
234
233
  svg.setAttribute("class", classes);
235
234
  if (animate && animation?.enter) {
236
- stampAnimationVars(svg, { duration: animation.enter.duration, stagger: 40 });
235
+ stampAnimationVars(svg, {
236
+ duration: animation.enter.duration,
237
+ spring: animation.enter.spring,
238
+ stagger: 40
239
+ });
237
240
  }
238
241
  renderChrome(svg, layout);
239
242
  renderRows(svg, rows, animate ? animation : void 0);
@@ -256,8 +259,8 @@ function createBarList(container, spec, options) {
256
259
  let pendingResize = false;
257
260
  let fontsReloadPending = false;
258
261
  container.classList.add("oc-barlist-root");
259
- function resolveEffectiveFont() {
260
- return options?.theme?.fonts?.family ?? currentSpec.theme?.fonts?.family ?? resolveFontFamily(container);
262
+ function resolveEffectiveFont(candidate = currentSpec) {
263
+ return options?.theme?.fonts?.family ?? candidate.theme?.fonts?.family ?? resolveFontFamily(container);
261
264
  }
262
265
  let fontFamily = resolveEffectiveFont();
263
266
  let measureText = createMeasureText(fontFamily);
@@ -269,7 +272,7 @@ function createBarList(container, spec, options) {
269
272
  height: Math.max(rect.height || 400, 100)
270
273
  };
271
274
  }
272
- function compile() {
275
+ function compile(candidate = currentSpec, measure = measureText) {
273
276
  const { width, height } = getContainerDimensions();
274
277
  const darkMode = resolveDarkMode(options?.darkMode);
275
278
  const compileOpts = {
@@ -278,9 +281,9 @@ function createBarList(container, spec, options) {
278
281
  theme: options?.theme,
279
282
  darkMode,
280
283
  watermark: options?.watermark,
281
- measureText
284
+ measureText: measure
282
285
  };
283
- const layout = compileBarList(currentSpec, compileOpts);
286
+ const layout = compileBarList(candidate, compileOpts);
284
287
  if (layout.rows.length > 0) {
285
288
  const lastRow = layout.rows[layout.rows.length - 1];
286
289
  const contentBottom = lastRow.y + lastRow.height + layout.chrome.bottomHeight + layout.theme.spacing.padding;
@@ -293,6 +296,8 @@ function createBarList(container, spec, options) {
293
296
  }
294
297
  function wireTooltipAndInteraction(svg, layout) {
295
298
  const cleanups = [];
299
+ const navigation = [];
300
+ let activeLeave = null;
296
301
  const rowElements = svg.querySelectorAll(".oc-barlist-row");
297
302
  for (const el of rowElements) {
298
303
  const indexStr = el.getAttribute("data-row-index");
@@ -300,6 +305,8 @@ function createBarList(container, spec, options) {
300
305
  const content = layout.tooltipDescriptors.get(indexStr);
301
306
  const row = layout.rows[Number(indexStr)];
302
307
  const handleMouseEnter = (e) => {
308
+ activeLeave?.();
309
+ activeLeave = handleMouseLeave;
303
310
  const mouseEvent = e;
304
311
  if (content && tooltipManager && options?.tooltip !== false) {
305
312
  const svgRect = svg.getBoundingClientRect();
@@ -325,6 +332,8 @@ function createBarList(container, spec, options) {
325
332
  }
326
333
  };
327
334
  const handleMouseLeave = () => {
335
+ if (activeLeave !== handleMouseLeave) return;
336
+ activeLeave = null;
328
337
  tooltipManager?.hide();
329
338
  options?.onRowHover?.(null);
330
339
  };
@@ -337,6 +346,13 @@ function createBarList(container, spec, options) {
337
346
  });
338
347
  }
339
348
  };
349
+ navigation.push({
350
+ element: el,
351
+ key: row?.label.text ?? indexStr,
352
+ enter: handleMouseEnter,
353
+ leave: handleMouseLeave,
354
+ activate: handleClick
355
+ });
340
356
  el.addEventListener("mouseenter", handleMouseEnter);
341
357
  el.addEventListener("mousemove", handleMouseMove);
342
358
  el.addEventListener("mouseleave", handleMouseLeave);
@@ -348,11 +364,15 @@ function createBarList(container, spec, options) {
348
364
  el.removeEventListener("click", handleClick);
349
365
  });
350
366
  }
367
+ cleanups.push(wireMarkNavigation(svg, navigation));
351
368
  return () => {
369
+ activeLeave?.();
352
370
  for (const cleanup of cleanups) cleanup();
353
371
  };
354
372
  }
355
373
  function render(animate = false) {
374
+ const focusedMark = captureMarkFocus(svgElement);
375
+ tooltipManager?.hide();
356
376
  if (animationCleanup) {
357
377
  animationCleanup();
358
378
  animationCleanup = null;
@@ -373,7 +393,7 @@ function createBarList(container, spec, options) {
373
393
  tooltipManager = createTooltipManager(container);
374
394
  }
375
395
  }
376
- if (currentLayout.animation?.enter) {
396
+ if (animate && currentLayout.animation?.enter) {
377
397
  animationCleanup = setupAnimationCleanup(newSvg, () => {
378
398
  animationCleanup = null;
379
399
  if (pendingResize && !destroyed) {
@@ -388,15 +408,17 @@ function createBarList(container, spec, options) {
388
408
  fontsReloadPending = false;
389
409
  container.dataset.ocFontsState = "ready";
390
410
  }
411
+ if (svgElement) restoreMarkFocus(svgElement, focusedMark);
391
412
  }
392
413
  function update(newSpec) {
414
+ if (destroyed) return;
415
+ const nextFont = resolveEffectiveFont(newSpec);
416
+ const nextMeasure = nextFont === fontFamily ? measureText : createMeasureText(nextFont);
417
+ const nextLayout = compile(newSpec, nextMeasure);
393
418
  currentSpec = newSpec;
394
- const nextFont = resolveEffectiveFont();
395
- if (nextFont !== fontFamily) {
396
- fontFamily = nextFont;
397
- measureText = createMeasureText(fontFamily);
398
- }
399
- currentLayout = compile();
419
+ fontFamily = nextFont;
420
+ measureText = nextMeasure;
421
+ currentLayout = nextLayout;
400
422
  render();
401
423
  }
402
424
  function resize() {
@@ -410,15 +432,17 @@ function createBarList(container, spec, options) {
410
432
  }
411
433
  function exportChart(format, options_) {
412
434
  if (!svgElement) return "";
435
+ const terminalSvg = renderBarListSVG(currentLayout, { animate: false });
436
+ const resolvedOptions = { theme: currentLayout.theme, ...options_ };
413
437
  switch (format) {
414
438
  case "svg":
415
- return exportSVG(svgElement);
439
+ return exportSVG(terminalSvg);
416
440
  case "svg-with-fonts":
417
- return exportSVGWithFonts(svgElement);
441
+ return exportSVGWithFonts(terminalSvg, resolvedOptions);
418
442
  case "png":
419
- return exportPNG(svgElement, options_);
443
+ return exportPNG(terminalSvg, resolvedOptions);
420
444
  case "jpg":
421
- return exportJPG(svgElement, options_);
445
+ return exportJPG(terminalSvg, resolvedOptions);
422
446
  default:
423
447
  return "";
424
448
  }
@@ -611,6 +635,285 @@ function createSimulationWorker() {
611
635
  return new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
612
636
  }
613
637
 
638
+ // src/reader.ts
639
+ import {
640
+ observationKey,
641
+ reconcileReaderSelection,
642
+ resolveObservationIdentities
643
+ } from "@opendata-ai/openchart-engine";
644
+ var LAYER_UNSUPPORTED = "Reader inspection is unavailable for layered charts: observations cannot be bound to a single dataset.";
645
+ var seriesField = (spec) => spec.encoding.color && "field" in spec.encoding.color ? spec.encoding.color.field : void 0;
646
+ function createReader(container, instance, initial, onChange) {
647
+ let spec = "layer" in initial ? null : initial;
648
+ let state = {
649
+ preview: null,
650
+ pinned: [],
651
+ announcement: spec ? "" : LAYER_UNSUPPORTED
652
+ };
653
+ let keyboardIndex = -1;
654
+ let settleFrame = null;
655
+ let visibilityOverride;
656
+ let identities = spec ? resolveObservationIdentities(spec.data, spec.encoding.key?.field, seriesField(spec)) : [];
657
+ const emit = () => onChange({ ...state, pinned: [...state.pinned] });
658
+ const visibleData = (layout = instance.sampleLayout()) => {
659
+ const rows = layout.marks.flatMap((mark) => {
660
+ if ("opacity" in mark && mark.opacity === 0) return [];
661
+ if (mark.type === "line" || mark.type === "area")
662
+ return (mark.dataPoints ?? []).map((point, index) => ({
663
+ datum: point.datum,
664
+ key: mark.pointKeys?.[index]
665
+ }));
666
+ if ("data" in mark) return [{ datum: mark.data, key: mark.key }];
667
+ return [];
668
+ });
669
+ return [...new Map(rows.map((row) => [row.datum, row])).values()];
670
+ };
671
+ const compiledVisibleKeys = (observations) => {
672
+ const keys = /* @__PURE__ */ new Set();
673
+ if (!spec || !observations.length) return keys;
674
+ const rows = visibleData(instance.layout);
675
+ const references = new Set(rows.map(({ datum }) => datum));
676
+ const signatures = new Set(rows.map(({ datum }) => JSON.stringify(datum)));
677
+ const keyField = spec.encoding.key?.field;
678
+ const visibleSourceKeys = new Set(
679
+ keyField ? rows.map(({ datum }) => observationKey(datum[keyField])) : []
680
+ );
681
+ for (const observation of observations) {
682
+ if (observation.kind === "aggregate") {
683
+ if (signatures.has(JSON.stringify(observation.datum))) keys.add(observation.observation);
684
+ continue;
685
+ }
686
+ const index = identities.findIndex((id) => id.observation === observation.observation);
687
+ if (index < 0) continue;
688
+ const row = spec.data[index];
689
+ const signature = JSON.stringify(row);
690
+ const visible = observation.persistent ? visibleSourceKeys.has(observation.observation) : references.has(row) || signatures.has(signature) && spec.data.filter((source) => JSON.stringify(source) === signature).length === 1;
691
+ if (visible) keys.add(observation.observation);
692
+ }
693
+ return keys;
694
+ };
695
+ const resolve = (datum) => {
696
+ if (!spec) return null;
697
+ const keyField = spec.encoding.key?.field;
698
+ let index = spec.data.indexOf(datum);
699
+ if (index >= 0 && spec.data.lastIndexOf(datum) !== index) return null;
700
+ if (index < 0 && keyField)
701
+ index = spec.data.findIndex(
702
+ (_row, i) => identities[i].persistent && identities[i].observation === observationKey(datum[keyField])
703
+ );
704
+ if (index < 0) {
705
+ const matches = spec.data.flatMap(
706
+ (row, i) => JSON.stringify(row) === JSON.stringify(datum) ? [i] : []
707
+ );
708
+ if (matches.length > 1) return null;
709
+ if (matches.length === 1) index = matches[0];
710
+ }
711
+ if (index >= 0) return { ...identities[index], datum: spec.data[index], state: "visible" };
712
+ const group = visibleData(instance.layout).find(
713
+ (row) => row.datum === datum || JSON.stringify(row.datum) === JSON.stringify(datum)
714
+ );
715
+ return group ? {
716
+ observation: `aggregate:${JSON.stringify([spec.mark, spec.encoding, spec.transform])}:${group.key ?? JSON.stringify(datum)}`,
717
+ persistent: false,
718
+ datum: group.datum,
719
+ kind: "aggregate",
720
+ state: "visible"
721
+ } : null;
722
+ };
723
+ const pin = (datum) => {
724
+ const observation = resolve(datum);
725
+ if (!observation) return;
726
+ observation.state = (visibilityOverride ?? compiledVisibleKeys([observation])).has(
727
+ observation.observation
728
+ ) ? "visible" : "filtered";
729
+ state = {
730
+ ...state,
731
+ preview: observation,
732
+ announcement: "Observation pinned.",
733
+ pinned: [
734
+ ...state.pinned.filter((item) => item.observation !== observation.observation),
735
+ observation
736
+ ].slice(-2)
737
+ };
738
+ emit();
739
+ };
740
+ const clear = () => {
741
+ state = {
742
+ preview: null,
743
+ pinned: [],
744
+ announcement: spec ? "Selection dismissed." : LAYER_UNSUPPORTED
745
+ };
746
+ emit();
747
+ };
748
+ function hit(event) {
749
+ if (!spec) return null;
750
+ const svg = container.querySelector("svg");
751
+ if (!svg) return null;
752
+ const bounds = svg.getBoundingClientRect();
753
+ if (!bounds.width || !bounds.height) return null;
754
+ const layout = instance.sampleLayout();
755
+ const x = (event.clientX - bounds.left) * layout.dimensions.width / bounds.width;
756
+ const y = (event.clientY - bounds.top) * layout.dimensions.height / bounds.height;
757
+ if (x < layout.area.x || x > layout.area.x + layout.area.width || y < layout.area.y || y > layout.area.y + layout.area.height)
758
+ return null;
759
+ let nearest = null;
760
+ let distance = 32;
761
+ const consider = (datum, dx, dy) => {
762
+ const d = Math.hypot(dx, dy);
763
+ if (d <= distance) {
764
+ distance = d;
765
+ nearest = datum;
766
+ }
767
+ };
768
+ for (const mark of layout.marks) {
769
+ if ("opacity" in mark && mark.opacity === 0) continue;
770
+ if (mark.type === "point") consider(mark.data, x - mark.cx, y - mark.cy);
771
+ else if (mark.type === "rect")
772
+ consider(
773
+ mark.data,
774
+ Math.max(mark.x - x, 0, x - mark.x - mark.width),
775
+ Math.max(mark.y - y, 0, y - mark.y - mark.height)
776
+ );
777
+ else if (mark.type === "arc") {
778
+ const dx = x - mark.center.x;
779
+ const dy = y - mark.center.y;
780
+ const radius = Math.hypot(dx, dy);
781
+ const angle = (Math.atan2(dy, dx) + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2);
782
+ const start = (mark.startAngle + Math.PI * 2) % (Math.PI * 2);
783
+ const sweep = mark.endAngle - mark.startAngle;
784
+ if (radius >= mark.innerRadius && radius <= mark.outerRadius && (angle - start + Math.PI * 2) % (Math.PI * 2) <= sweep)
785
+ consider(mark.data, 0, 0);
786
+ } else if (mark.type === "line" || mark.type === "area") {
787
+ for (const point of mark.dataPoints ?? []) consider(point.datum, x - point.x, y - point.y);
788
+ }
789
+ }
790
+ return nearest;
791
+ }
792
+ const preview = (event) => {
793
+ if (event.pointerType === "touch") return;
794
+ const datum = hit(event);
795
+ state = { ...state, preview: datum ? resolve(datum) : null };
796
+ emit();
797
+ };
798
+ const click = (event) => {
799
+ const datum = hit(event);
800
+ if (datum) pin(datum);
801
+ };
802
+ const leave = () => {
803
+ state = { ...state, preview: null };
804
+ emit();
805
+ };
806
+ const keydown = (event) => {
807
+ if (!spec) return;
808
+ if (event.target.closest(
809
+ "input, textarea, select, button, [contenteditable=true]"
810
+ ))
811
+ return;
812
+ if (event.key === "Escape") {
813
+ clear();
814
+ return;
815
+ }
816
+ if (event.key === "Enter" && state.preview) {
817
+ event.preventDefault();
818
+ pin(state.preview.datum);
819
+ return;
820
+ }
821
+ const rows = visibleData();
822
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) || !rows.length)
823
+ return;
824
+ event.preventDefault();
825
+ event.stopPropagation();
826
+ const backwards = event.key === "ArrowLeft" || event.key === "ArrowUp";
827
+ keyboardIndex = keyboardIndex < 0 ? backwards ? rows.length - 1 : 0 : (keyboardIndex + (backwards ? -1 : 1) + rows.length) % rows.length;
828
+ state = { ...state, preview: resolve(rows[keyboardIndex].datum) };
829
+ emit();
830
+ };
831
+ container.addEventListener("pointermove", preview);
832
+ container.addEventListener("pointerleave", leave);
833
+ container.addEventListener("click", click);
834
+ container.addEventListener("keydown", keydown, true);
835
+ if (!spec) emit();
836
+ return {
837
+ get selection() {
838
+ return state;
839
+ },
840
+ pin,
841
+ clear,
842
+ update(next, visibleKeys) {
843
+ if (settleFrame !== null) {
844
+ cancelAnimationFrame(settleFrame);
845
+ settleFrame = null;
846
+ }
847
+ visibilityOverride = visibleKeys;
848
+ if ("layer" in next) {
849
+ spec = null;
850
+ identities = [];
851
+ keyboardIndex = -1;
852
+ state = { preview: null, pinned: [], announcement: LAYER_UNSUPPORTED };
853
+ emit();
854
+ return;
855
+ }
856
+ const meaningChanged = !spec || JSON.stringify([next.mark, next.encoding, next.transform]) !== JSON.stringify([spec.mark, spec.encoding, spec.transform]);
857
+ if (meaningChanged && (state.pinned.some((item) => item.kind === "aggregate") || state.preview?.kind === "aggregate")) {
858
+ state = {
859
+ ...state,
860
+ preview: null,
861
+ pinned: state.pinned.filter((item) => item.kind !== "aggregate"),
862
+ announcement: "Aggregate selection cleared because its grouping or transformation changed."
863
+ };
864
+ }
865
+ const replaced = !spec || next.data !== spec.data || next.encoding.key?.field !== spec.encoding.key?.field;
866
+ const resuming = !spec;
867
+ spec = next;
868
+ identities = resolveObservationIdentities(
869
+ spec.data,
870
+ spec.encoding.key?.field,
871
+ seriesField(spec)
872
+ );
873
+ const effectiveVisibility = visibleKeys ?? compiledVisibleKeys(state.pinned);
874
+ if (replaced)
875
+ state = reconcileReaderSelection(
876
+ state,
877
+ spec.data,
878
+ spec.encoding.key?.field,
879
+ seriesField(spec),
880
+ effectiveVisibility
881
+ );
882
+ else
883
+ state = {
884
+ ...state,
885
+ preview: null,
886
+ pinned: state.pinned.map((item) => ({
887
+ ...item,
888
+ ...item.kind !== "aggregate" ? identities.find((id) => id.observation === item.observation) : {},
889
+ state: effectiveVisibility.has(item.observation) ? "visible" : "filtered"
890
+ }))
891
+ };
892
+ if (resuming) state = { ...state, announcement: "" };
893
+ if (replaced || meaningChanged) keyboardIndex = -1;
894
+ emit();
895
+ const settle = () => {
896
+ settleFrame = null;
897
+ if (!state.pinned.some((item) => item.previous)) return;
898
+ if (instance.sampleLayout() !== instance.layout) {
899
+ settleFrame = requestAnimationFrame(settle);
900
+ return;
901
+ }
902
+ state = { ...state, pinned: state.pinned.map(({ previous: _previous, ...item }) => item) };
903
+ emit();
904
+ };
905
+ settleFrame = requestAnimationFrame(settle);
906
+ },
907
+ destroy() {
908
+ if (settleFrame !== null) cancelAnimationFrame(settleFrame);
909
+ container.removeEventListener("pointermove", preview);
910
+ container.removeEventListener("pointerleave", leave);
911
+ container.removeEventListener("click", click);
912
+ container.removeEventListener("keydown", keydown, true);
913
+ }
914
+ };
915
+ }
916
+
614
917
  // src/renderers/table-cells.ts
615
918
  function applyCellStyle(td, cell) {
616
919
  if (cell.style.backgroundColor) {
@@ -972,7 +1275,6 @@ import {
972
1275
  BRAND_FONT_SIZE,
973
1276
  BRAND_MIN_WIDTH,
974
1277
  estimateTextWidth,
975
- textAscent as textAscent2,
976
1278
  wrapText
977
1279
  } from "@opendata-ai/openchart-core";
978
1280
  import { clampStaggerDelay } from "@opendata-ai/openchart-engine";
@@ -987,55 +1289,12 @@ function setAttrs2(el, attrs) {
987
1289
  el.setAttribute(key, String(value));
988
1290
  }
989
1291
  }
990
- function applyTextStyle(el, style) {
991
- setAttrs2(el, {
992
- "font-family": style.fontFamily,
993
- "font-size": style.fontSize,
994
- "font-weight": style.fontWeight
995
- });
996
- el.style.setProperty("fill", style.fill);
997
- if (style.textAnchor) {
998
- el.setAttribute("text-anchor", style.textAnchor);
999
- }
1000
- if (style.dominantBaseline) {
1001
- el.setAttribute("dominant-baseline", style.dominantBaseline);
1002
- }
1003
- if (style.fontVariant) {
1004
- el.setAttribute("font-variant", style.fontVariant);
1005
- }
1006
- }
1007
1292
  function stampAnimationAttrs(el, mark, fallbackIndex, animation) {
1008
1293
  if (!animation?.enter) return;
1009
1294
  const idx = mark.animationIndex ?? fallbackIndex;
1010
1295
  el.setAttribute("data-animation-index", String(idx));
1011
1296
  el.style.setProperty("--oc-mark-index", String(idx));
1012
1297
  }
1013
- function renderChromeElement(parent, element, className, chromeKey, measureText) {
1014
- const text = createSVGElement2("text");
1015
- setAttrs2(text, { x: element.x, y: element.y + textAscent2(element.style.fontSize) });
1016
- applyTextStyle(text, element.style);
1017
- text.setAttribute("class", className);
1018
- text.setAttribute("data-chrome-key", chromeKey);
1019
- const lines = wrapText(
1020
- element.text,
1021
- element.style.fontSize,
1022
- element.style.fontWeight,
1023
- element.maxWidth,
1024
- measureText
1025
- );
1026
- if (lines.length === 1) {
1027
- text.textContent = element.text;
1028
- } else {
1029
- const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
1030
- for (let i = 0; i < lines.length; i++) {
1031
- const tspan = createSVGElement2("tspan");
1032
- setAttrs2(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
1033
- tspan.textContent = lines[i];
1034
- text.appendChild(tspan);
1035
- }
1036
- }
1037
- parent.appendChild(text);
1038
- }
1039
1298
  function renderChrome2(parent, layout) {
1040
1299
  const g = createSVGElement2("g");
1041
1300
  g.setAttribute("class", "oc-chrome");
@@ -1361,6 +1620,7 @@ function renderSankeySVG(layout, animation) {
1361
1620
  const stagger = clampStaggerDelay(enterPhase.staggerDelay, totalMarks);
1362
1621
  stampAnimationVars(svg, {
1363
1622
  duration: enterPhase.duration,
1623
+ spring: enterPhase.spring,
1364
1624
  stagger,
1365
1625
  annotationDelay: animation.annotationDelay,
1366
1626
  ease: enterPhase.ease
@@ -1408,8 +1668,8 @@ function createSankey(container, spec, options) {
1408
1668
  let pendingResize = false;
1409
1669
  let fontsReloadPending = false;
1410
1670
  container.classList.add("oc-sankey-root");
1411
- function resolveEffectiveFont() {
1412
- return options?.theme?.fonts?.family ?? currentSpec.theme?.fonts?.family ?? resolveFontFamily(container);
1671
+ function resolveEffectiveFont(candidate = currentSpec) {
1672
+ return options?.theme?.fonts?.family ?? candidate.theme?.fonts?.family ?? resolveFontFamily(container);
1413
1673
  }
1414
1674
  let fontFamily = resolveEffectiveFont();
1415
1675
  let measureText = createMeasureText(fontFamily);
@@ -1421,7 +1681,7 @@ function createSankey(container, spec, options) {
1421
1681
  height: Math.max(rect.height || 400, 100)
1422
1682
  };
1423
1683
  }
1424
- function compile() {
1684
+ function compile(candidate = currentSpec, measure = measureText) {
1425
1685
  const { width, height } = getContainerDimensions();
1426
1686
  const darkMode = resolveDarkMode(options?.darkMode);
1427
1687
  const compileOpts = {
@@ -1430,12 +1690,14 @@ function createSankey(container, spec, options) {
1430
1690
  theme: options?.theme,
1431
1691
  darkMode,
1432
1692
  watermark: options?.watermark,
1433
- measureText
1693
+ measureText: measure
1434
1694
  };
1435
- return compileSankey(currentSpec, compileOpts);
1695
+ return compileSankey(candidate, compileOpts);
1436
1696
  }
1437
1697
  function wireTooltipAndInteraction(svg, layout) {
1438
1698
  const cleanups = [];
1699
+ const navigation = [];
1700
+ let activeLeave = null;
1439
1701
  const nodesById = /* @__PURE__ */ new Map();
1440
1702
  for (const n of layout.nodes) {
1441
1703
  if (!nodesById.has(n.nodeId)) nodesById.set(n.nodeId, n);
@@ -1448,6 +1710,8 @@ function createSankey(container, spec, options) {
1448
1710
  const nodeId = el.getAttribute("data-node-id");
1449
1711
  const nodeData = nodeId ? nodesById.get(nodeId)?.data ?? {} : {};
1450
1712
  const handleMouseEnter = (e) => {
1713
+ activeLeave?.();
1714
+ activeLeave = handleMouseLeave;
1451
1715
  const mouseEvent = e;
1452
1716
  if (content && tooltipManager) {
1453
1717
  const svgRect = svg.getBoundingClientRect();
@@ -1468,6 +1732,8 @@ function createSankey(container, spec, options) {
1468
1732
  }
1469
1733
  };
1470
1734
  const handleMouseLeave = () => {
1735
+ if (activeLeave !== handleMouseLeave) return;
1736
+ activeLeave = null;
1471
1737
  tooltipManager?.hide();
1472
1738
  options?.onNodeHover?.(null);
1473
1739
  resetLinkOpacity(svg, layout);
@@ -1475,6 +1741,13 @@ function createSankey(container, spec, options) {
1475
1741
  const handleClick = () => {
1476
1742
  options?.onNodeClick?.(nodeData);
1477
1743
  };
1744
+ navigation.push({
1745
+ element: el,
1746
+ key: nodeId ? `node:${nodeId}` : markId,
1747
+ enter: handleMouseEnter,
1748
+ leave: handleMouseLeave,
1749
+ activate: handleClick
1750
+ });
1478
1751
  el.addEventListener("mouseenter", handleMouseEnter);
1479
1752
  el.addEventListener("mousemove", handleMouseMove);
1480
1753
  el.addEventListener("mouseleave", handleMouseLeave);
@@ -1487,14 +1760,16 @@ function createSankey(container, spec, options) {
1487
1760
  });
1488
1761
  }
1489
1762
  const linkElements = svg.querySelectorAll(".oc-sankey-link");
1490
- for (const el of linkElements) {
1763
+ for (const [index, el] of linkElements.entries()) {
1491
1764
  const markId = el.getAttribute("data-mark-id");
1492
1765
  if (!markId) continue;
1493
1766
  const content = layout.tooltipDescriptors.get(markId);
1494
1767
  const sourceId = el.getAttribute("data-source");
1495
1768
  const targetId = el.getAttribute("data-target");
1496
- const linkData = findLinkData(layout, sourceId, targetId);
1769
+ const linkData = layout.links[index]?.data ?? {};
1497
1770
  const handleMouseEnter = (e) => {
1771
+ activeLeave?.();
1772
+ activeLeave = handleMouseLeave;
1498
1773
  const mouseEvent = e;
1499
1774
  if (content && tooltipManager) {
1500
1775
  const svgRect = svg.getBoundingClientRect();
@@ -1521,6 +1796,8 @@ function createSankey(container, spec, options) {
1521
1796
  }
1522
1797
  };
1523
1798
  const handleMouseLeave = () => {
1799
+ if (activeLeave !== handleMouseLeave) return;
1800
+ activeLeave = null;
1524
1801
  tooltipManager?.hide();
1525
1802
  options?.onLinkHover?.(null);
1526
1803
  resetLinkOpacity(svg, layout);
@@ -1528,6 +1805,13 @@ function createSankey(container, spec, options) {
1528
1805
  const handleClick = () => {
1529
1806
  options?.onLinkClick?.(linkData);
1530
1807
  };
1808
+ navigation.push({
1809
+ element: el,
1810
+ key: JSON.stringify(["link", sourceId, targetId]),
1811
+ enter: handleMouseEnter,
1812
+ leave: handleMouseLeave,
1813
+ activate: handleClick
1814
+ });
1531
1815
  el.addEventListener("mouseenter", handleMouseEnter);
1532
1816
  el.addEventListener("mousemove", handleMouseMove);
1533
1817
  el.addEventListener("mouseleave", handleMouseLeave);
@@ -1539,17 +1823,14 @@ function createSankey(container, spec, options) {
1539
1823
  el.removeEventListener("click", handleClick);
1540
1824
  });
1541
1825
  }
1826
+ cleanups.push(wireMarkNavigation(svg, navigation));
1542
1827
  return () => {
1828
+ activeLeave?.();
1543
1829
  for (const cleanup of cleanups) {
1544
1830
  cleanup();
1545
1831
  }
1546
1832
  };
1547
1833
  }
1548
- function findLinkData(layout, sourceId, targetId) {
1549
- if (!sourceId || !targetId) return {};
1550
- const link = layout.links.find((l) => l.sourceId === sourceId && l.targetId === targetId);
1551
- return link?.data ?? {};
1552
- }
1553
1834
  function tracePath(layout, seeds) {
1554
1835
  const out = /* @__PURE__ */ new Map();
1555
1836
  const inn = /* @__PURE__ */ new Map();
@@ -1601,20 +1882,21 @@ function createSankey(container, spec, options) {
1601
1882
  }
1602
1883
  function resetLinkOpacity(svg, layout) {
1603
1884
  const linkElements = svg.querySelectorAll(".oc-sankey-link");
1604
- for (const el of linkElements) {
1885
+ for (const [index, el] of linkElements.entries()) {
1605
1886
  const path = el.querySelector("path");
1606
1887
  if (!path) continue;
1607
- const source = el.getAttribute("data-source");
1608
- const target = el.getAttribute("data-target");
1609
- const link = layout.links.find((l) => l.sourceId === source && l.targetId === target);
1888
+ const link = layout.links[index];
1610
1889
  path.setAttribute("fill-opacity", String(link?.fillOpacity ?? 0.5));
1611
1890
  }
1612
1891
  for (const el of svg.querySelectorAll(".oc-sankey-node, .oc-sankey-label")) {
1613
1892
  el.style.opacity = "1";
1614
1893
  }
1615
1894
  }
1616
- function render() {
1895
+ function render(prepared) {
1617
1896
  if (destroyed) return;
1897
+ const nextLayout = prepared ?? compile();
1898
+ const focusedMark = captureMarkFocus(svgElement);
1899
+ tooltipManager?.hide();
1618
1900
  if (animationCleanup) {
1619
1901
  animationCleanup();
1620
1902
  animationCleanup = null;
@@ -1629,7 +1911,7 @@ function createSankey(container, spec, options) {
1629
1911
  if (svgElement?.parentNode) {
1630
1912
  svgElement.parentNode.removeChild(svgElement);
1631
1913
  }
1632
- currentLayout = compile();
1914
+ currentLayout = nextLayout;
1633
1915
  const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
1634
1916
  isFirstRender = false;
1635
1917
  const animation = shouldAnimate ? currentLayout.animation : void 0;
@@ -1645,8 +1927,8 @@ function createSankey(container, spec, options) {
1645
1927
  if (!tooltipManager) {
1646
1928
  tooltipManager = createTooltipManager(container);
1647
1929
  }
1648
- cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
1649
1930
  }
1931
+ cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
1650
1932
  if (shouldAnimate && svgElement) {
1651
1933
  animationCleanup = setupAnimationCleanup(svgElement, () => {
1652
1934
  animationCleanup = null;
@@ -1662,17 +1944,17 @@ function createSankey(container, spec, options) {
1662
1944
  fontsReloadPending = false;
1663
1945
  container.dataset.ocFontsState = "ready";
1664
1946
  }
1947
+ if (svgElement) restoreMarkFocus(svgElement, focusedMark);
1665
1948
  }
1666
1949
  function update(newSpec) {
1667
1950
  if (destroyed) return;
1951
+ const nextFont = resolveEffectiveFont(newSpec);
1952
+ const nextMeasure = nextFont === fontFamily ? measureText : createMeasureText(nextFont);
1953
+ const nextLayout = compile(newSpec, nextMeasure);
1668
1954
  currentSpec = newSpec;
1669
- const nextFont = resolveEffectiveFont();
1670
- if (nextFont !== fontFamily) {
1671
- fontFamily = nextFont;
1672
- measureText = createMeasureText(fontFamily);
1673
- }
1674
- isFirstRender = true;
1675
- render();
1955
+ fontFamily = nextFont;
1956
+ measureText = nextMeasure;
1957
+ render(nextLayout);
1676
1958
  }
1677
1959
  function resize() {
1678
1960
  if (destroyed) return;
@@ -1686,15 +1968,17 @@ function createSankey(container, spec, options) {
1686
1968
  if (!svgElement) {
1687
1969
  throw new Error("Sankey is not rendered yet");
1688
1970
  }
1971
+ const terminalSvg = renderSankeySVG(currentLayout);
1972
+ const resolvedOptions = { theme: currentLayout.theme, ...exportOptions };
1689
1973
  switch (format) {
1690
1974
  case "svg":
1691
- return exportSVG(svgElement);
1975
+ return exportSVG(terminalSvg);
1692
1976
  case "svg-with-fonts":
1693
- return exportSVGWithFonts(svgElement, exportOptions);
1977
+ return exportSVGWithFonts(terminalSvg, resolvedOptions);
1694
1978
  case "png":
1695
- return exportPNG(svgElement, exportOptions);
1979
+ return exportPNG(terminalSvg, resolvedOptions);
1696
1980
  case "jpg":
1697
- return exportJPG(svgElement, exportOptions);
1981
+ return exportJPG(terminalSvg, resolvedOptions);
1698
1982
  default:
1699
1983
  throw new Error(`Unsupported export format: ${format}`);
1700
1984
  }
@@ -1744,8 +2028,8 @@ function createSankey(container, spec, options) {
1744
2028
  }
1745
2029
  if (options?.tooltip !== false && svgElement) {
1746
2030
  tooltipManager = createTooltipManager(container);
1747
- cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
1748
2031
  }
2032
+ cleanupTooltipEvents = wireTooltipAndInteraction(svgElement, currentLayout);
1749
2033
  if (shouldAnimate && svgElement) {
1750
2034
  animationCleanup = setupAnimationCleanup(svgElement, () => {
1751
2035
  animationCleanup = null;
@@ -1787,9 +2071,11 @@ function createSankey(container, spec, options) {
1787
2071
  }
1788
2072
 
1789
2073
  // src/table-keyboard.ts
2074
+ var navigationId = 0;
1790
2075
  function attachKeyboardNav(options) {
1791
2076
  const { wrapper, onSort, onClearSearch, onAnnounce } = options;
1792
2077
  let focusedCell = { row: -1, col: 0 };
2078
+ const idPrefix = `oc-table-${++navigationId}`;
1793
2079
  const table = wrapper.querySelector("table");
1794
2080
  if (!table) return () => {
1795
2081
  };
@@ -1806,10 +2092,10 @@ function attachKeyboardNav(options) {
1806
2092
  if (!thead) return [];
1807
2093
  const headerRow = thead.querySelector("tr");
1808
2094
  if (!headerRow) return [];
1809
- return Array.from(headerRow.querySelectorAll("th"));
2095
+ return Array.from(headerRow.querySelectorAll("th")).filter((cell) => !cell.hidden);
1810
2096
  }
1811
2097
  function getCellsInRow(tr) {
1812
- return Array.from(tr.querySelectorAll("td"));
2098
+ return Array.from(tr.querySelectorAll("td")).filter((cell) => !cell.hidden);
1813
2099
  }
1814
2100
  function getColCount() {
1815
2101
  const rows = getRows();
@@ -1836,7 +2122,7 @@ function attachKeyboardNav(options) {
1836
2122
  const cells = getCellsInRow(tr);
1837
2123
  const cell = cells[col];
1838
2124
  if (!cell) return;
1839
- const cellId = `oc-cell-${row}-${col}`;
2125
+ const cellId = `${idPrefix}-cell-${row}-${col}`;
1840
2126
  cell.id = cellId;
1841
2127
  cell.classList.add("oc-table-cell-focus");
1842
2128
  cell.setAttribute("data-row", String(row));
@@ -1848,17 +2134,33 @@ function attachKeyboardNav(options) {
1848
2134
  }
1849
2135
  function handleTbodyFocus() {
1850
2136
  if (focusedCell.row < 0) {
1851
- setFocusedCell(0, 0);
2137
+ const row = getRows().findIndex(
2138
+ (item) => item.getAttribute("data-row-id") === options.initialCell?.rowId
2139
+ );
2140
+ const col = getHeaderCells().findIndex(
2141
+ (item) => item.getAttribute("data-column") === options.initialCell?.columnKey
2142
+ );
2143
+ setFocusedCell(Math.max(0, row), Math.max(0, col));
1852
2144
  } else {
1853
2145
  setFocusedCell(focusedCell.row, focusedCell.col);
1854
2146
  }
1855
2147
  }
1856
2148
  function handleTbodyKeydown(e) {
2149
+ if (e.target !== tbody) return;
1857
2150
  const rows = getRows();
1858
2151
  if (rows.length === 0) return;
1859
2152
  const colCount = getColCount();
1860
2153
  const { row, col } = focusedCell;
1861
2154
  switch (e.key) {
2155
+ case "Enter":
2156
+ case " ": {
2157
+ const rowId = rows[Math.max(row, 0)]?.getAttribute("data-row-id");
2158
+ if (rowId && options.onRowActivate) {
2159
+ e.preventDefault();
2160
+ options.onRowActivate(rowId);
2161
+ }
2162
+ break;
2163
+ }
1862
2164
  case "ArrowDown":
1863
2165
  e.preventDefault();
1864
2166
  if (row < rows.length - 1) {
@@ -1944,7 +2246,7 @@ function attachKeyboardNav(options) {
1944
2246
  if (e.key === "Escape") {
1945
2247
  e.preventDefault();
1946
2248
  onClearSearch();
1947
- if (tbody) {
2249
+ if (tbody?.isConnected) {
1948
2250
  tbody.focus();
1949
2251
  onAnnounce("Search cleared");
1950
2252
  }
@@ -1973,6 +2275,46 @@ function attachKeyboardNav(options) {
1973
2275
  };
1974
2276
  }
1975
2277
 
2278
+ // src/table-responsive.ts
2279
+ function applyPriorityTable(wrapper, layout, enabled) {
2280
+ wrapper.classList.toggle("oc-table--priority", enabled);
2281
+ for (const old of wrapper.querySelectorAll(".oc-table-details")) old.remove();
2282
+ const secondary = layout.columns.flatMap((column, index) => column.priority > 1 ? [index] : []);
2283
+ const hidden = new Set(secondary);
2284
+ for (const row of wrapper.querySelectorAll("thead tr, tbody tr, tfoot tr")) {
2285
+ const cells = Array.from(row.children);
2286
+ cells.forEach((cell2, index) => {
2287
+ cell2.hidden = enabled && hidden.has(index);
2288
+ });
2289
+ if (!enabled || !secondary.length) continue;
2290
+ const cell = document.createElement(row.parentElement?.tagName === "THEAD" ? "th" : "td");
2291
+ cell.className = "oc-table-details";
2292
+ if (cell.tagName === "TH") {
2293
+ cell.textContent = "Details";
2294
+ cell.setAttribute("scope", "col");
2295
+ } else if (row.parentElement?.tagName === "TBODY") {
2296
+ const details = document.createElement("details");
2297
+ const summary = document.createElement("summary");
2298
+ summary.textContent = "More";
2299
+ summary.setAttribute("aria-label", `More details for ${cells[0]?.textContent ?? "row"}`);
2300
+ details.appendChild(summary);
2301
+ const list = document.createElement("dl");
2302
+ for (const index of secondary) {
2303
+ const term = document.createElement("dt");
2304
+ term.textContent = layout.columns[index].label;
2305
+ const description = document.createElement("dd");
2306
+ for (const child of Array.from(cells[index]?.childNodes ?? []))
2307
+ description.appendChild(child.cloneNode(true));
2308
+ list.append(term, description);
2309
+ }
2310
+ details.appendChild(list);
2311
+ details.addEventListener("click", (event) => event.stopPropagation());
2312
+ cell.appendChild(details);
2313
+ }
2314
+ row.appendChild(cell);
2315
+ }
2316
+ }
2317
+
1976
2318
  // src/table-mount.ts
1977
2319
  import { BREAKPOINT_COMPACT_MAX } from "@opendata-ai/openchart-core";
1978
2320
  import { compileTable } from "@opendata-ai/openchart-engine";
@@ -2064,6 +2406,7 @@ function renderTbody(rows, columns) {
2064
2406
  if (!cell) continue;
2065
2407
  const td = renderCell(cell);
2066
2408
  td.setAttribute("role", "gridcell");
2409
+ td.setAttribute("data-column", columns[c].key);
2067
2410
  td.style.textAlign = columns[c].align;
2068
2411
  td.setAttribute("data-label", columns[c].label);
2069
2412
  td.setAttribute("data-priority", String(columns[c].priority));
@@ -2083,6 +2426,7 @@ function renderTfoot(totalRow, columns) {
2083
2426
  const cell = totalRow.cells[c];
2084
2427
  const td = cell ? renderCell(cell) : document.createElement("td");
2085
2428
  td.setAttribute("role", "gridcell");
2429
+ td.setAttribute("data-column", columns[c].key);
2086
2430
  td.style.textAlign = columns[c].align;
2087
2431
  td.setAttribute("data-label", columns[c].label);
2088
2432
  td.setAttribute("data-priority", String(columns[c].priority));
@@ -2271,6 +2615,7 @@ function renderTable(layout, container, opts) {
2271
2615
  const stagger = clampStaggerDelay2(enterPhase.staggerDelay, rowCount);
2272
2616
  stampAnimationVars(wrapper, {
2273
2617
  duration: enterPhase.duration,
2618
+ spring: enterPhase.spring,
2274
2619
  stagger,
2275
2620
  ease: enterPhase.ease
2276
2621
  });
@@ -2305,6 +2650,7 @@ function createTable(container, spec, options) {
2305
2650
  let disconnectResize = null;
2306
2651
  let cleanupKeyboard = null;
2307
2652
  let cleanupAnimations = null;
2653
+ let pendingResize = false;
2308
2654
  let isFirstRender = true;
2309
2655
  let destroyed = false;
2310
2656
  let sortTouched = false;
@@ -2346,7 +2692,7 @@ function createTable(container, spec, options) {
2346
2692
  options?.onStateChange?.({ ...internalState });
2347
2693
  }
2348
2694
  }
2349
- function compile() {
2695
+ function compile(candidate = currentSpec) {
2350
2696
  const state = getState();
2351
2697
  const darkMode = resolveDarkMode(options?.darkMode);
2352
2698
  const { width } = getContainerDimensions();
@@ -2364,7 +2710,7 @@ function createTable(container, spec, options) {
2364
2710
  search: state.search || void 0,
2365
2711
  page: state.page
2366
2712
  };
2367
- return compileTable(currentSpec, compileOpts);
2713
+ return compileTable(candidate, compileOpts);
2368
2714
  }
2369
2715
  function sameSort(a, b) {
2370
2716
  if (a === null || b === null) return a === b;
@@ -2390,7 +2736,17 @@ function createTable(container, spec, options) {
2390
2736
  function applyBreakpointClass() {
2391
2737
  if (!wrapperElement) return;
2392
2738
  const { width } = getContainerDimensions();
2393
- wrapperElement.classList.toggle("oc-table--cards", width < BREAKPOINT_COMPACT_MAX);
2739
+ const compact = width < BREAKPOINT_COMPACT_MAX;
2740
+ wrapperElement.classList.toggle(
2741
+ "oc-table--cards",
2742
+ compact && (currentSpec.mobileLayout ?? "cards") === "cards"
2743
+ );
2744
+ if (currentLayout)
2745
+ applyPriorityTable(
2746
+ wrapperElement,
2747
+ currentLayout,
2748
+ compact && currentSpec.mobileLayout === "priority"
2749
+ );
2394
2750
  if (currentSpec.density !== void 0 || currentSpec.compact === true) return;
2395
2751
  const columns = currentLayout?.columns.length ?? 0;
2396
2752
  const condensed = columns * MIN_READABLE_COLUMN_WIDTH > width || width < AUTO_CONDENSE_MAX_WIDTH;
@@ -2398,69 +2754,100 @@ function createTable(container, spec, options) {
2398
2754
  wrapperElement.classList.toggle("oc-table--compact", condensed);
2399
2755
  wrapperElement.classList.toggle("oc-table--regular", !condensed);
2400
2756
  }
2401
- function render() {
2757
+ function render(candidate = currentSpec) {
2402
2758
  if (destroyed) return;
2403
- try {
2404
- if (cleanupAnimations) {
2405
- cleanupAnimations();
2759
+ const nextLayout = compile(candidate);
2760
+ const active = document.activeElement;
2761
+ const bodyFocused = active === wrapperElement?.querySelector("tbody");
2762
+ const focusedCell = wrapperElement?.querySelector(".oc-table-cell-focus");
2763
+ const initialCell = bodyFocused && focusedCell ? {
2764
+ rowId: focusedCell.closest("tr")?.getAttribute("data-row-id") ?? "",
2765
+ columnKey: focusedCell.getAttribute("data-column") ?? ""
2766
+ } : void 0;
2767
+ const headerKey = wrapperElement?.contains(active) ? active?.closest("th")?.getAttribute("data-column") : null;
2768
+ const pageAction = wrapperElement?.contains(active) ? active?.getAttribute("data-page-action") : null;
2769
+ currentSpec = candidate;
2770
+ if (cleanupAnimations) {
2771
+ cleanupAnimations();
2772
+ cleanupAnimations = null;
2773
+ }
2774
+ if (cleanupKeyboard) {
2775
+ cleanupKeyboard();
2776
+ cleanupKeyboard = null;
2777
+ }
2778
+ if (wrapperElement?.parentNode) {
2779
+ wrapperElement.parentNode.removeChild(wrapperElement);
2780
+ wrapperElement = null;
2781
+ }
2782
+ currentLayout = nextLayout;
2783
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
2784
+ wrapperElement = renderTable(currentLayout, container, { animate: shouldAnimate });
2785
+ if (shouldAnimate && wrapperElement) {
2786
+ cleanupAnimations = setupTableAnimationCleanup(wrapperElement, () => {
2406
2787
  cleanupAnimations = null;
2407
- }
2408
- if (cleanupKeyboard) {
2409
- cleanupKeyboard();
2410
- cleanupKeyboard = null;
2411
- }
2412
- if (wrapperElement?.parentNode) {
2413
- wrapperElement.parentNode.removeChild(wrapperElement);
2414
- wrapperElement = null;
2415
- }
2416
- currentLayout = compile();
2417
- const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
2418
- wrapperElement = renderTable(currentLayout, container, { animate: shouldAnimate });
2419
- if (shouldAnimate && wrapperElement) {
2420
- cleanupAnimations = setupTableAnimationCleanup(wrapperElement);
2421
- }
2422
- if (isFirstRender) {
2423
- isFirstRender = false;
2424
- }
2425
- const isDark = resolveDarkMode(options?.darkMode);
2426
- if (isDark) {
2427
- container.classList.add("oc-dark");
2428
- } else {
2429
- container.classList.remove("oc-dark");
2430
- }
2431
- applyBreakpointClass();
2432
- if (options?.onRowClick) {
2433
- wrapperElement.classList.add("oc-table--clickable");
2434
- }
2435
- wireEvents();
2436
- if (wrapperElement) {
2437
- cleanupKeyboard = attachKeyboardNav({
2438
- wrapper: wrapperElement,
2439
- onSort: (columnKey) => {
2440
- const state = getState();
2441
- const newSort = cycleSort(state.sort, columnKey);
2442
- updateState({ sort: newSort, page: 0 });
2443
- if (newSort) {
2444
- const dir = newSort.direction === "asc" ? "ascending" : "descending";
2445
- announce(`Sorted by ${columnKey} ${dir}`);
2446
- } else {
2447
- announce("Sort cleared");
2448
- }
2449
- if (!isControlled) {
2450
- rerender();
2451
- }
2452
- },
2453
- onClearSearch: () => {
2454
- updateState({ search: "", page: 0 });
2455
- if (!isControlled) {
2456
- rerender();
2457
- }
2458
- },
2459
- onAnnounce: announce
2460
- });
2461
- }
2462
- } catch (err) {
2463
- console.error("[viz] Table render failed:", err);
2788
+ if (pendingResize && !destroyed) {
2789
+ pendingResize = false;
2790
+ resize();
2791
+ }
2792
+ });
2793
+ }
2794
+ if (isFirstRender) {
2795
+ isFirstRender = false;
2796
+ }
2797
+ const isDark = resolveDarkMode(options?.darkMode);
2798
+ if (isDark) {
2799
+ container.classList.add("oc-dark");
2800
+ } else {
2801
+ container.classList.remove("oc-dark");
2802
+ }
2803
+ applyBreakpointClass();
2804
+ if (options?.onRowClick) {
2805
+ wrapperElement.classList.add("oc-table--clickable");
2806
+ }
2807
+ wireEvents();
2808
+ if (wrapperElement) {
2809
+ cleanupKeyboard = attachKeyboardNav({
2810
+ wrapper: wrapperElement,
2811
+ initialCell,
2812
+ onRowActivate: options?.onRowClick ? (rowId) => {
2813
+ const row = currentLayout.rows.find((item) => item.id === rowId);
2814
+ if (row) options.onRowClick?.(row.data);
2815
+ } : void 0,
2816
+ onSort: (columnKey) => {
2817
+ const state = getState();
2818
+ const newSort = cycleSort(state.sort, columnKey);
2819
+ updateState({ sort: newSort, page: 0 });
2820
+ if (newSort) {
2821
+ const dir = newSort.direction === "asc" ? "ascending" : "descending";
2822
+ announce(`Sorted by ${columnKey} ${dir}`);
2823
+ } else {
2824
+ announce("Sort cleared");
2825
+ }
2826
+ if (!isControlled) {
2827
+ rerender();
2828
+ }
2829
+ },
2830
+ onClearSearch: () => {
2831
+ if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
2832
+ searchDebounceTimer = null;
2833
+ updateState({ search: "", page: 0 });
2834
+ if (!isControlled) {
2835
+ rerender();
2836
+ }
2837
+ wrapperElement?.querySelector("tbody")?.focus();
2838
+ announce("Search cleared");
2839
+ },
2840
+ onAnnounce: announce
2841
+ });
2842
+ }
2843
+ if (bodyFocused) wrapperElement?.querySelector("tbody")?.focus();
2844
+ else if (headerKey) {
2845
+ [...wrapperElement?.querySelectorAll("th[data-column]") ?? []].find((element) => element.dataset.column === headerKey)?.focus();
2846
+ } else if (pageAction) {
2847
+ const buttons = [
2848
+ ...wrapperElement?.querySelectorAll("[data-page-action]") ?? []
2849
+ ];
2850
+ (buttons.find((button) => button.dataset.pageAction === pageAction && !button.disabled) ?? buttons.find((button) => !button.disabled) ?? wrapperElement?.querySelector("tbody"))?.focus();
2464
2851
  }
2465
2852
  }
2466
2853
  function wireEvents() {
@@ -2543,20 +2930,22 @@ function createTable(container, spec, options) {
2543
2930
  options?.onRowClick?.(row.data);
2544
2931
  }
2545
2932
  }
2546
- function rerender() {
2933
+ function rerender(candidate = currentSpec) {
2547
2934
  if (destroyed) return;
2548
2935
  const searchInput = wrapperElement?.querySelector(
2549
2936
  ".oc-table-search input"
2550
2937
  );
2551
2938
  const hadFocus = searchInput && document.activeElement === searchInput;
2939
+ const pendingQuery = searchDebounceTimer !== null ? searchInput?.value : void 0;
2552
2940
  const selectionStart = searchInput?.selectionStart ?? 0;
2553
2941
  const selectionEnd = searchInput?.selectionEnd ?? 0;
2554
- render();
2942
+ render(candidate);
2555
2943
  if (hadFocus) {
2556
2944
  const newInput = wrapperElement?.querySelector(
2557
2945
  ".oc-table-search input"
2558
2946
  );
2559
2947
  if (newInput) {
2948
+ if (pendingQuery !== void 0) newInput.value = pendingQuery;
2560
2949
  newInput.focus();
2561
2950
  newInput.setSelectionRange(selectionStart, selectionEnd);
2562
2951
  }
@@ -2564,13 +2953,15 @@ function createTable(container, spec, options) {
2564
2953
  }
2565
2954
  function update(newSpec) {
2566
2955
  if (destroyed) return;
2567
- currentSpec = newSpec;
2568
- render();
2956
+ rerender(newSpec);
2569
2957
  }
2570
2958
  function resize() {
2571
2959
  if (destroyed) return;
2572
- if (cleanupAnimations) return;
2573
- render();
2960
+ if (cleanupAnimations) {
2961
+ pendingResize = true;
2962
+ return;
2963
+ }
2964
+ rerender();
2574
2965
  }
2575
2966
  function doExport(format) {
2576
2967
  if (format !== "csv") {
@@ -2579,15 +2970,18 @@ function createTable(container, spec, options) {
2579
2970
  const state = getState();
2580
2971
  const darkMode = resolveDarkMode(options?.darkMode);
2581
2972
  const { width } = getContainerDimensions();
2582
- const fullLayout = compileTable(currentSpec, {
2583
- width,
2584
- height: 600,
2585
- theme: options?.theme,
2586
- darkMode,
2587
- sort: resolveEffectiveSort(state),
2588
- search: state.search || void 0
2589
- // No page/pageSize: get all rows
2590
- });
2973
+ const fullLayout = compileTable(
2974
+ { ...currentSpec, pagination: false },
2975
+ {
2976
+ width,
2977
+ height: 600,
2978
+ theme: options?.theme,
2979
+ darkMode,
2980
+ sort: resolveEffectiveSort(state),
2981
+ search: state.search || void 0
2982
+ // No page/pageSize: get all rows
2983
+ }
2984
+ );
2591
2985
  const headers = fullLayout.columns.map((c) => c.label);
2592
2986
  const csvRows = [headers.map(csvEscape).join(",")];
2593
2987
  for (const row of fullLayout.rows) {
@@ -2598,6 +2992,10 @@ function createTable(container, spec, options) {
2598
2992
  }
2599
2993
  function setState(partial) {
2600
2994
  if (destroyed) return;
2995
+ if (partial.search !== void 0 && partial.search !== getState().search && searchDebounceTimer !== null) {
2996
+ clearTimeout(searchDebounceTimer);
2997
+ searchDebounceTimer = null;
2998
+ }
2601
2999
  if (partial.sort !== void 0 && !sameSort(partial.sort, getState().sort)) {
2602
3000
  sortTouched = true;
2603
3001
  }
@@ -2610,7 +3008,7 @@ function createTable(container, spec, options) {
2610
3008
  if (partial.search !== void 0) internalState.search = partial.search;
2611
3009
  if (partial.page !== void 0) internalState.page = partial.page;
2612
3010
  }
2613
- render();
3011
+ rerender();
2614
3012
  }
2615
3013
  function destroy() {
2616
3014
  if (destroyed) return;
@@ -2687,8 +3085,8 @@ function createTileMap(container, spec, options) {
2687
3085
  let isAutoHeight = null;
2688
3086
  let fontsReloadPending = false;
2689
3087
  container.classList.add("oc-tilemap-root");
2690
- function resolveEffectiveFont() {
2691
- return options?.theme?.fonts?.family ?? currentSpec.theme?.fonts?.family ?? resolveFontFamily(container);
3088
+ function resolveEffectiveFont(candidate = currentSpec) {
3089
+ return options?.theme?.fonts?.family ?? candidate.theme?.fonts?.family ?? resolveFontFamily(container);
2692
3090
  }
2693
3091
  let fontFamily = resolveEffectiveFont();
2694
3092
  let measureText = createMeasureText(fontFamily);
@@ -2709,7 +3107,7 @@ function createTileMap(container, spec, options) {
2709
3107
  const height = targetTile * TILEMAP_GRID_ROWS + TILEMAP_TILE_GAP * (TILEMAP_GRID_ROWS - 1) + TILEMAP_CHROME_RESERVE;
2710
3108
  return { width, height };
2711
3109
  }
2712
- function compile() {
3110
+ function compile(candidate = currentSpec, measure = measureText) {
2713
3111
  const { width, height } = getContainerDimensions();
2714
3112
  const darkMode = resolveDarkMode(options?.darkMode);
2715
3113
  const compileOpts = {
@@ -2718,12 +3116,14 @@ function createTileMap(container, spec, options) {
2718
3116
  theme: options?.theme,
2719
3117
  darkMode,
2720
3118
  watermark: options?.watermark,
2721
- measureText
3119
+ measureText: measure
2722
3120
  };
2723
- return compileTileMap(currentSpec, compileOpts);
3121
+ return compileTileMap(candidate, compileOpts);
2724
3122
  }
2725
3123
  function wireTooltipAndInteraction(svg, layout) {
2726
3124
  const cleanups = [];
3125
+ const navigation = [];
3126
+ let activeLeave = null;
2727
3127
  const tilesByStateCode = /* @__PURE__ */ new Map();
2728
3128
  for (const t of layout.tiles) {
2729
3129
  if (!tilesByStateCode.has(t.stateCode)) tilesByStateCode.set(t.stateCode, t);
@@ -2736,6 +3136,8 @@ function createTileMap(container, spec, options) {
2736
3136
  const content = layout.tooltipDescriptors.get(stateCode);
2737
3137
  const tile = tilesByStateCode.get(stateCode);
2738
3138
  const handleMouseEnter = (e) => {
3139
+ activeLeave?.();
3140
+ activeLeave = handleMouseLeave;
2739
3141
  const mouseEvent = e;
2740
3142
  tilesGroup?.classList.add("oc-hover-active");
2741
3143
  el.classList.add("oc-tilemap-tile--hover");
@@ -2764,6 +3166,8 @@ function createTileMap(container, spec, options) {
2764
3166
  }
2765
3167
  };
2766
3168
  const handleMouseLeave = () => {
3169
+ if (activeLeave !== handleMouseLeave) return;
3170
+ activeLeave = null;
2767
3171
  tilesGroup?.classList.remove("oc-hover-active");
2768
3172
  el.classList.remove("oc-tilemap-tile--hover");
2769
3173
  tooltipManager?.hide();
@@ -2779,6 +3183,13 @@ function createTileMap(container, spec, options) {
2779
3183
  });
2780
3184
  }
2781
3185
  };
3186
+ navigation.push({
3187
+ element: el,
3188
+ key: stateCode,
3189
+ enter: handleMouseEnter,
3190
+ leave: handleMouseLeave,
3191
+ activate: handleClick
3192
+ });
2782
3193
  el.addEventListener("mouseenter", handleMouseEnter);
2783
3194
  el.addEventListener("mousemove", handleMouseMove);
2784
3195
  el.addEventListener("mouseleave", handleMouseLeave);
@@ -2790,13 +3201,17 @@ function createTileMap(container, spec, options) {
2790
3201
  el.removeEventListener("click", handleClick);
2791
3202
  });
2792
3203
  }
3204
+ cleanups.push(wireMarkNavigation(svg, navigation));
2793
3205
  return () => {
3206
+ activeLeave?.();
2794
3207
  for (const cleanup of cleanups) {
2795
3208
  cleanup();
2796
3209
  }
2797
3210
  };
2798
3211
  }
2799
3212
  function render(animate = false) {
3213
+ const focusedMark = captureMarkFocus(svgElement);
3214
+ tooltipManager?.hide();
2800
3215
  if (animationCleanup) {
2801
3216
  animationCleanup();
2802
3217
  animationCleanup = null;
@@ -2819,6 +3234,7 @@ function createTileMap(container, spec, options) {
2819
3234
  }
2820
3235
  if (animate && currentLayout.animation?.enter) {
2821
3236
  animationCleanup = setupAnimationCleanup(newSvg, () => {
3237
+ animationCleanup = null;
2822
3238
  if (pendingResize && !destroyed) {
2823
3239
  pendingResize = false;
2824
3240
  resize();
@@ -2831,15 +3247,17 @@ function createTileMap(container, spec, options) {
2831
3247
  fontsReloadPending = false;
2832
3248
  container.dataset.ocFontsState = "ready";
2833
3249
  }
3250
+ if (svgElement) restoreMarkFocus(svgElement, focusedMark);
2834
3251
  }
2835
3252
  function update(newSpec) {
3253
+ if (destroyed) return;
3254
+ const nextFont = resolveEffectiveFont(newSpec);
3255
+ const nextMeasure = nextFont === fontFamily ? measureText : createMeasureText(nextFont);
3256
+ const nextLayout = compile(newSpec, nextMeasure);
2836
3257
  currentSpec = newSpec;
2837
- const nextFont = resolveEffectiveFont();
2838
- if (nextFont !== fontFamily) {
2839
- fontFamily = nextFont;
2840
- measureText = createMeasureText(fontFamily);
2841
- }
2842
- currentLayout = compile();
3258
+ fontFamily = nextFont;
3259
+ measureText = nextMeasure;
3260
+ currentLayout = nextLayout;
2843
3261
  render();
2844
3262
  }
2845
3263
  function resize() {
@@ -2853,15 +3271,17 @@ function createTileMap(container, spec, options) {
2853
3271
  }
2854
3272
  function exportChart(format, options_) {
2855
3273
  if (!svgElement) return "";
3274
+ const terminalSvg = renderTileMapSVG(currentLayout, { animate: false });
3275
+ const resolvedOptions = { theme: currentLayout.theme, ...options_ };
2856
3276
  switch (format) {
2857
3277
  case "svg":
2858
- return exportSVG(svgElement);
3278
+ return exportSVG(terminalSvg);
2859
3279
  case "svg-with-fonts":
2860
- return exportSVGWithFonts(svgElement);
3280
+ return exportSVGWithFonts(terminalSvg, resolvedOptions);
2861
3281
  case "png":
2862
- return exportPNG(svgElement, options_);
3282
+ return exportPNG(terminalSvg, resolvedOptions);
2863
3283
  case "jpg":
2864
- return exportJPG(svgElement, options_);
3284
+ return exportJPG(terminalSvg, resolvedOptions);
2865
3285
  default:
2866
3286
  return "";
2867
3287
  }
@@ -2931,6 +3351,7 @@ export {
2931
3351
  createChart,
2932
3352
  createGeoMap,
2933
3353
  createGraph,
3354
+ createReader,
2934
3355
  createSankey,
2935
3356
  createSimulationWorker,
2936
3357
  createTable,