npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

co-aqua-graph

v0.1.6

Published

Zero-dependency vanilla JavaScript visual node graph editor with SVG rendering and auto-layout

Readme

Visual Node Editor

License: MIT Node.js Vite

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

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 dev

Visit 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 rules

Available 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 codeRunning during 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 code after 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 control
  • event - Event triggers (can connect to control ports)

Port Directions:

  • input - Left side of node, receives connections
  • output - 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:

  1. Single Root Rule: Only one root node allowed; duplicates flagged as invalid
  2. Orphan Detection: Nodes without inputs (except root) are flagged
  3. Auto-Connection: Orphaned Data/Plan/Code nodes auto-connect to Root
  4. Insight Reversion: Invalid Insight nodes automatically revert to Result nodes
  5. 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 → World

Auto-Layout Algorithm

The layout engine uses a hierarchical layered approach:

  1. Layer Assignment: Topological sort with longest-path ranking
  2. Cycle Detection: Isolated cycles placed in fallback layer
  3. Barycentric Ordering: Minimizes edge crossings within layers
  4. Tree Construction: Builds primary spanning tree from roots
  5. Position Calculation: Assigns X (column) and Y (row) coordinates
  6. 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:

  1. Update viewport transform (viewBox attribute)
  2. Compute port positions for all nodes (cached per frame)
  3. Render edges using cached port coordinates
  4. Render connection preview (if dragging)
  5. Render nodes with ports, actions, fold controls
  6. Render debug overlay (if enabled)

Persistence & Serialization

Autosave Flow:

  1. User mutates state (add node, move, connect)
  2. Debounced save triggered (200ms delay)
  3. State serialized to JSON via exportGraph()
  4. JSON saved to localStorage under visual-node-editor-graph
  5. 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 like transaction:* 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.js

Embedding 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.css

Extension 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 fmt

Development Workflow

  1. Hot Module Replacement: Vite provides instant updates on file save
  2. Debug Overlay: Enable via checkbox in UI to see port positions and hierarchy
  3. Browser DevTools: Use React/Vue DevTools-style inspection on graphState object
  4. 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

  1. 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 }]
    }
  }
});
  1. 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'])
});
  1. Add quick action handler in src/main.js:
const QUICK_ACTION_HANDLERS = {
  // ...existing handlers
  customAction(sourceNode) {
    // Implement custom behavior
    flashStatus('Custom action triggered');
  }
};
  1. 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 test

Writing 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 test

Deployment

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 branch

Docker 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-editor

CDN 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:

  1. Fork & Branch: Create feature branches from master
  2. Test Coverage: Add tests for new features (tests/*.test.js)
  3. Code Style: Run npm run fmt and npm run lint before committing
  4. Commit Messages: Use conventional commits format (feat:, fix:, docs:, etc.)
  5. 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 dev

Documentation

  • 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


Built with ❤️ using vanilla JavaScript and modern web standards