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}`);
  }

  // ...
}