@behindthemusictree/genre-tree-view 6.2.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -181,6 +181,9 @@ var MIN_NODE_WIDTH = 180;
181
181
  var MAX_NODE_WIDTH = 700;
182
182
  var MIN_NODE_HEIGHT = 35;
183
183
  var MAX_NODE_HEIGHT = 120;
184
+ var NODE_SIZE_REFERENCE_COUNT = 100;
185
+ var HIGH_DENSITY_NODE_MAX_FLOOR_SCALE = 1.5;
186
+ var LOW_DENSITY_DOT_MAX_RADIUS = 70;
184
187
  var TOOLBAR_MENU_X_GAP = 4;
185
188
  var ACTIONS_OVERLAY_WIDTH = MENU_WIDTH + TOOLBAR_MENU_X_GAP;
186
189
  var ACTIONS_OVERLAY_HEIGHT = RECT_BASE_DIMENSIONS.HEIGHT * 7;
@@ -195,23 +198,47 @@ var HORIZONTAL_SEPARATION_BETWEEN_NODES = (NODE_DIMENSIONS.WIDTH + HORIZONTAL_SE
195
198
  var VERTICAL_SEPARATION_BETWEEN_NODES = NODE_DIMENSIONS.HEIGHT + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
196
199
  var VERTICAL_ORIENTATION_DEPTH_SEPARATION = VERTICAL_SEPARATION_BETWEEN_NODES * 2 * DEPTH_AXIS_SPACING_FACTOR;
197
200
  var SIBLING_SEPARATION_BETWEEN_NODES = MAX_NODE_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES;
198
- function getItemCountRange(nodes) {
199
- if (nodes.length === 0) return { min: 0, max: 0 };
201
+ function getDensityAdjustedMax(baseMax, min, nodeCount) {
202
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
203
+ const availableRangeScale = Math.min(1, NODE_SIZE_REFERENCE_COUNT / safeNodeCount);
204
+ const denseTreeFloor = min * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
205
+ return denseTreeFloor + (baseMax - denseTreeFloor) * availableRangeScale;
206
+ }
207
+ function getDensityAdjustedDotMax(nodeCount) {
208
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
209
+ const inverseDensityRadius = NODE_DOT_MAX_RADIUS * NODE_SIZE_REFERENCE_COUNT / safeNodeCount;
210
+ const denseTreeFloor = NODE_DOT_MIN_RADIUS * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
211
+ return Math.max(denseTreeFloor, Math.min(LOW_DENSITY_DOT_MAX_RADIUS, inverseDensityRadius));
212
+ }
213
+ function getItemCountRange(nodes, options = {}) {
200
214
  const counts = nodes.map((node) => node.itemCount);
201
- return { min: Math.min(...counts), max: Math.max(...counts) };
215
+ const validScaleMax = options.itemCountScaleMax !== void 0 && Number.isFinite(options.itemCountScaleMax) && options.itemCountScaleMax >= 0;
216
+ const nodeCount = options.nodeCountForSizing ?? nodes.length;
217
+ const observedMin = counts.length === 0 ? 0 : Math.min(...counts);
218
+ const observedMax = counts.length === 0 ? 0 : Math.max(...counts);
219
+ const includeDensityCaps = options.nodeCountForSizing !== void 0 || validScaleMax;
220
+ return {
221
+ min: validScaleMax ? 0 : observedMin,
222
+ max: validScaleMax ? options.itemCountScaleMax : observedMax,
223
+ ...includeDensityCaps && {
224
+ maxNodeWidth: getDensityAdjustedMax(MAX_NODE_WIDTH, MIN_NODE_WIDTH, nodeCount),
225
+ maxNodeHeight: getDensityAdjustedMax(MAX_NODE_HEIGHT, MIN_NODE_HEIGHT, nodeCount),
226
+ maxDotRadius: getDensityAdjustedDotMax(nodeCount)
227
+ }
228
+ };
202
229
  }
203
230
  function logarithmicPosition(itemCount, range) {
204
231
  const { min, max } = range;
205
232
  const logMin = Math.log1p(Math.max(min, 0));
206
233
  const logMax = Math.log1p(Math.max(max, 0));
207
234
  const logValue = Math.log1p(Math.max(itemCount, 0));
208
- return logMax > logMin ? (logValue - logMin) / (logMax - logMin) : 0;
235
+ return logMax > logMin ? Math.max(0, Math.min(1, (logValue - logMin) / (logMax - logMin))) : 0;
209
236
  }
210
237
  function calculateNodeDimensions(itemCount, range) {
211
238
  const t = logarithmicPosition(itemCount, range);
212
239
  return {
213
- WIDTH: Math.round(MIN_NODE_WIDTH + t * (MAX_NODE_WIDTH - MIN_NODE_WIDTH)),
214
- HEIGHT: Math.round(MIN_NODE_HEIGHT + t * (MAX_NODE_HEIGHT - MIN_NODE_HEIGHT))
240
+ WIDTH: Math.round(MIN_NODE_WIDTH + t * ((range.maxNodeWidth ?? MAX_NODE_WIDTH) - MIN_NODE_WIDTH)),
241
+ HEIGHT: Math.round(MIN_NODE_HEIGHT + t * ((range.maxNodeHeight ?? MAX_NODE_HEIGHT) - MIN_NODE_HEIGHT))
215
242
  };
216
243
  }
217
244
  var NODE_MIN_BORDER_WIDTH = 1;
@@ -256,7 +283,7 @@ var NODE_DOT_LABEL_GAP = 6;
256
283
  var NODE_LAYOUT_SLOT_RADIUS = 8;
257
284
  function calculateNodeDotRadius(itemCount, range) {
258
285
  const minArea = NODE_DOT_MIN_RADIUS ** 2;
259
- const maxArea = NODE_DOT_MAX_RADIUS ** 2;
286
+ const maxArea = (range.maxDotRadius ?? NODE_DOT_MAX_RADIUS) ** 2;
260
287
  return Math.sqrt(minArea + logarithmicPosition(itemCount, range) * (maxArea - minArea));
261
288
  }
262
289
  var WHEEL_RADIUS = 260;
@@ -291,19 +318,22 @@ var PAN_MIN_VISIBLE_PX = 80;
291
318
  var PAN_ZOOM_SETTLE_MS = 100;
292
319
  var DEFAULT_VIEWPORT_CENTER_GUARD_MS = 1e3;
293
320
  var INFO_PANEL_WIDTH = 280;
321
+ var INFO_PANEL_SHEET_MAX_WIDTH = 767;
322
+ var INFO_PANEL_SHEET_PEEK_FRACTION = 0.4;
323
+ var INFO_PANEL_SHEET_DRAG_THRESHOLD_PX = 32;
294
324
 
295
325
  // src/NodeHelper.tsx
296
326
  var import_jsx_runtime = require("react/jsx-runtime");
297
327
  function buildTreeHierarchyStructure(d3Lib, nodes) {
298
328
  return d3Lib.stratify().id((d) => d.id).parentId((d) => d.parentId)(nodes);
299
329
  }
300
- function calculateLocalRootDimensions(d3Lib, nodes) {
330
+ function calculateLocalRootDimensions(d3Lib, nodes, options = {}) {
301
331
  const root = buildTreeHierarchyStructure(d3Lib, nodes);
302
- const range = getItemCountRange(root.descendants().map((d) => d.data));
332
+ const range = getItemCountRange(root.descendants().map((d) => d.data), options);
303
333
  return calculateNodeDimensions(root.data.itemCount, range);
304
334
  }
305
- function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis) {
306
- const localExtent = calculateLocalRootDimensions(d3Lib, nodes)[axis];
335
+ function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis, options = {}) {
336
+ const localExtent = calculateLocalRootDimensions(d3Lib, nodes, options)[axis];
307
337
  const displayExtent = calculateNodeDimensions(rootItemCount, rootItemCountRange)[axis];
308
338
  return (localExtent + displayExtent) / 2;
309
339
  }
@@ -387,13 +417,31 @@ function appendPaths(d3Lib, svg, treeData, itemCountRange, orientation = "horizo
387
417
  }
388
418
 
389
419
  // src/tree-renderer.ts
390
- function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1) {
391
- const nodes = treeData.descendants();
392
- const itemCountRange = getItemCountRange(nodes.map((d) => d.data));
393
- const maxNodeDimensions = { WIDTH: MAX_NODE_WIDTH, HEIGHT: MAX_NODE_HEIGHT };
420
+ function getLayoutSpacings(itemCountRange, depthSpacingScale = 1) {
421
+ const maxWidth = itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH;
422
+ const maxHeight = itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT;
423
+ const verticalSeparation = maxHeight + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
424
+ return {
425
+ horizontalDepth: (maxWidth + ACTIONS_OVERLAY_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES) * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
426
+ verticalBreadth: verticalSeparation,
427
+ verticalDepth: verticalSeparation * 2 * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
428
+ verticalSibling: maxWidth + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES
429
+ };
430
+ }
431
+ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1, suppliedItemCountRange) {
432
+ const treeNodes = treeData.descendants();
433
+ const nodes = treeNodes;
434
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
435
+ treeNodes.map((d) => d.data)
436
+ );
437
+ const maxNodeDimensions = {
438
+ WIDTH: itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
439
+ HEIGHT: itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT
440
+ };
394
441
  const maximumLevel = d3Lib.max(nodes, (d) => d.depth);
395
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
396
- const depthSeparationHorizontal = HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale;
442
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
443
+ const depthSeparation = layoutSpacings.verticalDepth;
444
+ const depthSeparationHorizontal = layoutSpacings.horizontalDepth;
397
445
  if (isVerticalOrientation(orientation)) {
398
446
  const svgHeight2 = hideRoot ? maximumLevel * depthSeparation : maximumLevel * depthSeparation + maxNodeDimensions.HEIGHT + ACTIONS_OVERLAY_HEIGHT / 2;
399
447
  const rootWidth = calculateNodeDimensions(
@@ -455,10 +503,15 @@ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hid
455
503
  const svgWidth = maximumLevel * depthSeparationHorizontal + maxNodeDimensions.WIDTH + ACTIONS_OVERLAY_WIDTH;
456
504
  return { svgWidth, svgHeight, highestVerticalCoordinate, rootDepthOffset: 0 };
457
505
  }
458
- function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1) {
506
+ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1, suppliedItemCountRange) {
507
+ const treeNodes = treeData.descendants();
508
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
509
+ treeNodes.map((d) => d.data)
510
+ );
511
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
459
512
  if (isVerticalOrientation(orientation)) {
460
513
  const maximumLevel = d3Lib.max(treeData.descendants(), (d) => d.depth);
461
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
514
+ const depthSeparation = layoutSpacings.verticalDepth;
462
515
  treeData.each(function(d) {
463
516
  d.x = d.x + highestVerticalCoordinate;
464
517
  d.y = depthAxisSign(orientation) === -1 ? (maximumLevel - d.depth) * depthSeparation : d.depth * depthSeparation + rootDepthOffset;
@@ -480,24 +533,30 @@ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation
480
533
  });
481
534
  return treeData;
482
535
  }
483
- function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1) {
536
+ function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1, suppliedItemCountRange) {
537
+ const nodes = root.descendants();
538
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
539
+ nodes.map((d) => d.data)
540
+ );
541
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
484
542
  const nodeSize = isVerticalOrientation(orientation) ? [
485
- SIBLING_SEPARATION_BETWEEN_NODES,
486
- VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale
543
+ layoutSpacings.verticalSibling,
544
+ layoutSpacings.verticalDepth
487
545
  ] : [
488
- VERTICAL_SEPARATION_BETWEEN_NODES,
489
- HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale
546
+ layoutSpacings.verticalBreadth,
547
+ layoutSpacings.horizontalDepth
490
548
  ];
