mgraph.forcelayout
Version:
Force directed graph drawing layout
1,219 lines (1,075 loc) • 40 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.mgraphCreateLayout = factory());
})(this, (function () { 'use strict';
// index.js
function eventify(subject) {
if (!subject) {
throw new Error('Eventify cannot use a falsy object as events subject');
}
for (const prop of ['on', 'off', 'fire']) {
if (Object.prototype.hasOwnProperty.call(subject, prop)) {
throw new Error(`Subject already has property '${prop}'`);
}
}
// Use a Map to store event listeners:
const events = new Map();
subject.on = function (eventName, callback, ctx) {
if (typeof callback !== 'function') {
throw new Error('Callback is expected to be a function');
}
if (!events.has(eventName)) {
events.set(eventName, []);
}
events.get(eventName).push({ callback, ctx });
return subject;
};
subject.off = function (eventName, callback) {
// Remove all events if eventName is undefined:
if (eventName === undefined) {
events.clear();
return subject;
}
if (events.has(eventName)) {
// Remove all handlers for this event if callback is not a function:
if (typeof callback !== 'function') {
events.delete(eventName);
} else {
const filtered = events.get(eventName).filter(
handler => handler.callback !== callback
);
if (filtered.length) {
events.set(eventName, filtered);
} else {
events.delete(eventName);
}
}
}
return subject;
};
subject.fire = function (eventName, ...args) {
const handlers = events.get(eventName);
if (handlers) {
for (const { callback, ctx } of handlers) {
callback.apply(ctx, args);
}
}
return subject;
};
return subject;
}
/**
* Augments `target` with properties in `options`. It does not override
* a target property if it already exists and its type matches the type in options.
* Performs a deep merge for nested objects.
*
* @param {Object} target - The target object. If falsy, a new object is created.
* @param {Object} options - The options object to merge into the target.
* @returns {Object} The merged target object.
*/
function merge(target, options) {
if (!target) {
target = {};
}
if (options) {
for (const key in options) {
if (Object.prototype.hasOwnProperty.call(options, key)) {
const targetHasIt = Object.prototype.hasOwnProperty.call(target, key);
const optionsValueType = typeof options[key];
const shouldReplace =
!targetHasIt || typeof target[key] !== optionsValueType;
if (shouldReplace) {
target[key] = options[key];
} else if (optionsValueType === 'object' && options[key] !== null) {
// Deep merge for nested objects.
target[key] = merge(target[key], options[key]);
}
}
}
}
return target;
}
// index.js
class Generator {
constructor(seed) {
this.seed = seed;
}
/**
* Generates a random double in [0, 1)
*/
nextDouble() {
// Robert Jenkins' 32-bit integer hash function
let seed = this.seed;
seed = ((seed + 0x7ed55d16) + (seed << 12)) >>> 0;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) >>> 0;
seed = ((seed + 0x165667b1) + (seed << 5)) >>> 0;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) >>> 0;
seed = ((seed + 0xfd7046c5) + (seed << 3)) >>> 0;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) >>> 0;
this.seed = seed;
return (seed & 0xfffffff) / 0x10000000;
}
/**
* Returns a random integer in [0, maxValue)
*/
next(maxValue) {
return Math.floor(this.nextDouble() * maxValue);
}
/**
* Alias for nextDouble()
*/
uniform() {
return this.nextDouble();
}
/**
* Returns a random number following a Gaussian distribution
* (mean = 0, standard deviation = 1)
*/
gaussian() {
let x, y, r;
do {
x = this.nextDouble() * 2 - 1;
y = this.nextDouble() * 2 - 1;
r = x * x + y * y;
} while (r >= 1 || r === 0);
return x * Math.sqrt(-2 * Math.log(r) / r);
}
/**
* Returns a random number following a Lévy distribution.
*/
levy() {
const beta = 3 / 2;
const sigma = Math.pow(
gamma(1 + beta) *
Math.sin((Math.PI * beta) / 2) /
(gamma((1 + beta) / 2) * beta * Math.pow(2, (beta - 1) / 2)),
1 / beta
);
return this.gaussian() * sigma / Math.pow(Math.abs(this.gaussian()), 1 / beta);
}
}
// Gamma function approximation.
function gamma(z) {
return Math.sqrt(2 * Math.PI / z) *
Math.pow((1 / Math.E) * (z + 1 / (12 * z - 1 / (10 * z))), z);
}
/**
* Creates a new seeded random number generator.
* @param {number} [inputSeed] - If not provided, current time is used.
*/
function random(inputSeed) {
const seed = typeof inputSeed === 'number' ? inputSeed : Date.now();
return new Generator(seed);
}
/**
* Creates an iterator over an array that visits each element in random order.
* The iterator provides:
* - forEach(callback): Iterates over items in random order.
* - shuffle(): Shuffles the array in place.
*
* @param {Array} array - The array to iterate over.
* @param {Object} [customRandom] - An optional seeded generator that implements next().
*/
function randomIterator(array, customRandom) {
const localRandom = customRandom || random();
if (typeof localRandom.next !== 'function') {
throw new Error("customRandom does not match expected API: next() function is missing");
}
return {
forEach(callback) {
const arr = array;
// Fisher–Yates shuffle while calling callback:
for (let i = arr.length - 1; i > 0; i--) {
const j = localRandom.next(i + 1);
const t = arr[j];
arr[j] = arr[i];
arr[i] = t;
callback(t);
}
if (arr.length) {
callback(arr[0]);
}
},
shuffle() {
const arr = array;
for (let i = arr.length - 1; i > 0; i--) {
const j = localRandom.next(i + 1);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
};
}
/*
* For backward compatibility, we want our default export
* to be callable as a function and also expose:
* random.random = random and random.randomIterator = randomIterator.
*/
const randomAPI = Object.assign(random, { random, randomIterator });
// lib/code-generators.js
/* eslint-disable no-new-func */
// -----------------------------------------------------------------------------
// Code‑generation helpers for mgraph.forcelayout
// All functions are ESM‑friendly and place helper declarations *before* they
// are referenced inside template literals to avoid ReferenceErrors.
// -----------------------------------------------------------------------------
/**
* Maps a zero‑based coordinate index to a variable name.
* 0 → x, 1 → y, 2 → z, 3 → c4, 4 → c5 … (historical convention)
*/
const getVariableName = (index) => {
if (!Number.isInteger(index) || index < 0)
throw new Error('Index must be a non‑negative integer');
switch (index) {
case 0: return 'x';
case 1: return 'y';
case 2: return 'z';
default: return `c${index + 1}`; // keep original +1 offset for backward compatibility
}
};
/**
* Returns a tiny template helper that repeats `template` once per dimension and
* substitutes `{var}` with the coordinate name. Options:
* • indent – spaces inserted before every line after the first (default 0)
* • join – string used to join the lines (default "\n")
* • escapeNewlines – if true, converts newlines to literal "\n" (for string
* literals embedded inside generated code)
*/
const createPatternBuilder = (dimension) => {
if (!Number.isInteger(dimension) || dimension <= 0)
throw new Error('Dimension must be a positive integer');
return (template, { indent = 0, join = '\n', escapeNewlines = false } = {}) => {
const pad = ' '.repeat(indent);
const lines = Array.from({ length: dimension }, (_, i) => {
const prefix = i === 0 ? '' : pad;
return prefix + template.replace(/{var}/g, getVariableName(i));
}).join(join);
return escapeNewlines ? lines.replace(/\n/g, '\\n') : lines;
};
};
// -----------------------------------------------------------------------------
// Bounds helper (computes bounding box & best new position)
// -----------------------------------------------------------------------------
const generateBoundsFunction = (dimension) =>
new Function('bodies', 'settings', 'random', generateBoundsFunctionBody(dimension));
const generateBoundsFunctionBody = (dimension) => {
const p = createPatternBuilder(dimension);
return `
const boundingBox = {
${p('min_{var}: 0, max_{var}: 0,', { indent: 4 })}
};
return {
box: boundingBox,
update: updateBoundingBox,
reset: resetBoundingBox,
getBestNewPosition(neighbors) {
let ${p('base_{var} = 0', { join: ', ' })};
if (neighbors.length) {
for (let i = 0; i < neighbors.length; ++i) {
const pos = neighbors[i].pos;
${p('base_{var} += pos.{var};', { indent: 10 })}
}
${p('base_{var} /= neighbors.length;', { indent: 8 })}
} else {
${p('base_{var} = (boundingBox.min_{var} + boundingBox.max_{var}) / 2;', { indent: 8 })}
}
const len = settings.springLength;
return {
${p('{var}: base_{var} + (random.nextDouble() - 0.5) * len,', { indent: 8 })}
};
}
};
function updateBoundingBox() {
if (!bodies.length) return;
${p('let min_{var} = Infinity;', { indent: 4 })}
${p('let max_{var} = -Infinity;', { indent: 4 })}
for (let i = 0, l = bodies.length; i < l; ++i) {
const pos = bodies[i].pos;
${p('if (pos.{var} < min_{var}) min_{var} = pos.{var};', { indent: 6 })}
${p('if (pos.{var} > max_{var}) max_{var} = pos.{var};', { indent: 6 })}
}
${p('boundingBox.min_{var} = min_{var};', { indent: 4 })}
${p('boundingBox.max_{var} = max_{var};', { indent: 4 })}
}
function resetBoundingBox() {
${p('boundingBox.min_{var} = boundingBox.max_{var} = 0;', { indent: 4 })}
}
`;
};
// -----------------------------------------------------------------------------
// Body & Vector generators
// -----------------------------------------------------------------------------
const generateCreateBodyFunction = (dimension, debugSetters = false) => {
const code = generateCreateBodyFunctionBody(dimension, debugSetters);
return new Function(code)().Body; // extract constructor
};
const generateCreateBodyFunctionBody = (dimension, debugSetters) => {
const p = createPatternBuilder(dimension);
// --- Vector ---------------------------------------------------------------
const vectorCode = (() => {
const setters = debugSetters ? Array.from({ length: dimension }, (_, i) => {
const v = getVariableName(i);
return `
let _${v};
Object.defineProperty(this, '${v}', {
get: () => _${v},
set: (val) => { if (!Number.isFinite(val)) throw new Error('Non‑finite ${v}'); _${v} = val; }
});`;
}).join('') : '';
return `function Vector(${p('{var}', { join: ', ' })}) {
${setters}
if (typeof arguments[0] === 'object') {
const src = arguments[0];
${p('this.{var} = src.{var};', { indent: 4 })}
} else {
${p('this.{var} = Number.isFinite({var}) ? {var} : 0;', { indent: 4 })}
}
}
Vector.prototype.reset = function () { ${p('this.{var} = 0;', { join: ' ' })} };`;
})();
// --- Body ----------------------------------------------------------------
const bodyCode = `function Body(${p('{var}', { join: ', ' })}) {
this.isPinned = false;
this.pos = new Vector(${p('{var}', { join: ', ' })});
this.force = new Vector();
this.velocity = new Vector();
this.mass = 1;
this.springCount = 0;
this.springLength = 0;
}
Body.prototype.reset = function () {
this.force.reset();
this.springCount = 0;
this.springLength = 0;
};
Body.prototype.setPosition = function(${p('{var}', { join: ', ' })}) {
${p('this.pos.{var} = Number.isFinite({var}) ? {var} : 0;', { indent: 2 })}
};`;
return `${vectorCode}\n${bodyCode}\nreturn { Body, Vector };`;
};
// -----------------------------------------------------------------------------
// Drag & Spring force generators
// -----------------------------------------------------------------------------
const generateCreateDragForceFunction = (dimension) =>
new Function('options', generateCreateDragForceFunctionBody(dimension));
const generateCreateDragForceFunctionBody = (dimension) => {
const p = createPatternBuilder(dimension);
return `
if (!Number.isFinite(options.dragCoefficient))
throw new Error('dragCoefficient must be finite');
return {
update(body) {
${p('body.force.{var} -= options.dragCoefficient * body.velocity.{var};', { indent: 6 })}
}
};`;
};
const generateCreateSpringForceFunction = (dimension) =>
new Function('options', 'random', generateCreateSpringForceFunctionBody(dimension));
const generateCreateSpringForceFunctionBody = (dimension) => {
const p = createPatternBuilder(dimension);
return `
if (!Number.isFinite(options.springCoefficient)) throw new Error('springCoefficient must be finite');
if (!Number.isFinite(options.springLength)) throw new Error('springLength must be finite');
return {
update(spring) {
const b1 = spring.from;
const b2 = spring.to;
const len = spring.length < 0 ? options.springLength : spring.length;
${p('let d{var} = b2.pos.{var} - b1.pos.{var};', { indent: 6 })}
let r = Math.hypot(${p('d{var}', { join: ', ' })});
if (r === 0) {
${p('d{var} = (random.nextDouble() - 0.5) / 50;', { indent: 8 })}
r = Math.hypot(${p('d{var}', { join: ', ' })});
}
const delta = r - len;
const k = (spring.coefficient > 0 ? spring.coefficient : options.springCoefficient) * delta / r;
${p('b1.force.{var} += k * d{var};', { indent: 6 })}
b1.springCount += 1;
b1.springLength += r;
${p('b2.force.{var} -= k * d{var};', { indent: 6 })}
b2.springCount += 1;
b2.springLength += r;
}
};`;
};
// -----------------------------------------------------------------------------
// Integrator generator (Euler with optional adaptive dt)
// -----------------------------------------------------------------------------
const generateIntegratorFunction = (dimension) =>
new Function('bodies', 'timeStep', 'adaptiveTimeStepWeight', generateIntegratorFunctionBody(dimension));
const generateIntegratorFunctionBody = (dimension) => {
const p = createPatternBuilder(dimension);
return `
const n = bodies.length;
if (!n) return 0;
${p('let d{var} = 0, t{var} = 0;', { indent: 2 })}
for (let i = 0; i < n; ++i) {
const body = bodies[i];
if (body.isPinned) continue;
let dt = timeStep;
if (adaptiveTimeStepWeight && body.springCount)
dt = adaptiveTimeStepWeight * body.springLength / body.springCount;
const coeff = dt / body.mass;
${p('body.velocity.{var} += coeff * body.force.{var};', { indent: 4 })}
${p('const v{var} = body.velocity.{var};', { indent: 4 })}
const v = Math.hypot(${p('v{var}', { join: ', ' })});
if (v > 1) {
const inv = 1 / v;
${p('body.velocity.{var} *= inv;', { indent: 6 })}
}
${p('d{var} = dt * body.velocity.{var};', { indent: 4 })}
${p('body.pos.{var} += d{var};', { indent: 4 })}
${p('t{var} += Math.abs(d{var});', { indent: 4 })}
}
return (${p('t{var} * t{var}', { join: ' + ' })}) / n;`;
};
// -----------------------------------------------------------------------------
// QuadTree generator (Barnes–Hut, k‑D generalisation)
// -----------------------------------------------------------------------------
const generateQuadTreeFunction = (dimension) =>
new Function(generateQuadTreeFunctionBody(dimension));
const generateQuadTreeFunctionBody = (dimension) => {
/* -------------------------------------------------------------------------
Helper utilities declared **before** string interpolation ↓
------------------------------------------------------------------------- */
const quadCount = 2 ** dimension;
const p = createPatternBuilder(dimension);
const assignQuads = (indent, count) => Array.from({ length: count }, (_, i) => `${indent}quad${i} = null;`).join('\n');
const assignInsertionQuadIndex = (indent) => {
const pad = ' '.repeat(indent);
return Array.from({ length: dimension }, (_, i) => {
const v = getVariableName(i);
return `${pad}if (${v} > max_${v}) {\n` +
`${pad} quadIdx += ${2 ** i};\n` +
`${pad} min_${v} = max_${v};\n` +
`${pad} max_${v} = node.max_${v};\n` +
`${pad}}`;
}).join('\n');
};
const runRecursiveOnChildren = () => {
const pad = ' '.repeat(11);
return Array.from({ length: quadCount }, (_, i) => `${pad}if (node.quad${i}) {\n` +
`${pad} queue[pushIdx++] = node.quad${i};\n` +
`${pad} queueLength++;\n` +
`${pad}}`).join('\n');
};
/* -------------------------------------------------------------------------
Now build the giant template string that produces the factory function
------------------------------------------------------------------------- */
return `
${getInsertStackCode()}
${getQuadNodeCode()}
${getUtilityFns()}
${getChildFns()}
function createQuadTree(options = {}, random) {
let gravity = typeof options.gravity === 'number' ? options.gravity : -1;
let theta = typeof options.theta === 'number' ? options.theta : 0.8;
const updateQueue = [];
const insertStack = new InsertStack();
const nodesCache = [];
let currentInCache = 0;
let root = newNode();
return { insertBodies, getRoot: () => root, updateBodyForce: update, options: opts };
function opts(newOpts) {
if (newOpts) {
if (typeof newOpts.gravity === 'number') gravity = newOpts.gravity;
if (typeof newOpts.theta === 'number') theta = newOpts.theta;
return this;
}
return { gravity, theta };
}
function newNode() {
let node = nodesCache[currentInCache];
if (node) {
${assignQuads(' node.', quadCount)}
node.body = null;
node.mass = ${p('node.mass_{var} = ', { join: '' })}0;
${p('node.min_{var} = node.max_{var} = 0;', { indent: 6 })}
} else {
node = new QuadNode();
nodesCache[currentInCache] = node;
}
currentInCache++;
return node;
}
${getUpdateFn()}
${getInsertBodiesFn()}
}
return createQuadTree;`;
// -------------------------------------------------------------------------
// Template sub‑sections (helpers inside the returned string)
// -------------------------------------------------------------------------
function getInsertStackCode() {
return `
function InsertStack() { this.stack = []; this.popIdx = 0; }
InsertStack.prototype = {
isEmpty() { return this.popIdx === 0; },
push(node, body) {
const item = this.stack[this.popIdx] || (this.stack[this.popIdx] = {});
item.node = node; item.body = body; this.popIdx++; },
pop() { return this.popIdx ? this.stack[--this.popIdx] : undefined; },
reset() { this.popIdx = 0; }
};`;
}
function getQuadNodeCode() {
return `
function QuadNode() {
this.body = null;
${assignQuads(' this.', quadCount)}
this.mass = 0;
${p('this.mass_{var} = 0;', { indent: 2 })}
${p('this.min_{var} = 0; this.max_{var} = 0;', { indent: 2, join: '\n ' })}
}`;
}
function getUtilityFns() {
return `
function isSamePosition(p1, p2) {
return ${p('Math.abs(p1.{var} - p2.{var}) < 1e-8', { join: ' && ' })};
}`;
}
function getChildFns() {
const getChild = `function getChild(node, idx) {
${Array.from({ length: quadCount }, (_, i) => ` if (idx === ${i}) return node.quad${i};`).join('\n')}
return null; }`;
const setChild = `function setChild(node, idx, child) {
${Array.from({ length: quadCount }, (_, i) => `${i ? ' else ' : ' '}if (idx === ${i}) node.quad${i} = child;`).join('\n')} }`;
return `${getChild}\n${setChild}`;
}
function getUpdateFn() {
return `
function update(sourceBody) {
const queue = updateQueue;
let queueLength = 1, shiftIdx = 0, pushIdx = 1;
queue[0] = root;
${p('let f{var} = 0;', { indent: 2 })}
while (queueLength) {
const node = queue[shiftIdx++];
queueLength--;
const body = node.body;
const different = body && body !== sourceBody;
${p('let d{var};', { indent: 4 })}
let r, v;
if (different) {
${p('d{var} = body.pos.{var} - sourceBody.pos.{var};', { indent: 6 })}
r = Math.hypot(${p('d{var}', { join: ', ' })});
if (r === 0) { ${p('d{var} = (Math.random() - 0.5) / 50;', { indent: 8 })} r = Math.hypot(${p('d{var}', { join: ', ' })}); }
v = gravity * body.mass * sourceBody.mass / (r ** 3);
${p('f{var} += v * d{var};', { indent: 6 })}
} else if (node.body !== sourceBody) {
${p('d{var} = node.mass_{var} / node.mass - sourceBody.pos.{var};', { indent: 6 })}
r = Math.hypot(${p('d{var}', { join: ', ' })});
if (r === 0) { ${p('d{var} = (Math.random() - 0.5) / 50;', { indent: 8 })} r = Math.hypot(${p('d{var}', { join: ', ' })}); }
if ((node.max_${getVariableName(0)} - node.min_${getVariableName(0)}) / r < theta) {
v = gravity * node.mass * sourceBody.mass / (r ** 3);
${p('f{var} += v * d{var};', { indent: 8 })}
} else {
${runRecursiveOnChildren()}
}
}
}
${p('sourceBody.force.{var} += f{var};', { indent: 2 })}
}`;
}
function getInsertBodiesFn() {
return `
function insertBodies(bodies) {
${p('let {var}min = Infinity, {var}max = -Infinity;', { indent: 2 })}
for (let i = 0; i < bodies.length; ++i) {
const pos = bodies[i].pos;
${p('if (pos.{var} < {var}min) {var}min = pos.{var};', { indent: 4 })}
${p('if (pos.{var} > {var}max) {var}max = pos.{var};', { indent: 4 })}
}
let maxSideLength = 0;
${p('if ({var}max - {var}min > maxSideLength) maxSideLength = {var}max - {var}min;', { indent: 2 })}
currentInCache = 0;
root = newNode();
${p('root.min_{var} = {var}min;', { indent: 2 })}
${p('root.max_{var} = {var}min + maxSideLength;', { indent: 2 })}
for (let i = bodies.length - 1; i >= 0; --i) insert(bodies[i], root);
}
function insert(newBody, startNode) {
insertStack.reset(); insertStack.push(startNode, newBody);
while (!insertStack.isEmpty()) {
const { node, body } = insertStack.pop();
if (!node.body) {
${p('const {var} = body.pos.{var};', { indent: 6 })}
node.mass += body.mass;
${p('node.mass_{var} += body.mass * {var};', { indent: 6 })}
let quadIdx = 0;
${p('let min_{var} = node.min_{var};', { indent: 6 })}
${p('let max_{var} = (min_{var} + node.max_{var}) / 2;', { indent: 6 })}
${assignInsertionQuadIndex(6)}
let child = getChild(node, quadIdx);
if (!child) {
child = newNode();
${p('child.min_{var} = min_{var};', { indent: 8 })}
${p('child.max_{var} = max_{var};', { indent: 8 })}
child.body = body;
setChild(node, quadIdx, child);
} else {
insertStack.push(child, body);
}
} else {
const oldBody = node.body;
node.body = null;
if (isSamePosition(oldBody.pos, body.pos)) {
for (let retries = 3; retries-- && isSamePosition(oldBody.pos, body.pos);) {
const off = Math.random();
${p('const d{var} = (node.max_{var} - node.min_{var}) * off;', { indent: 10 })}
${p('oldBody.pos.{var} = node.min_{var} + d{var};', { indent: 10 })}
}
}
insertStack.push(node, oldBody);
insertStack.push(node, body);
}
}
}`;
}
};
// lib/createPhysicsSimulator.js
const dimensionalCache = {};
function createPhysicsSimulator(settings) {
if (settings) {
if (settings.springCoeff !== undefined) throw new Error('springCoeff was renamed to springCoefficient');
if (settings.dragCoeff !== undefined) throw new Error('dragCoeff was renamed to dragCoefficient');
}
settings = merge(settings, {
springLength: 10,
springCoefficient: 0.8,
gravity: -12,
theta: 0.8,
dragCoefficient: 0.9,
timeStep: 0.5,
adaptiveTimeStepWeight: 0,
dimensions: 2,
debug: false
});
let factory = dimensionalCache[settings.dimensions];
if (!factory) {
const dimensions = settings.dimensions;
factory = {
Body: generateCreateBodyFunction(dimensions, settings.debug),
createQuadTree: generateQuadTreeFunction(dimensions),
createBounds: generateBoundsFunction(dimensions),
createDragForce: generateCreateDragForceFunction(dimensions),
createSpringForce: generateCreateSpringForceFunction(dimensions),
integrate: generateIntegratorFunction(dimensions),
};
dimensionalCache[dimensions] = factory;
}
const Body = factory.Body;
const createQuadTree = factory.createQuadTree;
const createBounds = factory.createBounds;
const createDragForce = factory.createDragForce;
const createSpringForce = factory.createSpringForce;
const integrate = factory.integrate;
const rng = randomAPI.random(42);
const bodies = [];
const springs = [];
// Fix: Call the factory function to get the actual QuadTree constructor
const QuadTreeConstructor = createQuadTree();
const quadTree = QuadTreeConstructor(settings, rng);
const bounds = createBounds(bodies, settings, rng);
const springForce = createSpringForce(settings, rng);
const dragForce = createDragForce(settings);
const forces = [];
const forceMap = new Map();
let iterationNumber = 0;
addForce('nbody', nbodyForce);
addForce('spring', updateSpringForce);
const publicApi = {
bodies,
quadTree,
springs,
settings,
addForce,
removeForce,
getForces,
step() {
for (let i = 0; i < forces.length; ++i) {
forces[i](iterationNumber);
}
const movement = integrate(bodies, settings.timeStep, settings.adaptiveTimeStepWeight);
iterationNumber += 1;
return movement;
},
addBody(body) {
if (!body) throw new Error('Body is required');
bodies.push(body);
return body;
},
addBodyAt(pos) {
if (!pos) throw new Error('Body position is required');
const body = createBody(pos);
bodies.push(body);
return body;
},
removeBody(body) {
if (!body) return;
const idx = bodies.indexOf(body);
if (idx < 0) return;
bodies.splice(idx, 1);
if (bodies.length === 0) {
bounds.reset();
}
return true;
},
addSpring(body1, body2, springLength, springCoefficient) {
if (!body1 || !body2) {
throw new Error('Cannot add null spring to force simulator');
}
if (typeof springLength !== 'number') {
springLength = -1;
}
const spring = new Spring(body1, body2, springLength, springCoefficient >= 0 ? springCoefficient : -1);
springs.push(spring);
return spring;
},
getTotalMovement() {
return integrate.totalMovement || 0;
},
removeSpring(spring) {
if (!spring) return;
const idx = springs.indexOf(spring);
if (idx > -1) {
springs.splice(idx, 1);
return true;
}
},
getBestNewBodyPosition(neighbors) {
return bounds.getBestNewPosition(neighbors);
},
getBBox: getBoundingBox,
getBoundingBox: getBoundingBox,
invalidateBBox() {
console.warn('invalidateBBox() is deprecated, bounds always recomputed on `getBBox()` call');
},
gravity(value) {
if (value !== undefined) {
settings.gravity = value;
quadTree.options({gravity: value});
return this;
} else {
return settings.gravity;
}
},
theta(value) {
if (value !== undefined) {
settings.theta = value;
quadTree.options({theta: value});
return this;
} else {
return settings.theta;
}
},
random: rng
};
// Helper function to create bodies
function createBody(pos) {
return new Body(pos);
}
expose(settings, publicApi);
eventify(publicApi);
return publicApi;
function getBoundingBox() {
bounds.update();
return bounds.box;
}
function addForce(forceName, forceFunction) {
if (forceMap.has(forceName)) throw new Error('Force ' + forceName + ' is already added');
forceMap.set(forceName, forceFunction);
forces.push(forceFunction);
}
function removeForce(forceName) {
const forceIndex = forces.indexOf(forceMap.get(forceName));
if (forceIndex < 0) return;
forces.splice(forceIndex, 1);
forceMap.delete(forceName);
}
function getForces() {
return forceMap;
}
function nbodyForce() {
if (bodies.length === 0) return;
quadTree.insertBodies(bodies);
let i = bodies.length;
while (i--) {
const body = bodies[i];
if (!body.isPinned) {
body.reset();
quadTree.updateBodyForce(body);
dragForce.update(body);
}
}
}
function updateSpringForce() {
let i = springs.length;
while (i--) {
springForce.update(springs[i]);
}
}
}
// Simple Spring class
class Spring {
constructor(fromBody, toBody, length, springCoefficient) {
this.from = fromBody;
this.to = toBody;
this.length = length;
this.coefficient = springCoefficient;
}
}
function expose(settings, target) {
for (const key in settings) {
augment(settings, target, key);
}
}
function augment(source, target, key) {
if (!source.hasOwnProperty(key)) return;
if (typeof target[key] === 'function') {
return;
}
const sourceIsNumber = Number.isFinite(source[key]);
if (sourceIsNumber) {
target[key] = function (value) {
if (value !== undefined) {
if (!Number.isFinite(value)) throw new Error('Value of ' + key + ' should be a valid number.');
source[key] = value;
return target;
}
return source[key];
};
} else {
target[key] = function (value) {
if (value !== undefined) {
source[key] = value;
return target;
}
return source[key];
};
}
}
// index.js
const noop = () => {};
/**
* Creates a force-based layout for a given graph.
*/
function createLayout(graph, physicsSettings = {}) {
if (!graph) {
throw new Error('Graph structure cannot be undefined');
}
if (Array.isArray(physicsSettings)) {
throw new Error('Physics settings is expected to be an object');
}
const createSimulator = physicsSettings.createSimulator || createPhysicsSimulator;
const physicsSimulator = createSimulator(physicsSettings);
const nodeBodies = new Map();
const springs = {};
let bodiesCount = 0;
const springTransform = physicsSimulator.settings.springTransform || noop;
initPhysics();
listenToEvents();
let wasStable = false;
function onStableChanged(isStable) {
api.fire('stable', isStable);
}
const updateStableStatus = (isStableNow) => {
if (wasStable !== isStableNow) {
wasStable = isStableNow;
onStableChanged(isStableNow);
}
};
function forEachBody(cb) {
nodeBodies.forEach(cb);
}
function getForceVectorLength() {
let fx = 0, fy = 0;
forEachBody((body) => {
fx += Math.abs(body.force.x);
fy += Math.abs(body.force.y);
});
return Math.hypot(fx, fy);
}
function getSpring(fromId, toId) {
let linkId;
if (toId === undefined) {
linkId = typeof fromId !== 'object' ? fromId : fromId.id;
} else {
const link = graph.hasLink(fromId, toId);
if (!link) return;
linkId = link.id;
}
return springs[linkId];
}
const api = {
step() {
if (bodiesCount === 0) {
updateStableStatus(true);
return true;
}
const lastMove = physicsSimulator.step();
api.lastMove = lastMove;
api.fire('step');
const ratio = lastMove / bodiesCount;
const isStableNow = ratio <= 0.01;
updateStableStatus(isStableNow);
return isStableNow;
},
getNodePosition(nodeId) {
return getInitializedBody(nodeId).pos;
},
setNodePosition(nodeId, ...args) {
const body = getInitializedBody(nodeId);
body.setPosition(...args);
},
getLinkPosition(linkId) {
const spring = springs[linkId];
if (spring) {
return { from: spring.from.pos, to: spring.to.pos };
}
},
getGraphRect() {
return physicsSimulator.getBBox();
},
forEachBody,
pinNode(node, isPinned) {
const body = getInitializedBody(node.id);
body.isPinned = Boolean(isPinned);
},
isNodePinned(node) {
return getInitializedBody(node.id).isPinned;
},
dispose() {
graph.off('changed', onGraphChanged);
api.fire('disposed');
},
getBody(nodeId) {
return nodeBodies.get(nodeId);
},
getSpring,
getForceVectorLength,
simulator: physicsSimulator,
graph,
lastMove: 0,
};
eventify(api);
function listenToEvents() {
graph.on('changed', onGraphChanged);
}
function onGraphChanged(changes) {
changes.forEach((change) => {
if (change.changeType === 'add') {
if (change.node) {
initBody(change.node.id);
}
if (change.link) {
initLink(change.link);
}
} else if (change.changeType === 'remove') {
if (change.node) {
releaseNode(change.node);
}
if (change.link) {
releaseLink(change.link);
}
}
});
bodiesCount = graph.getNodesCount();
}
function initPhysics() {
bodiesCount = 0;
graph.forEachNode((node) => {
initBody(node.id);
bodiesCount++;
});
graph.forEachLink(initLink);
}
function initBody(nodeId) {
if (nodeBodies.has(nodeId)) return;
const node = graph.getNode(nodeId);
if (!node) {
throw new Error(`initBody() was called with unknown node id: ${nodeId}`);
}
let pos = node.position;
if (!pos) {
const neighbors = getNeighborBodies(node);
pos = physicsSimulator.getBestNewBodyPosition(neighbors);
}
const body = physicsSimulator.addBodyAt(pos);
body.id = nodeId;
nodeBodies.set(nodeId, body);
updateBodyMass(nodeId);
if (isNodeOriginallyPinned(node)) {
body.isPinned = true;
}
}
function releaseNode(node) {
const nodeId = node.id;
const body = nodeBodies.get(nodeId);
if (body) {
nodeBodies.delete(nodeId);
physicsSimulator.removeBody(body);
}
}
function initLink(link) {
updateBodyMass(link.fromId);
updateBodyMass(link.toId);
const fromBody = nodeBodies.get(link.fromId);
const toBody = nodeBodies.get(link.toId);
if (!fromBody || !toBody) return; // Safety check
const spring = physicsSimulator.addSpring(fromBody, toBody, link.length);
springTransform(link, spring);
springs[link.id] = spring;
}
function releaseLink(link) {
const spring = springs[link.id];
if (spring) {
const from = graph.getNode(link.fromId);
const to = graph.getNode(link.toId);
if (from) updateBodyMass(from.id);
if (to) updateBodyMass(to.id);
delete springs[link.id];
physicsSimulator.removeSpring(spring);
}
}
function getNeighborBodies(node) {
const links = graph.getLinks(node.id);
if (!links) return [];
const linksArray = Array.from(links);
return linksArray.reduce((neighbors, link) => {
const otherBody = link.fromId !== node.id
? nodeBodies.get(link.fromId)
: nodeBodies.get(link.toId);
if (otherBody && otherBody.pos) {
neighbors.push(otherBody);
}
return neighbors;
}, []);
}
function updateBodyMass(nodeId) {
const body = nodeBodies.get(nodeId);
if (!body) return; // Safety check
const links = graph.getLinks(nodeId);
const linkCount = links ? Array.from(links).length : 0;
const mass = 1 + linkCount / 3.0;
if (Number.isNaN(mass)) {
throw new Error('Node mass should be a number');
}
body.mass = mass;
}
function isNodeOriginallyPinned(node) {
return node?.isPinned || (node?.data && node.data.isPinned);
}
function getInitializedBody(nodeId) {
if (!nodeBodies.has(nodeId)) {
initBody(nodeId);
}
return nodeBodies.get(nodeId);
}
return api;
}
// Export the simulator for compatibility
createLayout.simulator = createPhysicsSimulator;
return createLayout;
}));
//# sourceMappingURL=mgraph.forcelayout.umd.js.map