@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.cjs CHANGED
@@ -181,6 +181,9 @@ var MIN_NODE_WIDTH = 180;
181
181
  var MAX_NODE_WIDTH = 700;
182
182
  var MIN_NODE_HEIGHT = 35;
183
183
  var MAX_NODE_HEIGHT = 120;
184
+ var NODE_SIZE_REFERENCE_COUNT = 100;
185
+ var HIGH_DENSITY_NODE_MAX_FLOOR_SCALE = 1.5;
186
+ var LOW_DENSITY_DOT_MAX_RADIUS = 70;
184
187
  var TOOLBAR_MENU_X_GAP = 4;
185
188
  var ACTIONS_OVERLAY_WIDTH = MENU_WIDTH + TOOLBAR_MENU_X_GAP;
186
189
  var ACTIONS_OVERLAY_HEIGHT = RECT_BASE_DIMENSIONS.HEIGHT * 7;
@@ -195,23 +198,47 @@ var HORIZONTAL_SEPARATION_BETWEEN_NODES = (NODE_DIMENSIONS.WIDTH + HORIZONTAL_SE
195
198
  var VERTICAL_SEPARATION_BETWEEN_NODES = NODE_DIMENSIONS.HEIGHT + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
196
199
  var VERTICAL_ORIENTATION_DEPTH_SEPARATION = VERTICAL_SEPARATION_BETWEEN_NODES * 2 * DEPTH_AXIS_SPACING_FACTOR;
197
200
  var SIBLING_SEPARATION_BETWEEN_NODES = MAX_NODE_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES;
198
- function getItemCountRange(nodes) {
199
- if (nodes.length === 0) return { min: 0, max: 0 };
201
+ function getDensityAdjustedMax(baseMax, min, nodeCount) {
202
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
203
+ const availableRangeScale = Math.min(1, NODE_SIZE_REFERENCE_COUNT / safeNodeCount);
204
+ const denseTreeFloor = min * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
205
+ return denseTreeFloor + (baseMax - denseTreeFloor) * availableRangeScale;
206
+ }
207
+ function getDensityAdjustedDotMax(nodeCount) {
208
+ const safeNodeCount = Number.isFinite(nodeCount) ? Math.max(1, nodeCount) : 1;
209
+ const inverseDensityRadius = NODE_DOT_MAX_RADIUS * NODE_SIZE_REFERENCE_COUNT / safeNodeCount;
210
+ const denseTreeFloor = NODE_DOT_MIN_RADIUS * HIGH_DENSITY_NODE_MAX_FLOOR_SCALE;
211
+ return Math.max(denseTreeFloor, Math.min(LOW_DENSITY_DOT_MAX_RADIUS, inverseDensityRadius));
212
+ }
213
+ function getItemCountRange(nodes, options = {}) {
200
214
  const counts = nodes.map((node) => node.itemCount);
201
- return { min: Math.min(...counts), max: Math.max(...counts) };
215
+ const validScaleMax = options.itemCountScaleMax !== void 0 && Number.isFinite(options.itemCountScaleMax) && options.itemCountScaleMax >= 0;
216
+ const nodeCount = options.nodeCountForSizing ?? nodes.length;
217
+ const observedMin = counts.length === 0 ? 0 : Math.min(...counts);
218
+ const observedMax = counts.length === 0 ? 0 : Math.max(...counts);
219
+ const includeDensityCaps = options.nodeCountForSizing !== void 0 || validScaleMax;
220
+ return {
221
+ min: validScaleMax ? 0 : observedMin,
222
+ max: validScaleMax ? options.itemCountScaleMax : observedMax,
223
+ ...includeDensityCaps && {
224
+ maxNodeWidth: getDensityAdjustedMax(MAX_NODE_WIDTH, MIN_NODE_WIDTH, nodeCount),
225
+ maxNodeHeight: getDensityAdjustedMax(MAX_NODE_HEIGHT, MIN_NODE_HEIGHT, nodeCount),
226
+ maxDotRadius: getDensityAdjustedDotMax(nodeCount)
227
+ }
228
+ };
202
229
  }
203
230
  function logarithmicPosition(itemCount, range) {
204
231
  const { min, max } = range;
205
232
  const logMin = Math.log1p(Math.max(min, 0));
206
233
  const logMax = Math.log1p(Math.max(max, 0));
207
234
  const logValue = Math.log1p(Math.max(itemCount, 0));
208
- return logMax > logMin ? (logValue - logMin) / (logMax - logMin) : 0;
235
+ return logMax > logMin ? Math.max(0, Math.min(1, (logValue - logMin) / (logMax - logMin))) : 0;
209
236
  }
210
237
  function calculateNodeDimensions(itemCount, range) {
211
238
  const t = logarithmicPosition(itemCount, range);
212
239
  return {
213
- WIDTH: Math.round(MIN_NODE_WIDTH + t * (MAX_NODE_WIDTH - MIN_NODE_WIDTH)),
214
- HEIGHT: Math.round(MIN_NODE_HEIGHT + t * (MAX_NODE_HEIGHT - MIN_NODE_HEIGHT))
240
+ WIDTH: Math.round(MIN_NODE_WIDTH + t * ((range.maxNodeWidth ?? MAX_NODE_WIDTH) - MIN_NODE_WIDTH)),
241
+ HEIGHT: Math.round(MIN_NODE_HEIGHT + t * ((range.maxNodeHeight ?? MAX_NODE_HEIGHT) - MIN_NODE_HEIGHT))
215
242
  };
216
243
  }
217
244
  var NODE_MIN_BORDER_WIDTH = 1;
@@ -256,7 +283,7 @@ var NODE_DOT_LABEL_GAP = 6;
256
283
  var NODE_LAYOUT_SLOT_RADIUS = 8;
257
284
  function calculateNodeDotRadius(itemCount, range) {
258
285
  const minArea = NODE_DOT_MIN_RADIUS ** 2;
259
- const maxArea = NODE_DOT_MAX_RADIUS ** 2;
286
+ const maxArea = (range.maxDotRadius ?? NODE_DOT_MAX_RADIUS) ** 2;
260
287
  return Math.sqrt(minArea + logarithmicPosition(itemCount, range) * (maxArea - minArea));
261
288
  }
262
289
  var WHEEL_RADIUS = 260;
@@ -291,19 +318,22 @@ var PAN_MIN_VISIBLE_PX = 80;
291
318
  var PAN_ZOOM_SETTLE_MS = 100;
292
319
  var DEFAULT_VIEWPORT_CENTER_GUARD_MS = 1e3;
293
320
  var INFO_PANEL_WIDTH = 280;
321
+ var INFO_PANEL_SHEET_MAX_WIDTH = 767;
322
+ var INFO_PANEL_SHEET_PEEK_FRACTION = 0.4;
323
+ var INFO_PANEL_SHEET_DRAG_THRESHOLD_PX = 32;
294
324
 
295
325
  // src/NodeHelper.tsx
296
326
  var import_jsx_runtime = require("react/jsx-runtime");
297
327
  function buildTreeHierarchyStructure(d3Lib, nodes) {
298
328
  return d3Lib.stratify().id((d) => d.id).parentId((d) => d.parentId)(nodes);
299
329
  }
300
- function calculateLocalRootDimensions(d3Lib, nodes) {
330
+ function calculateLocalRootDimensions(d3Lib, nodes, options = {}) {
301
331
  const root = buildTreeHierarchyStructure(d3Lib, nodes);
302
- const range = getItemCountRange(root.descendants().map((d) => d.data));
332
+ const range = getItemCountRange(root.descendants().map((d) => d.data), options);
303
333
  return calculateNodeDimensions(root.data.itemCount, range);
304
334
  }
305
- function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis) {
306
- const localExtent = calculateLocalRootDimensions(d3Lib, nodes)[axis];
335
+ function calculateRootAnchorClearance(d3Lib, nodes, rootItemCount, rootItemCountRange, axis, options = {}) {
336
+ const localExtent = calculateLocalRootDimensions(d3Lib, nodes, options)[axis];
307
337
  const displayExtent = calculateNodeDimensions(rootItemCount, rootItemCountRange)[axis];
308
338
  return (localExtent + displayExtent) / 2;
309
339
  }
@@ -387,13 +417,31 @@ function appendPaths(d3Lib, svg, treeData, itemCountRange, orientation = "horizo
387
417
  }
388
418
 
389
419
  // src/tree-renderer.ts
390
- function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1) {
391
- const nodes = treeData.descendants();
392
- const itemCountRange = getItemCountRange(nodes.map((d) => d.data));
393
- const maxNodeDimensions = { WIDTH: MAX_NODE_WIDTH, HEIGHT: MAX_NODE_HEIGHT };
420
+ function getLayoutSpacings(itemCountRange, depthSpacingScale = 1) {
421
+ const maxWidth = itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH;
422
+ const maxHeight = itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT;
423
+ const verticalSeparation = maxHeight + VERTICAL_SEPARATION_BETWEEN_RECTANGLES;
424
+ return {
425
+ horizontalDepth: (maxWidth + ACTIONS_OVERLAY_WIDTH + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES) * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
426
+ verticalBreadth: verticalSeparation,
427
+ verticalDepth: verticalSeparation * 2 * DEPTH_AXIS_SPACING_FACTOR * depthSpacingScale,
428
+ verticalSibling: maxWidth + HORIZONTAL_SEPARATION_BETWEEN_RECTANGLES
429
+ };
430
+ }
431
+ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hideRoot = false, depthSpacingScale = 1, suppliedItemCountRange) {
432
+ const treeNodes = treeData.descendants();
433
+ const nodes = treeNodes;
434
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
435
+ treeNodes.map((d) => d.data)
436
+ );
437
+ const maxNodeDimensions = {
438
+ WIDTH: itemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
439
+ HEIGHT: itemCountRange.maxNodeHeight ?? MAX_NODE_HEIGHT
440
+ };
394
441
  const maximumLevel = d3Lib.max(nodes, (d) => d.depth);
395
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
396
- const depthSeparationHorizontal = HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale;
442
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
443
+ const depthSeparation = layoutSpacings.verticalDepth;
444
+ const depthSeparationHorizontal = layoutSpacings.horizontalDepth;
397
445
  if (isVerticalOrientation(orientation)) {
398
446
  const svgHeight2 = hideRoot ? maximumLevel * depthSeparation : maximumLevel * depthSeparation + maxNodeDimensions.HEIGHT + ACTIONS_OVERLAY_HEIGHT / 2;
399
447
  const rootWidth = calculateNodeDimensions(
@@ -455,10 +503,15 @@ function calculateSvgDimensions(d3Lib, treeData, orientation = "horizontal", hid
455
503
  const svgWidth = maximumLevel * depthSeparationHorizontal + maxNodeDimensions.WIDTH + ACTIONS_OVERLAY_WIDTH;
456
504
  return { svgWidth, svgHeight, highestVerticalCoordinate, rootDepthOffset: 0 };
457
505
  }
458
- function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1) {
506
+ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation = "horizontal", rootDepthOffset = 0, svgWidth = 0, depthSpacingScale = 1, suppliedItemCountRange) {
507
+ const treeNodes = treeData.descendants();
508
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
509
+ treeNodes.map((d) => d.data)
510
+ );
511
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
459
512
  if (isVerticalOrientation(orientation)) {
460
513
  const maximumLevel = d3Lib.max(treeData.descendants(), (d) => d.depth);
461
- const depthSeparation = VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale;
514
+ const depthSeparation = layoutSpacings.verticalDepth;
462
515
  treeData.each(function(d) {
463
516
  d.x = d.x + highestVerticalCoordinate;
464
517
  d.y = depthAxisSign(orientation) === -1 ? (maximumLevel - d.depth) * depthSeparation : d.depth * depthSeparation + rootDepthOffset;
@@ -480,24 +533,30 @@ function setupTreeLayout(d3Lib, treeData, highestVerticalCoordinate, orientation
480
533
  });
481
534
  return treeData;
482
535
  }
483
- function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1) {
536
+ function createTreeLayout(d3Lib, root, orientation = "horizontal", depthSpacingScale = 1, suppliedItemCountRange) {
537
+ const nodes = root.descendants();
538
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
539
+ nodes.map((d) => d.data)
540
+ );
541
+ const layoutSpacings = getLayoutSpacings(itemCountRange, depthSpacingScale);
484
542
  const nodeSize = isVerticalOrientation(orientation) ? [
485
- SIBLING_SEPARATION_BETWEEN_NODES,
486
- VERTICAL_ORIENTATION_DEPTH_SEPARATION * depthSpacingScale
543
+ layoutSpacings.verticalSibling,
544
+ layoutSpacings.verticalDepth
487
545
  ] : [
488
- VERTICAL_SEPARATION_BETWEEN_NODES,
489
- HORIZONTAL_SEPARATION_BETWEEN_NODES * depthSpacingScale
546
+ layoutSpacings.verticalBreadth,
547
+ layoutSpacings.horizontalDepth
490
548
  ];
491
549
  const treeLayout = d3Lib.tree().nodeSize(nodeSize).separation(() => 1);
492
550
  return treeLayout(root);
493
551
  }
494
- function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null) {
552
+ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNodeId, reparentForbiddenIds, rootColor, callbacks, orientation = "horizontal", hideRoot = false, selectedNodeId = null, suppliedItemCountRange) {
495
553
  const { onReparentTargetSelect, onNodeClick } = callbacks;
496
554
  if (!svgRef.current) {
497
555
  throw new Error("SVG reference is null");
498
556
  }
499
- const itemCountRange = getItemCountRange(
500
- treeData.descendants().map((d) => d.data)
557
+ const treeNodes = treeData.descendants();
558
+ const itemCountRange = suppliedItemCountRange ?? getItemCountRange(
559
+ treeNodes.map((d) => d.data)
501
560
  );
502
561
  const svg = d3Lib.select(svgRef.current).append("svg").attr("width", svgWidth).attr("height", svgHeight).style("overflow", "visible").append("g");
503
562
  const safeRootId = treeData.data.id.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -645,7 +704,7 @@ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNod
645
704
  }
646
705
 
647
706
  // src/FloatingControls.tsx
648
- var import_md = require("react-icons/md");
707
+ var import_ui = require("@behindthemusictree/ui");
649
708
 
650
709
  // src/BusyIndicator.tsx
651
710
  var import_jsx_runtime2 = require("react/jsx-runtime");
@@ -658,7 +717,7 @@ function BusyIndicator() {
658
717
 
659
718
  // src/FloatingControls.tsx
660
719
  var import_jsx_runtime3 = require("react/jsx-runtime");
661
- function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
720
+ function FloatingControls({ panZoom, onFit, orientation, zoomControls, leading, busy }) {
662
721
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
663
722
  "div",
664
723
  {
@@ -669,31 +728,18 @@ function FloatingControls({ panZoom, onFit, orientation, leading, busy }) {
669
728
  children: [
670
729
  busy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BusyIndicator, {}),
671
730
  leading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "gtv-floating-controls-leading", children: leading }),
672
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "gtv-zoom-controls", children: [
673
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
674
- "button",
675
- {
676
- type: "button",
677
- className: ["gtv-zoom-btn", !panZoom.canZoomIn && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
678
- disabled: !panZoom.canZoomIn,
679
- onClick: panZoom.zoomIn,
680
- "aria-label": "Zoom in",
681
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdZoomIn, { className: "gtv-icon", size: 18 })
682
- }
683
- ),
684
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
685
- "button",
686
- {
687
- type: "button",
688
- className: ["gtv-zoom-btn", !panZoom.canZoomOut && "gtv-zoom-btn--disabled"].filter(Boolean).join(" "),
689
- disabled: !panZoom.canZoomOut,
690
- onClick: panZoom.zoomOut,
691
- "aria-label": "Zoom out",
692
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdZoomOut, { className: "gtv-icon", size: 18 })
693
- }
694
- ),
695
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: "gtv-zoom-btn", onClick: onFit, "aria-label": "Fit to frame", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_md.MdFitScreen, { className: "gtv-icon", size: 18 }) })
696
- ] })
731
+ zoomControls !== "never" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
732
+ import_ui.ZoomControls,
733
+ {
734
+ onZoomIn: panZoom.zoomIn,
735
+ onZoomOut: panZoom.zoomOut,
736
+ onFit,
737
+ canZoomIn: panZoom.canZoomIn,
738
+ canZoomOut: panZoom.canZoomOut,
739
+ orientation,
740
+ className: zoomControls === "auto" ? "gtv-zoom-controls gtv-zoom-controls--auto" : "gtv-zoom-controls"
741
+ }
742
+ )
697
743
  ]