491
549
  const treeLayout = d3Lib.tree().nodeSize(nodeSize).separation(() => 1);
492
550
  return treeLayout(root);
493
551
  }
494
- function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null) {
552
+ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null, suppliedItemCountRange) {
495
553
  const { onReparentTargetSelect, onNodeClick } = callbacks;
496
554
  if (!svgRef.current) {
497
555
  throw new Error("SVG reference is null");
498
556
  }
499
- const itemCountRange = getItemCountRange(
500
- treeData.descendants().map((d) => d.data)
557
+ const treeNodes = treeData.descendants();
558
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
559
+ treeNodes.map((d) => d.data)
501
560
  );
502
561
  const svg = d3Lib.select(svgRef.current).append("svg").attr("width", svgWidth).attr("height", svgHeight).style("overflow", "visible").append("g");
503
562
  const safeRootId = treeData.data.id.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -645,7 +704,7 @@ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNod
645
704
  }
646
705
 
647
706
  // src/FloatingControls.tsx
648
- var import_md = require("react-icons/md");
707
+ var import_ui = require("@behindthemusictree/ui");
649
708
 
650
709
  // src/BusyIndicator.tsx
651
710
  var import_jsx_runtime2 = require("react/jsx-runtime");
@@ -658,7 +717,7 @@ function BusyIndicator() {
658
717
 
659
718
  // src/FloatingControls.tsx
660
719
  var import_jsx_runtime3 = require("react/jsx-runtime");
661
- function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
720
+ function FloatingControls({ panZoom, onFit, orientation, zoomControls, leading, busy }) {
662
721
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
663
722
  "div",
664
723
  {
@@ -669,31 +728,18 @@ function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
669
728
  children: [
670
729
  busy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BusyIndicator, {}),
671
730
  leading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "gtv-floating-controls-leading", children: leading }),
672
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "gtv-zoom-controls", children: [
673
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
674
- "button",
675
- {
676
- type: "button",
677
- className: ["gtv-zoom-btn", !panZoom.canZoomIn && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
678
- disabled: !panZoom.canZoomIn,
679
- onClick: panZoom.zoomIn,
680
- "aria-label": "Zoom in",
681
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdZoomIn, { className: "gtv-icon", size: 18 })
682
- }
683
- ),
684
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
685
- "button",
686
- {
687
- type: "button",
688
- className: ["gtv-zoom-btn", !panZoom.canZoomOut && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
689
- disabled: !panZoom.canZoomOut,
690
- onClick: panZoom.zoomOut,
691
- "aria-label": "Zoom out",
692
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdZoomOut, { className: "gtv-icon", size: 18 })
693
- }
694
- ),
695
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: "gtv-zoom-btn", onClick: onFit, "aria-label": "Fit to frame", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdFitScreen, { className: "gtv-icon", size: 18 }) })
696
- ] })
731
+ zoomControls !== "never" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
732
+ import_ui.ZoomControls,
733
+ {
734
+ onZoomIn: panZoom.zoomIn,
735
+ onZoomOut: panZoom.zoomOut,
736
+ onFit,
737
+ canZoomIn: panZoom.canZoomIn,
738
+ canZoomOut: panZoom.canZoomOut,
739
+ orientation,
740
+ className: zoomControls === "auto" ? "gtv-zoom-controls gtv-zoom-controls--auto" : "gtv-zoom-controls"
741
+ }
742
+ )
697
743
  ]
698
744
  }
699
745
  );
@@ -757,6 +803,18 @@ function computeFitScale(contentWidth, contentHeight, viewportWidth, viewportHei
757
803
  );
758
804
  }
759
805
 
806
+ // src/info-panel-geometry.ts
807
+ function resolveInfoPanelSide() {
808
+ return "left";
809
+ }
810
+ function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
811
+ if (!element || !viewport) return null;
812
+ if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
813
+ return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
814
+ }
815
+ return { width: panelWidth, side: resolveInfoPanelSide() };
816
+ }
817
+
760
818
  // src/use-pan-zoom.ts
761
819
  function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
762
820
  const [panX, setPanX] = (0, import_react.useState)(0);
@@ -767,6 +825,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
767
825
  const settleTimerRef = (0, import_react.useRef)(null);
768
826
  const stageAnimationRef = (0, import_react.useRef)(null);
769
827
  const defaultViewportRef = (0, import_react.useRef)(options.defaultViewport);
828
+ const operationRef = (0, import_react.useRef)(0);
829
+ const restoredSelectionRef = (0, import_react.useRef)(void 0);
830
+ const restoredRevisionRef = (0, import_react.useRef)(void 0);
831
+ const restoredFrameRef = (0, import_react.useRef)(void 0);
832
+ const pendingRestoreRef = (0, import_react.useRef)(null);
833
+ const measuredFrameRef = (0, import_react.useRef)(void 0);
834
+ const fittedFrameRef = (0, import_react.useRef)(null);
835
+ const frameElementsRef = (0, import_react.useRef)([]);
836
+ const retryFrameRef = (0, import_react.useRef)(() => {
837
+ });
838
+ const pendingCenterRef = (0, import_react.useRef)(null);
770
839
  const centerGuardUntilRef = (0, import_react.useRef)(0);
771
840
  const onViewportChangeRef = (0, import_react.useRef)(options.onViewportChange);
772
841
  (0, import_react.useEffect)(() => {
@@ -840,6 +909,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
840
909
  }, [viewportRef]);
