@omiron33/omi-neuron-web 0.2.24 → 0.2.26

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.
@@ -109,6 +109,12 @@ interface TreeLayoutOptions {
109
109
  rootNodeId?: string;
110
110
  /** Direction the tree grows: 'down'/'up' for vertical, 'left'/'right' for horizontal (default: 'down') */
111
111
  direction?: 'down' | 'up' | 'left' | 'right';
112
+ /**
113
+ * When true, reverses edge interpretation: edge.from becomes child and edge.to becomes parent.
114
+ * Use this if your edges point from child to parent (e.g., "derives from" relationships).
115
+ * Default: false (edges point from parent to child)
116
+ */
117
+ reverseEdgeDirection?: boolean;
112
118
  }
113
119
  interface NeuronLayoutOptions {
114
120
  mode?: NeuronLayoutMode;
@@ -320,6 +326,32 @@ interface NeuronWebProps {
320
326
  onEdgeClick?: (edge: NeuronEdge) => void;
321
327
  onBackgroundClick?: () => void;
322
328
  onCameraChange?: (position: [number, number, number]) => void;
329
+ /**
330
+ * Enable node dragging for manual arrangement.
331
+ * - `true` or `{ enabled: true }`: Enable dragging, constrain to XY plane in tree mode
332
+ * - `{ enabled: true, constrainToPlane: 'xy' | 'xz' | 'yz' }`: Constrain to specific plane
333
+ */
334
+ draggable?: boolean | {
335
+ enabled: boolean;
336
+ constrainToPlane?: 'xy' | 'xz' | 'yz';
337
+ };
338
+ /** Called continuously while a node is being dragged. */
339
+ onNodeDrag?: (node: NeuronNode, position: [number, number, number]) => void;
340
+ /** Called when a node drag operation completes. */
341
+ onNodeDragEnd?: (node: NeuronNode, position: [number, number, number]) => void;
342
+ /**
343
+ * Enable keyboard controls for camera movement:
344
+ * - WASD: Pan camera
345
+ * - Q/E: Zoom in/out
346
+ * This frees the mouse for node interaction.
347
+ */
348
+ keyboardControls?: boolean | {
349
+ enabled: boolean;
350
+ /** Pan speed multiplier (default: 1) */
351
+ panSpeed?: number;
352
+ /** Zoom speed multiplier (default: 1) */
353
+ zoomSpeed?: number;
354
+ };
323
355
  /** When set, plays a story beat by id using the built-in study path player. */
324
356
  activeStoryBeatId?: string | null;
325
357
  /** Optional override for story beat step duration (ms). */
@@ -405,7 +437,7 @@ interface NeuronWebExplorerProps {
405
437
  renderLoadingState?: () => React__default.ReactNode;
406
438
  }
407
439
 
408
- declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, selectedNode, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, domainColors, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, hoverCardSlots, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onEdgeClick, onBackgroundClick, onCameraChange, performanceMode, density, rendering, activeStoryBeatId, storyBeatStepDurationMs, onStoryBeatComplete, }: NeuronWebProps): React__default.ReactElement;
440
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, selectedNode, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, domainColors, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, hoverCardSlots, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onEdgeClick, onBackgroundClick, onCameraChange, draggable, onNodeDrag, onNodeDragEnd, keyboardControls, performanceMode, density, rendering, activeStoryBeatId, storyBeatStepDurationMs, onStoryBeatComplete, }: NeuronWebProps): React__default.ReactElement;
409
441
 
410
442
  declare function NeuronWebExplorer(props: NeuronWebExplorerProps): React__default.ReactElement;
411
443
 
@@ -109,6 +109,12 @@ interface TreeLayoutOptions {
109
109
  rootNodeId?: string;
110
110
  /** Direction the tree grows: 'down'/'up' for vertical, 'left'/'right' for horizontal (default: 'down') */
111
111
  direction?: 'down' | 'up' | 'left' | 'right';
112
+ /**
113
+ * When true, reverses edge interpretation: edge.from becomes child and edge.to becomes parent.
114
+ * Use this if your edges point from child to parent (e.g., "derives from" relationships).
115
+ * Default: false (edges point from parent to child)
116
+ */
117
+ reverseEdgeDirection?: boolean;
112
118
  }
