// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import './nodegraph.scss';
/**
 * A component for displaying and interacting with a node-based graph.
 *
 * Features:
 * - Draggable, selectable, and removable nodes.
 * - Pannable and zoomable canvas.
 * - Connectable ports to create links between nodes.
 * - Docking nodes to each other to form chains.
 * - Customizable node content and appearance.
 * - Auto-layout and fit-to-screen functionality.
 *
 * Minimal example:
 *
 * ```typescript
 * const nodes: Node[] = [
 *   {id: 'node1', x: 50, y: 50, outputs: [{direction: 'right'}]},
 *   {id: 'node2', x: 250, y: 50, inputs: [{direction: 'left'}]},
 * ];
 *
 * const connections: Connection[] = [
 *   {fromNode: 'node1', fromPort: 0, toNode: 'node2', toPort: 0},
 * ];
 *
 * m(NodeGraph, {
 *   nodes,
 *   connections,
 *   onConnect: (newConnection) => {
 *     // Handle new connection
 *   },
 *   onNodeMove: (nodeId, x, y) => {
 *     // Handle node position change (called when node is dropped)
 *   },
 * });
 * ```
 */
import m from 'mithril';
import {Button, ButtonGroup, ButtonVariant} from './button';
import {Icon} from './icon';
import {PopupMenu} from './menu';
import {classNames} from '../base/classnames';
import {Icons} from '../base/semantic_icons';
import {assertExists} from '../base/assert';
import {shortUuid} from '../base/uuid';

// Default height estimate for labels (used for box selection calculations)
const DEFAULT_LABEL_MIN_HEIGHT = 30;

// Typical height estimate for labels with content (used for autofit calculations)
// Labels can vary in height based on content, but this provides a reasonable
// estimate for bounding box calculations when actual DOM measurements aren't available
const TYPICAL_LABEL_HEIGHT = 100;

interface Position {
  x: number;
  y: number;
  transformedX?: number;
  transformedY?: number;
}

export interface Connection {
  readonly fromNode: string;
  readonly fromPort: number;
  readonly toNode: string;
  readonly toPort: number;
}

export interface NodeTitleBar {
  readonly title: m.Children;
  readonly icon?: string;
}

export interface NodePort {
  readonly content?: m.Children;
  readonly direction: 'top' | 'left' | 'right' | 'bottom';
  readonly contextMenuItems?: m.Children;
}

export type DockedNode = Omit<Node, 'x' | 'y'>;

export interface Node {
  readonly id: string;
  readonly x: number;
  readonly y: number;
  readonly hue?: number; // Color of the title / accent bar (0-360)
  readonly accentBar?: boolean; // Optional strip of accent color on the left side (doesn't work well with titleBar)
  readonly titleBar?: NodeTitleBar; // Optional title bar (doesn't work well with accentBar or docking)
  readonly inputs?: ReadonlyArray<NodePort>;
  readonly outputs?: ReadonlyArray<NodePort>;
  readonly content?: m.Children; // Optional custom content to render in node body
  readonly next?: DockedNode; // Next node in chain
  readonly canDockTop?: boolean;
  readonly canDockBottom?: boolean;
  readonly contextMenuItems?: m.Children;
  readonly invalid?: boolean; // Whether this node is in an invalid state
  readonly className?: string; // Extra CSS class(es) on the .pf-node element
}

export interface Label {
  readonly id: string;
  x: number;
  y: number;
  width: number; // Width of the label box (user can resize)
  content?: m.Children; // Content to render inside the label (optional, defaults to empty)
  selectable?: boolean; // Whether clicking the label selects it (default: false, only shift+click works)
}

interface ConnectingState {
  nodeId: string;
  portIndex: number;
  type: 'input' | 'output';
  portType: 'top' | 'bottom' | 'left' | 'right';
  x: number;
  y: number;
  transformedX: number;
  transformedY: number;
}

interface UndockCandidate {
  nodeId: string;
  parentId: string;
  startX: number;
  startY: number;
  renderY: number;
}

interface UndockedNode {
  nodeId: string;
  parentId: string;
}

interface SelectionRect {
  startX: number;
  startY: number;
  currentX: number;
  currentY: number;
}

interface CanvasState {
  draggedNode: string | null;
  dragOffset: Position;
  connecting: ConnectingState | null;
  mousePos: Position;
  selectedNodes: ReadonlySet<string>;
  panOffset: Position;
  isPanning: boolean;
  panStart: Position;
  zoom: number;
  dockTarget: string | null; // Node being targeted for docking
  isDockZone: boolean; // Whether we're in valid dock position
  undockCandidate: UndockCandidate | null; // Tracks potential undock before threshold
  undockedNode: UndockedNode | null; // Node that was undocked (set when threshold exceeded)
  hoveredPort: {
    nodeId: string;
    portIndex: number;
    type: 'input' | 'output';
  } | null;
  selectionRect: SelectionRect | null; // Box selection state
  canvasMouseDownPos: Position;
  tempNodePositions: Map<string, Position>; // Temporary positions during drag
  tempLabelPositions: Map<string, Position>; // Temporary label positions during drag
  tempLabelWidths: Map<string, number>; // Temporary label widths during resize
  draggedLabel: string | null; // ID of label being dragged
  labelDragStartPos: Position | null; // Position where label drag started
  resizingLabel: string | null; // ID of label being resized
  resizeStartWidth: number; // Width when resize started
  resizeStartX: number; // Mouse X position when resize started
}

export interface NodeGraphApi {
  autoLayout: () => void;
  recenter: () => void;
  findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position;
  panBy: (dx: number, dy: number) => void;
  /**
   * Zooms the canvas by the given delta factor.
   * @param deltaZoom - The zoom delta (e.g., 0.1 for 10% zoom in, -0.1 for 10% zoom out)
   * @param centerX - X coordinate to zoom around (in viewport space). Defaults to canvas center.
   * @param centerY - Y coordinate to zoom around (in viewport space). Defaults to canvas center.
   */
  zoomBy: (deltaZoom: number, centerX?: number, centerY?: number) => void;
  /**
   * Reset the canvas zoom level to the default (1.0) retaining the current
   * center point.
   */
  resetZoom: () => void;
}

export interface NodeGraphAttrs {
  readonly nodes: ReadonlyArray<Node>;
  readonly connections: ReadonlyArray<Connection>;
  readonly labels?: ReadonlyArray<Label>;
  readonly onConnect?: (connection: Connection) => void;
  readonly onNodeMove?: (nodeId: string, x: number, y: number) => void;
  readonly onConnectionRemove?: (index: number) => void;
  readonly onReady?: (api: NodeGraphApi) => void;
  // Selection state and callbacks apply to both nodes and labels.
  // selectedNodeIds should contain IDs of both selected nodes and labels.
  readonly selectedNodeIds?: ReadonlySet<string>;
  // Called when a node or label is selected (replacing current selection).
  readonly onNodeSelect?: (nodeId: string) => void;
  // Called when a node or label is added to the current selection (multiselect).
  readonly onNodeAddToSelection?: (nodeId: string) => void;
  // Called when a node or label is removed from the current selection.
  readonly onNodeRemoveFromSelection?: (nodeId: string) => void;
  readonly onSelectionClear?: () => void;
  readonly onDock?: (
    parentId: string,
    childNode: Omit<Node, 'x' | 'y'>,
  ) => void;
  readonly onUndock?: (
    parentId: string,
    nodeId: string,
    x: number,
    y: number,
  ) => void;
  readonly onNodeRemove?: (nodeId: string) => void;
  readonly onLabelMove?: (labelId: string, x: number, y: number) => void;
  readonly onLabelResize?: (labelId: string, width: number) => void;
  readonly onLabelRemove?: (labelId: string) => void;
  readonly hideControls?: boolean;
  readonly multiselect?: boolean; // Enable multi-node selection (default: true)
  readonly contextMenuOnHover?: boolean; // Show context menu on hover (default: false)
  readonly fillHeight?: boolean;
  readonly toolbarItems?: m.Children;
  readonly style?: Partial<CSSStyleDeclaration>;
}

const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking

function isPortConnected(
  nodeId: string,
  portType: 'input' | 'output',
  portIndex: number,
  connections: ReadonlyArray<Connection>,
): boolean {
  return connections.some((conn) => {
    if (portType === 'input') {
      return conn.toNode === nodeId && conn.toPort === portIndex;
    } else {
      return conn.fromNode === nodeId && conn.fromPort === portIndex;
    }
  });
}

// Get the entire chain starting from a root node
function getChain(rootNode: Node): Array<Node | Omit<Node, 'x' | 'y'>> {
  const chain: Array<Node | Omit<Node, 'x' | 'y'>> = [rootNode];
  let current = rootNode.next;

  while (current) {
    chain.push(current);
    current = current.next;
  }

  return chain;
}

