Files
OpenNoodl/dev-docs/tasks/phase-9-styles-overhaul/CLEANUP-SUBTASKS/TASK-000I-node-graph-visual-improvements/TASK-000I-C-port-organization.md

21 KiB

TASK-009I-C: Port Organization & Smart Connections

Parent Task: TASK-000I Node Graph Visual Improvements
Estimated Time: 15-20 hours
Risk Level: Medium
Dependencies: Sub-Task A (visual polish) recommended first


Objective

Improve the usability of nodes with many ports through visual organization, type indicators, and smart connection previews that highlight compatible ports.


Scope

  1. Port grouping system - Collapsible groups for nodes with many ports
  2. Port type icons - Small, classy icons indicating data types
  3. Connection preview on hover - Highlight compatible ports when hovering

Out of Scope

  • Two-column port layout
  • Hiding unused ports
  • User-customizable groups (node type defines groups)
  • Animated connections

Target Nodes

These nodes have the most ports and will benefit most:

Node Type Typical Port Count Pain Point
Object 10-30+ Dynamic properties
States 5-20+ State transitions
Function/Script Variable User-defined I/O
Component I/O Variable Exposed ports
HTTP Request 15+ Headers, params, response
Visual nodes (Group, etc.) 20+ Style properties

Implementation Phases

Phase C1: Port Grouping System (6-8 hours)

Design: Group Configuration

Groups can be defined in two ways:

1. Explicit configuration in node type definition:

// In node type registration
{
  name: 'net.noodl.httpnode',
  displayName: 'HTTP Request',

  portGroups: [
    {
      name: 'Request',
      ports: ['url', 'method', 'body'],
      dynamicPorts: 'header-*',  // Wildcard for dynamic ports
      defaultExpanded: true
    },
    {
      name: 'Query Parameters',
      ports: ['queryParams'],
      dynamicPorts: 'param-*',
      defaultExpanded: false
    },
    {
      name: 'Response',
      ports: ['status', 'response', 'responseHeaders', 'error'],
      defaultExpanded: true
    },
    {
      name: 'Control',
      ports: ['send', 'success', 'failure'],
      defaultExpanded: true
    }
  ]
}

2. Auto-grouping fallback:

// For nodes without explicit groups
function autoGroupPorts(node: NodeGraphEditorNode): PortGroup[] {
  const ports = node.getAllPorts();

  const inputs = ports.filter((p) => p.direction === 'input' && p.type !== 'signal');
  const outputs = ports.filter((p) => p.direction === 'output' && p.type !== 'signal');
  const signals = ports.filter((p) => p.type === 'signal');

  const groups: PortGroup[] = [];

  // Only create groups if node has many ports
  const GROUPING_THRESHOLD = 8;
  if (ports.length < GROUPING_THRESHOLD) {
    return []; // No grouping, render flat
  }

  if (signals.length > 0) {
    groups.push({
      name: 'Events',
      ports: signals,
      expanded: true,
      isAutoGenerated: true
    });
  }

  if (inputs.length > 0) {
    groups.push({
      name: 'Inputs',
      ports: inputs,
      expanded: true,
      isAutoGenerated: true
    });
  }

  if (outputs.length > 0) {
    groups.push({
      name: 'Outputs',
      ports: outputs,
      expanded: true,
      isAutoGenerated: true
    });
  }

  return groups;
}

Data Structures

File: views/nodegrapheditor/portGrouping.ts

export interface PortGroupDefinition {
  name: string;
  ports: string[]; // Explicit port names
  dynamicPorts?: string; // Wildcard pattern like 'header-*'
  defaultExpanded?: boolean;
}

export interface PortGroup {
  name: string;
  ports: PlugInfo[];
  expanded: boolean;
  isAutoGenerated: boolean;
  yPosition?: number; // Calculated during layout
}

export const GROUP_HEADER_HEIGHT = 24;
export const GROUP_INDENT = 8;

/**
 * Build port groups for a node
 */
export function buildPortGroups(node: NodeGraphEditorNode, plugs: PlugInfo[]): PortGroup[] {
  const typeDefinition = node.model.type;

  // Check for explicit group configuration
  if (typeDefinition.portGroups && typeDefinition.portGroups.length > 0) {
    return buildExplicitGroups(typeDefinition.portGroups, plugs);
  }

  // Fall back to auto-grouping
  return autoGroupPorts(plugs);
}