113
119
  interface NeuronLayoutOptions {
114
120
  mode?: NeuronLayoutMode;
@@ -320,6 +326,32 @@ interface NeuronWebProps {
320
326
  onEdgeClick?: (edge: NeuronEdge) => void;
321
327
  onBackgroundClick?: () => void;
322
328
  onCameraChange?: (position: [number, number, number]) => void;
329
+ /**
330
+ * Enable node dragging for manual arrangement.
331
+ * - `true` or `{ enabled: true }`: Enable dragging, constrain to XY plane in tree mode
332
+ * - `{ enabled: true, constrainToPlane: 'xy' | 'xz' | 'yz' }`: Constrain to specific plane
333
+ */
334
+ draggable?: boolean | {
335
+ enabled: boolean;
336
+ constrainToPlane?: 'xy' | 'xz' | 'yz';
337
+ };
338
+ /** Called continuously while a node is being dragged. */
339
+ onNodeDrag?: (node: NeuronNode, position: [number, number, number]) => void;
340
+ /** Called when a node drag operation completes. */
341
+ onNodeDragEnd?: (node: NeuronNode, position: [number, number, number]) => void;
342
+ /**
343
+ * Enable keyboard controls for camera movement:
344
+ * - WASD: Pan camera
345
+ * - Q/E: Zoom in/out
346
+ * This frees the mouse for node interaction.
347
+ */
348
+ keyboardControls?: boolean | {
349
+ enabled: boolean;
350
+ /** Pan speed multiplier (default: 1) */
351
+ panSpeed?: number;
352
+ /** Zoom speed multiplier (default: 1) */
353
+ zoomSpeed?: number;
354
+ };
323
355
  /** When set, plays a story beat by id using the built-in study path player. */
324
356
  activeStoryBeatId?: string | null;
325
357
  /** Optional override for story beat step duration (ms). */
@@ -405,7 +437,7 @@ interface NeuronWebExplorerProps {
405
437
  renderLoadingState?: () => React__default.ReactNode;
406
438
  }
407
439
 
408
- declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, selectedNode, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, domainColors, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, hoverCardSlots, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onEdgeClick, onBackgroundClick, onCameraChange, performanceMode, density, rendering, activeStoryBeatId, storyBeatStepDurationMs, onStoryBeatComplete, }: NeuronWebProps): React__default.ReactElement;
440
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, selectedNode, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, domainColors, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, hoverCardSlots, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onEdgeClick, onBackgroundClick, onCameraChange, draggable, onNodeDrag, onNodeDragEnd, keyboardControls, performanceMode, density, rendering, activeStoryBeatId, storyBeatStepDurationMs, onStoryBeatComplete, }: NeuronWebProps): React__default.ReactElement;
409
441
 
410
442
  declare function NeuronWebExplorer(props: NeuronWebExplorerProps): React__default.ReactElement;
411
443
 
@@ -1080,6 +1080,14 @@ var NodeRenderer = class {
1080
1080
  const state = this.nodeStates.get(nodeId);
1081
1081
  return state ? state.object.position.clone() : null;
1082
1082
  }
