├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github └── stale.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── bower.json ├── cytoscape-automove.js ├── demo-multiple-mean.html ├── demo.html ├── package-lock.json ├── package.json ├── pages ├── cytoscape-automove.js ├── demo-multiple-mean.html └── demo.html ├── src ├── assign.js ├── automove.js ├── defaults.js └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "node": true, 6 | "amd": true, 7 | "es6": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "rules": { 11 | "semi": "error" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 30 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | # Label to use when marking an issue as stale 9 | staleLabel: stale 10 | # Comment to post when marking an issue as stale. Set to `false` to disable 11 | markComment: > 12 | This issue has been automatically marked as stale, because it has not had 13 | activity within the past 30 days. It will be closed if no further activity 14 | occurs within the next 30 days. If a feature request is important to you, 15 | please consider making a pull request. Thank you for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | index.html 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cytoscape/cytoscape.js-automove/e77bcff384d0ce3fe7b77a084b4540295a47a4f8/.npmrc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Copyright (c) 2016-2019, 2022, The Cytoscape Consortium. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cytoscape-automove 2 | ================================================================================ 3 | 4 | 5 | ## Description 6 | 7 | An extension for Cytoscape.js that automatically updates node positions based on specified rules ([demo](https://cytoscape.github.io/cytoscape.js-automove)) 8 | 9 | Common usecases: 10 | 11 | * Making one node move in step with another node 12 | * Constraining a node within a boundary 13 | * Using a node to represent an n-ary interaction 14 | 15 | 16 | ## Dependencies 17 | 18 | * Cytoscape.js ^3.2.0 19 | 20 | 21 | ## Usage instructions 22 | 23 | Download the library: 24 | * via npm: `npm install cytoscape-automove`, 25 | * via bower: `bower install cytoscape-automove`, or 26 | * via direct download in the repository (probably from a tag). 27 | 28 | Import the library as appropriate for your project: 29 | 30 | ES import: 31 | 32 | ```js 33 | import cytoscape from 'cytoscape'; 34 | import automove from 'cytoscape-automove'; 35 | 36 | cytoscape.use( automove ); 37 | ``` 38 | 39 | CommonJS require: 40 | 41 | ```js 42 | let cytoscape = require('cytoscape'); 43 | let automove = require('cytoscape-automove'); 44 | 45 | cytoscape.use( automove ); // register extension 46 | ``` 47 | 48 | AMD: 49 | 50 | ```js 51 | require(['cytoscape', 'cytoscape-automove'], function( cytoscape, automove ){ 52 | automove( cytoscape ); // register extension 53 | }); 54 | ``` 55 | 56 | Plain HTML/JS has the extension registered for you automatically, because no `require()` is needed. 57 | 58 | 59 | ## API 60 | 61 | Each time `cy.automove()` is called, the specified rules are added to the core instance: 62 | 63 | ```js 64 | let defaults = { 65 | // specify nodes that should be automoved with one of 66 | // - a function that returns true for matching nodes 67 | // - a selector that matches the nodes 68 | // - a collection of nodes (very good for performance) 69 | nodesMatching: function( node ){ return false; }, 70 | 71 | // specify how a node's position should be updated with one of 72 | // - function( node ){ return { x: 1, y: 2 }; } => put the node where the function returns 73 | // - { x1, y1, x2, y2 } => constrain the node position within the bounding box (in model co-ordinates) 74 | // - { x1, y1, x2, y2, type: 'inside' } => constrain the node position within the bounding box (in model co-ordinates) 75 | // - { x1, y1, x2, y2, type: 'outside' } => constrain the node position outside the bounding box (in model co-ordinates) 76 | // - 'mean' => put the node in the average position of its neighbourhood 77 | // - 'viewport' => keeps the node body within the viewport 78 | // - 'drag' => matching nodes are effectively dragged along 79 | reposition: 'mean', 80 | 81 | // specify when the repositioning should occur by specifying a function that 82 | // calls update() when reposition updates should occur 83 | // - function( update ){ /* ... */ update(); } => a manual function for updating 84 | // - 'matching' => automatically update on position events for nodesMatching 85 | // - set efficiently and automatically for 86 | // - reposition: 'mean' 87 | // - reposition: { x1, y1, x2, y2 } 88 | // - reposition: 'viewport' 89 | // - reposition: 'drag' 90 | // - default/undefined => on a position event for any node (not as efficient...) 91 | when: undefined, 92 | 93 | 94 | 95 | // 96 | // customisation options for non-function `reposition` values 97 | // 98 | 99 | // `reposition: 'mean'` 100 | 101 | // specify nodes that should be ignored in the mean calculation 102 | // - a function that returns true for nodes to be ignored 103 | // - a selector that matches the nodes to be ignored 104 | // - a collection of nodes to be ignored (very good for performance) 105 | meanIgnores: function( node ){ return false; }, 106 | 107 | // specify whether moving a particular `nodesMatching` node causes repositioning 108 | // - true : the mid node can't be independently moved/dragged 109 | // - false : the mid node can be independently moved/dragged (useful if you want the mid node to use `reposition: 'drag' in another rule with its neighbourhood`) 110 | meanOnSelfPosition: function( node ){ return true; }, 111 | 112 | // `reposition: 'drag'` 113 | 114 | // specify nodes that when dragged cause the matched nodes to move along (i.e. the master nodes) 115 | // - a function that returns true for nodes to be listened to for drag events 116 | // - a selector that matches the nodes to be listened to for drag events 117 | // - a collection of nodes to be listened to for drag events (very good for performance) 118 | dragWith: function( node ){ return false; } 119 | }; 120 | 121 | let options = defaults; 122 | 123 | let rule = cy.automove( options ); 124 | ``` 125 | 126 | A rule has a number of functions available: 127 | 128 | ```js 129 | rule.apply(); // manually apply a rule 130 | 131 | rule.enabled(); // get whether rule is enabled 132 | 133 | rule.toggle(); // toggle whether the rule is enabled 134 | 135 | rule.disable(); // temporarily disable the rule 136 | 137 | rule.enable(); // re-enable the rule 138 | 139 | rule.destroy(); // remove and clean up just this rule 140 | ``` 141 | 142 | You can also remove all the rules you previously specified: 143 | 144 | ```js 145 | cy.automove('destroy'); 146 | ``` 147 | 148 | ## Events 149 | 150 | - `automove` : Emitted on a node when its position is changed by a rule 151 | - `node.on('automove', function( event, rule ){})` 152 | 153 | 154 | ## Build targets 155 | 156 | * `npm run test` : Run Mocha tests in `./test` 157 | * `npm run build` : Build `./src/**` into `cytoscape-automove.js` 158 | * `npm run watch` : Automatically build on changes with live reloading (N.b. you must already have an HTTP server running) 159 | * `npm run dev` : Automatically build on changes with live reloading with webpack dev server 160 | * `npm run lint` : Run eslint on the source 161 | 162 | N.b. all builds use babel, so modern ES features can be used in the `src`. 163 | 164 | 165 | ## Publishing instructions 166 | 167 | This project is set up to automatically be published to npm and bower. To publish: 168 | 169 | 1. Build the extension : `npm run build:release` 170 | 1. Commit the build : `git commit -am "Build for release"` 171 | 1. Bump the version number and tag: `npm version major|minor|patch` 172 | 1. Push to origin: `git push && git push --tags` 173 | 1. Publish to npm: `npm publish .` 174 | 1. If publishing to bower for the first time, you'll need to run `bower register cytoscape-automove https://github.com/cytoscape/cytoscape.js-automove.git` 175 | 1. [Make a new release](https://github.com/cytoscape/cytoscape.js-automove/releases/new) for Zenodo. 176 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-automove", 3 | "description": "An extension for Cytoscape.js that automatically updates node positions based on specified rules", 4 | "main": "cytoscape-automove.js", 5 | "dependencies": { 6 | "cytoscape": "^3.0.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/cytoscape/cytoscape.js-automove.git" 11 | }, 12 | "ignore": [ 13 | "**/.*", 14 | "node_modules", 15 | "bower_components", 16 | "test", 17 | "tests" 18 | ], 19 | "keywords": [ 20 | "cytoscape", 21 | "cytoscape-extension" 22 | ], 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /cytoscape-automove.js: -------------------------------------------------------------------------------- 1 | (function webpackUniversalModuleDefinition(root, factory) { 2 | if(typeof exports === 'object' && typeof module === 'object') 3 | module.exports = factory(); 4 | else if(typeof define === 'function' && define.amd) 5 | define([], factory); 6 | else if(typeof exports === 'object') 7 | exports["cytoscapeAutomove"] = factory(); 8 | else 9 | root["cytoscapeAutomove"] = factory(); 10 | })(this, function() { 11 | return /******/ (function(modules) { // webpackBootstrap 12 | /******/ // The module cache 13 | /******/ var installedModules = {}; 14 | /******/ 15 | /******/ // The require function 16 | /******/ function __webpack_require__(moduleId) { 17 | /******/ 18 | /******/ // Check if module is in cache 19 | /******/ if(installedModules[moduleId]) { 20 | /******/ return installedModules[moduleId].exports; 21 | /******/ } 22 | /******/ // Create a new module (and put it into the cache) 23 | /******/ var module = installedModules[moduleId] = { 24 | /******/ i: moduleId, 25 | /******/ l: false, 26 | /******/ exports: {} 27 | /******/ }; 28 | /******/ 29 | /******/ // Execute the module function 30 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 31 | /******/ 32 | /******/ // Flag the module as loaded 33 | /******/ module.l = true; 34 | /******/ 35 | /******/ // Return the exports of the module 36 | /******/ return module.exports; 37 | /******/ } 38 | /******/ 39 | /******/ 40 | /******/ // expose the modules object (__webpack_modules__) 41 | /******/ __webpack_require__.m = modules; 42 | /******/ 43 | /******/ // expose the module cache 44 | /******/ __webpack_require__.c = installedModules; 45 | /******/ 46 | /******/ // identity function for calling harmony imports with the correct context 47 | /******/ __webpack_require__.i = function(value) { return value; }; 48 | /******/ 49 | /******/ // define getter function for harmony exports 50 | /******/ __webpack_require__.d = function(exports, name, getter) { 51 | /******/ if(!__webpack_require__.o(exports, name)) { 52 | /******/ Object.defineProperty(exports, name, { 53 | /******/ configurable: false, 54 | /******/ enumerable: true, 55 | /******/ get: getter 56 | /******/ }); 57 | /******/ } 58 | /******/ }; 59 | /******/ 60 | /******/ // getDefaultExport function for compatibility with non-harmony modules 61 | /******/ __webpack_require__.n = function(module) { 62 | /******/ var getter = module && module.__esModule ? 63 | /******/ function getDefault() { return module['default']; } : 64 | /******/ function getModuleExports() { return module; }; 65 | /******/ __webpack_require__.d(getter, 'a', getter); 66 | /******/ return getter; 67 | /******/ }; 68 | /******/ 69 | /******/ // Object.prototype.hasOwnProperty.call 70 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 71 | /******/ 72 | /******/ // __webpack_public_path__ 73 | /******/ __webpack_require__.p = ""; 74 | /******/ 75 | /******/ // Load entry module and return exports 76 | /******/ return __webpack_require__(__webpack_require__.s = 3); 77 | /******/ }) 78 | /************************************************************************/ 79 | /******/ ([ 80 | /* 0 */ 81 | /***/ (function(module, exports, __webpack_require__) { 82 | 83 | "use strict"; 84 | 85 | 86 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 87 | 88 | var defaults = __webpack_require__(2); 89 | 90 | var typeofStr = _typeof(''); 91 | var typeofObj = _typeof({}); 92 | var typeofFn = _typeof(function () {}); 93 | 94 | var isObject = function isObject(x) { 95 | return (typeof x === 'undefined' ? 'undefined' : _typeof(x)) === typeofObj; 96 | }; 97 | var isString = function isString(x) { 98 | return (typeof x === 'undefined' ? 'undefined' : _typeof(x)) === typeofStr; 99 | }; 100 | var isFunction = function isFunction(x) { 101 | return (typeof x === 'undefined' ? 'undefined' : _typeof(x)) === typeofFn; 102 | }; 103 | var isCollection = function isCollection(x) { 104 | return isObject(x) && isFunction(x.collection); 105 | }; 106 | 107 | // Object.assign() polyfill 108 | var assign = __webpack_require__(1); 109 | 110 | var eleExists = function eleExists(ele) { 111 | return ele != null && !ele.removed(); 112 | }; 113 | 114 | var _elesHasEle = function elesHasEle(eles, ele) { 115 | if (eles.has != undefined) { 116 | // 3.x 117 | _elesHasEle = function elesHasEle(eles, ele) { 118 | return eles.has(ele); 119 | }; 120 | } else { 121 | // 2.x 122 | _elesHasEle = function elesHasEle(eles, ele) { 123 | return eles.intersection(ele).length > 0; 124 | }; 125 | } 126 | 127 | return _elesHasEle(eles, ele); 128 | }; 129 | 130 | var getEleMatchesSpecFn = function getEleMatchesSpecFn(spec) { 131 | if (isString(spec)) { 132 | return function (ele) { 133 | return ele.is(spec); 134 | }; 135 | } else if (isFunction(spec)) { 136 | return spec; 137 | } else if (isCollection(spec)) { 138 | return function (ele) { 139 | return _elesHasEle(spec, ele); 140 | }; 141 | } else { 142 | throw new Error('Can not create match function for spec', spec); 143 | } 144 | }; 145 | 146 | var bind = function bind(cy, events, selector, fn) { 147 | var b = { cy: cy, events: events, selector: selector || 'node', fn: fn }; 148 | 149 | cy.on(b.events, b.selector, b.fn); 150 | 151 | return b; 152 | }; 153 | 154 | var bindOnRule = function bindOnRule(rule, cy, events, selector, fn) { 155 | var b = bind(cy, events, selector, fn); 156 | var bindings = rule.bindings = rule.bindings || []; 157 | 158 | bindings.push(b); 159 | }; 160 | 161 | var unbindAllOnRule = function unbindAllOnRule(rule) { 162 | var unbind = function unbind(b) { 163 | b.cy.off(b.events, b.selector, b.fn); 164 | }; 165 | 166 | rule.bindings.forEach(unbind); 167 | 168 | rule.bindings = []; 169 | }; 170 | 171 | var getRepositioner = function getRepositioner(rule, cy) { 172 | var r = rule.reposition; 173 | 174 | if (r === 'mean') { 175 | return meanNeighborhoodPosition(getEleMatchesSpecFn(rule.meanIgnores)); 176 | } else if (r === 'viewport') { 177 | return viewportPosition(cy); 178 | } else if (r === 'drag') { 179 | return dragAlong(rule); 180 | } else if (isObject(r)) { 181 | if (r.type == undefined || r.type == "inside") { 182 | return boxPosition(r); 183 | } else if (r.type == "outside") { 184 | return outsideBoxPosition(r); 185 | } 186 | } else { 187 | return r; 188 | } 189 | }; 190 | 191 | var dragAlong = function dragAlong(rule) { 192 | return function (node) { 193 | var pos = node.position(); 194 | var delta = rule.delta; 195 | 196 | if (rule.delta != null && !node.same(rule.grabbedNode) && !node.grabbed()) { 197 | return { 198 | x: pos.x + delta.x, 199 | y: pos.y + delta.y 200 | }; 201 | } 202 | }; 203 | }; 204 | 205 | var meanNeighborhoodPosition = function meanNeighborhoodPosition(ignore) { 206 | return function (node) { 207 | var nhood = node.neighborhood(); 208 | var avgPos = { x: 0, y: 0 }; 209 | var nhoodSize = 0; 210 | 211 | for (var i = 0; i < nhood.length; i++) { 212 | var nhoodEle = nhood[i]; 213 | 214 | if (nhoodEle.isNode() && !ignore(nhoodEle)) { 215 | var pos = nhoodEle.position(); 216 | 217 | avgPos.x += pos.x; 218 | avgPos.y += pos.y; 219 | 220 | nhoodSize++; 221 | } 222 | } 223 | 224 | // the position should remain unchanged if we would stack the nodes on top of each other 225 | if (nhoodSize < 2) { 226 | return undefined; 227 | } 228 | 229 | avgPos.x /= nhoodSize; 230 | avgPos.y /= nhoodSize; 231 | 232 | return avgPos; 233 | }; 234 | }; 235 | 236 | var constrain = function constrain(val, min, max) { 237 | return val < min ? min : val > max ? max : val; 238 | }; 239 | 240 | var constrainInBox = function constrainInBox(node, bb) { 241 | var pos = node.position(); 242 | 243 | return { 244 | x: constrain(pos.x, bb.x1, bb.x2), 245 | y: constrain(pos.y, bb.y1, bb.y2) 246 | }; 247 | }; 248 | 249 | var boxPosition = function boxPosition(bb) { 250 | return function (node) { 251 | return constrainInBox(node, bb); 252 | }; 253 | }; 254 | 255 | var constrainOutsideBox = function constrainOutsideBox(node, bb) { 256 | var pos = node.position(); 257 | var x = pos.x, 258 | y = pos.y; 259 | var x1 = bb.x1, 260 | y1 = bb.y1, 261 | x2 = bb.x2, 262 | y2 = bb.y2; 263 | 264 | var inX = x1 <= x && x <= x2; 265 | var inY = y1 <= y && y <= y2; 266 | var abs = Math.abs; 267 | 268 | if (inX && inY) { 269 | // inside 270 | var dx1 = abs(x1 - x); 271 | var dx2 = abs(x2 - x); 272 | var dy1 = abs(y1 - y); 273 | var dy2 = abs(y2 - y); 274 | var min = Math.min(dx1, dx2, dy1, dy2); // which side of box is closest? 275 | 276 | // get position outside, by closest side of box 277 | if (min === dx1) { 278 | return { x: x1, y: y }; 279 | } else if (min === dx2) { 280 | return { x: x2, y: y }; 281 | } else if (min === dy1) { 282 | return { x: x, y: y1 }; 283 | } else { 284 | // min === dy2 285 | return { x: x, y: y2 }; 286 | } 287 | } else { 288 | // outside already 289 | return { x: x, y: y }; 290 | } 291 | }; 292 | 293 | var outsideBoxPosition = function outsideBoxPosition(bb) { 294 | return function (node) { 295 | return constrainOutsideBox(node, bb); 296 | }; 297 | }; 298 | 299 | var viewportPosition = function viewportPosition(cy) { 300 | return function (node) { 301 | var extent = cy.extent(); 302 | var w = node.outerWidth(); 303 | var h = node.outerHeight(); 304 | var bb = { 305 | x1: extent.x1 + w / 2, 306 | x2: extent.x2 - w / 2, 307 | y1: extent.y1 + h / 2, 308 | y2: extent.y2 - h / 2 309 | }; 310 | 311 | return constrainInBox(node, bb); 312 | }; 313 | }; 314 | 315 | var meanListener = function meanListener(rule) { 316 | return function (update, cy) { 317 | var matches = function matches(ele) { 318 | // must meet ele set and be connected to more than (1 edge + 1 node) 319 | return rule.matches(ele) && ele.neighborhood().length > 2 && !ele.grabbed(); 320 | }; 321 | 322 | bindOnRule(rule, cy, 'position', 'node', function () { 323 | var movedNode = this; 324 | 325 | if (movedNode.neighborhood().some(matches) || rule.meanOnSelfPosition(movedNode) && matches(movedNode)) { 326 | update(cy, [rule]); 327 | } 328 | }); 329 | 330 | bindOnRule(rule, cy, 'add remove', 'edge', function () { 331 | var edge = this; 332 | var src = cy.getElementById(edge.data('source')); 333 | var tgt = cy.getElementById(edge.data('target')); 334 | 335 | if ([src, tgt].some(matches)) { 336 | update(cy, [rule]); 337 | } 338 | }); 339 | }; 340 | }; 341 | 342 | var dragListener = function dragListener(rule) { 343 | return function (update, cy) { 344 | bindOnRule(rule, cy, 'grab', 'node', function () { 345 | var node = this; 346 | 347 | if (rule.dragWithMatches(node)) { 348 | var p = node.position(); 349 | 350 | rule.grabbedNode = node; 351 | rule.p1 = { x: p.x, y: p.y }; 352 | rule.delta = { x: 0, y: 0 }; 353 | } 354 | }); 355 | 356 | bindOnRule(rule, cy, 'drag', 'node', function () { 357 | var node = this; 358 | 359 | if (node.same(rule.grabbedNode)) { 360 | var d = rule.delta; 361 | var p1 = rule.p1; 362 | var p = node.position(); 363 | var p2 = { x: p.x, y: p.y }; 364 | 365 | d.x = p2.x - p1.x; 366 | d.y = p2.y - p1.y; 367 | 368 | rule.p1 = p2; 369 | 370 | update(cy, [rule]); 371 | } 372 | }); 373 | 374 | bindOnRule(rule, cy, 'free', 'node', function () { 375 | rule.grabbedNode = null; 376 | rule.delta = null; 377 | rule.p1 = null; 378 | }); 379 | }; 380 | }; 381 | 382 | var matchingNodesListener = function matchingNodesListener(rule) { 383 | return function (update, cy) { 384 | bindOnRule(rule, cy, 'position', 'node', function () { 385 | var movedNode = this; 386 | 387 | if (rule.matches(movedNode)) { 388 | update(cy, [rule]); 389 | } 390 | }); 391 | }; 392 | }; 393 | 394 | var getListener = function getListener(cy, rule) { 395 | if (rule.reposition === 'mean') { 396 | return meanListener(rule); 397 | } else if (rule.reposition === 'drag') { 398 | return dragListener(rule); 399 | } else if (isObject(rule.reposition) || rule.when === 'matching' || rule.reposition === 'viewport') { 400 | return matchingNodesListener(rule); 401 | } else { 402 | return rule.when; 403 | } 404 | }; 405 | 406 | var addRule = function addRule(cy, scratch, options) { 407 | var rule = assign({}, defaults, options); 408 | 409 | rule.getNewPos = getRepositioner(rule, cy); 410 | rule.listener = getListener(cy, rule); 411 | 412 | var nodesAreCollection = isCollection(rule.nodesMatching); 413 | 414 | if (nodesAreCollection) { 415 | rule.nodes = rule.nodesMatching.slice(); 416 | 417 | rule.matches = function (ele) { 418 | return eleExists(ele) && _elesHasEle(rule.nodes, ele); 419 | }; 420 | } else { 421 | var matches = getEleMatchesSpecFn(rule.nodesMatching); 422 | 423 | rule.matches = function (ele) { 424 | return eleExists(ele) && matches(ele); 425 | }; 426 | } 427 | 428 | if (rule.dragWith != null) { 429 | rule.dragWithMatches = getEleMatchesSpecFn(rule.dragWith); 430 | } 431 | 432 | rule.listener(function () { 433 | update(cy, [rule]); 434 | }, cy); 435 | 436 | rule.enabled = true; 437 | 438 | scratch.rules.push(rule); 439 | 440 | return rule; 441 | }; 442 | 443 | var bindForNodeList = function bindForNodeList(cy, scratch) { 444 | scratch.onAddNode = function (evt) { 445 | var target = evt.target; 446 | 447 | scratch.nodes.merge(target); 448 | }; 449 | 450 | scratch.onRmNode = function (evt) { 451 | var target = evt.target; 452 | 453 | scratch.nodes.unmerge(target); 454 | }; 455 | 456 | cy.on('add', 'node', scratch.onAddNode); 457 | cy.on('remove', 'node', scratch.onRmNode); 458 | }; 459 | 460 | var unbindForNodeList = function unbindForNodeList(cy, scratch) { 461 | cy.removeListener('add', 'node', scratch.onAddNode); 462 | cy.removeListener('remove', 'node', scratch.onRmNode); 463 | }; 464 | 465 | var update = function update(cy, rules) { 466 | var scratch = cy.scratch().automove; 467 | 468 | rules = rules != null ? rules : scratch.rules; 469 | 470 | cy.batch(function () { 471 | // batch for performance 472 | for (var i = 0; i < rules.length; i++) { 473 | var rule = rules[i]; 474 | 475 | if (rule.destroyed || !rule.enabled) { 476 | break; 477 | } // ignore destroyed rules b/c user may use custom when() 478 | 479 | var nodes = rule.nodes || scratch.nodes; 480 | 481 | for (var j = nodes.length - 1; j >= 0; j--) { 482 | var node = nodes[j]; 483 | 484 | if (node.removed()) { 485 | // remove from list for perf 486 | nodes.unmerge(node); 487 | continue; 488 | } 489 | 490 | if (!rule.matches(node)) { 491 | continue; 492 | } 493 | 494 | var pos = node.position(); 495 | var newPos = rule.getNewPos(node); 496 | var newPosIsDiff = newPos != null && (pos.x !== newPos.x || pos.y !== newPos.y); 497 | 498 | if (newPosIsDiff) { 499 | // only update on diff for perf 500 | node.position(newPos); 501 | 502 | node.trigger('automove', [rule]); 503 | } 504 | } 505 | } 506 | }); 507 | }; 508 | 509 | var automove = function automove(options) { 510 | var cy = this; 511 | 512 | var scratch = cy.scratch().automove = cy.scratch().automove || { 513 | rules: [] 514 | }; 515 | 516 | if (scratch.rules.length === 0) { 517 | scratch.nodes = cy.nodes().slice(); 518 | 519 | bindForNodeList(cy, scratch); 520 | } 521 | 522 | if (options === 'destroy') { 523 | scratch.rules.forEach(function (r) { 524 | unbindAllOnRule(r); 525 | 526 | r.destroyed = true; 527 | }); 528 | 529 | scratch.rules.splice(0, scratch.rules.length); 530 | 531 | unbindForNodeList(cy, scratch); 532 | 533 | return; 534 | } 535 | 536 | var rule = addRule(cy, scratch, options); 537 | 538 | update(cy, [rule]); // do an initial update to make sure the start state is correct 539 | 540 | return { 541 | apply: function apply() { 542 | update(cy, [rule]); 543 | }, 544 | 545 | disable: function disable() { 546 | this.toggle(false); 547 | }, 548 | 549 | enable: function enable() { 550 | this.toggle(true); 551 | }, 552 | 553 | enabled: function enabled() { 554 | return rule.enabled; 555 | }, 556 | 557 | toggle: function toggle(on) { 558 | rule.enabled = on !== undefined ? on : !rule.enabled; 559 | 560 | if (rule.enabled) { 561 | update(cy, [rule]); 562 | } 563 | }, 564 | 565 | destroy: function destroy() { 566 | var rules = scratch.rules; 567 | 568 | unbindAllOnRule(rule); 569 | 570 | rule.destroyed = true; 571 | 572 | rules.splice(rules.indexOf(rule), 1); 573 | 574 | if (rules.length === 0) { 575 | unbindForNodeList(cy, scratch); 576 | } 577 | 578 | return this; 579 | } 580 | }; 581 | }; 582 | 583 | module.exports = automove; 584 | 585 | /***/ }), 586 | /* 1 */ 587 | /***/ (function(module, exports, __webpack_require__) { 588 | 589 | "use strict"; 590 | 591 | 592 | // Simple, internal Object.assign() polyfill for options objects etc. 593 | 594 | module.exports = Object.assign != null ? Object.assign.bind(Object) : function (tgt) { 595 | for (var _len = arguments.length, srcs = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 596 | srcs[_key - 1] = arguments[_key]; 597 | } 598 | 599 | srcs.forEach(function (src) { 600 | Object.keys(src).forEach(function (k) { 601 | return tgt[k] = src[k]; 602 | }); 603 | }); 604 | 605 | return tgt; 606 | }; 607 | 608 | /***/ }), 609 | /* 2 */ 610 | /***/ (function(module, exports, __webpack_require__) { 611 | 612 | "use strict"; 613 | 614 | 615 | /* eslint-disable no-unused-vars */ 616 | var defaults = { 617 | // specify nodes that should be automoved with one of 618 | // - a function that returns true for matching nodes 619 | // - a selector that matches the nodes 620 | // - a collection of nodes (very good for performance) 621 | nodesMatching: function nodesMatching(node) { 622 | return false; 623 | }, 624 | 625 | // specify how a node's position should be updated with one of 626 | // - function( node ){ return { x: 1, y: 2 }; } => put the node where the function returns 627 | // - { x1, y1, x2, y2 } => constrain the node position within the bounding box (in model co-ordinates) 628 | // - { x1, y1, x2, y2, type: 'inside' } => constrain the node position within the bounding box (in model co-ordinates) 629 | // - { x1, y1, x2, y2, type: 'outside' } => constrain the node position outside the bounding box (in model co-ordinates) 630 | // - 'mean' => put the node in the average position of its neighbourhood 631 | // - 'viewport' => keeps the node body within the viewport 632 | // - 'drag' => matching nodes are effectively dragged along 633 | reposition: 'mean', 634 | 635 | // specify when the repositioning should occur by specifying a function that 636 | // calls update() when reposition updates should occur 637 | // - function( update ){ /* ... */ update(); } => a manual function for updating 638 | // - 'matching' => automatically update on position events for nodesMatching 639 | // - set efficiently and automatically for 640 | // - reposition: 'mean' 641 | // - reposition: { x1, y1, x2, y2 } 642 | // - reposition: 'viewport' 643 | // - reposition: 'drag' 644 | // - default/undefined => on a position event for any node (not as efficient...) 645 | when: undefined, 646 | 647 | // 648 | // customisation options for non-function `reposition` values 649 | // 650 | 651 | // `reposition: 'mean'` 652 | 653 | // specify nodes that should be ignored in the mean calculation 654 | // - a function that returns true for nodes to be ignored 655 | // - a selector that matches the nodes to be ignored 656 | // - a collection of nodes to be ignored (very good for performance) 657 | meanIgnores: function meanIgnores(node) { 658 | return false; 659 | }, 660 | 661 | // specify whether moving a particular `nodesMatching` node causes repositioning 662 | // - true : the mid node can't be independently moved/dragged 663 | // - false : the mid node can be independently moved/dragged (useful if you want the mid node to use `reposition: 'drag' in another rule with its neighbourhood`) 664 | meanOnSelfPosition: function meanOnSelfPosition(node) { 665 | return true; 666 | }, 667 | 668 | // `reposition: 'drag'` 669 | 670 | // specify nodes that when dragged cause the matched nodes to move along (i.e. the master nodes) 671 | // - a function that returns true for nodes to be listened to for drag events 672 | // - a selector that matches the nodes to be listened to for drag events 673 | // - a collection of nodes to be listened to for drag events (very good for performance) 674 | dragWith: function dragWith(node) { 675 | return false; 676 | } 677 | }; 678 | 679 | /* eslint-enable */ 680 | 681 | module.exports = defaults; 682 | 683 | /***/ }), 684 | /* 3 */ 685 | /***/ (function(module, exports, __webpack_require__) { 686 | 687 | "use strict"; 688 | 689 | 690 | var automove = __webpack_require__(0); 691 | 692 | // registers the extension on a cytoscape lib ref 693 | var register = function register(cytoscape) { 694 | if (!cytoscape) { 695 | return; 696 | } // can't register if cytoscape unspecified 697 | 698 | cytoscape('core', 'automove', automove); // register with cytoscape.js 699 | }; 700 | 701 | if (typeof cytoscape !== 'undefined') { 702 | // expose to global cytoscape (i.e. window.cytoscape) 703 | register(cytoscape); 704 | } 705 | 706 | module.exports = register; 707 | 708 | /***/ }) 709 | /******/ ]); 710 | }); -------------------------------------------------------------------------------- /demo-multiple-mean.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | cytoscape-automove.js demo 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 40 | 41 | 134 | 135 | 136 | 137 |

cytoscape-automove demo

138 | 139 |
140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cytoscape-automove.js demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 38 | 39 | 251 | 252 | 253 | 254 |

cytoscape-automove demo

255 | 256 |
257 | 258 | 259 | 260 | 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-automove", 3 | "version": "1.10.3", 4 | "description": "An extension for Cytoscape.js that automatically updates node positions based on specified rules", 5 | "main": "cytoscape-automove.js", 6 | "author": { 7 | "name": "Max Franz", 8 | "email": "maxkfranz@gmail.com" 9 | }, 10 | "scripts": { 11 | "postpublish": "run-s gh-pages", 12 | "gh-pages": "gh-pages -d pages", 13 | "copyright": "update license", 14 | "lint": "eslint src", 15 | "build": "cross-env NODE_ENV=production webpack", 16 | "build:min": "cross-env NODE_ENV=production MIN=true webpack", 17 | "build:release": "run-s build copyright", 18 | "watch": "webpack --progress --watch", 19 | "dev": "webpack-dev-server --open", 20 | "test": "mocha" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/cytoscape/cytoscape.js-automove.git" 25 | }, 26 | "keywords": [ 27 | "cytoscape", 28 | "cytoscape-extension" 29 | ], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/cytoscape/cytoscape.js-automove/issues" 33 | }, 34 | "homepage": "https://github.com/cytoscape/cytoscape.js-automove", 35 | "devDependencies": { 36 | "babel-core": "^6.24.1", 37 | "babel-loader": "^7.0.0", 38 | "babel-preset-env": "^1.5.1", 39 | "camelcase": "^4.1.0", 40 | "chai": "4.0.2", 41 | "cpy-cli": "^1.0.1", 42 | "cross-env": "^5.0.0", 43 | "eslint": "^3.9.1", 44 | "gh-pages": "^1.0.0", 45 | "mocha": "3.4.2", 46 | "npm-run-all": "^4.1.2", 47 | "rimraf": "^2.6.2", 48 | "update": "^0.7.4", 49 | "updater-license": "^1.0.0", 50 | "webpack": "^2.6.1", 51 | "webpack-dev-server": "^2.4.5" 52 | }, 53 | "peerDependencies": { 54 | "cytoscape": "^3.2.0" 55 | }, 56 | "dependencies": {} 57 | } 58 | -------------------------------------------------------------------------------- /pages/cytoscape-automove.js: -------------------------------------------------------------------------------- 1 | ../cytoscape-automove.js -------------------------------------------------------------------------------- /pages/demo-multiple-mean.html: -------------------------------------------------------------------------------- 1 | ../demo-multiple-mean.html -------------------------------------------------------------------------------- /pages/demo.html: -------------------------------------------------------------------------------- 1 | ../demo.html -------------------------------------------------------------------------------- /src/assign.js: -------------------------------------------------------------------------------- 1 | // Simple, internal Object.assign() polyfill for options objects etc. 2 | 3 | module.exports = Object.assign != null ? Object.assign.bind( Object ) : function( tgt, ...srcs ){ 4 | srcs.forEach( src => { 5 | Object.keys( src ).forEach( k => tgt[k] = src[k] ); 6 | } ); 7 | 8 | return tgt; 9 | }; 10 | -------------------------------------------------------------------------------- /src/automove.js: -------------------------------------------------------------------------------- 1 | let defaults = require('./defaults'); 2 | 3 | let typeofStr = typeof ''; 4 | let typeofObj = typeof {}; 5 | let typeofFn = typeof function(){}; 6 | 7 | let isObject = function( x ){ return typeof x === typeofObj; }; 8 | let isString = function( x ){ return typeof x === typeofStr; }; 9 | let isFunction = function( x ){ return typeof x === typeofFn; }; 10 | let isCollection = function( x ){ return isObject( x ) && isFunction( x.collection ); }; 11 | 12 | // Object.assign() polyfill 13 | let assign = require('./assign'); 14 | 15 | let eleExists = function( ele ){ 16 | return ele != null && !ele.removed(); 17 | }; 18 | 19 | let elesHasEle = function( eles, ele ){ 20 | if( eles.has != undefined ){ // 3.x 21 | elesHasEle = function( eles, ele ){ return eles.has( ele ); }; 22 | } else { // 2.x 23 | elesHasEle = function( eles, ele ){ return eles.intersection( ele ).length > 0; }; 24 | } 25 | 26 | return elesHasEle( eles, ele ); 27 | }; 28 | 29 | let getEleMatchesSpecFn = function( spec ){ 30 | if( isString( spec ) ){ 31 | return function( ele ){ 32 | return ele.is( spec ); 33 | }; 34 | } else if( isFunction( spec ) ){ 35 | return spec; 36 | } else if( isCollection( spec ) ){ 37 | return function( ele ){ 38 | return elesHasEle( spec, ele ); 39 | }; 40 | } else { 41 | throw new Error('Can not create match function for spec', spec); 42 | } 43 | }; 44 | 45 | let bind = function( cy, events, selector, fn ){ 46 | let b = { cy: cy, events: events, selector: selector || 'node', fn: fn }; 47 | 48 | cy.on( b.events, b.selector, b.fn ); 49 | 50 | return b; 51 | }; 52 | 53 | let bindOnRule = function( rule, cy, events, selector, fn ){ 54 | let b = bind( cy, events, selector, fn ); 55 | let bindings = rule.bindings = rule.bindings || []; 56 | 57 | bindings.push( b ); 58 | }; 59 | 60 | let unbindAllOnRule = function( rule ){ 61 | let unbind = function( b ){ b.cy.off( b.events, b.selector, b.fn ); }; 62 | 63 | rule.bindings.forEach( unbind ); 64 | 65 | rule.bindings = []; 66 | }; 67 | 68 | let getRepositioner = function( rule, cy ){ 69 | let r = rule.reposition; 70 | 71 | if( r === 'mean' ){ 72 | return meanNeighborhoodPosition( getEleMatchesSpecFn( rule.meanIgnores ) ); 73 | } else if( r === 'viewport' ){ 74 | return viewportPosition( cy ); 75 | } else if( r === 'drag' ){ 76 | return dragAlong( rule ); 77 | } else if( isObject( r ) ){ 78 | if( r.type == undefined || r.type == "inside" ){ 79 | return boxPosition( r ); 80 | } 81 | else if( r.type == "outside" ){ 82 | return outsideBoxPosition( r ); 83 | } 84 | } else { 85 | return r; 86 | } 87 | }; 88 | 89 | let dragAlong = function( rule ){ 90 | return function( node ){ 91 | let pos = node.position(); 92 | let delta = rule.delta; 93 | 94 | if( rule.delta != null && !node.same( rule.grabbedNode ) && !node.grabbed() ){ 95 | return { 96 | x: pos.x + delta.x, 97 | y: pos.y + delta.y 98 | }; 99 | } 100 | }; 101 | }; 102 | 103 | let meanNeighborhoodPosition = function( ignore ){ 104 | return function( node ){ 105 | let nhood = node.neighborhood(); 106 | let avgPos = { x: 0, y: 0 }; 107 | let nhoodSize = 0; 108 | 109 | for( let i = 0; i < nhood.length; i++ ){ 110 | let nhoodEle = nhood[i]; 111 | 112 | if( nhoodEle.isNode() && !ignore( nhoodEle ) ){ 113 | let pos = nhoodEle.position(); 114 | 115 | avgPos.x += pos.x; 116 | avgPos.y += pos.y; 117 | 118 | nhoodSize++; 119 | } 120 | } 121 | 122 | // the position should remain unchanged if we would stack the nodes on top of each other 123 | if( nhoodSize < 2 ){ 124 | return undefined; 125 | } 126 | 127 | avgPos.x /= nhoodSize; 128 | avgPos.y /= nhoodSize; 129 | 130 | return avgPos; 131 | }; 132 | }; 133 | 134 | let constrain = function( val, min, max ){ 135 | return val < min ? min : ( val > max ? max : val ); 136 | }; 137 | 138 | let constrainInBox = function( node, bb ){ 139 | let pos = node.position(); 140 | 141 | return { 142 | x: constrain( pos.x, bb.x1, bb.x2 ), 143 | y: constrain( pos.y, bb.y1, bb.y2 ) 144 | }; 145 | }; 146 | 147 | let boxPosition = function( bb ){ 148 | return function( node ){ 149 | return constrainInBox( node, bb ); 150 | }; 151 | }; 152 | 153 | let constrainOutsideBox = function( node, bb ){ 154 | let pos = node.position(); 155 | let { x, y } = pos; 156 | let { x1, y1, x2, y2 } = bb; 157 | let inX = x1 <= x && x <= x2; 158 | let inY = y1 <= y && y <= y2; 159 | let abs = Math.abs; 160 | 161 | if( inX && inY ){ // inside 162 | let dx1 = abs(x1 - x); 163 | let dx2 = abs(x2 - x); 164 | let dy1 = abs(y1 - y); 165 | let dy2 = abs(y2 - y); 166 | let min = Math.min(dx1, dx2, dy1, dy2); // which side of box is closest? 167 | 168 | // get position outside, by closest side of box 169 | if( min === dx1 ){ 170 | return { x: x1, y }; 171 | } else if( min === dx2 ){ 172 | return { x: x2, y }; 173 | } else if( min === dy1 ){ 174 | return { x, y: y1 }; 175 | } else { // min === dy2 176 | return { x, y: y2 }; 177 | } 178 | } else { // outside already 179 | return { x, y }; 180 | } 181 | }; 182 | 183 | let outsideBoxPosition = function( bb ){ 184 | return function( node ){ 185 | return constrainOutsideBox( node, bb ); 186 | }; 187 | }; 188 | 189 | let viewportPosition = function( cy ){ 190 | return function( node ){ 191 | let extent = cy.extent(); 192 | let w = node.outerWidth(); 193 | let h = node.outerHeight(); 194 | let bb = { 195 | x1: extent.x1 + w/2, 196 | x2: extent.x2 - w/2, 197 | y1: extent.y1 + h/2, 198 | y2: extent.y2 - h/2 199 | }; 200 | 201 | return constrainInBox( node, bb ); 202 | }; 203 | }; 204 | 205 | let meanListener = function( rule ){ 206 | return function( update, cy ){ 207 | let matches = function( ele ){ 208 | // must meet ele set and be connected to more than (1 edge + 1 node) 209 | return rule.matches( ele ) && ele.neighborhood().length > 2 && !ele.grabbed(); 210 | }; 211 | 212 | bindOnRule( rule, cy, 'position', 'node', function(){ 213 | let movedNode = this; 214 | 215 | if( 216 | movedNode.neighborhood().some( matches ) || 217 | ( rule.meanOnSelfPosition( movedNode ) && matches( movedNode ) ) 218 | ){ 219 | update( cy, [ rule ] ); 220 | } 221 | }); 222 | 223 | bindOnRule( rule, cy, 'add remove', 'edge', function(){ 224 | let edge = this; 225 | let src = cy.getElementById( edge.data('source') ); 226 | let tgt = cy.getElementById( edge.data('target') ); 227 | 228 | if( [ src, tgt ].some( matches ) ){ 229 | update( cy, [ rule ] ); 230 | } 231 | }); 232 | }; 233 | }; 234 | 235 | let dragListener = function( rule ){ 236 | return function( update, cy ){ 237 | bindOnRule( rule, cy, 'grab', 'node', function(){ 238 | let node = this; 239 | 240 | if( rule.dragWithMatches( node ) ){ 241 | let p = node.position(); 242 | 243 | rule.grabbedNode = node; 244 | rule.p1 = { x: p.x, y: p.y }; 245 | rule.delta = { x: 0, y: 0 }; 246 | } 247 | }); 248 | 249 | bindOnRule( rule, cy, 'drag', 'node', function(){ 250 | let node = this; 251 | 252 | if( node.same( rule.grabbedNode ) ){ 253 | let d = rule.delta; 254 | let p1 = rule.p1; 255 | let p = node.position(); 256 | let p2 = { x: p.x, y: p.y }; 257 | 258 | d.x = p2.x - p1.x; 259 | d.y = p2.y - p1.y; 260 | 261 | rule.p1 = p2; 262 | 263 | update( cy, [ rule ] ); 264 | } 265 | }); 266 | 267 | bindOnRule( rule, cy, 'free', 'node', function(){ 268 | rule.grabbedNode = null; 269 | rule.delta = null; 270 | rule.p1 = null; 271 | }); 272 | }; 273 | }; 274 | 275 | let matchingNodesListener = function( rule ){ 276 | return function( update, cy ){ 277 | bindOnRule( rule, cy, 'position', 'node', function(){ 278 | let movedNode = this; 279 | 280 | if( rule.matches( movedNode ) ){ 281 | update( cy, [ rule ] ); 282 | } 283 | }); 284 | }; 285 | }; 286 | 287 | let getListener = function( cy, rule ){ 288 | if( rule.reposition === 'mean' ){ 289 | return meanListener( rule ); 290 | } else if( rule.reposition === 'drag' ){ 291 | return dragListener( rule ); 292 | } else if( 293 | isObject( rule.reposition ) 294 | || rule.when === 'matching' 295 | || rule.reposition === 'viewport' 296 | ){ 297 | return matchingNodesListener( rule ); 298 | } else { 299 | return rule.when; 300 | } 301 | }; 302 | 303 | let addRule = function( cy, scratch, options ){ 304 | let rule = assign( {}, defaults, options ); 305 | 306 | rule.getNewPos = getRepositioner( rule, cy ); 307 | rule.listener = getListener( cy, rule ); 308 | 309 | let nodesAreCollection = isCollection( rule.nodesMatching ); 310 | 311 | if( nodesAreCollection ){ 312 | rule.nodes = rule.nodesMatching.slice(); 313 | 314 | rule.matches = function( ele ){ return eleExists( ele ) && elesHasEle( rule.nodes, ele ); }; 315 | } else { 316 | let matches = getEleMatchesSpecFn( rule.nodesMatching ); 317 | 318 | rule.matches = function( ele ){ return eleExists( ele ) && matches( ele ); }; 319 | } 320 | 321 | if( rule.dragWith != null ){ 322 | rule.dragWithMatches = getEleMatchesSpecFn( rule.dragWith ); 323 | } 324 | 325 | rule.listener( function(){ 326 | update( cy, [ rule ] ); 327 | }, cy ); 328 | 329 | rule.enabled = true; 330 | 331 | scratch.rules.push( rule ); 332 | 333 | return rule; 334 | }; 335 | 336 | let bindForNodeList = function( cy, scratch ){ 337 | scratch.onAddNode = function( evt ){ 338 | let target = evt.target; 339 | 340 | scratch.nodes.merge( target ); 341 | }; 342 | 343 | scratch.onRmNode = function( evt ){ 344 | let target = evt.target; 345 | 346 | scratch.nodes.unmerge( target ); 347 | }; 348 | 349 | cy.on('add', 'node', scratch.onAddNode); 350 | cy.on('remove', 'node', scratch.onRmNode); 351 | }; 352 | 353 | let unbindForNodeList = function( cy, scratch ){ 354 | cy.removeListener('add', 'node', scratch.onAddNode); 355 | cy.removeListener('remove', 'node', scratch.onRmNode); 356 | }; 357 | 358 | let update = function( cy, rules ){ 359 | let scratch = cy.scratch().automove; 360 | 361 | rules = rules != null ? rules : scratch.rules; 362 | 363 | cy.batch(function(){ // batch for performance 364 | for( let i = 0; i < rules.length; i++ ){ 365 | let rule = rules[i]; 366 | 367 | if( rule.destroyed || !rule.enabled ){ break; } // ignore destroyed rules b/c user may use custom when() 368 | 369 | let nodes = rule.nodes || scratch.nodes; 370 | 371 | for( let j = nodes.length - 1; j >= 0; j-- ){ 372 | let node = nodes[j]; 373 | 374 | if( node.removed() ){ // remove from list for perf 375 | nodes.unmerge( node ); 376 | continue; 377 | } 378 | 379 | if( !rule.matches(node) ){ continue; } 380 | 381 | let pos = node.position(); 382 | let newPos = rule.getNewPos( node ); 383 | let newPosIsDiff = newPos != null && ( pos.x !== newPos.x || pos.y !== newPos.y ); 384 | 385 | if( newPosIsDiff ){ // only update on diff for perf 386 | node.position( newPos ); 387 | 388 | node.trigger( 'automove', [rule] ); 389 | } 390 | } 391 | } 392 | }); 393 | }; 394 | 395 | let automove = function( options ){ 396 | let cy = this; 397 | 398 | let scratch = cy.scratch().automove = cy.scratch().automove || { 399 | rules: [] 400 | }; 401 | 402 | if( scratch.rules.length === 0 ){ 403 | scratch.nodes = cy.nodes().slice(); 404 | 405 | bindForNodeList( cy, scratch ); 406 | } 407 | 408 | if( options === 'destroy' ){ 409 | scratch.rules.forEach(function( r ){ 410 | unbindAllOnRule( r ); 411 | 412 | r.destroyed = true; 413 | }); 414 | 415 | scratch.rules.splice( 0, scratch.rules.length ); 416 | 417 | unbindForNodeList( cy, scratch ); 418 | 419 | return; 420 | } 421 | 422 | let rule = addRule( cy, scratch, options ); 423 | 424 | update( cy, [ rule ] ); // do an initial update to make sure the start state is correct 425 | 426 | return { 427 | apply: function(){ 428 | update( cy, [ rule ] ); 429 | }, 430 | 431 | disable: function(){ 432 | this.toggle( false ); 433 | }, 434 | 435 | enable: function(){ 436 | this.toggle( true ); 437 | }, 438 | 439 | enabled: function(){ 440 | return rule.enabled; 441 | }, 442 | 443 | toggle: function( on ){ 444 | rule.enabled = on !== undefined ? on : !rule.enabled; 445 | 446 | if( rule.enabled ){ 447 | update( cy, [ rule ] ); 448 | } 449 | }, 450 | 451 | destroy: function(){ 452 | let rules = scratch.rules; 453 | 454 | unbindAllOnRule( rule ); 455 | 456 | rule.destroyed = true; 457 | 458 | rules.splice( rules.indexOf( rule ), 1 ); 459 | 460 | if( rules.length === 0 ){ 461 | unbindForNodeList( cy, scratch ); 462 | } 463 | 464 | return this; 465 | } 466 | }; 467 | }; 468 | 469 | module.exports = automove; 470 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | 2 | /* eslint-disable no-unused-vars */ 3 | let defaults = { 4 | // specify nodes that should be automoved with one of 5 | // - a function that returns true for matching nodes 6 | // - a selector that matches the nodes 7 | // - a collection of nodes (very good for performance) 8 | nodesMatching: function( node ){ return false; }, 9 | 10 | // specify how a node's position should be updated with one of 11 | // - function( node ){ return { x: 1, y: 2 }; } => put the node where the function returns 12 | // - { x1, y1, x2, y2 } => constrain the node position within the bounding box (in model co-ordinates) 13 | // - { x1, y1, x2, y2, type: 'inside' } => constrain the node position within the bounding box (in model co-ordinates) 14 | // - { x1, y1, x2, y2, type: 'outside' } => constrain the node position outside the bounding box (in model co-ordinates) 15 | // - 'mean' => put the node in the average position of its neighbourhood 16 | // - 'viewport' => keeps the node body within the viewport 17 | // - 'drag' => matching nodes are effectively dragged along 18 | reposition: 'mean', 19 | 20 | // specify when the repositioning should occur by specifying a function that 21 | // calls update() when reposition updates should occur 22 | // - function( update ){ /* ... */ update(); } => a manual function for updating 23 | // - 'matching' => automatically update on position events for nodesMatching 24 | // - set efficiently and automatically for 25 | // - reposition: 'mean' 26 | // - reposition: { x1, y1, x2, y2 } 27 | // - reposition: 'viewport' 28 | // - reposition: 'drag' 29 | // - default/undefined => on a position event for any node (not as efficient...) 30 | when: undefined, 31 | 32 | 33 | 34 | // 35 | // customisation options for non-function `reposition` values 36 | // 37 | 38 | // `reposition: 'mean'` 39 | 40 | // specify nodes that should be ignored in the mean calculation 41 | // - a function that returns true for nodes to be ignored 42 | // - a selector that matches the nodes to be ignored 43 | // - a collection of nodes to be ignored (very good for performance) 44 | meanIgnores: function( node ){ return false; }, 45 | 46 | // specify whether moving a particular `nodesMatching` node causes repositioning 47 | // - true : the mid node can't be independently moved/dragged 48 | // - false : the mid node can be independently moved/dragged (useful if you want the mid node to use `reposition: 'drag' in another rule with its neighbourhood`) 49 | meanOnSelfPosition: function( node ){ return true; }, 50 | 51 | // `reposition: 'drag'` 52 | 53 | // specify nodes that when dragged cause the matched nodes to move along (i.e. the master nodes) 54 | // - a function that returns true for nodes to be listened to for drag events 55 | // - a selector that matches the nodes to be listened to for drag events 56 | // - a collection of nodes to be listened to for drag events (very good for performance) 57 | dragWith: function( node ){ return false; } 58 | }; 59 | 60 | /* eslint-enable */ 61 | 62 | module.exports = defaults; 63 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const automove = require('./automove'); 2 | 3 | // registers the extension on a cytoscape lib ref 4 | let register = function( cytoscape ){ 5 | if( !cytoscape ){ return; } // can't register if cytoscape unspecified 6 | 7 | cytoscape( 'core', 'automove', automove ); // register with cytoscape.js 8 | }; 9 | 10 | if( typeof cytoscape !== 'undefined' ){ // expose to global cytoscape (i.e. window.cytoscape) 11 | register( cytoscape ); 12 | } 13 | 14 | module.exports = register; 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pkg = require('./package.json'); 3 | const camelcase = require('camelcase'); 4 | const process = require('process'); 5 | const webpack = require('webpack'); 6 | const env = process.env; 7 | const NODE_ENV = env.NODE_ENV; 8 | const MIN = env.MIN; 9 | const PROD = NODE_ENV === 'production'; 10 | 11 | let config = { 12 | devtool: PROD ? false : 'inline-source-map', 13 | entry: './src/index.js', 14 | output: { 15 | path: path.join( __dirname ), 16 | filename: pkg.name + '.js', 17 | library: camelcase( pkg.name ), 18 | libraryTarget: 'umd' 19 | }, 20 | module: { 21 | rules: [ 22 | { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' } 23 | ] 24 | }, 25 | externals: PROD ? Object.keys( pkg.dependencies || {} ) : [], 26 | plugins: MIN ? [ 27 | new webpack.optimize.UglifyJsPlugin({ 28 | compress: { 29 | warnings: false, 30 | drop_console: false, 31 | } 32 | }) 33 | ] : [] 34 | }; 35 | 36 | module.exports = config; 37 | --------------------------------------------------------------------------------