function buildExplicitGroups(definitions: PortGroupDefinition[], plugs: PlugInfo[]): PortGroup[] {
  const groups: PortGroup[] = [];
  const assignedPorts = new Set<string>();

  for (const def of definitions) {
    const groupPorts: PlugInfo[] = [];

    // Match explicit port names
    for (const portName of def.ports) {
      const plug = plugs.find((p) => p.property === portName);
      if (plug) {
        groupPorts.push(plug);
        assignedPorts.add(portName);
      }
    }

    // Match dynamic ports via wildcard
    if (def.dynamicPorts) {
      const pattern = def.dynamicPorts.replace('*', '(.*)');
      const regex = new RegExp(`^${pattern}$`);

      for (const plug of plugs) {
        if (!assignedPorts.has(plug.property) && regex.test(plug.property)) {
          groupPorts.push(plug);
          assignedPorts.add(plug.property);
        }
      }
    }

    if (groupPorts.length > 0) {
      groups.push({
        name: def.name,
        ports: groupPorts,
        expanded: def.defaultExpanded !== false,
        isAutoGenerated: false
      });
    }
  }

  // Add ungrouped ports to "Other" group
  const ungrouped = plugs.filter((p) => !assignedPorts.has(p.property));
  if (ungrouped.length > 0) {
    groups.push({
      name: 'Other',
      ports: ungrouped,
      expanded: true,
      isAutoGenerated: true
    });
  }

  return groups;
}

Rendering Changes

In NodeGraphEditorNode.ts:

import { buildPortGroups, PortGroup, GROUP_HEADER_HEIGHT } from './portGrouping';

// Add to class
private portGroups: PortGroup[] = [];
private groupExpandState: Map<string, boolean> = new Map();

// Modify measure() method
measure() {
  // ... existing size calculations

  // Build port groups
  this.portGroups = buildPortGroups(this, this.plugs);

  // Apply saved expand states
  for (const group of this.portGroups) {
    const savedState = this.groupExpandState.get(group.name);
    if (savedState !== undefined) {
      group.expanded = savedState;
    }
  }

  // Calculate height
  if (this.portGroups.length > 0) {
    let height = this.titlebarHeight();

    for (const group of this.portGroups) {
      height += GROUP_HEADER_HEIGHT;
      if (group.expanded) {
        height += group.ports.length * NodeGraphEditorNode.propertyConnectionHeight;
      }
    }

    this.nodeSize.height = Math.max(height, NodeGraphEditorNode.size.height);
  }

  // ... rest of measure
}

// Add group header drawing
private drawGroupHeader(
  ctx: CanvasRenderingContext2D,
  group: PortGroup,
  x: number,
  y: number
): void {
  const headerY = y;

  // Background
  ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
  ctx.fillRect(x, headerY, this.nodeSize.width, GROUP_HEADER_HEIGHT);

  // Chevron
  ctx.save();
  ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
  ctx.font = '10px Inter-Regular';
  ctx.textBaseline = 'middle';

  const chevron = group.expanded ? '▼' : '▶';
  ctx.fillText(chevron, x + 8, headerY + GROUP_HEADER_HEIGHT / 2);

  // Group name
  ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
  ctx.font = '11px Inter-Medium';
  ctx.fillText(group.name, x + 22, headerY + GROUP_HEADER_HEIGHT / 2);

  // Port count
  ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
  ctx.font = '10px Inter-Regular';
  ctx.fillText(`(${group.ports.length})`, x + 22 + ctx.measureText(group.name).width + 6, headerY + GROUP_HEADER_HEIGHT / 2);

  ctx.restore();

  // Store hit area for click detection
  group.headerBounds = {
    x: x,
    y: headerY,
    width: this.nodeSize.width,
    height: GROUP_HEADER_HEIGHT
  };
}

// Modify drawPlugs or create new drawGroupedPlugs
private drawGroupedPorts(ctx: CanvasRenderingContext2D, x: number, startY: number): void {
  let y = startY;

  for (const group of this.portGroups) {
    // Draw header
    this.drawGroupHeader(ctx, group, x, y);
    y += GROUP_HEADER_HEIGHT;
    group.yPosition = y;

    // Draw ports if expanded
    if (group.expanded) {
      for (const plug of group.ports) {
        this.drawPort(ctx, plug, x, y);
        y += NodeGraphEditorNode.propertyConnectionHeight;
      }
    }
  }
}

