@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 +185 -206
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -1
- package/dist/index.d.ts +82 -1
- package/dist/index.js +175 -201
- package/dist/index.js.map +1 -1
- package/dist/styles.css +9 -9
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
894
|
-
const panMax =
|
|
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
|
|
1199
|
-
if (automatic && (measuredFrameRef.current !== options.frameKey || fittedFrameRef.current === null || !
|
|
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 =
|
|
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:
|
|
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
|
|
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:
|
|
4704
|
-
startAngle
|
|
4705
|
-
endAngle
|
|
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
|