Skip to content

Custom Layouts

Guide to creating custom layout algorithms.

Overview

Graphty's layout system is extensible. Create custom layouts for specialized graph structures or unique positioning requirements.

LayoutEngine Interface

All layouts extend the abstract LayoutEngine class:

typescript
abstract class LayoutEngine {
  static type: string;

  abstract initialize(nodes: Node[], edges: Edge[]): void;
  abstract step(): boolean; // Returns true when settled
  abstract getPosition(nodeId: string): Vector3;
}

Creating a Custom Layout

Basic Example

typescript
import { LayoutEngine, Node, Edge, Vector3 } from '@graphty/graphty-element';

class MyLayout extends LayoutEngine {
  static type = 'my-layout';

  private positions: Map<string, Vector3> = new Map();

  initialize(nodes: Node[], edges: Edge[]): void {
    // Set up initial positions
    nodes.forEach((node, index) => {
      this.positions.set(node.id, {
        x: index * 10,
        y: 0,
        z: 0
      });
    });
  }

  step(): boolean {
    // Perform one iteration of layout algorithm
    // Return true when layout is stable
    return true; // Immediately settled for static layouts
  }

  getPosition(nodeId: string): Vector3 {
    return this.positions.get(nodeId) || { x: 0, y: 0, z: 0 };
  }
}

// Register the layout
LayoutEngine.register(MyLayout);

Using Your Layout

typescript
graph.setLayout('my-layout');

Complete Example: Spiral Layout

typescript
import { LayoutEngine, Node, Edge, Vector3 } from '@graphty/graphty-element';

class SpiralLayout extends LayoutEngine {
  static type = 'spiral';

  private positions: Map<string, Vector3> = new Map();
  private options: SpiralOptions;

  constructor(options: Partial<SpiralOptions> = {}) {
    super();
    this.options = {
      radiusStep: 2,
      angleStep: 0.5,
      heightStep: 1,
      ...options
    };
  }

  initialize(nodes: Node[], edges: Edge[]): void {
    let angle = 0;
    let radius = 0;
    let height = 0;

    nodes.forEach((node) => {
      this.positions.set(node.id, {
        x: radius * Math.cos(angle),
        y: height,
        z: radius * Math.sin(angle)
      });

      angle += this.options.angleStep;
      radius += this.options.radiusStep;
      height += this.options.heightStep;
    });
  }

  step(): boolean {
    // Static layout - immediately settled
    return true;
  }

  getPosition(nodeId: string): Vector3 {
    return this.positions.get(nodeId) || { x: 0, y: 0, z: 0 };
  }
}

interface SpiralOptions {
  radiusStep: number;
  angleStep: number;
  heightStep: number;
}

LayoutEngine.register(SpiralLayout);

Usage:

typescript
graph.setLayout('spiral', {
  radiusStep: 3,
  angleStep: 0.3,
  heightStep: 0.5
});

Force-Directed Layout Example

For iterative layouts that converge over time:

typescript
import { LayoutEngine, Node, Edge, Vector3 } from '@graphty/graphty-element';

class SimpleForceLayout extends LayoutEngine {
  static type = 'simple-force';

  private nodes: Node[] = [];
  private edges: Edge[] = [];
  private positions: Map<string, Vector3> = new Map();
  private velocities: Map<string, Vector3> = new Map();

  private repulsion = 100;
  private attraction = 0.01;
  private damping = 0.9;
  private threshold = 0.1;

  initialize(nodes: Node[], edges: Edge[]): void {
    this.nodes = nodes;
    this.edges = edges;

    // Random initial positions
    nodes.forEach((node) => {
      this.positions.set(node.id, {
        x: (Math.random() - 0.5) * 100,
        y: (Math.random() - 0.5) * 100,
        z: (Math.random() - 0.5) * 100
      });
      this.velocities.set(node.id, { x: 0, y: 0, z: 0 });
    });
  }

