@behindthemusictree/genre-tree-view 6.2.0 → 6.4.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
  );
@@ -1047,9 +1093,10 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1047
1093
  const newScale = clampZoomScale(Math.max(targetScale, currentScale), minScaleRef.current);
1048
1094
  const centerX = (rect.left + rect.width / 2 - viewportRect.left - view.x) / currentScale;
1049
1095
  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;
1096
+ const availableCenterX = obscured?.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : obscured?.side === "right" ? (viewportRect.width - obscured.width) / 2 : viewportRect.width / 2;
1097
+ const availableCenterY = obscured?.side === "bottom" ? (viewportRect.height - obscured.height) / 2 : viewportRect.height / 2;
1051
1098
  const targetPanX = availableCenterX - centerX * newScale;
1052
- const targetPanY = viewportRect.height / 2 - centerY * newScale;
1099
+ const targetPanY = availableCenterY - centerY * newScale;
1053
1100
  zoomAnimationRef.current = null;
1054
1101
  if (animationFrameIdRef.current !== null) {
1055
1102
  cancelAnimationFrame(animationFrameIdRef.current);
@@ -1203,6 +1250,9 @@ function resolveInfoPanelSide() {
1203
1250
  }
1204
1251
  function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
1205
1252
  if (!element || !viewport) return null;
1253
+ if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
1254
+ return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
1255
+ }
1206
1256
  return { width: panelWidth, side: resolveInfoPanelSide() };
1207
1257
  }
1208
1258
 
@@ -1284,8 +1334,8 @@ function useTreeIndex(nodes) {
1284
1334
  }
1285
1335
 
1286
1336
  // src/InfoPanel.tsx
1287
- import { useEffect as useEffect3, useRef as useRef3 } from "react";
1288
- import { MdArrowBack, MdClose, MdZoomIn as MdZoomIn2 } from "react-icons/md";
1337
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState5 } from "react";
1338
+ import { MdArrowBack, MdClose, MdZoomIn } from "react-icons/md";
1289
1339
 
1290
1340
  // src/InfoPanelExpandButton.tsx
1291
1341
  import { MdCloseFullscreen, MdOpenInFull } from "react-icons/md";