function createCurve(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  fromPortType?: 'top' | 'bottom' | 'left' | 'right',
  toPortType?: 'top' | 'bottom' | 'left' | 'right',
  shortenEnd = 0,
): string {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const distance = Math.sqrt(dx * dx + dy * dy);

  let cx1: number;
  let cy1: number;
  let cx2: number;
  let cy2: number;

  if (shortenEnd > 0) {
    if (toPortType === 'bottom') {
      y2 += shortenEnd;
    } else if (toPortType === 'top') {
      y2 -= shortenEnd;
    } else if (toPortType === 'left') {
      x2 -= shortenEnd;
    } else if (toPortType === 'right') {
      x2 += shortenEnd;
    }
  }

  // For top/bottom ports, control points extend vertically
  // For left/right ports, control points extend horizontally
  if (fromPortType === 'bottom' || fromPortType === 'top') {
    // First control point extends vertically
    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
    cx1 = x1;
    cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset;
  } else {
    // First control point extends horizontally for left/right ports
    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
    cx1 = x1 + horizontalOffset;
    cy1 = y1; // Keep Y constant for horizontal extension
  }

  if (toPortType === 'bottom' || toPortType === 'top') {
    // Second control point extends vertically
    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
    cx2 = x2;
    cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset;
  } else {
    // Second control point extends horizontally for left/right ports
    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
    cx2 = x2 - horizontalOffset;
    cy2 = y2; // Keep Y constant for horizontal extension
  }

  // if (shortenEnd > 0) {
  //   const tangentX = x2 - cx2;
  //   const tangentY = y2 - cy2;
  //   const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
  //   if (tangentLength > shortenEnd) {
  //     const unitTangentX = tangentX / tangentLength;
  //     const unitTangentY = tangentY / tangentLength;
  //     x2 -= unitTangentX * shortenEnd;
  //     y2 -= unitTangentY * shortenEnd;
  //   }
  // }

  return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}