698
744
  }
699
745
  );
@@ -1097,9 +1143,10 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1097
1143
  const newScale = clampZoomScale(Math.max(targetScale, currentScale), minScaleRef.current);
1098
1144
  const centerX = (rect.left + rect.width / 2 - viewportRect.left - view.x) / currentScale;
1099
1145
  const centerY = (rect.top + rect.height / 2 - viewportRect.top - view.y) / currentScale;
1100
- const availableCenterX = !obscured ? viewportRect.width / 2 : obscured.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : (viewportRect.width - obscured.width) / 2;
1146
+ const availableCenterX = obscured?.side === "left" ? obscured.width + (viewportRect.width - obscured.width) / 2 : obscured?.side === "right" ? (viewportRect.width - obscured.width) / 2 : viewportRect.width / 2;
1147
+ const availableCenterY = obscured?.side === "bottom" ? (viewportRect.height - obscured.height) / 2 : viewportRect.height / 2;
1101
1148
  const targetPanX = availableCenterX - centerX * newScale;
1102
- const targetPanY = viewportRect.height / 2 - centerY * newScale;
1149
+ const targetPanY = availableCenterY - centerY * newScale;
1103
1150
  zoomAnimationRef.current = null;
1104
1151
  if (animationFrameIdRef.current !== null) {
1105
1152
  cancelAnimationFrame(animationFrameIdRef.current);
@@ -1253,6 +1300,9 @@ function resolveInfoPanelSide() {
1253
1300
  }
1254
1301
  function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
1255
1302
  if (!element || !viewport) return null;
1303
+ if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
1304
+ return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
1305
+ }
1256
1306
  return { width: panelWidth, side: resolveInfoPanelSide() };