Click Handling for Expand/Collapse

// In handleMouseEvent
case 'up':
  // Check group header clicks
  for (const group of this.portGroups) {
    if (group.headerBounds && this.isPointInBounds(localX, localY, group.headerBounds)) {
      this.toggleGroupExpanded(group);
      return;
    }
  }
  // ... rest of click handling

private toggleGroupExpanded(group: PortGroup): void {
  group.expanded = !group.expanded;
  this.groupExpandState.set(group.name, group.expanded);

  // Remeasure and repaint
  this.measuredSize = null;
  this.owner.relayout();
  this.owner.repaint();
}

Phase C2: Port Type Icons (4-6 hours)

Icon Design

Small, monochrome icons that indicate data type at a glance.

File: views/nodegrapheditor/portIcons.ts

export type PortType =
  | 'signal'
  | 'string'
  | 'number'
  | 'boolean'
  | 'object'
  | 'array'
  | 'color'
  | 'any'
  | 'component'
  | 'enum';

export interface PortIcon {
  char?: string; // Single character fallback
  path?: Path2D; // Canvas path for precise control
}

// Simple character-based icons (reliable, easy)
export const PORT_ICONS: Record<PortType, PortIcon> = {
  signal: { char: '⚡' }, // Lightning bolt
  string: { char: 'T' }, // Text
  number: { char: '#' }, // Number sign
  boolean: { char: '◐' }, // Half circle
  object: { char: '{ }' }, // Braces (might need path)
  array: { char: '[ ]' }, // Brackets
  color: { char: '●' }, // Filled circle
  any: { char: '◇' }, // Diamond
  component: { char: '◈' }, // Diamond with dot
  enum: { char: '≡' } // Menu/list
};

// Size constants
export const PORT_ICON_SIZE = 10;
export const PORT_ICON_PADDING = 4;

/**
 * Map Noodl internal type names to our icon types
 */
export function getPortIconType(type: string | undefined): PortType {
  if (!type) return 'any';

  const typeMap: Record<string, PortType> = {
    signal: 'signal',
    '*': 'signal',
    string: 'string',
    number: 'number',
    boolean: 'boolean',
    object: 'object',
    array: 'array',
    color: 'color',
    component: 'component',
    enum: 'enum'
  };

  return typeMap[type.toLowerCase()] || 'any';
}

/**
 * Draw a port type icon
 */
export function drawPortIcon(ctx: CanvasRenderingContext2D, type: PortType, x: number, y: number, color: string): void {
  const icon = PORT_ICONS[type];

  ctx.save();
  ctx.fillStyle = color;
  ctx.font = `${PORT_ICON_SIZE}px Inter-Regular`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(icon.char || '?', x, y);
  ctx.restore();
}

Integration

// In drawPort() or drawPlugs()

// After drawing the connection dot/arrow, add type icon
const portType = getPortIconType(plug.type);
const iconX =
  side === 'left'
    ? x + PORT_RADIUS + PORT_ICON_PADDING + PORT_ICON_SIZE / 2
    : x + this.nodeSize.width - PORT_RADIUS - PORT_ICON_PADDING - PORT_ICON_SIZE / 2;

drawPortIcon(ctx, portType, iconX, ty, 'rgba(255, 255, 255, 0.5)');

// Adjust label position to account for icon
const labelX = side === 'left' ? iconX + PORT_ICON_SIZE / 2 + 4 : iconX - PORT_ICON_SIZE / 2 - 4;

Alternative: SVG Path Icons

For more precise control:

// Create paths once
const signalPath = new Path2D('M4 0 L8 4 L6 4 L6 8 L2 8 L2 4 L0 4 Z');

export function drawPortIconPath(
  ctx: CanvasRenderingContext2D,
  type: PortType,
  x: number,
  y: number,
  color: string,
  scale: number = 1
): void {
  const path = PORT_ICON_PATHS[type];
  if (!path) return;

  ctx.save();
  ctx.fillStyle = color;
  ctx.translate(x - 4 * scale, y - 4 * scale);
  ctx.scale(scale, scale);
  ctx.fill(path);
  ctx.restore();
}

Phase C3: Connection Preview on Hover (5-6 hours)

Behavior Specification

  1. User hovers over a port (input or output)
  2. System identifies all compatible ports on other nodes
  3. Compatible ports are highlighted (brighter, glow effect)
  4. Incompatible ports are dimmed (reduced opacity)
  5. Preview clears when mouse leaves port area