1083
+ /** Updates a node's position during drag operations */
1084
+ updateNodePosition(nodeId, position) {
1085
+ const state = this.nodeStates.get(nodeId);
1086
+ if (!state) return;
1087
+ state.basePosition.copy(position);
1088
+ state.object.position.copy(position);
1089
+ state.positionTween = null;
1090
+ }
1083
1091
  getNodeObject(nodeId) {
1084
1092
  const state = this.nodeStates.get(nodeId);
1085
1093
  return state?.object ?? null;
@@ -2266,6 +2274,7 @@ function applyTreeLayout(nodes, edges, options = {}) {
2266
2274
  const verticalSpacing = treeOptions.verticalSpacing ?? 4;
2267
2275
  const direction = treeOptions.direction ?? "down";
2268
2276
  const rootNodeId = treeOptions.rootNodeId;
2277
+ const reverseEdgeDirection = treeOptions.reverseEdgeDirection ?? false;
2269
2278
  const nodeById = /* @__PURE__ */ new Map();
2270
2279
  const nodeBySlug = /* @__PURE__ */ new Map();
2271
2280
  for (const node of nodes) {
@@ -2280,8 +2289,8 @@ function applyTreeLayout(nodes, edges, options = {}) {
2280
2289
  const fromNode = nodeBySlug.get(edge.from) ?? nodeById.get(edge.from);
2281
2290
  const toNode = nodeBySlug.get(edge.to) ?? nodeById.get(edge.to);
2282
2291
  if (!fromNode || !toNode) continue;
2283
- const parentId = fromNode.id;
2284
- const childId = toNode.id;
2292
+ const parentId = reverseEdgeDirection ? toNode.id : fromNode.id;
2293
+ const childId = reverseEdgeDirection ? fromNode.id : toNode.id;
2285
2294
  if (!parentMap.has(childId)) {
2286
2295
  parentMap.set(childId, parentId);
2287
2296
  const siblings = childrenMap.get(parentId) ?? [];
@@ -2557,6 +2566,15 @@ var InteractionManager = class {
2557
2566
  lastHoverId = null;
2558
2567
  lastClickTime = 0;
2559
2568
  lastClickId = null;
2569
+ // Drag state
2570
+ isDragging = false;
2571
+ dragNode = null;
2572
+ dragPlane = new THREE.Plane();
2573
+ dragOffset = new THREE.Vector3();
2574
+ dragIntersection = new THREE.Vector3();
2575
+ pointerDownPosition = new THREE.Vector2();
2576
+ dragThreshold = 5;
2577
+ // pixels
2560
2578
  onNodeHover = () => {
2561
2579
  };
2562
2580
  onNodeClick = () => {
@@ -2567,9 +2585,45 @@ var InteractionManager = class {
2567
2585
  };
2568
2586
  onBackgroundClick = () => {
2569
2587
  };
2588
+ /** Called when pointer-down occurs on a node (before drag threshold). Use to disable controls. */
2589
+ onNodePointerDown = () => {
2590
+ };
2591
+ /** Called when pointer-up occurs without a drag. Use to re-enable controls. */
2592
+ onNodePointerUp = () => {
2593
+ };
2594
+ onNodeDragStart = () => {
2595
+ };
2596
+ onNodeDrag = () => {
2597
+ };
2598
+ onNodeDragEnd = () => {
2599
+ };
2570
2600
  onPointerMove(event) {
2571
- if (!this.config.enableHover) return;
2572
2601
  this.updatePointer(event);
2602
+ if (this.config.enableDrag && this.dragNode) {
2603
+ if (!this.isDragging) {
2604
+ const dx = (this.pointer.x - this.pointerDownPosition.x) * this.renderer.domElement.clientWidth;
2605
+ const dy = (this.pointer.y - this.pointerDownPosition.y) * this.renderer.domElement.clientHeight;
2606
+ const distance = Math.sqrt(dx * dx + dy * dy);
2607
+ if (distance > this.dragThreshold) {
2608
+ this.isDragging = true;
2609
+ const nodeObject = this.nodeObjects.find(
2610
+ (obj) => obj.userData?.nodeId === this.dragNode.id
2611
+ );
2612
+ if (nodeObject) {
2613
+ this.onNodeDragStart(this.dragNode, nodeObject.position.clone());
2614
+ }
2615
+ }
2616
+ }
2617
+ if (this.isDragging) {
2618
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2619
+ if (this.raycaster.ray.intersectPlane(this.dragPlane, this.dragIntersection)) {
2620
+ const newPosition = this.dragIntersection.clone().add(this.dragOffset);
2621
+ this.onNodeDrag(this.dragNode, newPosition);
2622
+ }
2623
+ return;
2624
+ }
2625
+ }
2626
+ if (!this.config.enableHover) return;
2573
2627
  const node = this.getIntersectedNode(this.pointer);
2574
2628
  if (this.hoverTimeout) {
2575
2629
  window.clearTimeout(this.hoverTimeout);
@@ -2581,11 +2635,59 @@ var InteractionManager = class {
2581
2635
  }
2582
2636
  }, this.config.hoverDelay);
2583
2637
  }
2584
- onPointerDown() {
2638
+ onPointerDown(event) {
2639
+ if (!this.config.enableDrag) return;
2640
+ this.updatePointer(event);
2641
+ this.pointerDownPosition.copy(this.pointer);
2642
+ const node = this.getIntersectedNode(this.pointer);
2643
+ if (node) {
2644
+ this.onNodePointerDown(node);
2645
+ const nodeObject = this.nodeObjects.find(
2646
+ (obj) => obj.userData?.nodeId === node.id
2647
+ );
2648
+ if (nodeObject) {
2649
+ const normal = this.getPlaneNormal();
2650
+ this.dragPlane.setFromNormalAndCoplanarPoint(normal, nodeObject.position);
2651
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2652
+ if (this.raycaster.ray.intersectPlane(this.dragPlane, this.dragIntersection)) {
2653
+ this.dragOffset.copy(nodeObject.position).sub(this.dragIntersection);
2654
+ this.dragNode = node;
2655
+ }
2656
+ }
2657
+ }
2658
+ }
2659
+ getPlaneNormal() {
2660
+ switch (this.config.dragConstrainPlane) {
2661
+ case "xz":
2662
+ return new THREE.Vector3(0, 1, 0);
2663
+ case "yz":
2664
+ return new THREE.Vector3(1, 0, 0);
2665
+ case "xy":
2666
+ default:
2667
+ return new THREE.Vector3(0, 0, 1);
2668
+ }
2669
+ }
2670
+ /** Returns true if currently dragging a node */
2671
+ get dragging() {
2672
+ return this.isDragging;
2585
2673
  }
2586
2674
  onPointerUp(event) {
2587
- if (!this.config.enableClick) return;
2588
2675
  this.updatePointer(event);
2676
+ if (this.isDragging && this.dragNode) {
2677
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2678
+ if (this.raycaster.ray.intersectPlane(this.dragPlane, this.dragIntersection)) {
2679
+ const finalPosition = this.dragIntersection.clone().add(this.dragOffset);
2680
+ this.onNodeDragEnd(this.dragNode, finalPosition);
2681
+ }
2682
+ this.isDragging = false;
2683
+ this.dragNode = null;
2684
+ return;
2685
+ }
2686
+ if (this.dragNode) {
2687
+ this.onNodePointerUp();
2688
+ this.dragNode = null;
2689
+ }
2690
+ if (!this.config.enableClick) return;
2589
2691
  const node = this.getIntersectedNode(this.pointer);
2590
2692
  if (node) {
2591
2693
  const now = performance.now();
@@ -2662,8 +2764,6 @@ var InteractionManager = class {
2662
2764
  this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
2663
2765
  }
2664
2766
  };
2665
-
2666
- // src/visualization/animations/animation-controller.ts
2667
2767
  var AnimationController = class {
2668
2768
  constructor(camera, controls, config) {
2669
2769
  this.camera = camera;
@@ -2672,8 +2772,13 @@ var AnimationController = class {
2672
2772
  }
2673
2773
  focusTween = null;
2674
2774
  focusOnNode(position, callback) {
2675
- const direction = this.camera.position.clone().sub(this.controls.target).normalize();
2676
2775
  const distance = this.camera.position.distanceTo(this.controls.target);
2776
+ let direction;
2777
+ if (this.config.constrainTo2D) {
2778
+ direction = new THREE.Vector3(0, 0, 1);
2779
+ } else {
2780
+ direction = this.camera.position.clone().sub(this.controls.target).normalize();
2781
+ }
2677
2782
  const targetPosition = position.clone().add(direction.multiplyScalar(distance));
2678
2783
  this.focusOnPosition(targetPosition, position, callback);
2679
2784
  }
@@ -2841,6 +2946,10 @@ function NeuronWeb({
2841
2946
  onEdgeClick,
2842
2947
  onBackgroundClick,
2843
2948
  onCameraChange,
2949
+ draggable,
2950
+ onNodeDrag,
2951
+ onNodeDragEnd,
2952
+ keyboardControls,
2844
2953
  performanceMode,
2845
2954
  density,
2846
2955
  rendering,
@@ -3270,24 +3379,106 @@ function NeuronWeb({
3270
3379
  };
3271
3380
  }, [clusterRenderer]);
3272
3381
  const doubleClickEnabled = false;
3382
+ const dragConfig = useMemo(() => {
3383
+ if (!draggable) return { enabled: false, constrainToPlane: "xy" };
3384
+ if (draggable === true) {
3385
+ return { enabled: true, constrainToPlane: "xy" };
3386
+ }
3387
+ return {
3388
+ enabled: draggable.enabled,
3389
+ constrainToPlane: draggable.constrainToPlane ?? "xy"
3390
+ };
3391
+ }, [draggable]);
3392
+ const keyboardControlsConfig = useMemo(() => {
3393
+ if (!keyboardControls) return { enabled: false, panSpeed: 1, zoomSpeed: 1 };
3394
+ if (keyboardControls === true) {
3395
+ return { enabled: true, panSpeed: 1, zoomSpeed: 1 };
3396
+ }
3397
+ return {
3398
+ enabled: keyboardControls.enabled,
3399
+ panSpeed: keyboardControls.panSpeed ?? 1,
3400
+ zoomSpeed: keyboardControls.zoomSpeed ?? 1
3401
+ };
3402
+ }, [keyboardControls]);
3403
+ const pressedKeysRef = useRef(/* @__PURE__ */ new Set());
3404
+ useEffect(() => {
3405
+ if (!keyboardControlsConfig.enabled || !sceneManager) return;
3406
+ const basePanSpeed = 0.15 * keyboardControlsConfig.panSpeed;
3407
+ const baseZoomSpeed = 0.5 * keyboardControlsConfig.zoomSpeed;
3408
+ const handleKeyDown2 = (e) => {
3409
+ const key = e.key.toLowerCase();
3410
+ if (["w", "a", "s", "d", "q", "e"].includes(key)) {
3411
+ pressedKeysRef.current.add(key);
3412
+ e.preventDefault();
3413
+ }
3414
+ };
3415
+ const handleKeyUp = (e) => {
3416
+ const key = e.key.toLowerCase();
3417
+ pressedKeysRef.current.delete(key);
3418
+ };
3419
+ let animationFrameId;
3420
+ const animate = () => {
3421
+ const keys = pressedKeysRef.current;
3422
+ if (keys.size > 0) {
3423
+ const camera = sceneManager.camera;
3424
+ const controls = sceneManager.controls;
3425
+ const right = new THREE.Vector3();
3426
+ const up = new THREE.Vector3();
3427
+ camera.getWorldDirection(up);
3428
+ right.crossVectors(camera.up, up).normalize();
3429
+ up.copy(camera.up);
3430
+ let dx = 0, dy = 0, dz = 0;
3431
+ if (keys.has("a")) dx -= basePanSpeed;
3432
+ if (keys.has("d")) dx += basePanSpeed;
3433
+ if (keys.has("w")) dy += basePanSpeed;
3434
+ if (keys.has("s")) dy -= basePanSpeed;
3435
+ if (keys.has("q")) dz += baseZoomSpeed;
3436
+ if (keys.has("e")) dz -= baseZoomSpeed;
3437
+ const panOffset = right.multiplyScalar(dx).add(up.multiplyScalar(dy));
3438
+ camera.position.add(panOffset);
3439
+ controls.target.add(panOffset);
3440
+ if (dz !== 0) {
3441
+ const direction = new THREE.Vector3();
3442
+ camera.getWorldDirection(direction);
3443
+ const distance = camera.position.distanceTo(controls.target);
3444
+ const newDistance = Math.max(controls.minDistance, Math.min(controls.maxDistance, distance - dz));
3445
+ camera.position.copy(controls.target).add(direction.multiplyScalar(-newDistance));
3446
+ }
3447
+ controls.update();
3448
+ }
3449
+ animationFrameId = requestAnimationFrame(animate);
3450
+ };
3451
+ window.addEventListener("keydown", handleKeyDown2);
3452
+ window.addEventListener("keyup", handleKeyUp);
3453
+ animationFrameId = requestAnimationFrame(animate);
3454
+ return () => {
3455
+ window.removeEventListener("keydown", handleKeyDown2);
3456
+ window.removeEventListener("keyup", handleKeyUp);
3457
+ cancelAnimationFrame(animationFrameId);
3458
+ pressedKeysRef.current.clear();
3459
+ };
3460
+ }, [keyboardControlsConfig, sceneManager]);
3273
3461
  const interactionManager = useMemo(() => {
3274
3462
  if (!sceneManager) return null;
3275
3463
  return new InteractionManager(sceneManager.scene, sceneManager.camera, sceneManager.renderer, {
3276
3464
  enableHover: true,
3277
3465
  enableClick: true,
3278
3466
  enableDoubleClick: doubleClickEnabled,
3467
+ enableDrag: dragConfig.enabled,
3279
3468
  hoverDelay: Math.max(40, resolvedTheme.animation.hoverCardFadeDuration * 0.6),
3280
- doubleClickDelay: 280
3469
+ doubleClickDelay: 280,
3470
+ dragConstrainPlane: dragConfig.constrainToPlane
3281
3471
  });
3282
- }, [sceneManager, resolvedTheme.animation.hoverCardFadeDuration, doubleClickEnabled]);
3472
+ }, [sceneManager, resolvedTheme.animation.hoverCardFadeDuration, doubleClickEnabled, dragConfig]);
3283
3473
  const animationController = useMemo(() => {
3284
3474
  if (!sceneManager) return null;
3285
3475
  return new AnimationController(sceneManager.camera, sceneManager.controls, {
3286
3476
  focusDuration: resolvedAnimationConfig.focusDurationMs,
3287
3477
  transitionDuration: resolvedAnimationConfig.transitionDurationMs,
3288
- easing: resolvedAnimationConfig.easing
3478
+ easing: resolvedAnimationConfig.easing,
3479
+ constrainTo2D: isTreeLayout
3289
3480
  });
3290
- }, [sceneManager, resolvedAnimationConfig]);
3481
+ }, [sceneManager, resolvedAnimationConfig, isTreeLayout]);
3291
3482
  const rippleEnabled = resolvedAnimationConfig.enableSelectionRipple && resolvedPerformanceMode !== "fallback";
3292
3483
  const selectionRipple = useMemo(() => {
3293
3484
  if (!sceneManager || !rippleEnabled) return null;
@@ -3504,6 +3695,16 @@ function NeuronWeb({
3504
3695
  const focusOnNodePosition = useCallback(
3505
3696
  (nodePosition, callback) => {
3506
3697
  if (!animationController && !sceneManager) return;
3698
+ const getCameraDirection = () => {
3699
+ if (isTreeLayout) {
3700
+ return new THREE.Vector3(0, 0, 1);
3701
+ }
3702
+ const direction = sceneManager.camera.position.clone().sub(sceneManager.controls.target);
3703
+ if (direction.lengthSq() < 1e-4) {
3704
+ direction.set(0, 0, 1);
3705
+ }
3706
+ return direction.normalize();
3707
+ };
3507
3708
  if (Array.isArray(clickZoomOffset) && clickZoomOffset.length === 3) {
3508
3709
  const offset = new THREE.Vector3(...clickZoomOffset);
3509
3710
  const targetPosition = nodePosition.clone().add(offset);
@@ -3524,11 +3725,7 @@ function NeuronWeb({
3524
3725
  }
3525
3726
  return;
3526
3727
  }
3527
- const direction = sceneManager.camera.position.clone().sub(sceneManager.controls.target);
3528
- if (direction.lengthSq() < 1e-4) {
3529
- direction.set(0, 0, 1);
3530
- }
3531
- direction.normalize();
3728
+ const direction = getCameraDirection();
3532
3729
  const targetPosition = nodePosition.clone().add(direction.multiplyScalar(clickZoomDistance));
3533
3730
  if (cameraTweenEnabled && animationController) {
3534
3731
  animationController.focusOnPosition(targetPosition, nodePosition, callback);
@@ -3543,11 +3740,7 @@ function NeuronWeb({
3543
3740
  if (cameraTweenEnabled && animationController) {
3544
3741
  animationController.focusOnNode(nodePosition, callback);
3545
3742
  } else if (sceneManager) {
3546
- const direction = sceneManager.camera.position.clone().sub(sceneManager.controls.target);
3547
- if (direction.lengthSq() < 1e-4) {
3548
- direction.set(0, 0, 1);
3549
- }
3550
- direction.normalize();
3743
+ const direction = getCameraDirection();
3551
3744
  const distance = sceneManager.camera.position.distanceTo(sceneManager.controls.target);
3552
3745
  const targetPosition = nodePosition.clone().add(direction.multiplyScalar(distance));
3553
3746
  sceneManager.camera.position.copy(targetPosition);
@@ -3556,7 +3749,7 @@ function NeuronWeb({
3556
3749
  if (callback) callback();
3557
3750
  }
3558
3751
  },
3559
- [animationController, clickZoomOffset, clickZoomDistance, sceneManager, cameraTweenEnabled]
3752
+ [animationController, clickZoomOffset, clickZoomDistance, sceneManager, cameraTweenEnabled, isTreeLayout]
3560
3753
  );
3561
3754
  const handleKeyDown = useCallback(
3562
3755
  (event) => {
@@ -3783,11 +3976,16 @@ function NeuronWeb({
3783
3976
  const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
3784
3977
  const targetFov = Math.min(vFov, hFov);
3785
3978
  const distance = radius * (1 + padding) / Math.tan(targetFov * viewportFraction / 2);
3786
- const direction = camera.position.clone().sub(sceneManager.controls.target);
3787
- if (direction.lengthSq() < 1e-4) {
3788
- direction.set(0, 0, 1);
3979
+ let direction;
3980
+ if (isTreeLayout) {
3981
+ direction = new THREE.Vector3(0, 0, 1);
3982
+ } else {
3983
+ direction = camera.position.clone().sub(sceneManager.controls.target);
3984
+ if (direction.lengthSq() < 1e-4) {
3985
+ direction.set(0, 0, 1);
3986
+ }
3987
+ direction.normalize();
3789
3988
  }
3790
- direction.normalize();
3791
3989
  const targetPosition = sphere.center.clone().add(direction.multiplyScalar(distance));
3792
3990
  if (cameraTweenEnabled) {
3793
3991
  animationController.focusOnPosition(targetPosition, sphere.center.clone());
@@ -3804,7 +4002,8 @@ function NeuronWeb({
3804
4002
  displayNodes,
3805
4003
  fitSignature,
3806
4004
  cameraFitSuspended,
3807
- cameraTweenEnabled
4005
+ cameraTweenEnabled,
4006
+ isTreeLayout
3808
4007
  ]);
3809
4008
  useEffect(() => {
3810
4009
  if (!sceneManager) return;
@@ -4058,6 +4257,34 @@ function NeuronWeb({
4058
4257
  onEdgeClick(edge);
4059
4258
  }
4060
4259
  };
4260
+ if (dragConfig.enabled) {
4261
+ interactionManager.onNodePointerDown = () => {
4262
+ if (sceneManager) {
4263
+ sceneManager.controls.enabled = false;
4264
+ }
4265
+ };
4266
+ interactionManager.onNodePointerUp = () => {
4267
+ if (sceneManager) {
4268
+ sceneManager.controls.enabled = true;
4269
+ }
4270
+ };
4271
+ interactionManager.onNodeDragStart = () => {
4272
+ };
4273
+ interactionManager.onNodeDrag = (node, position) => {
4274
+ nodeRenderer.updateNodePosition(node.id, position);
4275
+ if (onNodeDrag) {
4276
+ onNodeDrag(node, [position.x, position.y, position.z]);
4277
+ }
4278
+ };
4279
+ interactionManager.onNodeDragEnd = (node, position) => {
4280
+ if (sceneManager) {
4281
+ sceneManager.controls.enabled = true;
4282
+ }
4283
+ if (onNodeDragEnd) {
4284
+ onNodeDragEnd(node, [position.x, position.y, position.z]);
4285
+ }
4286
+ };
4287
+ }
4061
4288
  }, [
4062
4289
  interactionManager,
4063
4290
  nodeRenderer,
@@ -4076,6 +4303,10 @@ function NeuronWeb({
4076
4303
  onBackgroundClick,
4077
4304
  applyFocusEdges,
4078
4305
  selectionRipple,
4306
+ dragConfig,
4307
+ sceneManager,
4308
+ onNodeDrag,
4309
+ onNodeDragEnd,
4079
4310
  selectionControlled,
4080
4311
  doubleClickEnabled
4081
4312
  ]);
@@ -4628,5 +4859,5 @@ function dedupePreserveOrder(values) {
4628
4859
  }
4629
4860
 
4630
4861
  export { DEFAULT_RENDERING_OPTIONS, DEFAULT_STATUS_COLORS, DEFAULT_THEME, NeuronContext, NeuronWeb, NeuronWebExplorer, SceneManager, ThemeEngine, applyFuzzyLayout, applyTreeLayout, createStoryBeat, createStudyPathFromBeat, createStudyPathFromNodeIds, getAutoPerformanceMode, normalizeStoryBeat, useNeuronContext, useNeuronGraph, validateStoryBeat };
4631
- //# sourceMappingURL=chunk-FROPRJSB.js.map
4632
- //# sourceMappingURL=chunk-FROPRJSB.js.map
4862
+ //# sourceMappingURL=chunk-QKCKZYLH.js.map
4863
+ //# sourceMappingURL=chunk-QKCKZYLH.js.map