@behindthemusictree/genre-tree-view 7.0.0 → 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");
@@ -722,7 +726,7 @@ function FloatingControls({ panZoom, onFit, zoomControls, leading, busy }) {
722
726
  busy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BusyIndicator, {}),
723
727
  leading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "gtv-floating-controls-leading", children: leading }),
724
728
  zoomControls !== "never" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
725
- import_ui.ZoomControls,
729
+ import_ui2.ZoomControls,
726
730
  {
727
731
  onZoomIn: panZoom.zoomIn,
728
732
  onZoomOut: panZoom.zoomOut,
@@ -890,8 +894,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
890
894
  if (!viewport || !bounds) return pan;
891
895
  const origin = axis === "x" ? bounds.originX : bounds.originY;
892
896
  const size = axis === "x" ? bounds.width : bounds.height;
893
- const viewportSize = axis === "x" ? viewport.clientWidth : viewport.clientHeight;
894
- 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;
895
899
  const panMin = PAN_MIN_VISIBLE_PX - size * scale - origin * scale;
896
900
  const lo = Math.min(panMin, panMax);
897
901
  const hi = Math.max(panMin, panMax);
@@ -979,6 +983,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
979
983
  [stepZoomAnimation]
980
984
  );
981
985
  const viewportSizeRef = (0, import_react.useRef)(null);
