ngraph.svg
Version:
SVG-based graph visualization library with adaptive rendering
464 lines (411 loc) • 14.2 kB
JavaScript
/**
* ForceLayoutAdapter wraps ngraph.forcelayout for use with ngraph.svg.
* Provides animation loop integration and position updates.
*
* Supports size-aware layout: when getNodeSize is provided (or node sizes
* are stored in graph.getNode(id).data.size), the layout will automatically:
* - Increase mass for larger nodes (stronger repulsion)
* - Enforce minimum spring lengths based on node radii
*
* Supports layered orchestration (enabled by default): peels degree-1 leaves
* to find the structural core, lays out core first, then unpins layers outward,
* finishing with stress refinement on stretched edges.
*/
import {
PHASE_STRESS_DETECT,
PHASE_STRESS_REFINE,
PHASE_DONE,
} from './forceLayoutAdapter.constants.js';
import initMethods from './forceLayoutAdapter.init.js';
import componentMethods from './forceLayoutAdapter.component.js';
import orchestrationMethods from './forceLayoutAdapter.orchestration.js';
import positionMethods from './forceLayoutAdapter.positions.js';
export default class ForceLayoutAdapter {
constructor(graph, options = {}) {
this._graph = graph;
this._options = options;
this._layout = null;
this._running = false;
this._rafId = null;
this._onUpdate = null;
this._nodePositions = new Map();
this._smoothing = options.smoothing ?? 0.15;
this._maxSpeed = options.maxSpeed ?? 50;
this._lastFrameTime = 0;
this._energyThreshold = options.energyThreshold ?? 0.003;
this._onStabilized = options.onStabilized || null;
this._stabilized = false;
this._stabilizedFired = false;
this._stableFrameCount = 0;
this._stableFramesRequired = options.stableFramesRequired ?? 10;
this._componentStableFramesRequired = options.componentStableFramesRequired ?? this._stableFramesRequired;
this._layeredLayout = options.layeredLayout ?? true;
this._stressThreshold = options.stressThreshold ?? 0.3;
this._maxStressIterations = options.maxStressIterations ?? 200;
this._componentLayout = options.componentLayout ?? true;
this._motifLayout = options.motifLayout ?? true;
this._motifMaxSize = options.motifMaxSize ?? 64;
this._componentPackingPadding = options.componentPackingPadding ?? 80;
this._componentPackingSmoothing = options.componentPackingSmoothing ?? 0.16;
this._componentRepackInterval = options.componentRepackInterval ?? 12;
this._componentContexts = [];
this._componentPackInput = [];
this._nodeToComponentContext = new Map();
this._componentLayoutProxy = null;
this._componentPacked = false;
this._componentFrameCounter = 0;
this._phase = null;
this._layerMap = null;
this._maxLayer = 0;
this._currentSettleLayer = 0;
this._stressPassCount = 0;
this._stressIterCount = 0;
this._graphChangeListener = null;
this._hiddenNodes = new Set();
this._scratchPosition = { x: 0, y: 0 };
this._pinOverrides = new Map();
this._initPromise = this._initLayout(options);
this._graphGrowthListener = (changes) => {
let hasNewNodes = false;
let hasStructuralChanges = false;
let hasPotentialNewComponent = false;
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
if (change.node || change.link) {
hasStructuralChanges = true;
}
if (change.changeType === 'add' && (change.node || change.link)) {
hasNewNodes = true;
}
if (change.changeType === 'add' && change.node) {
const links = this._graph.getLinks(change.node.id);
const linkCount = links ? (links.size ?? links.length ?? 0) : 0;
if (linkCount === 0) {
hasPotentialNewComponent = true;
}
}
if (change.changeType === 'remove' && change.node) {
this._pinOverrides.delete(change.node.id);
}
}
const needsComponentReinit = this._componentLayout && (
this._isComponentMode() || hasPotentialNewComponent
);
if (hasStructuralChanges && needsComponentReinit) {
this._phase = null;
this._componentPacked = false;
this._initPromise = this._initLayout(this._options);
}
if (hasNewNodes && this._stabilized && !this._running) {
this._stabilized = false;
this._stabilizedFired = false;
this._stableFrameCount = 0;
this.start();
} else if (hasNewNodes && !this._stabilized) {
this._stableFrameCount = 0;
}
};
this._graph.on('changed', this._graphGrowthListener);
}
async getLayout() {
await this._initPromise;
return this._isComponentMode() ? this._componentLayoutProxy : this._layout;
}
onUpdate(callback) {
this._onUpdate = callback;
return this;
}
async start() {
await this._initPromise;
if (this._running) return;
this._running = true;
this._stabilized = false;
this._stabilizedFired = false;
this._stableFrameCount = 0;
this._lastFrameTime = 0;
if (!this._isComponentMode() && this._layeredLayout && this._phase === null) {
this._initOrchestration();
}
const animate = (currentTime) => {
if (!this._running) return;
const dt = this._lastFrameTime ? (currentTime - this._lastFrameTime) : 16.67;
this._lastFrameTime = currentTime;
if (this._isComponentMode()) {
this._stepComponentLayouts();
this._componentFrameCounter++;
if (this._componentFrameCounter % this._componentRepackInterval === 0) {
this._packComponentContexts(false);
}
} else {
this._layout.step();
}
if (this._isComponentMode()) {
this._updateComponentOffsets(dt);
}
this._updateSmoothedPositions(dt);
if (!this._isComponentMode() && this._layeredLayout && this._phase !== null && this._phase !== PHASE_DONE) {
if (!this._stabilized) {
if (this._checkPhaseConvergence()) {
this._stableFrameCount++;
if (this._stableFrameCount >= this._stableFramesRequired) {
const shouldContinue = this._advancePhase();
this._stableFrameCount = 0;
if (!shouldContinue) {
this._stabilized = true;
this._updatePositions();
if (this._onUpdate) this._onUpdate(this._nodePositions);
this.stop();
if (this._onStabilized && !this._stabilizedFired) {
this._stabilizedFired = true;
this._onStabilized();
}
return;
}
}
} else {
this._stableFrameCount = 0;
}
}
if (this._phase === PHASE_STRESS_REFINE) {
this._stressIterCount++;
if (this._stressIterCount >= this._maxStressIterations) {
this._unpinAll();
this._phase = PHASE_STRESS_DETECT;
this._stableFrameCount = 0;
}
}
} else {
if (!this._stabilized && this._checkConvergence()) {
this._stableFrameCount++;
if (this._stableFrameCount >= this._stableFramesRequired) {
if (this._isComponentMode() && !this._componentPacked) {
this._packComponentContexts(true);
this._componentPacked = true;
}
this._stabilized = true;
this._updatePositions();
if (this._onUpdate) this._onUpdate(this._nodePositions);
this.stop();
if (this._onStabilized && !this._stabilizedFired) {
this._stabilizedFired = true;
this._onStabilized();
}
return;
}
} else if (!this._stabilized) {
this._stableFrameCount = 0;
}
}
if (this._onUpdate) {
this._onUpdate(this._nodePositions);
}
this._rafId = requestAnimationFrame(animate);
};
this._rafId = requestAnimationFrame(animate);
return this;
}
stop() {
this._running = false;
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
return this;
}
async step() {
await this._initPromise;
if (this._isComponentMode()) {
this._stepComponentLayouts();
this._componentFrameCounter++;
if (this._componentFrameCounter % this._componentRepackInterval === 0) {
this._packComponentContexts(false);
}
this._updateComponentOffsets(16.67);
} else {
this._layout.step();
}
this._updatePositions();
return this._nodePositions;
}
async stabilize(maxIterations = 100) {
await this._initPromise;
for (let i = 0; i < maxIterations; i++) {
if (this._isComponentMode()) {
this._stepComponentLayouts();
this._componentFrameCounter++;
if (this._componentFrameCounter % this._componentRepackInterval === 0) {
this._packComponentContexts(false);
}
this._updateComponentOffsets(16.67);
} else {
this._layout.step();
}
if (this._checkConvergence()) break;
}
if (this._isComponentMode() && !this._componentPacked) {
this._packComponentContexts(true);
this._componentPacked = true;
}
this._updatePositions();
return this._nodePositions;
}
async getNodePosition(nodeId) {
await this._initPromise;
this._readNodeTargetPosition(nodeId, this._scratchPosition);
return { x: this._scratchPosition.x, y: this._scratchPosition.y };
}
async setNodePosition(nodeId, x, y) {
await this._initPromise;
if (this._isComponentMode()) {
const context = this._nodeToComponentContext.get(nodeId);
if (!context) return this;
if (context.layout) {
const pos = context.layout.getNodePosition(nodeId);
pos.x = x - context.offsetX;
pos.y = y - context.offsetY;
context.isSleeping = false;
context.stableFrames = 0;
context.boundsDirty = true;
} else {
const pos = context.motifPositions.get(nodeId);
if (pos) {
pos.x = x - context.offsetX;
pos.y = y - context.offsetY;
context.boundsDirty = true;
}
}
const cached = this._nodePositions.get(nodeId);
if (cached) {
cached.x = x;
cached.y = y;
} else {
this._nodePositions.set(nodeId, { x, y });
}
return this;
}
const pos = this._layout.getNodePosition(nodeId);
pos.x = x;
pos.y = y;
const cached = this._nodePositions.get(nodeId);
if (cached) {
cached.x = x;
cached.y = y;
} else {
this._nodePositions.set(nodeId, { x, y });
}
return this;
}
async pinNode(nodeId) {
await this._initPromise;
this._pinOverrides.set(nodeId, true);
const body = this._isComponentMode()
? this._componentLayoutProxy.getBody(nodeId)
: this._layout.getBody(nodeId);
if (body) {
body.isPinned = true;
}
return this;
}
async unpinNode(nodeId) {
await this._initPromise;
this._pinOverrides.set(nodeId, false);
const body = this._isComponentMode()
? this._componentLayoutProxy.getBody(nodeId)
: this._layout.getBody(nodeId);
if (body) {
body.isPinned = false;
if (this._isComponentMode()) {
const context = this._nodeToComponentContext.get(nodeId);
if (context && context.layout) {
context.isSleeping = false;
context.stableFrames = 0;
}
}
}
return this;
}
isRunning() {
return this._running;
}
getPositions() {
return this._nodePositions;
}
async getBounds() {
await this._initPromise;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
this._graph.forEachNode((node) => {
if (this._hiddenNodes.has(node.id)) return;
this._readNodeTargetPosition(node.id, this._scratchPosition);
minX = Math.min(minX, this._scratchPosition.x);
minY = Math.min(minY, this._scratchPosition.y);
maxX = Math.max(maxX, this._scratchPosition.x);
maxY = Math.max(maxY, this._scratchPosition.y);
});
if (minX > maxX) {
return { left: -100, top: -100, right: 100, bottom: 100, width: 200, height: 200 };
}
const width = maxX - minX;
const height = maxY - minY;
if (width < 1 || height < 1) {
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { left: cx - 50, top: cy - 50, right: cx + 50, bottom: cy + 50, width: 100, height: 100 };
}
return { left: minX, top: minY, right: maxX, bottom: maxY, width, height };
}
dispose() {
this.stop();
if (this._graphChangeListener && this._graph) {
this._graph.off('changed', this._graphChangeListener);
this._graphChangeListener = null;
}
if (this._graphGrowthListener && this._graph) {
this._graph.off('changed', this._graphGrowthListener);
this._graphGrowthListener = null;
}
this._layout = null;
this._componentContexts.length = 0;
this._componentPackInput.length = 0;
this._componentLayoutProxy = null;
this._nodeToComponentContext.clear();
this._pinOverrides.clear();
this._graph = null;
this._nodePositions.clear();
this._onUpdate = null;
}
isStabilized() {
return this._stabilized;
}
async getEnergy() {
await this._initPromise;
if (this._isComponentMode()) {
let totalEnergy = 0;
for (let i = 0; i < this._componentContexts.length; ++i) {
const context = this._componentContexts[i];
if (!context.layout) continue;
if (context.isSleeping) continue;
totalEnergy += context.lastEnergy;
}
return totalEnergy;
}
let energy = 0;
this._graph.forEachNode((node) => {
const body = this._layout.getBody(node.id);
if (body && body.velocity) {
energy += body.velocity.x * body.velocity.x +
body.velocity.y * body.velocity.y;
}
});
return energy;
}
}
Object.assign(
ForceLayoutAdapter.prototype,
initMethods,
componentMethods,
orchestrationMethods,
positionMethods,
);