@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.js CHANGED
@@ -131,6 +131,9 @@ var MIN_NODE_WIDTH = 180;
131
131
  var MAX_NODE_WIDTH = 700;
132
132
  var MIN_NODE_HEIGHT = 35;
133
133
  var MAX_NODE_HEIGHT = 120;
134
+ var NODE_SIZE_REFERENCE_COUNT = 100;
135
+ var HIGH_DENSITY_NODE_MAX_FLOOR_SCALE = 1.5;
136
+ var LOW_DENSITY_DOT_MAX_RADIUS = 70;
134
137
  var TOOLBAR_MENU_X_GAP = 4;
135
138
  var ACTIONS_OVERLAY_WIDTH = MENU_WIDTH + TOOLBAR_MENU_X_GAP;
136
139
  var ACTIONS_OVERLAY_HEIGHT = RECT_BASE_DIMENSIONS.HEIGHT * 7;
@@ -145,23 +148,47 @@ var HORIZONTAL_SEPARATION_BETWEEN_NODES = (NODE_DIMENSIONS.WIDTH + HORIZONTAL_SE
145
148
  var VERTICAL_SEPARATION_BETWEEN_NODES = NODE_DIMENSIONS.HEIGHT + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
146
149
  var VERTICAL_ORIENTATION_DEPTH_SEPARATION = VERTICAL_SEPARATION_BETWEEN_NODES * 2 * DEPTH_AXIS_SPACING_FACTOR;
147
150
  var SIBLING_SEPARATION_BETWEEN_NODES = MAX_NODE_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES;
148
- function getItemCountRange(nodes) {
149
- if (nodes.length === 0) return { min: 0, max: 0 };
151
+ function getDensityAdjustedMax(baseMax, min, nodeCount) {
152
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
153
+ const availableRangeScale = Math.min(1, NODE_SIZE_REFERENCE_COUNT / safeNodeCount);
154
+ const denseTreeFloor = min * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
155
+ return denseTreeFloor + (baseMax - denseTreeFloor) * availableRangeScale;
156
+ }
157
+ function getDensityAdjustedDotMax(nodeCount) {
158
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
159
+ const inverseDensityRadius = NODE_DOT_MAX_RADIUS * NODE_SIZE_REFERENCE_COUNT / safeNodeCount;
160
+ const denseTreeFloor = NODE_DOT_MIN_RADIUS * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
161
+ return Math.max(denseTreeFloor, Math.min(LOW_DENSITY_DOT_MAX_RADIUS, inverseDensityRadius));
162
+ }
163
+ function getItemCountRange(nodes, options = {}) {
150
164
  const counts = nodes.map((node) => node.itemCount);
151
- return { min: Math.min(...counts), max: Math.max(...counts) };
165
+ const validScaleMax = options.itemCountScaleMax !== void 0 && Number.isFinite(options.itemCountScaleMax) && options.itemCountScaleMax >= 0;
166
+ const nodeCount = options.nodeCountForSizing ?? nodes.length;
167
+ const observedMin = counts.length === 0 ? 0 : Math.min(...counts);
168
+ const observedMax = counts.length === 0 ? 0 : Math.max(...counts);
169
+ const includeDensityCaps = options.nodeCountForSizing !== void 0 || validScaleMax;
170
+ return {
171
+ min: validScaleMax ? 0 : observedMin,
172
+ max: validScaleMax ? options.itemCountScaleMax : observedMax,
173
+ ...includeDensityCaps && {
174
+ maxNodeWidth: getDensityAdjustedMax(MAX_NODE_WIDTH, MIN_NODE_WIDTH, nodeCount),
175
+ maxNodeHeight: getDensityAdjustedMax(MAX_NODE_HEIGHT, MIN_NODE_HEIGHT, nodeCount),
176
+ maxDotRadius: getDensityAdjustedDotMax(nodeCount)
177
+ }
178
+ };
152
179
  }
153
180
  function logarithmicPosition(itemCount, range) {
154
181
  const { min, max } = range;
155
182
  const logMin = Math.log1p(Math.max(min, 0));
156
183
  const logMax = Math.log1p(Math.max(max, 0));
157
184
  const logValue = Math.log1p(Math.max(itemCount, 0));
158
- return logMax > logMin ? (logValue - logMin) / (logMax - logMin) : 0;
185
+ return logMax > logMin ? Math.max(0, Math.min(1, (logValue - logMin) / (logMax - logMin))) : 0;
159
186
  }
160
187
  function calculateNodeDimensions(itemCount, range) {
161
188
  const t = logarithmicPosition(itemCount, range);
162
189
  return {
163
- WIDTH: Math.round(MIN_NODE_WIDTH + t * (MAX_NODE_WIDTH - MIN_NODE_WIDTH)),
164
- HEIGHT: Math.round(MIN_NODE_HEIGHT + t * (MAX_NODE_HEIGHT - MIN_NODE_HEIGHT))
190
+ WIDTH: Math.round(MIN_NODE_WIDTH + t * ((range.maxNodeWidth ?? MAX_NODE_WIDTH) - MIN_NODE_WIDTH)),
191
+ HEIGHT: Math.round(MIN_NODE_HEIGHT + t * ((range.maxNodeHeight ?? MAX_NODE_HEIGHT) - MIN_NODE_HEIGHT))
165
192
  };
166
193
  }
167
194
  var NODE_MIN_BORDER_WIDTH = 1;
@@ -206,7 +233,7 @@ var NODE_DOT_LABEL_GAP = 6;
206
233
  var NODE_LAYOUT_SLOT_RADIUS = 8;
207
234
  function calculateNodeDotRadius(itemCount, range) {
208
235
  const minArea = NODE_DOT_MIN_RADIUS ** 2;
209
- const maxArea = NODE_DOT_MAX_RADIUS ** 2;
236
+ const maxArea = (range.maxDotRadius ?? NODE_DOT_MAX_RADIUS) ** 2;
210
237
  return Math.sqrt(minArea + logarithmicPosition(itemCount, range) * (maxArea - minArea));
211
238
  }
212
239
  var WHEEL_RADIUS = 260;
@@ -241,19 +268,22 @@ var PAN_MIN_VISIBLE_PX = 80;
241
268
  var PAN_ZOOM_SETTLE_MS = 100;
242
269
  var DEFAULT_VIEWPORT_CENTER_GUARD_MS = 1e3;
243
270
  var INFO_PANEL_WIDTH = 280;
271
+ var INFO_PANEL_SHEET_MAX_WIDTH = 767;
272
+ var INFO_PANEL_SHEET_PEEK_FRACTION = 0.4;
273
+ var INFO_PANEL_SHEET_DRAG_THRESHOLD_PX = 32;
244
274
 
245
275
  // src/NodeHelper.tsx
246
276
  import { jsx, jsxs } from "react/jsx-runtime";
247
277
  function buildTreeHierarchyStructure(d3Lib, nodes) {
248
278
  return d3Lib.stratify().id((d) => d.id).parentId((d) => d.parentId)(nodes);
249
279
  }
250
- function calculateLocalRootDimensions(d3Lib, nodes) {
280
+ function calculateLocalRootDimensions(d3Lib, nodes, options = {}) {
251
281
  const root = buildTreeHierarchyStructure(d3Lib, nodes);
252
- const range = getItemCountRange(root.descendants().map((d) => d.data));
282
+ const range = getItemCountRange(root.descendants().map((d) => d.data), options);
253
283
  return calculateNodeDimensions(root.data.itemCount, range);
254
284
  }
255
- function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis) {
256
- const localExtent = calculateLocalRootDimensions(d3Lib, nodes)[axis];
285
+ function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis, options = {}) {
286
+ const localExtent = calculateLocalRootDimensions(d3Lib, nodes, options)[axis];
257
287
  const displayExtent = calculateNodeDimensions(rootItemCount, rootItemCountRange)[axis];
258
288
  return (localExtent + displayExtent) / 2;
259
289
  }
@@ -337,13 +367,31 @@ function appendPaths(d3Lib, svg, treeData, itemCountRange, orientation = "horizo
337
367
  }
338
368
 
339
369
  // src/tree-renderer.ts
340
- function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1) {
341
- const nodes = treeData.descendants();
342
- const itemCountRange = getItemCountRange(nodes.map((d) => d.data));
343
- const maxNodeDimensions = { WIDTH: MAX_NODE_WIDTH, HEIGHT: MAX_NODE_HEIGHT };
370
+ function getLayoutSpacings(itemCountRange, depthSpacingScale = 1) {
371
+ const maxWidth = itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH;
372
+ const maxHeight = itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT;
373
+ const verticalSeparation = maxHeight + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
374
+ return {
375
+ horizontalDepth: (maxWidth + ACTIONS_OVERLAY_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES) * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
376
+ verticalBreadth: verticalSeparation,
377
+ verticalDepth: verticalSeparation * 2 * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
378
+ verticalSibling: maxWidth + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES
379
+ };
380
+ }
381
+ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1, suppliedItemCountRange) {
382
+ const treeNodes = treeData.descendants();
383
+ const nodes = treeNodes;
384
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
385
+ treeNodes.map((d) => d.data)
386
+ );
387
+ const maxNodeDimensions = {
388
+ WIDTH: itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
389
+ HEIGHT: itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT
390
+ };
344
391
  const maximumLevel = d3Lib.max(nodes, (d) => d.depth);
345
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
346
- const depthSeparationHorizontal = HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale;
392
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
393
+ const depthSeparation = layoutSpacings.verticalDepth;
394
+ const depthSeparationHorizontal = layoutSpacings.horizontalDepth;
347
395
  if (isVerticalOrientation(orientation)) {
348
396
  const svgHeight2 = hideRoot ? maximumLevel * depthSeparation : maximumLevel * depthSeparation + maxNodeDimensions.HEIGHT + ACTIONS_OVERLAY_HEIGHT / 2;
349
397
  const rootWidth = calculateNodeDimensions(
@@ -405,10 +453,15 @@ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hid
405
453
  const svgWidth = maximumLevel * depthSeparationHorizontal + maxNodeDimensions.WIDTH + ACTIONS_OVERLAY_WIDTH;
406
454
  return { svgWidth, svgHeight, highestVerticalCoordinate, rootDepthOffset: 0 };
407
455
  }
408
- function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1) {
456
+ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1, suppliedItemCountRange) {
457
+ const treeNodes = treeData.descendants();
458
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
459
+ treeNodes.map((d) => d.data)
460
+ );
461
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
409
462
  if (isVerticalOrientation(orientation)) {
410
463
  const maximumLevel = d3Lib.max(treeData.descendants(), (d) => d.depth);
411
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
464
+ const depthSeparation = layoutSpacings.verticalDepth;
412
465
  treeData.each(function(d) {
413
466
  d.x = d.x + highestVerticalCoordinate;
414
467
  d.y = depthAxisSign(orientation) === -1 ? (maximumLevel - d.depth) * depthSeparation : d.depth * depthSeparation + rootDepthOffset;
@@ -430,24 +483,30 @@ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation
430
483
  });
431
484
  return treeData;
432
485
  }
433
- function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1) {
486
+ function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1, suppliedItemCountRange) {
487
+ const nodes = root.descendants();
488
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
489
+ nodes.map((d) => d.data)
490
+ );
491
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
434
492
  const nodeSize = isVerticalOrientation(orientation) ? [
435
- SIBLING_SEPARATION_BETWEEN_NODES,
436
- VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale
493
+ layoutSpacings.verticalSibling,
494
+ layoutSpacings.verticalDepth
437
495
  ] : [
438
- VERTICAL_SEPARATION_BETWEEN_NODES,
439
- HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale
496
+ layoutSpacings.verticalBreadth,
497
+ layoutSpacings.horizontalDepth
440
498
  ];
