@behindthemusictree/genre-tree-view 6.4.0 → 6.5.1

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
@@ -803,6 +803,18 @@ function computeFitScale(contentWidth, contentHeight, viewportWidth, viewportHei
803
803
  );
804
804
  }
805
805
 
806
+ // src/info-panel-geometry.ts
807
+ function resolveInfoPanelSide() {
808
+ return "left";
809
+ }
810
+ function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
811
+ if (!element || !viewport) return null;
812
+ if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
813
+ return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
814
+ }
815
+ return { width: panelWidth, side: resolveInfoPanelSide() };
816
+ }
817
+
806
818
  // src/use-pan-zoom.ts
807
819
  function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
808
820
  const [panX, setPanX] = (0, import_react.useState)(0);
@@ -813,6 +825,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
813
825
  const settleTimerRef = (0, import_react.useRef)(null);
814
826
  const stageAnimationRef = (0, import_react.useRef)(null);
815
827
  const defaultViewportRef = (0, import_react.useRef)(options.defaultViewport);
828
+ const operationRef = (0, import_react.useRef)(0);
829
+ const restoredSelectionRef = (0, import_react.useRef)(void 0);
830
+ const restoredRevisionRef = (0, import_react.useRef)(void 0);
831
+ const restoredFrameRef = (0, import_react.useRef)(void 0);
832
+ const pendingRestoreRef = (0, import_react.useRef)(null);
833
+ const measuredFrameRef = (0, import_react.useRef)(void 0);
834
+ const fittedFrameRef = (0, import_react.useRef)(null);
835
+ const frameElementsRef = (0, import_react.useRef)([]);
836
+ const retryFrameRef = (0, import_react.useRef)(() => {
837
+ });
838
+ const pendingCenterRef = (0, import_react.useRef)(null);
816
839
  const centerGuardUntilRef = (0, import_react.useRef)(0);
817
840
  const onViewportChangeRef = (0, import_react.useRef)(options.onViewportChange);
818
841
  (0, import_react.useEffect)(() => {
@@ -886,6 +909,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
886
909
  }, [viewportRef]);
