@opendata-ai/openchart-vanilla 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -326,7 +326,8 @@ declare function renderTable(layout: TableLayout, container: HTMLElement): HTMLE
326
326
  * Tooltip manager: creates and positions a floating tooltip element.
327
327
  *
328
328
  * Shows tooltip content near the mouse/touch position with viewport
329
- * edge avoidance. Touch support via tap-to-show, tap-outside-to-hide.
329
+ * edge avoidance via @floating-ui/dom. Touch support via tap-to-show,
330
+ * tap-outside-to-hide.
330
331
  */
331
332
 
332
333
  interface TooltipManager {
package/dist/index.js CHANGED
@@ -640,10 +640,13 @@ var ZoomTransform = class _ZoomTransform {
640
640
  /**
641
641
  * Compute a transform that fits all nodes within the given canvas
642
642
  * dimensions with the specified padding.
643
+ *
644
+ * Returns the transform and the ideal content height (in screen pixels)
645
+ * so callers can shrink the canvas to eliminate dead space.
643
646
  */
644
647
  static fitBounds(nodes, canvasW, canvasH, padding = 40) {
645
648
  if (nodes.length === 0) {
646
- return _ZoomTransform.identity();
649
+ return { transform: _ZoomTransform.identity(), contentHeight: canvasH };
647
650
  }
648
651
  let minX = Infinity;
649
652
  let minY = Infinity;
@@ -659,7 +662,10 @@ var ZoomTransform = class _ZoomTransform {
659
662
  const graphW = maxX - minX;
660
663
  const graphH = maxY - minY;
661
664
  if (graphW === 0 && graphH === 0) {
662
- return new _ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1);
665
+ return {
666
+ transform: new _ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1),
667
+ contentHeight: padding * 2
668
+ };
663
669
  }
664
670
  const availW = canvasW - padding * 2;
665
671
  const availH = canvasH - padding * 2;
@@ -667,7 +673,11 @@ var ZoomTransform = class _ZoomTransform {
667
673
  const cx = (minX + maxX) / 2;
668
674
  const tx = canvasW / 2 - cx * k;
669
675
  const ty = padding - minY * k;
670
- return new _ZoomTransform(tx, ty, k);
676
+ const contentHeight = graphH * k + padding * 2;
677
+ return {
678
+ transform: new _ZoomTransform(tx, ty, k),
679
+ contentHeight
680
+ };
671
681
  }
672
682
  /** Identity transform (no pan, no zoom). */
673
683
  static identity() {
@@ -2373,6 +2383,7 @@ function observeResize(container, callback) {
2373
2383
  }
2374
2384
 
2375
2385
  // src/tooltip.ts
2386
+ import { computePosition, flip, offset, shift } from "@floating-ui/dom";
2376
2387
  var TOOLTIP_OFFSET = 12;
2377
2388
  function createTooltipManager(container) {
2378
2389
  const tooltip = document.createElement("div");
@@ -2380,6 +2391,8 @@ function createTooltipManager(container) {
2380
2391
  tooltip.setAttribute("role", "tooltip");
2381
2392
  container.style.position = container.style.position || "relative";
2382
2393
  container.appendChild(tooltip);
2394
+ let lastContentKey = "";
2395
+ let currentPositionId = 0;
2383
2396
  const handleDocumentTouch = (e) => {
2384
2397
  if (!container.contains(e.target)) {
2385
2398
  hide();
@@ -2387,45 +2400,60 @@ function createTooltipManager(container) {
2387
2400
  };
2388
2401
  document.addEventListener("touchstart", handleDocumentTouch);
2389
2402
  function show(content, x3, y3) {
2390
- let html = "";
2391
- if (content.title) {
2392
- const titleColor = content.fields.find((f) => f.color)?.color;
2393
- html += '<div class="viz-tooltip-header">';
2394
- if (titleColor) {
2395
- html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
2403
+ const contentKey = `${content.title}|${content.fields.length}|${content.fields[0]?.value}|${content.fields[content.fields.length - 1]?.value}`;
2404
+ if (contentKey !== lastContentKey) {
2405
+ lastContentKey = contentKey;
2406
+ let html = "";
2407
+ if (content.title) {
2408
+ const titleColor = content.fields.find((f) => f.color)?.color;
2409
+ html += '<div class="viz-tooltip-header">';
2410
+ if (titleColor) {
2411
+ html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
2412
+ }
2413
+ html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
2414
+ html += "</div>";
2396
2415
  }
2397
- html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
2398
- html += "</div>";
2399
- }
2400
- if (content.fields.length > 0) {
2401
- html += '<div class="viz-tooltip-body">';
2402
- for (const field of content.fields) {
2403
- html += '<div class="viz-tooltip-row">';
2404
- html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
2405
- html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
2416
+ if (content.fields.length > 0) {
2417
+ html += '<div class="viz-tooltip-body">';
2418
+ for (const field of content.fields) {
2419
+ html += '<div class="viz-tooltip-row">';
2420
+ html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
2421
+ html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
2422
+ html += "</div>";
2423
+ }
2406
2424
  html += "</div>";
2407
2425
  }
2408
- html += "</div>";
2426
+ tooltip.innerHTML = html;
2409
2427
  }
2410
- tooltip.innerHTML = html;
2411
2428
  tooltip.style.display = "block";
2412
- const containerRect = container.getBoundingClientRect();
2413
- const tooltipRect = tooltip.getBoundingClientRect();
2414
- let left = x3 + TOOLTIP_OFFSET;
2415
- let top = y3 + TOOLTIP_OFFSET;
2416
- if (left + tooltipRect.width > containerRect.width) {
2417
- left = x3 - tooltipRect.width - TOOLTIP_OFFSET;
2418
- }
2419
- if (top + tooltipRect.height > containerRect.height) {
2420
- top = y3 - tooltipRect.height - TOOLTIP_OFFSET;
2421
- }
2422
- left = Math.max(0, Math.min(left, containerRect.width - tooltipRect.width));
2423
- top = Math.max(0, Math.min(top, containerRect.height - tooltipRect.height));
2424
- tooltip.style.left = `${left}px`;
2425
- tooltip.style.top = `${top}px`;
2429
+ const positionId = ++currentPositionId;
2430
+ const virtualRef = {
2431
+ getBoundingClientRect() {
2432
+ const rect = container.getBoundingClientRect();
2433
+ return {
2434
+ x: rect.left + x3,
2435
+ y: rect.top + y3,
2436
+ width: 0,
2437
+ height: 0,
2438
+ top: rect.top + y3,
2439
+ left: rect.left + x3,
2440
+ right: rect.left + x3,
2441
+ bottom: rect.top + y3
2442
+ };
2443
+ }
2444
+ };
2445
+ computePosition(virtualRef, tooltip, {
2446
+ placement: "bottom-start",
2447
+ middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 5 })]
2448
+ }).then(({ x: fx, y: fy }) => {
2449
+ if (positionId !== currentPositionId) return;
2450
+ tooltip.style.left = `${fx}px`;
2451
+ tooltip.style.top = `${fy}px`;
2452
+ });
2426
2453
  }
2427
2454
  function hide() {
2428
2455
  tooltip.style.display = "none";
2456
+ lastContentKey = "";
2429
2457
  }
2430
2458
  function destroy() {
2431
2459
  document.removeEventListener("touchstart", handleDocumentTouch);
@@ -2436,7 +2464,7 @@ function createTooltipManager(container) {
2436
2464
  return { show, hide, destroy };
2437
2465
  }
2438
2466
  function esc(str) {
2439
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2467
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
2440
2468
  }
2441
2469
 
2442
2470
  // src/graph-mount.ts
@@ -2467,6 +2495,8 @@ function createGraph(container, spec, options) {
2467
2495
  let positionedNodes = [];
2468
2496
  let positionedEdges = [];
2469
2497
  let adjacencyMap = /* @__PURE__ */ new Map();
2498
+ let nodeDataMap = /* @__PURE__ */ new Map();
2499
+ let edgeDataMap = /* @__PURE__ */ new Map();
2470
2500
  let hoveredNodeId = null;
2471
2501
  let hoveredEdgeId = null;
2472
2502
  let selectedNodeIds = /* @__PURE__ */ new Set();
@@ -2474,6 +2504,7 @@ function createGraph(container, spec, options) {
2474
2504
  let needsRender = false;
2475
2505
  let isGesturing = false;
2476
2506
  let gestureTimeout = null;
2507
+ let lastEdgeHitTime = 0;
2477
2508
  function markGesture() {
2478
2509
  isGesturing = true;
2479
2510
  if (gestureTimeout !== null) clearTimeout(gestureTimeout);
@@ -2502,6 +2533,10 @@ function createGraph(container, spec, options) {
2502
2533
  };
2503
2534
  return compileGraph(currentSpec, compileOpts);
2504
2535
  }
2536
+ function buildDataMaps() {
2537
+ nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
2538
+ edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
2539
+ }
2505
2540
  function buildAdjacencyMap(edges) {
2506
2541
  const map = /* @__PURE__ */ new Map();
2507
2542
  for (const edge of edges) {
@@ -2526,8 +2561,7 @@ function createGraph(container, spec, options) {
2526
2561
  }));
2527
2562
  }
2528
2563
  function nodeDataById(nodeId) {
2529
- const node = compilation.nodes.find((n) => n.id === nodeId);
2530
- return node?.data ?? {};
2564
+ return nodeDataMap.get(nodeId) ?? {};
2531
2565
  }
2532
2566
  function pointToSegmentDist(px, py, ax, ay, bx, by) {
2533
2567
  const dx = bx - ax;
@@ -2557,9 +2591,7 @@ function createGraph(container, spec, options) {
2557
2591
  return bestEdgeId;
2558
2592
  }
2559
2593
  function edgeDataById(edgeId) {
2560
- const [source, target] = edgeId.split("->");
2561
- const edge = compilation.edges.find((e) => e.source === source && e.target === target);
2562
- return edge?.data ?? null;
2594
+ return edgeDataMap.get(edgeId) ?? null;
2563
2595
  }
2564
2596
  function createDOM() {
2565
2597
  const { width, height } = getContainerDimensions();
@@ -2677,9 +2709,9 @@ function createGraph(container, spec, options) {
2677
2709
  scheduleRender();
2678
2710
  });
2679
2711
  simulation.onSettled(() => {
2680
- if (canvas && positionedNodes.length > 0 && interactionManager) {
2712
+ if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
2681
2713
  const { width: cw, height: ch } = getCanvasDimensions();
2682
- const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
2714
+ const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
2683
2715
  interactionManager.setTransform(fitTransform);
2684
2716
  needsRender = true;
2685
2717
  scheduleRender();
@@ -2757,6 +2789,18 @@ function createGraph(container, spec, options) {
2757
2789
  }
2758
2790
  },
2759
2791
  onBackgroundHover(graphX, graphY, screenX, screenY) {
2792
+ const now2 = performance.now();
2793
+ if (now2 - lastEdgeHitTime < 32) {
2794
+ if (hoveredEdgeId) {
2795
+ hoveredEdgeId = null;
2796
+ needsRender = true;
2797
+ scheduleRender();
2798
+ options?.onEdgeHover?.(null);
2799
+ tooltipManager?.hide();
2800
+ }
2801
+ return;
2802
+ }
2803
+ lastEdgeHitTime = now2;
2760
2804
  const transform = interactionManager?.getTransform();
2761
2805
  const threshold = 5 / (transform?.k ?? 1);
2762
2806
  const edgeId = hitTestEdge(graphX, graphY, threshold);
@@ -2858,7 +2902,7 @@ function createGraph(container, spec, options) {
2858
2902
  function zoomToFit() {
2859
2903
  if (destroyed || !interactionManager || positionedNodes.length === 0) return;
2860
2904
  const { width: cw, height: ch } = getCanvasDimensions();
2861
- const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
2905
+ const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
2862
2906
  interactionManager.setTransform(fitTransform);
2863
2907
  needsRender = true;
2864
2908
  scheduleRender();
@@ -2901,6 +2945,7 @@ function createGraph(container, spec, options) {
2901
2945
  teardownSubsystems();
2902
2946
  compilation = compile();
2903
2947
  adjacencyMap = buildAdjacencyMap(compilation.edges);
2948
+ buildDataMaps();
2904
2949
  renderChrome2();
2905
2950
  renderLegend2();
2906
2951
  initSimulation();
@@ -2919,6 +2964,7 @@ function createGraph(container, spec, options) {
2919
2964
  }
2920
2965
  compilation = compile();
2921
2966
  adjacencyMap = buildAdjacencyMap(compilation.edges);
2967
+ buildDataMaps();
2922
2968
  positionedNodes = compilation.nodes.map((node) => {
2923
2969
  const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
2924
2970
  return { ...node, x: pos.x, y: pos.y };
@@ -2981,6 +3027,7 @@ function createGraph(container, spec, options) {
2981
3027
  try {
2982
3028
  compilation = compile();
2983
3029
  adjacencyMap = buildAdjacencyMap(compilation.edges);
3030
+ buildDataMaps();
2984
3031
  createDOM();
2985
3032
  initSimulation();
2986
3033
  initInteraction();
@@ -3675,18 +3722,36 @@ function renderLegend(parent, legend) {
3675
3722
  let offsetY = legend.bounds.y;
3676
3723
  for (let i = 0; i < legend.entries.length; i++) {
3677
3724
  const entry = legend.entries[i];
3725
+ if (isHorizontal && i > 0) {
3726
+ const labelWidth = estimateTextWidth(
3727
+ entry.label,
3728
+ legend.labelStyle.fontSize,
3729
+ legend.labelStyle.fontWeight
3730
+ );
3731
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
3732
+ if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
3733
+ offsetX = legend.bounds.x;
3734
+ offsetY += legend.swatchSize + 6;
3735
+ }
3736
+ }
3678
3737
  const entryG = createSVGElement("g");
3679
3738
  entryG.setAttribute("class", "viz-legend-entry");
3680
3739
  entryG.setAttribute("role", "listitem");
3681
3740
  entryG.setAttribute("data-legend-index", String(i));
3682
3741
  entryG.setAttribute("data-legend-label", entry.label);
3683
- entryG.setAttribute(
3684
- "aria-label",
3685
- `${entry.label}: ${entry.active !== false ? "visible" : "hidden"}`
3686
- );
3687
- entryG.setAttribute("style", "cursor: pointer");
3688
- if (entry.active === false) {
3689
- entryG.setAttribute("opacity", "0.3");
3742
+ if (entry.overflow) {
3743
+ entryG.setAttribute("data-legend-overflow", "true");
3744
+ entryG.setAttribute("aria-label", entry.label);
3745
+ entryG.setAttribute("opacity", "0.5");
3746
+ } else {
3747
+ entryG.setAttribute(
3748
+ "aria-label",
3749
+ `${entry.label}: ${entry.active !== false ? "visible" : "hidden"}`
3750
+ );
3751
+ entryG.setAttribute("style", "cursor: pointer");
3752
+ if (entry.active === false) {
3753
+ entryG.setAttribute("opacity", "0.3");
3754
+ }
3690
3755
  }
3691
3756
  if (entry.shape === "circle") {
3692
3757
  const circle = createSVGElement("circle");
@@ -3746,10 +3811,6 @@ function renderLegend(parent, legend) {
3746
3811
  );
3747
3812
  const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
3748
3813
  offsetX += entryWidth;
3749
- if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
3750
- offsetX = legend.bounds.x;
3751
- offsetY += legend.swatchSize + 6;
3752
- }
3753
3814
  } else {
3754
3815
  offsetY += legend.swatchSize + legend.entryGap;
3755
3816
  }
@@ -4538,6 +4599,7 @@ function wireLegendInteraction(svg, _layout, onLegendToggle, onEdit) {
4538
4599
  const cleanups = [];
4539
4600
  const hiddenSeries = /* @__PURE__ */ new Set();
4540
4601
  for (const entry of legendEntries) {
4602
+ if (entry.getAttribute("data-legend-overflow") === "true") continue;
4541
4603
  const handleClick = () => {
4542
4604
  const label = entry.getAttribute("data-legend-label");
4543
4605
  if (!label) return;
@@ -4711,6 +4773,7 @@ function createChart(container, spec, options) {
4711
4773
  let destroyed = false;
4712
4774
  let isDragging = false;
4713
4775
  let pendingRender = false;
4776
+ let resizeTimer = null;
4714
4777
  const measureText = createMeasureText();
4715
4778
  function compile() {
4716
4779
  const { width, height } = getContainerDimensions();
@@ -4869,6 +4932,10 @@ function createChart(container, spec, options) {
4869
4932
  function destroy() {
4870
4933
  if (destroyed) return;
4871
4934
  destroyed = true;
4935
+ if (resizeTimer !== null) {
4936
+ clearTimeout(resizeTimer);
4937
+ resizeTimer = null;
4938
+ }
4872
4939
  if (cleanupTooltipEvents) {
4873
4940
  cleanupTooltipEvents();
4874
4941
  cleanupTooltipEvents = null;
@@ -4915,7 +4982,11 @@ function createChart(container, spec, options) {
4915
4982
  render();
4916
4983
  if (options?.responsive !== false) {
4917
4984
  disconnectResize = observeResize(container, () => {
4918
- resize();
4985
+ if (resizeTimer !== null) clearTimeout(resizeTimer);
4986
+ resizeTimer = setTimeout(() => {
4987
+ resizeTimer = null;
4988
+ resize();
4989
+ }, 100);
4919
4990
  });
4920
4991
  }
4921
4992
  return {