1257
1307
  }
1258
1308
 
@@ -1335,10 +1385,10 @@ function useTreeIndex(nodes) {
1335
1385
 
1336
1386
  // src/InfoPanel.tsx
1337
1387
  var import_react6 = require("react");
1338
- var import_md5 = require("react-icons/md");
1388
+ var import_md4 = require("react-icons/md");
1339
1389
 
1340
1390
  // src/InfoPanelExpandButton.tsx
1341
- var import_md2 = require("react-icons/md");
1391
+ var import_md = require("react-icons/md");
1342
1392
  var import_jsx_runtime4 = require("react/jsx-runtime");
1343
1393
  function InfoPanelExpandButton({ expanded, onExpandedChange, style }) {
1344
1394
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
@@ -1349,14 +1399,14 @@ function InfoPanelExpandButton({ expanded, onExpandedChange, style }) {
1349
1399
  style,
1350
1400
  onClick: () => onExpandedChange(!expanded),
1351
1401
  "aria-label": expanded ? "Collapse panel" : "Expand panel",
1352
- children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md2.MdCloseFullscreen, { className: "gtv-icon", size: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md2.MdOpenInFull, { className: "gtv-icon", size: 16 })
1402
+ children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md.MdCloseFullscreen, { className: "gtv-icon", size: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_md.MdOpenInFull, { className: "gtv-icon", size: 16 })
1353
1403
  }
1354
1404
  );
1355
1405
  }
1356
1406
 
1357
1407
  // src/InfoPanelSection.tsx
1358
1408
  var import_react4 = require("react");
1359
- var import_md3 = require("react-icons/md");
1409
+ var import_md2 = require("react-icons/md");
1360
1410
  var import_jsx_runtime5 = require("react/jsx-runtime");
1361
1411
  function InfoPanelSection({ title, children }) {
1362
1412
  const [open, setOpen] = (0, import_react4.useState)(true);
@@ -1370,7 +1420,7 @@ function InfoPanelSection({ title, children }) {
1370
1420
  onClick: () => setOpen((isOpen) => !isOpen),
1371
1421
  children: [
1372
1422
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "gtv-info-panel-children-title", children: title }),
1373
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_md3.MdExpandMore, { className: "gtv-icon gtv-info-panel-section-chevron", size: 16 })
1423
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_md2.MdExpandMore, { className: "gtv-icon gtv-info-panel-section-chevron", size: 16 })
1374
1424
  ]
1375
1425
  }
1376
1426
  ),
@@ -1380,7 +1430,7 @@ function InfoPanelSection({ title, children }) {
1380
1430
 
1381
1431
  // src/NodeToolbar.tsx
1382
1432
  var import_react5 = require("react");
1383
- var import_md4 = require("react-icons/md");
1433
+ var import_md3 = require("react-icons/md");
1384
1434
  var import_fa = require("react-icons/fa");
1385
1435
  var import_pi2 = require("react-icons/pi");
1386
1436
  var import_jsx_runtime6 = require("react/jsx-runtime");
@@ -1458,7 +1508,7 @@ function NodeToolbar({
1458
1508
  "aria-haspopup": "menu",
1459
1509
  "aria-expanded": menuOpen,
1460
1510
  onClick: () => setMenuOpen((open) => !open),
1461
- children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md4.MdMoreVert, { size: "0.6em" })
1511
+ children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md3.MdMoreVert, { size: "0.6em" })
1462
1512
  }