887
910
  const zoomAtPoint = (0, import_react.useCallback)(
888
911
  (computeNewScale, clientX, clientY) => {
912
+ operationRef.current += 1;
889
913
  const viewport = viewportRef.current;
890
914
  if (!viewport) {
891
915
  return;
@@ -969,19 +993,26 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
969
993
  const viewport = viewportRef.current;
970
994
  if (!viewport) return;
971
995
  const observer = new ResizeObserver((entries) => {
972
- const entry = entries[0];
973
- if (!entry) return;
996
+ const entry = entries?.[0];
997
+ if (!entry) {
998
+ retryFrameRef.current();
999
+ return;
1000
+ }
974
1001
  const { width, height } = entry.contentRect;
975
1002
  const previous = viewportSizeRef.current;
976
1003
  viewportSizeRef.current = { width, height };
977
- if (!previous) return;
1004
+ if (!previous || previous.width <= 0 || previous.height <= 0) {
1005
+ retryFrameRef.current();
1006
+ return;
1007
+ }
978
1008
  const dx = (width - previous.width) / 2;
979
1009
  const dy = (height - previous.height) / 2;
980
1010
  if (dx === 0 && dy === 0) return;
981
1011
  const view = viewRef.current;
982
1012
  view.x = clampPanAxis(view.x + dx, view.k, "x");
983
1013
  view.y = clampPanAxis(view.y + dy, view.k, "y");
984
- commitView();
1014
+ commitView(false);
1015
+ retryFrameRef.current();
985
1016
  });
986
1017
  observer.observe(viewport);
987
1018
  return () => observer.disconnect();
@@ -992,6 +1023,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
992
1023
  return;
993
1024
  }
994
1025
  const handleWheel = (event) => {
1026
+ operationRef.current += 1;
995
1027
  if (event.target.closest(".gtv-info-panel") !== null) {
996
1028
  return;
997
1029
  }
@@ -1030,6 +1062,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1030
1062
  }, [viewportRef, wheelZoom]);
1031
1063
  const zoomByButton = (0, import_react.useCallback)(
1032
1064
  (direction) => {
1065
+ operationRef.current += 1;
1033
1066
  const viewport = viewportRef.current;
1034
1067
  if (!viewport) return;
1035
1068
  const rect = viewport.getBoundingClientRect();
@@ -1044,13 +1077,14 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1044
1077
  );
1045
1078
  const fitToFrame = (0, import_react.useCallback)(
1046
1079
  (elements, { silent = false } = {}) => {
1080
+ if (!silent) operationRef.current += 1;
1081
+ frameElementsRef.current = elements;
1047
1082
  const viewport = viewportRef.current;
1048
1083
  const present = elements.filter((el) => Boolean(el));
1049
- if (!viewport || present.length === 0) return;
1084
+ if (!viewport || present.length === 0) return false;
1050
1085
  const viewportRect = viewport.getBoundingClientRect();
1051
- if (viewportRect.width <= 0 || viewportRect.height <= 0) return;
1052
- centerAnimationRef.current = null;
1053
- if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return;
1086
+ if (viewportRect.width <= 0 || viewportRect.height <= 0) return false;
1087
+ if (viewportRect.width <= ZOOM_FIT_PADDING * 2 || viewportRect.height <= ZOOM_FIT_PADDING * 2) return false;
1054
1088
  const rects = present.map((el) => el.getBoundingClientRect());
1055
1089
  const contentLeft = Math.min(...rects.map((r) => r.left));
1056
1090
  const contentTop = Math.min(...rects.map((r) => r.top));
@@ -1059,7 +1093,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1059
1093
  const view = viewRef.current;
1060
1094
  const contentWidth = (contentRight - contentLeft) / view.k;
1061
1095
  const contentHeight = (contentBottom - contentTop) / view.k;
1062
- if (contentWidth <= 0 || contentHeight <= 0) return;
1096
+ if (contentWidth <= 0 || contentHeight <= 0) return false;
1063
1097
  const contentOriginX = (contentLeft - viewportRect.left - view.x) / view.k;
1064
1098
  const contentOriginY = (contentTop - viewportRect.top - view.y) / view.k;
1065
1099
  contentBoundsRef.current = {
@@ -1078,6 +1112,41 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1078
1112
  const newMinScale = Math.min(ZOOM_MIN_SCALE, fitScale);
1079
1113
  minScaleRef.current = newMinScale;
1080
1114
  setMinScale(newMinScale);
1115
+ measuredFrameRef.current = options.frameKey;
1116
+ const pending = pendingRestoreRef.current;
1117
+ if (pending && pending.operation !== operationRef.current) {
1118
+ pendingRestoreRef.current = null;
1119
+ pendingCenterRef.current = null;
1120
+ fittedFrameRef.current = { frame: options.frameKey };
1121
+ return true;
1122
+ }
1123
+ if (pending && pending.frame === options.frameKey && pending.operation === operationRef.current) {
1124
+ pendingRestoreRef.current = null;
1125
+ restoredRevisionRef.current = pending.revision;
1126
+ restoredFrameRef.current = pending.frame;
1127
+ restoredSelectionRef.current = options.selectedNodeId;
1128
+ defaultViewportRef.current = void 0;
1129
+ viewRef.current = viewportToView({ ...pending.viewport, zoom: clampZoomScale(pending.viewport.zoom, newMinScale) }, viewportRect.width, viewportRect.height);
1130
+ fittedFrameRef.current = { frame: options.frameKey };
1131
+ pendingCenterRef.current = null;
1132
+ commitView(false);
1133
+ return true;
1134
+ }
1135
+ if (silent && fittedFrameRef.current && fittedFrameRef.current.frame === options.frameKey) {
1136
+ const center2 = pendingCenterRef.current;
1137
+ pendingCenterRef.current = null;
1138
+ center2?.();
1139
+ return true;
1140
+ }
1141
+ fittedFrameRef.current = { frame: options.frameKey };
1142
+ const center = pendingCenterRef.current;
1143
+ pendingCenterRef.current = null;
1144
+ if (silent && center && !defaultViewportRef.current) {
1145
+ center();
1146
+ return true;
1147
+ }
1148
+ zoomAnimationRef.current = null;
1149
+ centerAnimationRef.current = null;
1081
1150
  const seed = defaultViewportRef.current;
1082
1151
  if (seed) {
1083
1152
  defaultViewportRef.current = void 0;
@@ -1091,14 +1160,15 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1091
1160
  view.y = seeded.y;
1092
1161
  centerGuardUntilRef.current = performance.now() + DEFAULT_VIEWPORT_CENTER_GUARD_MS;
1093
1162
  commitView(false);
1094
- return;
1163
+ return true;
1095
1164
  }
1096
1165
  view.k = fitScale;
1097
1166
  view.x = viewportRect.width / 2 - (contentOriginX + contentWidth / 2) * fitScale;
1098
1167
  view.y = viewportRect.height / 2 - (contentOriginY + contentHeight / 2) * fitScale;
1099
1168
  commitView(!silent);
1169
+ return true;
1100
1170
  },
1101
- [viewportRef, commitView]
1171
+ [viewportRef, commitView, options.frameKey, options.selectedNodeId]
1102
1172
  );
1103
1173
  const centerAnimationRef = (0, import_react.useRef)(null);
1104
1174
  const centerAnimationFrameIdRef = (0, import_react.useRef)(null);
@@ -1132,7 +1202,19 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1132
1202
  stepCenterAnimationRef.current = stepCenterAnimation;
1133
1203
  }, [stepCenterAnimation]);
1134
1204
  const centerOnElement = (0, import_react.useCallback)(
1135
- (element, targetScale, obscured) => {
1205
+ (element, targetScale, obscured, automatic = false) => {
1206
+ if (automatic && restoredRevisionRef.current !== void 0 && restoredFrameRef.current === options.frameKey && restoredSelectionRef.current === options.selectedNodeId) return;
1207
+ 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)) {
1210
+ const operation = operationRef.current;
1211
+ pendingCenterRef.current = () => {
1212
+ if (operation !== operationRef.current) return;
1213
+ const currentElement = element?.id ? viewportRef.current?.querySelector(`#${CSS.escape(element.id)}`) : element;
1214
+ centerLatestRef.current(currentElement, targetScale, obscured ? resolveInfoPanelObscuredArea(currentElement, viewportRef.current, INFO_PANEL_WIDTH) : obscured, true);
1215
+ };
1216
+ return;
1217
+ }
1136
1218
  const viewport = viewportRef.current;
1137
1219
  if (!viewport || !element || performance.now() < centerGuardUntilRef.current) return;
1138
1220
  const viewportRect = viewport.getBoundingClientRect();
@@ -1165,8 +1247,17 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1165
1247
  centerAnimationFrameIdRef.current = requestAnimationFrame(() => stepCenterAnimationRef.current());
1166
1248
  }
1167
1249
  },
