├── .gitignore ├── assets └── screenshot.png ├── package.json ├── LICENSE ├── README.md ├── test └── cmap-test.js └── cmap.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionstage/cmap/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmap", 3 | "version": "0.1.3", 4 | "description": "Interactive visualization library for concept map", 5 | "main": "cmap.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "devDependencies": { 10 | "mocha": "^2.2.5", 11 | "sinon": "^1.17.2" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/ionstage/cmap.git" 16 | }, 17 | "keywords": [ 18 | "concept map", 19 | "interactive", 20 | "visualization" 21 | ], 22 | "author": "iOnStage", 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 iOnStage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cmap 2 | 3 | Interactive visualization library for concept map 4 | 5 | [Demo](https://jsfiddle.net/k3uang1e/) 6 | 7 | ![Screen Shot](assets/screenshot.png) 8 | 9 | ```js 10 | var cmap = Cmap(); 11 | 12 | var node0 = cmap.node({content: 'Rock', x: 100, y: 100}); 13 | var node1 = cmap.node({content: 'Paper', x: 180, y: 320}); 14 | var node2 = cmap.node({content: 'Scissors', x: 360, y: 180}); 15 | 16 | var link0 = cmap.link({content: 'beats'}); 17 | var link1 = cmap.link({content: 'beats'}); 18 | var link2 = cmap.link({content: 'beats'}); 19 | 20 | link0 21 | .sourceNode(node0) 22 | .targetNode(node2); 23 | 24 | link1 25 | .sourceNode(node1) 26 | .targetNode(node0); 27 | 28 | link2 29 | .sourceNode(node2) 30 | .targetNode(node1) 31 | .attr({ 32 | cx: 356, 33 | cy: 335 34 | }); 35 | ``` 36 | 37 | ## Features 38 | 39 | - Draw and construct a concept map in a browser 40 | - Support touch devices 41 | - Standalone, no dependencies 42 | 43 | ## Usage 44 | 45 | ``` 46 | 47 | ``` 48 | 49 | Works on IE10+, Firefox, Safari, Chrome 50 | 51 | ## API 52 | 53 |

Cmap()
54 | Cmap(element)

55 | 56 | Create a element or wrap a existing element for drawing a concept map. 57 | 58 | ```js 59 | // create a element in document.body 60 | var cmap = Cmap(); 61 | ``` 62 | 63 | ```js 64 | // wrap a existing element 65 | var container = document.getElementById('container'); 66 | var cmap = Cmap(container); 67 | ``` 68 | 69 |

cmap.node(props)

70 | 71 | Create and draw a node. 72 | You can set the attributes of the node (see [*node.attr()*](#node-attr)). 73 | 74 | ```js 75 | var cmap = Cmap(); 76 | 77 | // node with setting the text content, position and size 78 | var node = cmap.node({ 79 | content: 'Rock', 80 | x: 10, 81 | y: 20, 82 | width: 60, 83 | height: 40 84 | }); 85 | ``` 86 | 87 |

cmap.link(props)

88 | 89 | Create and draw a link. 90 | You can set the attributes of the link (see [*link.attr()*](#link-attr)). 91 | 92 | ```js 93 | var cmap = Cmap(); 94 | 95 | // link with setting the text content and center position 96 | var link = cmap.link({ 97 | content: 'beats', 98 | cx: 100, 99 | cy: 200 100 | }); 101 | ``` 102 | 103 |

node.attr()
104 | node.attr(key)
105 | node.attr(key, value)
106 | node.attr(props)

107 | 108 | Get or set given attributes of the node. 109 | 110 |
Possible parameters
111 | 112 | - **content**: [string] the text string to draw (default: "") 113 | - **contentType**: [string] name of the content type, "text" or "html" (default: "text") 114 | - **x**: [number] x coordinate of the top left corner (default: 0) 115 | - **y**: [number] y coordinate of the top left corner (default: 0) 116 | - **width**: [number] width (default: 75) 117 | - **height**: [number] height (default: 30) 118 | - **backgroundColor**: [string] background color (default: "#a7cbe6") 119 | - **borderColor**: [string] color of the four sides of a border (default: "#333") 120 | - **borderWidth**: [number] width of the border (default: 2) 121 | - **textColor**: [string] foreground color of the text content (default: "#333") 122 | 123 | ```js 124 | var node = cmap.node({ 125 | content: 'Rock', 126 | x: 10, 127 | y: 20 128 | }); 129 | 130 | // get all attributes as object 131 | var attr = node.attr(); 132 | 133 | // get the value of an attribute 134 | var x = node.attr('x'); 135 | 136 | // set the value of an attribute 137 | node.attr('x', 100); 138 | 139 | // set multiple attributes 140 | node.attr({ 141 | x: 20, 142 | y: 30 143 | }); 144 | ``` 145 | 146 |

node.remove()

147 | 148 | Remove the node from the concept map. 149 | 150 |

node.toFront()

151 | 152 | Move the node on top of other nodes in the z-order. 153 | 154 |

node.element()

155 | 156 | Get the DOM element of the node. 157 | 158 |

node.redraw()

159 | 160 | Normally, creating and updating DOM elements of a concept map is along with the browser's normal redraw cycle (achieved by *window.requestAnimationFrame*). 161 | 162 | You can force a synchronous redraw. 163 | 164 | ```js 165 | var node = cmap.node({ 166 | content: 'Rock' 167 | }); 168 | 169 | console.log(node.element()); // null (not yet created) 170 | 171 | node.redraw(); 172 | 173 | var element = node.element(); 174 | 175 | console.log(element); // [object HTMLDivElement] 176 | console.log(element.textContent); // Rock 177 | 178 | node.attr('content', 'Paper'); 179 | 180 | console.log(node.attr('content')); // Paper 181 | console.log(element.textContent); // Rock (not yet updated) 182 | 183 | node.redraw(); 184 | 185 | console.log(element.textContent); // Paper 186 | ``` 187 | 188 |

node.draggable()
189 | node.draggable(true|false)

190 | 191 | Get or set whether or not to allow dragging the node (default: true). 192 | 193 | 197 | 198 | Get or set given attributes of the link. 199 | 200 |
Possible parameters
201 | 202 | - **content**: [string] the text string to draw (default: "") 203 | - **contentType**: [string] name of the content type, "text" or "html" (default: "text") 204 | - **cx**: [number] x coordinate of the center of the text content (default: 100) 205 | - **cy**: [number] y coordinate of the center of the text content (default: 40) 206 | - **width**: [number] width of the text content (default: 50) 207 | - **height**: [number] height of the text content (default: 20) 208 | - **backgroundColor**: [string] background color of the text content (default: "white") 209 | - **borderColor**: [string] color of the four sides of a border of the text content (default: "#333") 210 | - **borderWidth**: [number] width of the border of the text content (default: 2) 211 | - **textColor**: [string] foreground color of the text content (default: "#333") 212 | - **sourceX**: [number] x coordinate of the starting point of the path (default: *cx* - 70) 213 | - **sourceY**: [number] y coordinate of the starting point of the path (default: *cy*) 214 | - **targetX**: [number] x coordinate of the ending point of the path (default: *cx* + 70) 215 | - **targetY**: [number] y coordinate of the ending point of the path (default: *cy*) 216 | - **lineColor**: [string] color of a border of the path (default: "#333") 217 | - **lineWidth**: [number] width of the border of the path (default: 2) 218 | - **hasArrow**: [boolean] drawing arrow at ending point of the path (default: true) 219 | 220 | ```js 221 | var link = cmap.link({ 222 | content: 'beats', 223 | cx: 100, 224 | cy: 200 225 | }); 226 | 227 | // get all attributes as object 228 | var attr = link.attr(); 229 | 230 | // get the value of an attribute 231 | var cx = link.attr('cx'); 232 | 233 | // set the value of an attribute 234 | link.attr('cx', 150); 235 | 236 | // set multiple attributes 237 | link.attr({ 238 | cx: 200, 239 | cy: 300 240 | }); 241 | ``` 242 | 243 |

link.remove()

244 | 245 | Remove the link from the concept map. 246 | 247 |

link.toFront()

248 | 249 | Move the link on top of other links in the z-order. 250 | 251 |

link.element()

252 | 253 | Get the DOM element of the link. 254 | 255 |

link.redraw()

256 | 257 | Force a synchronous redraw of the link (same as [*node.redraw()*](#node-redraw)). 258 | 259 |

link.draggable()
260 | link.draggable(true|false)

261 | 262 | Get or set whether or not to allow dragging the link (default: true). 263 | 264 |

link.sourceNode()
265 | link.sourceNode(node)
266 | link.sourceNode(null)

267 | 268 | Get or set the node which is connected to the starting point of the path. 269 | 270 | ```js 271 | var link = cmap.link(); 272 | var node = cmap.node(); 273 | 274 | console.log(link.sourceNode()); // null 275 | 276 | // connect the node to the starting point of the path 277 | link.sourceNode(node); 278 | 279 | console.log(link.sourceNode() == node); // true 280 | 281 | // disconnect the node 282 | link.sourceNode(null); 283 | 284 | console.log(link.sourceNode()); // null 285 | ``` 286 | 287 |

link.targetNode()
288 | link.targetNode(node)
289 | link.targetNode(null)

290 | 291 | Get or set the node which is connected to the ending point of the path. 292 | 293 | ```js 294 | var link = cmap.link(); 295 | var node = cmap.node(); 296 | 297 | console.log(link.targetNode()); // null 298 | 299 | // connect the node to the ending point of the path 300 | link.targetNode(node); 301 | 302 | console.log(link.targetNode() == node); // true 303 | 304 | // disconnect the node 305 | link.targetNode(null); 306 | 307 | console.log(link.targetNode()); // null 308 | ``` 309 | 310 |

link.sourceConnectorEnabled()
311 | link.sourceConnectorEnabled(true|false)

312 | 313 | Get or set whether or not to enable a connector at the starting point of the path (default: true). 314 | 315 |

link.targetConnectorEnabled()
316 | link.targetConnectorEnabled(true|false)

317 | 318 | Get or set whether or not to enable a connector at the ending point of the path (default: true). 319 | 320 | ## Running tests 321 | 322 | Clone the repository and install the developer dependencies: 323 | 324 | ``` 325 | git clone https://github.com/ionstage/cmap.git 326 | cd cmap 327 | npm install 328 | ``` 329 | 330 | Then: 331 | 332 | ``` 333 | npm test 334 | ``` 335 | 336 | ## License 337 | 338 | Copyright © 2015 iOnStage 339 | Licensed under the [MIT License][mit]. 340 | 341 | [MIT]: http://www.opensource.org/licenses/mit-license.php 342 | -------------------------------------------------------------------------------- /test/cmap-test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var sinon = require('sinon'); 3 | var Cmap = require('../cmap.js'); 4 | 5 | describe('Cmap', function() { 6 | var cmap; 7 | 8 | beforeEach(function() { 9 | cmap = Cmap(); 10 | }); 11 | 12 | it('#node', function() { 13 | assert.doesNotThrow(function() { cmap.node(); }); 14 | assert.doesNotThrow(function() { cmap.node({}); }); 15 | assert.throws(function() { cmap.node(null); }, TypeError); 16 | assert.throws(function() { cmap.node('a'); }, TypeError); 17 | assert.throws(function() { cmap.node(1); }, TypeError); 18 | assert.throws(function() { cmap.node([]); }, TypeError); 19 | }); 20 | 21 | it('#link', function() { 22 | assert.doesNotThrow(function() { cmap.link(); }); 23 | assert.doesNotThrow(function() { cmap.link({}); }); 24 | assert.throws(function() { cmap.link(null); }, TypeError); 25 | assert.throws(function() { cmap.link('a'); }, TypeError); 26 | assert.throws(function() { cmap.link(1); }, TypeError); 27 | assert.throws(function() { cmap.link([]); }, TypeError); 28 | }); 29 | }); 30 | 31 | describe('Node', function() { 32 | var cmap; 33 | var cmapComponent; 34 | 35 | before(function() { 36 | // for internal access 37 | Cmap.prototype._component = function() { 38 | return this.component; 39 | }; 40 | }); 41 | 42 | beforeEach(function() { 43 | cmap = Cmap(); 44 | cmapComponent = cmap._component(); 45 | }); 46 | 47 | describe('#attr', function() { 48 | var props = { 49 | content: 'node', 50 | contentType: 'html', 51 | x: 100, 52 | y: 200, 53 | width: 120, 54 | height: 45, 55 | backgroundColor: 'white', 56 | borderColor: 'black', 57 | borderWidth: 4, 58 | textColor: 'black' 59 | }; 60 | 61 | it('attr()', function() { 62 | var node = cmap.node(props); 63 | assert.deepEqual(node.attr(), props); 64 | }); 65 | 66 | it('attr(key)', function() { 67 | var node = cmap.node(props); 68 | for (var key in props) { 69 | assert.equal(node.attr(key), props[key]); 70 | } 71 | }); 72 | 73 | it('attr(key, value)', function() { 74 | var node = cmap.node(); 75 | for (var key in props) { 76 | node.attr(key, props[key]); 77 | } 78 | assert.deepEqual(node.attr(), props); 79 | }); 80 | 81 | it('attr(props)', function() { 82 | var node = cmap.node(); 83 | node.attr(props); 84 | assert.deepEqual(node.attr(), props); 85 | }); 86 | 87 | it('validate type of value', function() { 88 | var node = cmap.node(props); 89 | node.attr({ 90 | content: 1, 91 | contentType: 1, 92 | x: '200', 93 | y: '300', 94 | width: '220', 95 | height: '145', 96 | backgroundColor: 1, 97 | borderColor: 1, 98 | borderWidth: '8', 99 | textColor: 1 100 | }); 101 | assert.deepStrictEqual(node.attr(), { 102 | content: '1', 103 | contentType: 'html', 104 | x: 200, 105 | y: 300, 106 | width: 220, 107 | height: 145, 108 | backgroundColor: '1', 109 | borderColor: '1', 110 | borderWidth: 8, 111 | textColor: '1' 112 | }); 113 | }); 114 | }); 115 | 116 | it('#remove', function() { 117 | var node = cmap.node(); 118 | var nodeComponent = node(cmapComponent).component; 119 | cmapComponent.remove = sinon.spy(); 120 | node.remove(); 121 | assert(cmapComponent.remove.calledWith(nodeComponent)); 122 | }); 123 | 124 | it('#toFront', function() { 125 | var node = cmap.node(); 126 | var nodeComponent = node(cmapComponent).component; 127 | cmapComponent.toFront = sinon.spy(); 128 | node.toFront(); 129 | assert(cmapComponent.toFront.calledWith(nodeComponent)); 130 | }); 131 | 132 | it('#element', function() { 133 | var node = cmap.node(); 134 | var nodeComponent = node(cmapComponent).component; 135 | var dummy = {}; 136 | nodeComponent.element = sinon.spy(function() { 137 | return dummy; 138 | }); 139 | assert.strictEqual(node.element(), dummy); 140 | }); 141 | 142 | it('#redraw', function() { 143 | var node = cmap.node(); 144 | var nodeComponent = node(cmapComponent).component; 145 | nodeComponent.redraw = sinon.spy(); 146 | node.redraw(); 147 | assert(nodeComponent.redraw.called); 148 | }); 149 | 150 | describe('#draggable', function() { 151 | it('draggable()', function() { 152 | var node = cmap.node(); 153 | assert.equal(node.draggable(), true); 154 | node.draggable(false); 155 | assert.equal(node.draggable(), false); 156 | }); 157 | 158 | it('draggable(enabled)', function() { 159 | var node = cmap.node(); 160 | var nodeComponent = node(cmapComponent).component; 161 | cmapComponent.enableDrag = sinon.spy(); 162 | node.draggable(true); 163 | assert(cmapComponent.enableDrag.calledWith(nodeComponent)); 164 | cmapComponent.disableDrag = sinon.spy(); 165 | node.draggable(false); 166 | assert(cmapComponent.disableDrag.calledWith(nodeComponent)); 167 | }); 168 | }); 169 | }); 170 | 171 | describe('Link', function() { 172 | var cmap; 173 | var cmapComponent; 174 | 175 | before(function() { 176 | // for internal access 177 | Cmap.prototype._component = function() { 178 | return this.component; 179 | }; 180 | }); 181 | 182 | beforeEach(function() { 183 | cmap = Cmap(); 184 | cmapComponent = cmap._component(); 185 | }); 186 | 187 | describe('#attr', function() { 188 | var props = { 189 | content: 'link', 190 | contentType: 'html', 191 | cx: 100, 192 | cy: 200, 193 | width: 120, 194 | height: 45, 195 | backgroundColor: 'black', 196 | borderColor: 'white', 197 | borderWidth: 4, 198 | textColor: 'white', 199 | sourceX: 50, 200 | sourceY: 100, 201 | targetX: 150, 202 | targetY: 300, 203 | lineColor: 'white', 204 | lineWidth: 4, 205 | hasArrow: true 206 | }; 207 | 208 | it('attr()', function() { 209 | var link = cmap.link(props); 210 | assert.deepEqual(link.attr(), props); 211 | }); 212 | 213 | it('attr(key)', function() { 214 | var link = cmap.link(props); 215 | for (var key in props) { 216 | assert.equal(link.attr(key), props[key]); 217 | } 218 | }); 219 | 220 | it('attr(key, value)', function() { 221 | var link = cmap.link(); 222 | for (var key in props) { 223 | link.attr(key, props[key]); 224 | } 225 | assert.deepEqual(link.attr(), props); 226 | }); 227 | 228 | it('attr(props)', function() { 229 | var link = cmap.link(); 230 | link.attr(props); 231 | assert.deepEqual(link.attr(), props); 232 | }); 233 | 234 | it('validate type of value', function() { 235 | var link = cmap.link(props); 236 | link.attr({ 237 | content: 1, 238 | contentType: 1, 239 | cx: '200', 240 | cy: '300', 241 | width: '320', 242 | height: '145', 243 | backgroundColor: 1, 244 | borderColor: 1, 245 | borderWidth: '8', 246 | textColor: 1, 247 | sourceX: '150', 248 | sourceY: '200', 249 | targetX: '250', 250 | targetY: '400', 251 | lineColor: 1, 252 | lineWidth: '8', 253 | hasArrow: 1 254 | }); 255 | assert.deepStrictEqual(link.attr(), { 256 | content: '1', 257 | contentType: 'html', 258 | cx: 200, 259 | cy: 300, 260 | width: 320, 261 | height: 145, 262 | backgroundColor: '1', 263 | borderColor: '1', 264 | borderWidth: 8, 265 | textColor: '1', 266 | sourceX: 150, 267 | sourceY: 200, 268 | targetX: 250, 269 | targetY: 400, 270 | lineColor: '1', 271 | lineWidth: 8, 272 | hasArrow: true 273 | }); 274 | }); 275 | }); 276 | 277 | it('#remove', function() { 278 | var link = cmap.link(); 279 | var linkComponent = link(cmapComponent).component; 280 | cmapComponent.remove = sinon.spy(); 281 | link.remove(); 282 | assert(cmapComponent.remove.calledWith(linkComponent)); 283 | }); 284 | 285 | it('#toFront', function() { 286 | var link = cmap.link(); 287 | var linkComponent = link(cmapComponent).component; 288 | cmapComponent.toFront = sinon.spy(); 289 | link.toFront(); 290 | assert(cmapComponent.toFront.calledWith(linkComponent)); 291 | }); 292 | 293 | it('#element', function() { 294 | var link = cmap.link(); 295 | var linkComponent = link(cmapComponent).component; 296 | var dummy = {}; 297 | linkComponent.element = sinon.spy(function() { 298 | return dummy; 299 | }); 300 | assert.strictEqual(link.element(), dummy); 301 | }); 302 | 303 | it('#redraw', function() { 304 | var link = cmap.link(); 305 | var linkComponent = link(cmapComponent).component; 306 | linkComponent.redraw = sinon.spy(); 307 | link.redraw(); 308 | assert(linkComponent.redraw.called); 309 | }); 310 | 311 | describe('#draggable', function() { 312 | it('draggable()', function() { 313 | var link = cmap.link(); 314 | assert.equal(link.draggable(), true); 315 | link.draggable(false); 316 | assert.equal(link.draggable(), false); 317 | }); 318 | 319 | it('draggable(enabled)', function() { 320 | var link = cmap.link(); 321 | var linkComponent = link(cmapComponent).component; 322 | cmapComponent.enableDrag = sinon.spy(); 323 | link.draggable(true); 324 | assert(cmapComponent.enableDrag.calledWith(linkComponent)); 325 | cmapComponent.disableDrag = sinon.spy(); 326 | link.draggable(false); 327 | assert(cmapComponent.disableDrag.calledWith(linkComponent)); 328 | }); 329 | }); 330 | 331 | describe('#sourceNode', function() { 332 | it('sourceNode()', function() { 333 | var link = cmap.link(); 334 | var node = cmap.node(); 335 | assert.equal(link.sourceNode(), null); 336 | link.sourceNode(node); 337 | assert.equal(link.sourceNode(), node); 338 | }); 339 | 340 | it('sourceNode(node)', function() { 341 | var link = cmap.link(); 342 | var node = cmap.node(); 343 | var linkComponent = link(cmapComponent).component; 344 | var nodeComponent = node(cmapComponent).component; 345 | cmapComponent.connect = sinon.spy(); 346 | link.sourceNode(node); 347 | assert(cmapComponent.connect.calledWith('source', nodeComponent, linkComponent)); 348 | }); 349 | 350 | it('sourceNode(null)', function() { 351 | var link = cmap.link(); 352 | var node = cmap.node(); 353 | var linkComponent = link(cmapComponent).component; 354 | var nodeComponent = node(cmapComponent).component; 355 | link.sourceNode(node); 356 | cmapComponent.disconnect = sinon.spy(); 357 | link.sourceNode(null); 358 | assert(cmapComponent.disconnect.calledWith('source', nodeComponent, linkComponent)); 359 | }); 360 | 361 | it('should throw exception for invalid node', function() { 362 | var link = cmap.link(); 363 | var node = cmap.node(); 364 | var nodeComponent = node(cmapComponent).component; 365 | assert.throws(function() { link.sourceNode({}); }, TypeError); 366 | assert.throws(function() { 367 | link.sourceNode(function() { 368 | return { 369 | component: nodeComponent 370 | }; 371 | }); 372 | }, TypeError); 373 | }); 374 | }); 375 | 376 | describe('#targetNode', function() { 377 | it('targetNode()', function() { 378 | var link = cmap.link(); 379 | var node = cmap.node(); 380 | assert.equal(link.targetNode(), null); 381 | link.targetNode(node); 382 | assert.equal(link.targetNode(), node); 383 | }); 384 | 385 | it('targetNode(node)', function() { 386 | var link = cmap.link(); 387 | var node = cmap.node(); 388 | var linkComponent = link(cmapComponent).component; 389 | var nodeComponent = node(cmapComponent).component; 390 | cmapComponent.connect = sinon.spy(); 391 | link.targetNode(node); 392 | assert(cmapComponent.connect.calledWith('target', nodeComponent, linkComponent)); 393 | }); 394 | 395 | it('targetNode(null)', function() { 396 | var link = cmap.link(); 397 | var node = cmap.node(); 398 | var linkComponent = link(cmapComponent).component; 399 | var nodeComponent = node(cmapComponent).component; 400 | link.targetNode(node); 401 | cmapComponent.disconnect = sinon.spy(); 402 | link.targetNode(null); 403 | assert(cmapComponent.disconnect.calledWith('target', nodeComponent, linkComponent)); 404 | }); 405 | 406 | it('should throw exception for invalid node', function() { 407 | var link = cmap.link(); 408 | var node = cmap.node(); 409 | var nodeComponent = node(cmapComponent).component; 410 | assert.throws(function() { link.targetNode({}); }, TypeError); 411 | assert.throws(function() { 412 | link.targetNode(function() { 413 | return { 414 | component: nodeComponent 415 | }; 416 | }); 417 | }, TypeError); 418 | }); 419 | }); 420 | 421 | describe('#sourceConnectorEnabled', function() { 422 | it('sourceConnectorEnabled()', function() { 423 | var link = cmap.link(); 424 | assert.equal(link.sourceConnectorEnabled(), true); 425 | link.sourceConnectorEnabled(false); 426 | assert.equal(link.sourceConnectorEnabled(), false); 427 | }); 428 | 429 | it('sourceConnectorEnabled(enabled)', function() { 430 | var link = cmap.link(); 431 | var linkComponent = link(cmapComponent).component; 432 | cmapComponent.enableConnector = sinon.spy(); 433 | link.sourceConnectorEnabled(true); 434 | assert(cmapComponent.enableConnector.calledWith('source', linkComponent)); 435 | cmapComponent.disableConnector = sinon.spy(); 436 | link.sourceConnectorEnabled(false); 437 | assert(cmapComponent.disableConnector.calledWith('source', linkComponent)); 438 | }); 439 | }); 440 | 441 | describe('#targetConnectorEnabled', function() { 442 | it('targetConnectorEnabled()', function() { 443 | var link = cmap.link(); 444 | assert.equal(link.targetConnectorEnabled(), true); 445 | link.targetConnectorEnabled(false); 446 | assert.equal(link.targetConnectorEnabled(), false); 447 | }); 448 | 449 | it('targetConnectorEnabled(enabled)', function() { 450 | var link = cmap.link(); 451 | var linkComponent = link(cmapComponent).component; 452 | cmapComponent.enableConnector = sinon.spy(); 453 | link.targetConnectorEnabled(true); 454 | assert(cmapComponent.enableConnector.calledWith('target', linkComponent)); 455 | cmapComponent.disableConnector = sinon.spy(); 456 | link.targetConnectorEnabled(false); 457 | assert(cmapComponent.disableConnector.calledWith('target', linkComponent)); 458 | }); 459 | }); 460 | }); 461 | -------------------------------------------------------------------------------- /cmap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * cmap v0.1.3 3 | * (c) 2015 iOnStage 4 | * Released under the MIT License. 5 | */ 6 | 7 | (function() { 8 | 'use strict'; 9 | 10 | var helper = {}; 11 | 12 | helper.toNumber = function(value, defaultValue) { 13 | return !isNaN(value) ? +value : defaultValue; 14 | }; 15 | 16 | helper.toString = function(value, defaultValue) { 17 | return (typeof value !== 'undefined') ? '' + value : defaultValue; 18 | }; 19 | 20 | helper.toBoolean = function(value, defaultValue) { 21 | return (typeof value !== 'undefined') ? !!value : defaultValue; 22 | }; 23 | 24 | helper.toContentType = function(value, defaultValue) { 25 | if (value === helper.CONTENT_TYPE_TEXT || value === helper.CONTENT_TYPE_HTML) 26 | return value; 27 | 28 | return defaultValue; 29 | }; 30 | 31 | helper.isPlainObject = function(obj) { 32 | return (typeof obj === 'object' && obj !== null && 33 | Object.prototype.toString.call(obj) === '[object Object]'); 34 | }; 35 | 36 | helper.inherits = function(ctor, superCtor) { 37 | ctor.super_ = superCtor; 38 | ctor.prototype = Object.create(superCtor.prototype, { 39 | constructor: { 40 | value: ctor, 41 | enumerable: false, 42 | writable: true, 43 | configurable: true 44 | } 45 | }); 46 | 47 | return ctor; 48 | }; 49 | 50 | helper.wrap = (function() { 51 | var Wrapper = function(obj, key) { 52 | this.obj = obj; 53 | this.key = key; 54 | 55 | var wrapper = unwrap.bind(this); 56 | var proto = Object.getPrototypeOf(obj); 57 | 58 | for (var key in proto) { 59 | wrapper[key] = chain(proto[key], obj); 60 | } 61 | 62 | return wrapper; 63 | }; 64 | 65 | var unwrap = function(key) { 66 | if (this.key === key) 67 | return this.obj; 68 | }; 69 | 70 | var chain = function(func, ctx) { 71 | return function() { 72 | var ret = func.apply(ctx, arguments); 73 | 74 | if (typeof ret === 'undefined') 75 | return this; 76 | 77 | return ret; 78 | }; 79 | }; 80 | 81 | return function(obj, key) { 82 | return new Wrapper(obj, key); 83 | }; 84 | })(); 85 | 86 | helper.deactivate = function(obj) { 87 | for (var key in obj) { 88 | delete obj[key]; 89 | } 90 | }; 91 | 92 | helper.eachInstance = function(array, ctor, callback) { 93 | array.filter(function(obj) { 94 | return obj instanceof ctor; 95 | }).forEach(callback); 96 | }; 97 | 98 | helper.firstInstance = function(array, ctor) { 99 | return array.filter(function(obj) { 100 | return obj instanceof ctor; 101 | })[0]; 102 | }; 103 | 104 | helper.diffObj = function(newObj, oldObj) { 105 | var diff = {}; 106 | 107 | for (var key in newObj) { 108 | if (!oldObj || newObj[key] !== oldObj[key]) 109 | diff[key] = newObj[key]; 110 | } 111 | 112 | return diff; 113 | }; 114 | 115 | helper.pick = function(obj, keys) { 116 | var ret = {}; 117 | 118 | if (!obj) 119 | return ret; 120 | 121 | keys.forEach(function(key) { 122 | if (key in obj) 123 | ret[key] = obj[key]; 124 | }); 125 | 126 | return ret; 127 | }; 128 | 129 | helper.identity = function(value) { 130 | return value; 131 | }; 132 | 133 | helper.List = (function() { 134 | var List = function() { 135 | this.data = []; 136 | }; 137 | 138 | List.prototype.add = function(item) { 139 | if (!this.contains(item)) 140 | this.data.push(item); 141 | }; 142 | 143 | List.prototype.remove = function(item) { 144 | var data = this.data; 145 | 146 | for (var i = data.length - 1; i >= 0; i--) { 147 | if (this.equal(data[i], item)) { 148 | data.splice(i, 1); 149 | break; 150 | } 151 | } 152 | }; 153 | 154 | List.prototype.contains = function(item) { 155 | return this.data.some(function(dataItem) { 156 | return this.equal(dataItem, item); 157 | }.bind(this)); 158 | }; 159 | 160 | List.prototype.equal = function(a, b) { 161 | return a === b; 162 | }; 163 | 164 | List.prototype.toArray = function() { 165 | return this.data.slice(); 166 | }; 167 | 168 | return List; 169 | })(); 170 | 171 | helper.CONTENT_TYPE_TEXT = 'text'; 172 | helper.CONTENT_TYPE_HTML = 'html'; 173 | 174 | var dom = {}; 175 | 176 | dom.disabled = function() { 177 | return (typeof document === 'undefined'); 178 | }; 179 | 180 | dom.el = function(selector) { 181 | if (selector.charAt(0) === '<') { 182 | selector = selector.match(/<(.+)>/)[1]; 183 | return document.createElement(selector); 184 | } 185 | }; 186 | 187 | dom.body = function() { 188 | return document.body; 189 | }; 190 | 191 | dom.attr = function(el, props) { 192 | for (var key in props) { 193 | el.setAttribute(key, props[key]); 194 | } 195 | }; 196 | 197 | dom.css = function(el, props) { 198 | var style = el.style; 199 | 200 | for (var key in props) { 201 | style[key] = props[key]; 202 | } 203 | }; 204 | 205 | dom.rect = function(el) { 206 | return el.getBoundingClientRect(); 207 | }; 208 | 209 | dom.clientWidth = function(el) { 210 | return el.clientWidth; 211 | }; 212 | 213 | dom.clientHeight = function(el) { 214 | return el.clientHeight; 215 | }; 216 | 217 | dom.scrollLeft = function(el) { 218 | return el.scrollLeft; 219 | }; 220 | 221 | dom.scrollTop = function(el) { 222 | return el.scrollTop; 223 | }; 224 | 225 | dom.scrollWidth = function(el) { 226 | return el.scrollWidth; 227 | }; 228 | 229 | dom.scrollHeight = function(el) { 230 | return el.scrollHeight; 231 | }; 232 | 233 | dom.text = function(el, s) { 234 | el.textContent = s; 235 | }; 236 | 237 | dom.html = function(el, s) { 238 | el.innerHTML = s; 239 | }; 240 | 241 | dom.append = function(parent, el) { 242 | parent.appendChild(el); 243 | }; 244 | 245 | dom.remove = function(el) { 246 | el.parentNode.removeChild(el); 247 | }; 248 | 249 | dom.child = function(el, index) { 250 | return el.childNodes[index]; 251 | }; 252 | 253 | dom.animate = function(callback) { 254 | return window.requestAnimationFrame(callback); 255 | }; 256 | 257 | dom.supportsTouch = function() { 258 | return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch)); 259 | }; 260 | 261 | dom.on = function(el, type, listener) { 262 | el.addEventListener(type, listener); 263 | }; 264 | 265 | dom.off = function(el, type, listener) { 266 | el.removeEventListener(type, listener); 267 | }; 268 | 269 | dom.pagePoint = function(event, offset) { 270 | if (dom.supportsTouch()) 271 | event = event.changedTouches[0]; 272 | 273 | return { 274 | x: event.pageX - (offset ? offset.x : 0), 275 | y: event.pageY - (offset ? offset.y : 0) 276 | }; 277 | }; 278 | 279 | dom.clientPoint = function(event, offset) { 280 | if (dom.supportsTouch()) 281 | event = event.changedTouches[0]; 282 | 283 | return { 284 | x: event.clientX - (offset ? offset.x : 0), 285 | y: event.clientY - (offset ? offset.y : 0) 286 | }; 287 | }; 288 | 289 | dom.cancel = function(event) { 290 | event.preventDefault(); 291 | }; 292 | 293 | dom.draggable = (function() { 294 | if (dom.disabled()) 295 | return function() {}; 296 | 297 | var supportsTouch = dom.supportsTouch(); 298 | var EVENT_TYPE_START = supportsTouch ? 'touchstart' : 'mousedown'; 299 | var EVENT_TYPE_MOVE = supportsTouch ? 'touchmove' : 'mousemove'; 300 | var EVENT_TYPE_END = supportsTouch ? 'touchend' : 'mouseup'; 301 | 302 | var Draggable = function(props) { 303 | this.el = props.el; 304 | this.onstart = props.onstart; 305 | this.onmove = props.onmove; 306 | this.onend = props.onend; 307 | this.start = start.bind(this); 308 | this.move = move.bind(this); 309 | this.end = end.bind(this); 310 | this.lock = false; 311 | this.startingPoint = null; 312 | 313 | dom.on(this.el, EVENT_TYPE_START, this.start); 314 | }; 315 | 316 | var start = function(event) { 317 | if (this.lock) 318 | return; 319 | 320 | this.lock = true; 321 | this.startingPoint = dom.pagePoint(event); 322 | 323 | var el = this.el; 324 | var onstart = this.onstart; 325 | 326 | var rect = dom.rect(el); 327 | var p = dom.clientPoint(event, { 328 | x: rect.left - dom.scrollLeft(el), 329 | y: rect.top - dom.scrollTop(el) 330 | }); 331 | 332 | if (typeof onstart === 'function') 333 | onstart(p.x, p.y, event); 334 | 335 | dom.on(document, EVENT_TYPE_MOVE, this.move); 336 | dom.on(document, EVENT_TYPE_END, this.end); 337 | }; 338 | 339 | var move = function(event) { 340 | var onmove = this.onmove; 341 | var d = dom.pagePoint(event, this.startingPoint); 342 | 343 | if (typeof onmove === 'function') 344 | onmove(d.x, d.y, event); 345 | }; 346 | 347 | var end = function(event) { 348 | dom.off(document, EVENT_TYPE_MOVE, this.move); 349 | dom.off(document, EVENT_TYPE_END, this.end); 350 | 351 | var onend = this.onend; 352 | var d = dom.pagePoint(event, this.startingPoint); 353 | 354 | if (typeof onend === 'function') 355 | onend(d.x, d.y, event); 356 | 357 | this.lock = false; 358 | }; 359 | 360 | return function(el, onstart, onmove, onend) { 361 | new Draggable({ 362 | el: el, 363 | onstart: onstart, 364 | onmove: onmove, 365 | onend: onend 366 | }); 367 | }; 368 | })(); 369 | 370 | var Component = function() {}; 371 | 372 | Component.prototype.prop = function(initialValue, defaultValue, converter) { 373 | if (typeof converter !== 'function') 374 | converter = helper.identity; 375 | 376 | var cache = converter(initialValue, defaultValue); 377 | 378 | return function(value) { 379 | if (typeof value === 'undefined') 380 | return cache; 381 | 382 | if (value === cache) 383 | return; 384 | 385 | cache = converter(value, cache); 386 | 387 | this.markDirty(); 388 | }; 389 | }; 390 | 391 | Component.prototype.relations = function() { 392 | return []; 393 | }; 394 | 395 | Component.prototype.redraw = function() {}; 396 | 397 | Component.prototype.markDirty = (function() { 398 | var dirtyComponents = []; 399 | var requestId = null; 400 | 401 | var updateRelations = function(index) { 402 | for (var i = index, len = dirtyComponents.length; i < len; i++) { 403 | var component = dirtyComponents[i]; 404 | component.relations().forEach(function(relation) { 405 | relation.update(component); 406 | }); 407 | } 408 | 409 | // may be inserted other dirty components by updating relations 410 | if (dirtyComponents.length > len) 411 | updateRelations(len); 412 | }; 413 | 414 | var callback = function() { 415 | updateRelations(0); 416 | 417 | dirtyComponents.forEach(function(component) { 418 | component.redraw(); 419 | }); 420 | 421 | dirtyComponents = []; 422 | requestId = null; 423 | }; 424 | 425 | return function() { 426 | if (dom.disabled()) 427 | return; 428 | 429 | if (dirtyComponents.indexOf(this) === -1) 430 | dirtyComponents.push(this); 431 | 432 | if (requestId !== null) 433 | return; 434 | 435 | requestId = dom.animate(callback); 436 | }; 437 | })(); 438 | 439 | var Node = helper.inherits(function(props) { 440 | this.content = this.prop(props.content, '', helper.toString); 441 | this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType); 442 | this.x = this.prop(props.x, 0, helper.toNumber); 443 | this.y = this.prop(props.y, 0, helper.toNumber); 444 | this.width = this.prop(props.width, 75, helper.toNumber); 445 | this.height = this.prop(props.height, 30, helper.toNumber); 446 | this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString); 447 | this.borderColor = this.prop(props.borderColor, '#333', helper.toString); 448 | this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber); 449 | this.textColor = this.prop(props.textColor, '#333', helper.toString); 450 | this.zIndex = this.prop('auto'); 451 | this.element = this.prop(null); 452 | this.parentElement = this.prop(null); 453 | this.cache = this.prop({}); 454 | this.relations = this.prop([]); 455 | }, Component); 456 | 457 | Node.prototype.cx = function() { 458 | return this.x() + this.width() / 2; 459 | }; 460 | 461 | Node.prototype.cy = function() { 462 | return this.y() + this.height() / 2; 463 | }; 464 | 465 | Node.prototype.borderRadius = function() { 466 | return 4; 467 | }; 468 | 469 | Node.prototype.contains = function(x, y, tolerance) { 470 | var nx = this.x(); 471 | var ny = this.y(); 472 | var nwidth = this.width(); 473 | var nheight = this.height(); 474 | 475 | return (nx - tolerance <= x && x <= nx + nwidth + tolerance && 476 | ny - tolerance <= y && y <= ny + nheight + tolerance); 477 | }; 478 | 479 | Node.prototype.style = function() { 480 | var contentType = this.contentType(); 481 | var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14; 482 | var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left'; 483 | var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)'; 484 | var borderWidthOffset = this.borderWidth() * 2; 485 | 486 | return { 487 | backgroundColor: this.backgroundColor(), 488 | border: this.borderWidth() + 'px solid ' + this.borderColor(), 489 | borderRadius: this.borderRadius() + 'px', 490 | color: this.textColor(), 491 | height: (this.height() - borderWidthOffset) + 'px', 492 | lineHeight: (lineHeight - borderWidthOffset) + 'px', 493 | msTransform: translate, 494 | overflow: 'hidden', 495 | pointerEvents: 'none', 496 | position: 'absolute', 497 | textAlign: textAlign, 498 | textOverflow: 'ellipsis', 499 | transform: translate, 500 | webkitTransform: translate, 501 | whiteSpace: 'nowrap', 502 | width: (this.width() - borderWidthOffset) + 'px', 503 | zIndex: this.zIndex() 504 | }; 505 | }; 506 | 507 | Node.prototype.redraw = function() { 508 | var element = this.element(); 509 | var parentElement = this.parentElement(); 510 | 511 | if (!parentElement && !element) 512 | return; 513 | 514 | // add element 515 | if (parentElement && !element) { 516 | element = dom.el('
'); 517 | this.element(element); 518 | this.redraw(); 519 | dom.append(parentElement, element); 520 | 521 | return; 522 | } 523 | 524 | // remove element 525 | if (!parentElement && element) { 526 | dom.remove(element); 527 | this.element(null); 528 | this.cache({}); 529 | 530 | return; 531 | } 532 | 533 | var cache = this.cache(); 534 | 535 | // update element 536 | var content = this.content(); 537 | 538 | if (content !== cache.content) { 539 | var contentType = this.contentType(); 540 | 541 | if (contentType === helper.CONTENT_TYPE_TEXT) 542 | dom.text(element, content); 543 | else if (contentType === helper.CONTENT_TYPE_HTML) 544 | dom.html(element, content); 545 | 546 | cache.content = content; 547 | } 548 | 549 | var style = this.style(); 550 | 551 | dom.css(element, helper.diffObj(style, cache.style)); 552 | cache.style = style; 553 | }; 554 | 555 | var Link = helper.inherits(function(props) { 556 | this.content = this.prop(props.content, '', helper.toString); 557 | this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType); 558 | this.cx = this.prop(props.cx, 100, helper.toNumber); 559 | this.cy = this.prop(props.cy, 40, helper.toNumber); 560 | this.width = this.prop(props.width, 50, helper.toNumber); 561 | this.height = this.prop(props.height, 20, helper.toNumber); 562 | this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString); 563 | this.borderColor = this.prop(props.borderColor, '#333', helper.toString); 564 | this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber); 565 | this.textColor = this.prop(props.textColor, '#333', helper.toString); 566 | this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber); 567 | this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber); 568 | this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber); 569 | this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber); 570 | this.lineColor = this.prop(props.lineColor, '#333', helper.toString); 571 | this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber); 572 | this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean); 573 | this.zIndex = this.prop('auto'); 574 | this.element = this.prop(null); 575 | this.parentElement = this.prop(null); 576 | this.cache = this.prop({}); 577 | this.relations = this.prop([]); 578 | }, Component); 579 | 580 | Link.prototype.straighten = function(sx, sy, tx, ty) { 581 | if (arguments.length === 0) { 582 | this.cx((this.sourceX() + this.targetX()) / 2); 583 | this.cy((this.sourceY() + this.targetY()) / 2); 584 | 585 | return; 586 | } 587 | 588 | this.cx((sx + tx) / 2); 589 | this.cy((sy + ty) / 2); 590 | this.sourceX(sx); 591 | this.sourceY(sy); 592 | this.targetX(tx); 593 | this.targetY(ty); 594 | }; 595 | 596 | Link.prototype.contains = function(x, y, tolerance) { 597 | var content = this.content(); 598 | var lcx = this.cx(); 599 | var lcy = this.cy(); 600 | 601 | // content area 602 | if (content) { 603 | var lwidth = this.width(); 604 | var lheight = this.height(); 605 | 606 | var lx = lcx - lwidth / 2; 607 | var ly = lcy - lheight / 2; 608 | 609 | if (lx - tolerance <= x && x <= lx + lwidth + tolerance && 610 | ly - tolerance <= y && y <= ly + lheight + tolerance) { 611 | return true; 612 | } 613 | } 614 | 615 | var lineWidth = this.lineWidth(); 616 | 617 | // source path 618 | if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance)) 619 | return true; 620 | 621 | // target path 622 | if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance)) 623 | return true; 624 | 625 | return false; 626 | }; 627 | 628 | Link.prototype.containsPath = function(x0, y0, x1, y1, x, y, d) { 629 | var ax = x1 - x0; 630 | var ay = y1 - y0; 631 | 632 | var bx = x - x0; 633 | var by = y - y0; 634 | 635 | var r = (ax * bx + ay * by) / (ax * ax + ay * ay); 636 | 637 | if (0 <= r && r <= 1) { 638 | var px = x0 + r * ax; 639 | var py = y0 + r * ay; 640 | 641 | var dx = px - x; 642 | var dy = py - y; 643 | 644 | if (dx * dx + dy * dy <= d * d) 645 | return true; 646 | } 647 | 648 | return false; 649 | }; 650 | 651 | Link.prototype.style = function() { 652 | return { 653 | pointerEvents: 'none', 654 | position: 'absolute', 655 | zIndex: this.zIndex() 656 | }; 657 | }; 658 | 659 | Link.prototype.pathContainerStyle = function() { 660 | var width = Math.max(this.cx(), this.sourceX(), this.targetX()); 661 | var height = Math.max(this.cy(), this.sourceY(), this.targetY()); 662 | 663 | return { 664 | height: height + 'px', 665 | overflow: 'visible', 666 | position: 'absolute', 667 | width: width + 'px' 668 | }; 669 | }; 670 | 671 | Link.prototype.lineAttributes = function() { 672 | var d = [ 673 | 'M', this.sourceX(), this.sourceY(), 674 | 'L', this.cx(), this.cy(), 675 | 'L', this.targetX(), this.targetY() 676 | ].join(' '); 677 | 678 | return { 679 | d: d, 680 | fill: 'none', 681 | stroke: this.lineColor(), 682 | 'stroke-linecap': 'round', 683 | 'stroke-width': this.lineWidth() 684 | }; 685 | }; 686 | 687 | Link.prototype.arrowAttributes = function() { 688 | var cx = this.cx(); 689 | var cy = this.cy(); 690 | var tx = this.targetX(); 691 | var ty = this.targetY(); 692 | 693 | var radians = Math.atan2(ty - cy, tx - cx); 694 | 695 | var p0 = { 696 | x: 15 * Math.cos(radians - 26 * Math.PI / 180), 697 | y: 15 * Math.sin(radians - 26 * Math.PI / 180) 698 | }; 699 | 700 | var p1 = { 701 | x: 15 * Math.cos(radians + 26 * Math.PI / 180), 702 | y: 15 * Math.sin(radians + 26 * Math.PI / 180) 703 | }; 704 | 705 | var p2 = { 706 | x: 7 * Math.cos(radians), 707 | y: 7 * Math.sin(radians) 708 | }; 709 | 710 | var d = [ 711 | 'M', tx - p0.x, ty - p0.y, 712 | 'L', tx, ty, 713 | 'L', tx - p1.x, ty - p1.y, 714 | 'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y, 715 | 'Z' 716 | ].join(' '); 717 | 718 | return { 719 | d: d, 720 | fill: this.lineColor(), 721 | stroke: this.lineColor(), 722 | 'stroke-linejoin': 'round', 723 | 'stroke-width': this.lineWidth(), 724 | visibility: this.hasArrow() ? 'visible' : 'hidden' 725 | }; 726 | }; 727 | 728 | Link.prototype.contentStyle = function() { 729 | var contentType = this.contentType(); 730 | var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14; 731 | var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left'; 732 | var x = this.cx() - this.width() / 2; 733 | var y = this.cy() - this.height() / 2; 734 | var translate = 'translate(' + x + 'px, ' + y + 'px)'; 735 | var borderWidthOffset = this.borderWidth() * 2; 736 | 737 | return { 738 | backgroundColor: this.backgroundColor(), 739 | border: this.borderWidth() + 'px solid ' + this.borderColor(), 740 | borderRadius: '4px', 741 | color: this.textColor(), 742 | height: (this.height() - borderWidthOffset) + 'px', 743 | lineHeight: (lineHeight - borderWidthOffset) + 'px', 744 | msTransform: translate, 745 | overflow: 'hidden', 746 | position: 'absolute', 747 | textAlign: textAlign, 748 | textOverflow: 'ellipsis', 749 | transform: translate, 750 | visibility: this.content() ? 'visible' : 'hidden', 751 | webkitTransform: translate, 752 | whiteSpace: 'nowrap', 753 | width: (this.width() - borderWidthOffset) + 'px' 754 | }; 755 | }; 756 | 757 | Link.prototype.redraw = function() { 758 | var element = this.element(); 759 | var parentElement = this.parentElement(); 760 | 761 | if (!parentElement && !element) 762 | return; 763 | 764 | // add element 765 | if (parentElement && !element) { 766 | element = dom.el('
'); 767 | dom.html(element, '
'); 768 | this.element(element); 769 | this.redraw(); 770 | dom.append(parentElement, element); 771 | 772 | return; 773 | } 774 | 775 | // remove element 776 | if (!parentElement && element) { 777 | dom.remove(element); 778 | this.element(null); 779 | this.cache({}); 780 | 781 | return; 782 | } 783 | 784 | var cache = this.cache(); 785 | 786 | // update path container element 787 | var pathContainerStyle = this.pathContainerStyle(); 788 | var pathContainerElement = dom.child(element, 0); 789 | 790 | dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle)); 791 | cache.pathContainerElementStyle = contentStyle; 792 | 793 | // update line element 794 | var lineAttributes = this.lineAttributes(); 795 | var lineElement = dom.child(pathContainerElement, 0); 796 | 797 | dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes)); 798 | cache.lineAttributes = lineAttributes; 799 | 800 | // update arrow element 801 | var arrowAttributes = this.arrowAttributes(); 802 | var arrowElement = dom.child(pathContainerElement, 1); 803 | 804 | dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes)); 805 | cache.arrowAttributes = arrowAttributes; 806 | 807 | // update content element 808 | var content = this.content(); 809 | var contentStyle = this.contentStyle(); 810 | var contentElement = dom.child(element, 1); 811 | 812 | if (content !== cache.content) { 813 | var contentType = this.contentType(); 814 | 815 | if (contentType === helper.CONTENT_TYPE_TEXT) 816 | dom.text(contentElement, content); 817 | else if (contentType === helper.CONTENT_TYPE_HTML) 818 | dom.html(contentElement, content); 819 | 820 | cache.content = content; 821 | } 822 | 823 | dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle)); 824 | cache.contentStyle = contentStyle; 825 | 826 | // update container element 827 | var style = this.style(); 828 | 829 | dom.css(element, helper.diffObj(style, cache.style)); 830 | cache.style = style; 831 | }; 832 | 833 | var Connector = helper.inherits(function(props) { 834 | this.x = this.prop(props.x, 0, helper.toNumber); 835 | this.y = this.prop(props.y, 0, helper.toNumber); 836 | this.color = this.prop(Connector.COLOR_UNCONNECTED); 837 | this.zIndex = this.prop('auto'); 838 | this.element = this.prop(null); 839 | this.parentElement = this.prop(null); 840 | this.cache = this.prop({}); 841 | this.relations = this.prop([]); 842 | }, Component); 843 | 844 | Connector.prototype.r = function() { 845 | return 16; 846 | }; 847 | 848 | Connector.prototype.contains = function(x, y, tolerance) { 849 | var dx = x - this.x(); 850 | var dy = y - this.y(); 851 | var r = this.r() + tolerance; 852 | 853 | return (dx * dx + dy * dy <= r * r); 854 | }; 855 | 856 | Connector.prototype.style = function() { 857 | var r = this.r(); 858 | var x = this.x() - r; 859 | var y = this.y() - r; 860 | var translate = 'translate(' + x + 'px, ' + y + 'px)'; 861 | 862 | return { 863 | backgroundColor: this.color(), 864 | border: '2px solid lightgray', 865 | borderRadius: '50%', 866 | boxSizing: 'border-box', 867 | height: r * 2 + 'px', 868 | msTransform: translate, 869 | opacity: 0.6, 870 | pointerEvents: 'none', 871 | position: 'absolute', 872 | transform: translate, 873 | webkitTransform: translate, 874 | width: r * 2 + 'px', 875 | zIndex: this.zIndex() 876 | }; 877 | }; 878 | 879 | Connector.prototype.redraw = function() { 880 | var element = this.element(); 881 | var parentElement = this.parentElement(); 882 | 883 | if (!parentElement && !element) 884 | return; 885 | 886 | // add element 887 | if (parentElement && !element) { 888 | element = dom.el('
'); 889 | this.element(element); 890 | this.redraw(); 891 | dom.append(parentElement, element); 892 | 893 | return; 894 | } 895 | 896 | // remove element 897 | if (!parentElement && element) { 898 | dom.remove(element); 899 | this.element(null); 900 | 901 | return; 902 | } 903 | 904 | var cache = this.cache(); 905 | 906 | // update element 907 | var style = this.style(); 908 | 909 | dom.css(element, helper.diffObj(style, cache.style)); 910 | cache.style = style; 911 | }; 912 | 913 | Connector.COLOR_CONNECTED = 'lightgreen'; 914 | Connector.COLOR_UNCONNECTED = 'pink'; 915 | 916 | var Relation = function() {}; 917 | 918 | Relation.prototype.prop = function(initialValue) { 919 | var cache = initialValue; 920 | 921 | return function(value) { 922 | if (typeof value === 'undefined') 923 | return cache; 924 | 925 | cache = value; 926 | }; 927 | }; 928 | 929 | Relation.prototype.update = function() {}; 930 | 931 | var Triple = helper.inherits(function(props) { 932 | this.link = this.prop(props.link); 933 | this.sourceNode = this.prop(props.sourceNode || null); 934 | this.targetNode = this.prop(props.targetNode || null); 935 | this.skipNextUpdate = this.prop(false); 936 | this.nodePositionsCache = this.prop({}); 937 | }, Relation); 938 | 939 | Triple.prototype.update = function(changedComponent) { 940 | if (this.skipNextUpdate()) { 941 | this.skipNextUpdate(false); 942 | return; 943 | } 944 | 945 | var link = this.link(); 946 | var sourceNode = this.sourceNode(); 947 | var targetNode = this.targetNode(); 948 | 949 | if (changedComponent instanceof Node) 950 | this.updateNode(link, sourceNode, targetNode, changedComponent); 951 | else if (changedComponent instanceof Link) 952 | this.updateLink(link, sourceNode, targetNode); 953 | }; 954 | 955 | Triple.prototype.updateNode = function(link, sourceNode, targetNode, changedNode) { 956 | if (sourceNode && targetNode) 957 | this.rotateLink(link, sourceNode, targetNode, changedNode); 958 | else 959 | this.shiftLink(link, sourceNode, targetNode, changedNode); 960 | 961 | this.updateNodePositionsCache(); 962 | }; 963 | 964 | Triple.prototype.rotateLink = function(link, sourceNode, targetNode, changedNode) { 965 | var cache = this.nodePositionsCache(); 966 | 967 | var sncx = cache.sncx; 968 | var sncy = cache.sncy; 969 | var tncx = cache.tncx; 970 | var tncy = cache.tncy; 971 | 972 | var lcx = link.cx(); 973 | var lcy = link.cy(); 974 | 975 | var ts_dx = tncx - sncx; 976 | var ts_dy = tncy - sncy; 977 | var cs_dx = lcx - sncx; 978 | var cs_dy = lcy - sncy; 979 | 980 | var ts_rad0 = Math.atan2(ts_dy, ts_dx); 981 | var cs_rad0 = Math.atan2(cs_dy, cs_dx); 982 | 983 | // changed node position 984 | if (changedNode === sourceNode) { 985 | sncx = sourceNode.cx(); 986 | sncy = sourceNode.cy(); 987 | } else if (changedNode === targetNode) { 988 | tncx = targetNode.cx(); 989 | tncy = targetNode.cy(); 990 | } 991 | 992 | // center positions of two nodes are equal 993 | if (cs_rad0 === 0) { 994 | link.cx((sncx + tncx) / 2); 995 | link.cy((sncy + tncy) / 2); 996 | 997 | return; 998 | } 999 | 1000 | var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy); 1001 | var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy); 1002 | 1003 | var ts_cs_rad = ts_rad0 - cs_rad0; 1004 | 1005 | ts_dx = tncx - sncx; 1006 | ts_dy = tncy - sncy; 1007 | 1008 | var ts_rad1 = Math.atan2(ts_dy, ts_dx); 1009 | var cs_rad1 = ts_rad1 - ts_cs_rad; 1010 | 1011 | var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy); 1012 | var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1; 1013 | var cs_d1 = cs_d0 * d_rate; 1014 | 1015 | lcx = sncx + cs_d1 * Math.cos(cs_rad1); 1016 | lcy = sncy + cs_d1 * Math.sin(cs_rad1); 1017 | 1018 | link.cx(lcx); 1019 | link.cy(lcy); 1020 | }; 1021 | 1022 | Triple.prototype.shiftLink = function(link, sourceNode, targetNode, changedNode) { 1023 | var cache = this.nodePositionsCache(); 1024 | 1025 | var ncx = changedNode.cx(); 1026 | var ncy = changedNode.cy(); 1027 | 1028 | if (changedNode === sourceNode) { 1029 | link.targetX(link.targetX() + (ncx - cache.sncx)); 1030 | link.targetY(link.targetY() + (ncy - cache.sncy)); 1031 | } else if (changedNode === targetNode) { 1032 | link.sourceX(link.sourceX() + (ncx - cache.tncx)); 1033 | link.sourceY(link.sourceY() + (ncy - cache.tncy)); 1034 | } 1035 | }; 1036 | 1037 | Triple.prototype.updateLink = function(link, sourceNode, targetNode) { 1038 | var lx, ly, p; 1039 | 1040 | if (sourceNode) { 1041 | // connect link to source node 1042 | lx = targetNode ? link.cx() : link.targetX(); 1043 | ly = targetNode ? link.cy() : link.targetY(); 1044 | p = this.connectedPoint(sourceNode, lx, ly); 1045 | link.sourceX(p.x); 1046 | link.sourceY(p.y); 1047 | } 1048 | 1049 | if (targetNode) { 1050 | // connect link to target node 1051 | lx = sourceNode ? link.cx() : link.sourceX(); 1052 | ly = sourceNode ? link.cy() : link.sourceY(); 1053 | p = this.connectedPoint(targetNode, lx, ly); 1054 | link.targetX(p.x); 1055 | link.targetY(p.y); 1056 | } 1057 | 1058 | if (!sourceNode || !targetNode) { 1059 | // link content moves to midpoint 1060 | link.cx((link.sourceX() + link.targetX()) / 2); 1061 | link.cy((link.sourceY() + link.targetY()) / 2); 1062 | } 1063 | }; 1064 | 1065 | Triple.prototype.updateLinkAngle = function(radians) { 1066 | var link = this.link(); 1067 | var sourceNode = this.sourceNode(); 1068 | var targetNode = this.targetNode(); 1069 | 1070 | var ldx = link.targetX() - link.sourceX(); 1071 | var ldy = link.targetY() - link.sourceY(); 1072 | var d = Math.sqrt(ldx * ldx + ldy * ldy); 1073 | 1074 | var connectedNode = sourceNode || targetNode; 1075 | var cx = connectedNode.cx(); 1076 | var cy = connectedNode.cy(); 1077 | var lx = cx + d * Math.cos(radians); 1078 | var ly = cy + d * Math.sin(radians); 1079 | var p = this.connectedPoint(connectedNode, lx, ly); 1080 | 1081 | if (connectedNode === sourceNode) 1082 | link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy); 1083 | else if (connectedNode === targetNode) 1084 | link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y); 1085 | }; 1086 | 1087 | Triple.prototype.updateNodePositionsCache = function() { 1088 | var sourceNode = this.sourceNode(); 1089 | var targetNode = this.targetNode(); 1090 | var cache = this.nodePositionsCache(); 1091 | 1092 | if (sourceNode) { 1093 | cache.sncx = sourceNode.cx(); 1094 | cache.sncy = sourceNode.cy(); 1095 | } 1096 | 1097 | if (targetNode) { 1098 | cache.tncx = targetNode.cx(); 1099 | cache.tncy = targetNode.cy(); 1100 | } 1101 | }; 1102 | 1103 | Triple.prototype.connectedPoint = function(node, lx, ly) { 1104 | var nx = node.x(); 1105 | var ny = node.y(); 1106 | var nwidth = node.width(); 1107 | var nheight = node.height(); 1108 | var ncx = node.cx(); 1109 | var ncy = node.cy(); 1110 | 1111 | var alpha = Math.atan2(ly - ncy, lx - ncx); 1112 | var beta = Math.PI / 2 - alpha; 1113 | var t = Math.atan2(nheight, nwidth); 1114 | 1115 | var x, y; 1116 | 1117 | // left edge 1118 | if (alpha < t - Math.PI || alpha > Math.PI - t) { 1119 | x = nx; 1120 | y = ncy - nwidth * Math.tan(alpha) / 2; 1121 | } 1122 | // top edge 1123 | else if (alpha < -t) { 1124 | x = ncx - nheight * Math.tan(beta) / 2; 1125 | y = ny; 1126 | } 1127 | // right edge 1128 | else if (alpha < t) { 1129 | x = nx + nwidth; 1130 | y = ncy + nwidth * Math.tan(alpha) / 2; 1131 | } 1132 | // bottom edge 1133 | else { 1134 | x = ncx + nheight * Math.tan(beta) / 2; 1135 | y = ny + nheight; 1136 | } 1137 | 1138 | var x0, y0, l, ex, ey; 1139 | var r = node.borderRadius(); 1140 | var atCorner = false; 1141 | 1142 | // top-left corner 1143 | if (x < nx + r && y < ny + r) { 1144 | x0 = nx + r; 1145 | y0 = ny + r; 1146 | atCorner = true; 1147 | } 1148 | // top-right corner 1149 | else if (x > nx + nwidth - r && y < ny + r) { 1150 | x0 = nx + nwidth - r; 1151 | y0 = ny + r; 1152 | atCorner = true; 1153 | } 1154 | // bottom-left corner 1155 | else if (x < nx + r && y > ny + nheight - r) { 1156 | x0 = nx + r; 1157 | y0 = ny + nheight - r; 1158 | atCorner = true; 1159 | } 1160 | // bottom-right corner 1161 | else if (x > nx + nwidth - r && y > ny + nheight - r) { 1162 | x0 = nx + nwidth - r; 1163 | y0 = ny + nheight - r; 1164 | atCorner = true; 1165 | } 1166 | 1167 | if (atCorner) { 1168 | l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y)); 1169 | ex = (x0 - x) / l; 1170 | ey = (y0 - y) / l; 1171 | x = x0 - r * ex; 1172 | y = y0 - r * ey; 1173 | } 1174 | 1175 | return { 1176 | x: x, 1177 | y: y 1178 | }; 1179 | }; 1180 | 1181 | var LinkConnectorRelation = helper.inherits(function(props) { 1182 | this.type = this.prop(props.type); 1183 | this.link = this.prop(props.link); 1184 | this.connector = this.prop(props.connector); 1185 | }, Relation); 1186 | 1187 | LinkConnectorRelation.prototype.isConnected = function(isConnected) { 1188 | var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED; 1189 | this.connector().color(color); 1190 | }; 1191 | 1192 | LinkConnectorRelation.prototype.update = function(changedComponent) { 1193 | var type = this.type(); 1194 | var link = this.link(); 1195 | var connector = this.connector(); 1196 | 1197 | if (changedComponent === link) { 1198 | connector.x(link[type + 'X']()); 1199 | connector.y(link[type + 'Y']()); 1200 | } 1201 | }; 1202 | 1203 | var ComponentList = helper.inherits(function() { 1204 | ComponentList.super_.call(this); 1205 | }, helper.List); 1206 | 1207 | ComponentList.prototype.toFront = function(component) { 1208 | var data = this.data; 1209 | var index = data.indexOf(component); 1210 | 1211 | if (index === -1) 1212 | return; 1213 | 1214 | data.splice(index, 1); 1215 | data.push(component); 1216 | }; 1217 | 1218 | ComponentList.prototype.fromPoint = function(ctor, x, y) { 1219 | var data = this.data; 1220 | var closeComponent = null; 1221 | 1222 | for (var i = data.length - 1; i >= 0; i--) { 1223 | var component = data[i]; 1224 | 1225 | if (!(component instanceof ctor)) 1226 | continue; 1227 | 1228 | if (component.contains(x, y, 0)) 1229 | return component; 1230 | 1231 | if (!closeComponent && component.contains(x, y, 8)) 1232 | closeComponent = component; 1233 | } 1234 | 1235 | return closeComponent; 1236 | }; 1237 | 1238 | var DisabledConnectorList = helper.inherits(function() { 1239 | DisabledConnectorList.super_.call(this); 1240 | }, helper.List); 1241 | 1242 | DisabledConnectorList.prototype.add = function(type, link) { 1243 | DisabledConnectorList.super_.prototype.add.call(this, { 1244 | type: type, 1245 | link: link 1246 | }); 1247 | }; 1248 | 1249 | DisabledConnectorList.prototype.remove = function(type, link) { 1250 | DisabledConnectorList.super_.prototype.remove.call(this, { 1251 | type: type, 1252 | link: link 1253 | }); 1254 | }; 1255 | 1256 | DisabledConnectorList.prototype.contains = function(type, link) { 1257 | return DisabledConnectorList.super_.prototype.contains.call(this, { 1258 | type: type, 1259 | link: link 1260 | }); 1261 | }; 1262 | 1263 | DisabledConnectorList.prototype.equal = function(a, b) { 1264 | return a.type === b.type && a.link === b.link; 1265 | }; 1266 | 1267 | var Cmap = helper.inherits(function(rootElement) { 1268 | this.componentList = this.prop(new ComponentList()); 1269 | this.disabledConnectorList = this.prop(new DisabledConnectorList()); 1270 | this.dragDisabledComponentList = this.prop(new ComponentList()); 1271 | this.element = this.prop(null); 1272 | this.rootElement = this.prop(rootElement || null); 1273 | this.retainerElement = this.prop(null); 1274 | this.dragContext = this.prop({}); 1275 | 1276 | this.markDirty(); 1277 | }, Component); 1278 | 1279 | Cmap.prototype.add = function(component) { 1280 | component.parentElement(this.element()); 1281 | this.componentList().add(component); 1282 | this.updateZIndex(); 1283 | }; 1284 | 1285 | Cmap.prototype.remove = function(component) { 1286 | component.parentElement(null); 1287 | 1288 | if (component instanceof Link) 1289 | this.hideConnectors(component); 1290 | 1291 | this.disconnect(component); 1292 | this.componentList().remove(component); 1293 | this.updateZIndex(); 1294 | }; 1295 | 1296 | Cmap.prototype.toFront = function(component) { 1297 | this.componentList().toFront(component); 1298 | this.updateZIndex(); 1299 | }; 1300 | 1301 | Cmap.prototype.updateZIndex = function() { 1302 | this.componentList().toArray().forEach(function(component, index) { 1303 | if (component instanceof Connector) 1304 | return; 1305 | 1306 | // update z-index of node/link 1307 | var zIndex = index * 10; 1308 | component.zIndex(zIndex); 1309 | 1310 | if (!(component instanceof Link)) 1311 | return; 1312 | 1313 | // update connector z-index of link 1314 | helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) { 1315 | relation.connector().zIndex(zIndex + index + 1); 1316 | }); 1317 | }); 1318 | }; 1319 | 1320 | Cmap.prototype.connect = function(type, node, link) { 1321 | var linkRelations = link.relations(); 1322 | var triple = helper.firstInstance(linkRelations, Triple); 1323 | var nodeKey = type + 'Node'; 1324 | 1325 | if (triple && triple[nodeKey]()) 1326 | throw new Error('Already connected'); 1327 | 1328 | var anotherType = Cmap.anotherConnectionType(type); 1329 | var anotherSideNode = triple ? triple[anotherType + 'Node']() : null; 1330 | 1331 | if (anotherSideNode === node) 1332 | throw new Error('Already connected to the ' + anotherType + ' of the link'); 1333 | 1334 | if (triple) { 1335 | triple[nodeKey](node); 1336 | } else { 1337 | var tripleProps = {}; 1338 | tripleProps.link = link; 1339 | tripleProps[nodeKey] = node; 1340 | triple = new Triple(tripleProps); 1341 | 1342 | // add triple to the beginning of link relations to be ahead of link-connector relation 1343 | // connector position won't be updated before triple update 1344 | linkRelations.unshift(triple); 1345 | } 1346 | 1347 | // add triple to node 1348 | node.relations().push(triple); 1349 | triple.updateNodePositionsCache(); 1350 | 1351 | // update connectors of link 1352 | helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) { 1353 | if (relation.type() === type) 1354 | relation.isConnected(true); 1355 | }); 1356 | 1357 | // link content moves to midpoint of connected nodes 1358 | if (anotherSideNode) { 1359 | link.cx((node.cx() + anotherSideNode.cx()) / 2); 1360 | link.cy((node.cy() + anotherSideNode.cy()) / 2); 1361 | } 1362 | 1363 | // do not need to mark node dirty (stay unchanged) 1364 | link.markDirty(); 1365 | }; 1366 | 1367 | Cmap.prototype.disconnect = function(type, node, link) { 1368 | if (type instanceof Component) { 1369 | var component = type; 1370 | var relations = component.relations().slice(); 1371 | 1372 | // disconnect all connections of component 1373 | helper.eachInstance(relations, Triple, function(triple) { 1374 | var link = triple.link(); 1375 | var sourceNode = triple.sourceNode(); 1376 | var targetNode = triple.targetNode(); 1377 | 1378 | if (sourceNode && (component === link || component === sourceNode)) 1379 | this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link); 1380 | 1381 | if (targetNode && (component === link || component === targetNode)) 1382 | this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link); 1383 | }.bind(this)); 1384 | 1385 | return; 1386 | } 1387 | 1388 | var linkRelations = link.relations(); 1389 | var triple = helper.firstInstance(linkRelations, Triple); 1390 | var nodeKey = type + 'Node'; 1391 | 1392 | if (!triple || triple[nodeKey]() !== node) 1393 | throw new Error('Not connected'); 1394 | 1395 | triple[nodeKey](null); 1396 | 1397 | // remove triple from node 1398 | var nodeRelations = node.relations(); 1399 | nodeRelations.splice(nodeRelations.indexOf(triple), 1); 1400 | 1401 | // remove triple from link 1402 | if (!triple.sourceNode() && !triple.targetNode()) 1403 | linkRelations.splice(linkRelations.indexOf(triple), 1); 1404 | 1405 | // update connectors of link 1406 | helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) { 1407 | if (relation.type() === type) 1408 | relation.isConnected(false); 1409 | }); 1410 | 1411 | // do not need to mark node dirty (stay unchanged) 1412 | link.markDirty(); 1413 | }; 1414 | 1415 | Cmap.prototype.connectedNode = function(type, link) { 1416 | var triple = helper.firstInstance(link.relations(), Triple); 1417 | 1418 | if (!triple) 1419 | return null; 1420 | 1421 | return triple[type + 'Node'](); 1422 | }; 1423 | 1424 | Cmap.prototype.showConnector = function(type, link) { 1425 | if (this.connectorVisible(type, link)) 1426 | return; 1427 | 1428 | var disabledConnectorList = this.disabledConnectorList(); 1429 | var connectorDisabled = disabledConnectorList.contains(type, link); 1430 | 1431 | if (!connectorDisabled) 1432 | this.addConnector(type, link); 1433 | }; 1434 | 1435 | Cmap.prototype.connectorVisible = function(type, link) { 1436 | return link.relations().some(function(relation) { 1437 | return relation instanceof LinkConnectorRelation && relation.type() === type; 1438 | }); 1439 | }; 1440 | 1441 | Cmap.prototype.addConnector = function(type, link) { 1442 | var connector = new Connector({ 1443 | x: link[type + 'X'](), 1444 | y: link[type + 'Y']() 1445 | }); 1446 | 1447 | var linkConnectorRelation = new LinkConnectorRelation({ 1448 | type: type, 1449 | link: link, 1450 | connector: connector 1451 | }); 1452 | 1453 | var linkRelations = link.relations(); 1454 | var triple = helper.firstInstance(linkRelations, Triple); 1455 | var isConnected = (triple && !!triple[type + 'Node']()); 1456 | 1457 | linkConnectorRelation.isConnected(isConnected); 1458 | linkRelations.push(linkConnectorRelation); 1459 | connector.relations().push(linkConnectorRelation); 1460 | 1461 | this.add(connector); 1462 | }; 1463 | 1464 | Cmap.prototype.hideConnector = function(type, link) { 1465 | var linkRelations = link.relations(); 1466 | 1467 | for (var i = linkRelations.length - 1; i >= 0; i--) { 1468 | var relation = linkRelations[i]; 1469 | 1470 | if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type) 1471 | continue; 1472 | 1473 | // remove connector component 1474 | this.remove(relation.connector()); 1475 | 1476 | // remove link-connector relation from link 1477 | linkRelations.splice(i, 1); 1478 | 1479 | break; 1480 | } 1481 | }; 1482 | 1483 | Cmap.prototype.showConnectors = function(link) { 1484 | this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link); 1485 | this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link); 1486 | }; 1487 | 1488 | Cmap.prototype.hideConnectors = function(link) { 1489 | this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link); 1490 | this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link); 1491 | }; 1492 | 1493 | Cmap.prototype.hideAllConnectors = function() { 1494 | this.componentList().toArray().forEach(function(component) { 1495 | if (component instanceof Link) 1496 | this.hideConnectors(component); 1497 | }.bind(this)); 1498 | }; 1499 | 1500 | Cmap.prototype.enableConnector = function(type, link) { 1501 | this.disabledConnectorList().remove(type, link); 1502 | }; 1503 | 1504 | Cmap.prototype.disableConnector = function(type, link) { 1505 | // remove showing connector 1506 | this.hideConnector(type, link); 1507 | 1508 | this.disabledConnectorList().add(type, link); 1509 | }; 1510 | 1511 | Cmap.prototype.connectorEnabled = function(type, link) { 1512 | return !this.disabledConnectorList().contains(type, link); 1513 | }; 1514 | 1515 | Cmap.prototype.enableDrag = function(component) { 1516 | this.dragDisabledComponentList().remove(component); 1517 | }; 1518 | 1519 | Cmap.prototype.disableDrag = function(component) { 1520 | this.dragDisabledComponentList().add(component); 1521 | }; 1522 | 1523 | Cmap.prototype.dragEnabled = function(component) { 1524 | return !this.dragDisabledComponentList().contains(component); 1525 | }; 1526 | 1527 | Cmap.prototype.onstart = function(x, y, event) { 1528 | var context = this.dragContext(); 1529 | 1530 | var component = this.componentList().fromPoint(Component, x, y); 1531 | context.component = component; 1532 | 1533 | if (!(component instanceof Connector)) 1534 | this.hideAllConnectors(); 1535 | 1536 | if (!component) 1537 | return; 1538 | 1539 | var draggable = !this.dragDisabledComponentList().contains(component); 1540 | context.draggable = draggable; 1541 | 1542 | if (!draggable) 1543 | return; 1544 | 1545 | dom.cancel(event); 1546 | 1547 | this.toFront(component); 1548 | 1549 | if (component instanceof Node) { 1550 | context.x = component.x(); 1551 | context.y = component.y(); 1552 | } else if (component instanceof Link) { 1553 | context.cx = component.cx(); 1554 | context.cy = component.cy(); 1555 | context.sourceX = component.sourceX(); 1556 | context.sourceY = component.sourceY(); 1557 | context.targetX = component.targetX(); 1558 | context.targetY = component.targetY(); 1559 | context.triple = helper.firstInstance(component.relations(), Triple); 1560 | 1561 | this.showConnectors(component); 1562 | } else if (component instanceof Connector) { 1563 | var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation); 1564 | 1565 | context.x = x; 1566 | context.y = y; 1567 | context.type = linkConnectorRelation.type(); 1568 | context.link = linkConnectorRelation.link(); 1569 | } 1570 | 1571 | this.fixScrollSize(); 1572 | }; 1573 | 1574 | Cmap.prototype.onmove = function(dx, dy, event) { 1575 | var context = this.dragContext(); 1576 | 1577 | var component = context.component; 1578 | 1579 | if (!component) 1580 | return; 1581 | 1582 | if (!context.draggable) 1583 | return; 1584 | 1585 | if (component instanceof Node) { 1586 | component.x(context.x + dx); 1587 | component.y(context.y + dy); 1588 | } else if (component instanceof Link) { 1589 | var cx = context.cx + dx; 1590 | var cy = context.cy + dy; 1591 | var triple = context.triple; 1592 | var connectedNode = null; 1593 | 1594 | if (triple) { 1595 | var sourceNode = triple.sourceNode(); 1596 | var targetNode = triple.targetNode(); 1597 | 1598 | if (sourceNode && !targetNode) 1599 | connectedNode = sourceNode; 1600 | else if (!sourceNode && targetNode) 1601 | connectedNode = targetNode; 1602 | } 1603 | 1604 | if (connectedNode) { 1605 | // only one node connected 1606 | var x = cx - connectedNode.cx(); 1607 | var y = cy - connectedNode.cy(); 1608 | triple.updateLinkAngle(Math.atan2(y, x)); 1609 | triple.skipNextUpdate(true); 1610 | } else if (!triple || component.content()) { 1611 | // not connected or link has content 1612 | // (except two nodes connected but link has no content) 1613 | component.cx(cx); 1614 | component.cy(cy); 1615 | component.sourceX(context.sourceX + dx); 1616 | component.sourceY(context.sourceY + dy); 1617 | component.targetX(context.targetX + dx); 1618 | component.targetY(context.targetY + dy); 1619 | } 1620 | } else if (component instanceof Connector) { 1621 | var x = context.x + dx; 1622 | var y = context.y + dy; 1623 | var type = context.type; 1624 | var link = context.link; 1625 | 1626 | var triple = helper.firstInstance(link.relations(), Triple); 1627 | var connectedNode = triple ? triple[type + 'Node']() : null; 1628 | var node = this.componentList().fromPoint(Node, x, y); 1629 | 1630 | if (connectedNode && connectedNode === node) { 1631 | // already connected (do nothing) 1632 | return; 1633 | } 1634 | 1635 | var anotherType = Cmap.anotherConnectionType(type); 1636 | var anotherSideNode = triple ? triple[anotherType + 'Node']() : null; 1637 | 1638 | if (connectedNode && connectedNode !== node) { 1639 | this.disconnect(type, connectedNode, link); 1640 | connectedNode = null; 1641 | } 1642 | 1643 | var needsConnect = !connectedNode && node && anotherSideNode !== node; 1644 | 1645 | if (needsConnect) { 1646 | if (anotherSideNode) { 1647 | var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy()); 1648 | 1649 | link[type + 'X'](p.x); 1650 | link[type + 'Y'](p.y); 1651 | 1652 | triple.update(link); 1653 | triple.skipNextUpdate(true); 1654 | } 1655 | 1656 | this.connect(type, node, link); 1657 | } else { 1658 | link[type + 'X'](x); 1659 | link[type + 'Y'](y); 1660 | 1661 | if (!anotherSideNode) 1662 | link.straighten(); 1663 | } 1664 | } 1665 | }; 1666 | 1667 | Cmap.prototype.onend = function(dx, dy, event) { 1668 | var context = this.dragContext(); 1669 | 1670 | var component = context.component; 1671 | 1672 | if (!component) 1673 | return; 1674 | 1675 | if (!context.draggable) 1676 | return; 1677 | 1678 | this.unfixScrollSize(); 1679 | }; 1680 | 1681 | Cmap.prototype.fixScrollSize = function() { 1682 | var element = this.element(); 1683 | 1684 | var clientWidth = dom.clientWidth(element); 1685 | var clientHeight = dom.clientHeight(element); 1686 | var scrollWidth = dom.scrollWidth(element); 1687 | var scrollHeight = dom.scrollHeight(element); 1688 | 1689 | // check if scrolled 1690 | if (clientWidth === scrollWidth && clientHeight === scrollHeight) 1691 | return; 1692 | 1693 | var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)'; 1694 | 1695 | dom.css(this.retainerElement(), { 1696 | msTransform: translate, 1697 | transform: translate, 1698 | webkitTransform: translate 1699 | }); 1700 | }; 1701 | 1702 | Cmap.prototype.unfixScrollSize = function() { 1703 | var translate = 'translate(-1px, -1px)'; 1704 | 1705 | dom.css(this.retainerElement(), { 1706 | msTransform: translate, 1707 | transform: translate, 1708 | webkitTransform: translate 1709 | }); 1710 | }; 1711 | 1712 | Cmap.prototype.style = function() { 1713 | return { 1714 | color: '#333', 1715 | cursor: 'default', 1716 | fontFamily: 'sans-serif', 1717 | fontSize: '14px', 1718 | height: '100%', 1719 | MozUserSelect: 'none', 1720 | msUserSelect: 'none', 1721 | overflow: 'auto', 1722 | position: 'relative', 1723 | userSelect: 'none', 1724 | webkitUserSelect: 'none', 1725 | width: '100%' 1726 | }; 1727 | }; 1728 | 1729 | Cmap.prototype.retainerStyle = function() { 1730 | return { 1731 | height: '1px', 1732 | pointerEvents: 'none', 1733 | position: 'absolute', 1734 | width: '1px' 1735 | }; 1736 | }; 1737 | 1738 | Cmap.prototype.redraw = function() { 1739 | var rootElement = this.rootElement(); 1740 | 1741 | if (!rootElement) { 1742 | rootElement = dom.body(); 1743 | dom.css(rootElement, { 1744 | height: '100vh', 1745 | margin: '0', 1746 | width: '100vw' 1747 | }); 1748 | this.rootElement(rootElement); 1749 | } 1750 | 1751 | var element = dom.el('
'); 1752 | dom.draggable(element, this.onstart.bind(this), this.onmove.bind(this), this.onend.bind(this)); 1753 | this.element(element); 1754 | 1755 | this.componentList().toArray().forEach(function(component) { 1756 | component.parentElement(element); 1757 | }); 1758 | 1759 | var retainerElement = dom.el('
'); 1760 | dom.css(retainerElement, this.retainerStyle()); 1761 | dom.append(element, retainerElement); 1762 | this.retainerElement(retainerElement); 1763 | 1764 | // set initial position of retainer 1765 | this.unfixScrollSize(); 1766 | 1767 | dom.css(element, this.style()); 1768 | dom.append(rootElement, element); 1769 | }; 1770 | 1771 | Cmap.anotherConnectionType = function(type) { 1772 | if (type === Cmap.CONNECTION_TYPE_SOURCE) 1773 | return Cmap.CONNECTION_TYPE_TARGET; 1774 | else if (type === Cmap.CONNECTION_TYPE_TARGET) 1775 | return Cmap.CONNECTION_TYPE_SOURCE; 1776 | }; 1777 | 1778 | Cmap.CONNECTION_TYPE_SOURCE = 'source'; 1779 | Cmap.CONNECTION_TYPE_TARGET = 'target'; 1780 | 1781 | var ComponentModule = function(component, cmap) { 1782 | this.component = component; 1783 | this.cmap = cmap; 1784 | this.wrapper = helper.wrap(this, cmap.component); 1785 | }; 1786 | 1787 | ComponentModule.prototype.attr = function(key, value) { 1788 | var component = this.component; 1789 | var attributeKeys = this.constructor.attributeKeys(); 1790 | 1791 | if (typeof key === 'undefined') { 1792 | var props = {}; 1793 | 1794 | attributeKeys.forEach(function(key) { 1795 | props[key] = component[key](); 1796 | }); 1797 | 1798 | return props; 1799 | } 1800 | 1801 | if (helper.isPlainObject(key)) { 1802 | var props = key; 1803 | 1804 | for (key in props) { 1805 | this.attr(key, props[key]); 1806 | } 1807 | 1808 | return; 1809 | } 1810 | 1811 | if (attributeKeys.indexOf(key) === -1) 1812 | return; 1813 | 1814 | if (typeof value === 'undefined') 1815 | return component[key](); 1816 | 1817 | component[key](value); 1818 | }; 1819 | 1820 | ComponentModule.prototype.remove = function() { 1821 | this.cmap.component.remove(this.component); 1822 | helper.deactivate(this.wrapper); 1823 | 1824 | this.component = null; 1825 | this.cmap = null; 1826 | this.wrapper = null; 1827 | }; 1828 | 1829 | ComponentModule.prototype.toFront = function() { 1830 | this.cmap.component.toFront(this.component); 1831 | }; 1832 | 1833 | ComponentModule.prototype.element = function() { 1834 | return this.component.element(); 1835 | }; 1836 | 1837 | ComponentModule.prototype.redraw = function() { 1838 | this.component.redraw(); 1839 | }; 1840 | 1841 | ComponentModule.prototype.draggable = function(enabled) { 1842 | var component = this.component; 1843 | var cmap = this.cmap; 1844 | 1845 | if (typeof enabled === 'undefined') 1846 | return cmap.component.dragEnabled(component); 1847 | 1848 | if (enabled) 1849 | cmap.component.enableDrag(component); 1850 | else 1851 | cmap.component.disableDrag(component); 1852 | }; 1853 | 1854 | ComponentModule.attributeKeys = function() { 1855 | return []; 1856 | }; 1857 | 1858 | var NodeModule = helper.inherits(function(props, cmap) { 1859 | var component = new Node(helper.pick(props, NodeModule.attributeKeys())); 1860 | 1861 | NodeModule.super_.call(this, component, cmap); 1862 | }, ComponentModule); 1863 | 1864 | NodeModule.prototype.remove = function() { 1865 | this.cmap.nodeModuleList.remove(this); 1866 | NodeModule.super_.prototype.remove.call(this); 1867 | }; 1868 | 1869 | NodeModule.attributeKeys = function() { 1870 | return [ 1871 | 'content', 1872 | 'contentType', 1873 | 'x', 1874 | 'y', 1875 | 'width', 1876 | 'height', 1877 | 'backgroundColor', 1878 | 'borderColor', 1879 | 'borderWidth', 1880 | 'textColor' 1881 | ]; 1882 | }; 1883 | 1884 | var LinkModule = helper.inherits(function(props, cmap) { 1885 | var component = new Link(helper.pick(props, LinkModule.attributeKeys())); 1886 | 1887 | LinkModule.super_.call(this, component, cmap); 1888 | }, ComponentModule); 1889 | 1890 | LinkModule.prototype.sourceNode = function(node) { 1891 | return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_SOURCE, node); 1892 | }; 1893 | 1894 | LinkModule.prototype.targetNode = function(node) { 1895 | return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node); 1896 | }; 1897 | 1898 | LinkModule.prototype.sourceConnectorEnabled = function(enabled) { 1899 | return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_SOURCE, enabled); 1900 | }; 1901 | 1902 | LinkModule.prototype.targetConnectorEnabled = function(enabled) { 1903 | return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_TARGET, enabled); 1904 | }; 1905 | 1906 | LinkModule.attributeKeys = function() { 1907 | return [ 1908 | 'content', 1909 | 'contentType', 1910 | 'cx', 1911 | 'cy', 1912 | 'width', 1913 | 'height', 1914 | 'backgroundColor', 1915 | 'borderColor', 1916 | 'borderWidth', 1917 | 'textColor', 1918 | 'sourceX', 1919 | 'sourceY', 1920 | 'targetX', 1921 | 'targetY', 1922 | 'lineColor', 1923 | 'lineWidth', 1924 | 'hasArrow' 1925 | ]; 1926 | }; 1927 | 1928 | LinkModule.connectNode = function(module, type, node) { 1929 | var component = module.component; 1930 | var cmap = module.cmap; 1931 | 1932 | var cmapComponent = cmap.component; 1933 | var connectedNodeComponent = cmapComponent.connectedNode(type, component); 1934 | 1935 | if (typeof node === 'undefined') { 1936 | if (!connectedNodeComponent) 1937 | return null; 1938 | 1939 | var connectedNode = cmap.nodeModuleList.fromComponent(connectedNodeComponent); 1940 | return connectedNode.wrapper; 1941 | } 1942 | 1943 | if (node !== null) { 1944 | if (typeof node !== 'function') 1945 | throw TypeError('Invalid node'); 1946 | 1947 | // unwrap node module 1948 | node = node(cmap.component); 1949 | 1950 | if (!(node instanceof NodeModule)) 1951 | throw TypeError('Invalid node'); 1952 | 1953 | // cannot connect the same node to the source and the target of the link 1954 | var anotherType = Cmap.anotherConnectionType(type); 1955 | var anotherNodeComponent = cmapComponent.connectedNode(anotherType, component); 1956 | 1957 | if (anotherNodeComponent === node.component) 1958 | return; 1959 | } 1960 | 1961 | if (connectedNodeComponent) 1962 | cmapComponent.disconnect(type, connectedNodeComponent, component); 1963 | 1964 | if (node === null) 1965 | return; 1966 | 1967 | cmapComponent.connect(type, node.component, component); 1968 | }; 1969 | 1970 | LinkModule.connectorEnabled = function(module, type, enabled) { 1971 | var component = module.component; 1972 | var cmap = module.cmap; 1973 | 1974 | var cmapComponent = cmap.component; 1975 | 1976 | if (typeof enabled === 'undefined') 1977 | return cmapComponent.connectorEnabled(type, component); 1978 | 1979 | if (enabled) { 1980 | cmapComponent.enableConnector(type, component); 1981 | 1982 | // show the connector if another connector is showing 1983 | var anotherType = Cmap.anotherConnectionType(type); 1984 | 1985 | if (cmapComponent.connectorVisible(anotherType, component)) 1986 | cmapComponent.showConnector(type, component); 1987 | } else { 1988 | cmapComponent.disableConnector(type, component); 1989 | } 1990 | }; 1991 | 1992 | var NodeModuleList = helper.inherits(function() { 1993 | NodeModuleList.super_.call(this); 1994 | }, helper.List); 1995 | 1996 | NodeModuleList.prototype.fromComponent = function(component) { 1997 | var data = this.data; 1998 | 1999 | for (var i = 0, len = data.length; i < len; i++) { 2000 | var node = data[i]; 2001 | 2002 | if (node.component === component) 2003 | return node; 2004 | } 2005 | 2006 | return null; 2007 | }; 2008 | 2009 | var CmapModule = function(element) { 2010 | if (!(this instanceof CmapModule)) 2011 | return new CmapModule(element); 2012 | 2013 | this.component = new Cmap(element); 2014 | this.nodeModuleList = new NodeModuleList(); 2015 | 2016 | return helper.wrap(this, this.component); 2017 | }; 2018 | 2019 | CmapModule.prototype.node = function(props) { 2020 | if (typeof props !== 'undefined' && !helper.isPlainObject(props)) 2021 | throw TypeError('Type error'); 2022 | 2023 | var node = new NodeModule(props, this); 2024 | 2025 | this.component.add(node.component); 2026 | this.nodeModuleList.add(node); 2027 | 2028 | return node.wrapper; 2029 | }; 2030 | 2031 | CmapModule.prototype.link = function(props) { 2032 | if (typeof props !== 'undefined' && !helper.isPlainObject(props)) 2033 | throw TypeError('Type error'); 2034 | 2035 | var link = new LinkModule(props, this); 2036 | 2037 | this.component.add(link.component); 2038 | 2039 | return link.wrapper; 2040 | }; 2041 | 2042 | if (typeof module !== 'undefined' && module.exports) 2043 | module.exports = CmapModule; 2044 | else 2045 | window.Cmap = CmapModule; 2046 | })(); 2047 | --------------------------------------------------------------------------------