export function NodeGraph(): m.Component<NodeGraphAttrs> {
  const canvasState: CanvasState = {
    draggedNode: null,
    dragOffset: {x: 0, y: 0},
    connecting: null,
    mousePos: {x: 0, y: 0},
    selectedNodes: new Set<string>(),
    panOffset: {x: 0, y: 0},
    isPanning: false,
    panStart: {x: 0, y: 0},
    zoom: 1.0,
    dockTarget: null,
    isDockZone: false,
    undockCandidate: null,
    undockedNode: null,
    hoveredPort: null,
    selectionRect: null,
    canvasMouseDownPos: {x: 0, y: 0},
    tempNodePositions: new Map<string, Position>(),
    tempLabelPositions: new Map<string, Position>(),
    tempLabelWidths: new Map<string, number>(),
    draggedLabel: null,
    labelDragStartPos: null,
    resizingLabel: null,
    resizeStartWidth: 0,
    resizeStartX: 0,
  };

  // Track drag state for batching updates
  let dragStartPosition: {nodeId: string; x: number; y: number} | null = null;
  let currentDragPosition: {x: number; y: number} | null = null;

  let latestVnode: m.Vnode<NodeGraphAttrs> | null = null;
  let canvasElement: HTMLElement | null = null;

  // Unique instance ID for SVG marker references. Multiple NodeGraph instances
  // (e.g. in different tabs) each create <marker id="..."> elements. Without
  // unique IDs, url(#arrowhead) resolves to the first matching marker in
  // document order, which may be inside a hidden tab (display:none).
  const instanceId = shortUuid();

  // Shared pan function used by both internal handlers and external API
  const panBy = (dx: number, dy: number) => {
    canvasState.panOffset.x += dx;
    canvasState.panOffset.y += dy;
  };

  // Shared zoom function used by both internal handlers and external API
  // Zooms around the center of the canvas (or specified point)
  const zoomBy = (deltaZoom: number, centerX?: number, centerY?: number) => {
    if (!canvasElement) return;
    const canvas = canvasElement;
    const canvasRect = canvas.getBoundingClientRect();

    // Use center of canvas if no center point specified
    const zoomX = centerX ?? canvasRect.width / 2;
    const zoomY = centerY ?? canvasRect.height / 2;

    const newZoom = Math.max(
      0.1,
      Math.min(5.0, canvasState.zoom * (1 + deltaZoom)),
    );

    // Calculate the point in canvas space (before zoom)
    const canvasX = (zoomX - canvasState.panOffset.x) / canvasState.zoom;
    const canvasY = (zoomY - canvasState.panOffset.y) / canvasState.zoom;

    // Update zoom
    canvasState.zoom = newZoom;

    // Adjust pan to keep the same point under the zoom center
    canvasState.panOffset = {
      x: zoomX - canvasX * newZoom,
      y: zoomY - canvasY * newZoom,
    };
  };

  // API functions that are exposed to parent components via onReady callback
  // These are initialized in oncreate and can be used in subsequent lifecycle hooks
  let autoLayoutApi: (() => void) | null = null;
  let recenterApi: (() => void) | null = null;
  let resetZoom: (() => void) | null = null;
  let findPlacementForNodeApi:
    | ((newNode: Omit<Node, 'x' | 'y'>) => Position)
    | null = null;

  const handleMouseMove = (e: PointerEvent) => {
    m.redraw();
    if (!latestVnode || !canvasElement) return;
    const vnode = latestVnode;
    const canvas = canvasElement;
    const canvasRect = canvas.getBoundingClientRect();

    // Store both screen and transformed coordinates
    canvasState.mousePos = {
      x: e.clientX,
      y: e.clientY,
      transformedX:
        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
        canvasState.zoom,
      transformedY:
        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
        canvasState.zoom,
    };

    // Track hovered port (useful for connection snapping and visual feedback)
    const portElement = (e.target as HTMLElement).closest('.pf-port.pf-input');
    if (portElement) {
      const nodeElement = portElement.closest(
        '[data-node]',
      ) as HTMLElement | null;
      const portId =
        portElement.getAttribute('data-port') ||
        portElement.parentElement?.getAttribute('data-port');

      if (nodeElement && portId) {
        const nodeId = nodeElement.dataset.node!;
        const [type, portIndexStr] = portId.split('-');
        if (type === 'input') {
          const portIndex = parseInt(portIndexStr, 10);
          canvasState.hoveredPort = {nodeId, portIndex, type: 'input'};
        } else {
          canvasState.hoveredPort = null;
        }
      } else {
        canvasState.hoveredPort = null;
      }
    } else {
      canvasState.hoveredPort = null;
    }

    if (canvasState.selectionRect) {
      // Update selection rectangle
      canvasState.selectionRect.currentX =
        canvasState.mousePos.transformedX ?? 0;
      canvasState.selectionRect.currentY =
        canvasState.mousePos.transformedY ?? 0;
      m.redraw();
    } else if (canvasState.draggedLabel !== null) {
      // Handle label dragging - store temp position, don't call callback yet
      const newX =
        (canvasState.mousePos.transformedX ?? 0) - canvasState.dragOffset.x;
      const newY =
        (canvasState.mousePos.transformedY ?? 0) - canvasState.dragOffset.y;

      // Store temporary position during drag
      canvasState.tempLabelPositions.set(canvasState.draggedLabel, {
        x: newX,
        y: newY,
      });
      m.redraw();
    } else if (canvasState.resizingLabel !== null) {
      // Handle label resizing - store temp width, don't call callback yet
      const currentX = canvasState.mousePos.transformedX ?? 0;
      const deltaX = currentX - canvasState.resizeStartX;
      const newWidth = Math.max(100, canvasState.resizeStartWidth + deltaX);

      // Store temporary width during resize
      canvasState.tempLabelWidths.set(canvasState.resizingLabel, newWidth);
      m.redraw();
    } else if (canvasState.isPanning) {
      // Pan the canvas
      const dx = e.clientX - canvasState.panStart.x;
      const dy = e.clientY - canvasState.panStart.y;
      panBy(dx, dy);
      canvasState.panStart = {x: e.clientX, y: e.clientY};
      m.redraw();
    } else if (canvasState.undockCandidate !== null) {
      // Check if we've exceeded the undock threshold
      const dx = e.clientX - canvasState.undockCandidate.startX;
      const dy = e.clientY - canvasState.undockCandidate.startY;
      const distance = Math.sqrt(dx * dx + dy * dy);

      if (distance > UNDOCK_THRESHOLD) {
        // Exceeded threshold - call onUndock immediately so node becomes independent
        const {onUndock} = vnode.attrs;
        const tempX =
          (canvasState.undockCandidate.startX -
            canvasRect.left -
            canvasState.panOffset.x) /
            canvasState.zoom -
          canvasState.dragOffset.x / canvasState.zoom;
        const tempY = canvasState.undockCandidate.renderY;

        // Store temp position for this node
        canvasState.tempNodePositions.set(canvasState.undockCandidate.nodeId, {
          x: tempX,
          y: tempY,
        });

        // Immediately call onUndock so the node becomes independent
        if (onUndock) {
          onUndock(
            canvasState.undockCandidate.parentId,
            canvasState.undockCandidate.nodeId,
            tempX,
            tempY,
          );
        }

        // Mark as undocked so we track it as a regular drag now
        canvasState.undockedNode = {
          nodeId: canvasState.undockCandidate.nodeId,
          parentId: canvasState.undockCandidate.parentId,
        };

        canvasState.undockCandidate = null;
        m.redraw(); // Force update so nodes array regenerates
      }
    } else if (canvasState.draggedNode !== null) {
      // Calculate new position relative to canvas container (accounting for pan and zoom)
      const newX =
        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
          canvasState.zoom -
        canvasState.dragOffset.x / canvasState.zoom;
      const newY =
        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
          canvasState.zoom -
        canvasState.dragOffset.y / canvasState.zoom;

      // Store current position internally
      currentDragPosition = {x: newX, y: newY};
      canvasState.tempNodePositions.set(canvasState.draggedNode, {
        x: newX,
        y: newY,
      });

      // Check if we're in a dock zone (exclude the parent we just undocked from)
      const {nodes} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
      if (draggedNode) {
        const dockInfo = findDockTarget(draggedNode, newX, newY, nodes);
        canvasState.dockTarget = dockInfo.targetNodeId;
        canvasState.isDockZone = dockInfo.isValidZone;
      }
      m.redraw();
    }
  };

  const handleMouseUp = () => {
    if (!latestVnode) return;
    const vnode = latestVnode;

    // Handle box selection completion
    if (canvasState.selectionRect) {
      const {nodes = [], labels = []} = vnode.attrs;
      const rect = canvasState.selectionRect;
      const minX = Math.min(rect.startX, rect.currentX);
      const maxX = Math.max(rect.startX, rect.currentX);
      const minY = Math.min(rect.startY, rect.currentY);
      const maxY = Math.max(rect.startY, rect.currentY);

      // Helper to check if a node at given position overlaps with selection rectangle
      const nodeOverlapsRect = (
        nodeX: number,
        nodeY: number,
        nodeId: string,
      ): boolean => {
        const dims = getNodeDimensions(nodeId);
        const nodeRight = nodeX + dims.width;
        const nodeBottom = nodeY + dims.height;

        return (
          nodeX < maxX && nodeRight > minX && nodeY < maxY && nodeBottom > minY
        );
      };

      // Helper to check if a label overlaps with selection rectangle
      const labelOverlapsRect = (label: Label): boolean => {
        const labelRight = label.x + label.width;
        const labelBottom = label.y + DEFAULT_LABEL_MIN_HEIGHT;

        return (
          label.x < maxX &&
          labelRight > minX &&
          label.y < maxY &&
          labelBottom > minY
        );
      };

      // Find all nodes (including chained/docked nodes) that intersect with the selection rectangle
      const selectedInRect: string[] = [];
      nodes.forEach((node) => {
        // Check root node
        if (nodeOverlapsRect(node.x, node.y, node.id)) {
          selectedInRect.push(node.id);
        }

        // Check all chained nodes
        const chain = getChain(node);
        let currentY = node.y;
        chain.slice(1).forEach((chainNode) => {
          // For chained nodes, calculate their Y position
          const previousNodeId = chain[chain.indexOf(chainNode) - 1].id;
          currentY += getNodeDimensions(previousNodeId).height;

          if (nodeOverlapsRect(node.x, currentY, chainNode.id)) {
            selectedInRect.push(chainNode.id);
          }
        });
      });

      // Find all labels that intersect with the selection rectangle
      labels.forEach((label) => {
        if (labelOverlapsRect(label)) {
          selectedInRect.push(label.id);
        }
      });

      // Add all selected nodes and labels to selection
      const {onNodeAddToSelection} = vnode.attrs;
      selectedInRect.forEach((id) => {
        if (!canvasState.selectedNodes.has(id)) {
          if (onNodeAddToSelection !== undefined) {
            onNodeAddToSelection(id);
          }
        }
      });

      canvasState.selectionRect = null;
      m.redraw();
      return;
    }

    // Handle docking if in dock zone
    if (
      canvasState.draggedNode &&
      canvasState.isDockZone &&
      canvasState.dockTarget
    ) {
      const {nodes = [], onDock} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
      if (onDock && draggedNode) {
        // Create child node without x/y coordinates
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const {x, y, ...childNode} = draggedNode;
        onDock(canvasState.dockTarget, childNode);
      }
    }

    // Check for collision and finalize drag (only for non-docked/undocked nodes)
    if (canvasState.draggedNode !== null && !canvasState.isDockZone) {
      const {nodes = [], onNodeMove} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);

      // Only do overlap checking if NOT being docked
      if (draggedNode) {
        // Get actual node dimensions from DOM
        const dims = getNodeDimensions(draggedNode.id);

        // Calculate total height of the dragged node's chain
        const chain = getChain(draggedNode);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          chainHeight += getNodeDimensions(chainNode.id).height;
        });

        // Check if node (and its entire chain) overlaps with any other nodes
        if (
          currentDragPosition &&
          checkNodeOverlap(
            currentDragPosition.x,
            currentDragPosition.y,
            draggedNode.id,
            nodes,
            dims.width,
            chainHeight,
          )
        ) {
          // Find nearest non-overlapping position
          const newPos = findNearestNonOverlappingPosition(
            currentDragPosition.x,
            currentDragPosition.y,
            draggedNode.id,
            nodes,
            dims.width,
            chainHeight,
          );
          // Update to the non-overlapping position
          currentDragPosition = newPos;
          canvasState.tempNodePositions.set(draggedNode.id, newPos);
        }
      }

      // Call onNodeMove with final position if it changed
      // For undocked nodes, this provides the final position after dragging
      // For regular nodes, this is the only position update
      if (onNodeMove !== undefined && currentDragPosition !== null) {
        const startX = dragStartPosition?.x ?? 0;
        const startY = dragStartPosition?.y ?? 0;
        const moved =
          Math.abs(currentDragPosition.x - startX) > 0.5 ||
          Math.abs(currentDragPosition.y - startY) > 0.5;
        if (moved || canvasState.undockedNode !== null) {
          onNodeMove(
            canvasState.draggedNode,
            currentDragPosition.x,
            currentDragPosition.y,
          );
        }
      }
    }

    // Handle label callbacks with final values
    const {onLabelMove, onLabelResize} = vnode.attrs;

    if (canvasState.draggedLabel !== null) {
      const finalPos = canvasState.tempLabelPositions.get(
        canvasState.draggedLabel,
      );
      if (finalPos && onLabelMove) {
        onLabelMove(canvasState.draggedLabel, finalPos.x, finalPos.y);
      }
    }

    if (canvasState.resizingLabel !== null) {
      const finalWidth = canvasState.tempLabelWidths.get(
        canvasState.resizingLabel,
      );
      if (finalWidth !== undefined && onLabelResize) {
        onLabelResize(canvasState.resizingLabel, finalWidth);
      }
    }

    // Cleanup label state
    canvasState.draggedLabel = null;
    canvasState.labelDragStartPos = null;
    canvasState.resizingLabel = null;
    canvasState.tempLabelPositions.clear();
    canvasState.tempLabelWidths.clear();

    canvasState.draggedNode = null;
    dragStartPosition = null;
    currentDragPosition = null;
    canvasState.connecting = null;
    canvasState.hoveredPort = null;
    canvasState.isPanning = false;
    canvasState.dockTarget = null;
    canvasState.isDockZone = false;
    canvasState.undockCandidate = null;
    canvasState.undockedNode = null;
    canvasState.tempNodePositions.clear();
    m.redraw();
  };

  // Helper to determine port type based on port index
  function getPortType(
    nodeId: string,
    portType: 'input' | 'output',
    portIndex: number,
    nodes: ReadonlyArray<Node>,
  ): 'top' | 'bottom' | 'left' | 'right' {
    // Search in main nodes array
    let node: Node | Omit<Node, 'x' | 'y'> | undefined = nodes.find(
      (n) => n.id === nodeId,
    );

    // If not found, search in the next chains of all nodes
    if (!node) {
      for (const rootNode of nodes) {
        let current = rootNode.next;
        while (current) {
          if (current.id === nodeId) {
            node = current;
            break;
          }
          current = current.next;
        }
        if (node) break;
      }
    }

    if (!node) return portType === 'input' ? 'left' : 'right';

    // Get the port from the node
    const ports = portType === 'input' ? node.inputs : node.outputs;
    if (!ports || portIndex >= ports.length) {
      return portType === 'input' ? 'left' : 'right';
    }

    return ports[portIndex].direction;
  }

  function renderConnections(
    svg: SVGElement,
    connections: ReadonlyArray<Connection>,
    nodes: ReadonlyArray<Node>,
    onConnectionRemove?: (index: number) => void,
  ) {
    const shortenLength = 16;
    const arrowheadLength = 4;

    // Cache all port positions at once for performance
    const portPositionCache = new Map<string, Position>();

    // Query ports within this NodeGraph instance only (not globally).
    // Using document.querySelectorAll would pick up ports from other
    // NodeGraph instances (e.g. hidden tabs), causing incorrect positions.
    const container = assertExists(canvasElement);
    const allPorts = container.querySelectorAll('.pf-port[data-port]');
    allPorts.forEach((portElement) => {
      const portId = portElement.getAttribute('data-port');
      if (!portId) return;

      const nodeElement = portElement.closest(
        '[data-node]',
      ) as HTMLElement | null;
      if (!nodeElement) return;

      const nodeId = nodeElement.dataset.node;
      if (!nodeId) return;

      const [portType, portIndexStr] = portId.split('-');
      const cacheKey = `${nodeId}-${portType}-${portIndexStr}`;

      // Calculate position
      const chainContainer = nodeElement.closest(
        '.pf-node-wrapper',
      ) as HTMLElement | null;

      let nodeLeft: number;
      let nodeTop: number;

      if (chainContainer) {
        // Node is in a dock chain - use container's position
        nodeLeft = parseFloat(chainContainer.style.left) || 0;
        nodeTop = parseFloat(chainContainer.style.top) || 0;

        // Add offset of node within the chain
        const chainRect = chainContainer.getBoundingClientRect();
        const nodeRect = nodeElement.getBoundingClientRect();
        const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;

        nodeTop += offsetY;
      } else {
        // Standalone node - use its position directly
        nodeLeft = parseFloat(nodeElement.style.left) || 0;
        nodeTop = parseFloat(nodeElement.style.top) || 0;
      }

      // Get port's position relative to the node
      const portRect = portElement.getBoundingClientRect();
      const nodeRect = nodeElement.getBoundingClientRect();

      // Calculate offset in screen space, then divide by zoom to get canvas content space
      const portX =
        (portRect.left - nodeRect.left + portRect.width / 2) / canvasState.zoom;
      const portY =
        (portRect.top - nodeRect.top + portRect.height / 2) / canvasState.zoom;

      portPositionCache.set(cacheKey, {
        x: nodeLeft + portX,
        y: nodeTop + portY,
      });
    });

    // Helper function to get port position from cache or fallback to direct lookup
    const getPortPos = (
      nodeId: string,
      portType: 'input' | 'output',
      portIndex: number,
    ): Position => {
      const cacheKey = `${nodeId}-${portType}-${portIndex}`;
      return (
        portPositionCache.get(cacheKey) ||
        getPortPosition(nodeId, portType, portIndex)
      );
    };

    // Build arrowhead markers using mithril
    const arrowheadMarker = (id: string) =>
      m(
        'marker',
        {
          id,
          viewBox: `0 0 ${arrowheadLength} 10`,
          refX: '0',
          refY: '5',
          markerWidth: `${arrowheadLength}`,
          markerHeight: '10',
          orient: 'auto',
        },
        m('polygon', {
          points: `0 2.5, ${arrowheadLength} 5, 0 7.5`,
          fill: 'context-stroke',
        }),
      );

    // Build connection paths using mithril
    // Each connection is rendered as two paths: a wider invisible hitbox and the visible line
    const connectionPaths = connections
      .map((conn, idx) => {
        const from = getPortPos(conn.fromNode, 'output', conn.fromPort);
        const to = getPortPos(conn.toNode, 'input', conn.toPort);

        // Validate that both ports exist (return {x: 0, y: 0} if not found)
        const fromValid = from.x !== 0 || from.y !== 0;
        const toValid = to.x !== 0 || to.y !== 0;

        if (!fromValid || !toValid) {
          console.warn(
            `Invalid connection: ${conn.fromNode}:${conn.fromPort} -> ${conn.toNode}:${conn.toPort}`,
            !fromValid ? `(source port not found)` : `(target port not found)`,
          );
          return null;
        }

        const fromPortType = getPortType(
          conn.fromNode,
          'output',
          conn.fromPort,
          nodes,
        );
        const toPortType = getPortType(
          conn.toNode,
          'input',
          conn.toPort,
          nodes,
        );

        const pathData = createCurve(
          from.x,
          from.y,
          to.x,
          to.y,
          fromPortType,
          toPortType,
          shortenLength,
        );

        const handlePointerDown = (e: PointerEvent) => {
          e.stopPropagation();
          e.preventDefault();
        };

        const handleClick = (e: Event) => {
          e.stopPropagation();
          if (onConnectionRemove !== undefined) {
            onConnectionRemove(idx);
          }
        };

        // Return a group with both the hitbox and visible path
        return m('g', {key: `conn-${idx}`, class: 'pf-connection-group'}, [
          // Invisible wider hitbox path
          m('path', {
            d: pathData,
            class: 'pf-connection-hitbox',
            style: {
              stroke: 'transparent',
              strokeWidth: '20',
              fill: 'none',
              pointerEvents: 'stroke',
              cursor: 'pointer',
            },
            onpointerdown: handlePointerDown,
            onclick: handleClick,
          }),
          // Visible connection path
          m('path', {
            'd': pathData,
            'class': 'pf-connection',
            'marker-end': `url(#arrowhead-${instanceId})`,
            'style': {
              pointerEvents: 'none',
            },
            'onpointerdown': handlePointerDown,
            'onclick': handleClick,
          }),
        ]);
      })
      .filter((path) => path !== null);

    // Build temp connection if connecting
    let tempConnectionPath = null;
    if (canvasState.connecting) {
      const fromX = canvasState.connecting.transformedX;
      const fromY = canvasState.connecting.transformedY;
      let toX = canvasState.mousePos.transformedX ?? 0;
      let toY = canvasState.mousePos.transformedY ?? 0;

      const fromPortType = canvasState.connecting.portType;
      let toPortType: 'top' | 'left' | 'right' | 'bottom' =
        fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left';

      if (
        canvasState.hoveredPort &&
        canvasState.connecting.type === 'output' &&
        canvasState.hoveredPort.type === 'input'
      ) {
        const {nodeId, portIndex, type} = canvasState.hoveredPort;
        const hoverPos = getPortPos(nodeId, type, portIndex);
        if (hoverPos.x !== 0 || hoverPos.y !== 0) {
          toX = hoverPos.x;
          toY = hoverPos.y;
          toPortType = getPortType(nodeId, type, portIndex, nodes);
        }
      }

      tempConnectionPath = m('path', {
        'class': 'pf-temp-connection',
        'd': createCurve(
          fromX,
          fromY,
          toX,
          toY,
          fromPortType,
          toPortType,
          shortenLength,
        ),
        'marker-end': `url(#arrowhead-${instanceId})`,
      });
    }

    // Render everything using mithril's render function.
    // Use instance-unique marker ID to avoid conflicts when multiple
    // NodeGraph instances exist in the document (e.g. tabs).
    const markerId = `arrowhead-${instanceId}`;
    m.render(svg, [
      m('defs', [arrowheadMarker(markerId)]),
      m('g', connectionPaths),
      tempConnectionPath,
    ]);
  }

  function getPortPosition(
    nodeId: string,
    portType: 'input' | 'output',
    portIndex: number,
  ): Position {
    // For port index 0 (top/bottom), data-port is on .pf-port itself
    // For port index 1+ (left/right), data-port is on .pf-port-row wrapper
    const selector =
      portIndex === 0
        ? `[data-node="${nodeId}"] .pf-port[data-port="${portType}-${portIndex}"]`
        : `[data-node="${nodeId}"] [data-port="${portType}-${portIndex}"] .pf-port`;

    // Scope to this NodeGraph instance to avoid matching elements from other
    // instances (e.g. hidden tabs with the same node IDs).
    const scope = assertExists(canvasElement);
    const portElement = scope.querySelector(selector);

    if (portElement) {
      const nodeElement = portElement.closest('.pf-node') as HTMLElement | null;
      if (nodeElement !== null) {
        // Check if node is in a dock chain (flexbox positioning)
        const chainContainer = nodeElement.closest(
          '.pf-node-wrapper',
        ) as HTMLElement | null;

        let nodeLeft: number;
        let nodeTop: number;

        if (chainContainer) {
          // Node is in a dock chain - use container's position
          nodeLeft = parseFloat(chainContainer.style.left) || 0;
          nodeTop = parseFloat(chainContainer.style.top) || 0;

          // Add offset of node within the chain
          const chainRect = chainContainer.getBoundingClientRect();
          const nodeRect = nodeElement.getBoundingClientRect();
          const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;

          nodeTop += offsetY;
        } else {
          // Standalone node - use its position directly
          nodeLeft = parseFloat(nodeElement.style.left) || 0;
          nodeTop = parseFloat(nodeElement.style.top) || 0;
        }

        // Get port's position relative to the node
        const portRect = portElement.getBoundingClientRect();
        const nodeRect = nodeElement.getBoundingClientRect();

        // Calculate offset in screen space, then divide by zoom to get canvas content space
        const portX =
          (portRect.left - nodeRect.left + portRect.width / 2) /
          canvasState.zoom;
        const portY =
          (portRect.top - nodeRect.top + portRect.height / 2) /
          canvasState.zoom;

        return {
          x: nodeLeft + portX,
          y: nodeTop + portY,
        };
      }
    }

    return {x: 0, y: 0};
  }

  // Find if dragged node is in dock zone of any node
  function findDockTarget(
    draggedNode: Node,
    draggedX: number,
    draggedY: number,
    nodes: ReadonlyArray<Node>,
  ): {targetNodeId: string | null; isValidZone: boolean} {
    const DOCK_DISTANCE = 30;
    const HORIZONTAL_TOLERANCE = 100;

    // Check if dragged node can be docked at the top
    if (!draggedNode.canDockTop) {
      return {targetNodeId: null, isValidZone: false};
    }

    const draggedPos = {x: draggedX, y: draggedY};

    for (const node of nodes) {
      if (node.id === draggedNode.id) continue;

      // Find the last node in this chain
      let lastInChain: Node | Omit<Node, 'x' | 'y'> = node;
      while (lastInChain.next) {
        lastInChain = lastInChain.next;
      }

      // Check if last node in chain allows docking below it
      if (!lastInChain.canDockBottom) {
        continue; // Skip this node as a dock target
      }

      const nodePos = {x: node.x, y: node.y};
      const lastDims = getNodeDimensions(lastInChain.id);

      // Calculate position of last node in chain
      let chainHeight = 0;
      let current: Node | Omit<Node, 'x' | 'y'> = node;
      while (current !== lastInChain) {
        chainHeight += getNodeDimensions(current.id).height;
        current = current.next!;
      }

      const nodeBottom = nodePos.y + chainHeight + lastDims.height;

      const verticalDist = draggedPos.y - nodeBottom;
      const isBelow = verticalDist >= -10 && verticalDist <= DOCK_DISTANCE;

      const draggedDims = getNodeDimensions(draggedNode.id);
      const nodeDims = getNodeDimensions(node.id);
      const horizontalDist = Math.abs(
        nodePos.x + nodeDims.width / 2 - (draggedPos.x + draggedDims.width / 2),
      );
      const isAligned = horizontalDist <= HORIZONTAL_TOLERANCE;

      if (isBelow && isAligned) {
        // Return the ID of the LAST node in the chain
        return {targetNodeId: lastInChain.id, isValidZone: true};
      }
    }

    return {targetNodeId: null, isValidZone: false};
  }

  function getNodeDimensions(nodeId: string): {width: number; height: number} {
    const scope = assertExists(canvasElement);
    const nodeElement = scope.querySelector(`[data-node="${nodeId}"]`);
    if (nodeElement) {
      const rect = nodeElement.getBoundingClientRect();
      // Divide by zoom to get canvas content space dimensions
      return {
        width: rect.width / canvasState.zoom,
        height: rect.height / canvasState.zoom,
      };
    }
    // Fallback if DOM element not found
    return {width: 180, height: 100};
  }

  function checkNodeOverlap(
    x: number,
    y: number,
    nodeId: string,
    nodes: ReadonlyArray<Node>,
    nodeWidth: number,
    nodeHeight: number,
  ): boolean {
    const padding = 10;

    for (const node of nodes) {
      if (node.id === nodeId) continue; // Don't check against self

      // Get dimensions of the node we're checking against
      const otherDims = getNodeDimensions(node.id);

      // Calculate total height of the other node's chain
      const chain = getChain(node);
      let otherChainHeight = 0;
      chain.forEach((chainNode) => {
        otherChainHeight += getNodeDimensions(chainNode.id).height;
      });

      const overlaps = !(
        x + nodeWidth + padding < node.x ||
        x > node.x + otherDims.width + padding ||
        y + nodeHeight + padding < node.y ||
        y > node.y + otherChainHeight + padding
      );

      if (overlaps) return true;
    }
    return false;
  }

  function findNearestNonOverlappingPosition(
    startX: number,
    startY: number,
    nodeId: string,
    nodes: ReadonlyArray<Node>,
    nodeWidth: number,
    nodeHeight: number,
  ): Position {
    // If no overlap at current position, return it
    if (
      !checkNodeOverlap(startX, startY, nodeId, nodes, nodeWidth, nodeHeight)
    ) {
      return {x: startX, y: startY};
    }

    // Search in a spiral pattern for a non-overlapping position
    const step = 20; // Step size for searching
    const maxRadius = 500; // Maximum search radius

    for (let radius = step; radius <= maxRadius; radius += step) {
      // Try positions in a circle around the original position
      const numSteps = Math.ceil((2 * Math.PI * radius) / step);

      for (let i = 0; i < numSteps; i++) {
        const angle = (2 * Math.PI * i) / numSteps;
        const x = Math.round(startX + radius * Math.cos(angle));
        const y = Math.round(startY + radius * Math.sin(angle));

        if (!checkNodeOverlap(x, y, nodeId, nodes, nodeWidth, nodeHeight)) {
          return {x, y};
        }
      }
    }

    // Fallback: return original position if no free space found
    return {x: startX, y: startY};
  }

  function getNodesBoundingBox(
    nodes: ReadonlyArray<Node>,
    includeChains: boolean,
  ): {minX: number; minY: number; maxX: number; maxY: number} {
    if (nodes.length === 0) {
      return {minX: 0, minY: 0, maxX: 0, maxY: 0};
    }

    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;

    nodes.forEach((node) => {
      const dims = getNodeDimensions(node.id);
      minX = Math.min(minX, node.x);
      minY = Math.min(minY, node.y);
      maxX = Math.max(maxX, node.x + dims.width);

      if (includeChains) {
        const chain = getChain(node);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          const chainDims = getNodeDimensions(chainNode.id);
          chainHeight += chainDims.height;
        });
        maxY = Math.max(maxY, node.y + chainHeight);
      } else {
        maxY = Math.max(maxY, node.y + dims.height);
      }
    });

    return {minX, minY, maxX, maxY};
  }

  // Helper to perform auto-layout
  function autoLayoutGraph(
    nodes: ReadonlyArray<Node>,
    connections: ReadonlyArray<Connection>,
    onNodeMove: ((nodeId: string, x: number, y: number) => void) | undefined,
  ) {
    // Build a map from any node ID (including nodes in chains) to its root node ID
    const nodeIdToRootId = new Map<string, string>();
    nodes.forEach((node) => {
      nodeIdToRootId.set(node.id, node.id);
      const chain = getChain(node);
      chain.slice(1).forEach((chainNode) => {
        nodeIdToRootId.set(chainNode.id, node.id);
      });
    });

    // Find root nodes (nodes with no incoming connections)
    // Count connections to any node in a chain as connections to the root
    const incomingCounts = new Map<string, number>();
    nodes.forEach((node) => incomingCounts.set(node.id, 0));
    connections.forEach((conn) => {
      const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
      const currentCount = incomingCounts.get(rootId) ?? 0;
      incomingCounts.set(rootId, currentCount + 1);
    });

    const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0);
    const visited = new Set<string>();
    const layers: string[][] = [];

    // BFS to assign nodes to layers
    const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({
      id: n.id,
      layer: 0,
    }));

    while (queue.length > 0) {
      const {id, layer} = queue.shift()!;
      if (visited.has(id)) continue;
      visited.add(id);

      if (layers[layer] === undefined) layers[layer] = [];
      layers[layer].push(id);

      // Add connected nodes to next layer
      // If connection goes to a node in a chain, add the root node
      connections
        .filter((conn) => {
          // Check if this node or any node in its chain is the source
          const node = nodes.find((n) => n.id === id);
          if (!node) return false;
          const chain = getChain(node);
          return chain.some((chainNode) => chainNode.id === conn.fromNode);
        })
        .forEach((conn) => {
          const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
          if (!visited.has(rootId)) {
            queue.push({id: rootId, layer: layer + 1});
          }
        });
    }

    // Position nodes using actual DOM dimensions
    const layerSpacing = 50; // Horizontal spacing between layers
    let currentX = 50; // Start position

    layers.forEach((layer) => {
      // Find the widest node in this layer (considering entire chains)
      let maxWidth = 0;
      layer.forEach((nodeId) => {
        const node = nodes.find((n) => n.id === nodeId);
        if (node) {
          // Check width of all nodes in the chain
          const chain = getChain(node);
          chain.forEach((chainNode) => {
            const chainDims = getNodeDimensions(chainNode.id);
            maxWidth = Math.max(maxWidth, chainDims.width);
          });
        }
      });

      // Position each node in this layer
      let currentY = 50;
      layer.forEach((nodeId) => {
        const node = nodes.find((n) => n.id === nodeId);
        if (node && onNodeMove) {
          onNodeMove(node.id, currentX, currentY);

          // Calculate height of entire chain
          const chain = getChain(node);
          let chainHeight = 0;
          chain.forEach((chainNode) => {
            const dims = getNodeDimensions(chainNode.id);
            chainHeight += dims.height;
          });

          currentY += chainHeight + 30;
        }
      });

      // Move to next layer
      currentX += maxWidth + layerSpacing;
    });

    m.redraw();
  }

  function autofit(
    nodes: ReadonlyArray<Node>,
    labels: ReadonlyArray<Label>,
    canvas: HTMLElement,
  ) {
    if (nodes.length === 0 && labels.length === 0) return;

    // Initialize bounding box
    // If we have nodes, start with their bounding box
    // If we only have labels, initialize with Infinity values that will be replaced
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;

    if (nodes.length > 0) {
      const nodesBBox = getNodesBoundingBox(nodes, true);
      minX = nodesBBox.minX;
      minY = nodesBBox.minY;
      maxX = nodesBBox.maxX;
      maxY = nodesBBox.maxY;
    }

    // Include labels in bounding box calculation
    labels.forEach((label) => {
      minX = Math.min(minX, label.x);
      minY = Math.min(minY, label.y);
      maxX = Math.max(maxX, label.x + label.width);
      maxY = Math.max(maxY, label.y + TYPICAL_LABEL_HEIGHT);
    });

    // Calculate bounding box dimensions
    const boundingWidth = maxX - minX;
    const boundingHeight = maxY - minY;

    // Get canvas dimensions
    const canvasRect = canvas.getBoundingClientRect();

    // Calculate zoom to fit with buffer (10% padding)
    const bufferFactor = 0.9; // Use 90% of viewport to leave 10% buffer
    const zoomX = (canvasRect.width * bufferFactor) / boundingWidth;
    const zoomY = (canvasRect.height * bufferFactor) / boundingHeight;
    const newZoom = Math.max(0.1, Math.min(1.0, Math.min(zoomX, zoomY)));

    // Calculate the scaled bounding box dimensions
    const scaledWidth = boundingWidth * newZoom;
    const scaledHeight = boundingHeight * newZoom;

    // Calculate pan offset to center the bounding box with equal padding on all sides
    const paddingX = (canvasRect.width - scaledWidth) / 2;
    const paddingY = (canvasRect.height - scaledHeight) / 2;

    canvasState.zoom = newZoom;
    canvasState.panOffset = {
      x: paddingX - minX * newZoom,
      y: paddingY - minY * newZoom,
    };

    m.redraw();
  }

  const handleWheel = (e: WheelEvent) => {
    if (!canvasElement) return;
    e.preventDefault();

    // Zoom with Ctrl+wheel, pan without Ctrl
    if (e.ctrlKey || e.metaKey) {
      // Zoom around mouse position
      const canvas = canvasElement;
      const canvasRect = canvas.getBoundingClientRect();
      const mouseX = e.clientX - canvasRect.left;
      const mouseY = e.clientY - canvasRect.top;

      // Calculate zoom delta (negative deltaY = zoom in)
      const zoomDelta = -e.deltaY * 0.003;
      const newZoom = Math.max(
        0.1,
        Math.min(5.0, canvasState.zoom * (1 + zoomDelta)),
      );

      // Calculate the point in canvas space (before zoom)
      const canvasX = (mouseX - canvasState.panOffset.x) / canvasState.zoom;
      const canvasY = (mouseY - canvasState.panOffset.y) / canvasState.zoom;

      // Update zoom
      canvasState.zoom = newZoom;

      // Adjust pan to keep the same point under the mouse
      canvasState.panOffset = {
        x: mouseX - canvasX * newZoom,
        y: mouseY - canvasY * newZoom,
      };
    } else {
      // Pan the canvas based on wheel delta
      panBy(-e.deltaX, -e.deltaY);
    }

    m.redraw();
  };

  // Helper function to render a single node
  function renderNode(
    node: Node | Omit<Node, 'x' | 'y'>,
    vnode: m.Vnode<NodeGraphAttrs>,
    options: {
      isDockedChild: boolean;
      hasDockedChild: boolean;
      isDockTarget: boolean;
      rootNode?: Node;
      multiselect: boolean;
      contextMenuOnHover: boolean;
    },
  ): m.Vnode {
    const {
      id,
      inputs = [],
      outputs = [],
      titleBar,
      content,
      hue,
      accentBar,
      contextMenuItems,
      invalid,
      className: nodeClassName,
    } = node;
    const {
      isDockedChild,
      hasDockedChild,
      isDockTarget,
      rootNode,
      multiselect,
      contextMenuOnHover,
    } = options;
    const {connections = [], onConnect, nodes = []} = vnode.attrs;

    // Separate ports by direction
    const topInputs = inputs.filter((p) => p.direction === 'top');
    const leftInputs = inputs.filter((p) => p.direction === 'left');
    const bottomOutputs = outputs.filter((p) => p.direction === 'bottom');
    const rightOutputs = outputs.filter((p) => p.direction === 'right');

    const classes = classNames(
      canvasState.selectedNodes.has(id) && 'pf-selected',
      isDockedChild && 'pf-docked-child',
      hasDockedChild && 'pf-has-docked-child',
      isDockTarget && 'pf-dock-target',
      accentBar && 'pf-node--has-accent-bar',
      invalid && 'pf-invalid',
      nodeClassName,
    );

    // Helper to render a port
    const renderPort = (
      port: NodePort,
      portIndex: number,
      portType: 'input' | 'output',
      forceConnected?: boolean,
    ) => {
      const portId = `${portType}-${portIndex}`;
      const cssClass = classNames(
        portType === 'input' ? 'pf-input' : 'pf-output',
        `pf-port-${port.direction}`,
        (forceConnected ||
          isPortConnected(id, portType, portIndex, connections)) &&
          'pf-connected',
        canvasState.connecting &&
          canvasState.connecting.nodeId === id &&
          canvasState.connecting.portIndex === portIndex &&
          canvasState.connecting.type === portType &&
          'pf-active',
        port.contextMenuItems !== undefined && 'pf-port--with-context-menu',
      );

      const portElement = m('.pf-port', {
        'data-port': portId,
        'className': cssClass,
        'onpointerdown': (e: PointerEvent) => {
          e.stopPropagation();
          if (portType === 'input') {
            // Input port - check for existing connection
            const existingConnIdx = connections.findIndex(
              (conn) => conn.toNode === id && conn.toPort === portIndex,
            );
            if (existingConnIdx !== -1) {
              const existingConn = connections[existingConnIdx];
              const {onConnectionRemove} = vnode.attrs;
              if (onConnectionRemove !== undefined) {
                onConnectionRemove(existingConnIdx);
              }
              const outputPos = getPortPosition(
                existingConn.fromNode,
                'output',
                existingConn.fromPort,
              );
              canvasState.connecting = {
                nodeId: existingConn.fromNode,
                portIndex: existingConn.fromPort,
                type: 'output',
                portType: getPortType(
                  existingConn.fromNode,
                  'output',
                  existingConn.fromPort,
                  nodes,
                ),
                x: 0,
                y: 0,
                transformedX: outputPos.x,
                transformedY: outputPos.y,
              };
              m.redraw();
            }
          } else {
            // Output port - start connection
            const portPos = getPortPosition(id, portType, portIndex);
            canvasState.connecting = {
              nodeId: id,
              portIndex,
              type: portType,
              portType: port.direction,
              x: 0,
              y: 0,
              transformedX: portPos.x,
              transformedY: portPos.y,
            };
          }
        },
        'onpointerup': (e: PointerEvent) => {
          e.stopPropagation();
          if (portType === 'input') {
            if (
              canvasState.connecting &&
              canvasState.connecting.type === 'output'
            ) {
              // Input port receiving connection
              const existingConnIdx = connections.findIndex(
                (conn) => conn.toNode === id && conn.toPort === portIndex,
              );
              if (existingConnIdx !== -1) {
                const {onConnectionRemove} = vnode.attrs;
                if (onConnectionRemove !== undefined) {
                  onConnectionRemove(existingConnIdx);
                }
              }
              const connection = {
                fromNode: canvasState.connecting.nodeId,
                fromPort: canvasState.connecting.portIndex,
                toNode: id,
                toPort: portIndex,
              };
              if (onConnect !== undefined) {
                onConnect(connection);
              }
              canvasState.connecting = null;
            }
          } else if (portType === 'output') {
            // Clear connecting state if releasing on output port without completing connection
            canvasState.connecting = null;
          }
        },
      });

      // Wrap with PopupMenu if contextMenuItems exist
      if (port.contextMenuItems !== undefined) {
        return m(PopupMenu, {trigger: portElement}, port.contextMenuItems);
      }
      return portElement;
    };

    const style = hue !== undefined ? {'--pf-node-hue': `${hue}`} : undefined;

    return m(
      '.pf-node',
      {
        'key': id,
        'data-node': id,
        'class': classes,
        'style': {
          ...style,
        },
        'onpointerdown': (e: PointerEvent) => {
          if ((e.target as HTMLElement).closest('.pf-port')) {
            return;
          }
          e.stopPropagation();

          // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled)
          if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) {
            // Toggle selection
            if (canvasState.selectedNodes.has(id)) {
              const {onNodeRemoveFromSelection} = vnode.attrs;
              if (onNodeRemoveFromSelection !== undefined) {
                onNodeRemoveFromSelection(id);
              }
            } else {
              const {onNodeAddToSelection} = vnode.attrs;
              if (onNodeAddToSelection !== undefined) {
                onNodeAddToSelection(id);
              }
            }

            // Focus the canvas element to ensure keyboard events (like Delete) are captured
            if (canvasElement) {
              canvasElement.focus();
            }

            return;
          }

          const {onNodeSelect, selectedNodeIds} = vnode.attrs;

          // When multiple nodes are selected, disable all dragging.
          // Only allow selection change and focus.
          if (selectedNodeIds !== undefined && selectedNodeIds.size > 1) {
            // If clicking an unselected node, select just that node
            if (!selectedNodeIds.has(id) && onNodeSelect !== undefined) {
              onNodeSelect(id);
            }
            // Focus the canvas for keyboard events (Delete, etc.)
            if (canvasElement) {
              canvasElement.focus();
            }
            return;
          }

          // Check if this is a chained node (not root)
          if (isDockedChild && rootNode) {
            // Don't undock immediately - wait for drag threshold
            // Calculate current render position
            let yOffset = rootNode.y;
            const chainArr = getChain(rootNode);
            for (const cn of chainArr) {
              if (cn.id === id) break;
              yOffset += getNodeDimensions(cn.id).height;
            }

            // Find parent node in chain
            let parentId = rootNode.id;
            let curr = rootNode.next;
            while (curr && curr.id !== id) {
              parentId = curr.id;
              curr = curr.next;
            }

            // Store undock candidate - will undock if dragged beyond threshold
            canvasState.undockCandidate = {
              nodeId: id,
              parentId: parentId,
              startX: e.clientX,
              startY: e.clientY,
              renderY: yOffset,
            };
          }

          canvasState.draggedNode = id;

          // Store initial drag position for batching
          // Check if node has x,y properties (root nodes) vs docked children (no x,y)
          if ('x' in node && 'y' in node) {
            dragStartPosition = {nodeId: id, x: node.x, y: node.y};
            currentDragPosition = {x: node.x, y: node.y};
          }

          // Only change selection if the clicked node is not already selected
          // This prevents unnecessary selection changes when starting to drag
          if (!selectedNodeIds?.has(id) && onNodeSelect !== undefined) {
            onNodeSelect(id);
          }

          // Focus the canvas element to ensure keyboard events (like Delete) are captured
          if (canvasElement) {
            canvasElement.focus();
          }

          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
          canvasState.dragOffset = {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top,
          };
        },
      },
      [
        // Render node title if it exists
        titleBar !== undefined &&
          m('.pf-node-header', [
            titleBar.icon !== undefined &&
              m(Icon, {icon: titleBar.icon, className: 'pf-node-title-icon'}),
            m('.pf-node-title', titleBar.title),
            contextMenuItems !== undefined &&
              m(
                PopupMenu,
                {
                  trigger: m(Button, {
                    rounded: true,
                    icon: Icons.ContextMenuAlt,
                    className: contextMenuOnHover ? 'pf-show-on-hover' : '',
                  }),
                },
                contextMenuItems,
              ),
          ]),

        // Context menu button for nodes without titlebar
        titleBar === undefined &&
          contextMenuItems !== undefined &&
          m(
            '.pf-node-context-menu',
            {className: contextMenuOnHover ? 'pf-show-on-hover' : ''},
            m(
              PopupMenu,
              {
                trigger: m(Button, {
                  rounded: true,
                  icon: Icons.ContextMenuAlt,
                }),
              },
              contextMenuItems,
            ),
          ),

        // Top input ports (if not docked child)
        topInputs.map((port) => {
          const portIndex = inputs.indexOf(port);
          return renderPort(port, portIndex, 'input');
        }),

        m('.pf-node-body', [
          content !== undefined &&
            m(
              '.pf-node-content',
              {
                onkeydown: (e: KeyboardEvent) => {
                  e.stopPropagation();
                },
              },
              content,
            ),

          // Left input ports
          leftInputs.map((port) => {
            const portIndex = inputs.indexOf(port);
            return m(
              '.pf-port-row.pf-port-input',
              {
                'data-port': `input-${portIndex}`,
              },
              [renderPort(port, portIndex, 'input'), port.content],
            );
          }),

          // Right output ports
          rightOutputs.map((port) => {
            const portIndex = outputs.indexOf(port);
            return m(
              '.pf-port-row.pf-port-output',
              {
                'data-port': `output-${portIndex}`,
              },
              [port.content, renderPort(port, portIndex, 'output')],
            );
          }),
        ]),

        // Bottom output ports (if no docked child below)
        bottomOutputs.map((port) => {
          const portIndex = outputs.indexOf(port);
          return renderPort(port, portIndex, 'output');
        }),
      ],
    );
  }

  function renderLabel(label: Label, vnode: m.Vnode<NodeGraphAttrs>): m.Vnode {
    const {id, x, y, width, content, selectable = false} = label;
    const isDragging = canvasState.draggedLabel === id;
    const isSelected = canvasState.selectedNodes.has(id);

    // Use temporary position/width during drag if available
    const tempPos = canvasState.tempLabelPositions.get(id);
    const tempWidth = canvasState.tempLabelWidths.get(id);
    const renderX = tempPos?.x ?? x;
    const renderY = tempPos?.y ?? y;
    const renderWidth = tempWidth ?? width;

    return m(
      '.pf-label',
      {
        'key': `label-${id}`,
        'data-label': id,
        'className': classNames(
          isDragging && 'pf-dragging',
          isSelected && 'pf-selected',
        ),
        'style': {
          left: `${renderX}px`,
          top: `${renderY}px`,
          width: `${renderWidth}px`,
        },
        'onpointerdown': (e: PointerEvent) => {
          const target = e.target as HTMLElement;

          // Check if clicking on the resize handle or delete button
          if (
            target.closest('.pf-label-resize-handle') ||
            target.closest('.pf-label-delete-button')
          ) {
            e.stopPropagation();
            return;
          }

          // Check if clicking on a textarea that is being edited (not readonly)
          // Allow normal text selection behavior in edit mode
          if (target instanceof HTMLTextAreaElement && !target.readOnly) {
            // Don't start dragging, allow text selection
            return;
          }

          const {multiselect = true} = vnode.attrs;

          // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled)
          if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) {
            // Toggle selection
            if (isSelected) {
              const {onNodeRemoveFromSelection} = vnode.attrs;
              if (onNodeRemoveFromSelection !== undefined) {
                onNodeRemoveFromSelection(id);
              }
            } else {
              const {onNodeAddToSelection} = vnode.attrs;
              if (onNodeAddToSelection !== undefined) {
                onNodeAddToSelection(id);
              }
            }

            // Focus the canvas element to ensure keyboard events (like Delete) are captured
            if (canvasElement) {
              canvasElement.focus();
            }

            e.stopPropagation();
            return;
          }

          // Start dragging the label
          canvasState.draggedLabel = id;
          canvasState.dragOffset = {
            x: (canvasState.mousePos.transformedX ?? 0) - x,
            y: (canvasState.mousePos.transformedY ?? 0) - y,
          };

          // Select the label if selectable (replace current selection)
          if (selectable) {
            const {onNodeSelect} = vnode.attrs;
            if (onNodeSelect !== undefined) {
              onNodeSelect(id);
            }
          }

          // Focus the canvas element to ensure keyboard events (like Delete) are captured
          if (canvasElement) {
            canvasElement.focus();
          }

          e.stopPropagation();
        },
      },
      [
        // Render the content (or placeholder if not provided)
        m(
          '.pf-label-content',
          content ?? m('.pf-label-placeholder', 'Empty label'),
        ),
        // Resize handle (always rendered)
        m('.pf-label-resize-handle', {
          onpointerdown: (e: PointerEvent) => {
            // Start resizing
            canvasState.resizingLabel = id;
            canvasState.resizeStartWidth = width;
            canvasState.resizeStartX = canvasState.mousePos.transformedX ?? 0;
            e.stopPropagation();
          },
        }),
        // Delete button (always rendered, visible on hover/selection)
        m(
          '.pf-label-delete-button',
          {
            onclick: (e: PointerEvent) => {
              const {onLabelRemove} = vnode.attrs;
              if (onLabelRemove !== undefined) {
                onLabelRemove(id);
              }
              e.stopPropagation();
            },
          },
          m(Icon, {icon: 'close'}),
        ),
      ],
    );
  }

  return {
    oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      latestVnode = vnode;
      canvasElement = vnode.dom as HTMLElement;
      document.addEventListener('pointermove', handleMouseMove);
      document.addEventListener('pointerup', handleMouseUp);
      canvasElement.addEventListener('wheel', handleWheel, {passive: false});

      const {
        connections = [],
        nodes = [],
        onConnectionRemove,
        onReady,
      } = vnode.attrs;

      // Render connections after DOM is ready
      const svg = vnode.dom.querySelector('svg');
      if (svg) {
        renderConnections(
          svg as SVGElement,
          connections,
          nodes,
          onConnectionRemove,
        );
      }

      // Create auto-layout function that uses actual DOM dimensions
      autoLayoutApi = () => {
        const {nodes = [], connections = [], onNodeMove} = vnode.attrs;
        autoLayoutGraph(nodes, connections, onNodeMove);
      };

      // Create recenter function that brings all nodes into view
      recenterApi = () => {
        if (latestVnode === null || canvasElement === null) {
          return;
        }
        const {nodes = [], labels = []} = latestVnode.attrs;
        const canvas = canvasElement;
        autofit(nodes, labels, canvas);
      };

      // Find a non-overlapping position for a new node
      findPlacementForNodeApi = (newNode: Omit<Node, 'x' | 'y'>): Position => {
        if (latestVnode === null || canvasElement === null) {
          return {x: 0, y: 0};
        }

        const {nodes = []} = latestVnode.attrs;
        const canvas = canvasElement;

        // Default starting position (center of viewport in canvas space)
        const canvasRect = canvas.getBoundingClientRect();
        const centerX =
          (canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom;
        const centerY =
          (canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom;

        // Create a temporary node with coordinates to render and measure
        const tempNode: Node = {
          ...newNode,
          x: centerX,
          y: centerY,
        };

        // Create temporary DOM element to measure size
        const tempContainer = document.createElement('div');
        tempContainer.style.position = 'absolute';
        tempContainer.style.left = '-9999px';
        tempContainer.style.visibility = 'hidden';
        canvas.appendChild(tempContainer);

        // Render the node into the temporary container with animation disabled
        m.render(
          tempContainer,
          m(
            '.pf-node-wrapper',
            m(
              '.pf-node',
              {
                'data-node': tempNode.id,
                'style': {
                  ...(tempNode.hue !== undefined
                    ? {'--pf-node-hue': `${tempNode.hue}`}
                    : {}),
                },
              },
              [
                tempNode.titleBar &&
                  m('.pf-node-header', [
                    m('.pf-node-title', tempNode.titleBar.title),
                  ]),
                m('.pf-node-body', [
                  tempNode.content !== undefined &&
                    m('.pf-node-content', tempNode.content),
                  tempNode.inputs
                    ?.filter((p) => p.direction === 'left')
                    .map((port) =>
                      m('.pf-port-row.pf-port-input', [
                        m('.pf-port'),
                        port.content,
                      ]),
                    ),
                  tempNode.outputs
                    ?.filter((p) => p.direction === 'right')
                    .map((port) =>
                      m('.pf-port-row.pf-port-output', [
                        port.content,
                        m('.pf-port'),
                      ]),
                    ),
                ]),
              ],
            ),
          ),
        );

        // Get dimensions from the rendered element
        const dims = getNodeDimensions(tempNode.id);

        // Calculate chain height
        const chain = getChain(tempNode);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          const chainDims = getNodeDimensions(chainNode.id);
          chainHeight += chainDims.height;
        });

        // Clean up temporary element
        canvas.removeChild(tempContainer);

        // Find non-overlapping position starting from center
        const finalPos = findNearestNonOverlappingPosition(
          centerX - dims.width / 2,
          centerY - dims.height / 2,
          tempNode.id,
          nodes,
          dims.width,
          chainHeight,
        );

        return finalPos;
      };

      // Reset zoom to 100% (1.0x) around canvas center
      resetZoom = () => {
        if (!canvasElement) return;

        const canvas = canvasElement;
        const canvasRect = canvas.getBoundingClientRect();
        const centerX = canvasRect.width / 2;
        const centerY = canvasRect.height / 2;

        // Calculate the point in canvas space (before zoom)
        const canvasX = (centerX - canvasState.panOffset.x) / canvasState.zoom;
        const canvasY = (centerY - canvasState.panOffset.y) / canvasState.zoom;

        // Reset zoom to 1.0
        canvasState.zoom = 1.0;

        // Adjust pan to keep the same point under the center
        canvasState.panOffset = {
          x: centerX - canvasX,
          y: centerY - canvasY,
        };

        m.redraw();
      };

      // Provide API to parent
      if (
        onReady !== undefined &&
        autoLayoutApi !== null &&
        recenterApi !== null &&
        findPlacementForNodeApi !== null
      ) {
        onReady({
          autoLayout: autoLayoutApi,
          recenter: recenterApi,
          findPlacementForNode: findPlacementForNodeApi,
          panBy,
          zoomBy,
          resetZoom,
        });
      }
    },

    onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      latestVnode = vnode;
      const {
        connections = [],
        nodes = [],
        onConnectionRemove,
        onReady,
      } = vnode.attrs;

      // Re-render connections when component updates
      const svg = vnode.dom.querySelector('svg');
      if (svg) {
        renderConnections(
          svg as SVGElement,
          connections,
          nodes,
          onConnectionRemove,
        );
      }

      // Call onReady after every render cycle so parent can perform
      // post-render actions like recentering
      if (
        onReady !== undefined &&
        autoLayoutApi !== null &&
        recenterApi !== null &&
        findPlacementForNodeApi !== null &&
        resetZoom !== null
      ) {
        onReady({
          autoLayout: autoLayoutApi,
          recenter: recenterApi,
          findPlacementForNode: findPlacementForNodeApi,
          panBy,
          zoomBy,
          resetZoom,
        });
      }
    },

    onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      document.removeEventListener('pointermove', handleMouseMove);
      document.removeEventListener('pointerup', handleMouseUp);
      (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
    },

    view: (vnode: m.Vnode<NodeGraphAttrs>) => {
      latestVnode = vnode;
      const {
        nodes,
        selectedNodeIds = new Set<string>(),
        multiselect = true,
        contextMenuOnHover = false,
        fillHeight,
      } = vnode.attrs;

      // Sync internal state with prop
      canvasState.selectedNodes = selectedNodeIds;

      const className = classNames(
        fillHeight && 'pf-canvas--fill-height',
        canvasState.connecting && 'pf-connecting',
        canvasState.connecting &&
          `connecting-from-${canvasState.connecting.type}`,
        canvasState.isPanning && 'pf-panning',
      );

      return m(
        '.pf-canvas',
        {
          className,
          tabindex: 0, // Make div focusable to capture keyboard events
          oncontextmenu: (e: Event) => {
            e.preventDefault(); // Disable default context menu
          },
          onpointerdown: (e: PointerEvent) => {
            const target = e.target as HTMLElement;
            if (
              target.classList.contains('pf-canvas') ||
              target.tagName === 'svg'
            ) {
              // Start box selection with Shift (only if multiselect is enabled)
              if (multiselect && e.shiftKey) {
                const transformedX = canvasState.mousePos.transformedX ?? 0;
                const transformedY = canvasState.mousePos.transformedY ?? 0;
                canvasState.selectionRect = {
                  startX: transformedX,
                  startY: transformedY,
                  currentX: transformedX,
                  currentY: transformedY,
                };
                return;
              }

              // Start panning and store position to detect click vs drag
              canvasState.isPanning = true;
              canvasState.panStart = {x: e.clientX, y: e.clientY};
              canvasState.canvasMouseDownPos = {x: e.clientX, y: e.clientY};
            }
          },
          onclick: (e: PointerEvent) => {
            const target = e.target as HTMLElement;
            // Clear selection on canvas click (only if mouse didn't move significantly)
            if (
              target.classList.contains('pf-canvas') ||
              target.tagName === 'svg'
            ) {
              const dx = Math.abs(e.clientX - canvasState.canvasMouseDownPos.x);
              const dy = Math.abs(e.clientY - canvasState.canvasMouseDownPos.y);
              const threshold = 3; // Pixels of movement tolerance

              // Only clear if it was a click (not a drag)
              if (dx <= threshold && dy <= threshold) {
                const {onSelectionClear} = vnode.attrs;
                if (onSelectionClear !== undefined) {
                  onSelectionClear();
                }
              }
            }
          },
          onkeydown: (e: KeyboardEvent) => {
            if (e.key === 'Escape') {
              // Deselect all nodes and labels
              const hasSelection = canvasState.selectedNodes.size > 0;
              if (hasSelection) {
                const {onSelectionClear} = vnode.attrs;
                if (onSelectionClear !== undefined) {
                  onSelectionClear();
                }
              }
            } else if (e.key === 'Delete' || e.key === 'Backspace') {
              const {onNodeRemove, onLabelRemove, labels = []} = vnode.attrs;

              if (canvasState.selectedNodes.size > 0) {
                // Flatten all nodes including docked nodes (via 'next' property)
                const allNodeIds = new Set<string>();
                const queue: Array<Node | DockedNode> = [...nodes];
                while (queue.length > 0) {
                  const node = queue.shift();
                  if (node) {
                    allNodeIds.add(node.id);
                    // Traverse docked children via 'next' property
                    if (node.next) {
                      queue.push(node.next);
                    }
                  }
                }

                const labelIds = new Set(labels.map((l) => l.id));

                // Delete selected nodes and labels
                canvasState.selectedNodes.forEach((id) => {
                  if (allNodeIds.has(id) && onNodeRemove !== undefined) {
                    onNodeRemove(id);
                  } else if (labelIds.has(id) && onLabelRemove !== undefined) {
                    onLabelRemove(id);
                  }
                });
              }
            }
          },
          style: {
            backgroundSize: (() => {
              const minPixelSpacing = 10;
              let gridSize = 20;
              while (gridSize * canvasState.zoom < minPixelSpacing) {
                gridSize *= 2;
              }
              const size = gridSize * canvasState.zoom;
              return `${size}px ${size}px`;
            })(),
            backgroundPosition: (() => {
              const minPixelSpacing = 10;
              let gridSize = 20;
              while (gridSize * canvasState.zoom < minPixelSpacing) {
                gridSize *= 2;
              }
              const size = gridSize * canvasState.zoom;
              // Subtract size/2 so the dot (centered in its tile) lands exactly
              // on the world origin — stays fixed when gridSize doubles.
              const x = canvasState.panOffset.x - size / 2;
              const y = canvasState.panOffset.y - size / 2;
              return `${x}px ${y}px`;
            })(),
            ...vnode.attrs.style,
          },
        },
        [
          (vnode.attrs.toolbarItems !== undefined ||
            !vnode.attrs.hideControls) &&
            m('.pf-nodegraph-controls', [
              vnode.attrs.toolbarItems,
              !vnode.attrs.hideControls &&
                m(Button, {
                  title: 'Auto layout',
                  icon: 'account_tree',
                  variant: ButtonVariant.Filled,
                  onclick: () => autoLayoutApi?.(),
                }),
              m(
                ButtonGroup,
                m(Button, {
                  title: 'Fit to screen',
                  icon: 'center_focus_strong',
                  variant: ButtonVariant.Filled,
                  onclick: () => recenterApi?.(),
                }),
                m(Button, {
                  title: 'Reset zoom to 100%',
                  icon: 'view_real_size',
                  variant: ButtonVariant.Filled,
                  onclick: () => resetZoom?.(),
                }),
                m(Button, {
                  title: 'Zoom in',
                  icon: 'zoom_in',
                  variant: ButtonVariant.Filled,
                  onclick: () => zoomBy(0.2),
                }),
                m(Button, {
                  title: 'Zoom out',
                  icon: 'zoom_out',
                  variant: ButtonVariant.Filled,
                  onclick: () => zoomBy(-0.2),
                }),
              ),
            ]),

          // Container for nodes and SVG that gets transformed
          m(
            '.pf-canvas-content',
            {
              style: `transform: translate(${canvasState.panOffset.x}px, ${canvasState.panOffset.y}px) scale(${canvasState.zoom}); transform-origin: 0 0;`,
            },
            [
              // SVG container for connections (rendered imperatively in oncreate/onupdate)
              m('svg'),

              // Selection rectangle overlay
              canvasState.selectionRect &&
                m('.pf-selection-rect', {
                  style: {
                    left: `${Math.min(canvasState.selectionRect.startX, canvasState.selectionRect.currentX)}px`,
                    top: `${Math.min(canvasState.selectionRect.startY, canvasState.selectionRect.currentY)}px`,
                    width: `${Math.abs(canvasState.selectionRect.currentX - canvasState.selectionRect.startX)}px`,
                    height: `${Math.abs(canvasState.selectionRect.currentY - canvasState.selectionRect.startY)}px`,
                  },
                }),

              // Render all nodes - wrap dock chains in flex container
              nodes
                .map((node: Node) => {
                  const {id} = node;

                  // Check if this is the root of a dock chain
                  const chain = getChain(node);
                  const isChainRoot = chain.length > 1;

                  // Check if we have a temp position for this node (during drag)
                  const tempPos = canvasState.tempNodePositions.get(id);
                  const renderPos = tempPos || {x: node.x, y: node.y};

                  // If this is a chain root, wrap all chain nodes in flex container
                  // Always wrap in a chain root container for consistency

                  if (isChainRoot) {
                    return m(
                      '.pf-node-wrapper',
                      {
                        key: `chain-${id}`,
                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
                        className: classNames(
                          canvasState.draggedNode === id &&
                            'pf-node-wrapper--dragging',
                        ),
                      },
                      chain.map((chainNode) => {
                        const cIsDockedChild = 'x' in chainNode === false;
                        const cHasDockedChild = chainNode.next !== undefined;
                        const cIsDockTarget =
                          canvasState.dockTarget === chainNode.id &&
                          canvasState.isDockZone;

                        return renderNode(chainNode, vnode, {
                          isDockedChild: cIsDockedChild,
                          hasDockedChild: cHasDockedChild,
                          isDockTarget: cIsDockTarget,
                          rootNode: node,
                          multiselect,
                          contextMenuOnHover,
                        });
                      }),
                    );
                  } else {
                    // Render standalone node (not part of a chain)
                    const isDockTarget =
                      canvasState.dockTarget === id && canvasState.isDockZone;

                    return m(
                      '.pf-node-wrapper',
                      {
                        key: `chain-${id}`,
                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
                        className: classNames(
                          canvasState.draggedNode === id &&
                            'pf-node-wrapper--dragging',
                        ),
                      },
                      renderNode(node, vnode, {
                        isDockedChild: false,
                        hasDockedChild: false,
                        isDockTarget,
                        rootNode: undefined,
                        multiselect,
                        contextMenuOnHover,
                      }),
                    );
                  }
                })
                .filter((vnode) => vnode !== null),

              // Render all labels
              (vnode.attrs.labels ?? []).map((label: Label) => {
                return renderLabel(label, vnode);
              }),
            ],
          ),
        ],
      );
    },
  };
}