841
910
  const zoomAtPoint = (0, import_react.useCallback)(
842
911
  (computeNewScale, clientX, clientY) => {
912
+ operationRef.current += 1;
843
913
  const viewport = viewportRef.current;
844
914
  if (!viewport) {
845
915
  return;
@@ -923,19 +993,26 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
923
993
  const viewport = viewportRef.current;
924
994
  if (!viewport) return;
925
995
  const observer = new ResizeObserver((entries) => {
926
- const entry = entries[0];
927
- if (!entry) return;
996
+ const entry = entries?.[0];
997
+ if (!entry) {
998
+ retryFrameRef.current();
999
+ return;
1000
+ }
928
1001
  const { width, height } = entry.contentRect;
929
1002
  const previous = viewportSizeRef.current;
930
1003
  viewportSizeRef.current = { width, height };
931
- if (!previous) return;
1004
+ if (!previous || previous.width <= 0 || previous.height <= 0) {
1005
+ retryFrameRef.current();
1006
+ return;
1007
+ }
932
1008
  const dx = (width - previous.width) / 2;
933
1009
  const dy = (height - previous.height) / 2;
934
1010
  if (dx === 0 && dy === 0) return;
935
1011
  const view = viewRef.current;
936
1012
  view.x = clampPanAxis(view.x + dx, view.k, "x");
937
1013
  view.y = clampPanAxis(view.y + dy, view.k, "y");
938
- commitView();
1014
+ commitView(false);
1015
+ retryFrameRef.current();
939
1016
  });
940
1017
  observer.observe(viewport);
941
1018
  return () => observer.disconnect();
@@ -946,6 +1023,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
946
1023
  return;
947
1024
  }
948
1025
  const handleWheel = (event) => {
1026
+ operationRef.current += 1;
949
1027
  if (event.target.closest(".gtv-info-panel") !== null) {
950
1028
  return;
951
1029
  }
@@ -984,6 +1062,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
984
1062
  }, [viewportRef, wheelZoom]);
985
1063
  const zoomByButton = (0, import_react.useCallback)(
986
1064
  (direction) => {
1065
+ operationRef.current += 1;
987
1066
  const viewport = viewportRef.current;
988
1067
  if (!viewport) return;
989
1068
  const rect = viewport.getBoundingClientRect();
@@ -998,13 +1077,14 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
998
1077
  );
999
1078
  const fitToFrame = (0, import_react.useCallback)(
1000
1079
  (elements, { silent = false } = {}) => {
1080
+ if (!silent) operationRef.current += 1;
1081
+ frameElementsRef.current = elements;
1001
1082
  const viewport = viewportRef.current;
1002
1083
  const present = elements.filter((el) => Boolean(el));
1003
- if (!viewport || present.length === 0) return;
1084
+ if (!viewport || present.length === 0) return false;
1004
1085
  const viewportRect = viewport.getBoundingClientRect();
1005
- if (viewportRect.width <= 0 || viewportRect.height <= 0) return;
1006
- centerAnimationRef.current = null;
1007
- if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return;
1086
+ if (viewportRect.width <= 0 || viewportRect.height <= 0) return false;
1087
+ if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return false;
1008
1088
  const rects = present.map((el) => el.getBoundingClientRect());
1009
1089
  const contentLeft = Math.min(...rects.map((r) => r.left));
1010
1090
  const contentTop = Math.min(...rects.map((r) => r.top));
@@ -1013,7 +1093,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1013
1093
  const view = viewRef.current;
1014
1094
  const contentWidth = (contentRight - contentLeft) / view.k;
1015
1095
  const contentHeight = (contentBottom - contentTop) / view.k;
1016
- if (contentWidth <= 0 || contentHeight <= 0) return;
1096
+ if (contentWidth <= 0 || contentHeight <= 0) return false;
1017
1097
  const contentOriginX = (contentLeft - viewportRect.left - view.x) / view.k;
1018
1098
  const contentOriginY = (contentTop - viewportRect.top - view.y) / view.k;
1019
1099
  contentBoundsRef.current = {
@@ -1032,6 +1112,41 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1032
1112
  const newMinScale = Math.min(ZOOM_MIN_SCALE, fitScale);
1033
1113
  minScaleRef.current = newMinScale;
1034
1114
  setMinScale(newMinScale);
1115
+ measuredFrameRef.current = options.frameKey;
1116
+ const pending = pendingRestoreRef.current;
1117
+ if (pending && pending.operation !== operationRef.current) {
1118
+ pendingRestoreRef.current = null;
1119
+ pendingCenterRef.current = null;
1120
+ fittedFrameRef.current = { frame: options.frameKey };
1121
+ return true;
1122
+ }
1123
+ if (pending && pending.frame === options.frameKey && pending.operation === operationRef.current) {
1124
+ pendingRestoreRef.current = null;
1125
+ restoredRevisionRef.current = pending.revision;
1126
+ restoredFrameRef.current = pending.frame;
1127
+ restoredSelectionRef.current = options.selectedNodeId;
1128
+ defaultViewportRef.current = void 0;
1129
+ viewRef.current = viewportToView({ ...pending.viewport, zoom: clampZoomScale(pending.viewport.zoom, newMinScale) }, viewportRect.width, viewportRect.height);
1130
+ fittedFrameRef.current = { frame: options.frameKey };
1131
+ pendingCenterRef.current = null;
1132
+ commitView(false);
1133
+ return true;
1134
+ }
1135
+ if (silent && fittedFrameRef.current && fittedFrameRef.current.frame === options.frameKey) {
1136
+ const center2 = pendingCenterRef.current;
1137
+ pendingCenterRef.current = null;
1138
+ center2?.();
1139
+ return true;
1140
+ }
1141
+ fittedFrameRef.current = { frame: options.frameKey };
1142
+ const center = pendingCenterRef.current;
1143
+ pendingCenterRef.current = null;
1144
+ if (silent && center && !defaultViewportRef.current) {
1145
+ center();
1146
+ return true;
1147
+ }
1148
+ zoomAnimationRef.current = null;
1149
+ centerAnimationRef.current = null;
1035
1150
  const seed = defaultViewportRef.current;
1036
1151
  if (seed) {
1037
1152
  defaultViewportRef.current = void 0;
@@ -1045,14 +1160,15 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1045
1160
  view.y = seeded.y;
1046
1161
  centerGuardUntilRef.current = performance.now() + DEFAULT_VIEWPORT_CENTER_GUARD_MS;
1047
1162
  commitView(false);
1048
- return;
1163
+ return true;
1049
1164
  }
1050
1165
  view.k = fitScale;
1051
1166
  view.x = viewportRect.width / 2 - (contentOriginX + contentWidth / 2) * fitScale;
1052
1167
  view.y = viewportRect.height / 2 - (contentOriginY + contentHeight / 2) * fitScale;
1053
1168
  commitView(!silent);
1169
+ return true;
1054
1170
  },
1055
- [viewportRef, commitView]
1171
+ [viewportRef, commitView, options.frameKey, options.selectedNodeId]
1056
1172
  );
1057
1173
  const centerAnimationRef = (0, import_react.useRef)(null);
1058
1174
  const centerAnimationFrameIdRef = (0, import_react.useRef)(null);
@@ -1086,7 +1202,19 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1086
1202
  stepCenterAnimationRef.current = stepCenterAnimation;
1087
1203
  }, [stepCenterAnimation]);
1088
1204
  const centerOnElement = (0, import_react.useCallback)(
1089
- (element, targetScale, obscured) => {
1205
+ (element, targetScale, obscured, automatic = false) => {
1206
+ if (automatic && restoredRevisionRef.current !== void 0 && restoredFrameRef.current === options.frameKey && restoredSelectionRef.current === options.selectedNodeId) return;
1207
+ if (!automatic) operationRef.current += 1;
1208
+ const viewportSize = viewportRef.current?.getBoundingClientRect();
1209
+ if (automatic && (measuredFrameRef.current !== options.frameKey || fittedFrameRef.current === null || !viewportSize?.width || !viewportSize.height)) {
1210
+ const operation = operationRef.current;
1211
+ pendingCenterRef.current = () => {
1212
+ if (operation !== operationRef.current) return;
1213
+ const currentElement = element?.id ? viewportRef.current?.querySelector(`#${CSS.escape(element.id)}`) : element;
1214
+ centerLatestRef.current(currentElement, targetScale, obscured ? resolveInfoPanelObscuredArea(currentElement, viewportRef.current, INFO_PANEL_WIDTH) : obscured, true);
1215
+ };
1216
+ return;
1217
+ }
1090
1218
  const viewport = viewportRef.current;
1091
1219
  if (!viewport || !element || performance.now() < centerGuardUntilRef.current) return;
1092
1220
  const viewportRect = viewport.getBoundingClientRect();
@@ -1097,9 +1225,10 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1097
1225
  const newScale = clampZoomScale(Math.max(targetScale, currentScale), minScaleRef.current);
1098
1226
  const centerX = (rect.left + rect.width / 2 - viewportRect.left - view.x) / currentScale;
1099
1227
  const centerY = (rect.top + rect.height / 2 - viewportRect.top - view.y) / currentScale;
1100
- const availableCenterX = !obscured ? viewportRect.width / 2 : obscured.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : (viewportRect.width - obscured.width) / 2;
1228
+ const availableCenterX = obscured?.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : obscured?.side === "right" ? (viewportRect.width - obscured.width) / 2 : viewportRect.width / 2;
1229
+ const availableCenterY = obscured?.side === "bottom" ? (viewportRect.height - obscured.height) / 2 : viewportRect.height / 2;
1101
1230
  const targetPanX = availableCenterX - centerX * newScale;
1102
- const targetPanY = viewportRect.height / 2 - centerY * newScale;
1231
+ const targetPanY = availableCenterY - centerY * newScale;
1103
1232
  zoomAnimationRef.current = null;
1104
1233
  if (animationFrameIdRef.current !== null) {
1105
1234
  cancelAnimationFrame(animationFrameIdRef.current);
@@ -1118,8 +1247,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1118
1247
  centerAnimationFrameIdRef.current = requestAnimationFrame(() => stepCenterAnimationRef.current());
1119
1248
  }
1120
1249
  },
1121
- [viewportRef]
1250
+ [viewportRef, options.frameKey, options.selectedNodeId]
1122
1251
  );
1252
+ const centerLatestRef = (0, import_react.useRef)(centerOnElement);
1253
+ (0, import_react.useEffect)(() => {
1254
+ centerLatestRef.current = centerOnElement;
1255
+ }, [centerOnElement]);
1256
+ (0, import_react.useEffect)(() => {
1257
+ retryFrameRef.current = () => {
1258
+ fitToFrame(frameElementsRef.current, { silent: true });
1259
+ };
1260
+ }, [fitToFrame]);
1123
1261
  const lastPointRef = (0, import_react.useRef)({ x: 0, y: 0 });
1124
1262
  const minScaleRef = (0, import_react.useRef)(minScale);
1125
1263
  const suppressedPointersRef = (0, import_react.useRef)(/* @__PURE__ */ new Set());
@@ -1194,6 +1332,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1194
1332
  }, [handlePointerUp]);
1195
1333
  const handlePointerDown = (0, import_react.useCallback)((event) => {
1196
1334
  if (event.button !== 0) return;
1335
+ operationRef.current += 1;
1197
1336
  const isInteractive = event.target.closest("g.node, foreignObject, .gtv-zoom-controls, .gtv-wheel-chip, .gtv-info-panel") !== null;
1198
1337
  const isPageScrollTouch = wheelZoom === "modifier" && event.pointerType === "touch";
1199
1338
  const pointers = activePointersRef.current;
@@ -1212,6 +1351,42 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1212
1351
  window.addEventListener("pointercancel", stablePointerUp);
1213
1352
  }
1214
1353
  }, [stablePointerMove, stablePointerUp, wheelZoom]);