986
+ const [viewportSize, setViewportSize] = (0, import_react.useState)(null);
982
987
  (0, import_react.useEffect)(() => {
983
988
  const viewport = viewportRef.current;
984
989
  if (!viewport) return;
@@ -991,6 +996,9 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
991
996
  const { width, height } = entry.contentRect;
992
997
  const previous = viewportSizeRef.current;
993
998
  viewportSizeRef.current = { width, height };
999
+ if (width > 0 && height > 0) {
1000
+ setViewportSize((prev) => prev?.width === width && prev.height === height ? prev : { width, height });
1001
+ }
994
1002
  if (!previous || previous.width <= 0 || previous.height <= 0) {
995
1003
  retryFrameRef.current();
996
1004
  return;
@@ -1195,8 +1203,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1195
1203
  (element, targetScale, obscured, automatic = false) => {
1196
1204
  if (automatic && restoredRevisionRef.current !== void 0 && restoredFrameRef.current === options.frameKey && restoredSelectionRef.current === options.selectedNodeId) return;
1197
1205
  if (!automatic) operationRef.current += 1;
1198
- const viewportSize = viewportRef.current?.getBoundingClientRect();
1199
- 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)) {
1200
1208
  const operation = operationRef.current;
1201
1209
  pendingCenterRef.current = () => {
1202
1210
  if (operation !== operationRef.current) return;
@@ -1397,6 +1405,8 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1397
1405
  panX,
1398
1406
  panY,
1399
1407
  zoomScale,
1408
+ minScale,
1409
+ viewportSize,
1400
1410
  transform: `translate(${panX}px, ${panY}px) scale(${zoomScale})`,
1401
1411
  canZoomIn: zoomScale < ZOOM_MAX_SCALE,
1402
1412
  canZoomOut: zoomScale > minScale,
@@ -2423,6 +2433,121 @@ function computeRotationForSelection(currentRotationDeg, targetAngle, landingAng
2423
2433
  return currentRotationDeg + shortestDelta;
2424
2434
  }
2425
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
+
2426
2551
  // src/GenreTreeWheelBase.tsx
2427
2552
  var import_jsx_runtime10 = require("react/jsx-runtime");
2428
2553
  function WheelCore({
@@ -2503,6 +2628,8 @@ function WheelCore({
2503
2628
  const stageRef = (0, import_react9.useRef)(null);
2504
2629
  const viewportFrame = (0, import_react9.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
2505
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;
2506
2633
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
2507
2634
  const wheelCircleRef = (0, import_react9.useRef)(null);
2508
2635
  const treeAnchorRef = (0, import_react9.useRef)(null);
@@ -2598,6 +2725,7 @@ function WheelCore({
2598
2725
  ].filter(Boolean).join(" "),
2599
2726
  style: {
2600
2727
  "--gtv-wheel-radius": `${wheelRadius}px`,
2728
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
2601
2729
  "--gtv-wheel-viewport-height": `${wheelViewportHeight}px`,
2602
2730
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
2603
2731
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
@@ -2873,6 +3001,9 @@ function GenreTreeWheelRight(props) {
2873
3001
  var import_react10 = require("react");
2874
3002
  var d33 = __toESM(require("d3"), 1);
2875
3003
 
3004
+ // src/pop-core-radial-layout.ts
3005
+ var import_ui3 = require("@behindthemusictree/ui");
3006
+
2876
3007
  // src/defer-click.ts
2877
3008
  function createDeferredClick() {
2878
3009
  let timeoutId = null;
@@ -2904,166 +3035,6 @@ function createDeferredClick() {
2904
3035
  return { schedule, cancel, doubleClick };
2905
3036
  }
2906
3037
 
2907
- // src/radial-wheel-geometry.ts
2908
- function computeRadialLayout(weights, clickedIndex, landingAngle) {
2909
- const rootCount = weights.length;
2910
- if (rootCount <= 0) return [];
2911
- if (weights.some((weight) => weight <= 0)) {
2912
- throw new Error("computeRadialLayout: every root weight (node count) must be positive");
2913
- }
2914
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2915
- const orderedWidths = weights.map((_, i) => weights[(clickedIndex + i) % rootCount] / total * 360);
2916
- const slots = new Array(rootCount);
2917
- let boundary = landingAngle - orderedWidths[0] / 2;
2918
- for (let i = 0; i < rootCount; i++) {
2919
- const width = orderedWidths[i];
2920
- const rootIndex = (clickedIndex + i) % rootCount;
2921
- slots[rootIndex] = { rootIndex, angle: ((boundary + width / 2) % 360 + 360) % 360 };
2922
- boundary += width;
2923
- }
2924
- return slots;
2925
- }
2926
- function computeSectorWidths(weights) {
2927
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2928
- return weights.map((weight) => weight / total * 360);
2929
- }
2930
- function subdivideWedge(wedgeCenterDeg, wedgeSpanDeg, weights) {
2931
- if (weights.length <= 0) return [];
2932
- const total = weights.reduce((sum2, weight) => sum2 + weight, 0);
2933
- const spans = weights.map((weight) => weight / total * wedgeSpanDeg);
2934
- const slices = [];
2935
- let boundary = wedgeCenterDeg - wedgeSpanDeg / 2;
2936
- for (const spanDeg of spans) {
2937
- slices.push({ centerDeg: boundary + spanDeg / 2, spanDeg });
2938
- boundary += spanDeg;
2939
- }
2940
- return slices;
2941
- }
2942
- function buildSectorClipPathPolygon(sweepDeg, stepDeg = 10) {
2943
- if (sweepDeg <= 0) return "";
2944
- const points = ["50% 50%"];
2945
- const steps = Math.max(1, Math.ceil(sweepDeg / stepDeg));
2946
- for (let i = 0; i <= steps; i++) {
2947
- const theta = Math.min(i * stepDeg, sweepDeg) * Math.PI / 180;
2948
- const x = 50 + 50 * Math.sin(theta);
2949
- const y = 50 - 50 * Math.cos(theta);
2950
- points.push(`${x}% ${y}%`);
2951
- }
2952
- return `polygon(${points.join(", ")})`;
2953
- }
2954
- function calculateWheelRadiusForAngles(angles, maxChipWidth, baseRadius) {
2955
- if (angles.length < 2) return baseRadius;
2956
- const sorted = [...angles].sort((a, b) => a - b);
2957
- let minGapDeg = 360;
2958
- for (let i = 0; i < sorted.length; i++) {
2959
- const next = i < sorted.length - 1 ? sorted[i + 1] : sorted[0] + 360;
2960
- minGapDeg = Math.min(minGapDeg, next - sorted[i]);
2961
- }
2962
- if (minGapDeg <= 0) return baseRadius;
2963
- const CHIP_GAP2 = 16;
2964
- const requiredRadius = (maxChipWidth + CHIP_GAP2) / (2 * Math.sin(minGapDeg * Math.PI / 360));
2965
- return Math.max(baseRadius, requiredRadius);
2966
- }
2967
- function solveRingRadius(angleNeededAt, floorRadius) {
2968
- const fits = (radius) => angleNeededAt(radius) <= 2 * Math.PI;
2969
- if (fits(floorRadius)) return floorRadius;
2970
- let lo = floorRadius;
2971
- let hi = floorRadius * 2;
2972
- while (!fits(hi)) {
2973
- lo = hi;
2974
- hi *= 2;
2975
- }
2976
- while (hi - lo > 1) {
2977
- const mid = (lo + hi) / 2;
2978
- if (fits(mid)) hi = mid;
2979
- else lo = mid;
2980
- }
2981
- return hi;
2982
- }
2983
- var boxesOverlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
2984
- var GRID_CELL = 64;
2985
- function createBoxGrid() {
2986
- const cells = /* @__PURE__ */ new Map();
2987
- const cellRange = (box) => ({
2988
- cx0: Math.floor(box.x0 / GRID_CELL),
2989
- cx1: Math.floor(box.x1 / GRID_CELL),
2990
- cy0: Math.floor(box.y0 / GRID_CELL),
2991
- cy1: Math.floor(box.y1 / GRID_CELL)
2992
- });
2993
- return {
2994
- insert(box, value) {
2995
- const { cx0, cx1, cy0, cy1 } = cellRange(box);
2996
- for (let cx = cx0; cx <= cx1; cx++) {
2997
- for (let cy = cy0; cy <= cy1; cy++) {
2998
- const key = `${cx},${cy}`;
2999
- const bucket = cells.get(key);
3000
- if (bucket) bucket.push({ box, value });
3001
- else cells.set(key, [{ box, value }]);
3002
- }
3003
- }
3004
- },
3005
- some(box, predicate) {
3006
- const { cx0, cx1, cy0, cy1 } = cellRange(box);
3007
- for (let cx = cx0; cx <= cx1; cx++) {
3008
- for (let cy = cy0; cy <= cy1; cy++) {
3009
- if (cells.get(`${cx},${cy}`)?.some((entry) => boxesOverlap(box, entry.box) && predicate(entry.value))) {
3010
- return true;
3011
- }
3012
- }
3013
- }
3014
- return false;
3015
- }
3016
- };
3017
- }
3018
- function placeLabels(items) {
3019
- const placement = /* @__PURE__ */ new Map();
3020
- const keptGrid = createBoxGrid();
3021
- const dotGrid = createBoxGrid();
3022
- for (const item of items) dotGrid.insert(item.dot, item.id);
3023
- for (const item of [...items].sort((a, b) => b.priority - a.priority)) {
3024
- let index = item.labels.findIndex(
3025
- (label) => !keptGrid.some(label, () => true) && !dotGrid.some(label, (id) => id !== item.id)
3026
- );
3027
- if (index < 0 && item.forced && item.labels.length > 0) index = 0;
3028
- placement.set(item.id, index);
3029
- if (index >= 0) keptGrid.insert(item.labels[index], true);
3030
- }
3031
- return placement;
3032
- }
3033
- function findSectorAtAngle(angle, sectors) {
3034
- const sector = sectors.find(
3035
- ({ center, span }) => Math.abs(((angle - center) % 360 + 540) % 360 - 180) <= span / 2
3036
- );
3037
- return sector?.id ?? null;
3038
- }
3039
- 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));
3040
- function pickEmptyLabelSpot(area, width, height, obstacles, prefer = () => true) {
3041
- const STEPS = 9;
3042
- const spanX = area.x1 - area.x0 - width;
3043
- const spanY = area.y1 - area.y0 - height;
3044
- if (spanX < 0 || spanY < 0) return null;
3045
- const centerX = (area.x0 + area.x1) / 2;
3046
- const centerY = (area.y0 + area.y1) / 2;
3047
- let best = null;
3048
- for (let i = 0; i < STEPS; i++) {
3049
- for (let j = 0; j < STEPS; j++) {
3050
- const x = area.x0 + width / 2 + spanX * i / (STEPS - 1);
3051
- const y = area.y0 + height / 2 + spanY * j / (STEPS - 1);
3052
- const box = { x0: x - width / 2, x1: x + width / 2, y0: y - height / 2, y1: y + height / 2 };
3053
- if (obstacles.some((obstacle) => boxesOverlap(box, obstacle))) continue;
3054
- const gap = Math.min(height, ...obstacles.map((obstacle) => boxGap(box, obstacle)));
3055
- const score = [
3056
- prefer(x, y) ? 1 : 0,
3057
- gap,
3058
- -Math.hypot(x - centerX, y - centerY)
3059
- ];
3060
- const k = best ? score.findIndex((value, index) => value !== best.score[index]) : 0;
3061
- if (!best || k >= 0 && score[k] > best.score[k]) best = { x, y, score };
3062
- }
3063
- }
3064
- return best && { x: best.x, y: best.y };
3065
- }
3066
-
3067
3038
  // src/pop-core-radial-layout.ts
3068
3039
  var POP_WEDGE_SPAN_DEGREES = 80;
3069
3040
  var POP_SUBTREE_OUTER_MARGIN = 24;
@@ -3230,11 +3201,10 @@ function getRadialPointOnCircle(angleDegrees, radius) {
3230
3201
  const angleRad = angleDegrees * Math.PI / 180;
3231
3202
  return { x: radius * Math.sin(angleRad), y: -radius * Math.cos(angleRad) };
3232
3203
  }
3233
- var LABEL_CHAR_WIDTH_RATIO = 0.6;
3234
3204
  function getDotLabelGeometry(x, name, itemCount, itemCountRange, zoomScale) {
3235
3205
  const dotRadius = calculateNodeDotRadius(itemCount, itemCountRange);
3236
3206
  const fontSize = zoomScale === void 0 ? calculateNodeFontSize(itemCount, itemCountRange) : calculateLabelFontSizeAtZoom(itemCount, itemCountRange, zoomScale);
3237
- 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 });
3238
3208
  const { x: labelX, box: label } = candidates[0];
3239
3209
  const dot = { x0: -dotRadius, x1: dotRadius, y0: -dotRadius, y1: dotRadius };
3240
3210
  const halfHeight = Math.max(dotRadius, fontSize / 2);
@@ -3254,25 +3224,6 @@ function getDotLabelGeometry(x, name, itemCount, itemCountRange, zoomScale) {
3254
3224
  candidates
3255
3225
  };
3256
3226
  }
3257
- function getLabelCandidates(x, name, dotRadius, fontSize) {
3258
- const width = name.length * fontSize * LABEL_CHAR_WIDTH_RATIO;
3259
- const halfFont = fontSize / 2;
3260
- const side = (right2) => {
3261
- const labelX = (right2 ? 1 : -1) * (dotRadius + NODE_DOT_LABEL_GAP);
3262
- return {
3263
- x: labelX,
3264
- y: 0,
3265
- anchor: right2 ? "start" : "end",
3266
- box: { x0: right2 ? labelX : labelX - width, x1: right2 ? labelX + width : labelX, y0: -halfFont, y1: halfFont }
3267
- };
3268
- };
3269
- const stacked = (below) => {
3270
- const y = (below ? 1 : -1) * (dotRadius + NODE_DOT_LABEL_GAP + halfFont);
3271
- return { x: 0, y, anchor: "middle", box: { x0: -width / 2, x1: width / 2, y0: y - halfFont, y1: y + halfFont } };
3272
- };
3273
- const right = x >= 0;
3274
- return [side(right), side(!right), stacked(false), stacked(true)];
3275
- }
3276
3227
  function renderRootLabels(container, roots) {
3277
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);
3278
3229
  }
@@ -3428,10 +3379,10 @@ function hideCollidingLabels(d3Lib, container, itemCountRange, zoomScale) {
3428
3379
  calculateLabelFontSizeAtZoom(d.itemCount, itemCountRange, zoomScale),
3429
3380
  zoomScale
3430
3381
  );
3431
- 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 }) }];
3432
3383
  })
3433
3384
  );
3434
- const placement = placeLabels([
3385
+ const placement = (0, import_ui3.placeLabels)([
3435
3386
  ...roots.data().map((d) => ({
3436
3387
  id: `root:${d.id}`,
3437
3388
  labels: rootGeometryById.get(d.id).candidates.map((candidate) => offset(candidate.box, d.x, d.y)),
@@ -3718,6 +3669,8 @@ function WheelRadialCore({
3718
3669
  () => Math.max(chipClearanceFloor, chipClearanceFloor + maxCoreExtentDelta),
3719
3670
  [chipClearanceFloor, maxCoreExtentDelta]
3720
3671
  );
3672
+ const { viewportSize, minScale } = panZoom;
3673
+ const wheelCoverageRadius = viewportSize ? coverageRadius(viewportSize.width, viewportSize.height, minScale, wheelRadius) : null;
3721
3674
  const onRootSelectRef = (0, import_react10.useRef)(onRootSelect);
3722
3675
  (0, import_react10.useEffect)(() => {
3723
3676
  onRootSelectRef.current = onRootSelect;
@@ -3869,6 +3822,7 @@ function WheelRadialCore({
3869
3822
  ].filter(Boolean).join(" "),
3870
3823
  style: {
3871
3824
  "--gtv-wheel-radius": `${wheelRadius}px`,
3825
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
3872
3826
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
3873
3827
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
3874
3828
  touchAction: panZoom.touchAction
@@ -4082,6 +4036,7 @@ function GenreTreeWheelRadial(props) {
4082
4036
 
4083
4037
  // src/GenreTreeWheelRadialPopCoreBase.tsx
4084
4038
  var import_react13 = require("react");
4039
+ var import_ui4 = require("@behindthemusictree/ui");
4085
4040
  var d34 = __toESM(require("d3"), 1);
4086
4041
 
4087
4042
  // src/use-focus-history.ts
@@ -4585,6 +4540,12 @@ function WheelRadialPopCoreCore({
4585
4540
  () => coreRootCircleRadius + maxCoreExtentDelta,
4586
4541
  [coreRootCircleRadius, maxCoreExtentDelta]
4587
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]);
4588
4549
  const middleCircleFloor = mainstreamCircleRadius;
4589
4550
  const onRootSelectRef = (0, import_react13.useRef)(onRootSelect);
4590
4551
  (0, import_react13.useEffect)(() => {
@@ -4698,13 +4659,14 @@ function WheelRadialPopCoreCore({
4698
4659
  const next = at >= 0 ? siblings[at + 1] : void 0;
4699
4660
  const startDeg = prev ? (subtreeRange(prev).max + mine.min) / 2 : angle - span / 2;
4700
4661
  const endDeg = next ? (mine.max + subtreeRange(next).min) / 2 : angle + span / 2;
4701
- 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()({
4702
4665
  innerRadius: coreRootCircleRadius,
4703
- outerRadius: SCENE_SECTOR_OUTER_RADIUS,
4704
- startAngle: startDeg * Math.PI / 180,
4705
- endAngle: endDeg * Math.PI / 180
4706
- });
4707
- 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);
4708
4670
  });
4709
4671
  const branchGroup = sectorGroup.append("g").attr("class", "gtv-wheel-core-branch").attr("data-gtv-branch-index", branchIndex);
4710
4672
  renderPopSubtree(
@@ -4874,6 +4836,17 @@ function WheelRadialPopCoreCore({
4874
4836
  wheelItemCountRange,
4875
4837
  panel?.node.id
4876
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]);
4877
4850
  (0, import_react13.useEffect)(() => {
4878
4851
  const timeout = setTimeout(() => {
4879
4852
  if (popSvgRef.current) hideCollidingLabels(d34, d34.select(popSvgRef.current), wheelItemCountRange, zoomScale);
@@ -4916,7 +4889,7 @@ function WheelRadialPopCoreCore({
4916
4889
  const inset = REGION_LABEL_TARGET_PX / 2;
4917
4890
  const spot = pickEmptyLabelSpot(
4918
4891
  { x0: vp.left + inset, y0: vp.top + inset, x1: vp.right - inset, y1: vp.bottom - inset },
4919
- name.length * REGION_LABEL_TARGET_PX * LABEL_CHAR_WIDTH_RATIO,
4892
+ name.length * REGION_LABEL_TARGET_PX * import_ui4.LABEL_CHAR_WIDTH_RATIO,
4920
4893
  REGION_LABEL_TARGET_PX,
4921
4894
  obstacles,
4922
4895
  (x, y) => rootIdAt(x, y) === rootId
@@ -5011,6 +4984,7 @@ function WheelRadialPopCoreCore({
5011
4984
  style: {
5012
4985
  "--gtv-wheel-radius": `${coreRootCircleRadius}px`,
5013
4986
  "--gtv-wheel-svg-radius": `${svgCanvasRadius}px`,
4987
+ ...wheelCoverageRadius !== null && { "--gtv-wheel-coverage-radius": `${wheelCoverageRadius}px` },
5014
4988
  "--gtv-wheel-rotation-transition-ms": `${WHEEL_ROTATION_TRANSITION_MS}ms`,
5015
4989
  "--gtv-wheel-rotation-easing": WHEEL_ROTATION_EASING,
5016
4990
  touchAction: panZoom.touchAction
@@ -6051,6 +6025,7 @@ function GenreTreeViewSkeleton({ viewMode }) {
6051
6025
  }
6052
6026
  // Annotate the CommonJS export names for ESM import in node:
6053
6027
  0 && (module.exports = {
6028
+ ACCENT_TEXT_COLOR,
6054
6029
  DEFAULT_FRAME_HEIGHT,
6055
6030
  DEFAULT_FRAME_WIDTH,
6056
6031
  GenreTree,
@@ -6063,8 +6038,12 @@ function GenreTreeViewSkeleton({ viewMode }) {
6063
6038
  GenreTreeWheelRadialPopCore,
6064
6039
  GenreTreeWheelRight,
6065
6040
  GenreTreeWheelSkeleton,
6041
+ InfoPanel,
6042
+ ROOT_TINT_RATIO,
6043
+ TEXT_COLOR,
6066
6044
  getGenreTreeColor,
6067
6045
  groupNodesByRoot,
6068
- isZoomableNode
6046
+ isZoomableNode,
6047
+ tintSurface
6069
6048
  });
6070
6049
  //# sourceMappingURL=index.cjs.map