moveNodeDrag method

void moveNodeDrag(
  1. Offset graphDelta
)

Moves nodes during a drag operation.

Call this from NodeWidget's GestureDetector.onPanUpdate. The delta is already in graph coordinates since GestureDetector is inside InteractiveViewer's transformed space - no conversion needed.

Position Model

This method uses a two-position model:

  • position: The user's intended position (accumulates raw deltas)
  • visualPosition: The displayed position (may be snapped)

The snap delegate transforms intended → visual position. This prevents "sticky snap" behavior where small movements can't escape a snap point.

The method applies snapping in this order:

  1. Snap delegate (alignment guides to other nodes)
  2. Snap-to-grid (quantizes final position if enabled in config)

Parameters:

  • graphDelta: The movement delta in graph coordinates

Implementation

void moveNodeDrag(Offset graphDelta) {
  final draggedNodeId = interaction.draggedNodeId.value;
  if (draggedNodeId == null) return;

  // Get nodes to move
  final nodeIdsToMove = selectedNodeIds.contains(draggedNodeId)
      ? selectedNodeIds.toSet()
      : {draggedNodeId};

  // Collect nodes that will be moved for event firing
  final movedNodes = <Node<T>>[];
  final context = _createDragContext();

  // First pass: Update all nodes' intended positions (raw movement)
  runInAction(() {
    for (final nodeId in nodeIdsToMove) {
      final node = _nodes[nodeId];
      if (node != null) {
        node.position.value = node.position.value + graphDelta;
        movedNodes.add(node);
      }
    }
  });

  // Get the primary node's intended position for snap calculation
  final primaryNode = _nodes[draggedNodeId];
  final intendedPosition = primaryNode?.position.value ?? Offset.zero;

  // Calculate snap adjustment
  var snappingX = false;
  var snappingY = false;
  var snapDelta = Offset.zero;

  if (_snapDelegate != null) {
    final snapResult = _snapDelegate!.snapPosition(
      draggedNodeIds: nodeIdsToMove,
      intendedPosition: intendedPosition,
      visibleBounds: visibleGraphBounds,
    );

    // Calculate the delta between intended and snapped positions
    // This delta will be applied to all selected nodes to maintain relative positions
    snapDelta = snapResult.position - intendedPosition;
    snappingX = snapResult.snappingX;
    snappingY = snapResult.snappingY;
  }

  // Second pass: Apply visual positions to all moved nodes
  runInAction(() {
    for (final node in movedNodes) {
      // Visual position = intended position + snap delta
      final snappedPosition = node.position.value + snapDelta;

      // Apply grid snapping only to axes not handled by snap delegate
      // This allows alignment snap (when active) to take priority over grid snap
      final visualPosition = _applyGridSnapPerAxis(
        snappedPosition,
        skipX: snappingX,
        skipY: snappingY,
      );
      node.setVisualPosition(visualPosition);

      // Notify the node of its own drag move
      node.onDragMove(graphDelta, context);
    }
  });

  // Mark moved nodes dirty for spatial index
  _markNodesDirty(movedNodes.map((n) => n.id));

  // Fire drag event for all moved nodes
  for (final node in movedNodes) {
    events.node?.onDrag?.call(node);
  }
}