1354
+ const restore = options.viewportRestore;
1355
+ const restoreRevision = restore?.revision;
1356
+ const restoreX = restore?.viewport.x;
1357
+ const restoreY = restore?.viewport.y;
1358
+ const restoreZoom = restore?.viewport.zoom;
1359
+ (0, import_react.useEffect)(() => {
1360
+ if (restoreRevision === restoredRevisionRef.current || restoreRevision === void 0 || restoreX === void 0 || restoreY === void 0 || restoreZoom === void 0) return;
1361
+ if (!Number.isFinite(restoreX) || !Number.isFinite(restoreY) || !Number.isFinite(restoreZoom) || restoreZoom <= 0) return;
1362
+ const viewportElement = viewportRef.current;
1363
+ if (!viewportElement) return;
1364
+ restoredRevisionRef.current = restoreRevision;
1365
+ const operation = ++operationRef.current;
1366
+ zoomAnimationRef.current = null;
1367
+ centerAnimationRef.current = null;
1368
+ if (animationFrameIdRef.current !== null) cancelAnimationFrame(animationFrameIdRef.current);
1369
+ if (centerAnimationFrameIdRef.current !== null) cancelAnimationFrame(centerAnimationFrameIdRef.current);
1370
+ if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
1371
+ if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current);
1372
+ animationFrameIdRef.current = null;
1373
+ centerAnimationFrameIdRef.current = null;
1374
+ frameIdRef.current = null;
1375
+ settleTimerRef.current = null;
1376
+ activePointersRef.current.clear();
1377
+ suppressedPointersRef.current.clear();
1378
+ pinchStartRef.current = null;
1379
+ window.removeEventListener("pointermove", stablePointerMove);
1380
+ window.removeEventListener("pointerup", stablePointerUp);
1381
+ window.removeEventListener("pointercancel", stablePointerUp);
1382
+ pendingRestoreRef.current = {
1383
+ viewport: { x: restoreX, y: restoreY, zoom: restoreZoom },
1384
+ revision: restoreRevision,
1385
+ frame: options.frameKey,
1386
+ operation
1387
+ };
1388
+ if (measuredFrameRef.current === options.frameKey) retryFrameRef.current();
1389
+ }, [restoreRevision, restoreX, restoreY, restoreZoom, options.frameKey, options.selectedNodeId, viewportRef, commitView, stablePointerMove, stablePointerUp]);
1215
1390
  (0, import_react.useEffect)(() => {
1216
1391
  const pointers = activePointersRef.current;
1217
1392
  const suppressed = suppressedPointersRef.current;
@@ -1246,17 +1421,6 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1246
1421
 
1247
1422
  // src/use-node-info-panel.ts
1248
1423
  var import_react2 = require("react");
1249
-
1250
- // src/info-panel-geometry.ts
1251
- function resolveInfoPanelSide() {
1252
- return "left";
1253
- }
1254
- function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
1255
- if (!element || !viewport) return null;
1256
- return { width: panelWidth, side: resolveInfoPanelSide() };
1257
- }
1258
-
1259
- // src/use-node-info-panel.ts
1260
1424
  function useNodeInfoPanel({
1261
1425
  selectedNodeId,
1262
1426
  onSelectedNodeChange
@@ -1265,7 +1429,7 @@ function useNodeInfoPanel({
1265
1429
  const [prevSelectedNodeId, setPrevSelectedNodeId] = (0, import_react2.useState)(selectedNodeId);
1266
1430
  if (selectedNodeId !== prevSelectedNodeId) {
1267
1431
  setPrevSelectedNodeId(selectedNodeId);
1268
- if (prevSelectedNodeId != null && selectedNodeId == null) setPanel(null);
1432
+ if (prevSelectedNodeId !== void 0) setPanel(null);
1269
1433
  }
1270
1434
  const shownNodeIdRef = (0, import_react2.useRef)(null);
1271
1435
  const onSelectedNodeChangeRef = (0, import_react2.useRef)(onSelectedNodeChange);
@@ -1275,20 +1439,32 @@ function useNodeInfoPanel({
1275
1439
  });
1276
1440
  const showNodeInfo = (0, import_react2.useCallback)(
1277
1441
  (node, element, viewport, { notify = true } = {}) => {
1442
+ if (notify && selectedNodeId !== void 0) {
1443
+ if (node.id !== selectedNodeId) onSelectedNodeChangeRef.current?.(node);
1444
+ return;
1445
+ }
1278
1446
  if (!element || !viewport) return;
1279
1447
  setPanel({ node, side: resolveInfoPanelSide() });
1280
1448
  const changed = node.id !== shownNodeIdRef.current;
1281
1449
  shownNodeIdRef.current = node.id;
1282
1450
  if (changed && notify) onSelectedNodeChangeRef.current?.(node);
1283
1451
  },
1284
- []
1452
+ [selectedNodeId]
1285
1453
  );
1286
1454
  const closeNodeInfo = (0, import_react2.useCallback)(() => {
1455
+ if (selectedNodeId !== void 0) {
1456
+ if (selectedNodeId !== null) onSelectedNodeChangeRef.current?.(null);
1457
+ return;
1458
+ }
1287
1459
  setPanel(null);
1288
1460
  shownNodeIdRef.current = null;
1289
1461
  onSelectedNodeChangeRef.current?.(null);
1290
- }, []);
1291
- return { panel, showNodeInfo, closeNodeInfo };
1462
+ }, [selectedNodeId]);
1463
+ return {
1464
+ panel: selectedNodeId !== void 0 && panel?.node.id !== selectedNodeId ? null : panel,
1465
+ showNodeInfo,
1466
+ closeNodeInfo
1467
+ };
1292
1468
  }
1293
1469
 
1294
1470
  // src/use-tree-index.ts
@@ -1335,10 +1511,10 @@ function useTreeIndex(nodes) {
1335
1511
 
1336
1512
  // src/InfoPanel.tsx
1337
1513
  var import_react6 = require("react");
1338
- var import_md5 = require("react-icons/md");
1514
+ var import_md4 = require("react-icons/md");
1339
1515
 
1340
1516
  // src/InfoPanelExpandButton.tsx
1341
- var import_md2 = require("react-icons/md");
1517
+ var import_md = require("react-icons/md");
1342
1518
  var import_jsx_runtime4 = require("react/jsx-runtime");
1343
1519
  function InfoPanelExpandButton({ expanded, onExpandedChange, style }) {
1344
1520
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
@@ -1349,14 +1525,14 @@ function InfoPanelExpandButton({ expanded, onExpandedChange, style }) {
1349
1525
  style,
1350
1526
  onClick: () => onExpandedChange(!expanded),
1351
1527
  "aria-label": expanded ? "Collapse panel" : "Expand panel",
1352
- children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md2.MdCloseFullscreen, { className: "gtv-icon", size: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md2.MdOpenInFull, { className: "gtv-icon", size: 16 })
1528
+ children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md.MdCloseFullscreen, { className: "gtv-icon", size: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md.MdOpenInFull, { className: "gtv-icon", size: 16 })
1353
1529
  }
1354
1530
  );
1355
1531
  }
1356
1532
 
1357
1533
  // src/InfoPanelSection.tsx
1358
1534
  var import_react4 = require("react");
1359
- var import_md3 = require("react-icons/md");
1535
+ var import_md2 = require("react-icons/md");
1360
1536
  var import_jsx_runtime5 = require("react/jsx-runtime");
1361
1537
  function InfoPanelSection({ title, children }) {
1362
1538
  const [open, setOpen] = (0, import_react4.useState)(true);
@@ -1370,7 +1546,7 @@ function InfoPanelSection({ title, children }) {
1370
1546
  onClick: () => setOpen((isOpen) => !isOpen),
1371
1547
  children: [
1372
1548
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "gtv-info-panel-children-title", children: title }),
1373
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_md3.MdExpandMore, { className: "gtv-icon gtv-info-panel-section-chevron", size: 16 })
1549
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_md2.MdExpandMore, { className: "gtv-icon gtv-info-panel-section-chevron", size: 16 })
1374
1550
  ]
1375
1551
  }
1376
1552
  ),
@@ -1380,7 +1556,7 @@ function InfoPanelSection({ title, children }) {
1380
1556
 
1381
1557
  // src/NodeToolbar.tsx
1382
1558
  var import_react5 = require("react");
1383
- var import_md4 = require("react-icons/md");
1559
+ var import_md3 = require("react-icons/md");
1384
1560
  var import_fa = require("react-icons/fa");
1385
1561
  var import_pi2 = require("react-icons/pi");
1386
1562
  var import_jsx_runtime6 = require("react/jsx-runtime");
@@ -1458,7 +1634,7 @@ function NodeToolbar({
1458
1634
  "aria-haspopup": "menu",
1459
1635
  "aria-expanded": menuOpen,
1460
1636
  onClick: () => setMenuOpen((open) => !open),
1461
- children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md4.MdMoreVert, { size: "0.6em" })
1637
+ children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md3.MdMoreVert, { size: "0.6em" })
1462
1638
  }
1463
1639
  ),