1168
- [viewportRef]
1250
+ [viewportRef, options.frameKey, options.selectedNodeId]
1169
1251
  );
1252
+ const centerLatestRef = (0, import_react.useRef)(centerOnElement);
1253
+ (0, import_react.useEffect)(() => {
1254
+ centerLatestRef.current = centerOnElement;
1255
+ }, [centerOnElement]);
1256
+ (0, import_react.useEffect)(() => {
1257
+ retryFrameRef.current = () => {
1258
+ fitToFrame(frameElementsRef.current, { silent: true });
1259
+ };
1260
+ }, [fitToFrame]);
1170
1261
  const lastPointRef = (0, import_react.useRef)({ x: 0, y: 0 });
1171
1262
  const minScaleRef = (0, import_react.useRef)(minScale);
1172
1263
  const suppressedPointersRef = (0, import_react.useRef)(/* @__PURE__ */ new Set());
@@ -1241,6 +1332,7 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1241
1332
  }, [handlePointerUp]);
1242
1333
  const handlePointerDown = (0, import_react.useCallback)((event) => {
1243
1334
  if (event.button !== 0) return;
1335
+ operationRef.current += 1;
1244
1336
  const isInteractive = event.target.closest("g.node, foreignObject, .gtv-zoom-controls, .gtv-wheel-chip, .gtv-info-panel") !== null;
1245
1337
  const isPageScrollTouch = wheelZoom === "modifier" && event.pointerType === "touch";
1246
1338
  const pointers = activePointersRef.current;
@@ -1259,6 +1351,42 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1259
1351
  window.addEventListener("pointercancel", stablePointerUp);
1260
1352
  }
1261
1353
  }, [stablePointerMove, stablePointerUp, wheelZoom]);
