co-aqua-graph
v0.1.6
Published
Zero-dependency vanilla JavaScript visual node graph editor with SVG rendering and auto-layout
Maintainers
Readme
Visual Node Editor
A zero-dependency, vanilla JavaScript visual node graph editor built with modern web standards. This library provides a complete interactive graph editing experience with SVG rendering, automatic layout, state management, and rich validation rules—all in a lightweight, embeddable package.
Table of Contents
- Features
- Quick Start
- Architecture
- Core Concepts
- API Reference
- Event Surface
- Integration Guide
- Development
- Testing
- Deployment
- Roadmap
- Contributing
Features
Core Capabilities
- 🎨 SVG-Based Rendering - Hardware-accelerated canvas with smooth pan/zoom/drag interactions
- 🔗 Intelligent Edge Routing - Cubic Bézier curves with automatic path calculation
- 📐 Automatic Layout - Hierarchical layered layout with barycentric ordering and collision avoidance
- 🔒 State Management - Immutable state model with validation and history tracking
- 💾 Persistence - JSON serialization with debounced autosave to localStorage
- 🎯 Type-Safe Connections - Port-based connections with type compatibility validation
- 🌗 Theme Support - Light/dark themes with system preference detection
- ♿ Accessibility - Keyboard navigation, ARIA labels, and focus management
- 📦 Zero Dependencies - Pure vanilla JavaScript with no runtime dependencies
- 📡 Event Surface - Subscribe to graph events (nodes, edges, layout, transactions) via
window.graphEvents(docs/events-api.md)
Interactive Features
- Drag & Drop - Move nodes with pointer capture and automatic viewport panning
- Connection Preview - Real-time visual feedback during edge creation
- Quick Actions - Context-aware toolbar buttons per node type
- Collapsible Subgraphs - Fold/unfold descendants to manage complexity
- Node Type Conversion - Runtime type changes with automatic port reconciliation
- Validation Feedback - Visual indicators for invalid connections and orphaned nodes
- Debug Overlay - Optional developer tools for port positions and hierarchy visualization
Quick Start
Prerequisites
- Node.js >= 22.9.0
- npm >= 10.9.0
Installation & Setup
# Clone the repository
git clone <repository-url>
cd visual-node-editor-mvp
# Install dependencies
npm install
# Start development server
npm run devVisit http://localhost:5173 in your browser to see the editor in action.
Build for Production
# Create optimized production bundle
npm run build
# Preview production build locally
npm run preview
# Generate standalone single-file build
npm run build:standalone
# Result: dist/standalone.html (self-contained HTML file)The build output is generated in the dist/ directory, ready for deployment to any static hosting service.
Architecture
System Overview
The editor is built with a unidirectional data flow architecture, separating concerns into distinct layers:
┌─────────────────────────────────────────────────────────────┐
│ main.js │
│ (Initialization & Event Handling) │
└───────────────────────┬────────────────┬────────────────────┘
│ │
┌───────────────▼────┐ ┌────▼──────────────┐
│ State Layer │ │ Render Layer │
│ (state.js) │ │ (render/) │
│ │ │ │
│ • Node CRUD │ │ • SVG Generation │
│ • Edge Management │ │ • Port Calculation│
│ • Validation │ │ • Visual Updates │
│ • Serialization │ │ • Debug Overlay │
└──────────┬──────────┘ └───────────────────┘
│
┌─────────────▼──────────────┐
│ Layout Engine │
│ (layout/) │
│ │
│ • Topological Sort │
│ • Layer Assignment │
│ • Barycentric Ordering │
│ • Position Calculation │
└────────────────────────────┘Directory Structure
visual-node-editor-mvp/
├── src/
│ ├── main.js # Entry point, event orchestration
│ ├── state.js # State management & mutations
│ ├── constants.js # Node types, port types, defaults
│ ├── geometry.js # Viewport transforms, coordinates
│ ├── validation.js # Connection rules, graph validation
│ ├── utils.js # Shared utilities
│ ├── style.css # Global styles, theme variables
│ │
│ ├── app/ # Application layer
│ │ ├── demo.js # Sample graph seed data
│ │ ├── persistence.js # Autosave, localStorage adapter
│ │ └── theme.js # Theme controller, system preference
│ │
│ ├── graph/ # Graph algorithms
│ │ └── edges.js # Adjacency queries (incoming/outgoing)
│ │
│ ├── layout/ # Auto-layout engine
│ │ ├── index.js # Main layout algorithm
│ │ └── config.js # Spacing constants
│ │
│ └── render/ # SVG rendering pipeline
│ ├── index.js # Render orchestrator
│ ├── nodes.js # Node visual generation
│ ├── edges.js # Edge path calculation
│ ├── ports.js # Port position calculation
│ ├── actions.js # Quick action buttons
│ ├── curves.js # Bézier curve generation
│ ├── text.js # Text layout & wrapping
│ ├── images.js # Icon rendering
│ ├── dom.js # SVG layer management
│ ├── cache.js # Port position caching
│ ├── config.js # Visual constants
│ └── debug.js # Debug overlay rendering
│
├── tests/ # Test suite (Node.js test runner)
│ ├── state.test.js # State mutations & validation
│ ├── geometry.test.js # Coordinate transforms
│ ├── layout.test.js # Auto-layout algorithm
│ ├── validation.test.js # Connection rules
│ ├── folding.test.js # Subgraph collapse/expand
│ ├── render.ports.test.js # Port position calculation
│ └── roundtrip.test.js # Serialization integrity
│
├── docs/ # Documentation
│ ├── decisions.md # Architectural decision records
│ ├── progress.md # Development stages log
│ ├── testing-strategy.md # Test coverage approach
│ ├── serialization.md # Persistence format spec
│ └── graph-rules.yaml # Flow rules & constraints
│
├── plans/ # Planning documents
│ └── event-surface-plan.md # Future event system design
│
├── scripts/ # Utility scripts
│ └── test-*.js # Manual test harnesses
│
├── index.html # Main HTML entry point
├── package.json # Dependencies & scripts
├── vite.config.js # Vite bundler configuration
└── eslint.config.js # Linting rulesAvailable Scripts
| Command | Description |
|---------|-------------|
| npm run dev | Start Vite dev server with hot module replacement |
| npm run build | Create optimized production bundle in dist/ |
| npm run preview | Preview production build locally |
| npm test | Run full test suite (7 test files) |
| npm run test:roundtrip | Run serialization tests only |
| npm run lint | Run ESLint on source files |
| npm run fmt | Format code with Prettier |
Core Concepts
Node Types
The editor supports 7 distinct node types, each with specific behavior and visual styling:
1. Root Node (root)
- Purpose: Starting anchor for the graph hierarchy
- Shape: Circle (50×50px)
- Ports: 1 output port
- Rules: Maximum one per graph; cannot receive inputs
- Quick Actions: Add Data, Add Plan, Add Code
2. Data Node (data)
- Purpose: Represents data sources or datasets
- Shape: Rounded rectangle with header (90×50px)
- Ports: 1 input, 1 output
- Rules: Must connect to Root or Plan nodes
- Quick Actions: Add Code (right-side placement)
3. Plan Node (plan)
- Purpose: High-level orchestration and workflow control
- Shape: Rectangle
- Ports: 1 input, 1 output
- Rules: Accepts data, code, or result inputs
- Quick Actions: Execute Plan
4. Code Node (code)
- Purpose: Code execution or transformation step
- Shape: Rectangle with monospace font
- Ports: 1 input, 1 output
- Rules: Requires data source or parent node
- Quick Actions: Run Code, Add New Node, Edit Code
- Special: Can transform into
codeRunningduring execution
5. Code Running Node (codeRunning)
- Purpose: Active execution state with animated border
- Shape: Rectangle with gradient pulse animation
- Ports: 1 input, 1 output
- Rules: Same as Code node
- Quick Actions: Stop Code
- Special: Auto-reverts to
codeafter timeout
6. Result Node (result)
- Purpose: Displays computation results
- Shape: Large square (240×240px)
- Ports: 1 input, 1 output
- Rules: Must receive input from Code node
- Quick Actions: Delete, Convert to Insight
- Special: Can be promoted to Insight node
7. Insight Node (insight)
- Purpose: Final interpreted insight (terminal node)
- Shape: Extra-large square (260×260px) with glow effect
- Ports: 1 input only (no outputs)
- Rules: Must connect to Code or Result; cannot have outgoing edges
- Quick Actions: Revert to Result
Port System
Ports define connection points on nodes with type-safe compatibility rules:
Port Types:
data- General data flow (most common)control- Execution flow controlevent- Event triggers (can connect to control ports)
Port Directions:
input- Left side of node, receives connectionsoutput- Right side of node, sends connections
Connection Rules:
- Source must be an output port, target must be an input port
- Port types must be compatible (defined in
PORT_COMPATIBILITY) - Self-loops are prohibited
- Duplicate edges are automatically rejected
Graph Validation & Auto-Correction
The editor enforces strict graph rules with automatic correction:
- Single Root Rule: Only one root node allowed; duplicates flagged as invalid
- Orphan Detection: Nodes without inputs (except root) are flagged
- Auto-Connection: Orphaned Data/Plan/Code nodes auto-connect to Root
- Insight Reversion: Invalid Insight nodes automatically revert to Result nodes
- Flow Rules: Node type pairs must follow allowed flow patterns (e.g., Data → Code ✓, Code → Data ✗)
State Management
State is managed through a centralized, immutable model:
{
version: 1, // Schema version
nodes: [ // Array of node objects
{
id: "node-1", // Unique identifier
type: "code", // Node type
label: "Transform Data", // Display name
position: { x: 100, y: 50 }, // World coordinates
size: { width: 180, height: 100 },
locked: false, // Prevents auto-layout
collapsed: false, // Hides descendants
data: {}, // Custom user data
ports: { // Port definitions
inputs: [...],
outputs: [...]
},
metadata: {} // Extension metadata
}
],
edges: [ // Array of edge objects
{
id: "edge-1",
source: { nodeId: "node-1", portId: "out" },
target: { nodeId: "node-2", portId: "in" },
label: "",
metadata: {}
}
],
viewport: { // Camera position
scale: 1,
offsetX: 0,
offsetY: 0,
minScale: 0.25,
maxScale: 4
},
counters: { // ID generation
node: { current: 1 },
edge: { current: 1 }
}
}Coordinate Systems
The editor uses two coordinate spaces:
- World Space: Infinite 2D plane where nodes live (stored in
position) - Screen Space: Visible viewport (1200×800px canvas transformed by scale/offset)
Conversions handled by geometry.js:
worldToScreen(point, viewport) // World → Screen
screenToWorld(point, viewport) // Screen → WorldAuto-Layout Algorithm
The layout engine uses a hierarchical layered approach:
- Layer Assignment: Topological sort with longest-path ranking
- Cycle Detection: Isolated cycles placed in fallback layer
- Barycentric Ordering: Minimizes edge crossings within layers
- Tree Construction: Builds primary spanning tree from roots
- Position Calculation: Assigns X (column) and Y (row) coordinates
- Lock Respect: Manually positioned nodes remain fixed
Configuration in src/layout/config.js:
{
startX: 80, // Left margin
startY: 60, // Top margin
columnGap: 160, // Horizontal spacing between layers
rowGap: 80 // Vertical spacing within layers
}Rendering Pipeline
SVG rendering follows a layered composition model:
┌─────────────────────────────────────────┐
│ SVG Canvas (1200×800) │
├─────────────────────────────────────────┤
│ Layer 1: Edges (below nodes) │
│ Layer 2: Preview (connection feedback) │
│ Layer 3: Nodes (interactive elements) │
│ Layer 4: Debug (optional overlay) │
└─────────────────────────────────────────┘Render Cycle:
- Update viewport transform (
viewBoxattribute) - Compute port positions for all nodes (cached per frame)
- Render edges using cached port coordinates
- Render connection preview (if dragging)
- Render nodes with ports, actions, fold controls
- Render debug overlay (if enabled)
Persistence & Serialization
Autosave Flow:
- User mutates state (add node, move, connect)
- Debounced save triggered (200ms delay)
- State serialized to JSON via
exportGraph() - JSON saved to
localStorageundervisual-node-editor-graph - On page load,
importGraph()restores if available
Export Format:
- Plain JSON with indentation (2 spaces)
- Includes all nodes, edges, viewport state
- Round-trip tested for lossless preservation
- Compatible with standard JSON parsers
API Reference
Core State Functions
Node Management
import { addNode, removeNode, updateNodePosition, toggleNodeCollapsed, changeNodeType } from './state.js';
// Create a new node
const node = addNode(state, 'code', { x: 100, y: 50 }, {
label: 'My Code Block',
data: { language: 'python' }
});
// Remove a node (also removes connected edges)
const removed = removeNode(state, 'node-1');
// Update node position
updateNodePosition(state, 'node-1', { x: 200, y: 100 });
// Toggle collapse/expand
const isCollapsed = toggleNodeCollapsed(state, 'node-1');
// Change node type (with port reconciliation)
changeNodeType(state, 'node-1', 'result', { preserveLabel: true });Edge Management
import { addEdge, removeEdge } from './state.js';
// Create a connection
const edge = addEdge(
state,
{ nodeId: 'node-1', portId: 'out' },
{ nodeId: 'node-2', portId: 'in' }
);
// Remove a connection
const removed = removeEdge(state, 'edge-1');Graph Queries
import { getVisibleNodes, findDescendants, getDescendantCount } from './state.js';
import { incomingEdges, outgoingEdges, getEdgesForNode } from './graph/edges.js';
// Get nodes excluding collapsed descendants
const visible = getVisibleNodes(state);
// Find all downstream nodes
const descendants = findDescendants(state, 'node-1');
const count = getDescendantCount(state, 'node-1');
// Query edges for a node
const incoming = incomingEdges(state, 'node-1');
const outgoing = outgoingEdges(state, 'node-1');
const { incoming, outgoing } = getEdgesForNode(state, 'node-1');Validation
import { validateConnection, validateGraph } from './validation.js';
// Validate a potential connection
const result = validateConnection(state, {
source: { nodeId: 'node-1', portId: 'out' },
target: { nodeId: 'node-2', portId: 'in' }
});
// result: { ok: boolean, code: string, reason: string }
// Validate entire graph
const validation = validateGraph(state);
// validation: {
// ok: boolean,
// issues: Array<{ code, message }>,
// invalidEdges: Array<{ edgeId, reason }>,
// invalidNodes: Array<{ nodeId, reason }>
// }Serialization
import { exportGraph, importGraph, replaceGraph, toSerializable } from './state.js';
// Export to JSON string
const json = exportGraph(state, { format: 'json', pretty: 2 });
// Export as object
const obj = exportGraph(state, { format: 'object' });
// Import from JSON string or object
importGraph(state, jsonStringOrObject);
// Replace existing graph (clears current nodes/edges)
replaceGraph(state, jsonStringOrObject);
// Get serializable snapshot (no side effects)
const snapshot = toSerializable(state);Layout
import { autoLayout } from './layout/index.js';
// Run auto-layout
const { updates, warnings } = autoLayout(state, {
respectLocks: true, // Skip manually positioned nodes
respectCollapsed: true // Exclude collapsed descendants
});
// Apply position updates
updates.forEach((position, nodeId) => {
updateNodePosition(state, nodeId, position);
});Rendering
import { renderGraph } from './render/index.js';
// Render to SVG element
renderGraph(svgElement, state, {
debug: false, // Show debug overlay
selection: ['node-1', 'node-2'], // Highlighted nodes
hoverNodeId: 'node-3', // Hovered node
connectionPreview: { // Preview during connection drag
from: { nodeId, portId, direction, point },
to: { point: { x, y } }
},
invalidEdges: new Map() // Edge ID → error message
});Utility Functions
import { normaliseViewport, worldToScreen, screenToWorld, zoomViewport } from './geometry.js';
import { debounce, findNode } from './utils.js';
// Coordinate transforms
const screenPoint = worldToScreen({ x: 100, y: 50 }, viewport);
const worldPoint = screenToWorld({ x: 500, y: 300 }, viewport);
// Zoom while keeping focus point stationary
const newViewport = zoomViewport(viewport, 1.2, { x: 600, y: 400 });
// Debounce a function
const debouncedSave = debounce(() => saveToServer(), 500);
// Find node by ID
const node = findNode(state, 'node-1');Event Surface
The editor exposes a lightweight pub/sub bus for observing runtime activity. When the application initialises, the singleton bus is assigned to window.graphEvents. You can subscribe with wildcard patterns, receive structured metadata, and unsubscribe manually or via AbortSignal. Full payload definitions live in docs/events-api.md.
// Subscribe to every node-related event
const stop = window.graphEvents.subscribe('node:*', (event) => {
const { _meta, context, ...payload } = event;
console.log(`[graph] #${_meta.sequence} ${_meta.eventName}`, payload, context);
});
// Listen for auto-layout completion
window.graphEvents.subscribe('layout:applied', ({ movedCount, warnings }) => {
console.log('Auto-layout moved', movedCount, 'nodes');
if (warnings.length) {
console.warn('Layout warnings:', warnings);
}
});
// Stop listening
stop();Tip: Call
window.graphEvents.setDebug(true)during development to log all emissions, or subscribe to patterns liketransaction:*to track imports, enforcement, and layout batches.
Integration Guide
Embedding in a JavaScript Project
Option 1: Import as ES Module
// main.js
import { createState, addNode, addEdge, exportGraph } from './src/state.js';
import { renderGraph } from './src/render/index.js';
// Create graph state
const state = createState();
// Add nodes
const root = addNode(state, 'root', { x: 360, y: 80 });
const data = addNode(state, 'data', { x: 360, y: 200 });
// Connect them
addEdge(state,
{ nodeId: root.id, portId: 'out' },
{ nodeId: data.id, portId: 'in' }
);
// Render to your SVG element
const svg = document.getElementById('my-graph');
renderGraph(svg, state);
// Export when needed
const json = exportGraph(state);Option 2: Build as Library
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: './src/index.js', // Create this export file
name: 'VisualNodeEditor',
fileName: 'visual-node-editor',
formats: ['es', 'umd']
},
rollupOptions: {
external: [], // No external dependencies!
output: {
globals: {}
}
}
}
});// src/index.js (library entry point)
export * from './state.js';
export * from './render/index.js';
export * from './layout/index.js';
export * from './validation.js';
export * from './geometry.js';
export * from './constants.js';
export { createThemeController } from './app/theme.js';
export { createPersistenceLayer } from './app/persistence.js';Build with:
npm run build # Generates dist/visual-node-editor.es.js and dist/visual-node-editor.umd.jsEmbedding in TypeScript Project
Create type definitions:
// types/visual-node-editor.d.ts
declare module 'visual-node-editor' {
export interface GraphState {
version: number;
nodes: Node[];
edges: Edge[];
viewport: Viewport;
counters: { node: Counter; edge: Counter };
}
export interface Node {
id: string;
type: NodeType;
label: string;
description: string;
position: Point;
size: Size;
locked: boolean;
collapsed: boolean;
data: Record<string, any>;
ports: { inputs: Port[]; outputs: Port[] };
metadata: Record<string, any>;
}
export interface Edge {
id: string;
source: PortRef;
target: PortRef;
label: string;
metadata: Record<string, any>;
}
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
export interface PortRef {
nodeId: string;
portId: string;
}
export type NodeType = 'root' | 'data' | 'plan' | 'code' | 'codeRunning' | 'result' | 'insight';
// State functions
export function createState(overrides?: Partial<GraphState>): GraphState;
export function addNode(state: GraphState, type: NodeType, position?: Partial<Point>, options?: Partial<Node>): Node;
export function removeNode(state: GraphState, nodeId: string): boolean;
export function addEdge(state: GraphState, source: PortRef, target: PortRef, options?: Partial<Edge>): Edge;
export function removeEdge(state: GraphState, edgeId: string): boolean;
export function exportGraph(state: GraphState, options?: { format?: 'json' | 'object'; pretty?: number }): string | object;
export function importGraph(state: GraphState, input: string | object): GraphState;
// Rendering
export function renderGraph(svg: SVGElement, state: GraphState, options?: RenderOptions): void;
// Layout
export function autoLayout(state: GraphState, options?: LayoutOptions): { updates: Map<string, Point>; warnings: string[] };
}Use in TypeScript:
import { createState, addNode, renderGraph, GraphState } from 'visual-node-editor';
class GraphEditor {
private state: GraphState;
private svg: SVGSVGElement;
constructor(container: HTMLElement) {
this.state = createState();
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('viewBox', '0 0 1200 800');
container.appendChild(this.svg);
this.render();
}
addDataNode(x: number, y: number): void {
addNode(this.state, 'data', { x, y });
this.render();
}
private render(): void {
renderGraph(this.svg, this.state);
}
}VS Code Extension Integration
Extension Structure
my-graph-extension/
├── package.json
├── src/
│ ├── extension.ts # Extension entry point
│ ├── graphProvider.ts # Custom editor provider
│ └── webview/
│ ├── index.html # Webview HTML
│ └── main.js # Copy of visual-node-editor bundle
└── media/
└── visual-node-editor.cssExtension Manifest
{
"name": "graph-editor",
"displayName": "Visual Node Graph Editor",
"description": "Edit .graph.json files visually",
"version": "0.1.0",
"engines": {
"vscode": "^1.80.0"
},
"activationEvents": [
"onCustomEditor:graphEditor.visual"
],
"main": "./dist/extension.js",
"contributes": {
"customEditors": [
{
"viewType": "graphEditor.visual",
"displayName": "Visual Graph Editor",
"selector": [
{
"filenamePattern": "*.graph.json"
}
],
"priority": "default"
}
]
}
}Custom Editor Provider
// src/graphProvider.ts
import * as vscode from 'vscode';
export class GraphEditorProvider implements vscode.CustomTextEditorProvider {
public static register(context: vscode.ExtensionContext): vscode.Disposable {
const provider = new GraphEditorProvider(context);
return vscode.window.registerCustomEditorProvider(
'graphEditor.visual',
provider,
{
webviewOptions: { retainContextWhenHidden: true }
}
);
}
constructor(private readonly context: vscode.ExtensionContext) {}
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
webviewPanel.webview.options = {
enableScripts: true
};
webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview);
// Send initial document content to webview
const updateWebview = () => {
webviewPanel.webview.postMessage({
type: 'load',
content: document.getText()
});
};
// Hook up event handlers
const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => {
if (e.document.uri.toString() === document.uri.toString()) {
updateWebview();
}
});
webviewPanel.onDidDispose(() => {
changeDocumentSubscription.dispose();
});
// Receive messages from webview
webviewPanel.webview.onDidReceiveMessage(e => {
switch (e.type) {
case 'save':
this.updateTextDocument(document, e.content);
return;
}
});
updateWebview();
}
private async updateTextDocument(document: vscode.TextDocument, json: string) {
const edit = new vscode.WorkspaceEdit();
edit.replace(
document.uri,
new vscode.Range(0, 0, document.lineCount, 0),
json
);
return vscode.workspace.applyEdit(edit);
}
private getHtmlForWebview(webview: vscode.Webview): string {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'media', 'main.js')
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'media', 'style.css')
);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${styleUri}" rel="stylesheet">
<title>Graph Editor</title>
</head>
<body>
<svg id="editor" viewBox="0 0 1200 800"></svg>
<script type="module" src="${scriptUri}"></script>
</body>
</html>`;
}
}Webview Bridge
// media/main.js
import { createState, importGraph, exportGraph, renderGraph } from './visual-node-editor.es.js';
const vscode = acquireVsCodeApi();
const svg = document.getElementById('editor');
const state = createState();
// Receive messages from extension
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'load':
try {
importGraph(state, message.content);
renderGraph(svg, state);
} catch (error) {
console.error('Failed to load graph:', error);
}
break;
}
});
// Send updates back to extension
function saveToExtension() {
const json = exportGraph(state);
vscode.postMessage({
type: 'save',
content: json
});
}
// Hook into state mutations to trigger saves
// (Add event listeners for node/edge modifications)Development
Project Setup
# Install dependencies
npm install
# Start dev server (localhost:5173)
npm run dev
# Run tests in watch mode
npm test -- --watch
# Lint and format
npm run lint
npm run fmtDevelopment Workflow
- Hot Module Replacement: Vite provides instant updates on file save
- Debug Overlay: Enable via checkbox in UI to see port positions and hierarchy
- Browser DevTools: Use React/Vue DevTools-style inspection on
graphStateobject - Test-Driven: Write tests first for new features (
tests/*.test.js)
Code Style
- Modern ES6+: Use arrow functions, destructuring, template literals
- Functional Core: Pure functions in
state.js,validation.js,geometry.js - No Dependencies: Vanilla JavaScript only, no external libraries
- Immutability: Clone objects before mutation (shallow copies acceptable)
- Naming: camelCase for functions/variables, UPPER_CASE for constants
Adding a New Node Type
- Define in
src/constants.js:
export const NODE_TYPES = Object.freeze({
// ...existing types
myCustomNode: {
id: 'myCustomNode',
label: 'My Custom Node',
description: 'Does something special',
shape: 'rectangle',
rounding: 12,
size: { width: 200, height: 120 },
color: { light: '#FF6B6B', dark: '#C92A2A' },
quickActions: ['delete', 'customAction'],
ports: {
inputs: [{ id: 'in', label: 'Input', type: PORT_TYPES.DATA }],
outputs: [{ id: 'out', label: 'Output', type: PORT_TYPES.DATA }]
}
}
});- Add flow rules in
src/validation.js:
const FLOW_OUT = Object.freeze({
// ...existing rules
myCustomNode: new Set(['result', 'code'])
});
const FLOW_IN = Object.freeze({
// ...existing rules
myCustomNode: new Set(['data', 'plan'])
});- Add quick action handler in
src/main.js:
const QUICK_ACTION_HANDLERS = {
// ...existing handlers
customAction(sourceNode) {
// Implement custom behavior
flashStatus('Custom action triggered');
}
};- Add styles in
src/style.css:
[data-node-type="myCustomNode"] .node__body {
fill: var(--color-custom-bg);
stroke: var(--color-custom-border);
}Testing
Test Suite Overview
| File | Coverage | Test Count |
|------|----------|------------|
| state.test.js | State mutations, CRUD, validation | 15+ tests |
| geometry.test.js | Coordinate transforms, zoom | 8 tests |
| layout.test.js | Auto-layout algorithm, spacing | 6 tests |
| validation.test.js | Connection rules, flow validation | 12 tests |
| folding.test.js | Collapse/expand, descendant tracking | 5 tests |
| render.ports.test.js | Port position calculation | 4 tests |
| roundtrip.test.js | Serialization integrity | 3 tests |
Running Tests
# Run all tests
npm test
# Run specific test file
npm test tests/state.test.js
# Run in watch mode
npm test -- --watch
# Run with coverage (requires additional setup)
NODE_V8_COVERAGE=coverage npm testWriting Tests
Using Node.js built-in test runner:
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createState, addNode } from '../src/state.js';
describe('Node creation', () => {
it('should add a node with default properties', () => {
const state = createState();
const node = addNode(state, 'data', { x: 100, y: 50 });
assert.strictEqual(node.type, 'data');
assert.strictEqual(node.position.x, 100);
assert.strictEqual(node.position.y, 50);
assert.strictEqual(state.nodes.length, 1);
});
it('should throw on invalid node type', () => {
const state = createState();
assert.throws(
() => addNode(state, 'invalid', { x: 0, y: 0 }),
/Unknown node type/
);
});
});Manual Testing Scripts
Utility scripts in scripts/ for exploratory testing:
node scripts/test-layout.js # Layout algorithm stress test
node scripts/test-port-positioning.js # Port calculation verification
node scripts/test-image-support.js # Image rendering testDeployment
Static Hosting (Netlify, Vercel, GitHub Pages)
# Build production bundle
npm run build
# Deploy dist/ directory to your hosting service
# Netlify: netlify deploy --prod --dir=dist
# Vercel: vercel --prod
# GitHub Pages: Copy dist/ to gh-pages branchDocker Deployment
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]# Build and run
docker build -t visual-node-editor .
docker run -p 8080:80 visual-node-editorCDN Deployment
Upload dist/ contents to CDN, then reference in HTML:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.example.com/visual-node-editor/style.css">
</head>
<body>
<svg id="editor" viewBox="0 0 1200 800"></svg>
<script type="module">
import { createState, renderGraph } from 'https://cdn.example.com/visual-node-editor/main.js';
const state = createState();
renderGraph(document.getElementById('editor'), state);
</script>
</body>
</html>Roadmap
Completed (Stage 07/MVP)
- ✅ SVG rendering with pan/zoom
- ✅ Node dragging with locking
- ✅ Connection preview & validation
- ✅ Auto-layout algorithm
- ✅ JSON serialization & autosave
- ✅ Theme support (light/dark)
- ✅ Subgraph folding
- ✅ Quick actions
- ✅ Comprehensive test coverage
Planned (Version 2.0)
See plans/deferred-backlog.md and plans/event-surface-plan.md for details:
- 🔲 Undo/Redo System - Command pattern with history stack
- 🔲 Event Surface API - Observable events for external integrations
- 🔲 Node Grouping - Visual containers for organizing subgraphs
- 🔲 Advanced Layout - Edge crossing minimization, force-directed layout
- 🔲 Search & Filter - Node/edge search, type filtering
- 🔲 Minimap - Overview navigator for large graphs
- 🔲 Keyboard Shortcuts - Accessibility and power user features
- 🔲 CI/CD Pipeline - Automated testing and deployment
- 🔲 Performance Optimization - Virtual rendering for 1000+ nodes
Future Considerations
- Real-time collaboration (WebRTC/WebSocket)
- Plugin system for custom node types
- GraphQL query interface
- Export to PNG/SVG/PDF
- Import from other graph formats (DOT, GEXF)
Known Limitations
Current Constraints
- No Undo/Redo: Changes are permanent until page refresh restores autosave
- Layout Limitations: No edge crossing minimization; dense graphs may need manual adjustment
- Accessibility: Limited to basic keyboard navigation; full screen reader support pending
- Storage Quota: localStorage may fail for very large graphs (>5MB typical limit)
- Browser Support: Modern evergreen browsers only (Chrome, Firefox, Safari, Edge)
- Performance: Not optimized for >500 nodes; virtual rendering deferred to v2
Workarounds
- Large Graphs: Use collapse/expand to manage complexity
- Browser Storage: Export to file frequently to avoid quota issues
- Edge Crossings: Manually reposition nodes or use multiple layout passes
- Old Browsers: Use polyfills for ES6+ features if needed
Contributing
Contributions welcome! Please follow these guidelines:
- Fork & Branch: Create feature branches from
master - Test Coverage: Add tests for new features (
tests/*.test.js) - Code Style: Run
npm run fmtandnpm run lintbefore committing - Commit Messages: Use conventional commits format (
feat:,fix:,docs:, etc.) - Pull Requests: Include description, screenshots, and test results
Development Setup
git clone https://github.com/your-repo/visual-node-editor.git
cd visual-node-editor
npm install
npm test
npm run devDocumentation
- Architecture Decisions: Update
docs/decisions.md - API Changes: Update this README and JSDoc comments
- Planning: Add ideas to
plans/deferred-backlog.md
License
MIT License - see LICENSE file for details
Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Documentation: See
docs/directory for detailed guides
Built with ❤️ using vanilla JavaScript and modern web standards