1463
1513
  ),
1464
1514
  menuOpen && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "gtv-menu-card gtv-toolbar-overflow-menu", children: [
@@ -1472,7 +1522,7 @@ function NodeToolbar({
1472
1522
  onRenameRequest?.(node);
1473
1523
  },
1474
1524
  children: [
1475
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-icon", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md4.MdModeEdit, { className: "gtv-icon", size: 13 }) }),
1525
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-icon", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_md3.MdModeEdit, { className: "gtv-icon", size: 13 }) }),
1476
1526
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "gtv-menu-row-label", children: "Rename" })
1477
1527
  ]
1478
1528
  }
@@ -1574,6 +1624,27 @@ function InfoPanel({
1574
1624
  hideExpandButton = false
1575
1625
  }) {
1576
1626
  const panelRef = (0, import_react6.useRef)(null);
1627
+ const [sheetFull, setSheetFull] = (0, import_react6.useState)(false);
1628
+ const grabberDragRef = (0, import_react6.useRef)(null);
1629
+ const onGrabberPointerDown = (event) => {
1630
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1631
+ grabberDragRef.current = { startY: event.clientY, dragged: false };
1632
+ };
1633
+ const onGrabberPointerUp = (event) => {
1634
+ const drag = grabberDragRef.current;
1635
+ if (!drag) return;
1636
+ const dy = event.clientY - drag.startY;
1637
+ if (Math.abs(dy) < INFO_PANEL_SHEET_DRAG_THRESHOLD_PX) return;
1638
+ drag.dragged = true;
1639
+ if (dy < 0) setSheetFull(true);
1640
+ else if (sheetFull) setSheetFull(false);
1641
+ else if (!hideClose) onClose();
1642
+ };
1643
+ const onGrabberClick = () => {
1644
+ const dragged = grabberDragRef.current?.dragged ?? false;
1645
+ grabberDragRef.current = null;
1646
+ if (!dragged) setSheetFull((full) => !full);
1647
+ };
1577
1648
  (0, import_react6.useEffect)(() => {
1578
1649
  panelRef.current?.toggleAttribute("inert", collapsed);
1579
1650
  }, [collapsed]);
@@ -1676,9 +1747,21 @@ function InfoPanel({
1676
1747
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1677
1748
  "div",
1678
1749
  {
1679
- className: `gtv-info-panel gtv-info-panel--${side}${expanded ? " gtv-info-panel--expanded" : ""}${expanded && !hasAside ? " gtv-info-panel--expanded-solo" : ""}${collapsed ? " gtv-info-panel--collapsed" : ""}`,
1750
+ className: `gtv-info-panel gtv-info-panel--sheet${sheetFull ? " gtv-info-panel--sheet-full" : ""} gtv-info-panel--${side}${expanded ? " gtv-info-panel--expanded" : ""}${expanded && !hasAside ? " gtv-info-panel--expanded-solo" : ""}${collapsed ? " gtv-info-panel--collapsed" : ""}`,
1680
1751
  ref: panelRef,
1681
1752
  children: [
1753
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1754
+ "button",
1755
+ {
1756
+ type: "button",
1757
+ className: "gtv-info-panel-grabber",
1758
+ "aria-label": sheetFull ? "Lower panel" : "Raise panel",
1759
+ "aria-expanded": sheetFull,
1760
+ onPointerDown: onGrabberPointerDown,
1761
+ onPointerUp: onGrabberPointerUp,
1762
+ onClick: onGrabberClick
1763
+ }
1764
+ ),
1682
1765
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1683
1766
  "div",
1684
1767
  {
@@ -1694,7 +1777,7 @@ function InfoPanel({
1694
1777
  onClick: onPrevious,
1695
1778
  "aria-label": previousLabel ? `Previous: ${previousLabel}` : "Previous",
1696
1779
  title: previousLabel,
1697
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdArrowBack, { className: "gtv-icon", size: 16 })
1780
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdArrowBack, { className: "gtv-icon", size: 16 })
1698
1781
  }
1699
1782
  ),
1700
1783
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
@@ -1717,7 +1800,7 @@ function InfoPanel({
1717
1800
  onClick: onZoomIn,
1718
1801
  "aria-label": "Zoom in",
1719
1802
  title: "Zoom in",
1720
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdZoomIn, { className: "gtv-icon", size: 16 })
1803
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdZoomIn, { className: "gtv-icon", size: 16 })
1721
1804
  }
1722
1805
  ),
1723
1806
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(InfoPanelExpandButton, { expanded, onExpandedChange, style: { color: textColor } }),
@@ -1729,7 +1812,7 @@ function InfoPanel({
1729
1812
  style: { color: textColor },
1730
1813
  onClick: onClose,
1731
1814
  "aria-label": "Close",
1732
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md5.MdClose, { className: "gtv-icon", size: 16 })
1815
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_md4.MdClose, { className: "gtv-icon", size: 16 })
1733
1816
  }
1734
1817
  )
1735
1818
  ]
@@ -1753,7 +1836,7 @@ function InfoPanel({
1753
1836
 
1754
1837
  // src/OverlayPanel.tsx
1755
1838
  var import_react7 = require("react");
1756
- var import_md6 = require("react-icons/md");
1839
+ var import_md5 = require("react-icons/md");
1757
1840
  var import_jsx_runtime8 = require("react/jsx-runtime");
1758
1841
  function OverlayPanel({
1759
1842
  title,
@@ -1793,7 +1876,7 @@ function OverlayPanel({
1793
1876
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-header", children: [
1794
1877
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: `gtv-info-panel-title${hideTitle ? " gtv-info-panel-title--hidden" : ""}`, children: title }),
1795
1878
  onExpandedChange && !hideExpandButton && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(InfoPanelExpandButton, { expanded, onExpandedChange }),
1796
- !hideClose && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "gtv-info-panel-close", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_md6.MdClose, { className: "gtv-icon", size: 16 }) })
1879
+ !hideClose && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "gtv-info-panel-close", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_md5.MdClose, { className: "gtv-icon", size: 16 }) })
1797
1880
  ] }),