1354
+ const restore = options.viewportRestore;
1355
+ const restoreRevision = restore?.revision;
1356
+ const restoreX = restore?.viewport.x;
1357
+ const restoreY = restore?.viewport.y;
1358
+ const restoreZoom = restore?.viewport.zoom;
1359
+ (0, import_react.useEffect)(() => {
1360
+ if (restoreRevision === restoredRevisionRef.current || restoreRevision === void 0 || restoreX === void 0 || restoreY === void 0 || restoreZoom === void 0) return;
1361
+ if (!Number.isFinite(restoreX) || !Number.isFinite(restoreY) || !Number.isFinite(restoreZoom) || restoreZoom <= 0) return;
1362
+ const viewportElement = viewportRef.current;
1363
+ if (!viewportElement) return;
1364
+ restoredRevisionRef.current = restoreRevision;
1365
+ const operation = ++operationRef.current;
1366
+ zoomAnimationRef.current = null;
1367
+ centerAnimationRef.current = null;
1368
+ if (animationFrameIdRef.current !== null) cancelAnimationFrame(animationFrameIdRef.current);
1369
+ if (centerAnimationFrameIdRef.current !== null) cancelAnimationFrame(centerAnimationFrameIdRef.current);
1370
+ if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
1371
+ if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current);
1372
+ animationFrameIdRef.current = null;
1373
+ centerAnimationFrameIdRef.current = null;
1374
+ frameIdRef.current = null;
1375
+ settleTimerRef.current = null;
1376
+ activePointersRef.current.clear();
1377
+ suppressedPointersRef.current.clear();
1378
+ pinchStartRef.current = null;
1379
+ window.removeEventListener("pointermove", stablePointerMove);
1380
+ window.removeEventListener("pointerup", stablePointerUp);
1381
+ window.removeEventListener("pointercancel", stablePointerUp);
1382
+ pendingRestoreRef.current = {
1383
+ viewport: { x: restoreX, y: restoreY, zoom: restoreZoom },
1384
+ revision: restoreRevision,
1385
+ frame: options.frameKey,
1386
+ operation
1387
+ };
1388
+ if (measuredFrameRef.current === options.frameKey) retryFrameRef.current();
1389
+ }, [restoreRevision, restoreX, restoreY, restoreZoom, options.frameKey, options.selectedNodeId, viewportRef, commitView, stablePointerMove, stablePointerUp]);
1262
1390
  (0, import_react.useEffect)(() => {
1263
1391
  const pointers = activePointersRef.current;
1264
1392
  const suppressed = suppressedPointersRef.current;
@@ -1293,20 +1421,6 @@ function usePanZoom(viewportRef, wheelZoom = "always", stageRef, options = {}) {
1293
1421
 
1294
1422
  // src/use-node-info-panel.ts
1295
1423
  var import_react2 = require("react");
1296
-
1297
- // src/info-panel-geometry.ts
1298
- function resolveInfoPanelSide() {
1299
- return "left";
1300
- }
1301
- function resolveInfoPanelObscuredArea(element, viewport, panelWidth) {
1302
- if (!element || !viewport) return null;
1303
- if (viewport.clientWidth > 0 && viewport.clientWidth <= INFO_PANEL_SHEET_MAX_WIDTH) {
1304
- return { side: "bottom", height: viewport.clientHeight * INFO_PANEL_SHEET_PEEK_FRACTION };
1305
- }
1306
- return { width: panelWidth, side: resolveInfoPanelSide() };
1307
- }
1308
-
1309
- // src/use-node-info-panel.ts
1310
1424
  function useNodeInfoPanel({
1311
1425
  selectedNodeId,
1312
1426
  onSelectedNodeChange
@@ -1315,7 +1429,7 @@ function useNodeInfoPanel({
1315
1429
  const [prevSelectedNodeId, setPrevSelectedNodeId] = (0, import_react2.useState)(selectedNodeId);
1316
1430
  if (selectedNodeId !== prevSelectedNodeId) {
1317
1431
  setPrevSelectedNodeId(selectedNodeId);
1318
- if (prevSelectedNodeId != null && selectedNodeId == null) setPanel(null);
1432
+ if (prevSelectedNodeId !== void 0) setPanel(null);
1319
1433
  }
1320
1434
  const shownNodeIdRef = (0, import_react2.useRef)(null);
1321
1435
  const onSelectedNodeChangeRef = (0, import_react2.useRef)(onSelectedNodeChange);
@@ -1325,20 +1439,32 @@ function useNodeInfoPanel({
1325
1439
  });
1326
1440
  const showNodeInfo = (0, import_react2.useCallback)(
1327
1441
  (node, element, viewport, { notify = true } = {}) => {
1442
+ if (notify && selectedNodeId !== void 0) {
1443
+ if (node.id !== selectedNodeId) onSelectedNodeChangeRef.current?.(node);
1444
+ return;
1445
+ }
1328
1446
  if (!element || !viewport) return;
1329
1447
  setPanel({ node, side: resolveInfoPanelSide() });
1330
1448
  const changed = node.id !== shownNodeIdRef.current;
1331
1449
  shownNodeIdRef.current = node.id;
1332
1450
  if (changed && notify) onSelectedNodeChangeRef.current?.(node);
1333
1451
  },
1334
- []
1452
+ [selectedNodeId]
1335
1453
  );
1336
1454
  const closeNodeInfo = (0, import_react2.useCallback)(() => {
1455
+ if (selectedNodeId !== void 0) {
1456
+ if (selectedNodeId !== null) onSelectedNodeChangeRef.current?.(null);
1457
+ return;
1458
+ }
1337
1459
  setPanel(null);
1338
1460
  shownNodeIdRef.current = null;
1339
1461
  onSelectedNodeChangeRef.current?.(null);
1340
- }, []);
1341
- return { panel, showNodeInfo, closeNodeInfo };
1462
+ }, [selectedNodeId]);
1463
+ return {
1464
+ panel: selectedNodeId !== void 0 && panel?.node.id !== selectedNodeId ? null : panel,
1465
+ showNodeInfo,
1466
+ closeNodeInfo
1467
+ };
1342
1468
  }
1343
1469
 
1344
1470
  // src/use-tree-index.ts
@@ -1924,6 +2050,7 @@ function GenreTree({
1924
2050
  overlayPanel,
1925
2051
  wheelZoom,
1926
2052
  defaultViewport,
2053
+ viewportRestore,
1927
2054
  onViewportChange,
1928
2055
  controlsOrientation = "vertical",
1929
2056
  showZoomControls = "auto",
@@ -1934,7 +2061,8 @@ function GenreTree({
1934
2061
  const svgRef = (0, import_react8.useRef)(null);
1935
2062
  const viewportRef = (0, import_react8.useRef)(null);
1936
2063
  const stageRef = (0, import_react8.useRef)(null);
1937
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2064
+ const viewportFrame = (0, import_react8.useMemo)(() => JSON.stringify([orientation, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, orientation]);
2065
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId: selectedNodeIdProp, onViewportChange, frameKey: viewportFrame });
1938
2066
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId: selectedNodeIdProp, onSelectedNodeChange });
1939
2067
  const itemCountRange = (0, import_react8.useMemo)(
1940
2068
  () => getItemCountRange(nodes, { itemCountScaleMax, nodeCountForSizing: nodeCountForSizing ?? nodes.length }),
@@ -2073,7 +2201,8 @@ function GenreTree({
2073
2201
  centerOnElementRef.current(
2074
2202
  element,
2075
2203
  ZOOM_FOCUS_SCALE,
2076
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2204
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2205
+ true
2077
2206
  );
2078
2207
  showNodeInfoRef.current(targetNode, element, viewportRef.current, { notify: false });
2079
2208
  }, [interactive, selectedNodeIdProp, index, panel]);
@@ -2085,12 +2214,10 @@ function GenreTree({
2085
2214
  }
2086
2215
  };
2087
2216
  }, []);
2088
- const hasInitialFitRef = (0, import_react8.useRef)(false);
2089
2217
  (0, import_react8.useEffect)(() => {
2090
- if (hasInitialFitRef.current || !interactive) return;
2218
+ if (!interactive) return;
2091
2219
  const elements = queryTreeContentElements(svgRef.current);
2092
2220
  if (elements.length === 0) return;
2093
- hasInitialFitRef.current = true;
2094
2221
  panZoom.fitToFrame(elements, { silent: true });
2095
2222
  });
2096
2223
  const svg = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
@@ -2345,6 +2472,7 @@ function WheelCore({
2345
2472
  overlayPanel,
2346
2473
  wheelZoom,
2347
2474
  defaultViewport,
2475
+ viewportRestore,
2348
2476
  onViewportChange,
2349
2477
  controlsOrientation = "vertical",
2350
2478
  showZoomControls = "auto",
@@ -2386,7 +2514,8 @@ function WheelCore({
2386
2514
  );
2387
2515
  const viewportRef = (0, import_react9.useRef)(null);
2388
2516
  const stageRef = (0, import_react9.useRef)(null);
2389
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
2517
+ const viewportFrame = (0, import_react9.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
2518
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
2390
2519
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
2391
2520
  const wheelCircleRef = (0, import_react9.useRef)(null);
2392
2521
  const treeAnchorRef = (0, import_react9.useRef)(null);
@@ -2400,15 +2529,12 @@ function WheelCore({
2400
2529
  (0, import_react9.useEffect)(() => {
2401
2530
  if (effectiveRootId) onRootSelectRef.current?.(effectiveRootId);
2402
2531
  }, [effectiveRootId]);
2403
- const hasInitialFitRef = (0, import_react9.useRef)(false);
2404
2532
  (0, import_react9.useEffect)(() => {
2405
- if (hasInitialFitRef.current) return;
2406
2533
  const elements = [
2407
2534
  wheelCircleRef.current,
2408
2535
  ...queryTreeContentElements(treeAnchorRef.current)
2409
2536
  ];
2410
2537
  if (!elements.some(Boolean)) return;
2411
- hasInitialFitRef.current = true;
2412
2538
  panZoom.fitToFrame(elements, { silent: true });
2413
2539
  });
2414
2540
  const selectedGroup = groups.find((group) => group.root.id === effectiveRootId) ?? null;
@@ -2468,7 +2594,8 @@ function WheelCore({
2468
2594
  panZoom.centerOnElement(
2469
2595
  element,
2470
2596
  ZOOM_FOCUS_SCALE,
2471
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
2597
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
2598
+ true
2472
2599
  );
2473
2600
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
2474
2601
  }, [selectedNodeId, effectiveRootId, nodes, panel, showNodeInfo]);
@@ -3461,6 +3588,7 @@ function WheelRadialCore({
3461
3588
  overlayPanel,
3462
3589
  wheelZoom,
3463
3590
  defaultViewport,
3591
+ viewportRestore,
3464
3592
  onViewportChange,
3465
3593
  controlsOrientation = "vertical",
3466
3594
  showZoomControls = "auto",
@@ -3477,7 +3605,8 @@ function WheelRadialCore({
3477
3605
  );
3478
3606
  const viewportRef = (0, import_react10.useRef)(null);
3479
3607
  const stageRef = (0, import_react10.useRef)(null);
3480
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
3608
+ const viewportFrame = (0, import_react10.useMemo)(() => JSON.stringify(nodes.map(({ id, parentId, side }) => [id, parentId, side])), [nodes]);
3609
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
3481
3610
  const { panel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
3482
3611
  const centerOnElementRef = (0, import_react10.useRef)(panZoom.centerOnElement);
3483
3612
  (0, import_react10.useEffect)(() => {
@@ -3696,12 +3825,9 @@ function WheelRadialCore({
3696
3825
  wheelItemCountRange,
3697
3826
  panel?.node.id
3698
3827
  ]);
3699
- const hasInitialFitRef = (0, import_react10.useRef)(false);
3700
3828
  (0, import_react10.useEffect)(() => {
3701
- if (hasInitialFitRef.current) return;
3702
3829
  const elements = [wheelCircleRef.current, coreSvgRef.current];
3703
3830
  if (!elements.some(Boolean)) return;
3704
- hasInitialFitRef.current = true;
3705
3831
  panZoom.fitToFrame(elements, { silent: true });
3706
3832
  });
3707
3833
  const handleChipClick = (rootId) => {
@@ -3717,7 +3843,8 @@ function WheelRadialCore({
3717
3843
  panZoom.centerOnElement(
3718
3844
  element,
3719
3845
  ZOOM_FOCUS_SCALE,
3720
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
3846
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
3847
+ true
3721
3848
  );
3722
3849
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
3723
3850
  }, [selectedNodeId, index, panel, showNodeInfo]);
@@ -4147,6 +4274,7 @@ function WheelRadialPopCoreCore({
4147
4274
  overlayPanel,
4148
4275
  wheelZoom,
4149
4276
  defaultViewport,
4277
+ viewportRestore,
4150
4278
  onViewportChange,
4151
4279
  controlsOrientation = "vertical",
4152
4280
  showZoomControls = "auto",
@@ -4226,7 +4354,8 @@ function WheelRadialPopCoreCore({
4226
4354
  const viewportRef = (0, import_react13.useRef)(null);
4227
4355
  const stageRef = (0, import_react13.useRef)(null);
4228
4356
  const dotRadiusMemoryRef = (0, import_react13.useRef)(/* @__PURE__ */ new Map());
4229
- const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, onViewportChange });
4357
+ const viewportFrame = (0, import_react13.useMemo)(() => JSON.stringify([focusNodeId, nodes.map(({ id, parentId, side }) => [id, parentId, side])]), [nodes, focusNodeId]);
4358
+ const panZoom = usePanZoom(viewportRef, wheelZoom, stageRef, { defaultViewport, viewportRestore, selectedNodeId, onViewportChange, frameKey: viewportFrame });
4230
4359
  const { panel: openPanel, showNodeInfo, closeNodeInfo } = useNodeInfoPanel({ selectedNodeId, onSelectedNodeChange });
4231
4360
  const isInFrame = (id) => id === focusNodeId || centerSubtreeNodeIds.has(id) || ringRootById.has(id);
4232
4361
  const panel = openPanel && isInFrame(openPanel.node.id) ? openPanel : null;
@@ -4830,18 +4959,12 @@ function WheelRadialPopCoreCore({
4830
4959
  index,
4831
4960
  regionLabel
4832
4961
  ]);
4833
- const hasInitialFitRef = (0, import_react13.useRef)(false);
4834
4962
  (0, import_react13.useEffect)(() => {
4835
- hasInitialFitRef.current = false;
4836
- }, [focusNodeId]);
4837
- (0, import_react13.useEffect)(() => {
4838
- if (hasInitialFitRef.current) return;
4839
4963
  const elements = [
4840
4964
  wheelCircleRef.current,
4841
4965
  ...queryTreeContentElements(popSvgRef.current)
4842
4966
  ];
4843
4967
  if (!elements.some(Boolean)) return;
4844
- hasInitialFitRef.current = true;
4845
4968
  panZoom.fitToFrame(elements, { silent: true });
4846
4969
  });
4847
4970
  const handleChipClick = (rootId) => {
@@ -4863,7 +4986,8 @@ function WheelRadialPopCoreCore({
4863
4986
  panZoom.centerOnElement(
4864
4987
  element,
4865
4988
  ZOOM_FOCUS_SCALE,
4866
- resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH)
4989
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
4990
+ true
4867
4991
  );
4868
4992
  showNodeInfo(targetNode, element, viewportRef.current, { notify: false });
4869
4993
  }, [selectedNodeId, index, openPanel, showNodeInfo, centerSubtreeNodeIds, ringRootById]);
@@ -5361,7 +5485,7 @@ function GenreTreeOutline(props) {
5361
5485
  return;
5362
5486
  }
5363
5487
  const changed = store.get().selectedId !== node.id;
5364
- store.set({ selectedId: node.id });
5488
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: node.id });
5365
5489
  if (changed) latestProps.current.onSelectedNodeChange?.(node);
5366
5490
  onNodeClick?.(node, event.nativeEvent);
5367
5491
  },
@@ -5393,7 +5517,7 @@ function GenreTreeOutline(props) {
5393
5517
  }
5394
5518
  const changed = store.get().selectedId !== nodeId;
5395
5519
  if (changed) pendingScrollIdRef.current = nodeId;
5396
- store.set({ openIds, selectedId: nodeId });
5520
+ store.set({ openIds, ...notify && latestProps.current.selectedNodeId !== void 0 ? {} : { selectedId: nodeId } });
5397
5521
  if (changed && notify) latestProps.current.onSelectedNodeChange?.(index.byId.get(nodeId));
5398
5522
  };
5399
5523
  const selectNodeRef = (0, import_react14.useRef)(selectNode);
@@ -5434,7 +5558,7 @@ function GenreTreeOutline(props) {
5434
5558
  ancestorNodes: index.ancestorsOf(selectedNode.id).map((n) => ({ node: n, ...model.styleOf(n) })),
5435
5559
  side: "left",
5436
5560
  onClose: () => {
5437
- store.set({ selectedId: null });
5561
+ if (latestProps.current.selectedNodeId === void 0) store.set({ selectedId: null });
5438
5562
  latestProps.current.onSelectedNodeChange?.(null);
5439
5563
  },
5440
5564
  onSelectNode: (id) => selectNodeRef.current(id),