1464
1640
  menuOpen && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "gtv-menu-card gtv-toolbar-overflow-menu", children: [
@@ -1472,7 +1648,7 @@ function NodeToolbar({
1472
1648
  onRenameRequest?.(node);
1473
1649
  },
1474
1650
  children: [
1475
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-icon", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md4.MdModeEdit, { className: "gtv-icon", size: 13 }) }),
1651
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-icon", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md3.MdModeEdit, { className: "gtv-icon", size: 13 }) }),
1476
1652
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-label", children: "Rename" })
1477
1653
  ]
1478
1654
  }
@@ -1574,6 +1750,27 @@ function InfoPanel({
1574
1750
  hideExpandButton = false
1575
1751
  }) {
1576
1752
  const panelRef = (0, import_react6.useRef)(null);
1753
+ const [sheetFull, setSheetFull] = (0, import_react6.useState)(false);
1754
+ const grabberDragRef = (0, import_react6.useRef)(null);
1755
+ const onGrabberPointerDown = (event) => {
1756
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1757
+ grabberDragRef.current = { startY: event.clientY, dragged: false };
1758
+ };
1759
+ const onGrabberPointerUp = (event) => {
1760
+ const drag = grabberDragRef.current;
1761
+ if (!drag) return;
1762
+ const dy = event.clientY - drag.startY;
1763
+ if (Math.abs(dy) < INFO_PANEL_SHEET_DRAG_THRESHOLD_PX) return;
1764
+ drag.dragged = true;
1765
+ if (dy < 0) setSheetFull(true);
1766
+ else if (sheetFull) setSheetFull(false);
1767
+ else if (!hideClose) onClose();
1768
+ };
1769
+ const onGrabberClick = () => {
1770
+ const dragged = grabberDragRef.current?.dragged ?? false;
1771
+ grabberDragRef.current = null;
1772
+ if (!dragged) setSheetFull((full) => !full);
1773
+ };
1577
1774
  (0, import_react6.useEffect)(() => {
1578
1775
  panelRef.current?.toggleAttribute("inert", collapsed);
1579
1776
  }, [collapsed]);
@@ -1676,9 +1873,21 @@ function InfoPanel({
1676
1873
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1677
1874
  "div",
1678
1875
  {
1679
- className: `gtv-info-panel gtv-info-panel--${side}${expanded ? " gtv-info-panel--expanded" : ""}${expanded && !hasAside ? " gtv-info-panel--expanded-solo" : ""}${collapsed ? " gtv-info-panel--collapsed" : ""}`,
1876
+ className: `gtv-info-panel gtv-info-panel--sheet${sheetFull ? " gtv-info-panel--sheet-full" : ""} gtv-info-panel--${side}${expanded ? " gtv-info-panel--expanded" : ""}${expanded && !hasAside ? " gtv-info-panel--expanded-solo" : ""}${collapsed ? " gtv-info-panel--collapsed" : ""}`,
1680
1877
  ref: panelRef,
1681
1878
  children: [
1879
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1880
+ "button",
1881
+ {
1882
+ type: "button",
1883
+ className: "gtv-info-panel-grabber",
1884
+ "aria-label": sheetFull ? "Lower panel" : "Raise panel",
1885
+ "aria-expanded": sheetFull,
1886
+ onPointerDown: onGrabberPointerDown,
1887
+ onPointerUp: onGrabberPointerUp,
1888
+ onClick: onGrabberClick
1889
+ }
1890
+ ),
1682
1891
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1683
1892
  "div",
1684
1893
  {
@@ -1694,7 +1903,7 @@ function InfoPanel({
1694
1903
  onClick: onPrevious,
1695
1904
  "aria-label": previousLabel ? `Previous: ${previousLabel}` : "Previous",
1696
1905
  title: previousLabel,
1697
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdArrowBack, { className: "gtv-icon", size: 16 })
1906
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdArrowBack, { className: "gtv-icon", size: 16 })
1698
1907
  }
1699
1908
  ),
1700
1909
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
@@ -1717,7 +1926,7 @@ function InfoPanel({
1717
1926
  onClick: onZoomIn,
1718
1927
  "aria-label": "Zoom in",
1719
1928
  title: "Zoom in",
1720
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdZoomIn, { className: "gtv-icon", size: 16 })
1929
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdZoomIn, { className: "gtv-icon", size: 16 })
1721
1930
  }
1722
1931
  ),
1723
1932
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(InfoPanelExpandButton, { expanded, onExpandedChange, style: { color: textColor } }),
@@ -1729,7 +1938,7 @@ function InfoPanel({
1729
1938
  style: { color: textColor },
1730
1939
  onClick: onClose,
1731
1940
  "aria-label": "Close",
1732
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdClose, { className: "gtv-icon", size: 16 })
1941
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdClose, { className: "gtv-icon", size: 16 })
1733
1942
  }
1734
1943
  )
1735
1944
  ]
@@ -1753,7 +1962,7 @@ function InfoPanel({
1753
1962
 
1754
1963
  // src/OverlayPanel.tsx
1755
1964
  var import_react7 = require("react");
1756
- var import_md6 = require("react-icons/md");
1965
+ var import_md5 = require("react-icons/md");
1757
1966
  var import_jsx_runtime8 = require("react/jsx-runtime");
1758
1967
  function OverlayPanel({
1759
1968
  title,
@@ -1793,7 +2002,7 @@ function OverlayPanel({
1793
2002
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-header", children: [
1794
2003
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: `gtv-info-panel-title${hideTitle ? " gtv-info-panel-title--hidden" : ""}`, children: title }),
1795
2004
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(InfoPanelExpandButton, { expanded, onExpandedChange }),
1796
- !hideClose && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "gtv-info-panel-close", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_md6.MdClose, { className: "gtv-icon", size: 16 }) })
2005
+ !hideClose && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "gtv-info-panel-close", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_md5.MdClose, { className: "gtv-icon", size: 16 }) })
1797
2006
  ] }),
1798
2007
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-body", children: [
1799
2008
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "gtv-info-panel-aside gtv-overlay-content", children: content }),
@@ -1830,6 +2039,8 @@ function GenreTree({
1830
2039
  hideInfoPanelClose = false,
1831
2040
  hideInfoPanelTitle = false,
1832
2041
  itemCountLabel,
2042
+ itemCountScaleMax,
2043
+ nodeCountForSizing,
1833
2044
  readOnly,
1834
2045
  renderDangerZone,
1835
2046
  expanded,
@@ -1839,8 +2050,10 @@ function GenreTree({
1839
2050
  overlayPanel,
1840
2051
  wheelZoom,
1841
2052
  defaultViewport,
2053
+ viewportRestore,
1842
2054
  onViewportChange,
1843
2055
  controlsOrientation = "vertical",
2056
+ showZoomControls = "auto",
1844
2057
  floatingControlsLeading,
1845
2058
  busy
1846
2059
  }) {
@@ -1848,8 +2061,13 @@ function GenreTree({
1848
2061
  const svgRef = (0, import_react8.useRef)(null);
1849
2062
  const viewportRef = (0, import_react8.useRef)(null);
1850
2063
  const stageRef = (0, import_react8.useRef)(null);
1851
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2064
+ const viewportFrame = (0, import_react8.useMemo)(() => JSON.stringify([orientation, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, orientation]);
2065
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId: selectedNodeIdProp, onViewportChange, frameKey: viewportFrame });
1852
2066
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId: selectedNodeIdProp, onSelectedNodeChange });
2067
+ const itemCountRange = (0, import_react8.useMemo)(
2068
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: nodeCountForSizing ?? nodes.length }),
2069
+ [nodes, itemCountScaleMax, nodeCountForSizing]
2070
+ );
1853
2071
  const showNodeInfoRef = (0, import_react8.useRef)(showNodeInfo);
