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
- Use spatial indexing: For large graphs, use quadtrees (2D) or octrees (3D)
- Batch updates: Update all positions before returning from
step() - Early exit: Return
truefromstep()as soon as layout is stable - 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}`);
}
// ...
}