@behindthemusictree/genre-tree-view 6.5.1 → 7.1.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
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ ACCENT_TEXT_COLOR: () => ACCENT_TEXT_COLOR,
33
34
  DEFAULT_FRAME_HEIGHT: () => DEFAULT_FRAME_HEIGHT,
34
35
  DEFAULT_FRAME_WIDTH: () => DEFAULT_FRAME_WIDTH,
35
36
  GenreTree: () => GenreTree,
@@ -42,9 +43,13 @@ __export(src_exports, {
42
43
  GenreTreeWheelRadialPopCore: () => GenreTreeWheelRadialPopCore,
43
44
  GenreTreeWheelRight: () => GenreTreeWheelRight,
44
45
  GenreTreeWheelSkeleton: () => GenreTreeWheelSkeleton,
46
+ InfoPanel: () => InfoPanel,
47
+ ROOT_TINT_RATIO: () => ROOT_TINT_RATIO,
48
+ TEXT_COLOR: () => TEXT_COLOR,
45
49
  getGenreTreeColor: () => getGenreTreeColor,
46
50
  groupNodesByRoot: () => groupNodesByRoot,
47
- isZoomableNode: () => isZoomableNode
51
+ isZoomableNode: () => isZoomableNode,
52
+ tintSurface: () => tintSurface
48
53
  });
49
54
  module.exports = __toCommonJS(src_exports);
50
55
 
@@ -57,6 +62,7 @@ var import_server = __toESM(require("react-dom/server"), 1);
57
62
  var import_pi = require("react-icons/pi");
58
63
 
59
64
  // src/constants.ts
65
+ var import_ui = require("@behindthemusictree/ui");
60
66
  var import_d3 = require("d3");
61
67
  var SURFACE_BORDER_COLOR = "#E4E4E7";
62
68
  var CORNER_RADIUS = 8;
@@ -76,7 +82,6 @@ var ROOT_TINT_RATIO = 0.08;
76
82
  var POP_SECTOR_TINT_RATIO = 0.8;
77
83
  var UNDERGROUND_DARKEN_RATIO = 0.62;
78
84
  var UNDERGROUND_SECTOR_FILL_OPACITY = 0.15;
79
- var SCENE_SECTOR_OUTER_RADIUS = 1e5;
80
85
  var CENTER_NODE_NAME = "Mainstream Pop";
81
86
  var TREE_COLORS = [
82
87
  "#4F46E5",
@@ -228,11 +233,7 @@ function getItemCountRange(nodes, options = {}) {
228
233
  };
229
234
  }
230
235
  function logarithmicPosition(itemCount, range) {
231
- const { min, max } = range;
232
- const logMin = Math.log1p(Math.max(min, 0));
233
- const logMax = Math.log1p(Math.max(max, 0));
234
- const logValue = Math.log1p(Math.max(itemCount, 0));
235
- return logMax > logMin ? Math.max(0, Math.min(1, (logValue - logMin) / (logMax - logMin))) : 0;
236
+ return (0, import_ui.logScalePosition)(itemCount, range);
236
237
  }
237
238
  function calculateNodeDimensions(itemCount, range) {
238
239
  const t = logarithmicPosition(itemCount, range);
@@ -271,8 +272,11 @@ function calculateLabelRank(itemCount, range, depth) {
271
272
  return logarithmicPosition(itemCount, range) - Math.max(0, depth - 1) * LABEL_DEPTH_RANK_PENALTY;
272
273
  }
273
274
  function isLabelRankedForZoom(rank, zoomScale) {
274
- const zoomOut = Math.log(ZOOM_FOCUS_SCALE / zoomScale) / Math.log(ZOOM_FOCUS_SCALE / ZOOM_MIN_SCALE);
275
- return rank >= Math.min(1, zoomOut) * LABEL_MIN_RANK_AT_MIN_ZOOM;
275
+ return (0, import_ui.isRankShownAtZoom)(rank, zoomScale, {
276
+ minScale: ZOOM_MIN_SCALE,
277
+ focusScale: ZOOM_FOCUS_SCALE,
278
+ minRankAtMinZoom: LABEL_MIN_RANK_AT_MIN_ZOOM
279
+ });
276
280
  }
277
281
  function calculateRootLabelFontSize(layoutFontSize, zoomScale) {
278
282
  return Math.max(layoutFontSize, ROOT_LABEL_MIN_SCREEN_PX / zoomScale);
@@ -704,7 +708,7 @@ function renderTree(d3Lib, svgRef, treeData, svgWidth, svgHeight, reparentingNod
704
708
  }
705
709
 
706
710
  // src/FloatingControls.tsx
707
- var import_ui = require("@behindthemusictree/ui");
711
+ var import_ui2 = require("@behindthemusictree/ui");
708
712
 
709
713
  // src/BusyIndicator.tsx
710
714
  var import_jsx_runtime2 = require("react/jsx-runtime");
@@ -717,32 +721,22 @@ function BusyIndicator() {
717
721
 
718
722
  // src/FloatingControls.tsx
719
723
  var import_jsx_runtime3 = require("react/jsx-runtime");
720
- function FloatingControls({ panZoom, onFit, orientation, zoomControls, leading, busy }) {
721
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
722
- "div",
723
- {
724
- className: [
725
- "gtv-wheel-floating-controls",
726
- orientation === "horizontal" && "gtv-wheel-floating-controls--horizontal"
727
- ].filter(Boolean).join(" "),
728
- children: [
729
- busy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BusyIndicator, {}),
730
- leading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "gtv-floating-controls-leading", children: leading }),
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
- )
743
- ]
744
- }
745
- );
724
+ function FloatingControls({ panZoom, onFit, zoomControls, leading, busy }) {
725
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "gtv-wheel-floating-controls", children: [
726
+ busy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BusyIndicator, {}),
727
+ leading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "gtv-floating-controls-leading", children: leading }),
728
+ zoomControls !== "never" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
729
+ import_ui2.ZoomControls,
730
+ {
731
+ onZoomIn: panZoom.zoomIn,
732
+ onZoomOut: panZoom.zoomOut,
733
+ onFit,
734
+ canZoomIn: panZoom.canZoomIn,
735
+ canZoomOut: panZoom.canZoomOut,
736
+ className: zoomControls === "auto" ? "gtv-zoom-controls gtv-zoom-controls--auto" : "gtv-zoom-controls"
737
+ }
738
+ )
739
+ ] });
746
740
  }
