visualiser2 / src /core /layout-engine.ts
Vishalpainjane's picture
added files
8a01471
/**
* Deterministic Layout Engine
*
* Positions nodes in 3D space following a consistent, predictable algorithm.
* Same model always produces the same layout.
*
* Coordinate System:
* - X → Forward (data flow direction)
* - Y → Vertical (depth/resolution/blocks)
* - Z → Width (channels/grouping)
*/
import type { HierarchyNode, ModelHierarchy, HierarchyConnection } from './model-hierarchy';
import { calculateLayerDimensions, type LayerDimensions } from './layer-geometry';
// ============================================================================
// Layout Configuration
// ============================================================================
export interface LayoutConfig {
// Spacing
macroSpacing: number; // Space between encoder/decoder/head
stageSpacing: number; // Space between stages within macro
layerSpacing: number; // Space between individual layers
blockSpacing: number; // Space between blocks within stage
// Offsets
baseY: number; // Starting Y position
channelZScale: number; // How much Z offset based on channel size
// Grouping
groupPadding: number; // Padding inside group containers
// Animation
transitionDuration: number;
}
const DEFAULT_LAYOUT_CONFIG: LayoutConfig = {
macroSpacing: 8.0,
stageSpacing: 4.0,
layerSpacing: 1.2,
blockSpacing: 0.8,
baseY: 0,
channelZScale: 0.002,
groupPadding: 0.5,
transitionDuration: 500,
};
// ============================================================================
// Position Types
// ============================================================================
export interface Position3D {
x: number;
y: number;
z: number;
}
export interface NodeLayout {
id: string;
position: Position3D;
dimensions: LayerDimensions;
rotation: { x: number; y: number; z: number };
visible: boolean;
expanded: boolean;
}
export interface ConnectionLayout {
id: string;
sourcePosition: Position3D;
targetPosition: Position3D;
controlPoints: Position3D[]; // For curved connections
isSkipConnection: boolean;
}
export interface LayoutResult {
nodes: Map<string, NodeLayout>;
connections: ConnectionLayout[];
bounds: {
min: Position3D;
max: Position3D;
center: Position3D;
};
cameraSuggestion: {
position: Position3D;
target: Position3D;
};
}
// ============================================================================
// Layout State (for expand/collapse)
// ============================================================================
export interface LayoutState {
expandedNodes: Set<string>;
currentLevel: 1 | 2 | 3;
focusedNodeId: string | null;
}
const defaultLayoutState: LayoutState = {
expandedNodes: new Set(),
currentLevel: 1,
focusedNodeId: null,
};
// ============================================================================
// Main Layout Engine
// ============================================================================
export class LayoutEngine {
private config: LayoutConfig;
private state: LayoutState;
private hierarchy: ModelHierarchy | null = null;
private cachedLayout: LayoutResult | null = null;
constructor(config: Partial<LayoutConfig> = {}) {
this.config = { ...DEFAULT_LAYOUT_CONFIG, ...config };
this.state = { ...defaultLayoutState };
}
/**
* Set the model hierarchy to layout
*/
setHierarchy(hierarchy: ModelHierarchy): void {
this.hierarchy = hierarchy;
this.cachedLayout = null;
}
/**
* Update layout state
*/
updateState(updates: Partial<LayoutState>): void {
this.state = { ...this.state, ...updates };
this.cachedLayout = null;
}
/**
* Toggle node expansion
*/
toggleExpanded(nodeId: string): void {
const expanded = new Set(this.state.expandedNodes);
if (expanded.has(nodeId)) {
expanded.delete(nodeId);
} else {
expanded.add(nodeId);
}
this.updateState({ expandedNodes: expanded });
}
/**
* Set current view level
*/
setLevel(level: 1 | 2 | 3): void {
this.updateState({ currentLevel: level });
}
/**
* Compute layout for current state
*/
computeLayout(): LayoutResult {
if (this.cachedLayout) return this.cachedLayout;
if (!this.hierarchy) {
return this.createEmptyLayout();
}
const nodes = new Map<string, NodeLayout>();
const connections: ConnectionLayout[] = [];
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
// Layout based on current level
const { currentLevel, expandedNodes } = this.state;
// Position macro nodes (Level 1)
let macroX = 0;
this.hierarchy.macroNodes.forEach((macroNode) => {
const macroDims = calculateLayerDimensions(macroNode);
const isExpanded = expandedNodes.has(macroNode.id) || currentLevel > 1;
if (currentLevel === 1 && !isExpanded) {
// Show macro as single block
const position: Position3D = {
x: macroX,
y: this.config.baseY,
z: 0,
};
nodes.set(macroNode.id, {
id: macroNode.id,
position,
dimensions: macroDims,
rotation: { x: 0, y: 0, z: 0 },
visible: true,
expanded: false,
});
this.updateBounds(position, macroDims);
macroX += macroDims.depth + this.config.macroSpacing;
} else {
// Expand macro to show stages/layers
const childLayout = this.layoutChildren(
macroNode,
{ x: macroX, y: this.config.baseY, z: 0 },
currentLevel,
expandedNodes
);
childLayout.forEach((layout, id) => {
nodes.set(id, layout);
this.updateBoundsFromLayout(layout);
});
// Calculate macro extent
let macroMaxX = macroX;
childLayout.forEach(layout => {
macroMaxX = Math.max(macroMaxX, layout.position.x + layout.dimensions.depth);
});
macroX = macroMaxX + this.config.macroSpacing;
}
});
// Layout connections
this.hierarchy.connections.forEach(conn => {
const sourceLayout = nodes.get(conn.source);
const targetLayout = nodes.get(conn.target);
if (sourceLayout && targetLayout && sourceLayout.visible && targetLayout.visible) {
connections.push(this.layoutConnection(conn, sourceLayout, targetLayout));
}
});
// Calculate bounds
nodes.forEach(layout => {
minX = Math.min(minX, layout.position.x - layout.dimensions.depth / 2);
maxX = Math.max(maxX, layout.position.x + layout.dimensions.depth / 2);
minY = Math.min(minY, layout.position.y - layout.dimensions.height / 2);
maxY = Math.max(maxY, layout.position.y + layout.dimensions.height / 2);
minZ = Math.min(minZ, layout.position.z - layout.dimensions.width / 2);
maxZ = Math.max(maxZ, layout.position.z + layout.dimensions.width / 2);
});
const bounds = {
min: { x: minX, y: minY, z: minZ },
max: { x: maxX, y: maxY, z: maxZ },
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
z: (minZ + maxZ) / 2,
},
};
// Calculate camera suggestion
const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
const cameraSuggestion = {
position: {
x: bounds.center.x - extent * 0.5,
y: bounds.center.y + extent * 0.5,
z: bounds.center.z + extent * 1.2,
},
target: bounds.center,
};
this.cachedLayout = { nodes, connections, bounds, cameraSuggestion };
return this.cachedLayout;
}
/**
* Layout children of a node
*/
private layoutChildren(
parentNode: HierarchyNode,
startPos: Position3D,
targetLevel: 1 | 2 | 3,
expandedNodes: Set<string>
): Map<string, NodeLayout> {
const layouts = new Map<string, NodeLayout>();
let currentX = startPos.x;
let currentY = startPos.y;
let blockIndex = 0;
parentNode.children.forEach((child) => {
const childDims = calculateLayerDimensions(child);
const isExpanded = expandedNodes.has(child.id) || (child.level < targetLevel);
// Calculate Z position based on channel size (creates depth effect)
const channelOffset = (child.channelSize || 64) * this.config.channelZScale;
if (child.level >= targetLevel || child.children.length === 0 || !isExpanded) {
// Render this node
const position: Position3D = {
x: currentX,
y: currentY + (child.blockIndex || 0) * this.config.blockSpacing,
z: startPos.z + channelOffset,
};
layouts.set(child.id, {
id: child.id,
position,
dimensions: childDims,
rotation: { x: 0, y: 0, z: 0 },
visible: true,
expanded: isExpanded,
});
currentX += childDims.depth + this.config.layerSpacing;
} else {
// Recursively layout children
const childLayouts = this.layoutChildren(
child,
{ x: currentX, y: currentY, z: startPos.z },
targetLevel,
expandedNodes
);
childLayouts.forEach((layout, id) => {
layouts.set(id, layout);
currentX = Math.max(currentX, layout.position.x + layout.dimensions.depth);
});
currentX += this.config.stageSpacing;
}
// Track block changes for Y positioning
if (child.blockIndex !== undefined && child.blockIndex !== blockIndex) {
blockIndex = child.blockIndex;
}
});
return layouts;
}
/**
* Layout a connection between two nodes
*/
private layoutConnection(
conn: HierarchyConnection,
source: NodeLayout,
target: NodeLayout
): ConnectionLayout {
// Source point: right side of source node
const sourcePos: Position3D = {
x: source.position.x + source.dimensions.depth / 2,
y: source.position.y,
z: source.position.z,
};
// Target point: left side of target node
const targetPos: Position3D = {
x: target.position.x - target.dimensions.depth / 2,
y: target.position.y,
z: target.position.z,
};
// Calculate control points for curves
const controlPoints: Position3D[] = [];
if (conn.isSkipConnection) {
// Arc above the main flow
const midX = (sourcePos.x + targetPos.x) / 2;
const arcHeight = Math.abs(target.position.x - source.position.x) * 0.3;
controlPoints.push({
x: midX,
y: Math.max(sourcePos.y, targetPos.y) + arcHeight,
z: (sourcePos.z + targetPos.z) / 2,
});
} else if (Math.abs(sourcePos.z - targetPos.z) > 0.5) {
// Z-axis curve for channel transitions
const midX = (sourcePos.x + targetPos.x) / 2;
controlPoints.push({
x: midX,
y: (sourcePos.y + targetPos.y) / 2,
z: (sourcePos.z + targetPos.z) / 2,
});
}
return {
id: conn.id,
sourcePosition: sourcePos,
targetPosition: targetPos,
controlPoints,
isSkipConnection: conn.isSkipConnection,
};
}
private updateBounds(_pos: Position3D, _dims: LayerDimensions): void {
// Helper for bound tracking - reserved for future use
}
private updateBoundsFromLayout(_layout: NodeLayout): void {
// Helper for bound tracking - reserved for future use
}
private createEmptyLayout(): LayoutResult {
return {
nodes: new Map(),
connections: [],
bounds: {
min: { x: 0, y: 0, z: 0 },
max: { x: 0, y: 0, z: 0 },
center: { x: 0, y: 0, z: 0 },
},
cameraSuggestion: {
position: { x: -10, y: 10, z: 20 },
target: { x: 0, y: 0, z: 0 },
},
};
}
}
// ============================================================================
// Standalone Layout Functions
// ============================================================================
/**
* Compute full layout for a model hierarchy
*/
export function computeFullLayout(
hierarchy: ModelHierarchy,
level: 1 | 2 | 3 = 3,
config: Partial<LayoutConfig> = {}
): LayoutResult {
const engine = new LayoutEngine(config);
engine.setHierarchy(hierarchy);
engine.setLevel(level);
return engine.computeLayout();
}
/**
* Compute layout for a specific node and its descendants
*/
export function computeSubLayout(
hierarchy: ModelHierarchy,
nodeId: string,
config: Partial<LayoutConfig> = {}
): LayoutResult {
const engine = new LayoutEngine(config);
engine.setHierarchy(hierarchy);
engine.updateState({
expandedNodes: new Set([nodeId]),
currentLevel: 3,
focusedNodeId: nodeId,
});
return engine.computeLayout();
}
/**
* Create a layout that focuses on data flow through the network
*/
export function computeFlowLayout(
hierarchy: ModelHierarchy,
config: Partial<LayoutConfig> = {}
): LayoutResult {
const mergedConfig: LayoutConfig = {
...DEFAULT_LAYOUT_CONFIG,
...config,
// Tighter spacing for flow visualization
layerSpacing: 0.8,
stageSpacing: 2.0,
};
const engine = new LayoutEngine(mergedConfig);
engine.setHierarchy(hierarchy);
engine.setLevel(3);
return engine.computeLayout();
}