@@ -1524,6 +1574,27 @@ function InfoPanel({
1524
1574
  hideExpandButton = false
1525
1575
  }) {
1526
1576
  const panelRef = useRef3(null);
1577
+ const [sheetFull, setSheetFull] = useState5(false);
1578
+ const grabberDragRef = useRef3(null);
1579
+ const onGrabberPointerDown = (event) => {
1580
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1581
+ grabberDragRef.current = { startY: event.clientY, dragged: false };
1582
+ };
1583
+ const onGrabberPointerUp = (event) => {
1584
+ const drag = grabberDragRef.current;
1585
+ if (!drag) return;
1586
+ const dy = event.clientY - drag.startY;
1587
+ if (Math.abs(dy) < INFO_PANEL_SHEET_DRAG_THRESHOLD_PX) return;
1588
+ drag.dragged = true;
1589
+ if (dy < 0) setSheetFull(true);
1590
+ else if (sheetFull) setSheetFull(false);
1591
+ else if (!hideClose) onClose();
1592
+ };
1593
+ const onGrabberClick = () => {
1594
+ const dragged = grabberDragRef.current?.dragged ?? false;
1595
+ grabberDragRef.current = null;
1596
+ if (!dragged) setSheetFull((full) => !full);
1597
+ };
1527
1598
  useEffect3(() => {
1528
1599
  panelRef.current?.toggleAttribute("inert", collapsed);
1529
1600
  }, [collapsed]);
@@ -1626,9 +1697,21 @@ function InfoPanel({
1626
1697
  return /* @__PURE__ */ jsxs6(
1627
1698
  "div",
1628
1699
  {
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" : ""}`,
1700
+ 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
1701
  ref: panelRef,
1631
1702
  children: [
1703
+ /* @__PURE__ */ jsx7(
1704
+ "button",
1705
+ {
1706
+ type: "button",
1707
+ className: "gtv-info-panel-grabber",
1708
+ "aria-label": sheetFull ? "Lower panel" : "Raise panel",
1709
+ "aria-expanded": sheetFull,
1710
+ onPointerDown: onGrabberPointerDown,
1711
+ onPointerUp: onGrabberPointerUp,
1712
+ onClick: onGrabberClick
1713
+ }
1714
+ ),
1632
1715
  /* @__PURE__ */ jsxs6(
1633
1716
  "div",
1634
1717
  {
@@ -1667,7 +1750,7 @@ function InfoPanel({
1667
1750
  onClick: onZoomIn,
1668
1751
  "aria-label": "Zoom in",
1669
1752
  title: "Zoom in",
1670
- children: /* @__PURE__ */ jsx7(MdZoomIn2, { className: "gtv-icon", size: 16 })
1753
+ children: /* @__PURE__ */ jsx7(MdZoomIn, { className: "gtv-icon", size: 16 })
1671
1754
  }
1672
1755
  ),
1673
1756
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ jsx7(InfoPanelExpandButton, { expanded, onExpandedChange, style: { color: textColor } }),
@@ -1780,6 +1863,8 @@ function GenreTree({
1780
1863
  hideInfoPanelClose = false,
1781
1864
  hideInfoPanelTitle = false,
1782
1865
  itemCountLabel,
1866
+ itemCountScaleMax,
1867
+ nodeCountForSizing,
1783
1868
  readOnly,
1784
1869
  renderDangerZone,
1785
1870
  expanded,
@@ -1791,6 +1876,7 @@ function GenreTree({
1791
1876
  defaultViewport,
1792
1877
  onViewportChange,
1793
1878
  controlsOrientation = "vertical",
1879
+ showZoomControls = "auto",
1794
1880
  floatingControlsLeading,
1795
1881
  busy
1796
1882
  }) {
@@ -1800,6 +1886,10 @@ function GenreTree({
1800
1886
  const stageRef = useRef5(null);
1801
1887
  const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
1802
1888
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId: selectedNodeIdProp, onSelectedNodeChange });
1889
+ const itemCountRange = useMemo2(
1890
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: nodeCountForSizing ?? nodes.length }),
1891
+ [nodes, itemCountScaleMax, nodeCountForSizing]
1892
+ );
1803
1893
  const showNodeInfoRef = useRef5(showNodeInfo);
1804
1894
  useEffect5(() => {
1805
1895
  showNodeInfoRef.current = showNodeInfo;
@@ -1814,7 +1904,8 @@ function GenreTree({
1814
1904
  d3,
1815
1905
  root,
1816
1906
  orientation,
1817
- depthSpacingScale
1907
+ depthSpacingScale,
1908
+ itemCountRange
1818
1909
  );
1819
1910
  const {
1820
1911
  svgWidth: width,
@@ -1826,7 +1917,8 @@ function GenreTree({
1826
1917
  originalTreeData,
1827
1918
  orientation,
1828
1919
  hideRoot,
1829
- depthSpacingScale
1920
+ depthSpacingScale,
1921
+ itemCountRange
1830
1922
  );
1831
1923
  const reshapedTreeData = setupTreeLayout(
1832
1924
  d3,
@@ -1835,7 +1927,8 @@ function GenreTree({
1835
1927
  orientation,
1836
1928
  rootDepthOffset,
1837
1929
  width,
1838
- depthSpacingScale
1930
+ depthSpacingScale,
1931
+ itemCountRange
1839
1932
  );
1840
1933
  return {
1841
1934
  treeData: reshapedTreeData,
@@ -1843,7 +1936,7 @@ function GenreTree({
1843
1936
  svgWidth: width,
1844
1937
  svgHeight: height
1845
1938
  };
1846
- }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale]);
1939
+ }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale, itemCountRange]);
1847
1940
  const reparentForbiddenIds = useMemo2(() => {
1848
1941
  if (!reparentingNodeId) return [];
1849
1942
  const reparentingHierarchyNode = treeData.descendants().find((d) => d.data.id === reparentingNodeId);
@@ -1894,7 +1987,8 @@ function GenreTree({
1894
1987
  },
1895
1988
  orientation,
1896
1989
  hideRoot,
1897
- effectiveSelectedNodeId
1990
+ effectiveSelectedNodeId,
1991
+ itemCountRange
1898
1992
  );
1899
1993
  }, [
1900
1994
  treeData,
@@ -1915,7 +2009,8 @@ function GenreTree({
1915
2009
  additionalActions,
1916
2010
  orientation,
1917
2011
  hideRoot,
1918
- effectiveSelectedNodeId
2012
+ effectiveSelectedNodeId,
2013
+ itemCountRange
1919
2014
  ]);
1920
2015
  useEffect5(() => {
1921
2016
  if (!interactive || !selectedNodeIdProp) return;
@@ -2076,6 +2171,7 @@ function GenreTree({
2076
2171
  {
2077
2172
  panZoom,
2078
2173
  orientation: controlsOrientation,
2174
+ zoomControls: showZoomControls,
2079
2175
  leading: floatingControlsLeading,
2080
2176
  busy,
2081
2177
  onFit: () => panZoom.fitToFrame(queryTreeContentElements(svgRef.current))
@@ -2087,7 +2183,7 @@ function GenreTree({
2087
2183
  }
2088
2184
 
2089
2185
  // src/GenreTreeWheelBase.tsx
2090
- import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef6, useState as useState5 } from "react";
2186
+ import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef6, useState as useState6 } from "react";
2091
2187
  import * as d32 from "d3";
2092
2188
 
2093
2189
  // src/root-grouping.ts
@@ -2188,6 +2284,8 @@ function WheelCore({
2188
2284
  hideInfoPanelClose = false,
2189
2285
  hideInfoPanelTitle = false,
2190
2286
  itemCountLabel,
2287
+ itemCountScaleMax,
2288
+ nodeCountForSizing,
2191
2289
  readOnly,
2192
2290
  renderDangerZone,
2193
2291
  expanded,
@@ -2199,10 +2297,16 @@ function WheelCore({
2199
2297
  defaultViewport,
2200
2298
  onViewportChange,
2201
2299
  controlsOrientation = "vertical",
2300
+ showZoomControls = "auto",
2202
2301
  floatingControlsLeading,
2203
2302
  busy,
2204
2303
  selectedNodeId
2205
2304
  }) {
2305
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
2306
+ const wheelSizeRange = useMemo3(
2307
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
2308
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
2309
+ );
2206
2310
  const treeOrientation = direction === "left" ? "horizontal-anchored" : "vertical";
2207
2311
  const landingAngle = direction === "left" ? 90 : 0;
2208
2312
  const chipHalfExtentVar = direction === "left" ? "--gtv-wheel-chip-half-width" : "--gtv-wheel-chip-half-height";
@@ -2212,18 +2316,18 @@ function WheelCore({
2212
2316
  return getGenreTreeColor(nodes.find((n) => n.id === rootId)?.wikidataId);
2213
2317
  };
2214
2318
  const wheelRadius = useMemo3(
2215
- () => calculateWheelRadius(groups.length, MAX_NODE_WIDTH, WHEEL_RADIUS),
2216
- [groups.length]
2319
+ () => calculateWheelRadius(groups.length, wheelSizeRange.maxNodeWidth ?? MAX_NODE_WIDTH, WHEEL_RADIUS),
2320
+ [groups.length, wheelSizeRange.maxNodeWidth]
2217
2321
  );
2218
- const wheelViewportHeight = wheelRadius * 2 + MAX_NODE_HEIGHT / 2;
2322
+ const wheelViewportHeight = wheelRadius * 2 + (wheelSizeRange.maxNodeHeight ?? MAX_NODE_HEIGHT) / 2;
2219
2323
  const rootItemCountById = useMemo3(
2220
2324
  () => new Map(groups.map((group) => [group.root.id, group.root.itemCount])),
2221
2325
  [groups]
2222
2326
  );
2223
- const [selectedRootId, setSelectedRootId] = useState5(
2327
+ const [selectedRootId, setSelectedRootId] = useState6(
2224
2328
  groups[0]?.root.id ?? null
2225
2329
  );
2226
- const [rotationDeg, setRotationDeg] = useState5(
2330
+ const [rotationDeg, setRotationDeg] = useState6(
2227
2331
  () => groups.length > 0 ? computeRotationForSelection(
2228
2332
  0,
2229
2333
  getChipAngle(0, groups.length),
@@ -2262,16 +2366,18 @@ function WheelCore({
2262
2366
  () => getItemCountRange(
2263
2367
  groups.map((group) => ({
2264
2368
  itemCount: rootItemCountById.get(group.root.id)
2265
- }))
2369
+ })),
2370
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2266
2371
  ),
2267
- [groups, rootItemCountById]
2372
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
2268
2373
  );
2269
2374
  const rootChipHalfExtent = selectedGroup ? calculateRootAnchorClearance(
2270
2375
  d32,
2271
2376
  selectedGroup.nodes,
2272
2377
  rootItemCountById.get(selectedGroup.root.id),
2273
2378
  rootItemCountRange,
2274
- direction === "left" ? "WIDTH" : "HEIGHT"
2379
+ direction === "left" ? "WIDTH" : "HEIGHT",
2380
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2275
2381
  ) : 0;
2276
2382
  const sectorFillGradient = useMemo3(
2277
2383
  () => buildWheelSectorGradient(
@@ -2289,7 +2395,7 @@ function WheelCore({
2289
2395
  );
2290
2396
  }
2291
2397
  };
2292
- const [appliedSelectedNodeId, setAppliedSelectedNodeId] = useState5(selectedNodeId);
2398
+ const [appliedSelectedNodeId, setAppliedSelectedNodeId] = useState6(selectedNodeId);
2293
2399
  if (selectedNodeId !== appliedSelectedNodeId) {
2294
2400
  setAppliedSelectedNodeId(selectedNodeId);
2295
2401
  const owningGroupIndex = selectedNodeId ? groups.findIndex(
@@ -2361,6 +2467,8 @@ function WheelCore({
2361
2467
  {
2362
2468
  nodes: selectedGroup.nodes,
2363
2469
  itemCountLabel,
2470
+ itemCountScaleMax,
2471
+ nodeCountForSizing: fullNodeCountForSizing,
2364
2472
  orientation: treeOrientation,
2365
2473
  hideRoot: true,
2366
2474
  interactive: false,
@@ -2572,6 +2680,7 @@ function WheelCore({
2572
2680
  {
2573
2681
  panZoom,
2574
2682
  orientation: controlsOrientation,
2683
+ zoomControls: showZoomControls,
2575
2684
  leading: floatingControlsLeading,
2576
2685
  busy,
2577
2686
  onFit: () => panZoom.fitToFrame([
@@ -2598,24 +2707,38 @@ function GenreTreeWheelRight(props) {
2598
2707
  }
2599
2708
 
2600
2709
  // src/GenreTreeWheelRadialBase.tsx
2601
- import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef7, useState as useState6 } from "react";
2710
+ import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef7, useState as useState7 } from "react";
2602
2711
  import * as d33 from "d3";
2603
2712
 
2604
2713
  // src/defer-click.ts
2605
2714
  function createDeferredClick() {
2606
2715
  let timeoutId = null;
2716
+ let pendingKey = void 0;
2717
+ let doubledAt = -Infinity;
2607
2718
  const cancel = () => {
2608
2719
  if (timeoutId !== null) clearTimeout(timeoutId);
2609
2720
  timeoutId = null;
2610
2721
  };
2611
- const schedule = (action) => {
2722
+ const schedule = (action, onDouble, key) => {
2723
+ if (onDouble && timeoutId !== null && key === pendingKey) {
2724
+ cancel();
2725
+ doubledAt = Date.now();
2726
+ onDouble();
2727
+ return;
2728
+ }
2612
2729
  cancel();
2730
+ pendingKey = key;
2613
2731
  timeoutId = setTimeout(() => {
2614
2732
  timeoutId = null;
2615
2733
  action();
2616
2734
  }, DOUBLE_CLICK_DELAY_MS);
2617
2735
  };
2618
- return { schedule, cancel };
2736
+ const doubleClick = (onDouble) => {
2737
+ cancel();
2738
+ if (Date.now() - doubledAt < DOUBLE_CLICK_DELAY_MS) return;
2739
+ onDouble();
2740
+ };
2741
+ return { schedule, cancel, doubleClick };
2619
2742
  }
2620
2743
 
2621
2744
  // src/radial-wheel-geometry.ts
@@ -2805,13 +2928,15 @@ function calculatePopSubtreeRadialExtent(hierarchy, coreRootCircleRadius = 0) {
2805
2928
  POP_TREE_DEPTH_RADIAL_SPACING
2806
2929
  ) + MAX_NODE_WIDTH / 2 + POP_SUBTREE_OUTER_MARGIN;
2807
2930
  }
2808
- function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING) {
2931
+ function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
2809
2932
  return layoutWedge(
2810
2933
  d3Lib,
2811
2934
  hierarchy,
2812
2935
  wedgeCenterAngleDegrees,
2813
2936
  wedgeSpanDegrees,
2814
- popRadiusOfDepth(coreRootCircleRadius, depthSpacing)
2937
+ popRadiusOfDepth(coreRootCircleRadius, depthSpacing),
2938
+ "center",
2939
+ dotRadius
2815
2940
  );
2816
2941
  }
2817
2942
  var MIN_DOT_GAP = 5.6;
@@ -2821,15 +2946,15 @@ function popRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2821
2946
  function coreRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2822
2947
  return (depth) => getRadialDepthRadius(depth + 1, coreRootCircleRadius, depthSpacing);
2823
2948
  }
2824
- function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2825
- const dotRadius = () => NODE_LAYOUT_SLOT_RADIUS;
2949
+ function measureWedge(_d3Lib, hierarchy, radiusOfDepth, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
2950
+ const slotRadius = () => dotRadius;
2826
2951
  const offsetFromParent = /* @__PURE__ */ new Map();
2827
2952
  const shiftLevel = (level, by) => ({
2828
2953
  left: { ...level.left, x: level.left.x + by },
2829
2954
  right: { ...level.right, x: level.right.x + by }
2830
2955
  });
2831
2956
  const pack = (node) => {
2832
- const own = { x: 0, dotRadius: dotRadius() };
2957
+ const own = { x: 0, dotRadius: slotRadius() };
2833
2958
  if (!node.children) return [{ left: own, right: own }];
2834
2959
  const below = [];
2835
2960
  const childOffsets = [];
@@ -2861,7 +2986,7 @@ function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2861
2986
  let min = Infinity;
2862
2987
  let max = -Infinity;
2863
2988
  hierarchy.each((d) => {
2864
- const inset = (dotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
2989
+ const inset = (slotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
2865
2990
  min = Math.min(min, d.x - inset);
2866
2991
  max = Math.max(max, d.x + inset);
2867
2992
  });
@@ -2881,8 +3006,8 @@ function splitAtBranching(hierarchy) {
2881
3006
  const children = branching.children;
2882
3007
  return children && children.length > 1 ? { chain, branching, children } : null;
2883
3008
  }
2884
- function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center") {
2885
- const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth);
3009
+ function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center", dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3010
+ const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth, dotRadius);
2886
3011
  const wedgeSpanRad = wedgeSpanDegrees * Math.PI / 180;
2887
3012
  const scale = Math.min(MAX_WEDGE_STRETCH, wedgeSpanRad / neededRad);
2888
3013
  const wedgeCenterRad = wedgeCenterAngleDegrees * Math.PI / 180;
@@ -3111,12 +3236,12 @@ function renderPopSubtree(d3Lib, svg, hierarchy, rootColor, reparentingNodeId, r
3111
3236
  group.on("click", (event) => {
3112
3237
  if (reparentingNodeId) return;
3113
3238
  const element = event.currentTarget;
3114
- deferredClick.schedule(() => onNodeClick?.(d.data, event, element));
3115
- });
3116
- group.on("dblclick", () => {
3117
- deferredClick.cancel();
3118
- onNodeDoubleClick?.(d.data);
3239
+ deferredClick.schedule(
3240
+ () => onNodeClick?.(d.data, event, element),
3241
+ () => onNodeDoubleClick?.(d.data)
3242
+ );
3119
3243
  });
3244
+ group.on("dblclick", () => deferredClick.doubleClick(() => onNodeDoubleClick?.(d.data)));
3120
3245
  });
3121
3246
  }
3122
3247
  function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
@@ -3193,14 +3318,15 @@ function buildCoreHierarchy(d3Lib, coreNodes) {
3193
3318
  });
3194
3319
  return hierarchy;
3195
3320
  }
3196
- function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing) {
3321
+ function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3197
3322
  return layoutWedge(
3198
3323
  d3Lib,
3199
3324
  hierarchy,
3200
3325
  wedgeCenterAngleDegrees,
3201
3326
  wedgeSpanDegrees,
3202
3327
  coreRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3203
- "between-children"
3328
+ "between-children",
3329
+ dotRadius
3204
3330
  );
3205
3331
  }
3206
3332
  function calculateCoreSubtreeRadialExtent(hierarchy, depthSpacing, coreRootCircleRadius = 0) {
@@ -3274,6 +3400,8 @@ function WheelRadialCore({
3274
3400
  hideInfoPanelClose = false,
3275
3401
  hideInfoPanelTitle = false,
3276
3402
  itemCountLabel,
3403
+ itemCountScaleMax,
3404
+ nodeCountForSizing,
3277
3405
  readOnly,
3278
3406
  renderDangerZone,
3279
3407
  expanded,
@@ -3285,14 +3413,16 @@ function WheelRadialCore({
3285
3413
  defaultViewport,
3286
3414
  onViewportChange,
3287
3415
  controlsOrientation = "vertical",
3416
+ showZoomControls = "auto",
3288
3417
  floatingControlsLeading,
3289
3418
  busy,
3290
3419
  selectedNodeId
3291
3420
  }) {
3421
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3292
3422
  const index = useTreeIndex(nodes);
3293
3423
  const groups = useMemo4(() => groupNodesByRoot(nodes), [nodes]);
3294
3424
  const rootColorOf = (node) => getGenreTreeColor(index.byId.get(index.rootIdById.get(node.id) ?? node.id)?.wikidataId);
3295
- const [topRootId, setTopRootId] = useState6(
3425
+ const [topRootId, setTopRootId] = useState7(
3296
3426
  groups[0]?.root.id ?? null
3297
3427
  );
3298
3428
  const viewportRef = useRef7(null);
@@ -3322,7 +3452,7 @@ function WheelRadialCore({
3322
3452
  () => computeRadialLayout(rootWeights, topIndex, LANDING_ANGLE),
3323
3453
  [rootWeights, topIndex]
3324
3454
  );
3325
- const [angleMemo, setAngleMemo] = useState6(() => ({
3455
+ const [angleMemo, setAngleMemo] = useState7(() => ({
3326
3456
  layout,
3327
3457
  angles: computeContinuousAngles(groups, layout, /* @__PURE__ */ new Map())
3328
3458
  }));
@@ -3341,11 +3471,15 @@ function WheelRadialCore({
3341
3471
  () => getItemCountRange(
3342
3472
  groups.map((group) => ({
3343
3473
  itemCount: rootItemCountById.get(group.root.id)
3344
- }))
3474
+ })),
3475
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
3345
3476
  ),
3346
- [groups, rootItemCountById]
3477
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
3478
+ );
3479
+ const wheelItemCountRange = useMemo4(
3480
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
3481
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
3347
3482
  );
3348
- const wheelItemCountRange = useMemo4(() => getItemCountRange(nodes), [nodes]);
3349
3483
  const sectorSpanByRootId = useMemo4(() => {
3350
3484
  const map = /* @__PURE__ */ new Map();
3351
3485
  if (groups.length <= 1) return map;
@@ -3395,10 +3529,10 @@ function WheelRadialCore({
3395
3529
  const chipClearanceFloor = useMemo4(
3396
3530
  () => calculateWheelRadiusForAngles(
3397
3531
  layout.map((slot) => slot.angle),
3398
- MAX_NODE_WIDTH,
3532
+ wheelItemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
3399
3533
  WHEEL_RADIUS
3400
3534
  ),
3401
- [layout]
3535
+ [layout, wheelItemCountRange.maxNodeWidth]
3402
3536
  );
3403
3537
  const maxCoreExtentDelta = useMemo4(() => {
3404
3538
  let extent = 0;
@@ -3768,6 +3902,7 @@ function WheelRadialCore({
3768
3902
  {
3769
3903
  panZoom,
3770
3904
  orientation: controlsOrientation,
3905
+ zoomControls: showZoomControls,
3771
3906
  leading: floatingControlsLeading,
3772
3907
  busy,
3773
3908
  onFit: () => panZoom.fitToFrame([wheelCircleRef.current, coreSvgRef.current])
@@ -3785,13 +3920,13 @@ function GenreTreeWheelRadial(props) {
3785
3920
  }
3786
3921
 
3787
3922
  // src/GenreTreeWheelRadialPopCoreBase.tsx
3788
- import { useCallback as useCallback4, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8, useState as useState9 } from "react";
3923
+ import { useCallback as useCallback4, useEffect as useEffect8, useMemo as useMemo5, useRef as useRef8, useState as useState10 } from "react";
3789
3924
  import * as d34 from "d3";
3790
3925
 
3791
3926
  // src/use-focus-history.ts
3792
- import { useState as useState7 } from "react";
3927
+ import { useState as useState8 } from "react";
3793
3928
  function useFocusHistory(focusNodeId, isFocusable) {
3794
- const [state, setState] = useState7({ seen: focusNodeId, stack: [], pending: void 0 });
3929
+ const [state, setState] = useState8({ seen: focusNodeId, stack: [], pending: void 0 });
3795
3930
  if (state.seen !== focusNodeId) {
3796
3931
  const popTo = state.pending === focusNodeId ? state.stack.lastIndexOf(focusNodeId) : -1;
3797
3932
  setState({
@@ -3813,13 +3948,13 @@ function useFocusHistory(focusNodeId, isFocusable) {
3813
3948
  }
3814
3949
 
3815
3950
  // src/WheelBreadcrumb.tsx
3816
- import { Fragment as Fragment3, useState as useState8 } from "react";
3951
+ import { Fragment as Fragment3, useState as useState9 } from "react";
3817
3952
  import { MdHome } from "react-icons/md";
3818
3953
  import { Fragment as Fragment4, jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
3819
3954
  var MAX_VISIBLE_ANCESTORS = 3;
3820
3955
  var ANCESTORS_KEPT_WHEN_COLLAPSED = 2;
3821
3956
  function WheelBreadcrumb({ ancestors, currentName, onHome, onAncestor }) {
3822
- const [expanded, setExpanded] = useState8(false);
3957
+ const [expanded, setExpanded] = useState9(false);
3823
3958
  const collapsed = !expanded && ancestors.length > MAX_VISIBLE_ANCESTORS;
3824
3959
  const visibleAncestors = collapsed ? ancestors.slice(-ANCESTORS_KEPT_WHEN_COLLAPSED) : ancestors;
3825
3960
  const separator = /* @__PURE__ */ jsx15("span", { "aria-hidden": "true", children: "\u203A" });
@@ -3951,6 +4086,8 @@ function WheelRadialPopCoreCore({
3951
4086
  hideInfoPanelClose = false,
3952
4087
  hideInfoPanelTitle = false,
3953
4088
  itemCountLabel,
4089
+ itemCountScaleMax,
4090
+ nodeCountForSizing,
3954
4091
  readOnly,
3955
4092
  renderDangerZone,
3956
4093
  expanded,
@@ -3962,6 +4099,7 @@ function WheelRadialPopCoreCore({
3962
4099
  defaultViewport,
3963
4100
  onViewportChange,
3964
4101
  controlsOrientation = "vertical",
4102
+ showZoomControls = "auto",
3965
4103
  floatingControlsLeading,
3966
4104
  busy,
3967
4105
  allowWheelRotation = true,
@@ -3969,6 +4107,7 @@ function WheelRadialPopCoreCore({
3969
4107
  focusNodeId: requestedFocusNodeId = null,
3970
4108
  onFocusChange
3971
4109
  }) {
4110
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3972
4111
  const index = useTreeIndex(nodes);
3973
4112
  const focusNodeId = useMemo5(
3974
4113
  () => requestedFocusNodeId !== null && isZoomableNode(nodes, requestedFocusNodeId) ? requestedFocusNodeId : null,
@@ -4022,13 +4161,13 @@ function WheelRadialPopCoreCore({
4022
4161
  (node) => getGenreNodeStyle(index, node.id),
4023
4162
  [index]
4024
4163
  );
4025
- const [topRootId, setTopRootId] = useState9(
4164
+ const [topRootId, setTopRootId] = useState10(
4026
4165
  groups[0]?.root.id ?? null
4027
4166
  );
4028
- const [rotationTopRootId, setRotationTopRootId] = useState9(
4167
+ const [rotationTopRootId, setRotationTopRootId] = useState10(
4029
4168
  groups[0]?.root.id ?? null
4030
4169
  );
4031
- const [renderedFocusNodeId, setRenderedFocusNodeId] = useState9(focusNodeId);
4170
+ const [renderedFocusNodeId, setRenderedFocusNodeId] = useState10(focusNodeId);
4032
4171
  if (renderedFocusNodeId !== focusNodeId) {
4033
4172
  setRenderedFocusNodeId(focusNodeId);
4034
4173
  setTopRootId(groups[0]?.root.id ?? null);
@@ -4091,7 +4230,7 @@ function WheelRadialPopCoreCore({
4091
4230
  const popSvgRef = useRef8(null);
4092
4231
  const centerChipRef = useRef8(null);
4093
4232
  const regionLabelRef = useRef8(null);
4094
- const [regionLabel, setRegionLabel] = useState9(null);
4233
+ const [regionLabel, setRegionLabel] = useState10(null);
4095
4234
  const effectiveTopRootId = groups.some((group) => group.root.id === topRootId) ? topRootId : groups[0]?.root.id ?? null;
4096
4235
  const effectiveRotationTopRootId = groups.some(
4097
4236
  (group) => group.root.id === rotationTopRootId
@@ -4108,8 +4247,8 @@ function WheelRadialPopCoreCore({
4108
4247
  () => getItemCountRange([
4109
4248
  ...nodes,
4110
4249
  ...[...rootItemCountById.values()].map((itemCount) => ({ itemCount }))
4111
- ]),
4112
- [nodes, rootItemCountById]
4250
+ ], { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
4251
+ [nodes, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
4113
4252
  );
4114
4253
  const isCenterMainstream = isMainstreamPopLineage(index, centerNode.id);
4115
4254
  const centerNodeColor = isCenterMainstream ? "#ffffff" : getGenreNodeStyle(index, centerNode.id).fill;
@@ -4143,8 +4282,8 @@ function WheelRadialPopCoreCore({
4143
4282
  centerSubtreeHierarchy,
4144
4283
  0,
4145
4284
  POP_TREE_DEPTH_RADIAL_SPACING
4146
- ) : NODE_LAYOUT_SLOT_RADIUS,
4147
- [centerSubtreeHierarchy]
4285
+ ) : wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS,
4286
+ [centerSubtreeHierarchy, wheelItemCountRange.maxDotRadius]
4148
4287
  );
4149
4288
  const maxCoreExtentDelta = useMemo5(() => {
4150
4289
  let extent = 0;
@@ -4166,7 +4305,8 @@ function WheelRadialPopCoreCore({
4166
4305
  (radius) => new Map(
4167
4306
  groups.map((group) => {
4168
4307
  const { pop, core } = branchHierarchiesByRootId.get(group.root.id);
4169
- const chipWidth = 2 * NODE_LAYOUT_SLOT_RADIUS;
4308
+ const dotRadius = wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS;
4309
+ const chipWidth = 2 * dotRadius;
4170
4310
  return [
4171
4311
  group.root.id,
4172
4312
  {
@@ -4174,14 +4314,16 @@ function WheelRadialPopCoreCore({
4174
4314
  (hierarchy) => measureWedge(
4175
4315
  d34,
4176
4316
  hierarchy,
4177
- popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4317
+ popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4318
+ dotRadius
4178
4319
  )
4179
4320
  ),
4180
4321
  core: core.map(
4181
4322
  (hierarchy) => measureWedge(
4182
4323
  d34,
4183
4324
  hierarchy,
4184
- coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4325
+ coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4326
+ dotRadius
4185
4327
  )
4186
4328
  ),
4187
4329
  chip: 2 * Math.asin(Math.min(1, (chipWidth / 2 + CHIP_GAP) / radius))
@@ -4189,7 +4331,7 @@ function WheelRadialPopCoreCore({
4189
4331
  ];
4190
4332
  })
4191
4333
  ),
4192
- [groups, branchHierarchiesByRootId]
4334
+ [groups, branchHierarchiesByRootId, wheelItemCountRange.maxDotRadius]
4193
4335
  );
4194
4336
  const coreRootCircleRadius = useMemo5(
4195
4337
  () => solveRingRadius(
@@ -4199,11 +4341,11 @@ function WheelRadialPopCoreCore({
4199
4341
  ),
4200
4342
  Math.max(
4201
4343
  WHEEL_POP_CORE_RADIUS,
4202
- mainstreamCircleRadius + NODE_DOT_MAX_RADIUS + POP_SUBTREE_OUTER_MARGIN,
4344
+ mainstreamCircleRadius + (wheelItemCountRange.maxDotRadius ?? NODE_DOT_MAX_RADIUS) + POP_SUBTREE_OUTER_MARGIN,
4203
4345
  popReachRequiredRadius
4204
4346
  )
4205
4347
  ),
4206
- [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius]
4348
+ [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius, wheelItemCountRange.maxDotRadius]
4207
4349
  );
4208
4350
  const branchNeedsByRootId = useMemo5(
4209
4351
  () => measureBranchNeedsAt(coreRootCircleRadius),
@@ -4220,7 +4362,7 @@ function WheelRadialPopCoreCore({
4220
4362
  () => computeRadialLayout(rootWeights, topIndex, LANDING_ANGLE2),
4221
4363
  [rootWeights, topIndex]
4222
4364
  );
4223
- const [angleMemo, setAngleMemo] = useState9(() => ({
4365
+ const [angleMemo, setAngleMemo] = useState10(() => ({
4224
4366
  layout,
4225
4367
  angles: computeContinuousAngles2(groups, layout, /* @__PURE__ */ new Map())
4226
4368
  }));
@@ -4307,7 +4449,8 @@ function WheelRadialPopCoreCore({
4307
4449
  angle,
4308
4450
  coreRootCircleRadius,
4309
4451
  span,
4310
- POP_TREE_DEPTH_RADIAL_SPACING
4452
+ POP_TREE_DEPTH_RADIAL_SPACING,
4453
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4311
4454
  );
4312
4455
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4313
4456
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-pop-branch").attr("data-gtv-branch-index", branchIndex);
@@ -4373,7 +4516,8 @@ function WheelRadialPopCoreCore({
4373
4516
  angle,
4374
4517
  span,
4375
4518
  coreRootCircleRadius,
4376
- POP_TREE_DEPTH_RADIAL_SPACING
4519
+ POP_TREE_DEPTH_RADIAL_SPACING,
4520
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4377
4521
  );
4378
4522
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4379
4523
  const angleOf = (d) => {
@@ -4537,7 +4681,7 @@ function WheelRadialPopCoreCore({
4537
4681
  originGroup.selectAll("g.gtv-root-label").select("text").style("pointer-events", "all").style("cursor", "pointer").on("click", (_event, d) => {
4538
4682
  const chip = d.id === centerNode.id ? centerChipRef.current : viewportRef.current?.querySelector(`#group-${CSS.escape(d.id)}`);
4539
4683
  chip?.click();
4540
- }).on("dblclick", (_event, d) => focusOnNodeRef.current(d.id));
4684
+ }).on("dblclick", (_event, d) => chipClickRef.current.doubleClick(() => focusOnNodeRef.current(d.id)));
4541
4685
  }, [
4542
4686
  centerNode,
4543
4687
  centerHasPopDisc,
@@ -4696,7 +4840,7 @@ function WheelRadialPopCoreCore({
4696
4840
  )
4697
4841
  };
4698
4842
  });
4699
- }, [groups, continuousAngleByRootId, sectorSpanByRootId]);
4843
+ }, [groups, continuousAngleByRootId, sectorSpanByRootId, index]);
4700
4844
  return /* @__PURE__ */ jsxs12(
4701
4845
  "div",
4702
4846
  {
@@ -4806,9 +4950,9 @@ function WheelRadialPopCoreCore({
4806
4950
  showNodeInfo(centerNode, chip, viewportRef.current);
4807
4951
  onNodeClick?.(centerNode, nativeEvent);
4808
4952
  }
4809
- });
4953
+ }, () => focusOnNode(centerNode.id), centerNode.id);
4810
4954
  },
4811
- onDoubleClick: () => focusOnNode(centerNode.id),
4955
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(centerNode.id)),
4812
4956
  "aria-label": centerNode.name
4813
4957
  }
4814
4958
  ) }) }),
@@ -4868,9 +5012,9 @@ function WheelRadialPopCoreCore({
4868
5012
  showNodeInfo(root, chip, viewportRef.current);
4869
5013
  onNodeClick?.(root, nativeEvent);
4870
5014
  }
4871
- });
5015
+ }, () => focusOnNode(group.root.id), group.root.id);
4872
5016
  },
4873
- onDoubleClick: () => focusOnNode(group.root.id)
5017
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(group.root.id))
4874
5018
  }
4875
5019
  ) })
4876
5020
  },
@@ -4965,6 +5109,7 @@ function WheelRadialPopCoreCore({
4965
5109
  {
4966
5110
  panZoom,
4967
5111
  orientation: controlsOrientation,
5112
+ zoomControls: showZoomControls,
4968
5113
  leading: floatingControlsLeading,
4969
5114
  busy,
4970
5115
  onFit: () => panZoom.fitToFrame([
@@ -4991,7 +5136,7 @@ import {
4991
5136
  useEffect as useEffect9,
4992
5137
  useMemo as useMemo6,
4993
5138
  useRef as useRef9,
4994
- useState as useState10,
5139
+ useState as useState11,
4995
5140
  useSyncExternalStore
4996
5141
  } from "react";
4997
5142
  import { Fragment as Fragment5, jsx as jsx18, jsxs as jsxs13 } from "react/jsx-runtime";
@@ -5149,7 +5294,7 @@ function GenreTreeOutline(props) {
5149
5294
  }
5150
5295
  return ids;
5151
5296
  }, [reparentingNodeId, index]);
5152
- const [store] = useState10(
5297
+ const [store] = useState11(
5153
5298
  () => createOutlineStore({
5154
5299
  selectedId: null,
5155
5300
  openIds: /* @__PURE__ */ new Set(),