747
741
 
748
742
  // src/use-pan-zoom.ts
@@ -900,8 +894,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
900
894
  if (!viewport || !bounds) return pan;
901
895
  const origin = axis === "x" ? bounds.originX : bounds.originY;
902
896
  const size = axis === "x" ? bounds.width : bounds.height;
903
- const viewportSize = axis === "x" ? viewport.clientWidth : viewport.clientHeight;
904
- const panMax = viewportSize - PAN_MIN_VISIBLE_PX - origin * scale;
897
+ const viewportSize2 = axis === "x" ? viewport.clientWidth : viewport.clientHeight;
898
+ const panMax = viewportSize2 - PAN_MIN_VISIBLE_PX - origin * scale;
905
899
  const panMin = PAN_MIN_VISIBLE_PX - size * scale - origin * scale;
906
900
  const lo = Math.min(panMin, panMax);
907
901
  const hi = Math.max(panMin, panMax);
@@ -989,6 +983,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
989
983
  [stepZoomAnimation]
990
984
  );
991
985
  const viewportSizeRef = (0, import_react.useRef)(null);
986
+ const [viewportSize, setViewportSize] = (0, import_react.useState)(null);
992
987
  (0, import_react.useEffect)(() => {
993
988
  const viewport = viewportRef.current;
994
989
  if (!viewport) return;
@@ -1001,6 +996,9 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1001
996
  const { width, height } = entry.contentRect;
1002
997
  const previous = viewportSizeRef.current;
1003
998
  viewportSizeRef.current = { width, height };
999
+ if (width > 0 && height > 0) {
1000
+ setViewportSize((prev) => prev?.width === width && prev.height === height ? prev : { width, height });
1001
+ }
1004
1002
  if (!previous || previous.width <= 0 || previous.height <= 0) {
1005
1003
  retryFrameRef.current();
1006
1004
  return;
@@ -1205,8 +1203,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1205
1203
  (element, targetScale, obscured, automatic = false) => {
1206
1204
  if (automatic && restoredRevisionRef.current !== void 0 && restoredFrameRef.current === options.frameKey && restoredSelectionRef.current === options.selectedNodeId) return;
1207
1205
  if (!automatic) operationRef.current += 1;
1208
- const viewportSize = viewportRef.current?.getBoundingClientRect();
1209
- if (automatic && (measuredFrameRef.current !== options.frameKey || fittedFrameRef.current === null || !viewportSize?.width || !viewportSize.height)) {
1206
+ const viewportSize2 = viewportRef.current?.getBoundingClientRect();
1207
+ if (automatic && (measuredFrameRef.current !== options.frameKey || fittedFrameRef.current === null || !viewportSize2?.width || !viewportSize2.height)) {
1210
1208
  const operation = operationRef.current;
1211
1209
  pendingCenterRef.current = () => {
1212
1210
  if (operation !== operationRef.current) return;
@@ -1407,6 +1405,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1407
1405
  panX,
1408
1406
  panY,
1409
1407
  zoomScale,
1408
+ minScale,
1409
+ viewportSize,
1410
1410
  transform: `translate(${panX}px, ${panY}px) scale(${zoomScale})`,
1411
1411
  canZoomIn: zoomScale < ZOOM_MAX_SCALE,
1412
1412
  canZoomOut: zoomScale > minScale,
@@ -2052,7 +2052,6 @@ function GenreTree({
2052
2052
  defaultViewport,
2053
2053
  viewportRestore,
2054
2054
  onViewportChange,
2055
- controlsOrientation = "vertical",
2056
2055
  showZoomControls = "auto",
2057
2056
  floatingControlsLeading,
2058
2057
  busy
@@ -2347,7 +2346,6 @@ function GenreTree({
2347
2346
  FloatingControls,
2348
2347
  {
2349
2348
  panZoom,
2350
- orientation: controlsOrientation,
2351
2349
  zoomControls: showZoomControls,
2352
2350
  leading: floatingControlsLeading,
2353
2351
  busy,
@@ -2435,6 +2433,121 @@ function computeRotationForSelection(currentRotationDeg, targetAngle, landingAng
2435
2433
  return currentRotationDeg + shortestDelta;
2436
2434
  }
2437
2435
 
2436
+ // src/radial-wheel-geometry.ts
2437
+ function computeRadialLayout(weights, clickedIndex, landingAngle) {
2438
+ const rootCount = weights.length;
2439
+ if (rootCount <= 0) return [];
2440
+ if (weights.some((weight) => weight <= 0)) {
2441
+ throw new Error("computeRadialLayout: every root weight (node count) must be positive");
2442
+ }
2443
+ const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2444
+ const orderedWidths = weights.map((_, i) => weights[(clickedIndex + i) % rootCount] / total * 360);
2445
+ const slots = new Array(rootCount);
2446
+ let boundary = landingAngle - orderedWidths[0] / 2;
2447
+ for (let i = 0; i < rootCount; i++) {
2448
+ const width = orderedWidths[i];
2449
+ const rootIndex = (clickedIndex + i) % rootCount;
2450
+ slots[rootIndex] = { rootIndex, angle: ((boundary + width / 2) % 360 + 360) % 360 };
2451
+ boundary += width;
2452
+ }
2453
+ return slots;
2454
+ }
2455
+ function computeSectorWidths(weights) {
2456
+ const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2457
+ return weights.map((weight) => weight / total * 360);
2458
+ }
2459
+ function subdivideWedge(wedgeCenterDeg, wedgeSpanDeg, weights) {
2460
+ if (weights.length <= 0) return [];
2461
+ const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2462
+ const spans = weights.map((weight) => weight / total * wedgeSpanDeg);
2463
+ const slices = [];
2464
+ let boundary = wedgeCenterDeg - wedgeSpanDeg / 2;
2465
+ for (const spanDeg of spans) {
2466
+ slices.push({ centerDeg: boundary + spanDeg / 2, spanDeg });
2467
+ boundary += spanDeg;
2468
+ }
2469
+ return slices;
2470
+ }
2471
+ function coverageRadius(viewportWidth, viewportHeight, minScale, contentRadius) {
2472
+ if (!(minScale > 0)) throw new Error("coverageRadius: minScale must be positive");
2473
+ return Math.ceil(contentRadius * Math.SQRT2 + Math.hypot(viewportWidth, viewportHeight) / minScale);
2474
+ }
2475
+ function buildSectorClipPathPolygon(sweepDeg, stepDeg = 10) {
2476
+ if (sweepDeg <= 0) return "";
2477
+ const points = ["50% 50%"];
2478
+ const steps = Math.max(1, Math.ceil(sweepDeg / stepDeg));
2479
+ for (let i = 0; i <= steps; i++) {
2480
+ const theta = Math.min(i * stepDeg, sweepDeg) * Math.PI / 180;
2481
+ const x = 50 + 50 * Math.sin(theta);
2482
+ const y = 50 - 50 * Math.cos(theta);
2483
+ points.push(`${x}% ${y}%`);
2484
+ }
2485
+ return `polygon(${points.join(", ")})`;
2486
+ }
2487
+ function calculateWheelRadiusForAngles(angles, maxChipWidth, baseRadius) {
2488
+ if (angles.length < 2) return baseRadius;
2489
+ const sorted = [...angles].sort((a, b) => a - b);
2490
+ let minGapDeg = 360;
2491
+ for (let i = 0; i < sorted.length; i++) {
2492
+ const next = i < sorted.length - 1 ? sorted[i + 1] : sorted[0] + 360;
2493
+ minGapDeg = Math.min(minGapDeg, next - sorted[i]);
2494
+ }
2495
+ if (minGapDeg <= 0) return baseRadius;
2496
+ const CHIP_GAP2 = 16;
2497
+ const requiredRadius = (maxChipWidth + CHIP_GAP2) / (2 * Math.sin(minGapDeg * Math.PI / 360));
2498
+ return Math.max(baseRadius, requiredRadius);
2499
+ }
2500
+ function solveRingRadius(angleNeededAt, floorRadius) {
2501
+ const fits = (radius) => angleNeededAt(radius) <= 2 * Math.PI;
2502
+ if (fits(floorRadius)) return floorRadius;
2503
+ let lo = floorRadius;
2504
+ let hi = floorRadius * 2;
2505
+ while (!fits(hi)) {
2506
+ lo = hi;
2507
+ hi *= 2;
2508
+ }
2509
+ while (hi - lo > 1) {
2510
+ const mid = (lo + hi) / 2;
2511
+ if (fits(mid)) hi = mid;
2512
+ else lo = mid;
2513
+ }
2514
+ return hi;
2515
+ }
2516
+ var boxesOverlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
2517
+ function findSectorAtAngle(angle, sectors) {
2518
+ const sector = sectors.find(
2519
+ ({ center, span }) => Math.abs(((angle - center) % 360 + 540) % 360 - 180) <= span / 2
2520
+ );
2521
+ return sector?.id ?? null;
2522
+ }
2523
+ var boxGap = (a, b) => Math.hypot(Math.max(0, a.x0 - b.x1, b.x0 - a.x1), Math.max(0, a.y0 - b.y1, b.y0 - a.y1));
2524
+ function pickEmptyLabelSpot(area, width, height, obstacles, prefer = () => true) {
2525
+ const STEPS = 9;
2526
+ const spanX = area.x1 - area.x0 - width;
2527
+ const spanY = area.y1 - area.y0 - height;
2528
+ if (spanX < 0 || spanY < 0) return null;
2529
+ const centerX = (area.x0 + area.x1) / 2;
2530
+ const centerY = (area.y0 + area.y1) / 2;
2531
+ let best = null;
2532
+ for (let i = 0; i < STEPS; i++) {
2533
+ for (let j = 0; j < STEPS; j++) {
2534
+ const x = area.x0 + width / 2 + spanX * i / (STEPS - 1);
2535
+ const y = area.y0 + height / 2 + spanY * j / (STEPS - 1);
2536
+ const box = { x0: x - width / 2, x1: x + width / 2, y0: y - height / 2, y1: y + height / 2 };
2537
+ if (obstacles.some((obstacle) => boxesOverlap(box, obstacle))) continue;
2538
+ const gap = Math.min(height, ...obstacles.map((obstacle) => boxGap(box, obstacle)));
2539
+ const score = [
2540
+ prefer(x, y) ? 1 : 0,
2541
+ gap,
2542
+ -Math.hypot(x - centerX, y - centerY)
2543
+ ];
2544
+ const k = best ? score.findIndex((value, index) => value !== best.score[index]) : 0;
2545
+ if (!best || k >= 0 && score[k] > best.score[k]) best = { x, y, score };
2546
+ }
2547
+ }
2548
+ return best && { x: best.x, y: best.y };
2549
+ }
2550
+
2438
2551
  // src/GenreTreeWheelBase.tsx
2439
2552
  var import_jsx_runtime10 = require("react/jsx-runtime");
2440
2553
  function WheelCore({
@@ -2474,7 +2587,6 @@ function WheelCore({
2474
2587
  defaultViewport,
2475
2588
  viewportRestore,
2476
2589
  onViewportChange,
2477
- controlsOrientation = "vertical",
2478
2590
  showZoomControls = "auto",
2479
2591
  floatingControlsLeading,
2480
2592
  busy,
@@ -2516,6 +2628,8 @@ function WheelCore({
2516
2628
  const stageRef = (0, import_react9.useRef)(null);
2517
2629
  const viewportFrame = (0, import_react9.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
2518
2630
  const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
2631
+ const { viewportSize, minScale } = panZoom;
2632
+ const wheelCoverageRadius = viewportSize ? coverageRadius(viewportSize.width, viewportSize.height, minScale, wheelRadius) : null;
2519
2633
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
2520
2634
  const wheelCircleRef = (0, import_react9.useRef)(null);
2521
2635
  const treeAnchorRef = (0, import_react9.useRef)(null);
@@ -2587,7 +2701,7 @@ function WheelCore({
2587
2701
  (0, import_react9.useEffect)(() => {
2588
2702
  if (!selectedNodeId || panel?.node.id === selectedNodeId) return;
2589
2703
  const targetNode = nodes.find((node) => node.id === selectedNodeId);
2590
- const element = treeAnchorRef.current?.querySelector(
2704
+ const element = viewportRef.current?.querySelector(
2591
2705
  `#group-${CSS.escape(selectedNodeId)}`
2592
2706
  );
2593
2707
  if (!targetNode || !element) return;
@@ -2611,6 +2725,7 @@ function WheelCore({
2611
2725
  ].filter(Boolean).join(" "),
2612
2726
  style: {
2613
2727
  "--gtv-wheel-radius": `${wheelRadius}px`,
2728
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
2614
2729
  "--gtv-wheel-viewport-height": `${wheelViewportHeight}px`,
2615
2730
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
2616
2731
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
@@ -2856,7 +2971,6 @@ function WheelCore({
2856
2971
  FloatingControls,
2857
2972
  {
2858
2973
  panZoom,
2859
- orientation: controlsOrientation,
2860
2974
  zoomControls: showZoomControls,
2861
2975
  leading: floatingControlsLeading,
2862
2976
  busy,
@@ -2887,6 +3001,9 @@ function GenreTreeWheelRight(props) {
2887
3001
  var import_react10 = require("react");
2888
3002
  var d33 = __toESM(require("d3"), 1);
2889
3003
 
3004
+ // src/pop-core-radial-layout.ts
3005
+ var import_ui3 = require("@behindthemusictree/ui");
3006
+
2890
3007
  // src/defer-click.ts
2891
3008
  function createDeferredClick() {
2892
3009
  let timeoutId = null;
@@ -2918,166 +3035,6 @@ function createDeferredClick() {
2918
3035
  return { schedule, cancel, doubleClick };
2919
3036
  }
2920
3037
 
2921
- // src/radial-wheel-geometry.ts
2922
- function computeRadialLayout(weights, clickedIndex, landingAngle) {
2923
- const rootCount = weights.length;
2924
- if (rootCount <= 0) return [];
2925
- if (weights.some((weight) => weight <= 0)) {
2926
- throw new Error("computeRadialLayout: every root weight (node count) must be positive");
2927
- }
2928
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2929
- const orderedWidths = weights.map((_, i) => weights[(clickedIndex + i) % rootCount] / total * 360);
2930
- const slots = new Array(rootCount);
2931
- let boundary = landingAngle - orderedWidths[0] / 2;
2932
- for (let i = 0; i < rootCount; i++) {
2933
- const width = orderedWidths[i];
2934
- const rootIndex = (clickedIndex + i) % rootCount;
2935
- slots[rootIndex] = { rootIndex, angle: ((boundary + width / 2) % 360 + 360) % 360 };
2936
- boundary += width;
2937
- }
2938
- return slots;
2939
- }
2940
- function computeSectorWidths(weights) {
2941
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2942
- return weights.map((weight) => weight / total * 360);
2943
- }
2944
- function subdivideWedge(wedgeCenterDeg, wedgeSpanDeg, weights) {
2945
- if (weights.length <= 0) return [];
2946
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2947
- const spans = weights.map((weight) => weight / total * wedgeSpanDeg);
2948
- const slices = [];
2949
- let boundary = wedgeCenterDeg - wedgeSpanDeg / 2;
2950
- for (const spanDeg of spans) {
2951
- slices.push({ centerDeg: boundary + spanDeg / 2, spanDeg });
2952
- boundary += spanDeg;
2953
- }
2954
- return slices;
2955
- }
2956
- function buildSectorClipPathPolygon(sweepDeg, stepDeg = 10) {
2957
- if (sweepDeg <= 0) return "";
2958
- const points = ["50% 50%"];
2959
- const steps = Math.max(1, Math.ceil(sweepDeg / stepDeg));
2960
- for (let i = 0; i <= steps; i++) {
2961
- const theta = Math.min(i * stepDeg, sweepDeg) * Math.PI / 180;
2962
- const x = 50 + 50 * Math.sin(theta);
2963
- const y = 50 - 50 * Math.cos(theta);
2964
- points.push(`${x}% ${y}%`);
2965
- }
2966
- return `polygon(${points.join(", ")})`;
2967
- }
2968
- function calculateWheelRadiusForAngles(angles, maxChipWidth, baseRadius) {
2969
- if (angles.length < 2) return baseRadius;
2970
- const sorted = [...angles].sort((a, b) => a - b);
2971
- let minGapDeg = 360;
2972
- for (let i = 0; i < sorted.length; i++) {
2973
- const next = i < sorted.length - 1 ? sorted[i + 1] : sorted[0] + 360;
2974
- minGapDeg = Math.min(minGapDeg, next - sorted[i]);
2975
- }
2976
- if (minGapDeg <= 0) return baseRadius;
2977
- const CHIP_GAP2 = 16;
2978
- const requiredRadius = (maxChipWidth + CHIP_GAP2) / (2 * Math.sin(minGapDeg * Math.PI / 360));
2979
- return Math.max(baseRadius, requiredRadius);
2980
- }
2981
- function solveRingRadius(angleNeededAt, floorRadius) {
2982
- const fits = (radius) => angleNeededAt(radius) <= 2 * Math.PI;
2983
- if (fits(floorRadius)) return floorRadius;
2984
- let lo = floorRadius;
2985
- let hi = floorRadius * 2;
2986
- while (!fits(hi)) {
2987
- lo = hi;
2988
- hi *= 2;
2989
- }
2990
- while (hi - lo > 1) {
2991
- const mid = (lo + hi) / 2;
2992
- if (fits(mid)) hi = mid;
2993
- else lo = mid;
2994
- }
2995
- return hi;
2996
- }
2997
- var boxesOverlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
2998
- var GRID_CELL = 64;
2999
- function createBoxGrid() {
3000
- const cells = /* @__PURE__ */ new Map();
3001
- const cellRange = (box) => ({
3002
- cx0: Math.floor(box.x0 / GRID_CELL),
3003
- cx1: Math.floor(box.x1 / GRID_CELL),
3004
- cy0: Math.floor(box.y0 / GRID_CELL),
3005
- cy1: Math.floor(box.y1 / GRID_CELL)
3006
- });
3007
- return {
3008
- insert(box, value) {
3009
- const { cx0, cx1, cy0, cy1 } = cellRange(box);
3010
- for (let cx = cx0; cx <= cx1; cx++) {
3011
- for (let cy = cy0; cy <= cy1; cy++) {
3012
- const key = `${cx},${cy}`;
3013
- const bucket = cells.get(key);
3014
- if (bucket) bucket.push({ box, value });
3015
- else cells.set(key, [{ box, value }]);
3016
- }
3017
- }
3018
- },
3019
- some(box, predicate) {
3020
- const { cx0, cx1, cy0, cy1 } = cellRange(box);
3021
- for (let cx = cx0; cx <= cx1; cx++) {
3022
- for (let cy = cy0; cy <= cy1; cy++) {
3023
- if (cells.get(`${cx},${cy}`)?.some((entry) => boxesOverlap(box, entry.box) && predicate(entry.value))) {
3024
- return true;
3025
- }
3026
- }
3027
- }
3028
- return false;
3029
- }
3030
- };
3031
- }
3032
- function placeLabels(items) {
3033
- const placement = /* @__PURE__ */ new Map();
3034
- const keptGrid = createBoxGrid();
3035
- const dotGrid = createBoxGrid();
3036
- for (const item of items) dotGrid.insert(item.dot, item.id);
3037
- for (const item of [...items].sort((a, b) => b.priority - a.priority)) {
3038
- let index = item.labels.findIndex(
3039
- (label) => !keptGrid.some(label, () => true) && !dotGrid.some(label, (id) => id !== item.id)
3040
- );
3041
- if (index < 0 && item.forced && item.labels.length > 0) index = 0;
3042
- placement.set(item.id, index);
3043
- if (index >= 0) keptGrid.insert(item.labels[index], true);
3044
- }
3045
- return placement;
3046
- }
3047
- function findSectorAtAngle(angle, sectors) {
3048
- const sector = sectors.find(
3049
- ({ center, span }) => Math.abs(((angle - center) % 360 + 540) % 360 - 180) <= span / 2
3050
- );
3051
- return sector?.id ?? null;
3052
- }
3053
- var boxGap = (a, b) => Math.hypot(Math.max(0, a.x0 - b.x1, b.x0 - a.x1), Math.max(0, a.y0 - b.y1, b.y0 - a.y1));
3054
- function pickEmptyLabelSpot(area, width, height, obstacles, prefer = () => true) {
3055
- const STEPS = 9;
3056
- const spanX = area.x1 - area.x0 - width;
3057
- const spanY = area.y1 - area.y0 - height;
3058
- if (spanX < 0 || spanY < 0) return null;
3059
- const centerX = (area.x0 + area.x1) / 2;
3060
- const centerY = (area.y0 + area.y1) / 2;
3061
- let best = null;
3062
- for (let i = 0; i < STEPS; i++) {
3063
- for (let j = 0; j < STEPS; j++) {
3064
- const x = area.x0 + width / 2 + spanX * i / (STEPS - 1);
3065
- const y = area.y0 + height / 2 + spanY * j / (STEPS - 1);
3066
- const box = { x0: x - width / 2, x1: x + width / 2, y0: y - height / 2, y1: y + height / 2 };
3067
- if (obstacles.some((obstacle) => boxesOverlap(box, obstacle))) continue;
3068
- const gap = Math.min(height, ...obstacles.map((obstacle) => boxGap(box, obstacle)));
3069
- const score = [
3070
- prefer(x, y) ? 1 : 0,
3071
- gap,
3072
- -Math.hypot(x - centerX, y - centerY)
3073
- ];
3074
- const k = best ? score.findIndex((value, index) => value !== best.score[index]) : 0;
3075
- if (!best || k >= 0 && score[k] > best.score[k]) best = { x, y, score };
3076
- }
3077
- }
3078
- return best && { x: best.x, y: best.y };
3079
- }
3080
-
3081
3038
  // src/pop-core-radial-layout.ts
3082
3039
  var POP_WEDGE_SPAN_DEGREES = 80;
3083
3040
  var POP_SUBTREE_OUTER_MARGIN = 24;
@@ -3244,11 +3201,10 @@ function getRadialPointOnCircle(angleDegrees, radius) {
3244
3201
  const angleRad = angleDegrees * Math.PI / 180;
3245
3202
  return { x: radius * Math.sin(angleRad), y: -radius * Math.cos(angleRad) };
3246
3203
  }
3247
- var LABEL_CHAR_WIDTH_RATIO = 0.6;
3248
3204
  function getDotLabelGeometry(x, name, itemCount, itemCountRange, zoomScale) {
3249
3205
  const dotRadius = calculateNodeDotRadius(itemCount, itemCountRange);
3250
3206
  const fontSize = zoomScale === void 0 ? calculateNodeFontSize(itemCount, itemCountRange) : calculateLabelFontSizeAtZoom(itemCount, itemCountRange, zoomScale);
3251
- const candidates = getLabelCandidates(x, name, dotRadius, fontSize);
3207
+ const candidates = (0, import_ui3.dotLabelCandidates)(name, dotRadius, fontSize, { gap: NODE_DOT_LABEL_GAP, preferRight: x >= 0 });
3252
3208
  const { x: labelX, box: label } = candidates[0];
3253
3209
  const dot = { x0: -dotRadius, x1: dotRadius, y0: -dotRadius, y1: dotRadius };
3254
3210
  const halfHeight = Math.max(dotRadius, fontSize / 2);
@@ -3268,25 +3224,6 @@ function getDotLabelGeometry(x, name, itemCount, itemCountRange, zoomScale) {
3268
3224
  candidates
3269
3225
  };
3270
3226
  }
3271
- function getLabelCandidates(x, name, dotRadius, fontSize) {
3272
- const width = name.length * fontSize * LABEL_CHAR_WIDTH_RATIO;
3273
- const halfFont = fontSize / 2;
3274
- const side = (right2) => {
3275
- const labelX = (right2 ? 1 : -1) * (dotRadius + NODE_DOT_LABEL_GAP);
3276
- return {
3277
- x: labelX,
3278
- y: 0,
3279
- anchor: right2 ? "start" : "end",
3280
- box: { x0: right2 ? labelX : labelX - width, x1: right2 ? labelX + width : labelX, y0: -halfFont, y1: halfFont }
3281
- };
3282
- };
3283
- const stacked = (below) => {
3284
- const y = (below ? 1 : -1) * (dotRadius + NODE_DOT_LABEL_GAP + halfFont);
3285
- return { x: 0, y, anchor: "middle", box: { x0: -width / 2, x1: width / 2, y0: y - halfFont, y1: y + halfFont } };
3286
- };
3287
- const right = x >= 0;
3288
- return [side(right), side(!right), stacked(false), stacked(true)];
3289
- }
3290
3227
  function renderRootLabels(container, roots) {
3291
3228
  container.append("g").attr("class", "gtv-wheel-root-labels").selectAll("g.gtv-root-label").data(roots).join("g").attr("class", "gtv-root-label").attr("transform", (d) => `translate(${d.x}, ${d.y})`).append("text").attr("class", "gtv-node-label").attr("dominant-baseline", "middle").attr("fill", TEXT_COLOR).text((d) => d.name);
3292
3229
  }
@@ -3442,10 +3379,10 @@ function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
3442
3379
  calculateLabelFontSizeAtZoom(d.itemCount, itemCountRange, zoomScale),
3443
3380
  zoomScale
3444
3381
  );
3445
- return [d.id, { fontSize, candidates: getLabelCandidates(d.x, d.name, d.radius, fontSize) }];
3382
+ return [d.id, { fontSize, candidates: (0, import_ui3.dotLabelCandidates)(d.name, d.radius, fontSize, { gap: NODE_DOT_LABEL_GAP, preferRight: d.x >= 0 }) }];
3446
3383
  })
3447
3384
  );
3448
- const placement = placeLabels([
3385
+ const placement = (0, import_ui3.placeLabels)([
3449
3386
  ...roots.data().map((d) => ({
3450
3387
  id: `root:${d.id}`,
3451
3388
  labels: rootGeometryById.get(d.id).candidates.map((candidate) => offset(candidate.box, d.x, d.y)),
@@ -3590,7 +3527,6 @@ function WheelRadialCore({
3590
3527
  defaultViewport,
3591
3528
  viewportRestore,
3592
3529
  onViewportChange,
3593
- controlsOrientation = "vertical",
3594
3530
  showZoomControls = "auto",
3595
3531
  floatingControlsLeading,
3596
3532
  busy,
@@ -3733,6 +3669,8 @@ function WheelRadialCore({
3733
3669
  () => Math.max(chipClearanceFloor, chipClearanceFloor + maxCoreExtentDelta),
3734
3670
  [chipClearanceFloor, maxCoreExtentDelta]
3735
3671
  );
3672
+ const { viewportSize, minScale } = panZoom;
3673
+ const wheelCoverageRadius = viewportSize ? coverageRadius(viewportSize.width, viewportSize.height, minScale, wheelRadius) : null;
3736
3674
  const onRootSelectRef = (0, import_react10.useRef)(onRootSelect);
3737
3675
  (0, import_react10.useEffect)(() => {
3738
3676
  onRootSelectRef.current = onRootSelect;
@@ -3884,6 +3822,7 @@ function WheelRadialCore({
3884
3822
  ].filter(Boolean).join(" "),
3885
3823
  style: {
3886
3824
  "--gtv-wheel-radius": `${wheelRadius}px`,
3825
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
3887
3826
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
3888
3827
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
3889
3828
  touchAction: panZoom.touchAction
@@ -4078,7 +4017,6 @@ function WheelRadialCore({
4078
4017
  FloatingControls,
4079
4018
  {
4080
4019
  panZoom,
4081
- orientation: controlsOrientation,
4082
4020
  zoomControls: showZoomControls,
4083
4021
  leading: floatingControlsLeading,
4084
4022
  busy,
@@ -4098,6 +4036,7 @@ function GenreTreeWheelRadial(props) {
4098
4036
 
4099
4037
  // src/GenreTreeWheelRadialPopCoreBase.tsx
4100
4038
  var import_react13 = require("react");
4039
+ var import_ui4 = require("@behindthemusictree/ui");
4101
4040
  var d34 = __toESM(require("d3"), 1);
4102
4041
 
4103
4042
  // src/use-focus-history.ts
@@ -4276,7 +4215,6 @@ function WheelRadialPopCoreCore({
4276
4215
  defaultViewport,
4277
4216
  viewportRestore,
4278
4217
  onViewportChange,
4279
- controlsOrientation = "vertical",
4280
4218
  showZoomControls = "auto",
4281
4219
  floatingControlsLeading,
4282
4220
  busy,
@@ -4602,6 +4540,12 @@ function WheelRadialPopCoreCore({
4602
4540
  () => coreRootCircleRadius + maxCoreExtentDelta,
4603
4541
  [coreRootCircleRadius, maxCoreExtentDelta]
4604
4542
  );
4543
+ const { viewportSize, minScale } = panZoom;
4544
+ const wheelCoverageRadius = viewportSize ? coverageRadius(viewportSize.width, viewportSize.height, minScale, svgCanvasRadius) : null;
4545
+ const wheelCoverageRadiusRef = (0, import_react13.useRef)(wheelCoverageRadius);
4546
+ (0, import_react13.useEffect)(() => {
4547
+ wheelCoverageRadiusRef.current = wheelCoverageRadius;
4548
+ }, [wheelCoverageRadius]);
4605
4549
  const middleCircleFloor = mainstreamCircleRadius;
4606
4550
  const onRootSelectRef = (0, import_react13.useRef)(onRootSelect);
4607
4551
  (0, import_react13.useEffect)(() => {
@@ -4715,13 +4659,14 @@ function WheelRadialPopCoreCore({
4715
4659
  const next = at >= 0 ? siblings[at + 1] : void 0;
4716
4660
  const startDeg = prev ? (subtreeRange(prev).max + mine.min) / 2 : angle - span / 2;
4717
4661
  const endDeg = next ? (mine.max + subtreeRange(next).min) / 2 : angle + span / 2;
4718
- const arcPath = d34.arc()({
4662
+ const startAngle = startDeg * Math.PI / 180;
4663
+ const endAngle = endDeg * Math.PI / 180;
4664
+ sectorGroup.append("path").attr("class", "gtv-wheel-scene-sector").attr("data-start-angle", startAngle).attr("data-end-angle", endAngle).attr("d", d34.arc()({
4719
4665
  innerRadius: coreRootCircleRadius,
4720
- outerRadius: SCENE_SECTOR_OUTER_RADIUS,
4721
- startAngle: startDeg * Math.PI / 180,
4722
- endAngle: endDeg * Math.PI / 180
4723
- });
4724
- sectorGroup.append("path").attr("class", "gtv-wheel-scene-sector").attr("d", arcPath).attr("fill", shadeSurface(getGenreNodeColor(index, rootId), UNDERGROUND_DARKEN_RATIO)).attr("fill-opacity", UNDERGROUND_SECTOR_FILL_OPACITY);
4666
+ outerRadius: wheelCoverageRadiusRef.current ?? svgCanvasRadius,
4667
+ startAngle,
4668
+ endAngle
4669
+ })).attr("fill", shadeSurface(getGenreNodeColor(index, rootId), UNDERGROUND_DARKEN_RATIO)).attr("fill-opacity", UNDERGROUND_SECTOR_FILL_OPACITY);
4725
4670
  });
4726
4671
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-core-branch").attr("data-gtv-branch-index", branchIndex);
4727
4672
  renderPopSubtree(
@@ -4891,6 +4836,17 @@ function WheelRadialPopCoreCore({
4891
4836
  wheelItemCountRange,
4892
4837
  panel?.node.id
4893
4838
  ]);
4839
+ (0, import_react13.useEffect)(() => {
4840
+ if (!popSvgRef.current || wheelCoverageRadius === null) return;
4841
+ d34.select(popSvgRef.current).selectAll("path.gtv-wheel-scene-sector").attr("d", function() {
4842
+ return d34.arc()({
4843
+ innerRadius: coreRootCircleRadius,
4844
+ outerRadius: wheelCoverageRadius,
4845
+ startAngle: Number(this.getAttribute("data-start-angle")),
4846
+ endAngle: Number(this.getAttribute("data-end-angle"))
4847
+ });
4848
+ });
4849
+ }, [wheelCoverageRadius, coreRootCircleRadius]);
4894
4850
  (0, import_react13.useEffect)(() => {
4895
4851
  const timeout = setTimeout(() => {
4896
4852
  if (popSvgRef.current) hideCollidingLabels(d34, d34.select(popSvgRef.current), wheelItemCountRange, zoomScale);
@@ -4933,7 +4889,7 @@ function WheelRadialPopCoreCore({
4933
4889
  const inset = REGION_LABEL_TARGET_PX / 2;
4934
4890
  const spot = pickEmptyLabelSpot(
4935
4891
  { x0: vp.left + inset, y0: vp.top + inset, x1: vp.right - inset, y1: vp.bottom - inset },
4936
- name.length * REGION_LABEL_TARGET_PX * LABEL_CHAR_WIDTH_RATIO,
4892
+ name.length * REGION_LABEL_TARGET_PX * import_ui4.LABEL_CHAR_WIDTH_RATIO,
4937
4893
  REGION_LABEL_TARGET_PX,
4938
4894
  obstacles,
4939
4895
  (x, y) => rootIdAt(x, y) === rootId
@@ -5028,6 +4984,7 @@ function WheelRadialPopCoreCore({
5028
4984
  style: {
5029
4985
  "--gtv-wheel-radius": `${coreRootCircleRadius}px`,
5030
4986
  "--gtv-wheel-svg-radius": `${svgCanvasRadius}px`,
4987
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
5031
4988
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
5032
4989
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
5033
4990
  touchAction: panZoom.touchAction
@@ -5282,7 +5239,6 @@ function WheelRadialPopCoreCore({
5282
5239
  FloatingControls,
5283
5240
  {
5284
5241
  panZoom,
5285
- orientation: controlsOrientation,
5286
5242
  zoomControls: showZoomControls,
5287
5243
  leading: floatingControlsLeading,
5288
5244
  busy,
@@ -6069,6 +6025,7 @@ function GenreTreeViewSkeleton({ viewMode }) {
6069
6025
  }
6070
6026
  // Annotate the CommonJS export names for ESM import in node:
6071
6027
  0 && (module.exports = {
6028
+ ACCENT_TEXT_COLOR,
6072
6029
  DEFAULT_FRAME_HEIGHT,
6073
6030
  DEFAULT_FRAME_WIDTH,
6074
6031
  GenreTree,
@@ -6081,8 +6038,12 @@ function GenreTreeViewSkeleton({ viewMode }) {
6081
6038
  GenreTreeWheelRadialPopCore,
6082
6039
  GenreTreeWheelRight,
6083
6040
  GenreTreeWheelSkeleton,
6041
+ InfoPanel,
6042
+ ROOT_TINT_RATIO,
6043
+ TEXT_COLOR,
6084
6044
  getGenreTreeColor,
6085
6045
  groupNodesByRoot,
6086
- isZoomableNode
6046
+ isZoomableNode,
6047
+ tintSurface
6087
6048
  });
6088
6049
  //# sourceMappingURL=index.cjs.map