441
499
  const treeLayout = d3Lib.tree().nodeSize(nodeSize).separation(() => 1);
442
500
  return treeLayout(root);
443
501
  }
444
- function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null) {
502
+ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null, suppliedItemCountRange) {
445
503
  const { onReparentTargetSelect, onNodeClick } = callbacks;
446
504
  if (!svgRef.current) {
447
505
  throw new Error("SVG reference is null");
448
506
  }
449
- const itemCountRange = getItemCountRange(
450
- treeData.descendants().map((d) => d.data)
507
+ const treeNodes = treeData.descendants();
508
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
509
+ treeNodes.map((d) => d.data)
451
510
  );
452
511
  const svg = d3Lib.select(svgRef.current).append("svg").attr("width", svgWidth).attr("height", svgHeight).style("overflow", "visible").append("g");
453
512
  const safeRootId = treeData.data.id.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -595,7 +654,7 @@ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNod
595
654
  }
596
655
 
597
656
  // src/FloatingControls.tsx
598
- import { MdFitScreen, MdZoomIn, MdZoomOut } from "react-icons/md";
657
+ import { ZoomControls } from "@behindthemusictree/ui";
599
658
 
600
659
  // src/BusyIndicator.tsx
601
660
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -608,7 +667,7 @@ function BusyIndicator() {
608
667
 
609
668
  // src/FloatingControls.tsx
610
669
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
611
- function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
670
+ function FloatingControls({ panZoom, onFit, orientation, zoomControls, leading, busy }) {
612
671
  return /* @__PURE__ */ jsxs3(
613
672
  "div",
614
673
  {
@@ -619,31 +678,18 @@ function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
619
678
  children: [
620
679
  busy && /* @__PURE__ */ jsx3(BusyIndicator, {}),
621
680
  leading && /* @__PURE__ */ jsx3("div", { className: "gtv-floating-controls-leading", children: leading }),
622
- /* @__PURE__ */ jsxs3("div", { className: "gtv-zoom-controls", children: [
623
- /* @__PURE__ */ jsx3(
624
- "button",
625
- {
626
- type: "button",
627
- className: ["gtv-zoom-btn", !panZoom.canZoomIn && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
628
- disabled: !panZoom.canZoomIn,
629
- onClick: panZoom.zoomIn,
630
- "aria-label": "Zoom in",
631
- children: /* @__PURE__ */ jsx3(MdZoomIn, { className: "gtv-icon", size: 18 })
632
- }
633
- ),
634
- /* @__PURE__ */ jsx3(
635
- "button",
636
- {
637
- type: "button",
638
- className: ["gtv-zoom-btn", !panZoom.canZoomOut && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
639
- disabled: !panZoom.canZoomOut,
640
- onClick: panZoom.zoomOut,
641
- "aria-label": "Zoom out",
642
- children: /* @__PURE__ */ jsx3(MdZoomOut, { className: "gtv-icon", size: 18 })
643
- }
644
- ),
645
- /* @__PURE__ */ jsx3("button", { type: "button", className: "gtv-zoom-btn", onClick: onFit, "aria-label": "Fit to frame", children: /* @__PURE__ */ jsx3(MdFitScreen, { className: "gtv-icon", size: 18 }) })
646
- ] })
681
+ zoomControls !== "never" && /* @__PURE__ */ jsx3(
682
+ ZoomControls,
683
+ {
684
+ onZoomIn: panZoom.zoomIn,
685
+ onZoomOut: panZoom.zoomOut,
686
+ onFit,
687
+ canZoomIn: panZoom.canZoomIn,
688
+ canZoomOut: panZoom.canZoomOut,
689
+ orientation,
690
+ className: zoomControls === "auto" ? "gtv-zoom-controls gtv-zoom-controls--auto" : "gtv-zoom-controls"
691
+ }
692
+ )
647
693
  ]
648
694
  }
649
695
  );
@@ -707,6 +753,18 @@ function computeFitScale(contentWidth, contentHeight, viewportWidth, viewportHei
707
753
  );
708
754
  }
709
755
 
756
+ // src/info-panel-geometry.ts
757
+ function resolveInfoPanelSide() {
758
+ return "left";
759
+ }
760
+ function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
761
+ if (!element || !viewport) return null;
762
+ if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
763
+ return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
764
+ }
765
+ return { width: panelWidth, side: resolveInfoPanelSide() };
766
+ }
767
+
710
768
  // src/use-pan-zoom.ts
711
769
  function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
712
770
  const [panX, setPanX] = useState(0);
@@ -717,6 +775,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
717
775
  const settleTimerRef = useRef(null);
718
776
  const stageAnimationRef = useRef(null);
719
777
  const defaultViewportRef = useRef(options.defaultViewport);
778
+ const operationRef = useRef(0);
779
+ const restoredSelectionRef = useRef(void 0);
780
+ const restoredRevisionRef = useRef(void 0);
781
+ const restoredFrameRef = useRef(void 0);
782
+ const pendingRestoreRef = useRef(null);
783
+ const measuredFrameRef = useRef(void 0);
784
+ const fittedFrameRef = useRef(null);
785
+ const frameElementsRef = useRef([]);
786
+ const retryFrameRef = useRef(() => {
787
+ });
788
+ const pendingCenterRef = useRef(null);
720
789
  const centerGuardUntilRef = useRef(0);
721
790
  const onViewportChangeRef = useRef(options.onViewportChange);
722
791
  useEffect(() => {
@@ -790,6 +859,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
790
859
  }, [viewportRef]);
791
860
  const zoomAtPoint = useCallback(
792
861
  (computeNewScale, clientX, clientY) => {
862
+ operationRef.current += 1;
793
863
  const viewport = viewportRef.current;
794
864
  if (!viewport) {
795
865
  return;
@@ -873,19 +943,26 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
873
943
  const viewport = viewportRef.current;
874
944
  if (!viewport) return;
875
945
  const observer = new ResizeObserver((entries) => {
876
- const entry = entries[0];
877
- if (!entry) return;
946
+ const entry = entries?.[0];
947
+ if (!entry) {
948
+ retryFrameRef.current();
949
+ return;
950
+ }
878
951
  const { width, height } = entry.contentRect;
879
952
  const previous = viewportSizeRef.current;
880
953
  viewportSizeRef.current = { width, height };
881
- if (!previous) return;
954
+ if (!previous || previous.width <= 0 || previous.height <= 0) {
955
+ retryFrameRef.current();
956
+ return;
957
+ }
882
958
  const dx = (width - previous.width) / 2;
883
959
  const dy = (height - previous.height) / 2;
884
960
  if (dx === 0 && dy === 0) return;
885
961
  const view = viewRef.current;
886
962
  view.x = clampPanAxis(view.x + dx, view.k, "x");
887
963
  view.y = clampPanAxis(view.y + dy, view.k, "y");
888
- commitView();
964
+ commitView(false);
965
+ retryFrameRef.current();
889
966
  });
890
967
  observer.observe(viewport);
891
968
  return () => observer.disconnect();
@@ -896,6 +973,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
896
973
  return;
897
974
  }
898
975
  const handleWheel = (event) => {
976
+ operationRef.current += 1;
899
977
  if (event.target.closest(".gtv-info-panel") !== null) {
900
978
  return;
901
979
  }
@@ -934,6 +1012,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
934
1012
  }, [viewportRef, wheelZoom]);
935
1013
  const zoomByButton = useCallback(
936
1014
  (direction) => {
1015
+ operationRef.current += 1;
937
1016
  const viewport = viewportRef.current;
938
1017
  if (!viewport) return;
939
1018
  const rect = viewport.getBoundingClientRect();
@@ -948,13 +1027,14 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
948
1027
  );
949
1028
  const fitToFrame = useCallback(
950
1029
  (elements, { silent = false } = {}) => {
1030
+ if (!silent) operationRef.current += 1;
1031
+ frameElementsRef.current = elements;
951
1032
  const viewport = viewportRef.current;
952
1033
  const present = elements.filter((el) => Boolean(el));
953
- if (!viewport || present.length === 0) return;
1034
+ if (!viewport || present.length === 0) return false;
954
1035
  const viewportRect = viewport.getBoundingClientRect();
955
- if (viewportRect.width <= 0 || viewportRect.height <= 0) return;
956
- centerAnimationRef.current = null;
957
- if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return;
1036
+ if (viewportRect.width <= 0 || viewportRect.height <= 0) return false;
1037
+ if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return false;
958
1038
  const rects = present.map((el) => el.getBoundingClientRect());
959
1039
  const contentLeft = Math.min(...rects.map((r) => r.left));
960
1040
  const contentTop = Math.min(...rects.map((r) => r.top));
@@ -963,7 +1043,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
963
1043
  const view = viewRef.current;
964
1044
  const contentWidth = (contentRight - contentLeft) / view.k;
965
1045
  const contentHeight = (contentBottom - contentTop) / view.k;
966
- if (contentWidth <= 0 || contentHeight <= 0) return;
1046
+ if (contentWidth <= 0 || contentHeight <= 0) return false;
967
1047
  const contentOriginX = (contentLeft - viewportRect.left - view.x) / view.k;
968
1048
  const contentOriginY = (contentTop - viewportRect.top - view.y) / view.k;
969
1049
  contentBoundsRef.current = {
@@ -982,6 +1062,41 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
982
1062
  const newMinScale = Math.min(ZOOM_MIN_SCALE, fitScale);
983
1063
  minScaleRef.current = newMinScale;
984
1064
  setMinScale(newMinScale);
1065
+ measuredFrameRef.current = options.frameKey;
1066
+ const pending = pendingRestoreRef.current;
1067
+ if (pending && pending.operation !== operationRef.current) {
1068
+ pendingRestoreRef.current = null;
1069
+ pendingCenterRef.current = null;
1070
+ fittedFrameRef.current = { frame: options.frameKey };
1071
+ return true;
1072
+ }
1073
+ if (pending && pending.frame === options.frameKey && pending.operation === operationRef.current) {
1074
+ pendingRestoreRef.current = null;
1075
+ restoredRevisionRef.current = pending.revision;
1076
+ restoredFrameRef.current = pending.frame;
1077
+ restoredSelectionRef.current = options.selectedNodeId;
1078
+ defaultViewportRef.current = void 0;
1079
+ viewRef.current = viewportToView({ ...pending.viewport, zoom: clampZoomScale(pending.viewport.zoom, newMinScale) }, viewportRect.width, viewportRect.height);
1080
+ fittedFrameRef.current = { frame: options.frameKey };
1081
+ pendingCenterRef.current = null;
1082
+ commitView(false);
1083
+ return true;
1084
+ }
1085
+ if (silent && fittedFrameRef.current && fittedFrameRef.current.frame === options.frameKey) {
1086
+ const center2 = pendingCenterRef.current;
1087
+ pendingCenterRef.current = null;
1088
+ center2?.();
1089
+ return true;
1090
+ }
1091
+ fittedFrameRef.current = { frame: options.frameKey };
1092
+ const center = pendingCenterRef.current;
1093
+ pendingCenterRef.current = null;
1094
+ if (silent && center && !defaultViewportRef.current) {
1095
+ center();
1096
+ return true;
1097
+ }
1098
+ zoomAnimationRef.current = null;
1099
+ centerAnimationRef.current = null;
985
1100
  const seed = defaultViewportRef.current;
986
1101
  if (seed) {
987
1102
  defaultViewportRef.current = void 0;
@@ -995,14 +1110,15 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
995
1110
  view.y = seeded.y;
996
1111
  centerGuardUntilRef.current = performance.now() + DEFAULT_VIEWPORT_CENTER_GUARD_MS;
997
1112
  commitView(false);
998
- return;
1113
+ return true;
999
1114
  }
1000
1115
  view.k = fitScale;
1001
1116
  view.x = viewportRect.width / 2 - (contentOriginX + contentWidth / 2) * fitScale;
1002
1117
  view.y = viewportRect.height / 2 - (contentOriginY + contentHeight / 2) * fitScale;
1003
1118
  commitView(!silent);
1119
+ return true;
1004
1120
  },
1005
- [viewportRef, commitView]
1121
+ [viewportRef, commitView, options.frameKey, options.selectedNodeId]
1006
1122
  );
1007
1123
  const centerAnimationRef = useRef(null);
1008
1124
  const centerAnimationFrameIdRef = useRef(null);
@@ -1036,7 +1152,19 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1036
1152
  stepCenterAnimationRef.current = stepCenterAnimation;
1037
1153
  }, [stepCenterAnimation]);
1038
1154
  const centerOnElement = useCallback(
1039
- (element, targetScale, obscured) => {
1155
+ (element, targetScale, obscured, automatic = false) => {
1156
+ if (automatic && restoredRevisionRef.current !== void 0 && restoredFrameRef.current === options.frameKey && restoredSelectionRef.current === options.selectedNodeId) return;
1157
+ if (!automatic) operationRef.current += 1;
1158
+ const viewportSize = viewportRef.current?.getBoundingClientRect();
1159
+ if (automatic && (measuredFrameRef.current !== options.frameKey || fittedFrameRef.current === null || !viewportSize?.width || !viewportSize.height)) {
1160
+ const operation = operationRef.current;
1161
+ pendingCenterRef.current = () => {
1162
+ if (operation !== operationRef.current) return;
1163
+ const currentElement = element?.id ? viewportRef.current?.querySelector(`#${CSS.escape(element.id)}`) : element;
1164
+ centerLatestRef.current(currentElement, targetScale, obscured ? resolveInfoPanelObscuredArea(currentElement, viewportRef.current, INFO_PANEL_WIDTH) : obscured, true);
1165
+ };
1166
+ return;
1167
+ }
1040
1168
  const viewport = viewportRef.current;
1041
1169
  if (!viewport || !element || performance.now() < centerGuardUntilRef.current) return;
1042
1170
  const viewportRect = viewport.getBoundingClientRect();
@@ -1047,9 +1175,10 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1047
1175
  const newScale = clampZoomScale(Math.max(targetScale, currentScale), minScaleRef.current);
1048
1176
  const centerX = (rect.left + rect.width / 2 - viewportRect.left - view.x) / currentScale;
1049
1177
  const centerY = (rect.top + rect.height / 2 - viewportRect.top - view.y) / currentScale;
1050
- const availableCenterX = !obscured ? viewportRect.width / 2 : obscured.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : (viewportRect.width - obscured.width) / 2;
1178
+ const availableCenterX = obscured?.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : obscured?.side === "right" ? (viewportRect.width - obscured.width) / 2 : viewportRect.width / 2;
1179
+ const availableCenterY = obscured?.side === "bottom" ? (viewportRect.height - obscured.height) / 2 : viewportRect.height / 2;
1051
1180
  const targetPanX = availableCenterX - centerX * newScale;
1052
- const targetPanY = viewportRect.height / 2 - centerY * newScale;
1181
+ const targetPanY = availableCenterY - centerY * newScale;
1053
1182
  zoomAnimationRef.current = null;
1054
1183
  if (animationFrameIdRef.current !== null) {
1055
1184
  cancelAnimationFrame(animationFrameIdRef.current);
@@ -1068,8 +1197,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1068
1197
  centerAnimationFrameIdRef.current = requestAnimationFrame(() => stepCenterAnimationRef.current());
1069
1198
  }
1070
1199
  },
1071
- [viewportRef]
1200
+ [viewportRef, options.frameKey, options.selectedNodeId]
1072
1201
  );
1202
+ const centerLatestRef = useRef(centerOnElement);
1203
+ useEffect(() => {
1204
+ centerLatestRef.current = centerOnElement;
1205
+ }, [centerOnElement]);
1206
+ useEffect(() => {
1207
+ retryFrameRef.current = () => {
1208
+ fitToFrame(frameElementsRef.current, { silent: true });
1209
+ };
1210
+ }, [fitToFrame]);
1073
1211
  const lastPointRef = useRef({ x: 0, y: 0 });
1074
1212
  const minScaleRef = useRef(minScale);
1075
1213
  const suppressedPointersRef = useRef(/* @__PURE__ */ new Set());
@@ -1144,6 +1282,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1144
1282
  }, [handlePointerUp]);
1145
1283
  const handlePointerDown = useCallback((event) => {
1146
1284
  if (event.button !== 0) return;
1285
+ operationRef.current += 1;
1147
1286
  const isInteractive = event.target.closest("g.node, foreignObject, .gtv-zoom-controls, .gtv-wheel-chip, .gtv-info-panel") !== null;
1148
1287
  const isPageScrollTouch = wheelZoom === "modifier" && event.pointerType === "touch";
1149
1288
  const pointers = activePointersRef.current;
@@ -1162,6 +1301,42 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1162
1301
  window.addEventListener("pointercancel", stablePointerUp);
1163
1302
  }
1164
1303
  }, [stablePointerMove, stablePointerUp, wheelZoom]);