1854
2072
  (0, import_react8.useEffect)(() => {
1855
2073
  showNodeInfoRef.current = showNodeInfo;
@@ -1864,7 +2082,8 @@ function GenreTree({
1864
2082
  d3,
1865
2083
  root,
1866
2084
  orientation,
1867
- depthSpacingScale
2085
+ depthSpacingScale,
2086
+ itemCountRange
1868
2087
  );
1869
2088
  const {
1870
2089
  svgWidth: width,
@@ -1876,7 +2095,8 @@ function GenreTree({
1876
2095
  originalTreeData,
1877
2096
  orientation,
1878
2097
  hideRoot,
1879
- depthSpacingScale
2098
+ depthSpacingScale,
2099
+ itemCountRange
1880
2100
  );
1881
2101
  const reshapedTreeData = setupTreeLayout(
1882
2102
  d3,
@@ -1885,7 +2105,8 @@ function GenreTree({
1885
2105
  orientation,
1886
2106
  rootDepthOffset,
1887
2107
  width,
1888
- depthSpacingScale
2108
+ depthSpacingScale,
2109
+ itemCountRange
1889
2110
  );
1890
2111
  return {
1891
2112
  treeData: reshapedTreeData,
@@ -1893,7 +2114,7 @@ function GenreTree({
1893
2114
  svgWidth: width,
1894
2115
  svgHeight: height
1895
2116
  };
1896
- }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale]);
2117
+ }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale, itemCountRange]);
1897
2118
  const reparentForbiddenIds = (0, import_react8.useMemo)(() => {
1898
2119
  if (!reparentingNodeId) return [];
1899
2120
  const reparentingHierarchyNode = treeData.descendants().find((d) => d.data.id === reparentingNodeId);
@@ -1944,7 +2165,8 @@ function GenreTree({
1944
2165
  },
1945
2166
  orientation,
1946
2167
  hideRoot,
1947
- effectiveSelectedNodeId
2168
+ effectiveSelectedNodeId,
2169
+ itemCountRange
1948
2170
  );
1949
2171
  }, [
1950
2172
  treeData,
@@ -1965,7 +2187,8 @@ function GenreTree({
1965
2187
  additionalActions,
1966
2188
  orientation,
1967
2189
  hideRoot,
1968
- effectiveSelectedNodeId
2190
+ effectiveSelectedNodeId,
2191
+ itemCountRange
1969
2192
  ]);
1970
2193
  (0, import_react8.useEffect)(() => {
1971
2194
  if (!interactive || !selectedNodeIdProp) return;
@@ -1978,7 +2201,8 @@ function GenreTree({
1978
2201
  centerOnElementRef.current(
1979
2202
  element,
1980
2203
  ZOOM_FOCUS_SCALE,
1981
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2204
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2205
+ true
1982
2206
  );
1983
2207
  showNodeInfoRef.current(targetNode, element, viewportRef.current, { notify: false });
1984
2208
  }, [interactive, selectedNodeIdProp, index, panel]);
@@ -1990,12 +2214,10 @@ function GenreTree({
1990
2214
  }
1991
2215
  };
1992
2216
  }, []);
1993
- const hasInitialFitRef = (0, import_react8.useRef)(false);
1994
2217
  (0, import_react8.useEffect)(() => {
1995
- if (hasInitialFitRef.current || !interactive) return;
2218
+ if (!interactive) return;
1996
2219
  const elements = queryTreeContentElements(svgRef.current);
1997
2220
  if (elements.length === 0) return;
1998
- hasInitialFitRef.current = true;
1999
2221
  panZoom.fitToFrame(elements, { silent: true });
2000
2222
  });
2001
2223
  const svg = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
@@ -2126,6 +2348,7 @@ function GenreTree({
2126
2348
  {
2127
2349
  panZoom,
2128
2350
  orientation: controlsOrientation,
2351
+ zoomControls: showZoomControls,
2129
2352
  leading: floatingControlsLeading,
2130
2353
  busy,
2131
2354
  onFit: () => panZoom.fitToFrame(queryTreeContentElements(svgRef.current))
@@ -2238,6 +2461,8 @@ function WheelCore({
2238
2461
  hideInfoPanelClose = false,
2239
2462
  hideInfoPanelTitle = false,
2240
2463
  itemCountLabel,
2464
+ itemCountScaleMax,
2465
+ nodeCountForSizing,
2241
2466
  readOnly,
2242
2467
  renderDangerZone,
2243
2468
  expanded,
@@ -2247,12 +2472,19 @@ function WheelCore({
2247
2472
  overlayPanel,
2248
2473
  wheelZoom,
2249
2474
  defaultViewport,
2475
+ viewportRestore,
2250
2476
  onViewportChange,
2251
2477
  controlsOrientation = "vertical",
2478
+ showZoomControls = "auto",
2252
2479
  floatingControlsLeading,
2253
2480
  busy,
2254
2481
  selectedNodeId
2255
2482
  }) {
2483
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
2484
+ const wheelSizeRange = (0, import_react9.useMemo)(
2485
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
2486
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
2487
+ );
2256
2488
  const treeOrientation = direction === "left" ? "horizontal-anchored" : "vertical";
2257
2489
  const landingAngle = direction === "left" ? 90 : 0;
2258
2490
  const chipHalfExtentVar = direction === "left" ? "--gtv-wheel-chip-half-width" : "--gtv-wheel-chip-half-height";
@@ -2262,10 +2494,10 @@ function WheelCore({
2262
2494
  return getGenreTreeColor(nodes.find((n) => n.id === rootId)?.wikidataId);
2263
2495
  };
2264
2496
  const wheelRadius = (0, import_react9.useMemo)(
2265
- () => calculateWheelRadius(groups.length, MAX_NODE_WIDTH, WHEEL_RADIUS),
2266
- [groups.length]
2497
+ () => calculateWheelRadius(groups.length, wheelSizeRange.maxNodeWidth ?? MAX_NODE_WIDTH, WHEEL_RADIUS),
2498
+ [groups.length, wheelSizeRange.maxNodeWidth]
2267
2499
  );
2268
- const wheelViewportHeight = wheelRadius * 2 + MAX_NODE_HEIGHT / 2;
2500
+ const wheelViewportHeight = wheelRadius * 2 + (wheelSizeRange.maxNodeHeight ?? MAX_NODE_HEIGHT) / 2;
2269
2501
  const rootItemCountById = (0, import_react9.useMemo)(
2270
2502
  () => new Map(groups.map((group) => [group.root.id, group.root.itemCount])),
2271
2503
  [groups]
@@ -2282,7 +2514,8 @@ function WheelCore({
2282
2514
  );
2283
2515
  const viewportRef = (0, import_react9.useRef)(null);
2284
2516
  const stageRef = (0, import_react9.useRef)(null);
2285
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2517
+ const viewportFrame = (0, import_react9.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
2518
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
2286
2519
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
2287
2520
  const wheelCircleRef = (0, import_react9.useRef)(null);
2288
2521
  const treeAnchorRef = (0, import_react9.useRef)(null);
@@ -2296,15 +2529,12 @@ function WheelCore({
2296
2529
  (0, import_react9.useEffect)(() => {
2297
2530
  if (effectiveRootId) onRootSelectRef.current?.(effectiveRootId);
2298
2531
  }, [effectiveRootId]);
2299
- const hasInitialFitRef = (0, import_react9.useRef)(false);
2300
2532
  (0, import_react9.useEffect)(() => {
2301
- if (hasInitialFitRef.current) return;
2302
2533
  const elements = [
2303
2534
  wheelCircleRef.current,
2304
2535
  ...queryTreeContentElements(treeAnchorRef.current)
2305
2536
  ];
2306
2537
  if (!elements.some(Boolean)) return;
2307
- hasInitialFitRef.current = true;
2308
2538
  panZoom.fitToFrame(elements, { silent: true });
2309
2539
  });
2310
2540
  const selectedGroup = groups.find((group) => group.root.id === effectiveRootId) ?? null;
@@ -2312,16 +2542,18 @@ function WheelCore({
2312
2542
  () => getItemCountRange(
2313
2543
  groups.map((group) => ({
2314
2544
  itemCount: rootItemCountById.get(group.root.id)
2315
- }))
2545
+ })),
2546
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2316
2547
  ),
2317
- [groups, rootItemCountById]
2548
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
2318
2549
  );
2319
2550
  const rootChipHalfExtent = selectedGroup ? calculateRootAnchorClearance(
2320
2551
  d32,
2321
2552
  selectedGroup.nodes,
2322
2553
  rootItemCountById.get(selectedGroup.root.id),
2323
2554
  rootItemCountRange,
2324
- direction === "left" ? "WIDTH" : "HEIGHT"
2555
+ direction === "left" ? "WIDTH" : "HEIGHT",
2556
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2325
2557
  ) : 0;
2326
2558
  const sectorFillGradient = (0, import_react9.useMemo)(
2327
2559
  () => buildWheelSectorGradient(
@@ -2362,7 +2594,8 @@ function WheelCore({
2362
2594
  panZoom.centerOnElement(
2363
2595
  element,
2364
2596
  ZOOM_FOCUS_SCALE,
2365
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2597
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2598
+ true
2366
2599
  );
2367
2600
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
2368
2601
  }, [selectedNodeId, effectiveRootId, nodes, panel, showNodeInfo]);
@@ -2411,6 +2644,8 @@ function WheelCore({
2411
2644
  {
2412
2645
  nodes: selectedGroup.nodes,
2413
2646
  itemCountLabel,
2647
+ itemCountScaleMax,
2648
+ nodeCountForSizing: fullNodeCountForSizing,
2414
2649
  orientation: treeOrientation,
2415
2650
  hideRoot: true,
2416
2651
  interactive: false,
@@ -2622,6 +2857,7 @@ function WheelCore({
2622
2857
  {
2623
2858
  panZoom,
2624
2859
  orientation: controlsOrientation,
2860
+ zoomControls: showZoomControls,
2625
2861
  leading: floatingControlsLeading,
2626
2862
  busy,
2627
2863
  onFit: () => panZoom.fitToFrame([
@@ -2654,18 +2890,32 @@ var d33 = __toESM(require("d3"), 1);
2654
2890
  // src/defer-click.ts
2655
2891
  function createDeferredClick() {
2656
2892
  let timeoutId = null;
2893
+ let pendingKey = void 0;
2894
+ let doubledAt = -Infinity;
2657
2895
  const cancel = () => {
2658
2896
  if (timeoutId !== null) clearTimeout(timeoutId);
2659
2897
  timeoutId = null;
2660
2898
  };
2661
- const schedule = (action) => {
2899
+ const schedule = (action, onDouble, key) => {
2900
+ if (onDouble && timeoutId !== null && key === pendingKey) {
2901
+ cancel();
2902
+ doubledAt = Date.now();
2903
+ onDouble();
2904
+ return;
2905
+ }
2662
2906
  cancel();
2907
+ pendingKey = key;
2663
2908
  timeoutId = setTimeout(() => {
2664
2909
  timeoutId = null;
2665
2910
  action();
2666
2911
  }, DOUBLE_CLICK_DELAY_MS);
2667
2912
  };
2668
- return { schedule, cancel };
2913
+ const doubleClick = (onDouble) => {
2914
+ cancel();
2915
+ if (Date.now() - doubledAt < DOUBLE_CLICK_DELAY_MS) return;
2916
+ onDouble();
2917
+ };
2918
+ return { schedule, cancel, doubleClick };
2669
2919
  }
2670
2920
 
2671
2921
  // src/radial-wheel-geometry.ts
@@ -2855,13 +3105,15 @@ function calculatePopSubtreeRadialExtent(hierarchy, coreRootCircleRadius = 0) {
2855
3105
  POP_TREE_DEPTH_RADIAL_SPACING
2856
3106
  ) + MAX_NODE_WIDTH / 2 + POP_SUBTREE_OUTER_MARGIN;
2857
3107
  }
2858
- function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING) {
3108
+ function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
2859
3109
  return layoutWedge(
2860
3110
  d3Lib,
2861
3111
  hierarchy,
2862
3112
  wedgeCenterAngleDegrees,
2863
3113
  wedgeSpanDegrees,
2864
- popRadiusOfDepth(coreRootCircleRadius, depthSpacing)
3114
+ popRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3115
+ "center",
3116
+ dotRadius
2865
3117
  );
2866
3118
  }
2867
3119
  var MIN_DOT_GAP = 5.6;
@@ -2871,15 +3123,15 @@ function popRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2871
3123
  function coreRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2872
3124
  return (depth) => getRadialDepthRadius(depth + 1, coreRootCircleRadius, depthSpacing);
2873
3125
  }
2874
- function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2875
- const dotRadius = () => NODE_LAYOUT_SLOT_RADIUS;
3126
+ function measureWedge(_d3Lib, hierarchy, radiusOfDepth, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3127
+ const slotRadius = () => dotRadius;
2876
3128
  const offsetFromParent = /* @__PURE__ */ new Map();
2877
3129
  const shiftLevel = (level, by) => ({
2878
3130
  left: { ...level.left, x: level.left.x + by },
2879
3131
  right: { ...level.right, x: level.right.x + by }
2880
3132
  });
2881
3133
  const pack = (node) => {
2882
- const own = { x: 0, dotRadius: dotRadius() };
3134
+ const own = { x: 0, dotRadius: slotRadius() };
2883
3135
  if (!node.children) return [{ left: own, right: own }];
2884
3136
  const below = [];
2885
3137
  const childOffsets = [];
@@ -2911,7 +3163,7 @@ function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2911
3163
  let min = Infinity;
2912
3164
  let max = -Infinity;
2913
3165
  hierarchy.each((d) => {
2914
- const inset = (dotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
3166
+ const inset = (slotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
2915
3167
  min = Math.min(min, d.x - inset);
2916
3168
  max = Math.max(max, d.x + inset);
2917
3169
  });
@@ -2931,8 +3183,8 @@ function splitAtBranching(hierarchy) {
2931
3183
  const children = branching.children;
2932
3184
  return children && children.length > 1 ? { chain, branching, children } : null;
2933
3185
  }
2934
- function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center") {
2935
- const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth);
3186
+ function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center", dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3187
+ const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth, dotRadius);
2936
3188
  const wedgeSpanRad = wedgeSpanDegrees * Math.PI / 180;
2937
3189
  const scale = Math.min(MAX_WEDGE_STRETCH, wedgeSpanRad / neededRad);
2938
3190
  const wedgeCenterRad = wedgeCenterAngleDegrees * Math.PI / 180;
@@ -3161,12 +3413,12 @@ function renderPopSubtree(d3Lib, svg, hierarchy, rootColor, reparentingNodeId, r
3161
3413
  group.on("click", (event) => {
3162
3414
  if (reparentingNodeId) return;
3163
3415
  const element = event.currentTarget;
3164
- deferredClick.schedule(() => onNodeClick?.(d.data, event, element));
3165
- });
3166
- group.on("dblclick", () => {
3167
- deferredClick.cancel();
3168
- onNodeDoubleClick?.(d.data);
3416
+ deferredClick.schedule(
3417
+ () => onNodeClick?.(d.data, event, element),
3418
+ () => onNodeDoubleClick?.(d.data)
3419
+ );
3169
3420
  });
3421
+ group.on("dblclick", () => deferredClick.doubleClick(() => onNodeDoubleClick?.(d.data)));
3170
3422
  });
3171
3423
  }
3172
3424
  function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
@@ -3243,14 +3495,15 @@ function buildCoreHierarchy(d3Lib, coreNodes) {
3243
3495
  });
3244
3496
  return hierarchy;
3245
3497
  }
3246
- function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing) {
3498
+ function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3247
3499
  return layoutWedge(
3248
3500
  d3Lib,
3249
3501
  hierarchy,
3250
3502
  wedgeCenterAngleDegrees,
3251
3503
  wedgeSpanDegrees,
3252
3504
  coreRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3253
- "between-children"
3505
+ "between-children",
3506
+ dotRadius
3254
3507
  );
3255
3508
  }
3256
3509
  function calculateCoreSubtreeRadialExtent(hierarchy, depthSpacing, coreRootCircleRadius = 0) {
@@ -3324,6 +3577,8 @@ function WheelRadialCore({
3324
3577
  hideInfoPanelClose = false,
3325
3578
  hideInfoPanelTitle = false,
3326
3579
  itemCountLabel,
3580
+ itemCountScaleMax,
3581
+ nodeCountForSizing,
3327
3582
  readOnly,
3328
3583
  renderDangerZone,
3329
3584
  expanded,
@@ -3333,12 +3588,15 @@ function WheelRadialCore({
3333
3588
  overlayPanel,
3334
3589
  wheelZoom,
3335
3590
  defaultViewport,
3591
+ viewportRestore,
3336
3592
  onViewportChange,
3337
3593
  controlsOrientation = "vertical",
3594
+ showZoomControls = "auto",
3338
3595
  floatingControlsLeading,
3339
3596
  busy,
3340
3597
  selectedNodeId
3341
3598
  }) {
3599
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3342
3600
  const index = useTreeIndex(nodes);
3343
3601
  const groups = (0, import_react10.useMemo)(() => groupNodesByRoot(nodes), [nodes]);
3344
3602
  const rootColorOf = (node) => getGenreTreeColor(index.byId.get(index.rootIdById.get(node.id) ?? node.id)?.wikidataId);
@@ -3347,7 +3605,8 @@ function WheelRadialCore({
3347
3605
  );
3348
3606
  const viewportRef = (0, import_react10.useRef)(null);
3349
3607
  const stageRef = (0, import_react10.useRef)(null);
3350
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
3608
+ const viewportFrame = (0, import_react10.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
3609
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
3351
3610
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
3352
3611
  const centerOnElementRef = (0, import_react10.useRef)(panZoom.centerOnElement);
3353
3612
  (0, import_react10.useEffect)(() => {
@@ -3391,11 +3650,15 @@ function WheelRadialCore({
3391
3650
  () => getItemCountRange(
3392
3651
  groups.map((group) => ({
3393
3652
  itemCount: rootItemCountById.get(group.root.id)
3394
- }))
3653
+ })),
3654
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
3395
3655
  ),
3396
- [groups, rootItemCountById]
3656
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
3657
+ );
3658
+ const wheelItemCountRange = (0, import_react10.useMemo)(
3659
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
3660
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
3397
3661
  );
3398
- const wheelItemCountRange = (0, import_react10.useMemo)(() => getItemCountRange(nodes), [nodes]);
3399
3662
  const sectorSpanByRootId = (0, import_react10.useMemo)(() => {
3400
3663
  const map = /* @__PURE__ */ new Map();
3401
3664
  if (groups.length <= 1) return map;
@@ -3445,10 +3708,10 @@ function WheelRadialCore({
3445
3708
  const chipClearanceFloor = (0, import_react10.useMemo)(
3446
3709
  () => calculateWheelRadiusForAngles(
3447
3710
  layout.map((slot) => slot.angle),
3448
- MAX_NODE_WIDTH,
3711
+ wheelItemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
3449
3712
  WHEEL_RADIUS
3450
3713
  ),
3451
- [layout]
3714
+ [layout, wheelItemCountRange.maxNodeWidth]
3452
3715
  );
3453
3716
  const maxCoreExtentDelta = (0, import_react10.useMemo)(() => {
3454
3717
  let extent = 0;
@@ -3562,12 +3825,9 @@ function WheelRadialCore({
3562
3825
  wheelItemCountRange,
3563
3826
  panel?.node.id
3564
3827
  ]);
3565
- const hasInitialFitRef = (0, import_react10.useRef)(false);
3566
3828
  (0, import_react10.useEffect)(() => {
3567
- if (hasInitialFitRef.current) return;
3568
3829
  const elements = [wheelCircleRef.current, coreSvgRef.current];
3569
3830
  if (!elements.some(Boolean)) return;
3570
- hasInitialFitRef.current = true;
3571
3831
  panZoom.fitToFrame(elements, { silent: true });
3572
3832
  });
3573
3833
  const handleChipClick = (rootId) => {
@@ -3583,7 +3843,8 @@ function WheelRadialCore({
3583
3843
  panZoom.centerOnElement(
3584
3844
  element,
3585
3845
  ZOOM_FOCUS_SCALE,
3586
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
3846
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
3847
+ true
3587
3848
  );
3588
3849
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
3589
3850
  }, [selectedNodeId, index, panel, showNodeInfo]);
@@ -3818,6 +4079,7 @@ function WheelRadialCore({
3818
4079
  {
3819
4080
  panZoom,
3820
4081
  orientation: controlsOrientation,
4082
+ zoomControls: showZoomControls,
3821
4083
  leading: floatingControlsLeading,
3822
4084
  busy,
3823
4085
  onFit: () => panZoom.fitToFrame([wheelCircleRef.current, coreSvgRef.current])
@@ -3864,7 +4126,7 @@ function useFocusHistory(focusNodeId, isFocusable) {
3864
4126
 
3865
4127
  // src/WheelBreadcrumb.tsx
3866
4128
  var import_react12 = require("react");
3867
- var import_md7 = require("react-icons/md");
4129
+ var import_md6 = require("react-icons/md");
3868
4130
  var import_jsx_runtime15 = require("react/jsx-runtime");
3869
4131
  var MAX_VISIBLE_ANCESTORS = 3;
3870
4132
  var ANCESTORS_KEPT_WHEN_COLLAPSED = 2;
@@ -3882,7 +4144,7 @@ function WheelBreadcrumb({ ancestors, currentName, onHome, onAncestor }) {
3882
4144
  "aria-label": "All genres",
3883
4145
  title: "All genres",
3884
4146
  onClick: onHome,
3885
- children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_md7.MdHome, { className: "gtv-icon", size: 14 })
4147
+ children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_md6.MdHome, { className: "gtv-icon", size: 14 })
3886
4148
  }
3887
4149
  ),
3888
4150
  separator,
@@ -4001,6 +4263,8 @@ function WheelRadialPopCoreCore({
4001
4263
  hideInfoPanelClose = false,
4002
4264
  hideInfoPanelTitle = false,
4003
4265
  itemCountLabel,
4266
+ itemCountScaleMax,
4267
+ nodeCountForSizing,
4004
4268
  readOnly,
4005
4269
  renderDangerZone,
4006
4270
  expanded,
@@ -4010,8 +4274,10 @@ function WheelRadialPopCoreCore({
4010
4274
  overlayPanel,
4011
4275
  wheelZoom,
4012
4276
  defaultViewport,
4277
+ viewportRestore,
4013
4278
  onViewportChange,
4014
4279
  controlsOrientation = "vertical",
4280
+ showZoomControls = "auto",
4015
4281
  floatingControlsLeading,
4016
4282
  busy,
4017
4283
  allowWheelRotation = true,
@@ -4019,6 +4285,7 @@ function WheelRadialPopCoreCore({
4019
4285
  focusNodeId: requestedFocusNodeId = null,
4020
4286
  onFocusChange
4021
4287
  }) {
4288
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
4022
4289
  const index = useTreeIndex(nodes);
4023
4290
  const focusNodeId = (0, import_react13.useMemo)(
4024
4291
  () => requestedFocusNodeId !== null && isZoomableNode(nodes, requestedFocusNodeId) ? requestedFocusNodeId : null,
@@ -4087,7 +4354,8 @@ function WheelRadialPopCoreCore({
4087
4354
  const viewportRef = (0, import_react13.useRef)(null);
4088
4355
  const stageRef = (0, import_react13.useRef)(null);
4089
4356
  const dotRadiusMemoryRef = (0, import_react13.useRef)(/* @__PURE__ */ new Map());
4090
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
4357
+ const viewportFrame = (0, import_react13.useMemo)(() => JSON.stringify([focusNodeId, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, focusNodeId]);
4358
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
4091
4359
  const { panel: openPanel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
4092
4360
  const isInFrame = (id) => id === focusNodeId || centerSubtreeNodeIds.has(id) || ringRootById.has(id);
4093
4361
  const panel = openPanel && isInFrame(openPanel.node.id) ? openPanel : null;
@@ -4158,8 +4426,8 @@ function WheelRadialPopCoreCore({
4158
4426
  () => getItemCountRange([
4159
4427
  ...nodes,
4160
4428
  ...[...rootItemCountById.values()].map((itemCount) => ({ itemCount }))
4161
- ]),
4162
- [nodes, rootItemCountById]
4429
+ ], { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
4430
+ [nodes, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
4163
4431
  );
4164
4432
  const isCenterMainstream = isMainstreamPopLineage(index, centerNode.id);
4165
4433
  const centerNodeColor = isCenterMainstream ? "#ffffff" : getGenreNodeStyle(index, centerNode.id).fill;
@@ -4193,8 +4461,8 @@ function WheelRadialPopCoreCore({
4193
4461
  centerSubtreeHierarchy,
4194
4462
  0,
4195
4463
  POP_TREE_DEPTH_RADIAL_SPACING
4196
- ) : NODE_LAYOUT_SLOT_RADIUS,
4197
- [centerSubtreeHierarchy]
4464
+ ) : wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS,
4465
+ [centerSubtreeHierarchy, wheelItemCountRange.maxDotRadius]
4198
4466
  );
4199
4467
  const maxCoreExtentDelta = (0, import_react13.useMemo)(() => {
4200
4468
  let extent = 0;
@@ -4216,7 +4484,8 @@ function WheelRadialPopCoreCore({
4216
4484
  (radius) => new Map(
4217
4485
  groups.map((group) => {
4218
4486
  const { pop, core } = branchHierarchiesByRootId.get(group.root.id);
4219
- const chipWidth = 2 * NODE_LAYOUT_SLOT_RADIUS;
4487
+ const dotRadius = wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS;
4488
+ const chipWidth = 2 * dotRadius;
4220
4489
  return [
4221
4490
  group.root.id,
4222
4491
  {
@@ -4224,14 +4493,16 @@ function WheelRadialPopCoreCore({
4224
4493
  (hierarchy) => measureWedge(
4225
4494
  d34,
4226
4495
  hierarchy,
4227
- popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4496
+ popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4497
+ dotRadius
4228
4498
  )
4229
4499
  ),
4230
4500
  core: core.map(
4231
4501
  (hierarchy) => measureWedge(
4232
4502
  d34,
4233
4503
  hierarchy,
4234
- coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4504
+ coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4505
+ dotRadius
4235
4506
  )
4236
4507
  ),
4237
4508
  chip: 2 * Math.asin(Math.min(1, (chipWidth / 2 + CHIP_GAP) / radius))
@@ -4239,7 +4510,7 @@ function WheelRadialPopCoreCore({
4239
4510
  ];
4240
4511
  })
4241
4512
  ),
4242
- [groups, branchHierarchiesByRootId]
4513
+ [groups, branchHierarchiesByRootId, wheelItemCountRange.maxDotRadius]
4243
4514
  );
4244
4515
  const coreRootCircleRadius = (0, import_react13.useMemo)(
4245
4516
  () => solveRingRadius(
@@ -4249,11 +4520,11 @@ function WheelRadialPopCoreCore({
4249
4520
  ),
4250
4521
  Math.max(
4251
4522
  WHEEL_POP_CORE_RADIUS,
4252
- mainstreamCircleRadius + NODE_DOT_MAX_RADIUS + POP_SUBTREE_OUTER_MARGIN,
4523
+ mainstreamCircleRadius + (wheelItemCountRange.maxDotRadius ?? NODE_DOT_MAX_RADIUS) + POP_SUBTREE_OUTER_MARGIN,
4253
4524
  popReachRequiredRadius
4254
4525
  )
4255
4526
  ),
4256
- [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius]
4527
+ [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius, wheelItemCountRange.maxDotRadius]
4257
4528
  );
4258
4529
  const branchNeedsByRootId = (0, import_react13.useMemo)(
4259
4530
  () => measureBranchNeedsAt(coreRootCircleRadius),
@@ -4357,7 +4628,8 @@ function WheelRadialPopCoreCore({
4357
4628
  angle,
4358
4629
  coreRootCircleRadius,
4359
4630
  span,
4360
- POP_TREE_DEPTH_RADIAL_SPACING
4631
+ POP_TREE_DEPTH_RADIAL_SPACING,
4632
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4361
4633
  );
4362
4634
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4363
4635
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-pop-branch").attr("data-gtv-branch-index", branchIndex);
@@ -4423,7 +4695,8 @@ function WheelRadialPopCoreCore({
4423
4695
  angle,
4424
4696
  span,
4425
4697
  coreRootCircleRadius,
4426
- POP_TREE_DEPTH_RADIAL_SPACING
4698
+ POP_TREE_DEPTH_RADIAL_SPACING,
4699
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4427
4700
  );
4428
4701
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4429
4702
  const angleOf = (d) => {
@@ -4587,7 +4860,7 @@ function WheelRadialPopCoreCore({
4587
4860
  originGroup.selectAll("g.gtv-root-label").select("text").style("pointer-events", "all").style("cursor", "pointer").on("click", (_event, d) => {
4588
4861
  const chip = d.id === centerNode.id ? centerChipRef.current : viewportRef.current?.querySelector(`#group-${CSS.escape(d.id)}`);
4589
4862
  chip?.click();
4590
- }).on("dblclick", (_event, d) => focusOnNodeRef.current(d.id));
4863
+ }).on("dblclick", (_event, d) => chipClickRef.current.doubleClick(() => focusOnNodeRef.current(d.id)));
4591
4864
  }, [
4592
4865
  centerNode,
4593
4866
  centerHasPopDisc,
@@ -4686,18 +4959,12 @@ function WheelRadialPopCoreCore({
4686
4959
  index,
4687
4960
  regionLabel
4688
4961
  ]);
4689
- const hasInitialFitRef = (0, import_react13.useRef)(false);
4690
4962
  (0, import_react13.useEffect)(() => {
4691
- hasInitialFitRef.current = false;
4692
- }, [focusNodeId]);
4693
- (0, import_react13.useEffect)(() => {
4694
- if (hasInitialFitRef.current) return;
4695
4963
  const elements = [
4696
4964
  wheelCircleRef.current,
4697
4965
  ...queryTreeContentElements(popSvgRef.current)
4698
4966
  ];
4699
4967
  if (!elements.some(Boolean)) return;
4700
- hasInitialFitRef.current = true;
4701
4968
  panZoom.fitToFrame(elements, { silent: true });
4702
4969
  });
4703
4970
  const handleChipClick = (rootId) => {
@@ -4719,7 +4986,8 @@ function WheelRadialPopCoreCore({
4719
4986
  panZoom.centerOnElement(
4720
4987
  element,
4721
4988
  ZOOM_FOCUS_SCALE,
4722
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
4989
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
4990
+ true
4723
4991
  );
4724
4992
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
4725
4993
  }, [selectedNodeId, index, openPanel, showNodeInfo, centerSubtreeNodeIds, ringRootById]);
@@ -4746,7 +5014,7 @@ function WheelRadialPopCoreCore({
4746
5014
  )
4747
5015
  };
4748
5016
  });
4749
- }, [groups, continuousAngleByRootId, sectorSpanByRootId]);
5017
+ }, [groups, continuousAngleByRootId, sectorSpanByRootId, index]);
4750
5018
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
4751
5019
  "div",
4752
5020
  {
@@ -4856,9 +5124,9 @@ function WheelRadialPopCoreCore({
4856
5124
  showNodeInfo(centerNode, chip, viewportRef.current);
4857
5125
  onNodeClick?.(centerNode, nativeEvent);
4858
5126
  }
4859
- });
5127
+ }, () => focusOnNode(centerNode.id), centerNode.id);
4860
5128
  },
4861
- onDoubleClick: () => focusOnNode(centerNode.id),
5129
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(centerNode.id)),
4862
5130
  "aria-label": centerNode.name
4863
5131
  }
4864
5132
  ) }) }),
@@ -4918,9 +5186,9 @@ function WheelRadialPopCoreCore({
4918
5186
  showNodeInfo(root, chip, viewportRef.current);
4919
5187
  onNodeClick?.(root, nativeEvent);
4920
5188
  }
4921
- });
5189
+ }, () => focusOnNode(group.root.id), group.root.id);
4922
5190
  },
4923
- onDoubleClick: () => focusOnNode(group.root.id)
5191
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(group.root.id))
4924
5192
  }
4925
5193
  ) })
4926
5194
  },
@@ -5015,6 +5283,7 @@ function WheelRadialPopCoreCore({
5015
5283
  {
5016
5284
  panZoom,
5017
5285
  orientation: controlsOrientation,
5286
+ zoomControls: showZoomControls,
5018
5287
  leading: floatingControlsLeading,
5019
5288
  busy,
5020
5289
  onFit: () => panZoom.fitToFrame([
@@ -5216,7 +5485,7 @@ function GenreTreeOutline(props) {
5216
5485
  return;
5217
5486
  }
5218
5487
  const changed = store.get().selectedId !== node.id;
5219
- store.set({ selectedId: node.id });
5488
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: node.id });
5220
5489
  if (changed) latestProps.current.onSelectedNodeChange?.(node);
5221
5490
  onNodeClick?.(node, event.nativeEvent);
5222
5491
  },
@@ -5248,7 +5517,7 @@ function GenreTreeOutline(props) {
5248
5517
  }
5249
5518
  const changed = store.get().selectedId !== nodeId;
5250
5519
  if (changed) pendingScrollIdRef.current = nodeId;
5251
- store.set({ openIds, selectedId: nodeId });
5520
+ store.set({ openIds, ...notify && latestProps.current.selectedNodeId !== void 0 ? {} : { selectedId: nodeId } });
5252
5521
  if (changed && notify) latestProps.current.onSelectedNodeChange?.(index.byId.get(nodeId));
5253
5522
  };
5254
5523
  const selectNodeRef = (0, import_react14.useRef)(selectNode);
@@ -5289,7 +5558,7 @@ function GenreTreeOutline(props) {
5289
5558
  ancestorNodes: index.ancestorsOf(selectedNode.id).map((n) => ({ node: n, ...model.styleOf(n) })),
5290
5559
  side: "left",
5291
5560
  onClose: () => {
5292
- store.set({ selectedId: null });
5561
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: null });
5293
5562
  latestProps.current.onSelectedNodeChange?.(null);
5294
5563
  },
5295
5564
  onSelectNode: (id) => selectNodeRef.current(id),