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
- Port grouping system - Collapsible groups for nodes with many ports
- Port type icons - Small, classy icons indicating data types
- 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
- User hovers over a port (input or output)
- System identifies all compatible ports on other nodes
- Compatible ports are highlighted (brighter, glow effect)
- Incompatible ports are dimmed (reduced opacity)
- 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.tsmeasure/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