1798
1881
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "gtv-info-panel-body", children: [
1799
1882
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "gtv-info-panel-aside gtv-overlay-content", children: content }),
@@ -1830,6 +1913,8 @@ function GenreTree({
1830
1913
  hideInfoPanelClose = false,
1831
1914
  hideInfoPanelTitle = false,
1832
1915
  itemCountLabel,
1916
+ itemCountScaleMax,
1917
+ nodeCountForSizing,
1833
1918
  readOnly,
1834
1919
  renderDangerZone,
1835
1920
  expanded,
@@ -1841,6 +1926,7 @@ function GenreTree({
1841
1926
  defaultViewport,
1842
1927
  onViewportChange,
1843
1928
  controlsOrientation = "vertical",
1929
+ showZoomControls = "auto",
1844
1930
  floatingControlsLeading,
1845
1931
  busy
1846
1932
  }) {
@@ -1850,6 +1936,10 @@ function GenreTree({
1850
1936
  const stageRef = (0, import_react8.useRef)(null);
1851
1937
  const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
1852
1938
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId: selectedNodeIdProp, onSelectedNodeChange });
1939
+ const itemCountRange = (0, import_react8.useMemo)(
1940
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: nodeCountForSizing ?? nodes.length }),
1941
+ [nodes, itemCountScaleMax, nodeCountForSizing]
1942
+ );
1853
1943
  const showNodeInfoRef = (0, import_react8.useRef)(showNodeInfo);
1854
1944
  (0, import_react8.useEffect)(() => {
1855
1945
  showNodeInfoRef.current = showNodeInfo;
@@ -1864,7 +1954,8 @@ function GenreTree({
1864
1954
  d3,
1865
1955
  root,
1866
1956
  orientation,
1867
- depthSpacingScale
1957
+ depthSpacingScale,
1958
+ itemCountRange
1868
1959
  );
1869
1960
  const {
1870
1961
  svgWidth: width,
@@ -1876,7 +1967,8 @@ function GenreTree({
1876
1967
  originalTreeData,
1877
1968
  orientation,
1878
1969
  hideRoot,
1879
- depthSpacingScale
1970
+ depthSpacingScale,
1971
+ itemCountRange
1880
1972
  );
1881
1973
  const reshapedTreeData = setupTreeLayout(
1882
1974
  d3,
@@ -1885,7 +1977,8 @@ function GenreTree({
1885
1977
  orientation,
1886
1978
  rootDepthOffset,
1887
1979
  width,
1888
- depthSpacingScale
1980
+ depthSpacingScale,
1981
+ itemCountRange
1889
1982
  );
1890
1983
  return {
1891
1984
  treeData: reshapedTreeData,
@@ -1893,7 +1986,7 @@ function GenreTree({
1893
1986
  svgWidth: width,
1894
1987
  svgHeight: height
1895
1988
  };
1896
- }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale]);
1989
+ }, [nodes, rootColor, orientation, hideRoot, depthSpacingScale, itemCountRange]);
1897
1990
  const reparentForbiddenIds = (0, import_react8.useMemo)(() => {
1898
1991
  if (!reparentingNodeId) return [];
1899
1992
  const reparentingHierarchyNode = treeData.descendants().find((d) => d.data.id === reparentingNodeId);
@@ -1944,7 +2037,8 @@ function GenreTree({
1944
2037
  },
1945
2038
  orientation,
1946
2039
  hideRoot,
1947
- effectiveSelectedNodeId
2040
+ effectiveSelectedNodeId,
2041
+ itemCountRange
1948
2042
  );
1949
2043
  }, [
1950
2044
  treeData,
@@ -1965,7 +2059,8 @@ function GenreTree({
1965
2059
  additionalActions,
1966
2060
  orientation,
1967
2061
  hideRoot,
1968
- effectiveSelectedNodeId
2062
+ effectiveSelectedNodeId,
2063
+ itemCountRange
1969
2064
  ]);
1970
2065
  (0, import_react8.useEffect)(() => {
1971
2066
  if (!interactive || !selectedNodeIdProp) return;
@@ -2126,6 +2221,7 @@ function GenreTree({
2126
2221
  {
2127
2222
  panZoom,
2128
2223
  orientation: controlsOrientation,
2224
+ zoomControls: showZoomControls,
2129
2225
  leading: floatingControlsLeading,
2130
2226
  busy,
2131
2227
  onFit: () => panZoom.fitToFrame(queryTreeContentElements(svgRef.current))
@@ -2238,6 +2334,8 @@ function WheelCore({
2238
2334
  hideInfoPanelClose = false,
2239
2335
  hideInfoPanelTitle = false,
2240
2336
  itemCountLabel,
2337
+ itemCountScaleMax,
2338
+ nodeCountForSizing,
2241
2339
  readOnly,
2242
2340
  renderDangerZone,
2243
2341
  expanded,
@@ -2249,10 +2347,16 @@ function WheelCore({
2249
2347
  defaultViewport,
2250
2348
  onViewportChange,
2251
2349
  controlsOrientation = "vertical",
2350
+ showZoomControls = "auto",
2252
2351
  floatingControlsLeading,
2253
2352
  busy,
2254
2353
  selectedNodeId
2255
2354
  }) {
2355
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
2356
+ const wheelSizeRange = (0, import_react9.useMemo)(
2357
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
2358
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
2359
+ );
2256
2360
  const treeOrientation = direction === "left" ? "horizontal-anchored" : "vertical";
2257
2361
  const landingAngle = direction === "left" ? 90 : 0;
2258
2362
  const chipHalfExtentVar = direction === "left" ? "--gtv-wheel-chip-half-width" : "--gtv-wheel-chip-half-height";
@@ -2262,10 +2366,10 @@ function WheelCore({
2262
2366
  return getGenreTreeColor(nodes.find((n) => n.id === rootId)?.wikidataId);
2263
2367
  };
2264
2368
  const wheelRadius = (0, import_react9.useMemo)(
2265
- () => calculateWheelRadius(groups.length, MAX_NODE_WIDTH, WHEEL_RADIUS),
2266
- [groups.length]
2369
+ () => calculateWheelRadius(groups.length, wheelSizeRange.maxNodeWidth ?? MAX_NODE_WIDTH, WHEEL_RADIUS),
2370
+ [groups.length, wheelSizeRange.maxNodeWidth]
2267
2371
  );
2268
- const wheelViewportHeight = wheelRadius * 2 + MAX_NODE_HEIGHT / 2;
2372
+ const wheelViewportHeight = wheelRadius * 2 + (wheelSizeRange.maxNodeHeight ?? MAX_NODE_HEIGHT) / 2;
2269
2373
  const rootItemCountById = (0, import_react9.useMemo)(
2270
2374
  () => new Map(groups.map((group) => [group.root.id, group.root.itemCount])),
2271
2375
  [groups]
@@ -2312,16 +2416,18 @@ function WheelCore({
2312
2416
  () => getItemCountRange(
2313
2417
  groups.map((group) => ({
2314
2418
  itemCount: rootItemCountById.get(group.root.id)
2315
- }))
2419
+ })),
2420
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2316
2421
  ),
2317
- [groups, rootItemCountById]
2422
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
2318
2423
  );
2319
2424
  const rootChipHalfExtent = selectedGroup ? calculateRootAnchorClearance(
2320
2425
  d32,
2321
2426
  selectedGroup.nodes,
2322
2427
  rootItemCountById.get(selectedGroup.root.id),
2323
2428
  rootItemCountRange,
2324
- direction === "left" ? "WIDTH" : "HEIGHT"
2429
+ direction === "left" ? "WIDTH" : "HEIGHT",
2430
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
2325
2431
  ) : 0;
2326
2432
  const sectorFillGradient = (0, import_react9.useMemo)(
2327
2433
  () => buildWheelSectorGradient(
@@ -2411,6 +2517,8 @@ function WheelCore({
2411
2517
  {
2412
2518
  nodes: selectedGroup.nodes,
2413
2519
  itemCountLabel,
2520
+ itemCountScaleMax,
2521
+ nodeCountForSizing: fullNodeCountForSizing,
2414
2522
  orientation: treeOrientation,
2415
2523
  hideRoot: true,
2416
2524
  interactive: false,
@@ -2622,6 +2730,7 @@ function WheelCore({
2622
2730
  {
2623
2731
  panZoom,
2624
2732
  orientation: controlsOrientation,
2733
+ zoomControls: showZoomControls,
2625
2734
  leading: floatingControlsLeading,
2626
2735
  busy,
2627
2736
  onFit: () => panZoom.fitToFrame([
@@ -2654,18 +2763,32 @@ var d33 = __toESM(require("d3"), 1);
2654
2763
  // src/defer-click.ts
2655
2764
  function createDeferredClick() {
2656
2765
  let timeoutId = null;
2766
+ let pendingKey = void 0;
2767
+ let doubledAt = -Infinity;
2657
2768
  const cancel = () => {
2658
2769
  if (timeoutId !== null) clearTimeout(timeoutId);
2659
2770
  timeoutId = null;
2660
2771
  };
2661
- const schedule = (action) => {
2772
+ const schedule = (action, onDouble, key) => {
2773
+ if (onDouble && timeoutId !== null && key === pendingKey) {
2774
+ cancel();
2775
+ doubledAt = Date.now();
2776
+ onDouble();
2777
+ return;
2778
+ }
2662
2779
  cancel();
2780
+ pendingKey = key;
2663
2781
  timeoutId = setTimeout(() => {
2664
2782
  timeoutId = null;
2665
2783
  action();
2666
2784
  }, DOUBLE_CLICK_DELAY_MS);
2667
2785
  };
2668
- return { schedule, cancel };
2786
+ const doubleClick = (onDouble) => {
2787
+ cancel();
2788
+ if (Date.now() - doubledAt < DOUBLE_CLICK_DELAY_MS) return;
2789
+ onDouble();
2790
+ };
2791
+ return { schedule, cancel, doubleClick };
2669
2792
  }
2670
2793
 
2671
2794
  // src/radial-wheel-geometry.ts
@@ -2855,13 +2978,15 @@ function calculatePopSubtreeRadialExtent(hierarchy, coreRootCircleRadius = 0) {
2855
2978
  POP_TREE_DEPTH_RADIAL_SPACING
2856
2979
  ) + MAX_NODE_WIDTH / 2 + POP_SUBTREE_OUTER_MARGIN;
2857
2980
  }
2858
- function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING) {
2981
+ function computePopRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, coreRootCircleRadius, wedgeSpanDegrees = POP_WEDGE_SPAN_DEGREES, depthSpacing = POP_TREE_DEPTH_RADIAL_SPACING, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
2859
2982
  return layoutWedge(
2860
2983
  d3Lib,
2861
2984
  hierarchy,
2862
2985
  wedgeCenterAngleDegrees,
2863
2986
  wedgeSpanDegrees,
2864
- popRadiusOfDepth(coreRootCircleRadius, depthSpacing)
2987
+ popRadiusOfDepth(coreRootCircleRadius, depthSpacing),
2988
+ "center",
2989
+ dotRadius
2865
2990
  );
2866
2991
  }
2867
2992
  var MIN_DOT_GAP = 5.6;
@@ -2871,15 +2996,15 @@ function popRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2871
2996
  function coreRadiusOfDepth(coreRootCircleRadius, depthSpacing) {
2872
2997
  return (depth) => getRadialDepthRadius(depth + 1, coreRootCircleRadius, depthSpacing);
2873
2998
  }
2874
- function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2875
- const dotRadius = () => NODE_LAYOUT_SLOT_RADIUS;
2999
+ function measureWedge(_d3Lib, hierarchy, radiusOfDepth, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3000
+ const slotRadius = () => dotRadius;
2876
3001
  const offsetFromParent = /* @__PURE__ */ new Map();
2877
3002
  const shiftLevel = (level, by) => ({
2878
3003
  left: { ...level.left, x: level.left.x + by },
2879
3004
  right: { ...level.right, x: level.right.x + by }
2880
3005
  });
2881
3006
  const pack = (node) => {
2882
- const own = { x: 0, dotRadius: dotRadius() };
3007
+ const own = { x: 0, dotRadius: slotRadius() };
2883
3008
  if (!node.children) return [{ left: own, right: own }];
2884
3009
  const below = [];
2885
3010
  const childOffsets = [];
@@ -2911,7 +3036,7 @@ function measureWedge(_d3Lib, hierarchy, radiusOfDepth) {
2911
3036
  let min = Infinity;
2912
3037
  let max = -Infinity;
2913
3038
  hierarchy.each((d) => {
2914
- const inset = (dotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
3039
+ const inset = (slotRadius() + MIN_DOT_GAP / 2) / radiusOfDepth(d.depth);
2915
3040
  min = Math.min(min, d.x - inset);
2916
3041
  max = Math.max(max, d.x + inset);
2917
3042
  });
@@ -2931,8 +3056,8 @@ function splitAtBranching(hierarchy) {
2931
3056
  const children = branching.children;
2932
3057
  return children && children.length > 1 ? { chain, branching, children } : null;
2933
3058
  }
2934
- function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center") {
2935
- const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth);
3059
+ function layoutWedge(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, radiusOfDepth, slack = "center", dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3060
+ const neededRad = measureWedge(d3Lib, hierarchy, radiusOfDepth, dotRadius);
2936
3061
  const wedgeSpanRad = wedgeSpanDegrees * Math.PI / 180;
2937
3062
  const scale = Math.min(MAX_WEDGE_STRETCH, wedgeSpanRad / neededRad);
2938
3063
  const wedgeCenterRad = wedgeCenterAngleDegrees * Math.PI / 180;
@@ -3161,12 +3286,12 @@ function renderPopSubtree(d3Lib, svg, hierarchy, rootColor, reparentingNodeId, r
3161
3286
  group.on("click", (event) => {
3162
3287
  if (reparentingNodeId) return;
3163
3288
  const element = event.currentTarget;
3164
- deferredClick.schedule(() => onNodeClick?.(d.data, event, element));
3165
- });
3166
- group.on("dblclick", () => {
3167
- deferredClick.cancel();
3168
- onNodeDoubleClick?.(d.data);
3289
+ deferredClick.schedule(
3290
+ () => onNodeClick?.(d.data, event, element),
3291
+ () => onNodeDoubleClick?.(d.data)
3292
+ );
3169
3293
  });
3294
+ group.on("dblclick", () => deferredClick.doubleClick(() => onNodeDoubleClick?.(d.data)));
3170
3295
  });
3171
3296
  }
3172
3297
  function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
@@ -3243,14 +3368,15 @@ function buildCoreHierarchy(d3Lib, coreNodes) {
3243
3368
  });
3244
3369
  return hierarchy;
3245
3370
  }
3246
- function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing) {
3371
+ function computeCoreRadialLayout(d3Lib, hierarchy, wedgeCenterAngleDegrees, wedgeSpanDegrees, coreRootCircleRadius, depthSpacing, dotRadius = NODE_LAYOUT_SLOT_RADIUS) {
3247
3372
  return layoutWedge(
3248
3373
  d3Lib,
3249
3374
  hierarchy,
3250
3375
  wedgeCenterAngleDegrees,
3251
3376
  wedgeSpanDegrees,
3252
3377
  coreRadiusOfDepth(coreRootCircleRadius, depthSpacing),
3253
- "between-children"
3378
+ "between-children",
3379
+ dotRadius
3254
3380
  );
3255
3381
  }
3256
3382
  function calculateCoreSubtreeRadialExtent(hierarchy, depthSpacing, coreRootCircleRadius = 0) {
@@ -3324,6 +3450,8 @@ function WheelRadialCore({
3324
3450
  hideInfoPanelClose = false,
3325
3451
  hideInfoPanelTitle = false,
3326
3452
  itemCountLabel,
3453
+ itemCountScaleMax,
3454
+ nodeCountForSizing,
3327
3455
  readOnly,
3328
3456
  renderDangerZone,
3329
3457
  expanded,
@@ -3335,10 +3463,12 @@ function WheelRadialCore({
3335
3463
  defaultViewport,
3336
3464
  onViewportChange,
3337
3465
  controlsOrientation = "vertical",
3466
+ showZoomControls = "auto",
3338
3467
  floatingControlsLeading,
3339
3468
  busy,
3340
3469
  selectedNodeId
3341
3470
  }) {
3471
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
3342
3472
  const index = useTreeIndex(nodes);
3343
3473
  const groups = (0, import_react10.useMemo)(() => groupNodesByRoot(nodes), [nodes]);
3344
3474
  const rootColorOf = (node) => getGenreTreeColor(index.byId.get(index.rootIdById.get(node.id) ?? node.id)?.wikidataId);
@@ -3391,11 +3521,15 @@ function WheelRadialCore({
3391
3521
  () => getItemCountRange(
3392
3522
  groups.map((group) => ({
3393
3523
  itemCount: rootItemCountById.get(group.root.id)
3394
- }))
3524
+ })),
3525
+ { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }
3395
3526
  ),
3396
- [groups, rootItemCountById]
3527
+ [groups, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
3528
+ );
3529
+ const wheelItemCountRange = (0, import_react10.useMemo)(
3530
+ () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
3531
+ [nodes, itemCountScaleMax, fullNodeCountForSizing]
3397
3532
  );
3398
- const wheelItemCountRange = (0, import_react10.useMemo)(() => getItemCountRange(nodes), [nodes]);
3399
3533
  const sectorSpanByRootId = (0, import_react10.useMemo)(() => {
3400
3534
  const map = /* @__PURE__ */ new Map();
3401
3535
  if (groups.length <= 1) return map;
@@ -3445,10 +3579,10 @@ function WheelRadialCore({
3445
3579
  const chipClearanceFloor = (0, import_react10.useMemo)(
3446
3580
  () => calculateWheelRadiusForAngles(
3447
3581
  layout.map((slot) => slot.angle),
3448
- MAX_NODE_WIDTH,
3582
+ wheelItemCountRange.maxNodeWidth ?? MAX_NODE_WIDTH,
3449
3583
  WHEEL_RADIUS
3450
3584
  ),
3451
- [layout]
3585
+ [layout, wheelItemCountRange.maxNodeWidth]
3452
3586
  );
3453
3587
  const maxCoreExtentDelta = (0, import_react10.useMemo)(() => {
3454
3588
  let extent = 0;
@@ -3818,6 +3952,7 @@ function WheelRadialCore({
3818
3952
  {
3819
3953
  panZoom,
3820
3954
  orientation: controlsOrientation,
3955
+ zoomControls: showZoomControls,
3821
3956
  leading: floatingControlsLeading,
3822
3957
  busy,
3823
3958
  onFit: () => panZoom.fitToFrame([wheelCircleRef.current, coreSvgRef.current])
@@ -3864,7 +3999,7 @@ function useFocusHistory(focusNodeId, isFocusable) {
3864
3999
 
3865
4000
  // src/WheelBreadcrumb.tsx
3866
4001
  var import_react12 = require("react");
3867
- var import_md7 = require("react-icons/md");
4002
+ var import_md6 = require("react-icons/md");
3868
4003
  var import_jsx_runtime15 = require("react/jsx-runtime");
3869
4004
  var MAX_VISIBLE_ANCESTORS = 3;
3870
4005
  var ANCESTORS_KEPT_WHEN_COLLAPSED = 2;
@@ -3882,7 +4017,7 @@ function WheelBreadcrumb({ ancestors, currentName, onHome, onAncestor }) {
3882
4017
  "aria-label": "All genres",
3883
4018
  title: "All genres",
3884
4019
  onClick: onHome,
3885
- children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_md7.MdHome, { className: "gtv-icon", size: 14 })
4020
+ children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_md6.MdHome, { className: "gtv-icon", size: 14 })
3886
4021
  }
3887
4022
  ),
3888
4023
  separator,
@@ -4001,6 +4136,8 @@ function WheelRadialPopCoreCore({
4001
4136
  hideInfoPanelClose = false,
4002
4137
  hideInfoPanelTitle = false,
4003
4138
  itemCountLabel,
4139
+ itemCountScaleMax,
4140
+ nodeCountForSizing,
4004
4141
  readOnly,
4005
4142
  renderDangerZone,
4006
4143
  expanded,
@@ -4012,6 +4149,7 @@ function WheelRadialPopCoreCore({
4012
4149
  defaultViewport,
4013
4150
  onViewportChange,
4014
4151
  controlsOrientation = "vertical",
4152
+ showZoomControls = "auto",
4015
4153
  floatingControlsLeading,
4016
4154
  busy,
4017
4155
  allowWheelRotation = true,
@@ -4019,6 +4157,7 @@ function WheelRadialPopCoreCore({
4019
4157
  focusNodeId: requestedFocusNodeId = null,
4020
4158
  onFocusChange
4021
4159
  }) {
4160
+ const fullNodeCountForSizing = nodeCountForSizing ?? nodes.length;
4022
4161
  const index = useTreeIndex(nodes);
4023
4162
  const focusNodeId = (0, import_react13.useMemo)(
4024
4163
  () => requestedFocusNodeId !== null && isZoomableNode(nodes, requestedFocusNodeId) ? requestedFocusNodeId : null,
@@ -4158,8 +4297,8 @@ function WheelRadialPopCoreCore({
4158
4297
  () => getItemCountRange([
4159
4298
  ...nodes,
4160
4299
  ...[...rootItemCountById.values()].map((itemCount) => ({ itemCount }))
4161
- ]),
4162
- [nodes, rootItemCountById]
4300
+ ], { itemCountScaleMax, nodeCountForSizing: fullNodeCountForSizing }),
4301
+ [nodes, rootItemCountById, itemCountScaleMax, fullNodeCountForSizing]
4163
4302
  );
4164
4303
  const isCenterMainstream = isMainstreamPopLineage(index, centerNode.id);
4165
4304
  const centerNodeColor = isCenterMainstream ? "#ffffff" : getGenreNodeStyle(index, centerNode.id).fill;
@@ -4193,8 +4332,8 @@ function WheelRadialPopCoreCore({
4193
4332
  centerSubtreeHierarchy,
4194
4333
  0,
4195
4334
  POP_TREE_DEPTH_RADIAL_SPACING
4196
- ) : NODE_LAYOUT_SLOT_RADIUS,
4197
- [centerSubtreeHierarchy]
4335
+ ) : wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS,
4336
+ [centerSubtreeHierarchy, wheelItemCountRange.maxDotRadius]
4198
4337
  );
4199
4338
  const maxCoreExtentDelta = (0, import_react13.useMemo)(() => {
4200
4339
  let extent = 0;
@@ -4216,7 +4355,8 @@ function WheelRadialPopCoreCore({
4216
4355
  (radius) => new Map(
4217
4356
  groups.map((group) => {
4218
4357
  const { pop, core } = branchHierarchiesByRootId.get(group.root.id);
4219
- const chipWidth = 2 * NODE_LAYOUT_SLOT_RADIUS;
4358
+ const dotRadius = wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS;
4359
+ const chipWidth = 2 * dotRadius;
4220
4360
  return [
4221
4361
  group.root.id,
4222
4362
  {
@@ -4224,14 +4364,16 @@ function WheelRadialPopCoreCore({
4224
4364
  (hierarchy) => measureWedge(
4225
4365
  d34,
4226
4366
  hierarchy,
4227
- popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4367
+ popRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4368
+ dotRadius
4228
4369
  )
4229
4370
  ),
4230
4371
  core: core.map(
4231
4372
  (hierarchy) => measureWedge(
4232
4373
  d34,
4233
4374
  hierarchy,
4234
- coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING)
4375
+ coreRadiusOfDepth(radius, POP_TREE_DEPTH_RADIAL_SPACING),
4376
+ dotRadius
4235
4377
  )
4236
4378
  ),
4237
4379
  chip: 2 * Math.asin(Math.min(1, (chipWidth / 2 + CHIP_GAP) / radius))
@@ -4239,7 +4381,7 @@ function WheelRadialPopCoreCore({
4239
4381
  ];
4240
4382
  })
4241
4383
  ),
4242
- [groups, branchHierarchiesByRootId]
4384
+ [groups, branchHierarchiesByRootId, wheelItemCountRange.maxDotRadius]
4243
4385
  );
4244
4386
  const coreRootCircleRadius = (0, import_react13.useMemo)(
4245
4387
  () => solveRingRadius(
@@ -4249,11 +4391,11 @@ function WheelRadialPopCoreCore({
4249
4391
  ),
4250
4392
  Math.max(
4251
4393
  WHEEL_POP_CORE_RADIUS,
4252
- mainstreamCircleRadius + NODE_DOT_MAX_RADIUS + POP_SUBTREE_OUTER_MARGIN,
4394
+ mainstreamCircleRadius + (wheelItemCountRange.maxDotRadius ?? NODE_DOT_MAX_RADIUS) + POP_SUBTREE_OUTER_MARGIN,
4253
4395
  popReachRequiredRadius
4254
4396
  )
4255
4397
  ),
4256
- [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius]
4398
+ [measureBranchNeedsAt, mainstreamCircleRadius, popReachRequiredRadius, wheelItemCountRange.maxDotRadius]
4257
4399
  );
4258
4400
  const branchNeedsByRootId = (0, import_react13.useMemo)(
4259
4401
  () => measureBranchNeedsAt(coreRootCircleRadius),
@@ -4357,7 +4499,8 @@ function WheelRadialPopCoreCore({
4357
4499
  angle,
4358
4500
  coreRootCircleRadius,
4359
4501
  span,
4360
- POP_TREE_DEPTH_RADIAL_SPACING
4502
+ POP_TREE_DEPTH_RADIAL_SPACING,
4503
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4361
4504
  );
4362
4505
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4363
4506
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-pop-branch").attr("data-gtv-branch-index", branchIndex);
@@ -4423,7 +4566,8 @@ function WheelRadialPopCoreCore({
4423
4566
  angle,
4424
4567
  span,
4425
4568
  coreRootCircleRadius,
4426
- POP_TREE_DEPTH_RADIAL_SPACING
4569
+ POP_TREE_DEPTH_RADIAL_SPACING,
4570
+ wheelItemCountRange.maxDotRadius ?? NODE_LAYOUT_SLOT_RADIUS
4427
4571
  );
4428
4572
  const reparentForbiddenIds = reparentingNodeId ? laidOut.descendants().find((d) => d.data.id === reparentingNodeId)?.descendants().map((d) => d.data.id) ?? [] : [];
4429
4573
  const angleOf = (d) => {
@@ -4587,7 +4731,7 @@ function WheelRadialPopCoreCore({
4587
4731
  originGroup.selectAll("g.gtv-root-label").select("text").style("pointer-events", "all").style("cursor", "pointer").on("click", (_event, d) => {
4588
4732
  const chip = d.id === centerNode.id ? centerChipRef.current : viewportRef.current?.querySelector(`#group-${CSS.escape(d.id)}`);
4589
4733
  chip?.click();
4590
- }).on("dblclick", (_event, d) => focusOnNodeRef.current(d.id));
4734
+ }).on("dblclick", (_event, d) => chipClickRef.current.doubleClick(() => focusOnNodeRef.current(d.id)));
4591
4735
  }, [
4592
4736
  centerNode,
4593
4737
  centerHasPopDisc,
@@ -4746,7 +4890,7 @@ function WheelRadialPopCoreCore({
4746
4890
  )
4747
4891
  };
4748
4892
  });
4749
- }, [groups, continuousAngleByRootId, sectorSpanByRootId]);
4893
+ }, [groups, continuousAngleByRootId, sectorSpanByRootId, index]);
4750
4894
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
4751
4895
  "div",
4752
4896
  {
@@ -4856,9 +5000,9 @@ function WheelRadialPopCoreCore({
4856
5000
  showNodeInfo(centerNode, chip, viewportRef.current);
4857
5001
  onNodeClick?.(centerNode, nativeEvent);
4858
5002
  }
4859
- });
5003
+ }, () => focusOnNode(centerNode.id), centerNode.id);
4860
5004
  },
4861
- onDoubleClick: () => focusOnNode(centerNode.id),
5005
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(centerNode.id)),
4862
5006
  "aria-label": centerNode.name
4863
5007
  }
4864
5008
  ) }) }),
@@ -4918,9 +5062,9 @@ function WheelRadialPopCoreCore({
4918
5062
  showNodeInfo(root, chip, viewportRef.current);
4919
5063
  onNodeClick?.(root, nativeEvent);
4920
5064
  }
4921
- });
5065
+ }, () => focusOnNode(group.root.id), group.root.id);
4922
5066
  },
4923
- onDoubleClick: () => focusOnNode(group.root.id)
5067
+ onDoubleClick: () => chipClickRef.current.doubleClick(() => focusOnNode(group.root.id))
4924
5068
  }
4925
5069
  ) })
4926
5070
  },
@@ -5015,6 +5159,7 @@ function WheelRadialPopCoreCore({
5015
5159
  {
5016
5160
  panZoom,
5017
5161
  orientation: controlsOrientation,
5162
+ zoomControls: showZoomControls,
5018
5163
  leading: floatingControlsLeading,
5019
5164
  busy,
5020
5165
  onFit: () => panZoom.fitToFrame([