  step(): boolean {
    let maxVelocity = 0;

    // Calculate forces
    this.nodes.forEach((node) => {
      const pos = this.positions.get(node.id)!;
      const vel = this.velocities.get(node.id)!;
      const force = { x: 0, y: 0, z: 0 };

      // Repulsion from other nodes
      this.nodes.forEach((other) => {
        if (other.id === node.id) return;
        const otherPos = this.positions.get(other.id)!;

        const dx = pos.x - otherPos.x;
        const dy = pos.y - otherPos.y;
        const dz = pos.z - otherPos.z;
        const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;

        const f = this.repulsion / (dist * dist);
        force.x += (dx / dist) * f;
        force.y += (dy / dist) * f;
        force.z += (dz / dist) * f;
      });

      // Attraction along edges
      this.edges.forEach((edge) => {
        let otherId: string | null = null;
        if (edge.source === node.id) otherId = edge.target as string;
        if (edge.target === node.id) otherId = edge.source as string;
        if (!otherId) return;

        const otherPos = this.positions.get(otherId);
        if (!otherPos) return;

        const dx = otherPos.x - pos.x;
        const dy = otherPos.y - pos.y;
        const dz = otherPos.z - pos.z;

        force.x += dx * this.attraction;
        force.y += dy * this.attraction;
        force.z += dz * this.attraction;
      });

      // Update velocity
      vel.x = (vel.x + force.x) * this.damping;
      vel.y = (vel.y + force.y) * this.damping;
      vel.z = (vel.z + force.z) * this.damping;

      // Update position
      pos.x += vel.x;
      pos.y += vel.y;
      pos.z += vel.z;

      const speed = Math.sqrt(vel.x*vel.x + vel.y*vel.y + vel.z*vel.z);
      maxVelocity = Math.max(maxVelocity, speed);
    });

    // Return true when settled
    return maxVelocity < this.threshold;
  }

  getPosition(nodeId: string): Vector3 {
    return this.positions.get(nodeId) || { x: 0, y: 0, z: 0 };
  }
}

LayoutEngine.register(SimpleForceLayout);

Layout Configuration

Accept configuration options in the constructor:

typescript
class ConfigurableLayout extends LayoutEngine {
  static type = 'configurable';

  private config: LayoutConfig;

  constructor(options: Partial<LayoutConfig> = {}) {
    super();
    this.config = {
      spacing: 10,
      direction: 'horizontal',
      ...options
    };
  }

  // ... implementation
}

interface LayoutConfig {
  spacing: number;
  direction: 'horizontal' | 'vertical';
}

Usage:

typescript
graph.setLayout('configurable', {
  spacing: 20,
  direction: 'vertical'
});

2D vs 3D Layouts

Check dimensions in your layout:

typescript
class FlexibleLayout extends LayoutEngine {
  static type = 'flexible';

  private dimensions: 2 | 3 = 3;

  constructor(options: { dimensions?: 2 | 3 } = {}) {
    super();
    this.dimensions = options.dimensions || 3;
  }

  initialize(nodes: Node[], edges: Edge[]): void {
    nodes.forEach((node, i) => {
      if (this.dimensions === 2) {
        this.positions.set(node.id, { x: i * 10, y: 0, z: 0 });
      } else {
        this.positions.set(node.id, {
          x: i * 10,
          y: Math.random() * 10,
          z: Math.random() * 10
        });
      }
    });
  }
}

Performance Tips

  1. Use spatial indexing: For large graphs, use quadtrees (2D) or octrees (3D)
  2. Batch updates: Update all positions before returning from step()
  3. Early exit: Return true from step() as soon as layout is stable
  4. Avoid allocations: Reuse objects instead of creating new ones each step
typescript
// Good: reuse force object
private force = { x: 0, y: 0, z: 0 };

step(): boolean {
  this.force.x = 0;
  this.force.y = 0;
  this.force.z = 0;
  // ... calculate forces
}

// Bad: create new object each time
step(): boolean {
  const force = { x: 0, y: 0, z: 0 }; // Allocation every frame!
}

Debugging Layouts

Log layout progress:

typescript
step(): boolean {
  this.iterationCount++;

  if (this.iterationCount % 100 === 0) {
    console.log(`Layout iteration ${this.iterationCount}`);
  }

  // ...
}