1304
+ const restore = options.viewportRestore;
1305
+ const restoreRevision = restore?.revision;
1306
+ const restoreX = restore?.viewport.x;
1307
+ const restoreY = restore?.viewport.y;
1308
+ const restoreZoom = restore?.viewport.zoom;
1309
+ useEffect(() => {
1310
+ if (restoreRevision === restoredRevisionRef.current || restoreRevision === void 0 || restoreX === void 0 || restoreY === void 0 || restoreZoom === void 0) return;
1311
+ if (!Number.isFinite(restoreX) || !Number.isFinite(restoreY) || !Number.isFinite(restoreZoom) || restoreZoom <= 0) return;
1312
+ const viewportElement = viewportRef.current;
1313
+ if (!viewportElement) return;
1314
+ restoredRevisionRef.current = restoreRevision;
1315
+ const operation = ++operationRef.current;
1316
+ zoomAnimationRef.current = null;
1317
+ centerAnimationRef.current = null;
1318
+ if (animationFrameIdRef.current !== null) cancelAnimationFrame(animationFrameIdRef.current);
1319
+ if (centerAnimationFrameIdRef.current !== null) cancelAnimationFrame(centerAnimationFrameIdRef.current);
1320
+ if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
1321
+ if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current);
1322
+ animationFrameIdRef.current = null;
1323
+ centerAnimationFrameIdRef.current = null;
1324
+ frameIdRef.current = null;
1325
+ settleTimerRef.current = null;
1326
+ activePointersRef.current.clear();
1327
+ suppressedPointersRef.current.clear();
1328
+ pinchStartRef.current = null;
1329
+ window.removeEventListener("pointermove", stablePointerMove);
1330
+ window.removeEventListener("pointerup", stablePointerUp);
1331
+ window.removeEventListener("pointercancel", stablePointerUp);
1332
+ pendingRestoreRef.current = {
1333
+ viewport: { x: restoreX, y: restoreY, zoom: restoreZoom },
1334
+ revision: restoreRevision,
1335
+ frame: options.frameKey,
1336
+ operation
1337
+ };
1338
+ if (measuredFrameRef.current === options.frameKey) retryFrameRef.current();
1339
+ }, [restoreRevision, restoreX, restoreY, restoreZoom, options.frameKey, options.selectedNodeId, viewportRef, commitView, stablePointerMove, stablePointerUp]);
1165
1340
  useEffect(() => {
1166
1341
  const pointers = activePointersRef.current;
1167
1342
  const suppressed = suppressedPointersRef.current;
@@ -1196,17 +1371,6 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1196
1371
 
1197
1372
  // src/use-node-info-panel.ts
1198
1373
  import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
1199
-
1200
- // src/info-panel-geometry.ts
1201
- function resolveInfoPanelSide() {
1202
- return "left";
1203
- }
1204
- function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
1205
- if (!element || !viewport) return null;
1206
- return { width: panelWidth, side: resolveInfoPanelSide() };
1207
- }
1208
-
1209
- // src/use-node-info-panel.ts
1210
1374
  function useNodeInfoPanel({
1211
1375
  selectedNodeId,
1212
1376
  onSelectedNodeChange
@@ -1215,7 +1379,7 @@ function useNodeInfoPanel({
1215
1379
  const [prevSelectedNodeId, setPrevSelectedNodeId] = useState2(selectedNodeId);
1216
1380
  if (selectedNodeId !== prevSelectedNodeId) {
1217
1381
  setPrevSelectedNodeId(selectedNodeId);
1218
- if (prevSelectedNodeId != null && selectedNodeId == null) setPanel(null);
1382
+ if (prevSelectedNodeId !== void 0) setPanel(null);
1219
1383
  }
1220
1384
  const shownNodeIdRef = useRef2(null);
1221
1385
  const onSelectedNodeChangeRef = useRef2(onSelectedNodeChange);
@@ -1225,20 +1389,32 @@ function useNodeInfoPanel({
1225
1389
  });
1226
1390
  const showNodeInfo = useCallback2(
1227
1391
  (node, element, viewport, { notify = true } = {}) => {
1392
+ if (notify && selectedNodeId !== void 0) {
1393
+ if (node.id !== selectedNodeId) onSelectedNodeChangeRef.current?.(node);
1394
+ return;
1395
+ }
1228
1396
  if (!element || !viewport) return;
1229
1397
  setPanel({ node, side: resolveInfoPanelSide() });
1230
1398
  const changed = node.id !== shownNodeIdRef.current;
1231
1399
  shownNodeIdRef.current = node.id;
1232
1400
  if (changed && notify) onSelectedNodeChangeRef.current?.(node);
1233
1401
  },
1234
- []
1402
+ [selectedNodeId]
1235
1403
  );
1236
1404
  const closeNodeInfo = useCallback2(() => {
1405
+ if (selectedNodeId !== void 0) {
1406
+ if (selectedNodeId !== null) onSelectedNodeChangeRef.current?.(null);
1407
+ return;
1408
+ }
1237
1409
  setPanel(null);
1238
1410
  shownNodeIdRef.current = null;
1239
1411
  onSelectedNodeChangeRef.current?.(null);
1240
- }, []);
1241
- return { panel, showNodeInfo, closeNodeInfo };
1412
+ }, [selectedNodeId]);
1413
+ return {
1414
+ panel: selectedNodeId !== void 0 && panel?.node.id !== selectedNodeId ? null : panel,
1415
+ showNodeInfo,
1416
+ closeNodeInfo
1417
+ };
1242
1418
  }
1243
1419
 
1244
1420
  // src/use-tree-index.ts
@@ -1284,8 +1460,8 @@ function useTreeIndex(nodes) {
1284
1460
  }
1285
1461
 
1286
1462
  // src/InfoPanel.tsx
1287
- import { useEffect as useEffect3, useRef as useRef3 } from "react";
1288
- import { MdArrowBack, MdClose, MdZoomIn as MdZoomIn2 } from "react-icons/md";
1463
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState5 } from "react";
1464
+ import { MdArrowBack, MdClose, MdZoomIn } from "react-icons/md";
1289
1465
 
1290
1466
  // src/InfoPanelExpandButton.tsx
1291
1467
  import { MdCloseFullscreen, MdOpenInFull } from "react-icons/md";
@@ -1524,6 +1700,27 @@ function InfoPanel({
1524
1700
  hideExpandButton = false
1525
1701
  }) {
1526
1702
  const panelRef = useRef3(null);
1703
+ const [sheetFull, setSheetFull] = useState5(false);
1704
+ const grabberDragRef = useRef3(null);
1705
+ const onGrabberPointerDown = (event) => {
1706
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1707
+ grabberDragRef.current = { startY: event.clientY, dragged: false };
1708
+ };
1709
+ const onGrabberPointerUp = (event) => {
1710
+ const drag = grabberDragRef.current;
1711
+ if (!drag) return;
1712
+ const dy = event.clientY - drag.startY;
1713
+ if (Math.abs(dy) < INFO_PANEL_SHEET_DRAG_THRESHOLD_PX) return;
1714
+ drag.dragged = true;
1715
+ if (dy < 0) setSheetFull(true);
1716
+ else if (sheetFull) setSheetFull(false);
1717
+ else if (!hideClose) onClose();
1718
+ };
1719
+ const onGrabberClick = () => {
1720
+ const dragged = grabberDragRef.current?.dragged ?? false;
1721
+ grabberDragRef.current = null;
1722
+ if (!dragged) setSheetFull((full) => !full);
1723
+ };
1527
1724
  useEffect3(() => {
1528
1725
  panelRef.current?.toggleAttribute("inert", collapsed);
1529
1726
  }, [collapsed]);
@@ -1626,9 +1823,21 @@ function InfoPanel({
1626
1823
  return /* @__PURE__ */ jsxs6(
1627
1824
  "div",
1628
1825
  {
1629
- 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" : ""}`,
1826
+ 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" : ""}`,
1630
1827
  ref: panelRef,
1631
1828
  children: [
1829
+ /* @__PURE__ */ jsx7(
1830
+ "button",
1831
+ {
1832
+ type: "button",
1833
+ className: "gtv-info-panel-grabber",
1834
+ "aria-label": sheetFull ? "Lower panel" : "Raise panel",
1835
+ "aria-expanded": sheetFull,
1836
+ onPointerDown: onGrabberPointerDown,
1837
+ onPointerUp: onGrabberPointerUp,
1838
+ onClick: onGrabberClick
1839
+ }
1840
+ ),
1632
1841
  /* @__PURE__ */ jsxs6(
1633
1842
  "div",
1634
1843
  {
@@ -1667,7 +1876,7 @@ function InfoPanel({
1667
1876
  onClick: onZoomIn,
1668
1877
  "aria-label": "Zoom in",
1669
1878
  title: "Zoom in",
1670
- children: /* @__PURE__ */ jsx7(MdZoomIn2, { className: "gtv-icon", size: 16 })
1879
+ children: /* @__PURE__ */ jsx7(MdZoomIn, { className: "gtv-icon", size: 16 })
1671
1880
  }
1672
1881
  ),
1673
1882
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ jsx7(InfoPanelExpandButton, { expanded, onExpandedChange, style: { color: textColor } }),
@@ -1780,6 +1989,8 @@ function GenreTree({
1780
1989
  hideInfoPanelClose = false,
1781
1990
  hideInfoPanelTitle = false,
1782
1991
  itemCountLabel,
1992
+ itemCountScaleMax,
1993
+ nodeCountForSizing,
1783
1994
  readOnly,
1784
1995
  renderDangerZone,
1785
1996
  expanded,
@@ -1789,8 +2000,10 @@ function GenreTree({
1789
2000
  overlayPanel,
1790
2001
  wheelZoom,
1791
2002
  defaultViewport,
2003
+ viewportRestore,
1792
2004
  onViewportChange,
1793
2005
  controlsOrientation = "vertical",
2006
+ showZoomControls = "auto",
1794
2007
  floatingControlsLeading,
1795
2008
  busy
1796
2009
  }) {
@@ -1798,8 +2011,13 @@ function GenreTree({
1798
2011
  const svgRef = useRef5(null);
1799
2012
  const viewportRef = useRef5(null);
1800
2013
  const stageRef = useRef5(null);
1801
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2014
+ const viewportFrame = useMemo2(() => JSON.stringify([orientation, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, orientation]);
2015
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId: selectedNodeIdProp, onViewportChange, frameKey: viewportFrame });
1802
2016
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId: selectedNodeIdProp, onSelectedNodeChange });
2017
+ const itemCountRange = useMemo2(
2018
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: nodeCountForSizing ?? nodes.length }),
2019
+ [nodes, itemCountScaleMax, nodeCountForSizing]
2020
+ );
1803
2021
  const showNodeInfoRef = useRef5(showNodeInfo);
1804
2022
  useEffect5(() => {
1805
2023
  showNodeInfoRef.current = showNodeInfo;
@@ -1814,7 +2032,8 @@ function GenreTree({
1814
2032
  d3,
1815
2033
  root,
1816
2034
  orientation,
1817
- depthSpacingScale
2035
+ depthSpacingScale,
2036
+ itemCountRange
1818
2037
  );
1819
2038
  const {
1820
2039
  svgWidth: width,
@@ -1826,7 +2045,8 @@ function GenreTree({
1826
2045
  originalTreeData,
1827
2046
  orientation,
1828
2047
  hideRoot,
1829
- depthSpacingScale
2048
+ depthSpacingScale,
2049
+ itemCountRange
1830
2050
  );
1831
2051
  const reshapedTreeData = setupTreeLayout(
1832
2052
  d3,
@@ -1835,7 +2055,8 @@ function GenreTree({
1835
2055
  orientation,
1836
2056
  rootDepthOffset,
1837
2057
  width,
1838
- depthSpacingScale
2058
+ depthSpacingScale,
2059
+ itemCountRange
1839
2060
  );
1840
2061
  return {
1841
2062
  treeData: reshapedTreeData,
@@ -1843,7 +2064,7 @@ function GenreTree({
1843
2064
  svgWidth: width,
1844
2065
  svgHeight: height
1845
2066
  };
1846
- }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale]);
2067
+ }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale, itemCountRange]);
1847
2068
  const reparentForbiddenIds = useMemo2(() => {
1848
2069
  if (!reparentingNodeId) return [];
1849
2070
  const reparentingHierarchyNode = treeData.descendants().find((d) => d.data.id === reparentingNodeId);
@@ -1894,7 +2115,8 @@ function GenreTree({
1894
2115
  },
1895
2116
  orientation,
1896
2117
  hideRoot,
1897
- effectiveSelectedNodeId
2118
+ effectiveSelectedNodeId,
2119
+ itemCountRange
1898
2120
  );
1899
2121
  }, [
1900
2122
  treeData,
@@ -1915,7 +2137,8 @@ function GenreTree({
1915
2137
  additionalActions,
1916
2138
  orientation,
1917
2139
  hideRoot,
1918
- effectiveSelectedNodeId
2140
+ effectiveSelectedNodeId,
2141
+ itemCountRange
1919
2142
  ]);
1920
2143
  useEffect5(() => {
1921
2144
  if (!interactive || !selectedNodeIdProp) return;
@@ -1928,7 +2151,8 @@ function GenreTree({
1928
2151
  centerOnElementRef.current(
1929
2152
  element,
1930
2153
  ZOOM_FOCUS_SCALE,
1931
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2154
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2155
+ true
1932
2156
  );
1933
2157
  showNodeInfoRef.current(targetNode, element, viewportRef.current, { notify: false });
1934
2158
  }, [interactive, selectedNodeIdProp, index, panel]);
@@ -1940,12 +2164,10 @@ function GenreTree({
1940
2164
  }
1941
2165
  };
1942
2166
  }, []);
1943
- const hasInitialFitRef = useRef5(false);
1944
2167
  useEffect5(() => {
1945
- if (hasInitialFitRef.current || !interactive) return;
2168
+ if (!interactive) return;
1946
2169
  const elements = queryTreeContentElements(svgRef.current);
1947
2170
  if (elements.length === 0) return;
1948
- hasInitialFitRef.current = true;
1949
2171
  panZoom.fitToFrame(elements, { silent: true });
1950
2172
  });
1951
2173
  const svg = /* @__PURE__ */ jsx9(
@@ -2076,6 +2298,7 @@ function GenreTree({
2076
2298
  {
2077
2299
  panZoom,
2078
2300
  orientation: controlsOrientation,
2301
+ zoomControls: showZoomControls,
2079
2302
  leading: floatingControlsLeading,
2080
2303
  busy,
2081
2304
  onFit: () => panZoom.fitToFrame(queryTreeContentElements(svgRef.current))
@@ -2087,7 +2310,7 @@ function GenreTree({
2087
2310
  }
2088
2311
 
2089
2312
  // src/GenreTreeWheelBase.tsx
2090
- import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef6, useState as useState5 } from "react";
2313
+ import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef6, useState as useState6 } from "react";
2091
2314
  import * as d32 from "d3";
2092
2315
 
2093
2316
  // src/root-grouping.ts
@@ -2188,6 +2411,8 @@ function WheelCore({
2188
2411
  hideInfoPanelClose = false,
2189
2412
  hideInfoPanelTitle = false,
2190
2413
  itemCountLabel,
2414
+ itemCountScaleMax,
2415
+ nodeCountForSizing,
2191
2416
  readOnly,
2192
2417
  renderDangerZone,
2193
2418
  expanded,
@@ -2197,12 +2422,19 @@ function WheelCore({
2197
2422
  overlayPanel,
2198
2423
  wheelZoom,
2199
2424
  defaultViewport,
2425
+ viewportRestore,
2200
2426
  onViewportChange,
2201
2427
  controlsOrientation = "vertical",
2428
+ showZoomControls = "auto",
2202
2429
  floatingControlsLeading,
2203
2430
  busy,
2204
2431
  selectedNodeId
2205
2432
  }) {
2433
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
2434
+ const wheelSizeRange = useMemo3(
2435
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
2436
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
2437
+ );
2206
2438
  const treeOrientation = direction === "left" ? "horizontal-anchored" : "vertical";
2207
2439
  const landingAngle = direction === "left" ? 90 : 0;
2208
2440
  const chipHalfExtentVar = direction === "left" ? "--gtv-wheel-chip-half-width" : "--gtv-wheel-chip-half-height";
@@ -2212,18 +2444,18 @@ function WheelCore({
2212
2444
  return getGenreTreeColor(nodes.find((n) => n.id === rootId)?.wikidataId);
2213
2445
  };
2214
2446
  const wheelRadius = useMemo3(
2215
- () => calculateWheelRadius(groups.length, MAX_NODE_WIDTH, WHEEL_RADIUS),
2216
- [groups.length]
2447
+ () => calculateWheelRadius(groups.length, wheelSizeRange.maxNodeWidth ?? MAX_NODE_WIDTH, WHEEL_RADIUS),
2448
+ [groups.length, wheelSizeRange.maxNodeWidth]
2217
2449
  );
2218
- const wheelViewportHeight = wheelRadius * 2 + MAX_NODE_HEIGHT / 2;
2450
+ const wheelViewportHeight = wheelRadius * 2 + (wheelSizeRange.maxNodeHeight ?? MAX_NODE_HEIGHT) / 2;
2219
2451
  const rootItemCountById = useMemo3(
2220
2452
  () => new Map(groups.map((group) => [group.root.id, group.root.itemCount])),
2221
2453
  [groups]
2222
2454
  );
2223
- const [selectedRootId, setSelectedRootId] = useState5(
2455
+ const [selectedRootId, setSelectedRootId] = useState6(
2224
2456
  groups[0]?.root.id ?? null
2225
2457
  );
2226
- const [rotationDeg, setRotationDeg] = useState5(
2458
+ const [rotationDeg, setRotationDeg] = useState6(
2227
2459
  () => groups.length > 0 ? computeRotationForSelection(
2228
2460
  0,
2229
2461
  getChipAngle(0, groups.length),
@@ -2232,7 +2464,8 @@ function WheelCore({
2232
2464
  );
2233
2465
  const viewportRef = useRef6(null);
2234
2466
  const stageRef = useRef6(null);
2235
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2467
+ const viewportFrame = useMemo3(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
2468
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
2236
2469
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
2237
2470
  const wheelCircleRef = useRef6(null);
2238
2471
  const treeAnchorRef = useRef6(null);
@@ -2246,15 +2479,12 @@ function WheelCore({
2246
2479
  useEffect6(() => {
2247
2480
  if (effectiveRootId) onRootSelectRef.current?.(effectiveRootId);
2248
2481
  }, [effectiveRootId]);
2249
- const hasInitialFitRef = useRef6(false);
2250
2482
  useEffect6(() => {
2251
- if (hasInitialFitRef.current) return;
2252
2483
  const elements = [
2253
2484
  wheelCircleRef.current,
2254
2485
  ...queryTreeContentElements(treeAnchorRef.current)
2255
2486
  ];
2256
2487
  if (!elements.some(Boolean)) return;
2257
- hasInitialFitRef.current = true;
2258
2488
  panZoom.fitToFrame(elements, { silent: true });
2259
2489
  });
2260
2490
  const selectedGroup = groups.find((group) => group.root.id === effectiveRootId) ?? null;
@@ -2262,16 +2492,18 @@ function WheelCore({
2262
2492
  () => getItemCountRange(
2263
2493
  groups.map((group) => ({
2264
2494
  itemCount: rootItemCountById.get(group.root.id)
2265
- }))
2495
+ })),
2496
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2266
2497
  ),
2267
- [groups, rootItemCountById]
2498
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
2268
2499
  );
2269
2500
  const rootChipHalfExtent = selectedGroup ? calculateRootAnchorClearance(
2270
2501
  d32,
2271
2502
  selectedGroup.nodes,
2272
2503
  rootItemCountById.get(selectedGroup.root.id),
2273
2504
  rootItemCountRange,
2274
- direction === "left" ? "WIDTH" : "HEIGHT"
2505
+ direction === "left" ? "WIDTH" : "HEIGHT",
2506
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2275
2507
  ) : 0;
2276
2508
  const sectorFillGradient = useMemo3(
2277
2509
  () => buildWheelSectorGradient(
@@ -2289,7 +2521,7 @@ function WheelCore({
2289
2521
  );
2290
2522
  }
2291
2523
  };
2292
- const [appliedSelectedNodeId, setAppliedSelectedNodeId] = useState5(selectedNodeId);
2524
+ const [appliedSelectedNodeId, setAppliedSelectedNodeId] = useState6(selectedNodeId);
2293
2525
  if (selectedNodeId !== appliedSelectedNodeId) {
2294
2526
  setAppliedSelectedNodeId(selectedNodeId);
2295
2527
  const owningGroupIndex = selectedNodeId ? groups.findIndex(
@@ -2312,7 +2544,8 @@ function WheelCore({
2312
2544
  panZoom.centerOnElement(
2313
2545
  element,
2314
2546
  ZOOM_FOCUS_SCALE,
2315
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2547
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2548
+ true
2316
2549
  );
2317
2550
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
2318
2551
  }, [selectedNodeId, effectiveRootId, nodes, panel, showNodeInfo]);
@@ -2361,6 +2594,8 @@ function WheelCore({
2361
2594
  {
2362
2595
  nodes: selectedGroup.nodes,
2363
2596
  itemCountLabel,
2597
+ itemCountScaleMax,
2598
+ nodeCountForSizing: fullNodeCountForSizing,
2364
2599
  orientation: treeOrientation,
2365
2600
  hideRoot: true,
2366
2601
  interactive: false,
@@ -2572,6 +2807,7 @@ function WheelCore({
2572
2807
  {
2573
2808
  panZoom,
2574
2809
  orientation: controlsOrientation,
2810
+ zoomControls: showZoomControls,
2575
2811
  leading: floatingControlsLeading,
2576
2812
  busy,
2577
2813
  onFit: () => panZoom.fitToFrame([
@@ -2598,24 +2834,38 @@ function GenreTreeWheelRight(props) {
2598
2834
  }
2599
2835
 
2600
2836
  // src/GenreTreeWheelRadialBase.tsx
2601
- import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef7, useState as useState6 } from "react";
2837
+ import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef7, useState as useState7 } from "react";
2602
2838
  import * as d33 from "d3";
2603
2839
 
2604
2840
  // src/defer-click.ts
2605
2841
  function createDeferredClick() {
2606
2842
  let timeoutId = null;
2843
+ let pendingKey = void 0;
2844
+ let doubledAt = -Infinity;
2607
2845
  const cancel = () => {
2608
2846
  if (timeoutId !== null) clearTimeout(timeoutId);
2609
2847
  timeoutId = null;
2610
2848
  };
2611
- const schedule = (action) => {
2849
+ const schedule = (action, onDouble, key) => {
2850
+ if (onDouble && timeoutId !== null && key === pendingKey) {
2851
+ cancel();
2852
+ doubledAt = Date.now();
2853
+ onDouble();
2854
+ return;
2855
+ }
2612
2856
  cancel();
2857
+ pendingKey = key;
2613
2858
  timeoutId = setTimeout(() => {
2614
2859
  timeoutId = null;
2615
2860
  action();
2616
2861
  }, DOUBLE_CLICK_DELAY_MS);
2617
2862
  };
2618
- return { schedule, cancel };
2863
+ const doubleClick = (onDouble) => {
2864
+ cancel();
2865
+ if (Date.now() - doubledAt < DOUBLE_CLICK_DELAY_MS) return;
2866
+ onDouble();
2867
+ };
2868
+ return { schedule, cancel, doubleClick };
2619
2869
  }
2620
2870
 
2621
2871
  // src/radial-wheel-geometry.ts
@@ -2805,13 +3055,15 @@ function calculatePopSubtreeRadialExtent(hierarchy, coreRootCircleRadius = 0) {
2805
3055
  POP_TREE_DEPTH_RADIAL_SPACING
2806
3056
  ) + MAX_NODE_WIDTH / 2 + POP_SUBTREE_OUTER_MARGIN;
2807
3057
  }
2808
- function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING) {
3058
+ function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
2809
3059
  return layoutWedge(
2810
3060
  d3Lib,
2811
3061
  hierarchy,
2812
3062
  wedgeCenterAngleDegrees,
2813
3063
  wedgeSpanDegrees,
2814
- popRadiusOfDepth(coreRootCircleRadius, depthSpacing)
3064
+ popRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3065
+ "center",
3066
+ dotRadius
2815
3067
  );
2816
3068
  }
2817
3069
  var MIN_DOT_GAP = 5.6;
@@ -2821,15 +3073,15 @@ function popRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2821
3073
  function coreRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2822
3074
  return (depth) => getRadialDepthRadius(depth + 1, coreRootCircleRadius, depthSpacing);
2823
3075
  }
2824
- function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2825
- const dotRadius = () => NODE_LAYOUT_SLOT_RADIUS;
3076
+ function measureWedge(_d3Lib, hierarchy, radiusOfDepth, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3077
+ const slotRadius = () => dotRadius;
2826
3078
  const offsetFromParent = /* @__PURE__ */ new Map();
2827
3079
  const shiftLevel = (level, by) => ({
2828
3080
  left: { ...level.left, x: level.left.x + by },
2829
3081
  right: { ...level.right, x: level.right.x + by }
2830
3082
  });
2831
3083
  const pack = (node) => {
2832
- const own = { x: 0, dotRadius: dotRadius() };
3084
+ const own = { x: 0, dotRadius: slotRadius() };
2833
3085
  if (!node.children) return [{ left: own, right: own }];
2834
3086
  const below = [];
2835
3087
  const childOffsets = [];
@@ -2861,7 +3113,7 @@ function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2861
3113
  let min = Infinity;
2862
3114
  let max = -Infinity;
2863
3115
  hierarchy.each((d) => {
2864
- const inset = (dotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
3116
+ const inset = (slotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
2865
3117
  min = Math.min(min, d.x - inset);
2866
3118
  max = Math.max(max, d.x + inset);
2867
3119
  });
@@ -2881,8 +3133,8 @@ function splitAtBranching(hierarchy) {
2881
3133
  const children = branching.children;
2882
3134
  return children && children.length > 1 ? { chain, branching, children } : null;
2883
3135
  }
2884
- function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center") {
2885
- const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth);
3136
+ function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center", dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3137
+ const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth, dotRadius);
2886
3138
  const wedgeSpanRad = wedgeSpanDegrees * Math.PI / 180;
2887
3139
  const scale = Math.min(MAX_WEDGE_STRETCH, wedgeSpanRad / neededRad);
2888
3140
  const wedgeCenterRad = wedgeCenterAngleDegrees * Math.PI / 180;
@@ -3111,12 +3363,12 @@ function renderPopSubtree(d3Lib, svg, hierarchy, rootColor, reparentingNodeId, r
3111
3363
  group.on("click", (event) => {
3112
3364
  if (reparentingNodeId) return;
3113
3365
  const element = event.currentTarget;
3114
- deferredClick.schedule(() => onNodeClick?.(d.data, event, element));
3115
- });
3116
- group.on("dblclick", () => {
3117
- deferredClick.cancel();
3118
- onNodeDoubleClick?.(d.data);
3366
+ deferredClick.schedule(
3367
+ () => onNodeClick?.(d.data, event, element),
3368
+ () => onNodeDoubleClick?.(d.data)
3369
+ );
3119
3370
  });
3371
+ group.on("dblclick", () => deferredClick.doubleClick(() => onNodeDoubleClick?.(d.data)));
3120
3372
  });
3121
3373
  }
3122
3374
  function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
@@ -3193,14 +3445,15 @@ function buildCoreHierarchy(d3Lib, coreNodes) {
3193
3445
  });
3194
3446
  return hierarchy;
3195
3447
  }
3196
- function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing) {
3448
+ function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3197
3449
  return layoutWedge(
3198
3450
  d3Lib,
3199
3451
  hierarchy,
3200
3452
  wedgeCenterAngleDegrees,
3201
3453
  wedgeSpanDegrees,
3202
3454
  coreRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3203
- "between-children"
3455
+ "between-children",
3456
+ dotRadius
3204
3457
  );
3205
3458
  }
3206
3459
  function calculateCoreSubtreeRadialExtent(hierarchy, depthSpacing, coreRootCircleRadius = 0) {
@@ -3274,6 +3527,8 @@ function WheelRadialCore({
3274
3527
  hideInfoPanelClose = false,
3275
3528
  hideInfoPanelTitle = false,
3276
3529
  itemCountLabel,
3530
+ itemCountScaleMax,
3531
+ nodeCountForSizing,
3277
3532
  readOnly,
3278
3533
  renderDangerZone,
3279
3534
  expanded,
@@ -3283,21 +3538,25 @@ function WheelRadialCore({
3283
3538
  overlayPanel,
3284
3539
  wheelZoom,
3285
3540
  defaultViewport,
3541
+ viewportRestore,
3286
3542
  onViewportChange,
3287
3543
  controlsOrientation = "vertical",
3544
+ showZoomControls = "auto",
3288
3545
  floatingControlsLeading,
3289
3546
  busy,
3290
3547
  selectedNodeId
3291
3548
  }) {
3549
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3292
3550
  const index = useTreeIndex(nodes);
3293
3551
  const groups = useMemo4(() => groupNodesByRoot(nodes), [nodes]);
3294
3552
  const rootColorOf = (node) => getGenreTreeColor(index.byId.get(index.rootIdById.get(node.id) ?? node.id)?.wikidataId);
3295
- const [topRootId, setTopRootId] = useState6(
3553
+ const [topRootId, setTopRootId] = useState7(
3296
3554
  groups[0]?.root.id ?? null
3297
3555
  );
3298
3556
  const viewportRef = useRef7(null);
3299
3557
  const stageRef = useRef7(null);
3300
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
3558
+ const viewportFrame = useMemo4(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
3559
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
3301
3560
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
3302
3561
  const centerOnElementRef = useRef7(panZoom.centerOnElement);
3303
3562
  useEffect7(() => {
@@ -3322,7 +3581,7 @@ function WheelRadialCore({
3322
3581
  () => computeRadialLayout(rootWeights, topIndex, LANDING_ANGLE),
3323
3582
  [rootWeights, topIndex]
3324
3583
  );
3325
- const [angleMemo, setAngleMemo] = useState6(() => ({
3584
+ const [angleMemo, setAngleMemo] = useState7(() => ({
3326
3585
  layout,
3327
3586
  angles: computeContinuousAngles(groups, layout, /* @__PURE__ */ new Map())
3328
3587
  }));
@@ -3341,11 +3600,15 @@ function WheelRadialCore({
3341
3600
  () => getItemCountRange(
3342
3601
  groups.map((group) => ({
3343
3602
  itemCount: rootItemCountById.get(group.root.id)
3344
- }))
3603
+ })),
3604
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
3345
3605
  ),
3346
- [groups, rootItemCountById]
3606
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
3607
+ );
3608
+ const wheelItemCountRange = useMemo4(
3609
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
3610
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
3347
3611
  );
3348
- const wheelItemCountRange = useMemo4(() => getItemCountRange(nodes), [nodes]);
3349
3612
  const sectorSpanByRootId = useMemo4(() => {
3350
3613
  const map = /* @__PURE__ */ new Map();
3351
3614
  if (groups.length <= 1) return map;
@@ -3395,10 +3658,10 @@ function WheelRadialCore({
3395
3658
  const chipClearanceFloor = useMemo4(
3396
3659
  () => calculateWheelRadiusForAngles(
3397
3660
  layout.map((slot) => slot.angle),
3398
- MAX_NODE_WIDTH,
3661
+ wheelItemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
3399
3662
  WHEEL_RADIUS
3400
3663
  ),
3401
- [layout]
3664
+ [layout, wheelItemCountRange.maxNodeWidth]
3402
3665
  );
3403
3666
  const maxCoreExtentDelta = useMemo4(() => {
3404
3667
  let extent = 0;
@@ -3512,12 +3775,9 @@ function WheelRadialCore({
3512
3775
  wheelItemCountRange,
3513
3776
  panel?.node.id
3514
3777
  ]);
3515
- const hasInitialFitRef = useRef7(false);
3516
3778
  useEffect7(() => {
3517
- if (hasInitialFitRef.current) return;
3518
3779
  const elements = [wheelCircleRef.current, coreSvgRef.current];
3519
3780
  if (!elements.some(Boolean)) return;
3520
- hasInitialFitRef.current = true;
3521
3781
  panZoom.fitToFrame(elements, { silent: true });
3522
3782
  });
3523
3783
  const handleChipClick = (rootId) => {
@@ -3533,7 +3793,8 @@ function WheelRadialCore({
3533
3793
  panZoom.centerOnElement(
3534
3794
  element,
3535
3795
  ZOOM_FOCUS_SCALE,
3536
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
3796
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
3797
+ true
3537
3798
  );
3538
3799
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
3539
3800
  }, [selectedNodeId, index, panel, showNodeInfo]);
@@ -3768,6 +4029,7 @@ function WheelRadialCore({
3768
4029
  {
3769
4030
  panZoom,
3770
4031
  orientation: controlsOrientation,
4032
+ zoomControls: showZoomControls,
3771
4033
  leading: floatingControlsLeading,
3772
4034
  busy,
3773
4035
  onFit: () => panZoom.fitToFrame([wheelCircleRef.current, coreSvgRef.current])
@@ -3785,13 +4047,13 @@ function GenreTreeWheelRadial(props) {
3785
4047
  }
3786
4048
 
3787
4049
  // src/GenreTreeWheelRadialPopCoreBase.tsx
3788
- import { useCallback as useCallback4, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8, useState as useState9 } from "react";
4050
+ import { useCallback as useCallback4, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8, useState as useState10 } from "react";
3789
4051
  import * as d34 from "d3";
3790
4052
 
3791
4053
  // src/use-focus-history.ts
3792
- import { useState as useState7 } from "react";
4054
+ import { useState as useState8 } from "react";
3793
4055
  function useFocusHistory(focusNodeId, isFocusable) {
3794
- const [state, setState] = useState7({ seen: focusNodeId, stack: [], pending: void 0 });
4056
+ const [state, setState] = useState8({ seen: focusNodeId, stack: [], pending: void 0 });
3795
4057
  if (state.seen !== focusNodeId) {
3796
4058
  const popTo = state.pending === focusNodeId ? state.stack.lastIndexOf(focusNodeId) : -1;
3797
4059
  setState({
@@ -3813,13 +4075,13 @@ function useFocusHistory(focusNodeId, isFocusable) {
3813
4075
  }
3814
4076
 
3815
4077
  // src/WheelBreadcrumb.tsx
3816
- import { Fragment as Fragment3, useState as useState8 } from "react";
4078
+ import { Fragment as Fragment3, useState as useState9 } from "react";
3817
4079
  import { MdHome } from "react-icons/md";
3818
4080
  import { Fragment as Fragment4, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
3819
4081
  var MAX_VISIBLE_ANCESTORS = 3;
3820
4082
  var ANCESTORS_KEPT_WHEN_COLLAPSED = 2;
3821
4083
  function WheelBreadcrumb({ ancestors, currentName, onHome, onAncestor }) {
3822
- const [expanded, setExpanded] = useState8(false);
4084
+ const [expanded, setExpanded] = useState9(false);
3823
4085
  const collapsed = !expanded && ancestors.length > MAX_VISIBLE_ANCESTORS;
3824
4086
  const visibleAncestors = collapsed ? ancestors.slice(-ANCESTORS_KEPT_WHEN_COLLAPSED) : ancestors;
3825
4087
  const separator = /* @__PURE__ */ jsx15("span", { "aria-hidden": "true", children: "\u203A" });
@@ -3951,6 +4213,8 @@ function WheelRadialPopCoreCore({
3951
4213
  hideInfoPanelClose = false,
3952
4214
  hideInfoPanelTitle = false,
3953
4215
  itemCountLabel,
4216
+ itemCountScaleMax,
4217
+ nodeCountForSizing,
3954
4218
  readOnly,
3955
4219
  renderDangerZone,
3956
4220
  expanded,
@@ -3960,8 +4224,10 @@ function WheelRadialPopCoreCore({
3960
4224
  overlayPanel,
3961
4225
  wheelZoom,
3962
4226
  defaultViewport,
4227
+ viewportRestore,
3963
4228
  onViewportChange,
3964
4229
  controlsOrientation = "vertical",
4230
+ showZoomControls = "auto",
3965
4231
  floatingControlsLeading,
3966
4232
  busy,
3967
4233
  allowWheelRotation = true,
@@ -3969,6 +4235,7 @@ function WheelRadialPopCoreCore({
3969
4235
  focusNodeId: requestedFocusNodeId = null,
3970
4236
  onFocusChange
3971
4237
  }) {
4238
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3972
4239
  const index = useTreeIndex(nodes);
3973
4240
  const focusNodeId = useMemo5(
3974
4241
  () => requestedFocusNodeId !== null && isZoomableNode(nodes, requestedFocusNodeId) ? requestedFocusNodeId : null,
@@ -4022,13 +4289,13 @@ function WheelRadialPopCoreCore({
4022
4289
  (node) => getGenreNodeStyle(index, node.id),
4023
4290
  [index]
4024
4291
  );
4025
- const [topRootId, setTopRootId] = useState9(
4292
+ const [topRootId, setTopRootId] = useState10(
4026
4293
  groups[0]?.root.id ?? null
4027
4294
  );
4028
- const [rotationTopRootId, setRotationTopRootId] = useState9(
4295
+ const [rotationTopRootId, setRotationTopRootId] = useState10(
4029
4296
  groups[0]?.root.id ?? null
4030
4297
  );
4031
- const [renderedFocusNodeId, setRenderedFocusNodeId] = useState9(focusNodeId);
4298
+ const [renderedFocusNodeId, setRenderedFocusNodeId] = useState10(focusNodeId);
4032
4299
  if (renderedFocusNodeId !== focusNodeId) {
4033
4300
  setRenderedFocusNodeId(focusNodeId);
4034
4301
  setTopRootId(groups[0]?.root.id ?? null);
@@ -4037,7 +4304,8 @@ function WheelRadialPopCoreCore({
4037
4304
  const viewportRef = useRef8(null);
4038
4305
  const stageRef = useRef8(null);
4039
4306
  const dotRadiusMemoryRef = useRef8(/* @__PURE__ */ new Map());
4040
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
4307
+ const viewportFrame = useMemo5(() => JSON.stringify([focusNodeId, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, focusNodeId]);
4308
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
4041
4309
  const { panel: openPanel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
4042
4310
  const isInFrame = (id) => id === focusNodeId || centerSubtreeNodeIds.has(id) || ringRootById.has(id);
4043
4311
  const panel = openPanel && isInFrame(openPanel.node.id) ? openPanel : null;
@@ -4091,7 +4359,7 @@ function WheelRadialPopCoreCore({
4091
4359
  const popSvgRef = useRef8(null);
4092
4360
  const centerChipRef = useRef8(null);
4093
4361
  const regionLabelRef = useRef8(null);
4094
- const [regionLabel, setRegionLabel] = useState9(null);
4362
+ const [regionLabel, setRegionLabel] = useState10(null);
4095
4363
  const effectiveTopRootId = groups.some((group) => group.root.id === topRootId) ? topRootId : groups[0]?.root.id ?? null;
4096
4364
  const effectiveRotationTopRootId = groups.some(
4097
4365
  (group) => group.root.id === rotationTopRootId
@@ -4108,8 +4376,8 @@ function WheelRadialPopCoreCore({
4108
4376
  () => getItemCountRange([
4109
4377
  ...nodes,
4110
4378
  ...[...rootItemCountById.values()].map((itemCount) => ({ itemCount }))
4111
- ]),
4112
- [nodes, rootItemCountById]
4379
+ ], { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
4380
+ [nodes, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
4113
4381
  );
4114
4382
  const isCenterMainstream = isMainstreamPopLineage(index, centerNode.id);
4115
4383
  const centerNodeColor = isCenterMainstream ? "#ffffff" : getGenreNodeStyle(index, centerNode.id).fill;
@@ -4143,8 +4411,8 @@ function WheelRadialPopCoreCore({
4143
4411
  centerSubtreeHierarchy,
4144
4412
  0,
4145
4413
  POP_TREE_DEPTH_RADIAL_SPACING
4146
- ) : NODE_LAYOUT_SLOT_RADIUS,
4147
- [centerSubtreeHierarchy]
4414
+ ) : wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS,
4415
+ [centerSubtreeHierarchy, wheelItemCountRange.maxDotRadius]
4148
4416
  );
4149
4417
  const maxCoreExtentDelta = useMemo5(() => {
4150
4418
  let extent = 0;
@@ -4166,7 +4434,8 @@ function WheelRadialPopCoreCore({
4166
4434
  (radius) => new Map(
4167
4435
  groups.map((group) => {
4168
4436
  const { pop, core } = branchHierarchiesByRootId.get(group.root.id);
4169
- const chipWidth = 2 * NODE_LAYOUT_SLOT_RADIUS;
4437
+ const dotRadius = wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS;
4438
+ const chipWidth = 2 * dotRadius;
4170
4439
  return [
4171
4440
  group.root.id,
4172
4441
  {
@@ -4174,14 +4443,16 @@ function WheelRadialPopCoreCore({
4174
4443
  (hierarchy) => measureWedge(
4175
4444
  d34,
4176
4445
  hierarchy,
4177
- popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4446
+ popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4447
+ dotRadius
4178
4448
  )
4179
4449
  ),
4180
4450
  core: core.map(
4181
4451
  (hierarchy) => measureWedge(
4182
4452
  d34,
4183
4453
  hierarchy,
4184
- coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4454
+ coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4455
+ dotRadius
4185
4456
  )
4186
4457
  ),
4187
4458
  chip: 2 * Math.asin(Math.min(1, (chipWidth / 2 + CHIP_GAP) / radius))
@@ -4189,7 +4460,7 @@ function WheelRadialPopCoreCore({
4189
4460
  ];
4190
4461
  })
4191
4462
  ),
4192
- [groups, branchHierarchiesByRootId]
4463
+ [groups, branchHierarchiesByRootId, wheelItemCountRange.maxDotRadius]
4193
4464
  );
4194
4465
  const coreRootCircleRadius = useMemo5(
4195
4466
  () => solveRingRadius(
@@ -4199,11 +4470,11 @@ function WheelRadialPopCoreCore({
4199
4470
  ),
4200
4471
  Math.max(
4201
4472
  WHEEL_POP_CORE_RADIUS,
4202
- mainstreamCircleRadius + NODE_DOT_MAX_RADIUS + POP_SUBTREE_OUTER_MARGIN,
4473
+ mainstreamCircleRadius + (wheelItemCountRange.maxDotRadius ?? NODE_DOT_MAX_RADIUS) + POP_SUBTREE_OUTER_MARGIN,
4203
4474
  popReachRequiredRadius
4204
4475
  )
4205
4476
  ),
4206
- [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius]
4477
+ [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius, wheelItemCountRange.maxDotRadius]
4207
4478
  );
4208
4479
  const branchNeedsByRootId = useMemo5(
4209
4480
  () => measureBranchNeedsAt(coreRootCircleRadius),
@@ -4220,7 +4491,7 @@ function WheelRadialPopCoreCore({
4220
4491
  () => computeRadialLayout(rootWeights, topIndex, LANDING_ANGLE2),
4221
4492
  [rootWeights, topIndex]
4222
4493
  );
4223
- const [angleMemo, setAngleMemo] = useState9(() => ({
4494
+ const [angleMemo, setAngleMemo] = useState10(() => ({
4224
4495
  layout,
4225
4496
  angles: computeContinuousAngles2(groups, layout, /* @__PURE__ */ new Map())
4226
4497
  }));
@@ -4307,7 +4578,8 @@ function WheelRadialPopCoreCore({
4307
4578
  angle,
4308
4579
  coreRootCircleRadius,
4309
4580
  span,
4310
- POP_TREE_DEPTH_RADIAL_SPACING
4581
+ POP_TREE_DEPTH_RADIAL_SPACING,
4582
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4311
4583
  );
4312
4584
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4313
4585
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-pop-branch").attr("data-gtv-branch-index", branchIndex);
@@ -4373,7 +4645,8 @@ function WheelRadialPopCoreCore({
4373
4645
  angle,
4374
4646
  span,
4375
4647
  coreRootCircleRadius,
4376
- POP_TREE_DEPTH_RADIAL_SPACING
4648
+ POP_TREE_DEPTH_RADIAL_SPACING,
4649
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4377
4650
  );
4378
4651
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4379
4652
  const angleOf = (d) => {
@@ -4537,7 +4810,7 @@ function WheelRadialPopCoreCore({
4537
4810
  originGroup.selectAll("g.gtv-root-label").select("text").style("pointer-events", "all").style("cursor", "pointer").on("click", (_event, d) => {
4538
4811
  const chip = d.id === centerNode.id ? centerChipRef.current : viewportRef.current?.querySelector(`#group-${CSS.escape(d.id)}`);
4539
4812
  chip?.click();
4540
- }).on("dblclick", (_event, d) => focusOnNodeRef.current(d.id));
4813
+ }).on("dblclick", (_event, d) => chipClickRef.current.doubleClick(() => focusOnNodeRef.current(d.id)));
4541
4814
  }, [
4542
4815
  centerNode,
4543
4816
  centerHasPopDisc,
@@ -4636,18 +4909,12 @@ function WheelRadialPopCoreCore({
4636
4909
  index,
4637
4910
  regionLabel
4638
4911
  ]);
4639
- const hasInitialFitRef = useRef8(false);
4640
4912
  useEffect8(() => {
4641
- hasInitialFitRef.current = false;
4642
- }, [focusNodeId]);
4643
- useEffect8(() => {
4644
- if (hasInitialFitRef.current) return;
4645
4913
  const elements = [
4646
4914
  wheelCircleRef.current,
4647
4915
  ...queryTreeContentElements(popSvgRef.current)
4648
4916
  ];
4649
4917
  if (!elements.some(Boolean)) return;
4650
- hasInitialFitRef.current = true;
4651
4918
  panZoom.fitToFrame(elements, { silent: true });
4652
4919
  });
4653
4920
  const handleChipClick = (rootId) => {
@@ -4669,7 +4936,8 @@ function WheelRadialPopCoreCore({
4669
4936
  panZoom.centerOnElement(
4670
4937
  element,
4671
4938
  ZOOM_FOCUS_SCALE,
4672
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
4939
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
4940
+ true
4673
4941
  );
4674
4942
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
4675
4943
  }, [selectedNodeId, index, openPanel, showNodeInfo, centerSubtreeNodeIds, ringRootById]);
@@ -4696,7 +4964,7 @@ function WheelRadialPopCoreCore({
4696
4964
  )
4697
4965
  };
4698
4966
  });
4699
- }, [groups, continuousAngleByRootId, sectorSpanByRootId]);
4967
+ }, [groups, continuousAngleByRootId, sectorSpanByRootId, index]);
4700
4968
  return /* @__PURE__ */ jsxs12(
4701
4969
  "div",
4702
4970
  {
@@ -4806,9 +5074,9 @@ function WheelRadialPopCoreCore({
4806
5074
  showNodeInfo(centerNode, chip, viewportRef.current);
4807
5075
  onNodeClick?.(centerNode, nativeEvent);
4808
5076
  }
4809
- });
5077
+ }, () => focusOnNode(centerNode.id), centerNode.id);
4810
5078
  },
4811
- onDoubleClick: () => focusOnNode(centerNode.id),
5079
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(centerNode.id)),
4812
5080
  "aria-label": centerNode.name
4813
5081
  }
4814
5082
  ) }) }),
@@ -4868,9 +5136,9 @@ function WheelRadialPopCoreCore({
4868
5136
  showNodeInfo(root, chip, viewportRef.current);
4869
5137
  onNodeClick?.(root, nativeEvent);
4870
5138
  }
4871
- });
5139
+ }, () => focusOnNode(group.root.id), group.root.id);
4872
5140
  },
4873
- onDoubleClick: () => focusOnNode(group.root.id)
5141
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(group.root.id))
4874
5142
  }
4875
5143
  ) })
4876
5144
  },
@@ -4965,6 +5233,7 @@ function WheelRadialPopCoreCore({
4965
5233
  {
4966
5234
  panZoom,
4967
5235
  orientation: controlsOrientation,
5236
+ zoomControls: showZoomControls,
4968
5237
  leading: floatingControlsLeading,
4969
5238
  busy,
4970
5239
  onFit: () => panZoom.fitToFrame([
@@ -4991,7 +5260,7 @@ import {
4991
5260
  useEffect as useEffect9,
4992
5261
  useMemo as useMemo6,
4993
5262
  useRef as useRef9,
4994
- useState as useState10,
5263
+ useState as useState11,
4995
5264
  useSyncExternalStore
4996
5265
  } from "react";
4997
5266
  import { Fragment as Fragment5, jsx as jsx18, jsxs as jsxs13 } from "react/jsx-runtime";
@@ -5149,7 +5418,7 @@ function GenreTreeOutline(props) {
5149
5418
  }
5150
5419
  return ids;
5151
5420
  }, [reparentingNodeId, index]);
5152
- const [store] = useState10(
5421
+ const [store] = useState11(
5153
5422
  () => createOutlineStore({
5154
5423
  selectedId: null,
5155
5424
  openIds: /* @__PURE__ */ new Set(),
@@ -5174,7 +5443,7 @@ function GenreTreeOutline(props) {
5174
5443
  return;
5175
5444
  }
5176
5445
  const changed = store.get().selectedId !== node.id;
5177
- store.set({ selectedId: node.id });
5446
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: node.id });
5178
5447
  if (changed) latestProps.current.onSelectedNodeChange?.(node);
5179
5448
  onNodeClick?.(node, event.nativeEvent);
5180
5449
  },
@@ -5206,7 +5475,7 @@ function GenreTreeOutline(props) {
5206
5475
  }
5207
5476
  const changed = store.get().selectedId !== nodeId;
5208
5477
  if (changed) pendingScrollIdRef.current = nodeId;
5209
- store.set({ openIds, selectedId: nodeId });
5478
+ store.set({ openIds, ...notify && latestProps.current.selectedNodeId !== void 0 ? {} : { selectedId: nodeId } });
5210
5479
  if (changed && notify) latestProps.current.onSelectedNodeChange?.(index.byId.get(nodeId));
5211
5480
  };
5212
5481
  const selectNodeRef = useRef9(selectNode);
@@ -5247,7 +5516,7 @@ function GenreTreeOutline(props) {
5247
5516
  ancestorNodes: index.ancestorsOf(selectedNode.id).map((n) => ({ node: n, ...model.styleOf(n) })),
5248
5517
  side: "left",
5249
5518
  onClose: () => {
5250
- store.set({ selectedId: null });
5519
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: null });
5251
5520
  latestProps.current.onSelectedNodeChange?.(null);
5252
5521
  },
5253
5522
  onSelectNode: (id) => selectNodeRef.current(id),