// // system.js // // the main controller object for creating/modifying graphs // var ParticleSystem = function(repulsion, stiffness, friction, centerGravity, targetFps, dt, precision, integrator){ // also callable with ({integrator:, stiffness:, repulsion:, friction:, timestep:, fps:, dt:, gravity:}) var _changes=[] var _notification=null var _epoch = 0 var _screenSize = null var _screenStep = .04 var _screenPadding = [20,20,20,20] var _bounds = null var _boundsTarget = null if (typeof repulsion=='object'){ var _p = repulsion friction = _p.friction repulsion = _p.repulsion targetFps = _p.fps dt = _p.dt stiffness = _p.stiffness centerGravity = _p.gravity precision = _p.precision integrator = _p.integrator } // param validation and defaults if (integrator!='verlet' && integrator!='euler') integrator='verlet' friction = isNaN(friction) ? .5 : friction repulsion = isNaN(repulsion) ? 1000 : repulsion targetFps = isNaN(targetFps) ? 55 : targetFps stiffness = isNaN(stiffness) ? 600 : stiffness dt = isNaN(dt) ? 0.02 : dt precision = isNaN(precision) ? .6 : precision centerGravity = (centerGravity===true) var _systemTimeout = (targetFps!==undefined) ? 1000/targetFps : 1000/50 var _parameters = {integrator:integrator, repulsion:repulsion, stiffness:stiffness, friction:friction, dt:dt, gravity:centerGravity, precision:precision, timeout:_systemTimeout} var _energy var state = { renderer:null, // this is set by the library user tween:null, // gets filled in by the Kernel nodes:{}, // lookup based on node _id's from the worker edges:{}, // likewise adjacency:{}, // {name1:{name2:{}, name3:{}}} names:{}, // lookup table based on 'name' field in data objects kernel: null } var that={ parameters:function(newParams){ if (newParams!==undefined){ if (!isNaN(newParams.precision)){ newParams.precision = Math.max(0, Math.min(1, newParams.precision)) } $.each(_parameters, function(p, v){ if (newParams[p]!==undefined) _parameters[p] = newParams[p] }) state.kernel.physicsModified(newParams) } return _parameters }, fps:function(newFPS){ if (newFPS===undefined) return state.kernel.fps() else that.parameters({timeout:1000/(newFPS||50)}) }, start:function(){ state.kernel.start() }, stop:function(){ state.kernel.stop() }, addNode:function(name, data){ data = data || {} var priorNode = state.names[name] if (priorNode){ priorNode.data = data return priorNode }else if (name!=undefined){ // the data object has a few magic fields that are actually used // by the simulation: // 'mass' overrides the default of 1 // 'fixed' overrides the default of false // 'x' & 'y' will set a starting position rather than // defaulting to random placement var x = (data.x!=undefined) ? data.x : null var y = (data.y!=undefined) ? data.y : null var fixed = (data.fixed) ? 1 : 0 var node = new Node(data) node.name = name state.names[name] = node state.nodes[node._id] = node; _changes.push({t:"addNode", id:node._id, m:node.mass, x:x, y:y, f:fixed}); that._notify(); return node; } }, // remove a node and its associated edges from the graph pruneNode:function(nodeOrName) { var node = that.getNode(nodeOrName) if (typeof(state.nodes[node._id]) !== 'undefined'){ delete state.nodes[node._id] delete state.names[node.name] } $.each(state.edges, function(id, e){ if (e.source._id === node._id || e.target._id === node._id){ that.pruneEdge(e); } }) _changes.push({t:"dropNode", id:node._id}) that._notify(); }, getNode:function(nodeOrName){ if (nodeOrName._id!==undefined){ return nodeOrName }else if (typeof nodeOrName=='string' || typeof nodeOrName=='number'){ return state.names[nodeOrName] } // otherwise let it return undefined }, eachNode:function(callback){ // callback should accept two arguments: Node, Point $.each(state.nodes, function(id, n){ if (n._p.x==null || n._p.y==null) return var pt = (_screenSize!==null) ? that.toScreen(n._p) : n._p callback.call(that, n, pt); }) }, addEdge:function(source, target, data){ source = that.getNode(source) || that.addNode(source) target = that.getNode(target) || that.addNode(target) data = data || {} var edge = new Edge(source, target, data); var src = source._id var dst = target._id state.adjacency[src] = state.adjacency[src] || {} state.adjacency[src][dst] = state.adjacency[src][dst] || [] var exists = (state.adjacency[src][dst].length > 0) if (exists){ // probably shouldn't allow multiple edges in same direction // between same nodes? for now just overwriting the data... $.extend(state.adjacency[src][dst].data, edge.data) return }else{ state.edges[edge._id] = edge state.adjacency[src][dst].push(edge) var len = (edge.length!==undefined) ? edge.length : 1 _changes.push({t:"addSpring", id:edge._id, fm:src, to:dst, l:len}) that._notify() } return edge; }, // remove an edge and its associated lookup entries pruneEdge:function(edge) { _changes.push({t:"dropSpring", id:edge._id}) delete state.edges[edge._id] for (var x in state.adjacency){ for (var y in state.adjacency[x]){ var edges = state.adjacency[x][y]; for (var j=edges.length - 1; j>=0; j--) { if (state.adjacency[x][y][j]._id === edge._id){ state.adjacency[x][y].splice(j, 1); } } } } that._notify(); }, // find the edges from node1 to node2 getEdges:function(node1, node2) { node1 = that.getNode(node1) node2 = that.getNode(node2) if (!node1 || !node2) return [] if (typeof(state.adjacency[node1._id]) !== 'undefined' && typeof(state.adjacency[node1._id][node2._id]) !== 'undefined'){ return state.adjacency[node1._id][node2._id]; } return []; }, getEdgesFrom:function(node) { node = that.getNode(node) if (!node) return [] if (typeof(state.adjacency[node._id]) !== 'undefined'){ var nodeEdges = [] $.each(state.adjacency[node._id], function(id, subEdges){ nodeEdges = nodeEdges.concat(subEdges) }) return nodeEdges } return []; }, getEdgesTo:function(node) { node = that.getNode(node) if (!node) return [] var nodeEdges = [] $.each(state.edges, function(edgeId, edge){ if (edge.target == node) nodeEdges.push(edge) }) return nodeEdges; }, eachEdge:function(callback){ // callback should accept two arguments: Edge, Point $.each(state.edges, function(id, e){ var p1 = state.nodes[e.source._id]._p var p2 = state.nodes[e.target._id]._p if (p1.x==null || p2.x==null) return p1 = (_screenSize!==null) ? that.toScreen(p1) : p1 p2 = (_screenSize!==null) ? that.toScreen(p2) : p2 if (p1 && p2) callback.call(that, e, p1, p2); }) }, prune:function(callback){ // callback should be of the form ƒ(node, {from:[],to:[]}) var changes = {dropped:{nodes:[], edges:[]}} if (callback===undefined){ $.each(state.nodes, function(id, node){ changes.dropped.nodes.push(node) that.pruneNode(node) }) }else{ that.eachNode(function(node){ var drop = callback.call(that, node, {from:that.getEdgesFrom(node), to:that.getEdgesTo(node)}) if (drop){ changes.dropped.nodes.push(node) that.pruneNode(node) } }) } // trace('prune', changes.dropped) return changes }, graft:function(branch){ // branch is of the form: { nodes:{name1:{d}, name2:{d},...}, // edges:{fromNm:{toNm1:{d}, toNm2:{d}}, ...} } var changes = {added:{nodes:[], edges:[]}} if (branch.nodes) $.each(branch.nodes, function(name, nodeData){ var oldNode = that.getNode(name) // should probably merge any x/y/m data as well... // if (oldNode) $.extend(oldNode.data, nodeData) if (oldNode) oldNode.data = nodeData else changes.added.nodes.push( that.addNode(name, nodeData) ) state.kernel.start() }) if (branch.edges) $.each(branch.edges, function(src, dsts){ var srcNode = that.getNode(src) if (!srcNode) changes.added.nodes.push( that.addNode(src, {}) ) $.each(dsts, function(dst, edgeData){ // should probably merge any x/y/m data as well... // if (srcNode) $.extend(srcNode.data, nodeData) // i wonder if it should spawn any non-existant nodes that are part // of one of these edge requests... var dstNode = that.getNode(dst) if (!dstNode) changes.added.nodes.push( that.addNode(dst, {}) ) var oldEdges = that.getEdges(src, dst) if (oldEdges.length>0){ // trace("update",src,dst) oldEdges[0].data = edgeData }else{ // trace("new ->",src,dst) changes.added.edges.push( that.addEdge(src, dst, edgeData) ) } }) }) // trace('graft', changes.added) return changes }, merge:function(branch){ var changes = {added:{nodes:[], edges:[]}, dropped:{nodes:[], edges:[]}} $.each(state.edges, function(id, edge){ // if ((branch.edges[edge.source.name]===undefined || branch.edges[edge.source.name][edge.target.name]===undefined) && // (branch.edges[edge.target.name]===undefined || branch.edges[edge.target.name][edge.source.name]===undefined)){ if ((branch.edges[edge.source.name]===undefined || branch.edges[edge.source.name][edge.target.name]===undefined)){ that.pruneEdge(edge) changes.dropped.edges.push(edge) } }) var prune_changes = that.prune(function(node, edges){ if (branch.nodes[node.name] === undefined){ changes.dropped.nodes.push(node) return true } }) var graft_changes = that.graft(branch) changes.added.nodes = changes.added.nodes.concat(graft_changes.added.nodes) changes.added.edges = changes.added.edges.concat(graft_changes.added.edges) changes.dropped.nodes = changes.dropped.nodes.concat(prune_changes.dropped.nodes) changes.dropped.edges = changes.dropped.edges.concat(prune_changes.dropped.edges) // trace('changes', changes) return changes }, tweenNode:function(nodeOrName, dur, to){ var node = that.getNode(nodeOrName) if (node) state.tween.to(node, dur, to) }, tweenEdge:function(a,b,c,d){ if (d===undefined){ // called with (edge, dur, to) that._tweenEdge(a,b,c) }else{ // called with (node1, node2, dur, to) var edges = that.getEdges(a,b) $.each(edges, function(i, edge){ that._tweenEdge(edge, c, d) }) } }, _tweenEdge:function(edge, dur, to){ if (edge && edge._id!==undefined) state.tween.to(edge, dur, to) }, _updateGeometry:function(e){ if (e != undefined){ var stale = (e.epoch<_epoch) _energy = e.energy var pts = e.geometry // an array of the form [id1,x1,y1, id2,x2,y2, ...] if (pts!==undefined){ for (var i=0, j=pts.length/3; i1 || diff.y*_screenSize.height>1){ _bounds = _newBounds return true }else{ return false } }, energy:function(){ return _energy }, bounds:function(){ // TL -1 // -1 1 // 1 BR var bottomright = null var topleft = null // find the true x/y range of the nodes $.each(state.nodes, function(id, node){ if (!bottomright){ bottomright = new Point(node._p) topleft = new Point(node._p) return } var point = node._p if (point.x===null || point.y===null) return if (point.x > bottomright.x) bottomright.x = point.x; if (point.y > bottomright.y) bottomright.y = point.y; if (point.x < topleft.x) topleft.x = point.x; if (point.y < topleft.y) topleft.y = point.y; }) // return the true range then let to/fromScreen handle the padding if (bottomright && topleft){ return {bottomright: bottomright, topleft: topleft} }else{ return {topleft: new Point(-1,-1), bottomright: new Point(1,1)}; } }, // Find the nearest node to a particular position nearest:function(pos){ if (_screenSize!==null) pos = that.fromScreen(pos) // if screen size has been specified, presume pos is in screen pixel // units and convert it back to the particle system coordinates var min = {node: null, point: null, distance: null}; var t = that; $.each(state.nodes, function(id, node){ var pt = node._p if (pt.x===null || pt.y===null) return var distance = pt.subtract(pos).magnitude(); if (min.distance === null || distance < min.distance){ min = {node: node, point: pt, distance: distance}; if (_screenSize!==null) min.screenPoint = that.toScreen(pt) } }) if (min.node){ if (_screenSize!==null) min.distance = that.toScreen(min.node.p).subtract(that.toScreen(pos)).magnitude() return min }else{ return null } }, _notify:function() { // pass on graph changes to the physics object in the worker thread // (using a short timeout to batch changes) if (_notification===null) _epoch++ else clearTimeout(_notification) _notification = setTimeout(that._synchronize,20) // that._synchronize() }, _synchronize:function(){ if (_changes.length>0){ state.kernel.graphChanged(_changes) _changes = [] _notification = null } }, } state.kernel = Kernel(that) state.tween = state.kernel.tween || null // some magic attrs to make the Node objects phone-home their physics-relevant changes Node.prototype.__defineGetter__("p", function() { var self = this var roboPoint = {} roboPoint.__defineGetter__('x', function(){ return self._p.x; }) roboPoint.__defineSetter__('x', function(newX){ state.kernel.particleModified(self._id, {x:newX}) }) roboPoint.__defineGetter__('y', function(){ return self._p.y; }) roboPoint.__defineSetter__('y', function(newY){ state.kernel.particleModified(self._id, {y:newY}) }) roboPoint.__proto__ = Point.prototype return roboPoint }) Node.prototype.__defineSetter__("p", function(newP) { this._p.x = newP.x this._p.y = newP.y state.kernel.particleModified(this._id, {x:newP.x, y:newP.y}) }) Node.prototype.__defineGetter__("mass", function() { return this._mass; }); Node.prototype.__defineSetter__("mass", function(newM) { this._mass = newM state.kernel.particleModified(this._id, {m:newM}) }) Node.prototype.__defineSetter__("tempMass", function(newM) { state.kernel.particleModified(this._id, {_m:newM}) }) Node.prototype.__defineGetter__("fixed", function() { return this._fixed; }); Node.prototype.__defineSetter__("fixed", function(isFixed) { this._fixed = isFixed state.kernel.particleModified(this._id, {f:isFixed?1:0}) }) return that }