UNPKG

mgraph.forcelayout

Version:
1,219 lines (1,075 loc) 40 kB
(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