28 KiB
SUBTASK-004: Connection Labels
Parent Task: TASK-000J Canvas Organization System
Estimate: 10-14 hours
Priority: 4
Dependencies: None (can be implemented independently)
Overview
Allow users to add text labels to connection lines to document data flow. Labels sit on the bezier curve connecting nodes and can be repositioned along the path.
The Problem
In complex node graphs:
- It's unclear what data flows through connections
- Users must trace connections to understand data types
- Documentation exists only in users' heads or external docs
- Similar-colored connections are indistinguishable
The Solution
Add inline labels directly on connection lines:
- Labels describe what data flows through the connection
- Position labels anywhere along the curve
- Labels persist with the project
- Quick to add via hover icon
Feature Capabilities
| Capability | Description |
|---|---|
| Hover to add | Icon appears on connection hover for adding labels |
| Inline editing | Click icon to add label, type and confirm |
| On-curve positioning | Label sits directly on the bezier curve |
| Draggable | Slide label along the curve path |
| Edit existing | Click label to edit text |
| Delete | Clear text or use delete button |
| Persistence | Labels saved with project |
Visual Design
Connection with Label
┌─────────┐
┌────────┐ │ user ID │
│ Source │─────────┴─────────┴──────────►│ Target │
└────────┘ └────────┘
▲
Label on curve
Label Styling
- Background: Semi-transparent, matches connection color
- Text: Small (10-11px), high contrast
- Shape: Rounded rectangle with padding
- Border: Optional subtle border
.connection-label {
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 3px;
background-color: rgba(var(--connection-color), 0.8);
color: white;
white-space: nowrap;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
Add Label Icon
When hovering a connection without a label:
┌───┐
──────│ + │──────►
└───┘
↑
Add label icon (appears on hover)
Similar size/style to existing delete "X"
Edit Mode
┌─────────────────┐
──────│ user ID█ │──────►
└─────────────────┘
↑
Inline input field
Cursor visible, typing active
Technical Architecture
Bezier Curve Math
Connections in Noodl use cubic bezier curves. Key formulas:
Point on cubic bezier at parameter t (0-1):
B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
Where:
- P₀ = start point (output port position)
- P₁ = first control point
- P₂ = second control point
- P₃ = end point (input port position)
- t = parameter from 0 (start) to 1 (end)
Tangent (direction) at parameter t:
B'(t) = 3(1-t)²(P₁-P₀) + 6(1-t)t(P₂-P₁) + 3t²(P₃-P₂)
Data Model Extension
interface Connection {
// Existing fields
fromId: string;
fromProperty: string;
toId: string;
toProperty: string;
// New label field
label?: ConnectionLabel;
}
interface ConnectionLabel {
text: string;
position: number; // 0-1 along curve, default 0.5 (midpoint)
}
Implementation Architecture
packages/noodl-editor/src/editor/src/
├── utils/
│ └── bezier.ts # Bezier math utilities
├── views/nodegrapheditor/
│ ├── NodeGraphEditorConnection.ts # Modified for label support
│ └── ConnectionLabel.ts # Label rendering (new)
└── models/
└── nodegraphmodel.ts # Connection model extension
Implementation Sessions
Session 4.1: Bezier Utilities (2 hours)
Goal: Create utility functions for bezier curve calculations.
Tasks:
- Create bezier utility module:
// packages/noodl-editor/src/editor/src/utils/bezier.ts export interface Point { x: number; y: number; } /** * Calculate point on cubic bezier curve at parameter t */ export function getPointOnCubicBezier( t: number, p0: Point, p1: Point, p2: Point, p3: Point ): Point { const mt = 1 - t; const mt2 = mt * mt; const mt3 = mt2 * mt; const t2 = t * t; const t3 = t2 * t; return { x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x, y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y }; } /** * Calculate tangent (direction vector) on cubic bezier at parameter t */ export function getTangentOnCubicBezier( t: number, p0: Point, p1: Point, p2: Point, p3: Point ): Point { const mt = 1 - t; const mt2 = mt * mt; const t2 = t * t; // Derivative of bezier const dx = 3 * mt2 * (p1.x - p0.x) + 6 * mt * t * (p2.x - p1.x) + 3 * t2 * (p3.x - p2.x); const dy = 3 * mt2 * (p1.y - p0.y) + 6 * mt * t * (p2.y - p1.y) + 3 * t2 * (p3.y - p2.y); return { x: dx, y: dy }; } /** * Find nearest t value on bezier curve to given point * Uses iterative refinement for accuracy */ export function getNearestTOnCubicBezier( point: Point, p0: Point, p1: Point, p2: Point, p3: Point, iterations: number = 10 ): number { // Initial coarse search let bestT = 0; let bestDist = Infinity; const steps = 20; for (let i = 0; i <= steps; i++) { const t = i / steps; const curvePoint = getPointOnCubicBezier(t, p0, p1, p2, p3); const dist = distance(point, curvePoint); if (dist < bestDist) { bestDist = dist; bestT = t; } } // Refine with binary search let low = Math.max(0, bestT - 1 / steps); let high = Math.min(1, bestT + 1 / steps); for (let i = 0; i < iterations; i++) { const midLow = (low + bestT) / 2; const midHigh = (bestT + high) / 2; const distLow = distance(point, getPointOnCubicBezier(midLow, p0, p1, p2, p3)); const distHigh = distance(point, getPointOnCubicBezier(midHigh, p0, p1, p2, p3)); if (distLow < distHigh) { high = bestT; bestT = midLow; } else { low = bestT; bestT = midHigh; } } return bestT; } function distance(a: Point, b: Point): number { const dx = a.x - b.x; const dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); } /** * Calculate approximate arc length of bezier curve * Useful for even label spacing if multiple labels needed */ export function getCubicBezierLength( p0: Point, p1: Point, p2: Point, p3: Point, steps: number = 100 ): number { let length = 0; let prevPoint = p0; for (let i = 1; i <= steps; i++) { const t = i / steps; const point = getPointOnCubicBezier(t, p0, p1, p2, p3); length += distance(prevPoint, point); prevPoint = point; } return length; } - Write unit tests for bezier functions:
describe('bezier utils', () => { it('returns start point at t=0', () => { const p0 = { x: 0, y: 0 }; const p1 = { x: 10, y: 0 }; const p2 = { x: 20, y: 0 }; const p3 = { x: 30, y: 0 }; const result = getPointOnCubicBezier(0, p0, p1, p2, p3); expect(result.x).toBeCloseTo(0); expect(result.y).toBeCloseTo(0); }); it('returns end point at t=1', () => { // ... }); it('finds nearest t to point on curve', () => { // ... }); });
Files to create:
packages/noodl-editor/src/editor/src/utils/bezier.tspackages/noodl-editor/src/editor/src/utils/bezier.test.ts
Success criteria:
getPointOnCubicBezierreturns correct pointsgetNearestTOnCubicBezierfinds accurate t values- All unit tests pass
Session 4.2: Data Model Extension (1 hour)
Goal: Extend Connection model to support labels.
Tasks:
- Add label interface to connection model:
// In nodegraphmodel.ts or connections.ts export interface ConnectionLabel { text: string; position: number; // 0-1, default 0.5 } // Extend Connection interface export interface Connection { // ... existing fields label?: ConnectionLabel; } - Add methods to set/remove labels:
class NodeGraphModel { setConnectionLabel( fromId: string, fromProperty: string, toId: string, toProperty: string, label: ConnectionLabel | null ): void { const connection = this.findConnection(fromId, fromProperty, toId, toProperty); if (connection) { if (label) { connection.label = label; } else { delete connection.label; } this.notifyListeners('connectionChanged', { connection }); } } updateConnectionLabelPosition( fromId: string, fromProperty: string, toId: string, toProperty: string, position: number ): void { const connection = this.findConnection(fromId, fromProperty, toId, toProperty); if (connection?.label) { connection.label.position = Math.max(0.1, Math.min(0.9, position)); this.notifyListeners('connectionChanged', { connection }); } } } - Ensure labels persist in project save/load (should work automatically if added to Connection)
- Add undo support for label operations
Files to modify:
packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts
Success criteria:
- Label field added to Connection interface
- Set/remove label methods work
- Labels persist in saved project
- Undo works for label operations
Session 4.3: Hover State and Add Icon (2-3 hours)
Goal: Show add-label icon when hovering a connection.
Tasks:
- Add hover state to NodeGraphEditorConnection:
// In NodeGraphEditorConnection.ts export class NodeGraphEditorConnection { // ... existing fields public isHovered: boolean = false; private addIconBounds: { x: number; y: number; width: number; height: number } | null = null; setHovered(hovered: boolean): void { if (this.isHovered !== hovered) { this.isHovered = hovered; // Trigger repaint } } } - Implement connection hit-testing (may already exist):
isPointNearCurve(point: Point, threshold: number = 10): boolean { const { p0, p1, p2, p3 } = this.getControlPoints(); const nearestT = getNearestTOnCubicBezier(point, p0, p1, p2, p3); const nearestPoint = getPointOnCubicBezier(nearestT, p0, p1, p2, p3); const dx = point.x - nearestPoint.x; const dy = point.y - nearestPoint.y; return Math.sqrt(dx * dx + dy * dy) <= threshold; } - Track hovered connection in nodegrapheditor:
// In mouse move handler private updateHoveredConnection(pos: Point): void { let newHovered: NodeGraphEditorConnection | null = null; for (const conn of this.connections) { if (conn.isPointNearCurve(pos)) { newHovered = conn; break; } } if (this.hoveredConnection !== newHovered) { this.hoveredConnection?.setHovered(false); this.hoveredConnection = newHovered; this.hoveredConnection?.setHovered(true); this.repaint(); } } - Render add icon when hovered (and no existing label):
// In NodeGraphEditorConnection.paint() if (this.isHovered && !this.model.label) { const midpoint = this.getMidpoint(); const iconSize = 16; // Draw icon background ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; ctx.beginPath(); ctx.arc(midpoint.x, midpoint.y, iconSize / 2 + 2, 0, Math.PI * 2); ctx.fill(); // Draw + icon ctx.strokeStyle = '#666'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(midpoint.x - 4, midpoint.y); ctx.lineTo(midpoint.x + 4, midpoint.y); ctx.moveTo(midpoint.x, midpoint.y - 4); ctx.lineTo(midpoint.x, midpoint.y + 4); ctx.stroke(); // Store bounds for click detection this.addIconBounds = { x: midpoint.x - iconSize / 2, y: midpoint.y - iconSize / 2, width: iconSize, height: iconSize }; }
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.tspackages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Hovering connection highlights it subtly
- Add icon appears at midpoint for connections without labels
- Icon styled consistently with existing delete icon
- Icon bounds stored for click detection
Session 4.4: Inline Label Input (2-3 hours)
Goal: Show input field for adding/editing labels.
Tasks:
- Create label input element (could be DOM overlay or canvas-based):
// DOM overlay approach (easier for text input) private showLabelInput(connection: NodeGraphEditorConnection, position: Point): void { // Create input element const input = document.createElement('input'); input.type = 'text'; input.className = 'connection-label-input'; input.placeholder = 'Enter label...'; // Position at connection point const canvasPos = this.nodeGraphCordsToScreenCoords(position); input.style.position = 'absolute'; input.style.left = `${canvasPos.x}px`; input.style.top = `${canvasPos.y}px`; input.style.transform = 'translate(-50%, -50%)'; // Pre-fill if editing existing label if (connection.model.label) { input.value = connection.model.label.text; } // Handle submission const submitLabel = () => { const text = input.value.trim(); if (text) { const labelPosition = connection.model.label?.position ?? 0.5; this.model.setConnectionLabel( connection.model.fromId, connection.model.fromProperty, connection.model.toId, connection.model.toProperty, { text, position: labelPosition } ); } else if (connection.model.label) { // Clear existing label if text is empty this.model.setConnectionLabel( connection.model.fromId, connection.model.fromProperty, connection.model.toId, connection.model.toProperty, null ); } input.remove(); this.activeInput = null; }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { submitLabel(); } else if (e.key === 'Escape') { input.remove(); this.activeInput = null; } }); input.addEventListener('blur', submitLabel); // Add to DOM and focus this.el.appendChild(input); input.focus(); input.select(); this.activeInput = input; } - Style the input:
.connection-label-input { font-size: 11px; padding: 4px 8px; border: 1px solid var(--theme-color-primary); border-radius: 4px; background: var(--theme-color-bg-2); color: var(--theme-color-fg-highlight); outline: none; min-width: 80px; max-width: 150px; z-index: 1000; } .connection-label-input:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); } - Handle click on add icon:
// In mouse click handler if (this.hoveredConnection?.addIconBounds) { const bounds = this.hoveredConnection.addIconBounds; if ( pos.x >= bounds.x && pos.x <= bounds.x + bounds.width && pos.y >= bounds.y && pos.y <= bounds.y + bounds.height ) { const midpoint = this.hoveredConnection.getMidpoint(); this.showLabelInput(this.hoveredConnection, midpoint); return true; // Consume event } } - Add undo support for label creation
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.tspackages/noodl-editor/src/editor/src/assets/css/style.css(or module scss)
Success criteria:
- Clicking add icon shows input field
- Input positioned at midpoint of connection
- Enter confirms and creates label
- Escape cancels without creating label
- Clicking outside (blur) confirms label
- Empty text removes existing label
Session 4.5: Label Rendering (2 hours)
Goal: Render labels on connection curves.
Tasks:
- Calculate label position on curve:
// In NodeGraphEditorConnection getLabelPosition(): Point | null { if (!this.model.label) return null; const { p0, p1, p2, p3 } = this.getControlPoints(); return getPointOnCubicBezier(this.model.label.position, p0, p1, p2, p3); } - Render label in paint():
// In NodeGraphEditorConnection.paint() if (this.model.label) { const position = this.getLabelPosition(); if (!position) return; const text = this.model.label.text; const padding = { x: 6, y: 3 }; // Measure text ctx.font = '10px system-ui, sans-serif'; const textMetrics = ctx.measureText(text); const textWidth = Math.min(textMetrics.width, 100); // Max width const textHeight = 12; // Calculate background bounds const bgWidth = textWidth + padding.x * 2; const bgHeight = textHeight + padding.y * 2; const bgX = position.x - bgWidth / 2; const bgY = position.y - bgHeight / 2; // Draw background ctx.fillStyle = this.getLabelBackgroundColor(); ctx.beginPath(); this.roundRect(ctx, bgX, bgY, bgWidth, bgHeight, 3); ctx.fill(); // Draw text ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, position.x, position.y, 100); // Max width // Store bounds for interaction this.labelBounds = { x: bgX, y: bgY, width: bgWidth, height: bgHeight }; } private getLabelBackgroundColor(): string { // Use connection color with some opacity const baseColor = this.getConnectionColor(); // Convert to rgba with 0.85 opacity return `${baseColor}d9`; // Hex alpha } private roundRect( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ): void { ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); } - Handle text truncation for long labels
- Ensure labels visible at different zoom levels
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts
Success criteria:
- Label renders at correct position on curve
- Label styled with rounded background
- Label color matches connection color
- Long text truncated with ellipsis
- Label visible at reasonable zoom levels
Session 4.6: Label Interaction (2-3 hours)
Goal: Enable editing, dragging, and deleting labels.
Tasks:
- Detect click on label:
// In nodegrapheditor.ts mouse handler private getClickedLabel(pos: Point): NodeGraphEditorConnection | null { for (const conn of this.connections) { if (conn.labelBounds && conn.model.label) { const { x, y, width, height } = conn.labelBounds; if (pos.x >= x && pos.x <= x + width && pos.y >= y && pos.y <= y + height) { return conn; } } } return null; } - Handle click on label (edit):
// In mouse click handler const clickedLabelConn = this.getClickedLabel(pos); if (clickedLabelConn) { const labelPos = clickedLabelConn.getLabelPosition(); if (labelPos) { this.showLabelInput(clickedLabelConn, labelPos); return true; } } - Implement label dragging:
// In mouse down handler const clickedLabelConn = this.getClickedLabel(pos); if (clickedLabelConn) { this.draggingLabel = clickedLabelConn; return true; } // In mouse move handler if (this.draggingLabel) { const { p0, p1, p2, p3 } = this.draggingLabel.getControlPoints(); const newT = getNearestTOnCubicBezier(pos, p0, p1, p2, p3); // Constrain to avoid endpoints const constrainedT = Math.max(0.1, Math.min(0.9, newT)); this.model.updateConnectionLabelPosition( this.draggingLabel.model.fromId, this.draggingLabel.model.fromProperty, this.draggingLabel.model.toId, this.draggingLabel.model.toProperty, constrainedT ); this.repaint(); return true; } // In mouse up handler if (this.draggingLabel) { this.draggingLabel = null; } - Add delete button on label hover:
// When label is hovered, show small X button if (this.hoveredLabel && conn === this.hoveredLabel) { const deleteX = labelBounds.x + labelBounds.width - 8; const deleteY = labelBounds.y; ctx.fillStyle = 'rgba(255, 0, 0, 0.8)'; ctx.beginPath(); ctx.arc(deleteX, deleteY, 6, 0, Math.PI * 2); ctx.fill(); // Draw X ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(deleteX - 2, deleteY - 2); ctx.lineTo(deleteX + 2, deleteY + 2); ctx.moveTo(deleteX + 2, deleteY - 2); ctx.lineTo(deleteX - 2, deleteY + 2); ctx.stroke(); this.labelDeleteBounds = { x: deleteX - 6, y: deleteY - 6, width: 12, height: 12 }; } - Handle delete click:
if (this.labelDeleteBounds && this.hoveredLabel) { const { x, y, width, height } = this.labelDeleteBounds; if (pos.x >= x && pos.x <= x + width && pos.y >= y && pos.y <= y + height) { this.model.setConnectionLabel( this.hoveredLabel.model.fromId, this.hoveredLabel.model.fromProperty, this.hoveredLabel.model.toId, this.hoveredLabel.model.toProperty, null ); return true; } }
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.tspackages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts
Success criteria:
- Clicking label opens edit input
- Dragging label moves it along curve
- Label constrained to 0.1-0.9 range (not at endpoints)
- Delete button appears on hover
- Clicking delete removes label
- Undo works for drag and delete
Testing Checklist
Adding Labels
- Hover connection → add icon appears at midpoint
- Click add icon → input appears
- Type text and press Enter → label created
- Type text and click outside → label created
- Press Escape → input cancelled, no label created
- Label renders on connection curve
Editing Labels
- Click existing label → input appears with current text
- Edit text and confirm → label updated
- Clear text and confirm → label deleted
Dragging Labels
- Click and drag label → moves along curve
- Label constrained to not overlap endpoints
- Position updates smoothly
- Release → position saved
Deleting Labels
- Hover label → delete button appears
- Click delete button → label removed
- Alternative: clear text in edit mode → label removed
Persistence
- Save project with labels → labels in saved file
- Load project → labels restored
- Label positions preserved
Undo/Redo
- Undo label creation → label removed
- Redo → label restored
- Undo label edit → previous text restored
- Undo label delete → label restored
- Undo label drag → previous position restored
Visual Quality
- Label readable at zoom 1.0
- Label readable at zoom 0.5
- Label hidden at very low zoom (optional)
- Label color matches connection color
- Long text truncated properly
Edge Cases
- Delete node with labeled connection → label removed
- Connection with label is deleted → label removed
- Multiple labels (different connections) → all render correctly
- Label on curved connection → positioned on actual curve
- Label on very short connection → still usable
Files Summary
Create
packages/noodl-editor/src/editor/src/utils/bezier.ts
packages/noodl-editor/src/editor/src/utils/bezier.test.ts
Modify
packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts
packages/noodl-editor/src/editor/src/assets/css/style.css
Performance Considerations
Hit Testing
- Don't test all connections on every mouse move
- Use spatial partitioning or only test visible connections
- Cache connection bounds
// Only test connections in viewport
const visibleConnections = this.connections.filter(conn =>
conn.intersectsRect(this.getViewportBounds())
);
Label Rendering
- Don't render labels that are off-screen
- Skip label rendering at very low zoom (labels unreadable anyway)
// Skip labels at low zoom
if (this.getPanAndScale().scale < 0.4) {
return; // Don't render labels
}
Bezier Calculations
- Cache control points during drag
- Use lower iteration count for real-time dragging
// Fast (lower accuracy) for dragging
const t = getNearestTOnCubicBezier(pos, p0, p1, p2, p3, 5);
// Accurate for final position
const t = getNearestTOnCubicBezier(pos, p0, p1, p2, p3, 15);
Design Decisions
Why One Label Per Connection?
Simplicity. Multiple labels would require:
- More complex UI for adding at specific positions
- Handling overlapping labels
- More complex data model
Single label covers 90% of use cases. Can extend later if needed.
Why Not Label Rotation?
Labels aligned to curve tangent could be rotated to follow the curve direction. However:
- Rotated text is harder to read
- Horizontal text is conventional
- Implementation complexity not worth it
Why Constrain Position to 0.1-0.9?
At exactly 0 or 1, labels would overlap with node ports. The constraint keeps labels in the "middle" of the connection where they're most readable and don't interfere with ports.
Why DOM Input vs Canvas Input?
DOM input provides:
- Native text selection and editing
- Proper cursor behavior
- IME support for international input
- Accessibility
Canvas-based text input is significantly more complex to implement correctly.