State Management

In NodeGraphEditor.ts:

// Add state
private highlightedPort: {
  node: NodeGraphEditorNode;
  plug: PlugInfo;
  isOutput: boolean;
} | null = null;

// Methods
setHighlightedPort(
  node: NodeGraphEditorNode,
  plug: PlugInfo,
  isOutput: boolean
): void {
  this.highlightedPort = { node, plug, isOutput };
  this.repaint();
}

clearHighlightedPort(): void {
  if (this.highlightedPort) {
    this.highlightedPort = null;
    this.repaint();
  }
}

/**
 * Check if a port is compatible with the currently highlighted port
 */
getPortCompatibility(
  targetNode: NodeGraphEditorNode,
  targetPlug: PlugInfo,
  targetIsOutput: boolean
): 'source' | 'compatible' | 'incompatible' | 'neutral' {
  if (!this.highlightedPort) return 'neutral';

  const source = this.highlightedPort;

  // Same port = source
  if (source.node === targetNode && source.plug.property === targetPlug.property) {
    return 'source';
  }

  // Same node = incompatible (can't connect to self)
  if (source.node === targetNode) {
    return 'incompatible';
  }

  // Same direction = incompatible (output to output, input to input)
  if (source.isOutput === targetIsOutput) {
    return 'incompatible';
  }

  // Check type compatibility
  const sourceType = source.plug.type || '*';
  const targetType = targetPlug.type || '*';

  // Use existing type compatibility logic
  const compatible = this.checkTypeCompatibility(sourceType, targetType);

  return compatible ? 'compatible' : 'incompatible';
}

private checkTypeCompatibility(sourceType: string, targetType: string): boolean {
  // Signals connect to signals
  if (sourceType === '*' || sourceType === 'signal') {
    return targetType === '*' || targetType === 'signal';
  }

  // Any type (*) is compatible with anything
  if (sourceType === '*' || targetType === '*') {
    return true;
  }

  // Same type
  if (sourceType === targetType) {
    return true;
  }

  // Number compatible with string (coercion)
  if ((sourceType === 'number' && targetType === 'string') ||
      (sourceType === 'string' && targetType === 'number')) {
    return true;
  }

  // Could add more rules based on NodeLibrary
  return false;
}

Visual Rendering

In NodeGraphEditorNode.ts drawPort():

private drawPort(
  ctx: CanvasRenderingContext2D,
  plug: PlugInfo,
  x: number,
  y: number,
  isOutput: boolean
): void {
  // Get compatibility state
  const compatibility = this.owner.getPortCompatibility(this, plug, isOutput);

  // Determine visual style
  let alpha = 1;
  let glowColor: string | null = null;

  switch (compatibility) {
    case 'source':
      // This is the hovered port - normal rendering
      break;

    case 'compatible':
      // Highlight compatible ports
      glowColor = 'rgba(100, 200, 255, 0.6)';
      break;

    case 'incompatible':
      // Dim incompatible ports
      alpha = 0.3;
      break;

    case 'neutral':
      // No highlighting active
      break;
  }

  ctx.save();
  ctx.globalAlpha = alpha;

  // Draw glow for compatible ports
  if (glowColor) {
    ctx.shadowColor = glowColor;
    ctx.shadowBlur = 8;
  }

  // ... existing port drawing code

  ctx.restore();
}

Mouse Event Handling

In NodeGraphEditorNode.ts handleMouseEvent():

case 'move':
  // Check if hovering a port
  const hoveredPlug = this.getPlugAtPosition(localX, localY);

  if (hoveredPlug) {
    const isOutput = hoveredPlug.side === 'right';
    this.owner.setHighlightedPort(this, hoveredPlug.plug, isOutput);
  } else if (this.owner.highlightedPort?.node === this) {
    // Was hovering our port, now not - clear
    this.owner.clearHighlightedPort();
  }
  break;

case 'move-out':
  // Clear if we were the source
  if (this.owner.highlightedPort?.node === this) {
    this.owner.clearHighlightedPort();
  }
  break;

