# 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:** ```typescript // 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:** ```typescript // 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` ```typescript 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(); 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`:** ```typescript import { buildPortGroups, PortGroup, GROUP_HEADER_HEIGHT } from './portGrouping'; // Add to class private portGroups: PortGroup[] = []; private groupExpandState: Map = 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 ```typescript // 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` ```typescript 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 = { 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 = { 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 ```typescript // 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: ```typescript // 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`:** ```typescript // 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():** ```typescript 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():** ```typescript 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:** ```typescript // Cache compatibility results when highlight changes private compatibilityCache: Map = 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