// Helper method
private getPlugAtPosition(x: number, y: number): { plug: PlugInfo; side: 'left' | 'right' } | null {
  const portRadius = 8; // Hit area

  for (const plug of this.plugs) {
    // Left side ports
    if (plug.leftCons?.length || plug.leftIcon) {
      const px = 0;
      const py = plug.yPosition; // Need to track this during layout

      if (Math.abs(x - px) < portRadius && Math.abs(y - py) < portRadius) {
        return { plug, side: 'left' };
      }
    }

    // Right side ports
    if (plug.rightCons?.length || plug.rightIcon) {
      const px = this.nodeSize.width;
      const py = plug.yPosition;

      if (Math.abs(x - px) < portRadius && Math.abs(y - py) < portRadius) {
        return { plug, side: 'right' };
      }
    }
  }

  return null;
}

Performance Consideration

With many nodes visible, checking compatibility for every port on every paint could be slow.

Optimization:

// Cache compatibility results when highlight changes
private compatibilityCache: Map<string, 'compatible' | 'incompatible'> = new Map();

setHighlightedPort(...) {
  this.highlightedPort = { node, plug, isOutput };
  this.rebuildCompatibilityCache();
  this.repaint();
}

private rebuildCompatibilityCache(): void {
  this.compatibilityCache.clear();

  if (!this.highlightedPort) return;

  // Pre-calculate for all visible nodes
  this.forEachNode(node => {
    for (const plug of node.plugs) {
      const key = `${node.model.id}:${plug.property}`;
      const compat = this.calculateCompatibility(node, plug);
      this.compatibilityCache.set(key, compat);
    }
  });
}

getPortCompatibility(node, plug, isOutput): string {
  if (!this.highlightedPort) return 'neutral';

  const key = `${node.model.id}:${plug.property}`;
  return this.compatibilityCache.get(key) || 'neutral';
}

Files to Create

packages/noodl-editor/src/editor/src/views/nodegrapheditor/
├── portGrouping.ts     # Group logic and interfaces
└── portIcons.ts        # Type icon definitions

Files to Modify

packages/noodl-editor/src/editor/src/views/nodegrapheditor/
└── NodeGraphEditorNode.ts    # Grouped rendering, icons, hover

packages/noodl-editor/src/editor/src/views/
└── nodegrapheditor.ts        # Highlight state management

packages/noodl-editor/src/editor/src/models/nodelibrary/
└── [node definitions]        # Add portGroups config (optional)

Testing Checklist

Port Grouping

  • Nodes with explicit groups render correctly
  • Nodes without groups use auto-grouping (if >8 ports)
  • Nodes with few ports render flat (no groups)
  • Group headers display name and count
  • Click expands/collapses group
  • Collapsed group hides ports
  • Node height adjusts with collapse
  • Connections still work with grouped ports
  • Group state doesn't persist (intentional)

Port Type Icons

  • Icons render for all port types
  • Icons visible at 100% zoom
  • Icons visible at 50% zoom
  • Icons don't overlap labels
  • Color matches port state
  • Icons for unknown types fallback to 'any'

Connection Preview

  • Hovering output highlights compatible inputs
  • Hovering input highlights compatible outputs
  • Same node ports dimmed
  • Same direction ports dimmed
  • Type-incompatible ports dimmed
  • Highlight clears when mouse leaves
  • Highlight clears on pan/zoom
  • Performance acceptable with 50+ nodes

Integration

  • Grouping + icons work together
  • Grouping + connection preview work together
  • No regression on ungrouped nodes
  • Copy/paste works with grouped nodes

Success Criteria

  • Port groups configurable per node type
  • Auto-grouping fallback for unconfigured nodes
  • Groups collapsible with visual feedback
  • Port type icons clear and minimal
  • Connection preview highlights compatible ports
  • Incompatible ports visually dimmed
  • Performance acceptable
  • No regression on existing functionality

Rollback Plan

Port grouping:

  • Revert NodeGraphEditorNode.ts measure/draw changes
  • Delete portGrouping.ts
  • Nodes will render flat (original behavior)

Type icons:

  • Delete portIcons.ts
  • Remove icon drawing from port render
  • Ports will show dots/arrows only (original behavior)

Connection preview:

  • Remove highlight state from nodegrapheditor.ts
  • Remove compatibility rendering from node
  • No visual change on hover (original behavior)

All features are independent and can be rolled back separately.


Future Enhancements (Out of Scope)

  • User-customizable port groups
  • Persistent group expand state per project
  • Search/filter ports within node
  • Port group templates (reusable across node types)
  • Connection line preview during hover
  • Animated highlight effects