mirror of
https://github.com/stylersnico/librenms.git
synced 2026-07-27 00:25:06 +02:00
Squashed 'lib/vis/' changes from 1d029ca..58f0da39
58f0da39 Released v4.5.0 c507a59 made all search go buttons a live search instead of submit b377194 fixed hover and blur events for edges. f7d1b46 added configChange event 4ef684a Fixed #1087, updated history 5c411a9 polising the network docs a little 5f46591 unfolded tabs 3f3509f Merge remote-tracking branch 'origin/develop' into develop b0ca320 bit more hacking to make search blazingly fast :) dfd9114 Merge pull request #1091 from ChenMachluf/develop e1f7c1a remove subgroup from subgroups list if there is no more items in the subgroup. ba4f282 Merge remote-tracking branch 'origin/develop' into develop 2ce1f12 Fixed #1076: hiding remainder grid line at the end of a month a85d046 added live update for search 38cba4c Updated history afe0305 Merge pull request #1082 from hansmaulwurf23/zoom_key d881052 added zoomKey option to Graph2d, added missing docs adb5bce fixed missing validation of available zoomKey option values c0f0ab9 Merge pull request #1084 from felixhayashi/develop be603b01 yay! new search for visjs.org 5d5f589 Merge remote-tracking branch 'origin/develop' into develop 1bc9ea4 fixed clustering bug #1083 1a3fa5b typo 11c820a added zoomKey to apply zooming only when a specific key is down b175068 Fixed #1076: Fixed possible overlap of minor labels text on the TimeAxis 25706fb Updated history 58986aa Merged #996 89660a3 Added missing method getEventProperties in Core 20af47e Fixed #239: Do not zoom/move the window when the mouse is on the left panel with group labels 5f2e3c1 Merge remote-tracking branch 'origin/develop' into develop 4fd843e Fixed #1033: Moved item data not updated in DataSet when using an asynchronous `onMove` handler 8d23446 rebuilt and updated git ignore 6cf71b3 Merge pull request #1072 from zukomgwili/develop 27a875a Corrected the shapeProperties.borderDashes property type description 8543123 Fixed #1071: HTML contents of a group not cleared when the contents is updated 77df99c Fixed #1001: First element of group style being cut faddd15 Updated history 57be317 Merge pull request #1004 from ozydingo/develop 6c502fb Added a shapeProperties object allowing configuration of shapes. 7053fda Merge pull request #1073 from sc8696/master c2b00d0 Added feature that allows the border for a circularImage shape to be dashed 789c591 Update Network.js 53446b4 Merge pull request #1069 from Rikkitp/develop ce7daf2 Udpated groupsPerformance.html 3ab17e2 Fixed node resizing on dragging. 0bbeb93 properly fixed the _lockedRedraw method dad2453 updated to 4.4.1-SNAPSHOT cae9d43 Release 4.4 113fc47 preperations for v4.4 release 296a457 fixed clustering issue #1050 34d8534 Merge pull request #1059 from felixhayashi/contrib/breadcrumbs fe9086b added breadcrumbs to all pages 23b8dfb Merge pull request #1045 from bliksemlabs/develop 76b27b5 Merge pull request #1049 from ChenMachluf/develop ec4798b Merge pull request #1046 from Tooa/improve-scaling-doc 947b2aa Remove item from subgroups in Group remove method 57237ac * Improved documentation for node and edge scaling properties. df3e876 Add the missing group reference identification to exception. ace45e6 - Added getOptionsFromConfigurator method. 78c5f38 - Added labelHighlightBold option to edges and nodes. c28051b - Added blurEdge and hoverEdge events. 1a3f63f fixed vertical dragging issue from hammer (which was found in network) in the timeline 4ad5ffa updated readme 6553b39 updated history 47d5282 Fixed #1039, icon now returns correct distance to border 103b5b1 Fixed #1044, gave navigation buttons their own handler for release. d49c27d rebuilt, fixed small bug in points 560c0f4 rebuilt, updated ignore, updated history f2092fd Merge pull request #1014 from ericvandever/develop 9d3157a Merge pull request #1030 from mschallar/mschallar/develop 11b22d3 - Fixed #1036, bug in lockedRedraw. Thanks @vges! 51e2135 Fixed multiple paths to examples 35e8e15 Updated graph2d docs for drawPoints & drawPoints.onRender 06b78a0 Updated example 19_labels.html f39c246 Updated Points.draw method to support onRender 71f875f added onRender as a drawPoints property and as a function on the object itself d51be3b Delegate the onRender function to the correct property f732674 - Fixed bug where 0 axis was always in the automatically fitted range. 0cf8c4a - Fixed updating bounding box of nodes without drawing. dc77069 added getDataSet to network manipulation edge add and edit functions to match existing remove and node equivalents f0c18f7 exposed keycharm as vis.keycharm 378c2e4 removed clear() from graph2d docs 94d63c2 updated history 04aea9e Made graph2d robust against strings for y values. b75ccba Fixed static smooth edges not being handled correctly in all angles 729ee12 Only set backgroundColor if specified in setOptions 6cf5191 added templating for groups e7bfa9c - Fixed #987 proper cleaning of support nodes. af39649 - Improved handling of empty image field. f699061 - Fixed #974 connecting static smooth and straight edges. also bug introduced in the layout 3d09e62 - No longer start stabilization with an empty node set. 4d8ade5 Fixed #953 - Added zoom events from keyboard and navigation buttons. 3507d26 Fixed #949 delete callbacks with null argument not showing toolbar afterwards. f7eee47 fixed accidental redrawing during stabilization. Fixed infinite recursion when fixing node without coordinates e23e235 fixed hammerjs direction issue 302f2f6 placed dragEnd event after restoration of fixed state of node aeba17b Fixed default value of option backgroundColor 7b31382 Ordered the options alphabetically 01a351f Fixed #970: Implemented options `dataColor`, `axisColor`, and `gridColor`. bf49a30 Bumped version number to 4.3.1-SNAPSHOT dae3c3d Released version 4.3.0 b750d2e Added css class `vis-editable`, created an example demonstrating individually editable items (see #980) 07fd970 Merge pull request #980 from danbertolini/develop 7c22bd3 Configurator is now lazy loaded in Timeline, Graph2d, and Network (see #964) 96afb72 Fixed #964: `Timeline`, `Graph2d`, and `Network` not working on IE9 79e35e3 Throw an error from abstract method `Core.prototype.getDataRange` (see #966) 2f23c2f Merge remote-tracking branch 'upstream/develop' into develop f43e935 Issues #275, #363 - individual Timeline items editable or readonly dd575ad Removed redundant code from `Core.prototype.getDataRange` (see #966) c5c221d Merge pull request #966 from mschallar/master 8fe6408 fixed #974, requesting a redraw on update in case of physics being disabled. d894d0e changing the properties from getDataRange d1e01bc returning meaningful values in getDataRange 38cdcf2 accessing the correct properties in dataRange ecb525c Improved locales validation again 8ab51a7 proper fix for validator locales 4178e4c Fixed #950: option `locales` broken in `Timeline`, `Graph2d`, and `Network` 4ddf0fe Merge remote-tracking branch 'origin/develop' into develop eef6931 Fixed #942: bug when data is empty 3c34a7d fixed dragStart, dragging and dragEnd events when nodes are selected but the viewport is dragged e2b3d2d added --SNAPSHOT to dev 0242a35 release 4.2.0 9d8ac59 rebuilt 356d5b2 Merge remote-tracking branch 'origin/develop' into develop 0987a84 - Fixed #927, dragStart event didnt contain node that was being dragged 805efd3 Fixed #925: Event `timechanged` did not fire when mouse has been moved outside the timeline. 7a41733 Fixed #923: Added a property `event` to the properties emitted with the `select` event 42596ab added physics:enabled field. Updated docs 1302a6b Fixed bug where the network could flicker when the pixelRatio is not integer. 68d432f updated build 760c3e9 changed autoResize to only draw when change is detected. 40141de added from and to to docs, fixed hoverConnectedEdges in docs a58f4e2 Added axis orientation option `'none'` 7d5ec02 Removed some redundant css files from the docs 6530614 Merge pull request #906 from btmills/master 92bf2c3 Fix broken links to docs assets 856348d Ignore lib folder in Bower package 263f21b Merge remote-tracking branch 'origin/develop' into develop 7ca06bc Removed left over console.log statement a2fd26e fixed small bug 801a71d Merge remote-tracking branch 'origin/develop' into develop 3e9cceb Fixed #897: Timeline option `{snap: null}` did give a validation error f17401c expanded docs, added buttons, fixed titles 4f17eb4 expanded docs ba5cf07 Fixed #893, #911: the `clickToUse` option of Network, Graph2d, and Network was blocking click events in the web page. 85765f9 parseFloat of edges e96d228 Added releaseFunction to openCluster. 9111c23 Fixed #908, unselecting nodes and edges now only when clicking in void. f1f6989 make sure an stabilized event is always fired atleast once. ad13323 updated history e44bc8f reverted example ab112d8 added stochastic option check to the testing. Fixed few bugs 633e689 updated docs 5ab8195 - Fixed dataView support for storePositions 5b1aecf - Fixed dataView support for storePositions a1d4b27 - fixed minor bugs 8732bee - Fixed #904, correctly parsing global font options now. d1b0c59 fixed #899, self referencing edges do not use orientation other than horizontal 0150255 - Fixed few functions including storePositions(). - Added beginnings of unit testing for network. 629e596 - Improved robustness against people molesting the Function.prototype.bind() 23c6db6 Merge remote-tracking branch 'origin/develop' into develop 34be08f - Improved robustness against people molesting the Function.prototype.bind() 80f7ac7 Updated /dist fef525b Merge remote-tracking branch 'origin/develop' into develop cedae98 Updated /dist ba5a6d2 Improved function `fit()` to take into account the actual width of items (see #847) 7771406 Reorganized `Timeline.fit()` a bit bc1d174 updated history ee24fdb Merge remote-tracking branch 'origin/develop' into develop 1cf66f6 fixed #892, added case for any format ac0563e Fit working (still needs simplification) 3970ccc Added a usage example for `getEventPropeties(event)` (see #901) 654cec3 Merge branch 'develop' into develop_fit 7d8f0a6 Merge remote-tracking branch 'origin/develop' into develop 14a5752 Replaced spdx license expression with array with identifiers for bower (see #876) 1648604 Halfway implementing improved Timeline.fit() c3ac96d Merge branch 'develop' into develop_fit d27ce7f Merge pull request #890 from Gillingham/patch-1 3e18569 Tell browserify to apply babelify transform d6a2fab Bumped version number to 4.1.1-SNAPSHOT 62a844d Released version 4.1.0 bbfe7d3 Halfway improving Timeline.fit() f70982d Renamed example 6b0a55b Deleted old docs 6ef9d20 Fixed two links a1b987b Replaced underscores in example names with camel case 7060bea Merge remote-tracking branch 'origin/develop' into develop 07c7970 Renamed an example a2de04b rebuilt, updated history facdc0b Merge remote-tracking branch 'origin/develop' into develop 50e916e removed random from physics. fixed setting hidden and physics dynamically, fixed manipulation system bug 37f93c8 Disable `yAxisOrientation` in configurator. (isn't stable) 0e915c1 Removed a broken link 1c8e8db Moved the code blocks describing event properties to the 3rd column e987f9c Put docs on importing from Gephi and DOT language under one tab 626d4b9 Fixed #877: Added support for metaKey to select multiple items. ebfb7f5 Removed accidentally committed tooltips example cb4c33a Removed promt and confirm in example with fancy looking (non-blocking) prompt/confirm 786765c Merge remote-tracking branch 'origin/develop' into develop 09bc7d0 Call event.stopPropagation() before adding or removing items (see #577) ab04dfd Merge remote-tracking branch 'origin/develop' into develop 1a741c2 code cleanup fab3215 made deselect and select nodes and edges fire more consistently 375c157 Merge remote-tracking branch 'origin/develop' into develop c3d5452 Changed license information to a spdx composite license expression 4d81920 Merge remote-tracking branch 'origin/develop' into develop e8bef2f - Fixed #884, selectNode event. 1a8a738 Added license information to package.json too (see #876) da909a8 Fixed #876: added license information to bower.json 883f924 Fixed browserify command in custom bundle example 3 4c8416d Merge pull request #880 from yetanalytics/develop 2c7473e fixed typo in clusterByHubsize 2fd1f4f - Fixed hoverNode and blurNode events and added them to the docs. 043f198 fixed getNodeAt and getEdgeAt functions 7e05e40 removed theta from docs cf36140 removed log, rebuilt db5782c - Fixed #861, brokenImage only working for one node if nodes have the same image. 9f74b3f removed log, rebuilt 5dffc93 Merge remote-tracking branch 'origin/develop' into develop 09d7d4b - Fixed bug with the moveTo and getViewPosition methods. d77275e Fixed #869: add className with id to custom time bars 520971d Merge remote-tracking branch 'origin/develop' into develop 03d0e25 Fixed #872: error about deprecated function `unsubscribe` ba6ba69 tweaked function naming 229c22b Removed depricated dynamic entree, allow any smooth curve style for hierarchical layout. 62a8f60 #870, added multiselect to the docs 9c7eba9 fixed little bug in configurator 719a16a fixed little bug in configurator b2eaf58 - Fixed clustering bug e294594 - Fixed click to use 67bda4f - Fixed #866, manipulation can now be set to false without crashing. aa12cea Fixed #860, edit node mode now works as it should. 87277e5 fixed #859. images now resize again when they are loaded. c2b07f0 fixed #858 547fb05 fixed dynamic edges not correctly handling non-existent nodes. a69269c revert example 0219c43 changed popup css to pass all mouse events. All pageX and pageY changed to clientX and clientY because the correction is done with getBoundingClientRect b6085eb Merge pull request #856 from killerDJO/develop a53bcd8 fixed colors for selected and hover states 54f99f7 fixed little bug in layoutengine and it's brother d61dd8d fixed little bug in layoutengine b6cd712 Bumped version number to 4.0.1-SNAPSHOT d6216bb Removed menu item Documentation 43b2fb5 Released version 4.0.0 c139062 Merge remote-tracking branch 'origin/v4' into v4 a3211bf Fixed description about `babel` in history 112c975 updated docs, examples, history dcc2eaf fixed path 3f6b206 fixed bug in circular images, moved examples a2236b2 Merge remote-tracking branch 'origin/v4' into v4 7099dd1 renamed getEdges to getConnectedEdges, made getConnectedNodes also work for edges c5badc2 Reorganized the Timeline examples f685d62 Renamed folder `rest` to `other` b3fe008 Fixed a bug in DataView still using the deprecated `unsubscribe` 970204b Removed index.html files from examples 1d2e92f Merge remote-tracking branch 'origin/v4' into v4 041c2c0 updated css d404798 fixed alot of bugs, expanded configurator, added more examples, updated docs, updated update interval 50f6dbc fixed bug in clustering! woohoo! added event on off once to docs. more examples 1d47ca4 Added shortcut key ctrl+enter b8502f2 fixed cluster bug ea66036 Merge remote-tracking branch 'origin/v4' into v4 ad2d70e fixed numerous clustering bugs, added more examples, numerous other bug fixes a1d9310 Some minor fixed in dotparser 23b9a91 Implemented attribute mapping for DOT language attributes f0bc4b1 Some tweaks of the DOT playground 0f35f54 Merge remote-tracking branch 'origin/v4' into v4 7ec9c91 Some tweaks of the DOT playground d829737 Reworked the dot language playground and merged with the graphviz gallery 29e0cdd Merge remote-tracking branch 'origin/v4' into v4 0b0785f more examples, more tweaks, small expansion of the validator to check locally for matches. c817962 Merge remote-tracking branch 'origin/v4' into v4 69f2e51 Added docs/data/index.html 40c11a9 more buxfixes n doc updates and default value tweaks and example tweaks 7c6979c Merge remote-tracking branch 'origin/v4' into v4 cbc4c8d fixed bugs, set all groups to reload if globally changed f51c364 DataSet and DataView now have new styling bca61e3 Fixed losing selection when changing configuration option `type` on the fly ab4e93c Minor tweak 2c8c26f Deprecated functions `DataSet.subscribe` and `DataSet.unsubscribe` c131c08 Refactored table columns "mid" 146ba57 Documented `configure` for Timeline and Graph2d. Some minor fixes in the options. 1e03f99 Merge remote-tracking branch 'origin/v4' into v4 176742b Implemented configurator for Graph2d. Fixed Configurator displaying nothing when `filter: true`. 9921fe7 worked with examples 21b76a5 Merge remote-tracking branch 'origin/v4' into v4 350f050 Doc styling tweaks df03211 rebuilt f22a096 Merge remote-tracking branch 'origin/v4' into v4 c388397 numerous bugfixes, partial progress with examples 4b47dc5 Created a folder docs/old where we will move all old docs (will remove this folder when docs are done) 7f01183 Fixed #831: items losing selection when their type changed. c5d46c1 Prefixed css classes of the TimeAxis grid (Fixed #836) f26c4ea Fixed #841: messing up clientY and pageY in event handling 14ed1bb bugfixes, added examples 0d32566 bugfixes, start of reorganisation of examples d4e7130 enabled bar style in legend 67cf954 enabled bar style in legend cb4e58e removed log e5d2692 made style work with barcharts ccbdd46 fixed bounding box bug, added function filter to configuration system 7aecbec added new solver to docs cc3e12a added another solver based on barnesHut but with some of the forceAtlas2 equations b9ff0eb added validator to graph2d, maybe also put it in groups? 20ef9a1 made new graph2d docs, fixed graph2d examples, updated graph2d options 41d51c2 added fields to docs 14c4a10 added dot and gephi parser to docs, need to include examples in the docs everywhere. Added working example code at the top of the docs and link to example 1 d9a6b41 Output error.toString() in gulpfile 88ab094 Moved validator and ConfigurationSystem to ./lib/shared, and continue with options on error 5b12f55 fixed validator (for real now) b9796e6 rebuilt b01e3d2 rebuilt 672bbd8 Merge remote-tracking branch 'origin/v4' into v4 f81b41c fixed docs (added tabs) fixed gephiparser, fixed edges, labels on selfreferenced edges, fixed dotlanguage 5b76f06 Merge remote-tracking branch 'origin/v4' into v4 d96bbe5 Some fixes in the validator fd13122 Merge remote-tracking branch 'origin/v4' into v4 85350c0 restructured networks docs again, collapsible methods f79449e Implemented option validator for Timeline f30a3a4 Integrated an option configurator 36a0822 collapsible methods, experiment... b075115 changed docs, collected methods and events d4c20ed Replaced `|` with `or` 79127d4 Added text input to ConfigurationSystem, added support for moment data type 5c0f6da rebuilt! af46198 Merge remote-tracking branch 'origin/v4' into v4 5b7a90f added multiselect, reworked options ce29f50 Implemented option `multiselect`, which is false by default a75b8e7 Fixed #716: Height of graph `2px` too large when configuring a fixed height. 5b7f264 Fixed consistently falling back to "bottom" orientation in case of invalid values 9a793f7 Fixed styling of Graph2d lines/points 6ed263b Merge remote-tracking branch 'origin/v4' into v4 dfb25f9 Collapsable sub-options 5b3f19a fixed viewable area check as per #812 f07d21f Halfway refactoring the Timeline docs b9b1974 Removed "example*" name prefix from the graph3d examples 3d4213f Added an example about orientation of axis and items, and fixed a bug in orientation of items 8effbd7 Use DataSet in example 01 9105832 Clean up test code from example 01 0c97030 Reviewed/updated all timeline examples 4fc9ffb Removed redundant file layout.html cc574a4 Fixed transparency of background items b8206c5 Removed example 21 and renamed 31 4f4fa3f Merge remote-tracking branch 'origin/v4' into v4 7e0961a - Dropped support for Google DataTable. - Dropped support for appending data returned by `DataSet.get()` to an existing Array or DataTable. - Fixed a `window` issue in uuid.js. bbb317b Merge remote-tracking branch 'origin/v4' into v4 04fb1ae added a setSize to the redraw function aee5c1e Renamed option `animate` to `animation`, and changed it to be either a boolean or an object `{duration: number, easingFunction: string}` 7f8bedf Added convenience method `setData({groups: groups, items: items})` c28e4b9 Deprecated event `finishedRedraw` as it's redundant 97de69a Fixed broken method `getEventProperties` 2929faf Deprecated option `showCustomTime`. Use method `addCustomTime()` instead. df4c200 Fixed vertical scroll shadows becoming visible when dragging an item c9a1a88 fixed all trello issues and added snazzy option lookup to validator 54c9efb Updated examples 34-39 d01112f Updated examples 32, 33 f676845 Updated some more examples e02e8e9 reverted emit 0d03373 Merge remote-tracking branch 'origin/v4' into v4 b413dd5 render issue with hierarchical layout b144a0e Some configuration fixes in example 27 a60db8c Merge remote-tracking branch 'origin/v4' into v4 6aebfcf Some updates in example 29 30 315ad7c Merge remote-tracking branch 'origin/v4' into v4 a03d926 fix manipulation bar hiding 233b5f2 Merge remote-tracking branch 'origin/v4' into v4 415fb2f Moved the worldcup data into a separate file bdf1132 fixed buttons e6480b1 Merge remote-tracking branch 'origin/v4' into v4 034d63c Updated exampled up to 24 e4b64da reverted fix, avoidance is better 3dc963c disable the 'deleting' of fixed option de5a9a7 fixed physics bug 8e91192 Merge remote-tracking branch 'origin/v4' into v4 e0a59ba fixed dashes fd29906 Merge remote-tracking branch 'origin/v4' into v4 ad088cd Updated examples 1 to 13 0d9e6b0 added docs for null and undefined. Reduced levenshtein distance threshold from 10 to 8 in validator 357853e allowed deleting values with null and undefined. Fix for global variables, fix for edge color strings 050e2da fix for sad labelless nodes 415a27a updated docs, fixed setsize message 4bc0d62 rebuilt eded5ec Merge remote-tracking branch 'origin/v4' into v4 676250f made config system fully independent of the network. b523d7e Fixed background styling issue with Graph2d 7261294 Merge remote-tracking branch 'origin/v4' into v4 7012eeb Removed option clear 0ed1bc1 new build 7d3315b Merge remote-tracking branch 'origin/v4' into v4 4d57a6d unified options with timeline, updated docs and configurator 61108f3 Renamed class `.vis-item-frame` to `.vis-item-overflow` cf8c299 Removed the option `padding` for Timeline, is now redundant (can be done purely with css now) 491e40c Merge remote-tracking branch 'origin/v4' into v4 d4ba655 Changed the 'auto' alignment of range item contents such that it always displays the left side of the conents (see #785) 7fc617f reformatted options for graph2d dataAxis, docs to follow. New docs have statically linked menu bars, switched to uuid library for uuid v4 rfc4122 UUIDs (#432) 8d48a9d fixed stacking of positive and negative bars. #616 c6171b3 rebuilt af448ca Merge remote-tracking branch 'origin/v4' into v4 99ed2be fixed align zero by rewriting that part. #728 1d3ec56 Added extra div to background items too 0585fc4 Fixed delete button not being visible 91f053c minor style fixes b825e3f unified the docs a bit 85931ba gave docs googleanalytics.js 6b770a3 fixed destroy! 2ea3b2e created shorthand for all methods 6f5bc44 docs, bugfixes efd876d debugging a bit fbf9439 moved options to network bf274fd added a pretty amazing option checker to network :) 3fbab7c updated docs partially d4d928d finalized all methods and adde3d more docs 9ecefa9 updated config ee33873 added folding to manipulation docs 7e30d2f added manipulation documentation and numerous bugfixes 5734676 added groups docs 7919d58 added clustering documentation, updated clustering to 4.0, bug fixing 0a53a4e very advanced table collapse technology introduced. 2256516 fixed bugs in hierarchical layout. Fixed bugs in configuration, added wrapper to configuration divs,, made label optional for nodes, if no label, id will be shown. Support for value arrays in deepextend. 5287b3d Merge remote-tracking branch 'origin/v4' into v4 a150b7c fixed a number of bugs in manipulation 08b6f7b Fixed vertical scroll not working in Timeline and Graph2d c48a48a added docs to branch 3d4983d Merge remote-tracking branch 'origin/v4' into v4 9218044 consistency in physics c9da1b1 Fixed clickToUse 69dbe80 Merge remote-tracking branch 'origin/v4' into v4 7ec6715 Upgraded dependencies to the latest version (including a fix in propagating-hammerjs) d600bf7 implemented error notification when not giving an unicode for the icon 2d8604b Some fixes in Timeline after merging develop 33a76d8 Merge branch 'develop' into v4 af1579b Updated test file f88863e changed the dashes options, fixed event bugs in drag ec5f644 Merge remote-tracking branch 'origin/v4' into v4 867f033 changed the dashes options, fixed bug 4eff172 Merge remote-tracking branch 'origin/v4' into v4 28f8367 Fixed the touch and release events being fired twice each time. Fixed a bug with dragging in the InteractionHandler 68b75d5 fixed css 28e4882 tweaked options, simplified edge:color:inherit option bc3e4a2 Fixed #782: Contents of items created from a template being unnecessary recreated on click, causing mouse events to get lost. 41cdb2f Merge remote-tracking branch 'origin/v4' into v4 670c8a0 Fixed issues with preventDefault and with clicking achors/buttons in items on mobile devices 6d8974b Merge remote-tracking branch 'origin/v4' into v4 06ce63e removed duplicate code cc12969 bug fixes, methods added and options changed to clean up code and to adhere to docs 48278b9 Merge remote-tracking branch 'origin/v4' into v4 3ddd43a Fixed outputting errors from webpack/babel-loader de93344 renamed option showNavigationIcons to navigationButtons 8fe1b70 fixed bug in navigation 3292c02 rebuilt 8a57330 Merge remote-tracking branch 'origin/v4' into v4 6393f3e added shadow option to nodes and edges 29ea22b Merge remote-tracking branch 'origin/v4' into v4 1e98740 Refactored css of manipulation menu 31599b9 Refactored css of network navigation 9b5fb0c rebuilt again after merge 8c1a470 Merge remote-tracking branch 'origin/v4' into v4 8d509ed rebuilt cdaf668 Renamed `unified` to `shared` 0d2cd48 Refactored css of ColorPicker and Popup 3c8d01e Merge remote-tracking branch 'origin/v4' into v4 0469a7c reworked some of the options 7c83653 Merge remote-tracking branch 'origin/v4' into v4 b3e3dcc Refactored the css of Timeline and Graph2d to have a `vis-` prefix everywhere. (Still a small issue in Graph2d) 6e7553b Merge remote-tracking branch 'origin/v4' into v4 b120443 updated option stucture to use static methods to avoid duplicate definitions ef470ff Beep on error e6dd671 Merge remote-tracking branch 'origin/v4' into v4 8613f79 Updated dependencies e881a78 replaced `6to5` with `babel`, letting webpack output errors, minor fix 517263a minor changes, adhere to ES6 in bezier edge dynamic 2d7a1b2 Merge remote-tracking branch 'origin/v4' into v4 7248587 Removed spam 88a9149 Fixed preventDefault issue with hammer.js (a brute force solution but lets see how it goes...) 45862bd Do not have the function name and argument name ef9dd9c added comments to canvas 6c61e7b Renamed Network modules to capital first letter 54c5498 improved (circular) images size handling and label positions 5f4ebd0 patched redraw requests with image load callbacks during stabilization. Fixed size option correctly working for images and circular images. 4f47608 Partly fixed issue with updating an item's subgroup (see #745) 3d29ba5 restored default width and height to 100% c8fe42e Fixed titles not working when any of the nodes has id `0`. b358087 moved the entire network to triple ===. Bugs arise in scaling 61b9513 More descriptive warning on missing locale (see #761) 5db59a9 Fixed #761: Timeline and Graph2d throwing an error when locale is not found. Gives a warning message instead. 4514e8f Released version 3.12.0 fbfe6ad Fixed offset issue with dragging items vertically when the page is scrolled (this regression was introduced a few commits back) 34a5767 The event handlers `onMove` and `onMoving` are now invoked with all item properties as argument, and can be used to update all properties (like content, className, etc) and add new properties as well 0129127 added HTML color codes c68f6d7 fixed more bugs in options, we now have a generate options button! 6bdad7e fixed bugs, restyled colorPicker f8be61d removed certain options from the interactive config. 75f5d48 bridged label object options, fixed bug with self referencing circles eec31b0 more bugfixes, setOptions now propagates shapes, smooth and font options when set globally. 9471166 bugfixes, setOptions really needs thorough testing! a30c994 finalized a very fancy option configurator with a fancy color picker for color fields! 9c32469 added color picker 681f48e basic outline for system settings exists 81ea2b1 added google analytics b954d9c start of configuration system 83a0cf1 updated option handling for manipulation system 77be45d all of the manipulation has been moved to 4.0, redone and nicely commented and cleaned up :) 39eb2e5 most of the manipulation is now in 4.0 3e3a407 Fixed issue with Dutch locale (see #736) 0975223 Fixed #736: broken script url in example 13_localization.html f7a257c Fixed #733: background items being selected on shift+click. 3180235 Merge remote-tracking branch 'origin/develop' into develop 88695a6 Removed typo in className cd3da3c fixed bugs, options, tooltip options b62d536 updated group options, icon api change b9c97e1 moved groups to v4.0, fixed some bugs in edges dc5564a moved the popup system to 4.0. Fixed #731. 5c54f27 renamed all node and edge files, added diamond shape, tweaked shapes to always align and center all shapes. this improves the labelling 4b2cc60 cleaned up code a little 26357c2 cleaned up hierarchical view to work with multiple unconnected sets 5b7c301 added edge arrow shorthand b28af6f hierarchical system is now working and original x or y values are retained 9cd6c4a fixed images, groups, circular images, repeating events cf057e7 simplified fixed for nodes, removed duplicate datachanged events with support nodes in edges a903ec4 moved arrows to baseEdge, gave the straight edge a + 50% length in the physics. 14de6f2 arrows working! 2152bd6 clustering now using new visibility model, ripped edges apart, arrows not yet functional ef39ec1 icons also working. b2178ff seperated all node styles from the main node object 3fda539 made labels seperate. Needs cleaning up and comments 53795c9 added this.options.selfReferenceSize to edge ec6a474 unified pointOnLine and pointOnBezier to pointOnEdge 4a1ae62 edges are now accepting arrows back and forth using unified code baf4cd0 altered edge option structure ca11929 put all nodes and edges into one container. keep track of indices for visible and physics activated nodes 1fbdf3b network works again! 0f850c6 Merge pull request #707 from m4r71n/labels-on-bargraph 0b90860 Fixed #708: wrong group detected when page is scrolled. e0cc8d3 Merge branch 'master' into develop 1aca8e6 Show labels on Bargraph 1c536c3 Fixed #654: removed unnecessary minimum height for groups, takes the height of the group label as minimum height now. 2cf7d10 Call `event.stopPropagation()` before callback functions like onAdd, onRemove, etc. (see #577) b9bb040 Fixed #691: Orientation can now be configured separately for axis and items. 4c6d331 Fixed support for DataSet with custom id fields (option `fieldId`). See #701. fd15b36 Added missing docs on `hiddenDates` for Graph2d (see #695) 91ff934 Updated version number to 3.11.1-SNAPSHOT 333ec38 started creation of the edges handler and the nodes handler. currently broken. cf19c22 started work on the layout engine that will handle hierarchical layouts and the initial layout 208da97 tweaks. NagivationMixin has now been ported along with keyboard bindings using keycharm. 084c194 interaction tweaks a294f98 working events like zoom and drag, testing on mobile still to be done. 165ab9a Halfway updating Network to hammer2 dcaa7ef Fixed test/timeline.html 15d0ae8 Merge branch 'v4' into hammerjs2 6c01a14 Merge branch 'develop' into hammerjs2 3541260 work in progress of porting the event handlres 57d15b6 removed event. events are used for messaging between modules. fd06e8e reasonably success in decoupling the rendering and the network by segmenting a canvas, view (for camera controls) and renderer. 714f4af physics detached from nodes and network. adacb9c migrated all of the physics to the modules 2edd8b8 removed sectors, physics is almost stand-alone. Edges need better options for physics. b91757f first step to modularize the physics engine 093cb73 first modularizing experiment. Made body object containing data nodes, emitter, support functions. ClusterEngine is the first module. 1ed569c Merge branch 'develop' into v4 978b7b6 Merge branch 'develop' into v4 dc977a7 Merge branch 'clusterRewrite' into v4 0521284 setup for modularization, move to v4 branch 2689fd3 added comments fd93236 asynchrome stabilization a9b7485 fantastic clustering in network woohoo, added point labels for bargraphs in graph2d 63be7f8 Fixed #631: invalid css names for time axis grid. Renamed hours class names from `4-8h` to `h4-h8`. a716e3f Updated /dist 261ea43 Merge branch 'develop' into v4 d07eb69 Updated history d24d45c Merge branch 'v4' into es6 e0e1de7 Upgraded to the latest version of 6to5 783af7a Changed unit tests to use the compiled vis.js 81dc764 Fixed #621: item ranges being displayed with a minimum of 10px (twice the padding). 5892bd6 Added `6to5ify` transform to the dependencies and docs on custom builds with browserify 7b55f66 Merge branch 'develop' into es6 5a8789b Added propagating-hammerjs to package.json 7e6261a Merged develop into hammer2 branch e65c8b0 Merge branch 'master' into es6 cfe3de2 Merge branch 'develop' into es6 7114d5c Added es5 to es6 transpilation step to the build script, so now we can use ES6 :) 1b75021 Network mostly working with Hammer2 bf9684d Merge branch 'develop' into hammerjs2 6818d68 Neatly using hammer2 for the delete button a56f8eb Hammer2 mostly working in the Timeline 8013a68 Merge branch 'develop' into hammerjs2 e19502e Moving Timeline working with hammer2 (stop event propagation still an issue) 3b08676 Halfway implementing hammerjs2 for Timeline git-subtree-dir: lib/vis git-subtree-split: 58f0da3921bfe60ec1b63ed833ba97573e2f6331
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
let Hammer = require('../../module/hammer');
|
||||
let hammerUtil = require('../../hammerUtil');
|
||||
|
||||
let util = require('../../util');
|
||||
|
||||
/**
|
||||
* Create the main frame for the Network.
|
||||
* This function is executed once when a Network object is created. The frame
|
||||
* contains a canvas, and this canvas contains all objects like the axis and
|
||||
* nodes.
|
||||
* @private
|
||||
*/
|
||||
class Canvas {
|
||||
constructor(body) {
|
||||
this.body = body;
|
||||
this.pixelRatio = 1;
|
||||
this.resizeTimer = undefined;
|
||||
this.resizeFunction = this._onResize.bind(this);
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
autoResize: true,
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
// bind the events
|
||||
this.body.emitter.once("resize", (obj) => {
|
||||
if (obj.width !== 0) {
|
||||
this.body.view.translation.x = obj.width * 0.5;
|
||||
}
|
||||
if (obj.height !== 0) {
|
||||
this.body.view.translation.y = obj.height * 0.5;
|
||||
}
|
||||
});
|
||||
this.body.emitter.on("setSize", this.setSize.bind(this));
|
||||
this.body.emitter.on("destroy", () => {
|
||||
this.hammerFrame.destroy();
|
||||
this.hammer.destroy();
|
||||
this._cleanUp();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
let fields = ['width','height','autoResize'];
|
||||
util.selectiveDeepExtend(fields,this.options, options);
|
||||
}
|
||||
|
||||
if (this.options.autoResize === true) {
|
||||
// automatically adapt to a changing size of the browser.
|
||||
this._cleanUp();
|
||||
this.resizeTimer = setInterval(() => {
|
||||
let changed = this.setSize();
|
||||
if (changed === true) {
|
||||
this.body.emitter.emit("_requestRedraw");
|
||||
}
|
||||
}, 1000);
|
||||
this.resizeFunction = this._onResize.bind(this);
|
||||
util.addEventListener(window,'resize',this.resizeFunction);
|
||||
}
|
||||
}
|
||||
|
||||
_cleanUp() {
|
||||
// automatically adapt to a changing size of the browser.
|
||||
if (this.resizeTimer !== undefined) {
|
||||
clearInterval(this.resizeTimer);
|
||||
}
|
||||
util.removeEventListener(window,'resize',this.resizeFunction);
|
||||
this.resizeFunction = undefined;
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
this.setSize();
|
||||
this.body.emitter.emit("_redraw");
|
||||
}
|
||||
|
||||
_prepareValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
return value + 'px';
|
||||
}
|
||||
else if (typeof value === 'string') {
|
||||
if (value.indexOf('%') !== -1 || value.indexOf('px') !== -1) {
|
||||
return value;
|
||||
}
|
||||
else if (value.indexOf('%') === -1) {
|
||||
return value + 'px';
|
||||
}
|
||||
}
|
||||
throw new Error('Could not use the value supplie for width or height:' + value);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create the HTML
|
||||
*/
|
||||
_create() {
|
||||
// remove all elements from the container element.
|
||||
while (this.body.container.hasChildNodes()) {
|
||||
this.body.container.removeChild(this.body.container.firstChild);
|
||||
}
|
||||
|
||||
this.frame = document.createElement('div');
|
||||
this.frame.className = 'vis-network';
|
||||
this.frame.style.position = 'relative';
|
||||
this.frame.style.overflow = 'hidden';
|
||||
this.frame.tabIndex = 900; // tab index is required for keycharm to bind keystrokes to the div instead of the window
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
|
||||
this.frame.canvas = document.createElement("canvas");
|
||||
this.frame.canvas.style.position = 'relative';
|
||||
this.frame.appendChild(this.frame.canvas);
|
||||
|
||||
if (!this.frame.canvas.getContext) {
|
||||
let noCanvas = document.createElement( 'DIV' );
|
||||
noCanvas.style.color = 'red';
|
||||
noCanvas.style.fontWeight = 'bold' ;
|
||||
noCanvas.style.padding = '10px';
|
||||
noCanvas.innerHTML = 'Error: your browser does not support HTML canvas';
|
||||
this.frame.canvas.appendChild(noCanvas);
|
||||
}
|
||||
else {
|
||||
let ctx = this.frame.canvas.getContext("2d");
|
||||
this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
|
||||
ctx.mozBackingStorePixelRatio ||
|
||||
ctx.msBackingStorePixelRatio ||
|
||||
ctx.oBackingStorePixelRatio ||
|
||||
ctx.backingStorePixelRatio || 1);
|
||||
|
||||
this.frame.canvas.getContext("2d").setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
|
||||
}
|
||||
|
||||
// add the frame to the container element
|
||||
this.body.container.appendChild(this.frame);
|
||||
|
||||
this.body.view.scale = 1;
|
||||
this.body.view.translation = {x: 0.5 * this.frame.canvas.clientWidth,y: 0.5 * this.frame.canvas.clientHeight};
|
||||
|
||||
this._bindHammer();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function binds hammer, it can be repeated over and over due to the uniqueness check.
|
||||
* @private
|
||||
*/
|
||||
_bindHammer() {
|
||||
if (this.hammer !== undefined) {
|
||||
this.hammer.destroy();
|
||||
}
|
||||
this.drag = {};
|
||||
this.pinch = {};
|
||||
|
||||
// init hammer
|
||||
this.hammer = new Hammer(this.frame.canvas);
|
||||
this.hammer.get('pinch').set({enable: true});
|
||||
// enable to get better response, todo: test on mobile.
|
||||
this.hammer.get('pan').set({threshold:5, direction:30}); // 30 is ALL_DIRECTIONS in hammer.
|
||||
|
||||
hammerUtil.onTouch(this.hammer, (event) => {this.body.eventListeners.onTouch(event)});
|
||||
this.hammer.on('tap', (event) => {this.body.eventListeners.onTap(event)});
|
||||
this.hammer.on('doubletap', (event) => {this.body.eventListeners.onDoubleTap(event)});
|
||||
this.hammer.on('press', (event) => {this.body.eventListeners.onHold(event)});
|
||||
this.hammer.on('panstart', (event) => {this.body.eventListeners.onDragStart(event)});
|
||||
this.hammer.on('panmove', (event) => {this.body.eventListeners.onDrag(event)});
|
||||
this.hammer.on('panend', (event) => {this.body.eventListeners.onDragEnd(event)});
|
||||
this.hammer.on('pinch', (event) => {this.body.eventListeners.onPinch(event)});
|
||||
|
||||
// TODO: neatly cleanup these handlers when re-creating the Canvas, IF these are done with hammer, event.stopPropagation will not work?
|
||||
this.frame.canvas.addEventListener('mousewheel', (event) => {this.body.eventListeners.onMouseWheel(event)});
|
||||
this.frame.canvas.addEventListener('DOMMouseScroll', (event) => {this.body.eventListeners.onMouseWheel(event)});
|
||||
|
||||
this.frame.canvas.addEventListener('mousemove', (event) => {this.body.eventListeners.onMouseMove(event)});
|
||||
this.frame.canvas.addEventListener('contextmenu', (event) => {this.body.eventListeners.onContext(event)});
|
||||
|
||||
this.hammerFrame = new Hammer(this.frame);
|
||||
hammerUtil.onRelease(this.hammerFrame, (event) => {this.body.eventListeners.onRelease(event)});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set a new size for the network
|
||||
* @param {string} width Width in pixels or percentage (for example '800px'
|
||||
* or '50%')
|
||||
* @param {string} height Height in pixels or percentage (for example '400px'
|
||||
* or '30%')
|
||||
*/
|
||||
setSize(width = this.options.width, height = this.options.height) {
|
||||
width = this._prepareValue(width);
|
||||
height= this._prepareValue(height);
|
||||
|
||||
let emitEvent = false;
|
||||
let oldWidth = this.frame.canvas.width;
|
||||
let oldHeight = this.frame.canvas.height;
|
||||
|
||||
if (width != this.options.width || height != this.options.height || this.frame.style.width != width || this.frame.style.height != height) {
|
||||
this.frame.style.width = width;
|
||||
this.frame.style.height = height;
|
||||
|
||||
this.frame.canvas.style.width = '100%';
|
||||
this.frame.canvas.style.height = '100%';
|
||||
|
||||
this.frame.canvas.width = Math.round(this.frame.canvas.clientWidth * this.pixelRatio);
|
||||
this.frame.canvas.height = Math.round(this.frame.canvas.clientHeight * this.pixelRatio);
|
||||
|
||||
this.options.width = width;
|
||||
this.options.height = height;
|
||||
|
||||
emitEvent = true;
|
||||
}
|
||||
else {
|
||||
// this would adapt the width of the canvas to the width from 100% if and only if
|
||||
// there is a change.
|
||||
|
||||
if (this.frame.canvas.width != Math.round(this.frame.canvas.clientWidth * this.pixelRatio)) {
|
||||
this.frame.canvas.width = Math.round(this.frame.canvas.clientWidth * this.pixelRatio);
|
||||
emitEvent = true;
|
||||
}
|
||||
if (this.frame.canvas.height != Math.round(this.frame.canvas.clientHeight * this.pixelRatio)) {
|
||||
this.frame.canvas.height = Math.round(this.frame.canvas.clientHeight * this.pixelRatio);
|
||||
emitEvent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (emitEvent === true) {
|
||||
this.body.emitter.emit('resize', {
|
||||
width:Math.round(this.frame.canvas.width / this.pixelRatio),
|
||||
height:Math.round(this.frame.canvas.height / this.pixelRatio),
|
||||
oldWidth: Math.round(oldWidth / this.pixelRatio),
|
||||
oldHeight: Math.round(oldHeight / this.pixelRatio)
|
||||
});
|
||||
}
|
||||
|
||||
return emitEvent;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert the X coordinate in DOM-space (coordinate point in browser relative to the container div) to
|
||||
* the X coordinate in canvas-space (the simulation sandbox, which the camera looks upon)
|
||||
* @param {number} x
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_XconvertDOMtoCanvas(x) {
|
||||
return (x - this.body.view.translation.x) / this.body.view.scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the X coordinate in canvas-space (the simulation sandbox, which the camera looks upon) to
|
||||
* the X coordinate in DOM-space (coordinate point in browser relative to the container div)
|
||||
* @param {number} x
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_XconvertCanvasToDOM(x) {
|
||||
return x * this.body.view.scale + this.body.view.translation.x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Y coordinate in DOM-space (coordinate point in browser relative to the container div) to
|
||||
* the Y coordinate in canvas-space (the simulation sandbox, which the camera looks upon)
|
||||
* @param {number} y
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_YconvertDOMtoCanvas(y) {
|
||||
return (y - this.body.view.translation.y) / this.body.view.scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Y coordinate in canvas-space (the simulation sandbox, which the camera looks upon) to
|
||||
* the Y coordinate in DOM-space (coordinate point in browser relative to the container div)
|
||||
* @param {number} y
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_YconvertCanvasToDOM(y) {
|
||||
return y * this.body.view.scale + this.body.view.translation.y;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} pos = {x: number, y: number}
|
||||
* @returns {{x: number, y: number}}
|
||||
* @constructor
|
||||
*/
|
||||
canvasToDOM (pos) {
|
||||
return {x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y)};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} pos = {x: number, y: number}
|
||||
* @returns {{x: number, y: number}}
|
||||
* @constructor
|
||||
*/
|
||||
DOMtoCanvas (pos) {
|
||||
return {x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y)};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Canvas;
|
||||
@@ -0,0 +1,348 @@
|
||||
if (typeof window !== 'undefined') {
|
||||
window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
|
||||
}
|
||||
|
||||
let util = require('../../util');
|
||||
|
||||
|
||||
class CanvasRenderer {
|
||||
constructor(body, canvas) {
|
||||
this.body = body;
|
||||
this.canvas = canvas;
|
||||
|
||||
this.redrawRequested = false;
|
||||
this.renderTimer = undefined;
|
||||
this.requiresTimeout = true;
|
||||
this.renderingActive = false;
|
||||
this.renderRequests = 0;
|
||||
this.pixelRatio = undefined;
|
||||
this.allowRedraw = true;
|
||||
|
||||
this.dragging = false;
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
hideEdgesOnDrag: false,
|
||||
hideNodesOnDrag: false
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this._determineBrowserMethod();
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
this.body.emitter.on("dragStart", () => {
|
||||
this.dragging = true;
|
||||
});
|
||||
this.body.emitter.on("dragEnd", () => this.dragging = false);
|
||||
this.body.emitter.on("_resizeNodes", () => this._resizeNodes());
|
||||
this.body.emitter.on("_redraw", () => {
|
||||
if (this.renderingActive === false) {
|
||||
this._redraw();
|
||||
}
|
||||
});
|
||||
this.body.emitter.on("_blockRedraw", () => {this.allowRedraw = false;});
|
||||
this.body.emitter.on("_allowRedraw", () => {this.allowRedraw = true; this.redrawRequested = false;});
|
||||
this.body.emitter.on("_requestRedraw", this._requestRedraw.bind(this));
|
||||
this.body.emitter.on("_startRendering", () => {
|
||||
this.renderRequests += 1;
|
||||
this.renderingActive = true;
|
||||
this._startRendering();
|
||||
});
|
||||
this.body.emitter.on("_stopRendering", () => {
|
||||
this.renderRequests -= 1;
|
||||
this.renderingActive = this.renderRequests > 0;
|
||||
this.renderTimer = undefined;
|
||||
});
|
||||
this.body.emitter.on('destroy', () => {
|
||||
this.renderRequests = 0;
|
||||
this.renderingActive = false;
|
||||
if (this.requiresTimeout === true) {
|
||||
clearTimeout(this.renderTimer);
|
||||
}
|
||||
else {
|
||||
cancelAnimationFrame(this.renderTimer);
|
||||
}
|
||||
this.body.emitter.off();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
let fields = ['hideEdgesOnDrag','hideNodesOnDrag'];
|
||||
util.selectiveDeepExtend(fields,this.options, options);
|
||||
}
|
||||
}
|
||||
|
||||
_startRendering() {
|
||||
if (this.renderingActive === true) {
|
||||
if (this.renderTimer === undefined) {
|
||||
if (this.requiresTimeout === true) {
|
||||
this.renderTimer = window.setTimeout(this._renderStep.bind(this), this.simulationInterval); // wait this.renderTimeStep milliseconds and perform the animation step function
|
||||
}
|
||||
else {
|
||||
this.renderTimer = window.requestAnimationFrame(this._renderStep.bind(this)); // wait this.renderTimeStep milliseconds and perform the animation step function
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_renderStep() {
|
||||
if (this.renderingActive === true) {
|
||||
// reset the renderTimer so a new scheduled animation step can be set
|
||||
this.renderTimer = undefined;
|
||||
|
||||
if (this.requiresTimeout === true) {
|
||||
// this schedules a new simulation step
|
||||
this._startRendering();
|
||||
}
|
||||
|
||||
this._redraw();
|
||||
|
||||
if (this.requiresTimeout === false) {
|
||||
// this schedules a new simulation step
|
||||
this._startRendering();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw the network with the current data
|
||||
* chart will be resized too.
|
||||
*/
|
||||
redraw() {
|
||||
this.body.emitter.emit('setSize');
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw the network with the current data
|
||||
* @param hidden | used to get the first estimate of the node sizes. only the nodes are drawn after which they are quickly drawn over.
|
||||
* @private
|
||||
*/
|
||||
_requestRedraw() {
|
||||
if (this.redrawRequested !== true && this.renderingActive === false && this.allowRedraw === true) {
|
||||
this.redrawRequested = true;
|
||||
if (this.requiresTimeout === true) {
|
||||
window.setTimeout(() => {this._redraw(false);}, 0);
|
||||
}
|
||||
else {
|
||||
window.requestAnimationFrame(() => {this._redraw(false);});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_redraw(hidden = false) {
|
||||
if (this.allowRedraw === true) {
|
||||
this.body.emitter.emit("initRedraw");
|
||||
|
||||
this.redrawRequested = false;
|
||||
let ctx = this.canvas.frame.canvas.getContext('2d');
|
||||
|
||||
// when the container div was hidden, this fixes it back up!
|
||||
if (this.canvas.frame.canvas.width === 0 || this.canvas.frame.canvas.height === 0) {
|
||||
this.canvas.setSize();
|
||||
}
|
||||
|
||||
if (this.pixelRatio === undefined) {
|
||||
this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
|
||||
ctx.mozBackingStorePixelRatio ||
|
||||
ctx.msBackingStorePixelRatio ||
|
||||
ctx.oBackingStorePixelRatio ||
|
||||
ctx.backingStorePixelRatio || 1);
|
||||
}
|
||||
|
||||
ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
|
||||
|
||||
// clear the canvas
|
||||
let w = this.canvas.frame.canvas.clientWidth;
|
||||
let h = this.canvas.frame.canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// set scaling and translation
|
||||
ctx.save();
|
||||
ctx.translate(this.body.view.translation.x, this.body.view.translation.y);
|
||||
ctx.scale(this.body.view.scale, this.body.view.scale);
|
||||
|
||||
ctx.beginPath();
|
||||
this.body.emitter.emit("beforeDrawing", ctx);
|
||||
ctx.closePath();
|
||||
|
||||
if (hidden === false) {
|
||||
if (this.dragging === false || (this.dragging === true && this.options.hideEdgesOnDrag === false)) {
|
||||
this._drawEdges(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dragging === false || (this.dragging === true && this.options.hideNodesOnDrag === false)) {
|
||||
this._drawNodes(ctx, hidden);
|
||||
}
|
||||
|
||||
if (this.controlNodesActive === true) {
|
||||
this._drawControlNodes(ctx);
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
//this.physics.nodesSolver._debug(ctx,"#F00F0F");
|
||||
this.body.emitter.emit("afterDrawing", ctx);
|
||||
ctx.closePath();
|
||||
// restore original scaling and translation
|
||||
ctx.restore();
|
||||
|
||||
if (hidden === true) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Redraw all nodes
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {Boolean} [alwaysShow]
|
||||
* @private
|
||||
*/
|
||||
_resizeNodes() {
|
||||
let ctx = this.canvas.frame.canvas.getContext('2d');
|
||||
if (this.pixelRatio === undefined) {
|
||||
this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
|
||||
ctx.mozBackingStorePixelRatio ||
|
||||
ctx.msBackingStorePixelRatio ||
|
||||
ctx.oBackingStorePixelRatio ||
|
||||
ctx.backingStorePixelRatio || 1);
|
||||
}
|
||||
ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
|
||||
ctx.save();
|
||||
ctx.translate(this.body.view.translation.x, this.body.view.translation.y);
|
||||
ctx.scale(this.body.view.scale, this.body.view.scale);
|
||||
|
||||
let nodes = this.body.nodes;
|
||||
let node;
|
||||
|
||||
// resize all nodes
|
||||
for (let nodeId in nodes) {
|
||||
if (nodes.hasOwnProperty(nodeId)) {
|
||||
node = nodes[nodeId];
|
||||
node.resize(ctx);
|
||||
node.updateBoundingBox(ctx, node.selected);
|
||||
}
|
||||
}
|
||||
|
||||
// restore original scaling and translation
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw all nodes
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {Boolean} [alwaysShow]
|
||||
* @private
|
||||
*/
|
||||
_drawNodes(ctx, alwaysShow = false) {
|
||||
let nodes = this.body.nodes;
|
||||
let nodeIndices = this.body.nodeIndices;
|
||||
let node;
|
||||
let selected = [];
|
||||
let margin = 20;
|
||||
let topLeft = this.canvas.DOMtoCanvas({x:-margin,y:-margin});
|
||||
let bottomRight = this.canvas.DOMtoCanvas({
|
||||
x: this.canvas.frame.canvas.clientWidth+margin,
|
||||
y: this.canvas.frame.canvas.clientHeight+margin
|
||||
});
|
||||
let viewableArea = {top:topLeft.y,left:topLeft.x,bottom:bottomRight.y,right:bottomRight.x};
|
||||
|
||||
|
||||
// draw unselected nodes;
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
node = nodes[nodeIndices[i]];
|
||||
// set selected nodes aside
|
||||
if (node.isSelected()) {
|
||||
selected.push(nodeIndices[i]);
|
||||
}
|
||||
else {
|
||||
if (alwaysShow === true) {
|
||||
node.draw(ctx);
|
||||
}
|
||||
else if (node.isBoundingBoxOverlappingWith(viewableArea) === true) {
|
||||
node.draw(ctx);
|
||||
}
|
||||
else {
|
||||
node.updateBoundingBox(ctx, node.selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw the selected nodes on top
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
node = nodes[selected[i]];
|
||||
node.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Redraw all edges
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_drawEdges(ctx) {
|
||||
let edges = this.body.edges;
|
||||
let edgeIndices = this.body.edgeIndices;
|
||||
let edge;
|
||||
|
||||
for (let i = 0; i < edgeIndices.length; i++) {
|
||||
edge = edges[edgeIndices[i]];
|
||||
if (edge.connected === true) {
|
||||
edge.draw(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw all edges
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_drawControlNodes(ctx) {
|
||||
let edges = this.body.edges;
|
||||
let edgeIndices = this.body.edgeIndices;
|
||||
let edge;
|
||||
|
||||
for (let i = 0; i < edgeIndices.length; i++) {
|
||||
edge = edges[edgeIndices[i]];
|
||||
edge._drawControlNodes(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the browser requires a setTimeout or a requestAnimationFrame. This was required because
|
||||
* some implementations (safari and IE9) did not support requestAnimationFrame
|
||||
* @private
|
||||
*/
|
||||
_determineBrowserMethod() {
|
||||
if (typeof window !== 'undefined') {
|
||||
let browserType = navigator.userAgent.toLowerCase();
|
||||
this.requiresTimeout = false;
|
||||
if (browserType.indexOf('msie 9.0') != -1) { // IE 9
|
||||
this.requiresTimeout = true;
|
||||
}
|
||||
else if (browserType.indexOf('safari') != -1) { // safari
|
||||
if (browserType.indexOf('chrome') <= -1) {
|
||||
this.requiresTimeout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.requiresTimeout = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CanvasRenderer;
|
||||
@@ -0,0 +1,721 @@
|
||||
let util = require("../../util");
|
||||
import Cluster from './components/nodes/Cluster'
|
||||
|
||||
class ClusterEngine {
|
||||
constructor(body) {
|
||||
this.body = body;
|
||||
this.clusteredNodes = {};
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.body.emitter.on('_resetData', () => {this.clusteredNodes = {};})
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param hubsize
|
||||
* @param options
|
||||
*/
|
||||
clusterByHubsize(hubsize, options) {
|
||||
if (hubsize === undefined) {
|
||||
hubsize = this._getHubSize();
|
||||
}
|
||||
else if (typeof(hubsize) === "object") {
|
||||
options = this._checkOptions(hubsize);
|
||||
hubsize = this._getHubSize();
|
||||
}
|
||||
|
||||
let nodesToCluster = [];
|
||||
for (let i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
let node = this.body.nodes[this.body.nodeIndices[i]];
|
||||
if (node.edges.length >= hubsize) {
|
||||
nodesToCluster.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodesToCluster.length; i++) {
|
||||
this.clusterByConnection(nodesToCluster[i],options,false);
|
||||
}
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* loop over all nodes, check if they adhere to the condition and cluster if needed.
|
||||
* @param options
|
||||
* @param refreshData
|
||||
*/
|
||||
cluster(options = {}, refreshData = true) {
|
||||
if (options.joinCondition === undefined) {throw new Error("Cannot call clusterByNodeData without a joinCondition function in the options.");}
|
||||
|
||||
// check if the options object is fine, append if needed
|
||||
options = this._checkOptions(options);
|
||||
|
||||
let childNodesObj = {};
|
||||
let childEdgesObj = {};
|
||||
|
||||
// collect the nodes that will be in the cluster
|
||||
for (let i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
let nodeId = this.body.nodeIndices[i];
|
||||
let node = this.body.nodes[nodeId];
|
||||
let clonedOptions = this._cloneOptions(node);
|
||||
if (options.joinCondition(clonedOptions) === true) {
|
||||
childNodesObj[nodeId] = this.body.nodes[nodeId];
|
||||
|
||||
// collect the nodes that will be in the cluster
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
childEdgesObj[edge.id] = edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._cluster(childNodesObj, childEdgesObj, options, refreshData);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cluster all nodes in the network that have only 1 edge
|
||||
* @param options
|
||||
* @param refreshData
|
||||
*/
|
||||
clusterOutliers(options, refreshData = true) {
|
||||
options = this._checkOptions(options);
|
||||
let clusters = [];
|
||||
|
||||
// collect the nodes that will be in the cluster
|
||||
for (let i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
let childNodesObj = {};
|
||||
let childEdgesObj = {};
|
||||
let nodeId = this.body.nodeIndices[i];
|
||||
let visibleEdges = 0;
|
||||
let edge;
|
||||
for (let j = 0; j < this.body.nodes[nodeId].edges.length; j++) {
|
||||
if (this.body.nodes[nodeId].edges[j].options.hidden === false) {
|
||||
visibleEdges++;
|
||||
edge = this.body.nodes[nodeId].edges[j];
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleEdges === 1) {
|
||||
// this is an outlier
|
||||
let childNodeId = this._getConnectedId(edge, nodeId);
|
||||
if (childNodeId !== nodeId) {
|
||||
if (options.joinCondition === undefined) {
|
||||
if (this._checkIfUsed(clusters,nodeId,edge.id) === false && this._checkIfUsed(clusters,childNodeId,edge.id) === false) {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
childNodesObj[nodeId] = this.body.nodes[nodeId];
|
||||
childNodesObj[childNodeId] = this.body.nodes[childNodeId];
|
||||
}
|
||||
}
|
||||
else {
|
||||
let clonedOptions = this._cloneOptions(this.body.nodes[nodeId]);
|
||||
if (options.joinCondition(clonedOptions) === true && this._checkIfUsed(clusters,nodeId,edge.id) === false) {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
childNodesObj[nodeId] = this.body.nodes[nodeId];
|
||||
}
|
||||
clonedOptions = this._cloneOptions(this.body.nodes[childNodeId]);
|
||||
if (options.joinCondition(clonedOptions) === true && this._checkIfUsed(clusters,nodeId,edge.id) === false) {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
childNodesObj[childNodeId] = this.body.nodes[childNodeId];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(childNodesObj).length > 0 && Object.keys(childEdgesObj).length > 0) {
|
||||
clusters.push({nodes: childNodesObj, edges: childEdgesObj})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
this._cluster(clusters[i].nodes, clusters[i].edges, options, false)
|
||||
}
|
||||
|
||||
if (refreshData === true) {
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_checkIfUsed(clusters, nodeId, edgeId) {
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
let cluster = clusters[i];
|
||||
if (cluster.nodes[nodeId] !== undefined || cluster.edges[edgeId] !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* suck all connected nodes of a node into the node.
|
||||
* @param nodeId
|
||||
* @param options
|
||||
* @param refreshData
|
||||
*/
|
||||
clusterByConnection(nodeId, options, refreshData = true) {
|
||||
// kill conditions
|
||||
if (nodeId === undefined) {throw new Error("No nodeId supplied to clusterByConnection!");}
|
||||
if (this.body.nodes[nodeId] === undefined) {throw new Error("The nodeId given to clusterByConnection does not exist!");}
|
||||
|
||||
let node = this.body.nodes[nodeId];
|
||||
options = this._checkOptions(options, node);
|
||||
if (options.clusterNodeProperties.x === undefined) {options.clusterNodeProperties.x = node.x;}
|
||||
if (options.clusterNodeProperties.y === undefined) {options.clusterNodeProperties.y = node.y;}
|
||||
if (options.clusterNodeProperties.fixed === undefined) {
|
||||
options.clusterNodeProperties.fixed = {};
|
||||
options.clusterNodeProperties.fixed.x = node.options.fixed.x;
|
||||
options.clusterNodeProperties.fixed.y = node.options.fixed.y;
|
||||
}
|
||||
|
||||
|
||||
let childNodesObj = {};
|
||||
let childEdgesObj = {};
|
||||
let parentNodeId = node.id;
|
||||
let parentClonedOptions = this._cloneOptions(node);
|
||||
childNodesObj[parentNodeId] = node;
|
||||
|
||||
// collect the nodes that will be in the cluster
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
let childNodeId = this._getConnectedId(edge, parentNodeId);
|
||||
|
||||
if (childNodeId !== parentNodeId) {
|
||||
if (options.joinCondition === undefined) {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
childNodesObj[childNodeId] = this.body.nodes[childNodeId];
|
||||
}
|
||||
else {
|
||||
// clone the options and insert some additional parameters that could be interesting.
|
||||
let childClonedOptions = this._cloneOptions(this.body.nodes[childNodeId]);
|
||||
if (options.joinCondition(parentClonedOptions, childClonedOptions) === true) {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
childNodesObj[childNodeId] = this.body.nodes[childNodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
childEdgesObj[edge.id] = edge;
|
||||
}
|
||||
}
|
||||
|
||||
this._cluster(childNodesObj, childEdgesObj, options, refreshData);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This returns a clone of the options or options of the edge or node to be used for construction of new edges or check functions for new nodes.
|
||||
* @param objId
|
||||
* @param type
|
||||
* @returns {{}}
|
||||
* @private
|
||||
*/
|
||||
_cloneOptions(item, type) {
|
||||
let clonedOptions = {};
|
||||
if (type === undefined || type === 'node') {
|
||||
util.deepExtend(clonedOptions, item.options, true);
|
||||
clonedOptions.x = item.x;
|
||||
clonedOptions.y = item.y;
|
||||
clonedOptions.amountOfConnections = item.edges.length;
|
||||
}
|
||||
else {
|
||||
util.deepExtend(clonedOptions, item.options, true);
|
||||
}
|
||||
return clonedOptions;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function creates the edges that will be attached to the cluster.
|
||||
*
|
||||
* @param childNodesObj
|
||||
* @param childEdgesObj
|
||||
* @param newEdges
|
||||
* @param options
|
||||
* @private
|
||||
*/
|
||||
_createClusterEdges (childNodesObj, childEdgesObj, newEdges, clusterNodeProperties, clusterEdgeProperties) {
|
||||
let edge, childNodeId, childNode, toId, fromId, otherNodeId;
|
||||
|
||||
let childKeys = Object.keys(childNodesObj);
|
||||
for (let i = 0; i < childKeys.length; i++) {
|
||||
childNodeId = childKeys[i];
|
||||
childNode = childNodesObj[childNodeId];
|
||||
|
||||
// construct new edges from the cluster to others
|
||||
for (let j = 0; j < childNode.edges.length; j++) {
|
||||
edge = childNode.edges[j];
|
||||
childEdgesObj[edge.id] = edge;
|
||||
|
||||
// childNodeId position will be replaced by the cluster.
|
||||
if (edge.toId == childNodeId) { // this is a double equals because ints and strings can be interchanged here.
|
||||
toId = clusterNodeProperties.id;
|
||||
fromId = edge.fromId;
|
||||
otherNodeId = fromId;
|
||||
}
|
||||
else {
|
||||
toId = edge.toId;
|
||||
fromId = clusterNodeProperties.id;
|
||||
otherNodeId = toId;
|
||||
}
|
||||
|
||||
// if the node connected to the cluster is also in the cluster we do not need a new edge.
|
||||
if (childNodesObj[otherNodeId] === undefined) {
|
||||
let clonedOptions = this._cloneOptions(edge, 'edge');
|
||||
util.deepExtend(clonedOptions, clusterEdgeProperties);
|
||||
clonedOptions.from = fromId;
|
||||
clonedOptions.to = toId;
|
||||
clonedOptions.id = 'clusterEdge:' + util.randomUUID();
|
||||
newEdges.push(this.body.functions.createEdge(clonedOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function checks the options that can be supplied to the different cluster functions
|
||||
* for certain fields and inserts defaults if needed
|
||||
* @param options
|
||||
* @returns {*}
|
||||
* @private
|
||||
*/
|
||||
_checkOptions(options = {}) {
|
||||
if (options.clusterEdgeProperties === undefined) {options.clusterEdgeProperties = {};}
|
||||
if (options.clusterNodeProperties === undefined) {options.clusterNodeProperties = {};}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} childNodesObj | object with node objects, id as keys, same as childNodes except it also contains a source node
|
||||
* @param {Object} childEdgesObj | object with edge objects, id as keys
|
||||
* @param {Array} options | object with {clusterNodeProperties, clusterEdgeProperties, processProperties}
|
||||
* @param {Boolean} refreshData | when true, do not wrap up
|
||||
* @private
|
||||
*/
|
||||
_cluster(childNodesObj, childEdgesObj, options, refreshData = true) {
|
||||
// kill condition: no children so cant cluster
|
||||
if (Object.keys(childNodesObj).length === 0) {return;}
|
||||
|
||||
let clusterNodeProperties = util.deepExtend({},options.clusterNodeProperties);
|
||||
|
||||
// construct the clusterNodeProperties
|
||||
if (options.processProperties !== undefined) {
|
||||
// get the childNode options
|
||||
let childNodesOptions = [];
|
||||
for (let nodeId in childNodesObj) {
|
||||
let clonedOptions = this._cloneOptions(childNodesObj[nodeId]);
|
||||
childNodesOptions.push(clonedOptions);
|
||||
}
|
||||
|
||||
// get clusterproperties based on childNodes
|
||||
let childEdgesOptions = [];
|
||||
for (let edgeId in childEdgesObj) {
|
||||
// these cluster edges will be removed on creation of the cluster.
|
||||
if (edgeId.substr(0,12) !== "clusterEdge:") {
|
||||
let clonedOptions = this._cloneOptions(childEdgesObj[edgeId], 'edge');
|
||||
childEdgesOptions.push(clonedOptions);
|
||||
}
|
||||
}
|
||||
|
||||
clusterNodeProperties = options.processProperties(clusterNodeProperties, childNodesOptions, childEdgesOptions);
|
||||
if (!clusterNodeProperties) {
|
||||
throw new Error("The processProperties function does not return properties!");
|
||||
}
|
||||
}
|
||||
|
||||
// check if we have an unique id;
|
||||
if (clusterNodeProperties.id === undefined) {clusterNodeProperties.id = 'cluster:' + util.randomUUID();}
|
||||
let clusterId = clusterNodeProperties.id;
|
||||
|
||||
if (clusterNodeProperties.label === undefined) {
|
||||
clusterNodeProperties.label = 'cluster';
|
||||
}
|
||||
|
||||
|
||||
// give the clusterNode a postion if it does not have one.
|
||||
let pos = undefined;
|
||||
if (clusterNodeProperties.x === undefined) {
|
||||
pos = this._getClusterPosition(childNodesObj);
|
||||
clusterNodeProperties.x = pos.x;
|
||||
}
|
||||
if (clusterNodeProperties.y === undefined) {
|
||||
if (pos === undefined) {
|
||||
pos = this._getClusterPosition(childNodesObj);
|
||||
}
|
||||
clusterNodeProperties.y = pos.y;
|
||||
}
|
||||
|
||||
// force the ID to remain the same
|
||||
clusterNodeProperties.id = clusterId;
|
||||
|
||||
// create the clusterNode
|
||||
let clusterNode = this.body.functions.createNode(clusterNodeProperties, Cluster);
|
||||
clusterNode.isCluster = true;
|
||||
clusterNode.containedNodes = childNodesObj;
|
||||
clusterNode.containedEdges = childEdgesObj;
|
||||
// cache a copy from the cluster edge properties if we have to reconnect others later on
|
||||
clusterNode.clusterEdgeProperties = options.clusterEdgeProperties;
|
||||
|
||||
// finally put the cluster node into global
|
||||
this.body.nodes[clusterNodeProperties.id] = clusterNode;
|
||||
|
||||
// create the new edges that will connect to the cluster
|
||||
let newEdges = [];
|
||||
this._createClusterEdges(childNodesObj, childEdgesObj, newEdges, clusterNodeProperties, options.clusterEdgeProperties);
|
||||
|
||||
// disable the childEdges
|
||||
for (let edgeId in childEdgesObj) {
|
||||
if (childEdgesObj.hasOwnProperty(edgeId)) {
|
||||
if (this.body.edges[edgeId] !== undefined) {
|
||||
let edge = this.body.edges[edgeId];
|
||||
|
||||
// if the edge is a clusterEdge, we delete it. The opening of the clusters will restore these edges when required.
|
||||
if (edgeId.substr(0,12) === "clusterEdge:") {
|
||||
// we only delete the cluster edge if there is another edge to the node that is not a cluster.
|
||||
let target = edge.from.isCluster === true ? edge.toId : edge.fromId;
|
||||
let deleteEdge = false;
|
||||
|
||||
// search the contained edges for an edge that has a link to the targetNode
|
||||
for (let edgeId2 in childEdgesObj) {
|
||||
if (childEdgesObj.hasOwnProperty(edgeId2)) {
|
||||
if (this.body.edges[edgeId2] !== undefined && edgeId2 !== edgeId) {
|
||||
let edge2 = this.body.edges[edgeId2];
|
||||
if (edge2.fromId == target || edge2.toId == target) {
|
||||
deleteEdge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we found the edge that will trigger the recreation of a new cluster edge on opening, we can delete this edge.
|
||||
if (deleteEdge === true) {
|
||||
edge.edgeType.cleanup();
|
||||
// this removes the edge from node.edges, which is why edgeIds is formed
|
||||
edge.disconnect();
|
||||
delete childEdgesObj[edgeId];
|
||||
delete this.body.edges[edgeId];
|
||||
}
|
||||
}
|
||||
else {
|
||||
edge.togglePhysics(false);
|
||||
edge.options.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// disable the childNodes
|
||||
for (let nodeId in childNodesObj) {
|
||||
if (childNodesObj.hasOwnProperty(nodeId)) {
|
||||
this.clusteredNodes[nodeId] = {clusterId:clusterNodeProperties.id, node: this.body.nodes[nodeId]};
|
||||
this.body.nodes[nodeId].togglePhysics(false);
|
||||
this.body.nodes[nodeId].options.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// push new edges to global
|
||||
for (let i = 0; i < newEdges.length; i++) {
|
||||
this.body.edges[newEdges[i].id] = newEdges[i];
|
||||
this.body.edges[newEdges[i].id].connect();
|
||||
}
|
||||
|
||||
// set ID to undefined so no duplicates arise
|
||||
clusterNodeProperties.id = undefined;
|
||||
|
||||
// wrap up
|
||||
if (refreshData === true) {
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a node is a cluster.
|
||||
* @param nodeId
|
||||
* @returns {*}
|
||||
*/
|
||||
isCluster(nodeId) {
|
||||
if (this.body.nodes[nodeId] !== undefined) {
|
||||
return this.body.nodes[nodeId].isCluster === true;
|
||||
}
|
||||
else {
|
||||
console.log("Node does not exist.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get the position of the cluster node based on what's inside
|
||||
* @param {object} childNodesObj | object with node objects, id as keys
|
||||
* @returns {{x: number, y: number}}
|
||||
* @private
|
||||
*/
|
||||
_getClusterPosition(childNodesObj) {
|
||||
let childKeys = Object.keys(childNodesObj);
|
||||
let minX = childNodesObj[childKeys[0]].x;
|
||||
let maxX = childNodesObj[childKeys[0]].x;
|
||||
let minY = childNodesObj[childKeys[0]].y;
|
||||
let maxY = childNodesObj[childKeys[0]].y;
|
||||
let node;
|
||||
for (let i = 1; i < childKeys.length; i++) {
|
||||
node = childNodesObj[childKeys[i]];
|
||||
minX = node.x < minX ? node.x : minX;
|
||||
maxX = node.x > maxX ? node.x : maxX;
|
||||
minY = node.y < minY ? node.y : minY;
|
||||
maxY = node.y > maxY ? node.y : maxY;
|
||||
}
|
||||
|
||||
|
||||
return {x: 0.5*(minX + maxX), y: 0.5*(minY + maxY)};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Open a cluster by calling this function.
|
||||
* @param {String} clusterNodeId | the ID of the cluster node
|
||||
* @param {Boolean} refreshData | wrap up afterwards if not true
|
||||
*/
|
||||
openCluster(clusterNodeId, options, refreshData = true) {
|
||||
// kill conditions
|
||||
if (clusterNodeId === undefined) {throw new Error("No clusterNodeId supplied to openCluster.");}
|
||||
if (this.body.nodes[clusterNodeId] === undefined) {throw new Error("The clusterNodeId supplied to openCluster does not exist.");}
|
||||
if (this.body.nodes[clusterNodeId].containedNodes === undefined) {
|
||||
console.log("The node:" + clusterNodeId + " is not a cluster.");
|
||||
return
|
||||
}
|
||||
let clusterNode = this.body.nodes[clusterNodeId];
|
||||
let containedNodes = clusterNode.containedNodes;
|
||||
let containedEdges = clusterNode.containedEdges;
|
||||
|
||||
// allow the user to position the nodes after release.
|
||||
if (options !== undefined && options.releaseFunction !== undefined && typeof options.releaseFunction === 'function') {
|
||||
let positions = {};
|
||||
let clusterPosition = {x:clusterNode.x, y:clusterNode.y};
|
||||
for (let nodeId in containedNodes) {
|
||||
if (containedNodes.hasOwnProperty(nodeId)) {
|
||||
let containedNode = this.body.nodes[nodeId];
|
||||
positions[nodeId] = {x: containedNode.x, y: containedNode.y};
|
||||
}
|
||||
}
|
||||
let newPositions = options.releaseFunction(clusterPosition, positions);
|
||||
|
||||
for (let nodeId in containedNodes) {
|
||||
if (containedNodes.hasOwnProperty(nodeId)) {
|
||||
let containedNode = this.body.nodes[nodeId];
|
||||
if (newPositions[nodeId] !== undefined) {
|
||||
containedNode.x = newPositions[nodeId].x || clusterNode.x;
|
||||
containedNode.y = newPositions[nodeId].y || clusterNode.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// copy the position from the cluster
|
||||
for (let nodeId in containedNodes) {
|
||||
if (containedNodes.hasOwnProperty(nodeId)) {
|
||||
let containedNode = this.body.nodes[nodeId];
|
||||
containedNode = containedNodes[nodeId];
|
||||
// inherit position
|
||||
containedNode.x = clusterNode.x;
|
||||
containedNode.y = clusterNode.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// release nodes
|
||||
for (let nodeId in containedNodes) {
|
||||
if (containedNodes.hasOwnProperty(nodeId)) {
|
||||
let containedNode = this.body.nodes[nodeId];
|
||||
|
||||
// inherit speed
|
||||
containedNode.vx = clusterNode.vx;
|
||||
containedNode.vy = clusterNode.vy;
|
||||
|
||||
containedNode.options.hidden = false;
|
||||
containedNode.togglePhysics(true);
|
||||
|
||||
delete this.clusteredNodes[nodeId];
|
||||
}
|
||||
}
|
||||
|
||||
// release edges
|
||||
for (let edgeId in containedEdges) {
|
||||
if (containedEdges.hasOwnProperty(edgeId)) {
|
||||
let edge = containedEdges[edgeId];
|
||||
// if this edge was a temporary edge and it's connected nodes do not exist anymore, we remove it from the data
|
||||
if (this.body.nodes[edge.fromId] === undefined || this.body.nodes[edge.toId] === undefined || edge.toId == clusterNodeId || edge.fromId == clusterNodeId) {
|
||||
edge.edgeType.cleanup();
|
||||
// this removes the edge from node.edges, which is why edgeIds is formed
|
||||
edge.disconnect();
|
||||
delete this.body.edges[edgeId];
|
||||
}
|
||||
else {
|
||||
// one of the nodes connected to this edge is in a cluster. We give the edge to that cluster so it will be released when that cluster is opened.
|
||||
if (this.clusteredNodes[edge.fromId] !== undefined || this.clusteredNodes[edge.toId] !== undefined) {
|
||||
let fromId, toId;
|
||||
let clusteredNode = this.clusteredNodes[edge.fromId] || this.clusteredNodes[edge.toId];
|
||||
let clusterId = clusteredNode.clusterId;
|
||||
let clusterNode = this.body.nodes[clusterId];
|
||||
clusterNode.containedEdges[edgeId] = edge;
|
||||
|
||||
if (this.clusteredNodes[edge.fromId] !== undefined) {
|
||||
fromId = clusterId;
|
||||
toId = edge.toId;
|
||||
}
|
||||
else {
|
||||
fromId = edge.fromId;
|
||||
toId = clusterId;
|
||||
}
|
||||
|
||||
// if both from and to nodes are visible, we create a new temporary edge
|
||||
if (this.body.nodes[fromId].options.hidden !== true && this.body.nodes[toId].options.hidden !== true) {
|
||||
let clonedOptions = this._cloneOptions(edge, 'edge');
|
||||
let id = 'clusterEdge:' + util.randomUUID();
|
||||
util.deepExtend(clonedOptions, clusterNode.clusterEdgeProperties);
|
||||
util.deepExtend(clonedOptions, {from: fromId, to: toId, hidden: false, physics: true, id: id});
|
||||
let newEdge = this.body.functions.createEdge(clonedOptions);
|
||||
|
||||
this.body.edges[id] = newEdge;
|
||||
this.body.edges[id].connect();
|
||||
}
|
||||
}
|
||||
else {
|
||||
edge.options.hidden = false;
|
||||
edge.togglePhysics(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove all temporary edges, make an array of ids so we don't remove from the list we're iterating over.
|
||||
let removeIds = [];
|
||||
for (let i = 0; i < clusterNode.edges.length; i++) {
|
||||
let edgeId = clusterNode.edges[i].id;
|
||||
removeIds.push(edgeId);
|
||||
}
|
||||
|
||||
// actually removing the edges
|
||||
for (let i = 0; i < removeIds.length; i++) {
|
||||
let edgeId = removeIds[i];
|
||||
this.body.edges[edgeId].edgeType.cleanup();
|
||||
// this removes the edge from node.edges, which is why edgeIds is formed
|
||||
this.body.edges[edgeId].disconnect();
|
||||
delete this.body.edges[edgeId];
|
||||
}
|
||||
|
||||
// remove clusterNode
|
||||
delete this.body.nodes[clusterNodeId];
|
||||
|
||||
if (refreshData === true) {
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
}
|
||||
|
||||
getNodesInCluster(clusterId) {
|
||||
let nodesArray = []
|
||||
if (this.isCluster(clusterId) === true) {
|
||||
let containedNodes = this.body.nodes[clusterId].containedNodes;
|
||||
for (let nodeId in containedNodes) {
|
||||
if (containedNodes.hasOwnProperty(nodeId)) {
|
||||
nodesArray.push(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodesArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stack clusterId's that a certain node resides in. cluster A -> cluster B -> cluster C -> node
|
||||
* @param nodeId
|
||||
* @returns {Array}
|
||||
* @private
|
||||
*/
|
||||
findNode(nodeId) {
|
||||
let stack = [];
|
||||
let max = 100;
|
||||
let counter = 0;
|
||||
|
||||
while (this.clusteredNodes[nodeId] !== undefined && counter < max) {
|
||||
stack.push(this.clusteredNodes[nodeId].node);
|
||||
nodeId = this.clusteredNodes[nodeId].clusterId;
|
||||
counter++;
|
||||
}
|
||||
stack.push(this.body.nodes[nodeId]);
|
||||
return stack;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the Id the node is connected to
|
||||
* @param edge
|
||||
* @param nodeId
|
||||
* @returns {*}
|
||||
* @private
|
||||
*/
|
||||
_getConnectedId(edge, nodeId) {
|
||||
if (edge.toId != nodeId) {
|
||||
return edge.toId;
|
||||
}
|
||||
else if (edge.fromId != nodeId) {
|
||||
return edge.fromId;
|
||||
}
|
||||
else {
|
||||
return edge.fromId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We determine how many connections denote an important hub.
|
||||
* We take the mean + 2*std as the important hub size. (Assuming a normal distribution of data, ~2.2%)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getHubSize() {
|
||||
let average = 0;
|
||||
let averageSquared = 0;
|
||||
let hubCounter = 0;
|
||||
let largestHub = 0;
|
||||
|
||||
for (let i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
let node = this.body.nodes[this.body.nodeIndices[i]];
|
||||
if (node.edges.length > largestHub) {
|
||||
largestHub = node.edges.length;
|
||||
}
|
||||
average += node.edges.length;
|
||||
averageSquared += Math.pow(node.edges.length,2);
|
||||
hubCounter += 1;
|
||||
}
|
||||
average = average / hubCounter;
|
||||
averageSquared = averageSquared / hubCounter;
|
||||
|
||||
let letiance = averageSquared - Math.pow(average,2);
|
||||
let standardDeviation = Math.sqrt(letiance);
|
||||
|
||||
let hubThreshold = Math.floor(average + 2*standardDeviation);
|
||||
|
||||
// always have at least one to cluster
|
||||
if (hubThreshold > largestHub) {
|
||||
hubThreshold = largestHub;
|
||||
}
|
||||
|
||||
return hubThreshold;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default ClusterEngine;
|
||||
@@ -0,0 +1,384 @@
|
||||
var util = require("../../util");
|
||||
var DataSet = require('../../DataSet');
|
||||
var DataView = require('../../DataView');
|
||||
|
||||
import Edge from "./components/Edge"
|
||||
import Label from "./components/shared/Label"
|
||||
|
||||
class EdgesHandler {
|
||||
constructor(body, images, groups) {
|
||||
this.body = body;
|
||||
this.images = images;
|
||||
this.groups = groups;
|
||||
|
||||
// create the edge API in the body container
|
||||
this.body.functions.createEdge = this.create.bind(this);
|
||||
|
||||
this.edgesListeners = {
|
||||
add: (event, params) => {this.add(params.items);},
|
||||
update: (event, params) => {this.update(params.items);},
|
||||
remove: (event, params) => {this.remove(params.items);}
|
||||
};
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
arrows: {
|
||||
to: {enabled: false, scaleFactor:1}, // boolean / {arrowScaleFactor:1} / {enabled: false, arrowScaleFactor:1}
|
||||
middle: {enabled: false, scaleFactor:1},
|
||||
from: {enabled: false, scaleFactor:1}
|
||||
},
|
||||
color: {
|
||||
color:'#848484',
|
||||
highlight:'#848484',
|
||||
hover: '#848484',
|
||||
inherit: 'from',
|
||||
opacity:1.0
|
||||
},
|
||||
dashes: false,
|
||||
font: {
|
||||
color: '#343434',
|
||||
size: 14, // px
|
||||
face: 'arial',
|
||||
background: 'none',
|
||||
strokeWidth: 2, // px
|
||||
strokeColor: '#ffffff',
|
||||
align:'horizontal'
|
||||
},
|
||||
hidden: false,
|
||||
hoverWidth: 1.5,
|
||||
label: undefined,
|
||||
labelHighlightBold: true,
|
||||
length: undefined,
|
||||
physics: true,
|
||||
scaling:{
|
||||
min: 1,
|
||||
max: 15,
|
||||
label: {
|
||||
enabled: true,
|
||||
min: 14,
|
||||
max: 30,
|
||||
maxVisible: 30,
|
||||
drawThreshold: 5
|
||||
},
|
||||
customScalingFunction: function (min,max,total,value) {
|
||||
if (max === min) {
|
||||
return 0.5;
|
||||
}
|
||||
else {
|
||||
var scale = 1 / (max - min);
|
||||
return Math.max(0,(value - min)*scale);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectionWidth: 1.5,
|
||||
selfReferenceSize:20,
|
||||
shadow:{
|
||||
enabled: false,
|
||||
size:10,
|
||||
x:5,
|
||||
y:5
|
||||
},
|
||||
smooth: {
|
||||
enabled: true,
|
||||
type: "dynamic",
|
||||
roundness: 0.5
|
||||
},
|
||||
title:undefined,
|
||||
width: 1,
|
||||
value: undefined
|
||||
};
|
||||
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
// this allows external modules to force all dynamic curves to turn static.
|
||||
this.body.emitter.on("_forceDisableDynamicCurves", (type) => {
|
||||
if (type === 'dynamic') {
|
||||
type = 'continuous';
|
||||
}
|
||||
let emitChange = false;
|
||||
for (let edgeId in this.body.edges) {
|
||||
if (this.body.edges.hasOwnProperty(edgeId)) {
|
||||
let edge = this.body.edges[edgeId];
|
||||
let edgeData = this.body.data.edges._data[edgeId];
|
||||
|
||||
// only forcilby remove the smooth curve if the data has been set of the edge has the smooth curves defined.
|
||||
// this is because a change in the global would not affect these curves.
|
||||
if (edgeData !== undefined) {
|
||||
let edgeOptions = edgeData.smooth;
|
||||
if (edgeOptions !== undefined) {
|
||||
if (edgeOptions.enabled === true && edgeOptions.type === 'dynamic') {
|
||||
if (type === undefined) {
|
||||
edge.setOptions({smooth: false});
|
||||
}
|
||||
else {
|
||||
edge.setOptions({smooth: {type: type}});
|
||||
}
|
||||
emitChange = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (emitChange === true) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
});
|
||||
|
||||
// this is called when options of EXISTING nodes or edges have changed.
|
||||
this.body.emitter.on("_dataUpdated", () => {
|
||||
this.reconnectEdges();
|
||||
this.markAllEdgesAsDirty();
|
||||
});
|
||||
|
||||
// refresh the edges. Used when reverting from hierarchical layout
|
||||
this.body.emitter.on("refreshEdges", this.refresh.bind(this));
|
||||
this.body.emitter.on("refresh", this.refresh.bind(this));
|
||||
this.body.emitter.on("destroy", () => {
|
||||
delete this.body.functions.createEdge;
|
||||
delete this.edgesListeners.add;
|
||||
delete this.edgesListeners.update;
|
||||
delete this.edgesListeners.remove;
|
||||
delete this.edgesListeners;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
// use the parser from the Edge class to fill in all shorthand notations
|
||||
Edge.parseOptions(this.options, options);
|
||||
|
||||
// hanlde multiple input cases for color
|
||||
if (options.color !== undefined) {
|
||||
this.markAllEdgesAsDirty();
|
||||
}
|
||||
|
||||
// update smooth settings in all edges
|
||||
let dataChanged = false;
|
||||
if (options.smooth !== undefined) {
|
||||
for (let edgeId in this.body.edges) {
|
||||
if (this.body.edges.hasOwnProperty(edgeId)) {
|
||||
dataChanged = this.body.edges[edgeId].updateEdgeType() || dataChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update fonts in all edges
|
||||
if (options.font !== undefined) {
|
||||
// use the parser from the Label class to fill in all shorthand notations
|
||||
Label.parseOptions(this.options.font, options);
|
||||
for (let edgeId in this.body.edges) {
|
||||
if (this.body.edges.hasOwnProperty(edgeId)) {
|
||||
this.body.edges[edgeId].updateLabelModule();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the state of the variables if needed
|
||||
if (options.hidden !== undefined || options.physics !== undefined || dataChanged === true) {
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load edges by reading the data table
|
||||
* @param {Array | DataSet | DataView} edges The data containing the edges.
|
||||
* @private
|
||||
* @private
|
||||
*/
|
||||
setData(edges, doNotEmit = false) {
|
||||
var oldEdgesData = this.body.data.edges;
|
||||
|
||||
if (edges instanceof DataSet || edges instanceof DataView) {
|
||||
this.body.data.edges = edges;
|
||||
}
|
||||
else if (Array.isArray(edges)) {
|
||||
this.body.data.edges = new DataSet();
|
||||
this.body.data.edges.add(edges);
|
||||
}
|
||||
else if (!edges) {
|
||||
this.body.data.edges = new DataSet();
|
||||
}
|
||||
else {
|
||||
throw new TypeError('Array or DataSet expected');
|
||||
}
|
||||
|
||||
// TODO: is this null or undefined or false?
|
||||
if (oldEdgesData) {
|
||||
// unsubscribe from old dataset
|
||||
util.forEach(this.edgesListeners, (callback, event) => {oldEdgesData.off(event, callback);});
|
||||
}
|
||||
|
||||
// remove drawn edges
|
||||
this.body.edges = {};
|
||||
|
||||
// TODO: is this null or undefined or false?
|
||||
if (this.body.data.edges) {
|
||||
// subscribe to new dataset
|
||||
util.forEach(this.edgesListeners, (callback, event) => {this.body.data.edges.on(event, callback);});
|
||||
|
||||
// draw all new nodes
|
||||
var ids = this.body.data.edges.getIds();
|
||||
this.add(ids, true);
|
||||
}
|
||||
|
||||
if (doNotEmit === false) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add edges
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
add(ids, doNotEmit = false) {
|
||||
var edges = this.body.edges;
|
||||
var edgesData = this.body.data.edges;
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
var id = ids[i];
|
||||
|
||||
var oldEdge = edges[id];
|
||||
if (oldEdge) {
|
||||
oldEdge.disconnect();
|
||||
}
|
||||
|
||||
var data = edgesData.get(id, {"showInternalIds" : true});
|
||||
edges[id] = this.create(data);
|
||||
}
|
||||
|
||||
if (doNotEmit === false) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Update existing edges, or create them when not yet existing
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
update(ids) {
|
||||
var edges = this.body.edges;
|
||||
var edgesData = this.body.data.edges;
|
||||
var dataChanged = false;
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var id = ids[i];
|
||||
var data = edgesData.get(id);
|
||||
var edge = edges[id];
|
||||
if (edge === null) {
|
||||
// update edge
|
||||
edge.disconnect();
|
||||
dataChanged = edge.setOptions(data) || dataChanged; // if a support node is added, data can be changed.
|
||||
edge.connect();
|
||||
}
|
||||
else {
|
||||
// create edge
|
||||
this.body.edges[id] = this.create(data);
|
||||
dataChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataChanged === true) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit("_dataUpdated");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Remove existing edges. Non existing ids will be ignored
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
remove(ids) {
|
||||
var edges = this.body.edges;
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var id = ids[i];
|
||||
var edge = edges[id];
|
||||
if (edge !== undefined) {
|
||||
edge.edgeType.cleanup();
|
||||
edge.disconnect();
|
||||
delete edges[id];
|
||||
}
|
||||
}
|
||||
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
|
||||
refresh() {
|
||||
let edges = this.body.edges;
|
||||
for (let edgeId in edges) {
|
||||
let edge = undefined;
|
||||
if (edges.hasOwnProperty(edgeId)) {
|
||||
edge = edges[edgeId];
|
||||
}
|
||||
let data = this.body.data.edges._data[edgeId];
|
||||
if (edge !== undefined && data !== undefined) {
|
||||
edge.setOptions(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create(properties) {
|
||||
return new Edge(properties, this.body, this.options)
|
||||
}
|
||||
|
||||
|
||||
markAllEdgesAsDirty() {
|
||||
for (var edgeId in this.body.edges) {
|
||||
this.body.edges[edgeId].edgeType.colorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect all edges
|
||||
* @private
|
||||
*/
|
||||
reconnectEdges() {
|
||||
var id;
|
||||
var nodes = this.body.nodes;
|
||||
var edges = this.body.edges;
|
||||
|
||||
for (id in nodes) {
|
||||
if (nodes.hasOwnProperty(id)) {
|
||||
nodes[id].edges = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (id in edges) {
|
||||
if (edges.hasOwnProperty(id)) {
|
||||
var edge = edges[id];
|
||||
edge.from = null;
|
||||
edge.to = null;
|
||||
edge.connect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getConnectedNodes(edgeId) {
|
||||
let nodeList = [];
|
||||
if (this.body.edges[edgeId] !== undefined) {
|
||||
let edge = this.body.edges[edgeId];
|
||||
if (edge.fromId) {nodeList.push(edge.fromId);}
|
||||
if (edge.toId) {nodeList.push(edge.toId);}
|
||||
}
|
||||
return nodeList;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EdgesHandler;
|
||||
@@ -0,0 +1,116 @@
|
||||
let util = require('../../util');
|
||||
|
||||
/**
|
||||
* @class Groups
|
||||
* This class can store groups and options specific for groups.
|
||||
*/
|
||||
class Groups {
|
||||
constructor() {
|
||||
this.clear();
|
||||
this.defaultIndex = 0;
|
||||
this.groupsArray = [];
|
||||
this.groupIndex = 0;
|
||||
|
||||
this.defaultGroups = [
|
||||
{border: "#2B7CE9", background: "#97C2FC", highlight: {border: "#2B7CE9", background: "#D2E5FF"}, hover: {border: "#2B7CE9", background: "#D2E5FF"}}, // 0: blue
|
||||
{border: "#FFA500", background: "#FFFF00", highlight: {border: "#FFA500", background: "#FFFFA3"}, hover: {border: "#FFA500", background: "#FFFFA3"}}, // 1: yellow
|
||||
{border: "#FA0A10", background: "#FB7E81", highlight: {border: "#FA0A10", background: "#FFAFB1"}, hover: {border: "#FA0A10", background: "#FFAFB1"}}, // 2: red
|
||||
{border: "#41A906", background: "#7BE141", highlight: {border: "#41A906", background: "#A1EC76"}, hover: {border: "#41A906", background: "#A1EC76"}}, // 3: green
|
||||
{border: "#E129F0", background: "#EB7DF4", highlight: {border: "#E129F0", background: "#F0B3F5"}, hover: {border: "#E129F0", background: "#F0B3F5"}}, // 4: magenta
|
||||
{border: "#7C29F0", background: "#AD85E4", highlight: {border: "#7C29F0", background: "#D3BDF0"}, hover: {border: "#7C29F0", background: "#D3BDF0"}}, // 5: purple
|
||||
{border: "#C37F00", background: "#FFA807", highlight: {border: "#C37F00", background: "#FFCA66"}, hover: {border: "#C37F00", background: "#FFCA66"}}, // 6: orange
|
||||
{border: "#4220FB", background: "#6E6EFD", highlight: {border: "#4220FB", background: "#9B9BFD"}, hover: {border: "#4220FB", background: "#9B9BFD"}}, // 7: darkblue
|
||||
{border: "#FD5A77", background: "#FFC0CB", highlight: {border: "#FD5A77", background: "#FFD1D9"}, hover: {border: "#FD5A77", background: "#FFD1D9"}}, // 8: pink
|
||||
{border: "#4AD63A", background: "#C2FABC", highlight: {border: "#4AD63A", background: "#E6FFE3"}, hover: {border: "#4AD63A", background: "#E6FFE3"}}, // 9: mint
|
||||
|
||||
{border: "#990000", background: "#EE0000", highlight: {border: "#BB0000", background: "#FF3333"}, hover: {border: "#BB0000", background: "#FF3333"}}, // 10:bright red
|
||||
|
||||
{border: "#FF6000", background: "#FF6000", highlight: {border: "#FF6000", background: "#FF6000"}, hover: {border: "#FF6000", background: "#FF6000"}}, // 12: real orange
|
||||
{border: "#97C2FC", background: "#2B7CE9", highlight: {border: "#D2E5FF", background: "#2B7CE9"}, hover: {border: "#D2E5FF", background: "#2B7CE9"}}, // 13: blue
|
||||
{border: "#399605", background: "#255C03", highlight: {border: "#399605", background: "#255C03"}, hover: {border: "#399605", background: "#255C03"}}, // 14: green
|
||||
{border: "#B70054", background: "#FF007E", highlight: {border: "#B70054", background: "#FF007E"}, hover: {border: "#B70054", background: "#FF007E"}}, // 15: magenta
|
||||
{border: "#AD85E4", background: "#7C29F0", highlight: {border: "#D3BDF0", background: "#7C29F0"}, hover: {border: "#D3BDF0", background: "#7C29F0"}}, // 16: purple
|
||||
{border: "#4557FA", background: "#000EA1", highlight: {border: "#6E6EFD", background: "#000EA1"}, hover: {border: "#6E6EFD", background: "#000EA1"}}, // 17: darkblue
|
||||
{border: "#FFC0CB", background: "#FD5A77", highlight: {border: "#FFD1D9", background: "#FD5A77"}, hover: {border: "#FFD1D9", background: "#FD5A77"}}, // 18: pink
|
||||
{border: "#C2FABC", background: "#74D66A", highlight: {border: "#E6FFE3", background: "#74D66A"}, hover: {border: "#E6FFE3", background: "#74D66A"}}, // 19: mint
|
||||
|
||||
{border: "#EE0000", background: "#990000", highlight: {border: "#FF3333", background: "#BB0000"}, hover: {border: "#FF3333", background: "#BB0000"}} // 20:bright red
|
||||
];
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
useDefaultGroups: true
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
}
|
||||
|
||||
|
||||
setOptions(options) {
|
||||
let optionFields = ['useDefaultGroups'];
|
||||
|
||||
if (options !== undefined) {
|
||||
for (let groupName in options) {
|
||||
if (options.hasOwnProperty(groupName)) {
|
||||
if (optionFields.indexOf(groupName) === -1) {
|
||||
let group = options[groupName];
|
||||
this.add(groupName, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear all groups
|
||||
*/
|
||||
clear() {
|
||||
this.groups = {};
|
||||
this.groupsArray = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* get group options of a groupname. If groupname is not found, a new group
|
||||
* is added.
|
||||
* @param {*} groupname Can be a number, string, Date, etc.
|
||||
* @return {Object} group The created group, containing all group options
|
||||
*/
|
||||
get(groupname) {
|
||||
let group = this.groups[groupname];
|
||||
if (group === undefined) {
|
||||
if (this.options.useDefaultGroups === false && this.groupsArray.length > 0) {
|
||||
// create new group
|
||||
let index = this.groupIndex % this.groupsArray.length;
|
||||
this.groupIndex++;
|
||||
group = {};
|
||||
group.color = this.groups[this.groupsArray[index]];
|
||||
this.groups[groupname] = group;
|
||||
}
|
||||
else {
|
||||
// create new group
|
||||
let index = this.defaultIndex % this.defaultGroups.length;
|
||||
this.defaultIndex++;
|
||||
group = {};
|
||||
group.color = this.defaultGroups[index];
|
||||
this.groups[groupname] = group;
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom group style
|
||||
* @param {String} groupName
|
||||
* @param {Object} style An object containing borderColor,
|
||||
* backgroundColor, etc.
|
||||
* @return {Object} group The created group object
|
||||
*/
|
||||
add(groupName, style) {
|
||||
this.groups[groupName] = style;
|
||||
this.groupsArray.push(groupName);
|
||||
return style;
|
||||
}
|
||||
}
|
||||
|
||||
export default Groups;
|
||||
@@ -0,0 +1,710 @@
|
||||
let util = require('../../util');
|
||||
|
||||
import NavigationHandler from './components/NavigationHandler'
|
||||
import Popup from './components/Popup'
|
||||
|
||||
class InteractionHandler {
|
||||
constructor(body, canvas, selectionHandler) {
|
||||
this.body = body;
|
||||
this.canvas = canvas;
|
||||
this.selectionHandler = selectionHandler;
|
||||
this.navigationHandler = new NavigationHandler(body,canvas);
|
||||
|
||||
// bind the events from hammer to functions in this object
|
||||
this.body.eventListeners.onTap = this.onTap.bind(this);
|
||||
this.body.eventListeners.onTouch = this.onTouch.bind(this);
|
||||
this.body.eventListeners.onDoubleTap = this.onDoubleTap.bind(this);
|
||||
this.body.eventListeners.onHold = this.onHold.bind(this);
|
||||
this.body.eventListeners.onDragStart = this.onDragStart.bind(this);
|
||||
this.body.eventListeners.onDrag = this.onDrag.bind(this);
|
||||
this.body.eventListeners.onDragEnd = this.onDragEnd.bind(this);
|
||||
this.body.eventListeners.onMouseWheel = this.onMouseWheel.bind(this);
|
||||
this.body.eventListeners.onPinch = this.onPinch.bind(this);
|
||||
this.body.eventListeners.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.body.eventListeners.onRelease = this.onRelease.bind(this);
|
||||
this.body.eventListeners.onContext = this.onContext.bind(this);
|
||||
|
||||
this.touchTime = 0;
|
||||
this.drag = {};
|
||||
this.pinch = {};
|
||||
this.popup = undefined;
|
||||
this.popupObj = undefined;
|
||||
this.popupTimer = undefined;
|
||||
|
||||
this.body.functions.getPointer = this.getPointer.bind(this);
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
dragNodes:true,
|
||||
dragView: true,
|
||||
hover: false,
|
||||
keyboard: {
|
||||
enabled: false,
|
||||
speed: {x: 10, y: 10, zoom: 0.02},
|
||||
bindToWindow: true
|
||||
},
|
||||
navigationButtons: false,
|
||||
tooltipDelay: 300,
|
||||
zoomView: true
|
||||
};
|
||||
util.extend(this.options,this.defaultOptions);
|
||||
|
||||
this.bindEventListeners()
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
this.body.emitter.on('destroy', () => {
|
||||
clearTimeout(this.popupTimer);
|
||||
delete this.body.functions.getPointer;
|
||||
})
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
// extend all but the values in fields
|
||||
let fields = ['hideEdgesOnDrag','hideNodesOnDrag','keyboard','multiselect','selectable','selectConnectedEdges'];
|
||||
util.selectiveNotDeepExtend(fields, this.options, options);
|
||||
|
||||
// merge the keyboard options in.
|
||||
util.mergeOptions(this.options, options, 'keyboard');
|
||||
|
||||
if (options.tooltip) {
|
||||
util.extend(this.options.tooltip, options.tooltip);
|
||||
if (options.tooltip.color) {
|
||||
this.options.tooltip.color = util.parseColor(options.tooltip.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.navigationHandler.setOptions(this.options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the pointer location from a touch location
|
||||
* @param {{x: Number, y: Number}} touch
|
||||
* @return {{x: Number, y: Number}} pointer
|
||||
* @private
|
||||
*/
|
||||
getPointer(touch) {
|
||||
return {
|
||||
x: touch.x - util.getAbsoluteLeft(this.canvas.frame.canvas),
|
||||
y: touch.y - util.getAbsoluteTop(this.canvas.frame.canvas)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* On start of a touch gesture, store the pointer
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
onTouch(event) {
|
||||
if (new Date().valueOf() - this.touchTime > 50) {
|
||||
this.drag.pointer = this.getPointer(event.center);
|
||||
this.drag.pinched = false;
|
||||
this.pinch.scale = this.body.view.scale;
|
||||
// to avoid double fireing of this event because we have two hammer instances. (on canvas and on frame)
|
||||
this.touchTime = new Date().valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* handle tap/click event: select/unselect a node
|
||||
* @private
|
||||
*/
|
||||
onTap(event) {
|
||||
let pointer = this.getPointer(event.center);
|
||||
let multiselect = this.selectionHandler.options.multiselect &&
|
||||
(event.changedPointers[0].ctrlKey || event.changedPointers[0].metaKey);
|
||||
|
||||
this.checkSelectionChanges(pointer, event, multiselect);
|
||||
this.selectionHandler._generateClickEvent('click', event, pointer);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handle doubletap event
|
||||
* @private
|
||||
*/
|
||||
onDoubleTap(event) {
|
||||
let pointer = this.getPointer(event.center);
|
||||
this.selectionHandler._generateClickEvent('doubleClick', event, pointer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* handle long tap event: multi select nodes
|
||||
* @private
|
||||
*/
|
||||
onHold(event) {
|
||||
let pointer = this.getPointer(event.center);
|
||||
let multiselect = this.selectionHandler.options.multiselect;
|
||||
|
||||
this.checkSelectionChanges(pointer, event, multiselect);
|
||||
|
||||
this.selectionHandler._generateClickEvent('click', event, pointer);
|
||||
this.selectionHandler._generateClickEvent('hold', event, pointer);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handle the release of the screen
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
onRelease(event) {
|
||||
if (new Date().valueOf() - this.touchTime > 10) {
|
||||
let pointer = this.getPointer(event.center);
|
||||
this.selectionHandler._generateClickEvent('release', event, pointer);
|
||||
// to avoid double fireing of this event because we have two hammer instances. (on canvas and on frame)
|
||||
this.touchTime = new Date().valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
onContext(event) {
|
||||
let pointer = this.getPointer({x:event.clientX, y:event.clientY});
|
||||
this.selectionHandler._generateClickEvent('oncontext', event, pointer);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param pointer
|
||||
* @param add
|
||||
*/
|
||||
checkSelectionChanges(pointer, event, add = false) {
|
||||
let previouslySelectedEdgeCount = this.selectionHandler._getSelectedEdgeCount();
|
||||
let previouslySelectedNodeCount = this.selectionHandler._getSelectedNodeCount();
|
||||
let previousSelection = this.selectionHandler.getSelection();
|
||||
let selected;
|
||||
if (add === true) {
|
||||
selected = this.selectionHandler.selectAdditionalOnPoint(pointer);
|
||||
}
|
||||
else {
|
||||
selected = this.selectionHandler.selectOnPoint(pointer);
|
||||
}
|
||||
let selectedEdgesCount = this.selectionHandler._getSelectedEdgeCount();
|
||||
let selectedNodesCount = this.selectionHandler._getSelectedNodeCount();
|
||||
let currentSelection = this.selectionHandler.getSelection();
|
||||
|
||||
let {nodesChanges, edgesChanges} = this._determineIfDifferent(previousSelection, currentSelection);
|
||||
|
||||
if (selectedNodesCount - previouslySelectedNodeCount > 0) { // node was selected
|
||||
this.selectionHandler._generateClickEvent('selectNode', event, pointer);
|
||||
selected = true;
|
||||
}
|
||||
else if (selectedNodesCount - previouslySelectedNodeCount < 0) { // node was deselected
|
||||
this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection);
|
||||
selected = true;
|
||||
}
|
||||
else if (selectedNodesCount === previouslySelectedNodeCount && nodesChanges === true) {
|
||||
this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection);
|
||||
this.selectionHandler._generateClickEvent('selectNode', event, pointer);
|
||||
selected = true;
|
||||
}
|
||||
|
||||
if (selectedEdgesCount - previouslySelectedEdgeCount > 0) { // edge was selected
|
||||
this.selectionHandler._generateClickEvent('selectEdge', event, pointer);
|
||||
selected = true;
|
||||
}
|
||||
else if (selectedEdgesCount - previouslySelectedEdgeCount < 0) { // edge was deselected
|
||||
this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection);
|
||||
selected = true;
|
||||
}
|
||||
else if (selectedEdgesCount === previouslySelectedEdgeCount && edgesChanges === true) {
|
||||
this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection);
|
||||
this.selectionHandler._generateClickEvent('selectEdge', event, pointer);
|
||||
selected = true;
|
||||
}
|
||||
|
||||
|
||||
if (selected === true) { // select or unselect
|
||||
this.selectionHandler._generateClickEvent('select', event, pointer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function checks if the nodes and edges previously selected have changed.
|
||||
* @param previousSelection
|
||||
* @param currentSelection
|
||||
* @returns {{nodesChanges: boolean, edgesChanges: boolean}}
|
||||
* @private
|
||||
*/
|
||||
_determineIfDifferent(previousSelection,currentSelection) {
|
||||
let nodesChanges = false;
|
||||
let edgesChanges = false;
|
||||
|
||||
for (let i = 0; i < previousSelection.nodes.length; i++) {
|
||||
if (currentSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) {
|
||||
nodesChanges = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < currentSelection.nodes.length; i++) {
|
||||
if (previousSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) {
|
||||
nodesChanges = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < previousSelection.edges.length; i++) {
|
||||
if (currentSelection.edges.indexOf(previousSelection.edges[i]) === -1) {
|
||||
edgesChanges = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < currentSelection.edges.length; i++) {
|
||||
if (previousSelection.edges.indexOf(previousSelection.edges[i]) === -1) {
|
||||
edgesChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {nodesChanges, edgesChanges};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function is called by onDragStart.
|
||||
* It is separated out because we can then overload it for the datamanipulation system.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
onDragStart(event) {
|
||||
//in case the touch event was triggered on an external div, do the initial touch now.
|
||||
if (this.drag.pointer === undefined) {
|
||||
this.onTouch(event);
|
||||
}
|
||||
|
||||
// note: drag.pointer is set in onTouch to get the initial touch location
|
||||
let node = this.selectionHandler.getNodeAt(this.drag.pointer);
|
||||
|
||||
this.drag.dragging = true;
|
||||
this.drag.selection = [];
|
||||
this.drag.translation = util.extend({},this.body.view.translation); // copy the object
|
||||
this.drag.nodeId = undefined;
|
||||
|
||||
if (node !== undefined && this.options.dragNodes === true) {
|
||||
this.drag.nodeId = node.id;
|
||||
// select the clicked node if not yet selected
|
||||
if (node.isSelected() === false) {
|
||||
this.selectionHandler.unselectAll();
|
||||
this.selectionHandler.selectObject(node);
|
||||
}
|
||||
|
||||
// after select to contain the node
|
||||
this.selectionHandler._generateClickEvent('dragStart', event, this.drag.pointer);
|
||||
|
||||
let selection = this.selectionHandler.selectionObj.nodes;
|
||||
// create an array with the selected nodes and their original location and status
|
||||
for (let nodeId in selection) {
|
||||
if (selection.hasOwnProperty(nodeId)) {
|
||||
let object = selection[nodeId];
|
||||
let s = {
|
||||
id: object.id,
|
||||
node: object,
|
||||
|
||||
// store original x, y, xFixed and yFixed, make the node temporarily Fixed
|
||||
x: object.x,
|
||||
y: object.y,
|
||||
xFixed: object.options.fixed.x,
|
||||
yFixed: object.options.fixed.y
|
||||
};
|
||||
|
||||
object.options.fixed.x = true;
|
||||
object.options.fixed.y = true;
|
||||
|
||||
this.drag.selection.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// fallback if no node is selected and thus the view is dragged.
|
||||
this.selectionHandler._generateClickEvent('dragStart', event, this.drag.pointer, undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handle drag event
|
||||
* @private
|
||||
*/
|
||||
onDrag(event) {
|
||||
if (this.drag.pinched === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the focus on node if it is focussed on by the focusOnNode
|
||||
this.body.emitter.emit('unlockNode');
|
||||
|
||||
let pointer = this.getPointer(event.center);
|
||||
|
||||
let selection = this.drag.selection;
|
||||
if (selection && selection.length && this.options.dragNodes === true) {
|
||||
this.selectionHandler._generateClickEvent('dragging', event, pointer);
|
||||
|
||||
// calculate delta's and new location
|
||||
let deltaX = pointer.x - this.drag.pointer.x;
|
||||
let deltaY = pointer.y - this.drag.pointer.y;
|
||||
|
||||
// update position of all selected nodes
|
||||
selection.forEach((selection) => {
|
||||
let node = selection.node;
|
||||
// only move the node if it was not fixed initially
|
||||
if (selection.xFixed === false) {
|
||||
node.x = this.canvas._XconvertDOMtoCanvas(this.canvas._XconvertCanvasToDOM(selection.x) + deltaX);
|
||||
}
|
||||
// only move the node if it was not fixed initially
|
||||
if (selection.yFixed === false) {
|
||||
node.y = this.canvas._YconvertDOMtoCanvas(this.canvas._YconvertCanvasToDOM(selection.y) + deltaY);
|
||||
}
|
||||
});
|
||||
|
||||
// start the simulation of the physics
|
||||
this.body.emitter.emit('startSimulation');
|
||||
}
|
||||
else {
|
||||
// move the network
|
||||
if (this.options.dragView === true) {
|
||||
this.selectionHandler._generateClickEvent('dragging', event, pointer, undefined, true);
|
||||
|
||||
// if the drag was not started properly because the click started outside the network div, start it now.
|
||||
if (this.drag.pointer === undefined) {
|
||||
this.onDragStart(event);
|
||||
return;
|
||||
}
|
||||
let diffX = pointer.x - this.drag.pointer.x;
|
||||
let diffY = pointer.y - this.drag.pointer.y;
|
||||
|
||||
this.body.view.translation = {x:this.drag.translation.x + diffX, y:this.drag.translation.y + diffY};
|
||||
this.body.emitter.emit('_redraw');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handle drag start event
|
||||
* @private
|
||||
*/
|
||||
onDragEnd(event) {
|
||||
this.drag.dragging = false;
|
||||
let selection = this.drag.selection;
|
||||
if (selection && selection.length) {
|
||||
selection.forEach(function (s) {
|
||||
// restore original xFixed and yFixed
|
||||
s.node.options.fixed.x = s.xFixed;
|
||||
s.node.options.fixed.y = s.yFixed;
|
||||
});
|
||||
this.selectionHandler._generateClickEvent('dragEnd', event, this.getPointer(event.center));
|
||||
this.body.emitter.emit('startSimulation');
|
||||
}
|
||||
else {
|
||||
this.selectionHandler._generateClickEvent('dragEnd', event, this.getPointer(event.center), undefined, true);
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle pinch event
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
onPinch(event) {
|
||||
let pointer = this.getPointer(event.center);
|
||||
|
||||
this.drag.pinched = true;
|
||||
if (this.pinch['scale'] === undefined) {
|
||||
this.pinch.scale = 1;
|
||||
}
|
||||
|
||||
// TODO: enabled moving while pinching?
|
||||
let scale = this.pinch.scale * event.scale;
|
||||
this.zoom(scale, pointer)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Zoom the network in or out
|
||||
* @param {Number} scale a number around 1, and between 0.01 and 10
|
||||
* @param {{x: Number, y: Number}} pointer Position on screen
|
||||
* @return {Number} appliedScale scale is limited within the boundaries
|
||||
* @private
|
||||
*/
|
||||
zoom(scale, pointer) {
|
||||
if (this.options.zoomView === true) {
|
||||
let scaleOld = this.body.view.scale;
|
||||
if (scale < 0.00001) {
|
||||
scale = 0.00001;
|
||||
}
|
||||
if (scale > 10) {
|
||||
scale = 10;
|
||||
}
|
||||
|
||||
let preScaleDragPointer = undefined;
|
||||
if (this.drag !== undefined) {
|
||||
if (this.drag.dragging === true) {
|
||||
preScaleDragPointer = this.canvas.DOMtoCanvas(this.drag.pointer);
|
||||
}
|
||||
}
|
||||
// + this.canvas.frame.canvas.clientHeight / 2
|
||||
let translation = this.body.view.translation;
|
||||
|
||||
let scaleFrac = scale / scaleOld;
|
||||
let tx = (1 - scaleFrac) * pointer.x + translation.x * scaleFrac;
|
||||
let ty = (1 - scaleFrac) * pointer.y + translation.y * scaleFrac;
|
||||
|
||||
this.body.view.scale = scale;
|
||||
this.body.view.translation = {x:tx, y:ty};
|
||||
|
||||
if (preScaleDragPointer != undefined) {
|
||||
let postScaleDragPointer = this.canvas.canvasToDOM(preScaleDragPointer);
|
||||
this.drag.pointer.x = postScaleDragPointer.x;
|
||||
this.drag.pointer.y = postScaleDragPointer.y;
|
||||
}
|
||||
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
|
||||
if (scaleOld < scale) {
|
||||
this.body.emitter.emit('zoom', {direction: '+', scale: this.body.view.scale});
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit('zoom', {direction: '-', scale: this.body.view.scale});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Event handler for mouse wheel event, used to zoom the timeline
|
||||
* See http://adomas.org/javascript-mouse-wheel/
|
||||
* https://github.com/EightMedia/hammer.js/issues/256
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
onMouseWheel(event) {
|
||||
// retrieve delta
|
||||
let delta = 0;
|
||||
if (event.wheelDelta) { /* IE/Opera. */
|
||||
delta = event.wheelDelta / 120;
|
||||
} else if (event.detail) { /* Mozilla case. */
|
||||
// In Mozilla, sign of delta is different than in IE.
|
||||
// Also, delta is multiple of 3.
|
||||
delta = -event.detail / 3;
|
||||
}
|
||||
|
||||
// If delta is nonzero, handle it.
|
||||
// Basically, delta is now positive if wheel was scrolled up,
|
||||
// and negative, if wheel was scrolled down.
|
||||
if (delta !== 0) {
|
||||
|
||||
// calculate the new scale
|
||||
let scale = this.body.view.scale;
|
||||
let zoom = delta / 10;
|
||||
if (delta < 0) {
|
||||
zoom = zoom / (1 - zoom);
|
||||
}
|
||||
scale *= (1 + zoom);
|
||||
|
||||
// calculate the pointer location
|
||||
let pointer = this.getPointer({x:event.clientX, y:event.clientY});
|
||||
|
||||
// apply the new scale
|
||||
this.zoom(scale, pointer);
|
||||
}
|
||||
|
||||
// Prevent default actions caused by mouse wheel.
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mouse move handler for checking whether the title moves over a node with a title.
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
onMouseMove(event) {
|
||||
let pointer = this.getPointer({x:event.clientX, y:event.clientY});
|
||||
let popupVisible = false;
|
||||
|
||||
// check if the previously selected node is still selected
|
||||
if (this.popup !== undefined) {
|
||||
if (this.popup.hidden === false) {
|
||||
this._checkHidePopup(pointer);
|
||||
}
|
||||
|
||||
// if the popup was not hidden above
|
||||
if (this.popup.hidden === false) {
|
||||
popupVisible = true;
|
||||
this.popup.setPosition(pointer.x + 3, pointer.y - 5);
|
||||
this.popup.show();
|
||||
}
|
||||
}
|
||||
|
||||
// if we bind the keyboard to the div, we have to highlight it to use it. This highlights it on mouse over.
|
||||
if (this.options.keyboard.bindToWindow === false && this.options.keyboard.enabled === true) {
|
||||
this.canvas.frame.focus();
|
||||
}
|
||||
|
||||
// start a timeout that will check if the mouse is positioned above an element
|
||||
if (popupVisible === false) {
|
||||
if (this.popupTimer !== undefined) {
|
||||
clearInterval(this.popupTimer); // stop any running calculationTimer
|
||||
this.popupTimer = undefined;
|
||||
}
|
||||
if (!this.drag.dragging) {
|
||||
this.popupTimer = setTimeout(() => this._checkShowPopup(pointer), this.options.tooltipDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding hover highlights
|
||||
*/
|
||||
if (this.options.hover === true) {
|
||||
// adding hover highlights
|
||||
let obj = this.selectionHandler.getNodeAt(pointer);
|
||||
if (obj === undefined) {
|
||||
obj = this.selectionHandler.getEdgeAt(pointer);
|
||||
}
|
||||
this.selectionHandler.hoverObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if there is an element on the given position in the network
|
||||
* (a node or edge). If so, and if this element has a title,
|
||||
* show a popup window with its title.
|
||||
*
|
||||
* @param {{x:Number, y:Number}} pointer
|
||||
* @private
|
||||
*/
|
||||
_checkShowPopup(pointer) {
|
||||
let x = this.canvas._XconvertDOMtoCanvas(pointer.x);
|
||||
let y = this.canvas._YconvertDOMtoCanvas(pointer.y);
|
||||
let pointerObj = {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x,
|
||||
bottom: y
|
||||
};
|
||||
|
||||
let previousPopupObjId = this.popupObj === undefined ? undefined : this.popupObj.id;
|
||||
let nodeUnderCursor = false;
|
||||
let popupType = 'node';
|
||||
|
||||
// check if a node is under the cursor.
|
||||
if (this.popupObj === undefined) {
|
||||
// search the nodes for overlap, select the top one in case of multiple nodes
|
||||
let nodeIndices = this.body.nodeIndices;
|
||||
let nodes = this.body.nodes;
|
||||
let node;
|
||||
let overlappingNodes = [];
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
node = nodes[nodeIndices[i]];
|
||||
if (node.isOverlappingWith(pointerObj) === true) {
|
||||
if (node.getTitle() !== undefined) {
|
||||
overlappingNodes.push(nodeIndices[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overlappingNodes.length > 0) {
|
||||
// if there are overlapping nodes, select the last one, this is the one which is drawn on top of the others
|
||||
this.popupObj = nodes[overlappingNodes[overlappingNodes.length - 1]];
|
||||
// if you hover over a node, the title of the edge is not supposed to be shown.
|
||||
nodeUnderCursor = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.popupObj === undefined && nodeUnderCursor === false) {
|
||||
// search the edges for overlap
|
||||
let edgeIndices = this.body.edgeIndices;
|
||||
let edges = this.body.edges;
|
||||
let edge;
|
||||
let overlappingEdges = [];
|
||||
for (let i = 0; i < edgeIndices.length; i++) {
|
||||
edge = edges[edgeIndices[i]];
|
||||
if (edge.isOverlappingWith(pointerObj) === true) {
|
||||
if (edge.connected === true && edge.getTitle() !== undefined) {
|
||||
overlappingEdges.push(edgeIndices[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overlappingEdges.length > 0) {
|
||||
this.popupObj = edges[overlappingEdges[overlappingEdges.length - 1]];
|
||||
popupType = 'edge';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.popupObj !== undefined) {
|
||||
// show popup message window
|
||||
if (this.popupObj.id !== previousPopupObjId) {
|
||||
if (this.popup === undefined) {
|
||||
this.popup = new Popup(this.canvas.frame);
|
||||
}
|
||||
|
||||
this.popup.popupTargetType = popupType;
|
||||
this.popup.popupTargetId = this.popupObj.id;
|
||||
|
||||
// adjust a small offset such that the mouse cursor is located in the
|
||||
// bottom left location of the popup, and you can easily move over the
|
||||
// popup area
|
||||
this.popup.setPosition(pointer.x + 3, pointer.y - 5);
|
||||
this.popup.setText(this.popupObj.getTitle());
|
||||
this.popup.show();
|
||||
this.body.emitter.emit('showPopup',this.popupObj.id);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.popup !== undefined) {
|
||||
this.popup.hide();
|
||||
this.body.emitter.emit('hidePopup');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the popup must be hidden, which is the case when the mouse is no
|
||||
* longer hovering on the object
|
||||
* @param {{x:Number, y:Number}} pointer
|
||||
* @private
|
||||
*/
|
||||
_checkHidePopup(pointer) {
|
||||
let pointerObj = this.selectionHandler._pointerToPositionObject(pointer);
|
||||
|
||||
let stillOnObj = false;
|
||||
if (this.popup.popupTargetType === 'node') {
|
||||
if (this.body.nodes[this.popup.popupTargetId] !== undefined) {
|
||||
stillOnObj = this.body.nodes[this.popup.popupTargetId].isOverlappingWith(pointerObj);
|
||||
|
||||
// if the mouse is still one the node, we have to check if it is not also on one that is drawn on top of it.
|
||||
// we initially only check stillOnObj because this is much faster.
|
||||
if (stillOnObj === true) {
|
||||
let overNode = this.selectionHandler.getNodeAt(pointer);
|
||||
stillOnObj = overNode.id === this.popup.popupTargetId;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.selectionHandler.getNodeAt(pointer) === undefined) {
|
||||
if (this.body.edges[this.popup.popupTargetId] !== undefined) {
|
||||
stillOnObj = this.body.edges[this.popup.popupTargetId].isOverlappingWith(pointerObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (stillOnObj === false) {
|
||||
this.popupObj = undefined;
|
||||
this.popup.hide();
|
||||
this.body.emitter.emit('hidePopup');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default InteractionHandler;
|
||||
@@ -0,0 +1,486 @@
|
||||
'use strict'
|
||||
|
||||
var util = require('../../util');
|
||||
|
||||
class LayoutEngine {
|
||||
constructor(body) {
|
||||
this.body = body;
|
||||
|
||||
this.initialRandomSeed = Math.round(Math.random() * 1000000);
|
||||
this.randomSeed = this.initialRandomSeed;
|
||||
this.options = {};
|
||||
this.optionsBackup = {};
|
||||
|
||||
this.defaultOptions = {
|
||||
randomSeed: undefined,
|
||||
hierarchical: {
|
||||
enabled:false,
|
||||
levelSeparation: 150,
|
||||
direction: 'UD', // UD, DU, LR, RL
|
||||
sortMethod: 'hubsize' // hubsize, directed
|
||||
}
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.hierarchicalLevels = {};
|
||||
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
this.body.emitter.on('_dataChanged', () => {
|
||||
this.setupHierarchicalLayout();
|
||||
});
|
||||
this.body.emitter.on('_resetHierarchicalLayout', () => {
|
||||
this.setupHierarchicalLayout();
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options, allOptions) {
|
||||
if (options !== undefined) {
|
||||
let prevHierarchicalState = this.options.hierarchical.enabled;
|
||||
|
||||
util.mergeOptions(this.options, options, 'hierarchical');
|
||||
if (options.randomSeed !== undefined) {
|
||||
this.initialRandomSeed = options.randomSeed;
|
||||
}
|
||||
|
||||
if (this.options.hierarchical.enabled === true) {
|
||||
if (prevHierarchicalState === true) {
|
||||
// refresh the overridden options for nodes and edges.
|
||||
this.body.emitter.emit('refresh');
|
||||
}
|
||||
|
||||
// make sure the level seperation is the right way up
|
||||
if (this.options.hierarchical.direction === 'RL' || this.options.hierarchical.direction === 'DU') {
|
||||
if (this.options.hierarchical.levelSeparation > 0) {
|
||||
this.options.hierarchical.levelSeparation *= -1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.options.hierarchical.levelSeparation < 0) {
|
||||
this.options.hierarchical.levelSeparation *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
this.body.emitter.emit('_resetHierarchicalLayout');
|
||||
// because the hierarchical system needs it's own physics and smooth curve settings, we adapt the other options if needed.
|
||||
return this.adaptAllOptions(allOptions);
|
||||
}
|
||||
else {
|
||||
if (prevHierarchicalState === true) {
|
||||
// refresh the overridden options for nodes and edges.
|
||||
this.body.emitter.emit('refresh');
|
||||
return util.deepExtend(allOptions,this.optionsBackup);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
adaptAllOptions(allOptions) {
|
||||
if (this.options.hierarchical.enabled === true) {
|
||||
// set the physics
|
||||
if (allOptions.physics === undefined || allOptions.physics === true) {
|
||||
allOptions.physics = {solver: 'hierarchicalRepulsion'};
|
||||
this.optionsBackup.physics = {solver:'barnesHut'};
|
||||
}
|
||||
else if (typeof allOptions.physics === 'object') {
|
||||
this.optionsBackup.physics = {solver:'barnesHut'};
|
||||
if (allOptions.physics.solver !== undefined) {
|
||||
this.optionsBackup.physics = {solver:allOptions.physics.solver};
|
||||
}
|
||||
allOptions.physics['solver'] = 'hierarchicalRepulsion';
|
||||
}
|
||||
else if (allOptions.physics !== false) {
|
||||
this.optionsBackup.physics = {solver:'barnesHut'};
|
||||
allOptions.physics['solver'] = 'hierarchicalRepulsion';
|
||||
}
|
||||
|
||||
// get the type of static smooth curve in case it is required
|
||||
let type = 'horizontal';
|
||||
if (this.options.hierarchical.direction === 'RL' || this.options.hierarchical.direction === 'LR') {
|
||||
type = 'vertical';
|
||||
}
|
||||
|
||||
// disable smooth curves if nothing is defined. If smooth curves have been turned on, turn them into static smooth curves.
|
||||
if (allOptions.edges === undefined) {
|
||||
this.optionsBackup.edges = {smooth:{enabled:true, type:'dynamic'}};
|
||||
allOptions.edges = {smooth: false};
|
||||
}
|
||||
else if (allOptions.edges.smooth === undefined) {
|
||||
this.optionsBackup.edges = {smooth:{enabled:true, type:'dynamic'}};
|
||||
allOptions.edges.smooth = false;
|
||||
}
|
||||
else {
|
||||
if (typeof allOptions.edges.smooth === 'boolean') {
|
||||
this.optionsBackup.edges = {smooth:allOptions.edges.smooth};
|
||||
allOptions.edges.smooth = {enabled: allOptions.edges.smooth, type:type}
|
||||
}
|
||||
else {
|
||||
// allow custom types except for dynamic
|
||||
if (allOptions.edges.smooth.type !== undefined && allOptions.edges.smooth.type !== 'dynamic') {
|
||||
type = allOptions.edges.smooth.type;
|
||||
}
|
||||
|
||||
this.optionsBackup.edges = {
|
||||
smooth: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled,
|
||||
type:allOptions.edges.smooth.type === undefined ? 'dynamic' : allOptions.edges.smooth.type,
|
||||
roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness
|
||||
};
|
||||
allOptions.edges.smooth = {
|
||||
enabled: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled,
|
||||
type:type,
|
||||
roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// force all edges into static smooth curves. Only applies to edges that do not use the global options for smooth.
|
||||
this.body.emitter.emit('_forceDisableDynamicCurves', type);
|
||||
}
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
seededRandom() {
|
||||
var x = Math.sin(this.randomSeed++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
positionInitially(nodesArray) {
|
||||
if (this.options.hierarchical.enabled !== true) {
|
||||
this.randomSeed = this.initialRandomSeed;
|
||||
for (let i = 0; i < nodesArray.length; i++) {
|
||||
let node = nodesArray[i];
|
||||
let radius = 10 * 0.1 * nodesArray.length + 10;
|
||||
let angle = 2 * Math.PI * this.seededRandom();
|
||||
if (node.x === undefined) {
|
||||
node.x = radius * Math.cos(angle);
|
||||
}
|
||||
if (node.y === undefined) {
|
||||
node.y = radius * Math.sin(angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSeed() {
|
||||
return this.initialRandomSeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the main function to layout the nodes in a hierarchical way.
|
||||
* It checks if the node details are supplied correctly
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
setupHierarchicalLayout() {
|
||||
if (this.options.hierarchical.enabled === true && this.body.nodeIndices.length > 0) {
|
||||
// get the size of the largest hubs and check if the user has defined a level for a node.
|
||||
let node, nodeId;
|
||||
let definedLevel = false;
|
||||
let undefinedLevel = false;
|
||||
this.hierarchicalLevels = {};
|
||||
this.nodeSpacing = 100;
|
||||
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
node = this.body.nodes[nodeId];
|
||||
if (node.options.level !== undefined) {
|
||||
definedLevel = true;
|
||||
this.hierarchicalLevels[nodeId] = node.options.level;
|
||||
}
|
||||
else {
|
||||
undefinedLevel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the user defined some levels but not all, alert and run without hierarchical layout
|
||||
if (undefinedLevel === true && definedLevel === true) {
|
||||
throw new Error('To use the hierarchical layout, nodes require either no predefined levels or levels have to be defined for all nodes.');
|
||||
return;
|
||||
}
|
||||
else {
|
||||
// setup the system to use hierarchical method.
|
||||
//this._changeConstants();
|
||||
|
||||
// define levels if undefined by the users. Based on hubsize
|
||||
if (undefinedLevel === true) {
|
||||
if (this.options.hierarchical.sortMethod === 'hubsize') {
|
||||
this._determineLevelsByHubsize();
|
||||
}
|
||||
else if (this.options.hierarchical.sortMethod === 'directed' || 'direction') {
|
||||
this._determineLevelsDirected();
|
||||
}
|
||||
}
|
||||
|
||||
// check the distribution of the nodes per level.
|
||||
let distribution = this._getDistribution();
|
||||
|
||||
// place the nodes on the canvas.
|
||||
this._placeNodesByHierarchy(distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function places the nodes on the canvas based on the hierarchial distribution.
|
||||
*
|
||||
* @param {Object} distribution | obtained by the function this._getDistribution()
|
||||
* @private
|
||||
*/
|
||||
_placeNodesByHierarchy(distribution) {
|
||||
let nodeId, node;
|
||||
this.positionedNodes = {};
|
||||
// start placing all the level 0 nodes first. Then recursively position their branches.
|
||||
for (let level in distribution) {
|
||||
if (distribution.hasOwnProperty(level)) {
|
||||
for (nodeId in distribution[level].nodes) {
|
||||
if (distribution[level].nodes.hasOwnProperty(nodeId)) {
|
||||
|
||||
node = distribution[level].nodes[nodeId];
|
||||
|
||||
if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
|
||||
if (node.x === undefined) {node.x = distribution[level].distance;}
|
||||
distribution[level].distance = node.x + this.nodeSpacing;
|
||||
}
|
||||
else {
|
||||
if (node.y === undefined) {node.y = distribution[level].distance;}
|
||||
distribution[level].distance = node.y + this.nodeSpacing;
|
||||
}
|
||||
|
||||
this.positionedNodes[nodeId] = true;
|
||||
this._placeBranchNodes(node.edges,node.id,distribution,level);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function get the distribution of levels based on hubsize
|
||||
*
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
_getDistribution() {
|
||||
let distribution = {};
|
||||
let nodeId, node;
|
||||
|
||||
// we fix Y because the hierarchy is vertical, we fix X so we do not give a node an x position for a second time.
|
||||
// the fix of X is removed after the x value has been set.
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
node = this.body.nodes[nodeId];
|
||||
let level = this.hierarchicalLevels[nodeId] === undefined ? 0 : this.hierarchicalLevels[nodeId];
|
||||
if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
|
||||
node.y = this.options.hierarchical.levelSeparation * level;
|
||||
node.options.fixed.y = true;
|
||||
}
|
||||
else {
|
||||
node.x = this.options.hierarchical.levelSeparation * level;
|
||||
node.options.fixed.x = true;
|
||||
}
|
||||
if (distribution[level] === undefined) {
|
||||
distribution[level] = {amount: 0, nodes: {}, distance: 0};
|
||||
}
|
||||
distribution[level].amount += 1;
|
||||
distribution[level].nodes[nodeId] = node;
|
||||
}
|
||||
}
|
||||
return distribution;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the hubsize from all remaining unlevelled nodes.
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getHubSize() {
|
||||
let hubSize = 0;
|
||||
for (let nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
let node = this.body.nodes[nodeId];
|
||||
if (this.hierarchicalLevels[nodeId] === undefined) {
|
||||
hubSize = node.edges.length < hubSize ? hubSize : node.edges.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hubSize;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* this function allocates nodes in levels based on the recursive branching from the largest hubs.
|
||||
*
|
||||
* @param hubsize
|
||||
* @private
|
||||
*/
|
||||
_determineLevelsByHubsize() {
|
||||
let nodeId, node;
|
||||
let hubSize = 1;
|
||||
|
||||
while (hubSize > 0) {
|
||||
// determine hubs
|
||||
hubSize = this._getHubSize();
|
||||
if (hubSize === 0)
|
||||
break;
|
||||
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
node = this.body.nodes[nodeId];
|
||||
if (node.edges.length === hubSize) {
|
||||
this._setLevelByHubsize(0, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* this function is called recursively to enumerate the barnches of the largest hubs and give each node a level.
|
||||
*
|
||||
* @param level
|
||||
* @param edges
|
||||
* @param parentId
|
||||
* @private
|
||||
*/
|
||||
_setLevelByHubsize(level, node) {
|
||||
if (this.hierarchicalLevels[node.id] !== undefined)
|
||||
return;
|
||||
|
||||
let childNode;
|
||||
this.hierarchicalLevels[node.id] = level;
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
if (node.edges[i].toId === node.id) {
|
||||
childNode = node.edges[i].from;
|
||||
}
|
||||
else {
|
||||
childNode = node.edges[i].to;
|
||||
}
|
||||
this._setLevelByHubsize(level + 1, childNode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* this function allocates nodes in levels based on the direction of the edges
|
||||
*
|
||||
* @param hubsize
|
||||
* @private
|
||||
*/
|
||||
_determineLevelsDirected() {
|
||||
let nodeId, node;
|
||||
let minLevel = 10000;
|
||||
|
||||
// set first node to source
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
node = this.body.nodes[nodeId];
|
||||
this._setLevelDirected(minLevel,node);
|
||||
}
|
||||
}
|
||||
|
||||
// get the minimum level
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
minLevel = this.hierarchicalLevels[nodeId] < minLevel ? this.hierarchicalLevels[nodeId] : minLevel;
|
||||
}
|
||||
}
|
||||
|
||||
// subtract the minimum from the set so we have a range starting from 0
|
||||
for (nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
this.hierarchicalLevels[nodeId] -= minLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* this function is called recursively to enumerate the branched of the first node and give each node a level based on edge direction
|
||||
*
|
||||
* @param level
|
||||
* @param edges
|
||||
* @param parentId
|
||||
* @private
|
||||
*/
|
||||
_setLevelDirected(level, node) {
|
||||
if (this.hierarchicalLevels[node.id] !== undefined)
|
||||
return;
|
||||
|
||||
let childNode;
|
||||
this.hierarchicalLevels[node.id] = level;
|
||||
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
if (node.edges[i].toId === node.id) {
|
||||
childNode = node.edges[i].from;
|
||||
this._setLevelDirected(level - 1, childNode);
|
||||
}
|
||||
else {
|
||||
childNode = node.edges[i].to;
|
||||
this._setLevelDirected(level + 1, childNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* This is a recursively called function to enumerate the branches from the largest hubs and place the nodes
|
||||
* on a X position that ensures there will be no overlap.
|
||||
*
|
||||
* @param edges
|
||||
* @param parentId
|
||||
* @param distribution
|
||||
* @param parentLevel
|
||||
* @private
|
||||
*/
|
||||
_placeBranchNodes(edges, parentId, distribution, parentLevel) {
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
let childNode = undefined;
|
||||
let parentNode = undefined;
|
||||
if (edges[i].toId === parentId) {
|
||||
childNode = edges[i].from;
|
||||
parentNode = edges[i].to;
|
||||
}
|
||||
else {
|
||||
childNode = edges[i].to;
|
||||
parentNode = edges[i].from;
|
||||
}
|
||||
let childNodeLevel = this.hierarchicalLevels[childNode.id];
|
||||
|
||||
if (this.positionedNodes[childNode.id] === undefined) {
|
||||
// if a node is conneceted to another node on the same level (or higher (means lower level))!, this is not handled here.
|
||||
if (childNodeLevel > parentLevel) {
|
||||
if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
|
||||
if (childNode.x === undefined) {
|
||||
childNode.x = Math.max(distribution[childNodeLevel].distance, parentNode.x);
|
||||
}
|
||||
distribution[childNodeLevel].distance = childNode.x + this.nodeSpacing;
|
||||
this.positionedNodes[childNode.id] = true;
|
||||
}
|
||||
else {
|
||||
if (childNode.y === undefined) {
|
||||
childNode.y = Math.max(distribution[childNodeLevel].distance, parentNode.y)
|
||||
}
|
||||
distribution[childNodeLevel].distance = childNode.y + this.nodeSpacing;
|
||||
}
|
||||
this.positionedNodes[childNode.id] = true;
|
||||
|
||||
if (childNode.edges.length > 1) {
|
||||
this._placeBranchNodes(childNode.edges, childNode.id, distribution, childNodeLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LayoutEngine;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
let util = require("../../util");
|
||||
let DataSet = require('../../DataSet');
|
||||
let DataView = require('../../DataView');
|
||||
|
||||
import Node from "./components/Node";
|
||||
import Label from "./components/shared/Label";
|
||||
|
||||
class NodesHandler {
|
||||
constructor(body, images, groups, layoutEngine) {
|
||||
this.body = body;
|
||||
this.images = images;
|
||||
this.groups = groups;
|
||||
this.layoutEngine = layoutEngine;
|
||||
|
||||
// create the node API in the body container
|
||||
this.body.functions.createNode = this.create.bind(this);
|
||||
|
||||
this.nodesListeners = {
|
||||
add: (event, params) => { this.add(params.items); },
|
||||
update: (event, params) => { this.update(params.items, params.data); },
|
||||
remove: (event, params) => { this.remove(params.items); }
|
||||
};
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
borderWidth: 1,
|
||||
borderWidthSelected: 2,
|
||||
brokenImage: undefined,
|
||||
color: {
|
||||
border: '#2B7CE9',
|
||||
background: '#97C2FC',
|
||||
highlight: {
|
||||
border: '#2B7CE9',
|
||||
background: '#D2E5FF'
|
||||
},
|
||||
hover: {
|
||||
border: '#2B7CE9',
|
||||
background: '#D2E5FF'
|
||||
}
|
||||
},
|
||||
fixed: {
|
||||
x: false,
|
||||
y: false
|
||||
},
|
||||
font: {
|
||||
color: '#343434',
|
||||
size: 14, // px
|
||||
face: 'arial',
|
||||
background: 'none',
|
||||
strokeWidth: 0, // px
|
||||
strokeColor: '#ffffff',
|
||||
align: 'horizontal'
|
||||
},
|
||||
group: undefined,
|
||||
hidden: false,
|
||||
icon: {
|
||||
face: 'FontAwesome', //'FontAwesome',
|
||||
code: undefined, //'\uf007',
|
||||
size: 50, //50,
|
||||
color: '#2B7CE9' //'#aa00ff'
|
||||
},
|
||||
image: undefined, // --> URL
|
||||
label: undefined,
|
||||
labelHighlightBold: true,
|
||||
level: undefined,
|
||||
mass: 1,
|
||||
physics: true,
|
||||
scaling: {
|
||||
min: 10,
|
||||
max: 30,
|
||||
label: {
|
||||
enabled: false,
|
||||
min: 14,
|
||||
max: 30,
|
||||
maxVisible: 30,
|
||||
drawThreshold: 5
|
||||
},
|
||||
customScalingFunction: function (min, max, total, value) {
|
||||
if (max === min) {
|
||||
return 0.5;
|
||||
}
|
||||
else {
|
||||
let scale = 1 / (max - min);
|
||||
return Math.max(0, (value - min) * scale);
|
||||
}
|
||||
}
|
||||
},
|
||||
shadow: {
|
||||
enabled: false,
|
||||
size: 10,
|
||||
x: 5,
|
||||
y: 5
|
||||
},
|
||||
shape: 'ellipse',
|
||||
shapeProperties: {
|
||||
borderDashes: false
|
||||
},
|
||||
size: 25,
|
||||
title: undefined,
|
||||
value: undefined,
|
||||
x: undefined,
|
||||
y: undefined
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
// refresh the nodes. Used when reverting from hierarchical layout
|
||||
this.body.emitter.on('refreshNodes', this.refresh.bind(this));
|
||||
this.body.emitter.on('refresh', this.refresh.bind(this));
|
||||
this.body.emitter.on('destroy', () => {
|
||||
delete this.body.functions.createNode;
|
||||
delete this.nodesListeners.add;
|
||||
delete this.nodesListeners.update;
|
||||
delete this.nodesListeners.remove;
|
||||
delete this.nodesListeners;
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
Node.parseOptions(this.options, options);
|
||||
|
||||
// update the shape in all nodes
|
||||
if (options.shape !== undefined) {
|
||||
for (let nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
this.body.nodes[nodeId].updateShape();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the shape size in all nodes
|
||||
if (options.font !== undefined) {
|
||||
Label.parseOptions(this.options.font, options);
|
||||
for (let nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
this.body.nodes[nodeId].updateLabelModule();
|
||||
this.body.nodes[nodeId]._reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the shape size in all nodes
|
||||
if (options.size !== undefined) {
|
||||
for (let nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
this.body.nodes[nodeId]._reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the state of the letiables if needed
|
||||
if (options.hidden !== undefined || options.physics !== undefined) {
|
||||
this.body.emitter.emit('_dataChanged');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a data set with nodes for the network
|
||||
* @param {Array | DataSet | DataView} nodes The data containing the nodes.
|
||||
* @private
|
||||
*/
|
||||
setData(nodes, doNotEmit = false) {
|
||||
let oldNodesData = this.body.data.nodes;
|
||||
|
||||
if (nodes instanceof DataSet || nodes instanceof DataView) {
|
||||
this.body.data.nodes = nodes;
|
||||
}
|
||||
else if (Array.isArray(nodes)) {
|
||||
this.body.data.nodes = new DataSet();
|
||||
this.body.data.nodes.add(nodes);
|
||||
}
|
||||
else if (!nodes) {
|
||||
this.body.data.nodes = new DataSet();
|
||||
}
|
||||
else {
|
||||
throw new TypeError('Array or DataSet expected');
|
||||
}
|
||||
|
||||
if (oldNodesData) {
|
||||
// unsubscribe from old dataset
|
||||
util.forEach(this.nodesListeners, function (callback, event) {
|
||||
oldNodesData.off(event, callback);
|
||||
});
|
||||
}
|
||||
|
||||
// remove drawn nodes
|
||||
this.body.nodes = {};
|
||||
|
||||
if (this.body.data.nodes) {
|
||||
// subscribe to new dataset
|
||||
let me = this;
|
||||
util.forEach(this.nodesListeners, function (callback, event) {
|
||||
me.body.data.nodes.on(event, callback);
|
||||
});
|
||||
|
||||
// draw all new nodes
|
||||
let ids = this.body.data.nodes.getIds();
|
||||
this.add(ids, true);
|
||||
}
|
||||
|
||||
if (doNotEmit === false) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add nodes
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
add(ids, doNotEmit = false) {
|
||||
let id;
|
||||
let newNodes = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
id = ids[i];
|
||||
let properties = this.body.data.nodes.get(id);
|
||||
let node = this.create(properties);
|
||||
newNodes.push(node);
|
||||
this.body.nodes[id] = node; // note: this may replace an existing node
|
||||
}
|
||||
|
||||
this.layoutEngine.positionInitially(newNodes);
|
||||
|
||||
if (doNotEmit === false) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing nodes, or create them when not yet existing
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
update(ids, changedData) {
|
||||
let nodes = this.body.nodes;
|
||||
let dataChanged = false;
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i];
|
||||
let node = nodes[id];
|
||||
let data = changedData[i];
|
||||
if (node !== undefined) {
|
||||
// update node
|
||||
dataChanged = node.setOptions(data);
|
||||
}
|
||||
else {
|
||||
dataChanged = true;
|
||||
// create node
|
||||
node = this.create(data);
|
||||
nodes[id] = node;
|
||||
}
|
||||
}
|
||||
if (dataChanged === true) {
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit("_dataUpdated");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove existing nodes. If nodes do not exist, the method will just ignore it.
|
||||
* @param {Number[] | String[]} ids
|
||||
* @private
|
||||
*/
|
||||
remove(ids) {
|
||||
let nodes = this.body.nodes;
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i];
|
||||
delete nodes[id];
|
||||
}
|
||||
|
||||
this.body.emitter.emit("_dataChanged");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* create a node
|
||||
* @param properties
|
||||
* @param constructorClass
|
||||
*/
|
||||
create(properties, constructorClass = Node) {
|
||||
return new constructorClass(properties, this.body, this.images, this.groups, this.options)
|
||||
}
|
||||
|
||||
|
||||
refresh() {
|
||||
let nodes = this.body.nodes;
|
||||
for (let nodeId in nodes) {
|
||||
let node = undefined;
|
||||
if (nodes.hasOwnProperty(nodeId)) {
|
||||
node = nodes[nodeId];
|
||||
}
|
||||
let data = this.body.data.nodes._data[nodeId];
|
||||
if (node !== undefined && data !== undefined) {
|
||||
node.setOptions({ fixed: false, x:null, y:null });
|
||||
node.setOptions(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the positions of the nodes.
|
||||
* @param ids --> optional, can be array of nodeIds, can be string
|
||||
* @returns {{}}
|
||||
*/
|
||||
getPositions(ids) {
|
||||
let dataArray = {};
|
||||
if (ids !== undefined) {
|
||||
if (Array.isArray(ids) === true) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (this.body.nodes[ids[i]] !== undefined) {
|
||||
let node = this.body.nodes[ids[i]];
|
||||
dataArray[ids[i]] = { x: Math.round(node.x), y: Math.round(node.y) };
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.body.nodes[ids] !== undefined) {
|
||||
let node = this.body.nodes[ids];
|
||||
dataArray[ids] = { x: Math.round(node.x), y: Math.round(node.y) };
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (let nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
let node = this.body.nodes[nodeId];
|
||||
dataArray[nodeId] = { x: Math.round(node.x), y: Math.round(node.y) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataArray;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load the XY positions of the nodes into the dataset.
|
||||
*/
|
||||
storePositions() {
|
||||
// todo: add support for clusters and hierarchical.
|
||||
let dataArray = [];
|
||||
var dataset = this.body.data.nodes.getDataSet();
|
||||
|
||||
for (let nodeId in dataset._data) {
|
||||
if (dataset._data.hasOwnProperty(nodeId)) {
|
||||
let node = this.body.nodes[nodeId];
|
||||
if (dataset._data[nodeId].x != Math.round(node.x) || dataset._data[nodeId].y != Math.round(node.y)) {
|
||||
dataArray.push({ id: nodeId, x: Math.round(node.x), y: Math.round(node.y) });
|
||||
}
|
||||
}
|
||||
}
|
||||
dataset.update(dataArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* get the bounding box of a node.
|
||||
* @param nodeId
|
||||
* @returns {j|*}
|
||||
*/
|
||||
getBoundingBox(nodeId) {
|
||||
if (this.body.nodes[nodeId] !== undefined) {
|
||||
return this.body.nodes[nodeId].shape.boundingBox;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the Ids of nodes connected to this node.
|
||||
* @param nodeId
|
||||
* @returns {Array}
|
||||
*/
|
||||
getConnectedNodes(nodeId) {
|
||||
let nodeList = [];
|
||||
if (this.body.nodes[nodeId] !== undefined) {
|
||||
let node = this.body.nodes[nodeId];
|
||||
let nodeObj = {}; // used to quickly check if node already exists
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
if (edge.toId == nodeId) { // these are double equals since ids can be numeric or string
|
||||
if (nodeObj[edge.fromId] === undefined) {
|
||||
nodeList.push(edge.fromId);
|
||||
nodeObj[edge.fromId] = true;
|
||||
}
|
||||
}
|
||||
else if (edge.fromId == nodeId) { // these are double equals since ids can be numeric or string
|
||||
if (nodeObj[edge.toId] === undefined) {
|
||||
nodeList.push(edge.toId);
|
||||
nodeObj[edge.toId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodeList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ids of the edges connected to this node.
|
||||
* @param nodeId
|
||||
* @returns {*}
|
||||
*/
|
||||
getConnectedEdges(nodeId) {
|
||||
let edgeList = [];
|
||||
if (this.body.nodes[nodeId] !== undefined) {
|
||||
let node = this.body.nodes[nodeId];
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
edgeList.push(node.edges[i].id)
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("NodeId provided for getConnectedEdges does not exist. Provided: ", nodeId)
|
||||
}
|
||||
return edgeList;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default NodesHandler;
|
||||
@@ -0,0 +1,567 @@
|
||||
import BarnesHutSolver from './components/physics/BarnesHutSolver';
|
||||
import Repulsion from './components/physics/RepulsionSolver';
|
||||
import HierarchicalRepulsion from './components/physics/HierarchicalRepulsionSolver';
|
||||
import SpringSolver from './components/physics/SpringSolver';
|
||||
import HierarchicalSpringSolver from './components/physics/HierarchicalSpringSolver';
|
||||
import CentralGravitySolver from './components/physics/CentralGravitySolver';
|
||||
import ForceAtlas2BasedRepulsionSolver from './components/physics/FA2BasedRepulsionSolver';
|
||||
import ForceAtlas2BasedCentralGravitySolver from './components/physics/FA2BasedCentralGravitySolver';
|
||||
|
||||
var util = require('../../util');
|
||||
|
||||
|
||||
class PhysicsEngine {
|
||||
constructor(body) {
|
||||
this.body = body;
|
||||
this.physicsBody = {physicsNodeIndices:[], physicsEdgeIndices:[], forces: {}, velocities: {}};
|
||||
|
||||
this.physicsEnabled = true;
|
||||
this.simulationInterval = 1000 / 60;
|
||||
this.requiresTimeout = true;
|
||||
this.previousStates = {};
|
||||
this.freezeCache = {};
|
||||
this.renderTimer = undefined;
|
||||
this.initialStabilizationEmitted = false;
|
||||
|
||||
this.stabilized = false;
|
||||
this.startedStabilization = false;
|
||||
this.stabilizationIterations = 0;
|
||||
this.ready = false; // will be set to true if the stabilize
|
||||
|
||||
// default options
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
enabled: true,
|
||||
barnesHut: {
|
||||
theta: 0.5,
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.3,
|
||||
springLength: 95,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0
|
||||
},
|
||||
forceAtlas2Based: {
|
||||
theta: 0.5,
|
||||
gravitationalConstant: -50,
|
||||
centralGravity: 0.01,
|
||||
springConstant: 0.08,
|
||||
springLength: 100,
|
||||
damping: 0.4,
|
||||
avoidOverlap: 0
|
||||
},
|
||||
repulsion: {
|
||||
centralGravity: 0.2,
|
||||
springLength: 200,
|
||||
springConstant: 0.05,
|
||||
nodeDistance: 100,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0
|
||||
},
|
||||
hierarchicalRepulsion: {
|
||||
centralGravity: 0.0,
|
||||
springLength: 100,
|
||||
springConstant: 0.01,
|
||||
nodeDistance: 120,
|
||||
damping: 0.09
|
||||
},
|
||||
maxVelocity: 50,
|
||||
minVelocity: 0.1, // px/s
|
||||
solver: 'barnesHut',
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 1000, // maximum number of iteration to stabilize
|
||||
updateInterval: 50,
|
||||
onlyDynamicEdges: false,
|
||||
fit: true
|
||||
},
|
||||
timestep: 0.5
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.bindEventListeners();
|
||||
}
|
||||
|
||||
bindEventListeners() {
|
||||
this.body.emitter.on('initPhysics', () => {this.initPhysics();});
|
||||
this.body.emitter.on('resetPhysics', () => {this.stopSimulation(); this.ready = false;});
|
||||
this.body.emitter.on('disablePhysics', () => {this.physicsEnabled = false; this.stopSimulation();});
|
||||
this.body.emitter.on('restorePhysics', () => {
|
||||
this.setOptions(this.options);
|
||||
if (this.ready === true) {
|
||||
this.startSimulation();
|
||||
}
|
||||
});
|
||||
this.body.emitter.on('startSimulation', () => {
|
||||
if (this.ready === true) {
|
||||
this.startSimulation();
|
||||
}
|
||||
});
|
||||
this.body.emitter.on('stopSimulation', () => {this.stopSimulation();});
|
||||
this.body.emitter.on('destroy', () => {
|
||||
this.stopSimulation(false);
|
||||
this.body.emitter.off();
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
if (options === false) {
|
||||
this.options.enabled = false;
|
||||
this.physicsEnabled = false;
|
||||
this.stopSimulation();
|
||||
}
|
||||
else {
|
||||
this.physicsEnabled = true;
|
||||
util.selectiveNotDeepExtend(['stabilization'], this.options, options);
|
||||
util.mergeOptions(this.options, options, 'stabilization')
|
||||
|
||||
if (options.enabled === undefined) {
|
||||
this.options.enabled = true;
|
||||
}
|
||||
|
||||
if (this.options.enabled === false) {
|
||||
this.physicsEnabled = false;
|
||||
this.stopSimulation();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
|
||||
|
||||
init() {
|
||||
var options;
|
||||
if (this.options.solver === 'forceAtlas2Based') {
|
||||
options = this.options.forceAtlas2Based;
|
||||
this.nodesSolver = new ForceAtlas2BasedRepulsionSolver(this.body, this.physicsBody, options);
|
||||
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options);
|
||||
this.gravitySolver = new ForceAtlas2BasedCentralGravitySolver(this.body, this.physicsBody, options);
|
||||
}
|
||||
else if (this.options.solver === 'repulsion') {
|
||||
options = this.options.repulsion;
|
||||
this.nodesSolver = new Repulsion(this.body, this.physicsBody, options);
|
||||
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options);
|
||||
this.gravitySolver = new CentralGravitySolver(this.body, this.physicsBody, options);
|
||||
}
|
||||
else if (this.options.solver === 'hierarchicalRepulsion') {
|
||||
options = this.options.hierarchicalRepulsion;
|
||||
this.nodesSolver = new HierarchicalRepulsion(this.body, this.physicsBody, options);
|
||||
this.edgesSolver = new HierarchicalSpringSolver(this.body, this.physicsBody, options);
|
||||
this.gravitySolver = new CentralGravitySolver(this.body, this.physicsBody, options);
|
||||
}
|
||||
else { // barnesHut
|
||||
options = this.options.barnesHut;
|
||||
this.nodesSolver = new BarnesHutSolver(this.body, this.physicsBody, options);
|
||||
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options);
|
||||
this.gravitySolver = new CentralGravitySolver(this.body, this.physicsBody, options);
|
||||
}
|
||||
|
||||
this.modelOptions = options;
|
||||
}
|
||||
|
||||
initPhysics() {
|
||||
if (this.physicsEnabled === true && this.options.enabled === true) {
|
||||
if (this.options.stabilization.enabled === true) {
|
||||
this.stabilize();
|
||||
}
|
||||
else {
|
||||
this.stabilized = false;
|
||||
this.ready = true;
|
||||
this.body.emitter.emit('fit', {}, true);
|
||||
this.startSimulation();
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.ready = true;
|
||||
this.body.emitter.emit('fit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the simulation
|
||||
*/
|
||||
startSimulation() {
|
||||
if (this.physicsEnabled === true && this.options.enabled === true) {
|
||||
this.stabilized = false;
|
||||
|
||||
// this sets the width of all nodes initially which could be required for the avoidOverlap
|
||||
this.body.emitter.emit("_resizeNodes");
|
||||
if (this.viewFunction === undefined) {
|
||||
this.viewFunction = this.simulationStep.bind(this);
|
||||
this.body.emitter.on('initRedraw', this.viewFunction);
|
||||
this.body.emitter.emit('_startRendering');
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit('_redraw');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stop the simulation, force stabilization.
|
||||
*/
|
||||
stopSimulation(emit = true) {
|
||||
this.stabilized = true;
|
||||
if (emit === true) {
|
||||
this._emitStabilized();
|
||||
}
|
||||
if (this.viewFunction !== undefined) {
|
||||
this.body.emitter.off('initRedraw', this.viewFunction);
|
||||
this.viewFunction = undefined;
|
||||
if (emit === true) {
|
||||
this.body.emitter.emit('_stopRendering');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The viewFunction inserts this step into each renderloop. It calls the physics tick and handles the cleanup at stabilized.
|
||||
*
|
||||
*/
|
||||
simulationStep() {
|
||||
// check if the physics have settled
|
||||
var startTime = Date.now();
|
||||
this.physicsTick();
|
||||
var physicsTime = Date.now() - startTime;
|
||||
|
||||
// run double speed if it is a little graph
|
||||
if ((physicsTime < 0.4 * this.simulationInterval || this.runDoubleSpeed === true) && this.stabilized === false) {
|
||||
this.physicsTick();
|
||||
|
||||
// this makes sure there is no jitter. The decision is taken once to run it at double speed.
|
||||
this.runDoubleSpeed = true;
|
||||
}
|
||||
|
||||
if (this.stabilized === true) {
|
||||
if (this.stabilizationIterations > 1) {
|
||||
// trigger the 'stabilized' event.
|
||||
// The event is triggered on the next tick, to prevent the case that
|
||||
// it is fired while initializing the Network, in which case you would not
|
||||
// be able to catch it
|
||||
this.startedStabilization = false;
|
||||
//this._emitStabilized();
|
||||
}
|
||||
this.stopSimulation();
|
||||
}
|
||||
}
|
||||
|
||||
_emitStabilized() {
|
||||
if (this.stabilizationIterations > 1 || this.initialStabilizationEmitted === false) {
|
||||
this.initialStabilizationEmitted = true;
|
||||
setTimeout(() => {
|
||||
this.body.emitter.emit('stabilized', {iterations: this.stabilizationIterations});
|
||||
this.stabilizationIterations = 0;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single simulation step (or 'tick') in the physics simulation
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
physicsTick() {
|
||||
if (this.stabilized === false) {
|
||||
this.calculateForces();
|
||||
this.stabilized = this.moveNodes();
|
||||
|
||||
// determine if the network has stabilzied
|
||||
if (this.stabilized === true) {
|
||||
this.revert();
|
||||
}
|
||||
else {
|
||||
// this is here to ensure that there is no start event when the network is already stable.
|
||||
if (this.startedStabilization === false) {
|
||||
this.body.emitter.emit('startStabilizing');
|
||||
this.startedStabilization = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.stabilizationIterations++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes and edges can have the physics toggles on or off. A collection of indices is created here so we can skip the check all the time.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
updatePhysicsData() {
|
||||
this.physicsBody.forces = {};
|
||||
this.physicsBody.physicsNodeIndices = [];
|
||||
this.physicsBody.physicsEdgeIndices = [];
|
||||
let nodes = this.body.nodes;
|
||||
let edges = this.body.edges;
|
||||
|
||||
// get node indices for physics
|
||||
for (let nodeId in nodes) {
|
||||
if (nodes.hasOwnProperty(nodeId)) {
|
||||
if (nodes[nodeId].options.physics === true) {
|
||||
this.physicsBody.physicsNodeIndices.push(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get edge indices for physics
|
||||
for (let edgeId in edges) {
|
||||
if (edges.hasOwnProperty(edgeId)) {
|
||||
if (edges[edgeId].options.physics === true) {
|
||||
this.physicsBody.physicsEdgeIndices.push(edgeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get the velocity and the forces vector
|
||||
for (let i = 0; i < this.physicsBody.physicsNodeIndices.length; i++) {
|
||||
let nodeId = this.physicsBody.physicsNodeIndices[i];
|
||||
this.physicsBody.forces[nodeId] = {x:0,y:0};
|
||||
|
||||
// forces can be reset because they are recalculated. Velocities have to persist.
|
||||
if (this.physicsBody.velocities[nodeId] === undefined) {
|
||||
this.physicsBody.velocities[nodeId] = {x:0,y:0};
|
||||
}
|
||||
}
|
||||
|
||||
// clean deleted nodes from the velocity vector
|
||||
for (let nodeId in this.physicsBody.velocities) {
|
||||
if (nodes[nodeId] === undefined) {
|
||||
delete this.physicsBody.velocities[nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Revert the simulation one step. This is done so after stabilization, every new start of the simulation will also say stabilized.
|
||||
*/
|
||||
revert() {
|
||||
var nodeIds = Object.keys(this.previousStates);
|
||||
var nodes = this.body.nodes;
|
||||
var velocities = this.physicsBody.velocities;
|
||||
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
let nodeId = nodeIds[i];
|
||||
if (nodes[nodeId] !== undefined) {
|
||||
if (nodes[nodeId].options.physics === true) {
|
||||
velocities[nodeId].x = this.previousStates[nodeId].vx;
|
||||
velocities[nodeId].y = this.previousStates[nodeId].vy;
|
||||
nodes[nodeId].x = this.previousStates[nodeId].x;
|
||||
nodes[nodeId].y = this.previousStates[nodeId].y;
|
||||
}
|
||||
}
|
||||
else {
|
||||
delete this.previousStates[nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* move the nodes one timestap and check if they are stabilized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
moveNodes() {
|
||||
var nodesPresent = false;
|
||||
var nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
var maxVelocity = this.options.maxVelocity ? this.options.maxVelocity : 1e9;
|
||||
var stabilized = true;
|
||||
var vminCorrected = this.options.minVelocity / Math.max(this.body.view.scale,0.05);
|
||||
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
let nodeVelocity = this._performStep(nodeId, maxVelocity);
|
||||
// stabilized is true if stabilized is true and velocity is smaller than vmin --> all nodes must be stabilized
|
||||
stabilized = nodeVelocity < vminCorrected && stabilized === true;
|
||||
nodesPresent = true;
|
||||
}
|
||||
|
||||
|
||||
if (nodesPresent === true) {
|
||||
if (vminCorrected > 0.5*this.options.maxVelocity) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
return stabilized;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform the actual step
|
||||
*
|
||||
* @param nodeId
|
||||
* @param maxVelocity
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_performStep(nodeId,maxVelocity) {
|
||||
var node = this.body.nodes[nodeId];
|
||||
var timestep = this.options.timestep;
|
||||
var forces = this.physicsBody.forces;
|
||||
var velocities = this.physicsBody.velocities;
|
||||
|
||||
// store the state so we can revert
|
||||
this.previousStates[nodeId] = {x:node.x, y:node.y, vx:velocities[nodeId].x, vy:velocities[nodeId].y};
|
||||
|
||||
if (node.options.fixed.x === false) {
|
||||
let dx = this.modelOptions.damping * velocities[nodeId].x; // damping force
|
||||
let ax = (forces[nodeId].x - dx) / node.options.mass; // acceleration
|
||||
velocities[nodeId].x += ax * timestep; // velocity
|
||||
velocities[nodeId].x = (Math.abs(velocities[nodeId].x) > maxVelocity) ? ((velocities[nodeId].x > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].x;
|
||||
node.x += velocities[nodeId].x * timestep; // position
|
||||
}
|
||||
else {
|
||||
forces[nodeId].x = 0;
|
||||
velocities[nodeId].x = 0;
|
||||
}
|
||||
|
||||
if (node.options.fixed.y === false) {
|
||||
let dy = this.modelOptions.damping * velocities[nodeId].y; // damping force
|
||||
let ay = (forces[nodeId].y - dy) / node.options.mass; // acceleration
|
||||
velocities[nodeId].y += ay * timestep; // velocity
|
||||
velocities[nodeId].y = (Math.abs(velocities[nodeId].y) > maxVelocity) ? ((velocities[nodeId].y > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].y;
|
||||
node.y += velocities[nodeId].y * timestep; // position
|
||||
}
|
||||
else {
|
||||
forces[nodeId].y = 0;
|
||||
velocities[nodeId].y = 0;
|
||||
}
|
||||
|
||||
var totalVelocity = Math.sqrt(Math.pow(velocities[nodeId].x,2) + Math.pow(velocities[nodeId].y,2));
|
||||
return totalVelocity;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* calculate the forces for one physics iteration.
|
||||
*/
|
||||
calculateForces() {
|
||||
this.gravitySolver.solve();
|
||||
this.nodesSolver.solve();
|
||||
this.edgesSolver.solve();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* When initializing and stabilizing, we can freeze nodes with a predefined position. This greatly speeds up stabilization
|
||||
* because only the supportnodes for the smoothCurves have to settle.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_freezeNodes() {
|
||||
var nodes = this.body.nodes;
|
||||
for (var id in nodes) {
|
||||
if (nodes.hasOwnProperty(id)) {
|
||||
if (nodes[id].x && nodes[id].y) {
|
||||
this.freezeCache[id] = {x:nodes[id].options.fixed.x,y:nodes[id].options.fixed.y};
|
||||
nodes[id].options.fixed.x = true;
|
||||
nodes[id].options.fixed.y = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfreezes the nodes that have been frozen by _freezeDefinedNodes.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_restoreFrozenNodes() {
|
||||
var nodes = this.body.nodes;
|
||||
for (var id in nodes) {
|
||||
if (nodes.hasOwnProperty(id)) {
|
||||
if (this.freezeCache[id] !== undefined) {
|
||||
nodes[id].options.fixed.x = this.freezeCache[id].x;
|
||||
nodes[id].options.fixed.y = this.freezeCache[id].y;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.freezeCache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a stable position for all nodes
|
||||
* @private
|
||||
*/
|
||||
stabilize(iterations = this.options.stabilization.iterations) {
|
||||
if (typeof iterations !== 'number') {
|
||||
console.log('The stabilize method needs a numeric amount of iterations. Switching to default: ', this.options.stabilization.iterations);
|
||||
iterations = this.options.stabilization.iterations;
|
||||
}
|
||||
|
||||
if (this.physicsBody.physicsNodeIndices.length === 0) {
|
||||
this.ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// this sets the width of all nodes initially which could be required for the avoidOverlap
|
||||
this.body.emitter.emit("_resizeNodes");
|
||||
|
||||
// stop the render loop
|
||||
this.stopSimulation();
|
||||
|
||||
// set stabilze to false
|
||||
this.stabilized = false;
|
||||
|
||||
// block redraw requests
|
||||
this.body.emitter.emit('_blockRedraw');
|
||||
this.targetIterations = iterations;
|
||||
|
||||
// start the stabilization
|
||||
if (this.options.stabilization.onlyDynamicEdges === true) {
|
||||
this._freezeNodes();
|
||||
}
|
||||
this.stabilizationIterations = 0;
|
||||
|
||||
setTimeout(() => this._stabilizationBatch(),0);
|
||||
}
|
||||
|
||||
_stabilizationBatch() {
|
||||
var count = 0;
|
||||
while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) {
|
||||
this.physicsTick();
|
||||
this.stabilizationIterations++;
|
||||
count++;
|
||||
}
|
||||
|
||||
if (this.stabilized === false && this.stabilizationIterations < this.targetIterations) {
|
||||
this.body.emitter.emit('stabilizationProgress', {iterations: this.stabilizationIterations, total: this.targetIterations});
|
||||
setTimeout(this._stabilizationBatch.bind(this),0);
|
||||
}
|
||||
else {
|
||||
this._finalizeStabilization();
|
||||
}
|
||||
}
|
||||
|
||||
_finalizeStabilization() {
|
||||
this.body.emitter.emit('_allowRedraw');
|
||||
if (this.options.stabilization.fit === true) {
|
||||
this.body.emitter.emit('fit');
|
||||
}
|
||||
|
||||
if (this.options.stabilization.onlyDynamicEdges === true) {
|
||||
this._restoreFrozenNodes();
|
||||
}
|
||||
|
||||
this.body.emitter.emit('stabilizationIterationsDone');
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
|
||||
if (this.stabilized === true) {
|
||||
this._emitStabilized();
|
||||
}
|
||||
else {
|
||||
this.startSimulation();
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PhysicsEngine;
|
||||
@@ -0,0 +1,680 @@
|
||||
let Node = require("./components/Node");
|
||||
let Edge = require("./components/Edge");
|
||||
let util = require('../../util');
|
||||
|
||||
class SelectionHandler {
|
||||
constructor(body, canvas) {
|
||||
this.body = body;
|
||||
this.canvas = canvas;
|
||||
this.selectionObj = {nodes: [], edges: []};
|
||||
this.hoverObj = {nodes:{},edges:{}};
|
||||
|
||||
this.options = {};
|
||||
this.defaultOptions = {
|
||||
multiselect: false,
|
||||
selectable: true,
|
||||
selectConnectedEdges: true,
|
||||
hoverConnectedEdges: true
|
||||
};
|
||||
util.extend(this.options, this.defaultOptions);
|
||||
|
||||
this.body.emitter.on("_dataChanged", () => {
|
||||
this.updateSelection()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
let fields = ['multiselect','hoverConnectedEdges','selectable','selectConnectedEdges'];
|
||||
util.selectiveDeepExtend(fields,this.options, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handles the selection part of the tap;
|
||||
*
|
||||
* @param {Object} pointer
|
||||
* @private
|
||||
*/
|
||||
selectOnPoint(pointer) {
|
||||
let selected = false;
|
||||
if (this.options.selectable === true) {
|
||||
let obj = this.getNodeAt(pointer) || this.getEdgeAt(pointer);
|
||||
|
||||
// unselect after getting the objects in order to restore width and height.
|
||||
this.unselectAll();
|
||||
|
||||
if (obj !== undefined) {
|
||||
selected = this.selectObject(obj);
|
||||
}
|
||||
this.body.emitter.emit("_requestRedraw");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
selectAdditionalOnPoint(pointer) {
|
||||
let selectionChanged = false;
|
||||
if (this.options.selectable === true) {
|
||||
let obj = this.getNodeAt(pointer) || this.getEdgeAt(pointer);
|
||||
|
||||
if (obj !== undefined) {
|
||||
selectionChanged = true;
|
||||
if (obj.isSelected() === true) {
|
||||
this.deselectObject(obj);
|
||||
}
|
||||
else {
|
||||
this.selectObject(obj);
|
||||
}
|
||||
|
||||
this.body.emitter.emit("_requestRedraw");
|
||||
}
|
||||
}
|
||||
return selectionChanged;
|
||||
}
|
||||
|
||||
_generateClickEvent(eventType, event, pointer, oldSelection, emptySelection = false) {
|
||||
let properties;
|
||||
if (emptySelection === true) {
|
||||
properties = {nodes:[], edges:[]};
|
||||
}
|
||||
else {
|
||||
properties = this.getSelection();
|
||||
}
|
||||
properties['pointer'] = {
|
||||
DOM: {x: pointer.x, y: pointer.y},
|
||||
canvas: this.canvas.DOMtoCanvas(pointer)
|
||||
};
|
||||
properties['event'] = event;
|
||||
|
||||
if (oldSelection !== undefined) {
|
||||
properties['previousSelection'] = oldSelection;
|
||||
}
|
||||
this.body.emitter.emit(eventType, properties);
|
||||
}
|
||||
|
||||
selectObject(obj, highlightEdges = this.options.selectConnectedEdges) {
|
||||
if (obj !== undefined) {
|
||||
if (obj instanceof Node) {
|
||||
if (highlightEdges === true) {
|
||||
this._selectConnectedEdges(obj);
|
||||
}
|
||||
}
|
||||
obj.select();
|
||||
this._addToSelection(obj);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
deselectObject(obj) {
|
||||
if (obj.isSelected() === true) {
|
||||
obj.selected = false;
|
||||
this._removeFromSelection(obj);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* retrieve all nodes overlapping with given object
|
||||
* @param {Object} object An object with parameters left, top, right, bottom
|
||||
* @return {Number[]} An array with id's of the overlapping nodes
|
||||
* @private
|
||||
*/
|
||||
_getAllNodesOverlappingWith(object) {
|
||||
let overlappingNodes = [];
|
||||
let nodes = this.body.nodes;
|
||||
for (let i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
let nodeId = this.body.nodeIndices[i];
|
||||
if (nodes[nodeId].isOverlappingWith(object)) {
|
||||
overlappingNodes.push(nodeId);
|
||||
}
|
||||
}
|
||||
return overlappingNodes;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a position object in canvasspace from a single point in screenspace
|
||||
*
|
||||
* @param pointer
|
||||
* @returns {{left: number, top: number, right: number, bottom: number}}
|
||||
* @private
|
||||
*/
|
||||
_pointerToPositionObject(pointer) {
|
||||
let canvasPos = this.canvas.DOMtoCanvas(pointer);
|
||||
return {
|
||||
left: canvasPos.x - 1,
|
||||
top: canvasPos.y + 1,
|
||||
right: canvasPos.x + 1,
|
||||
bottom: canvasPos.y - 1
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the top node at the a specific point (like a click)
|
||||
*
|
||||
* @param {{x: Number, y: Number}} pointer
|
||||
* @return {Node | undefined} node
|
||||
* @private
|
||||
*/
|
||||
getNodeAt(pointer, returnNode = true) {
|
||||
// we first check if this is an navigation controls element
|
||||
let positionObject = this._pointerToPositionObject(pointer);
|
||||
let overlappingNodes = this._getAllNodesOverlappingWith(positionObject);
|
||||
// if there are overlapping nodes, select the last one, this is the
|
||||
// one which is drawn on top of the others
|
||||
if (overlappingNodes.length > 0) {
|
||||
if (returnNode === true) {
|
||||
return this.body.nodes[overlappingNodes[overlappingNodes.length - 1]];
|
||||
}
|
||||
else {
|
||||
return overlappingNodes[overlappingNodes.length - 1];
|
||||
}
|
||||
}
|
||||
else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* retrieve all edges overlapping with given object, selector is around center
|
||||
* @param {Object} object An object with parameters left, top, right, bottom
|
||||
* @return {Number[]} An array with id's of the overlapping nodes
|
||||
* @private
|
||||
*/
|
||||
_getEdgesOverlappingWith(object, overlappingEdges) {
|
||||
let edges = this.body.edges;
|
||||
for (let i = 0; i < this.body.edgeIndices.length; i++) {
|
||||
let edgeId = this.body.edgeIndices[i];
|
||||
if (edges[edgeId].isOverlappingWith(object)) {
|
||||
overlappingEdges.push(edgeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* retrieve all nodes overlapping with given object
|
||||
* @param {Object} object An object with parameters left, top, right, bottom
|
||||
* @return {Number[]} An array with id's of the overlapping nodes
|
||||
* @private
|
||||
*/
|
||||
_getAllEdgesOverlappingWith(object) {
|
||||
let overlappingEdges = [];
|
||||
this._getEdgesOverlappingWith(object,overlappingEdges);
|
||||
return overlappingEdges;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Place holder. To implement change the getNodeAt to a _getObjectAt. Have the _getObjectAt call
|
||||
* getNodeAt and _getEdgesAt, then priortize the selection to user preferences.
|
||||
*
|
||||
* @param pointer
|
||||
* @returns {undefined}
|
||||
* @private
|
||||
*/
|
||||
getEdgeAt(pointer, returnEdge = true) {
|
||||
let positionObject = this._pointerToPositionObject(pointer);
|
||||
let overlappingEdges = this._getAllEdgesOverlappingWith(positionObject);
|
||||
|
||||
if (overlappingEdges.length > 0) {
|
||||
if (returnEdge === true) {
|
||||
return this.body.edges[overlappingEdges[overlappingEdges.length - 1]];
|
||||
}
|
||||
else {
|
||||
return overlappingEdges[overlappingEdges.length - 1];
|
||||
}
|
||||
}
|
||||
else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add object to the selection array.
|
||||
*
|
||||
* @param obj
|
||||
* @private
|
||||
*/
|
||||
_addToSelection(obj) {
|
||||
if (obj instanceof Node) {
|
||||
this.selectionObj.nodes[obj.id] = obj;
|
||||
}
|
||||
else {
|
||||
this.selectionObj.edges[obj.id] = obj;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add object to the selection array.
|
||||
*
|
||||
* @param obj
|
||||
* @private
|
||||
*/
|
||||
_addToHover(obj) {
|
||||
if (obj instanceof Node) {
|
||||
this.hoverObj.nodes[obj.id] = obj;
|
||||
}
|
||||
else {
|
||||
this.hoverObj.edges[obj.id] = obj;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a single option from selection.
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @private
|
||||
*/
|
||||
_removeFromSelection(obj) {
|
||||
if (obj instanceof Node) {
|
||||
delete this.selectionObj.nodes[obj.id];
|
||||
}
|
||||
else {
|
||||
delete this.selectionObj.edges[obj.id];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unselect all. The selectionObj is useful for this.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
unselectAll() {
|
||||
for(let nodeId in this.selectionObj.nodes) {
|
||||
if(this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
this.selectionObj.nodes[nodeId].unselect();
|
||||
}
|
||||
}
|
||||
for(let edgeId in this.selectionObj.edges) {
|
||||
if(this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
this.selectionObj.edges[edgeId].unselect();
|
||||
}
|
||||
}
|
||||
|
||||
this.selectionObj = {nodes:{},edges:{}};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* return the number of selected nodes
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getSelectedNodeCount() {
|
||||
let count = 0;
|
||||
for (let nodeId in this.selectionObj.nodes) {
|
||||
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* return the selected node
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getSelectedNode() {
|
||||
for (let nodeId in this.selectionObj.nodes) {
|
||||
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
return this.selectionObj.nodes[nodeId];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* return the selected edge
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getSelectedEdge() {
|
||||
for (let edgeId in this.selectionObj.edges) {
|
||||
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
return this.selectionObj.edges[edgeId];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* return the number of selected edges
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getSelectedEdgeCount() {
|
||||
let count = 0;
|
||||
for (let edgeId in this.selectionObj.edges) {
|
||||
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* return the number of selected objects.
|
||||
*
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_getSelectedObjectCount() {
|
||||
let count = 0;
|
||||
for(let nodeId in this.selectionObj.nodes) {
|
||||
if(this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
for(let edgeId in this.selectionObj.edges) {
|
||||
if(this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if anything is selected
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_selectionIsEmpty() {
|
||||
for(let nodeId in this.selectionObj.nodes) {
|
||||
if(this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for(let edgeId in this.selectionObj.edges) {
|
||||
if(this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* check if one of the selected nodes is a cluster.
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_clusterInSelection() {
|
||||
for(let nodeId in this.selectionObj.nodes) {
|
||||
if(this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
if (this.selectionObj.nodes[nodeId].clusterSize > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* select the edges connected to the node that is being selected
|
||||
*
|
||||
* @param {Node} node
|
||||
* @private
|
||||
*/
|
||||
_selectConnectedEdges(node) {
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
edge.select();
|
||||
this._addToSelection(edge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* select the edges connected to the node that is being selected
|
||||
*
|
||||
* @param {Node} node
|
||||
* @private
|
||||
*/
|
||||
_hoverConnectedEdges(node) {
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
edge.hover = true;
|
||||
this._addToHover(edge);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* unselect the edges connected to the node that is being selected
|
||||
*
|
||||
* @param {Node} node
|
||||
* @private
|
||||
*/
|
||||
_unselectConnectedEdges(node) {
|
||||
for (let i = 0; i < node.edges.length; i++) {
|
||||
let edge = node.edges[i];
|
||||
edge.unselect();
|
||||
this._removeFromSelection(edge);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This is called when someone clicks on a node. either select or deselect it.
|
||||
* If there is an existing selection and we don't want to append to it, clear the existing selection
|
||||
*
|
||||
* @param {Node || Edge} object
|
||||
* @private
|
||||
*/
|
||||
blurObject(object) {
|
||||
if (object.hover === true) {
|
||||
object.hover = false;
|
||||
if (object instanceof Node) {
|
||||
this.body.emitter.emit("blurNode", {node: object.id});
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit("blurEdge", {edge: object.id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called when someone clicks on a node. either select or deselect it.
|
||||
* If there is an existing selection and we don't want to append to it, clear the existing selection
|
||||
*
|
||||
* @param {Node || Edge} object
|
||||
* @private
|
||||
*/
|
||||
hoverObject(object) {
|
||||
let hoverChanged = false;
|
||||
// remove all node hover highlights
|
||||
for (let nodeId in this.hoverObj.nodes) {
|
||||
if (this.hoverObj.nodes.hasOwnProperty(nodeId)) {
|
||||
if (object === undefined || (object instanceof Node && object.id != nodeId) || object instanceof Edge) {
|
||||
this.blurObject(this.hoverObj.nodes[nodeId]);
|
||||
delete this.hoverObj.nodes[nodeId];
|
||||
hoverChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removing all edge hover highlights
|
||||
for (let edgeId in this.hoverObj.edges) {
|
||||
if (this.hoverObj.edges.hasOwnProperty(edgeId)) {
|
||||
// if the hover has been changed here it means that the node has been hovered over or off
|
||||
// we then do not use the blurObject method here.
|
||||
if (hoverChanged === true) {
|
||||
this.hoverObj.edges[edgeId].hover = false;
|
||||
delete this.hoverObj.edges[edgeId];
|
||||
}
|
||||
// if the blur remains the same and the object is undefined (mouse off), we blur the edge
|
||||
else if (object === undefined) {
|
||||
this.blurObject(this.hoverObj.edges[edgeId]);
|
||||
delete this.hoverObj.edges[edgeId];
|
||||
hoverChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (object !== undefined) {
|
||||
if (object.hover === false) {
|
||||
object.hover = true;
|
||||
this._addToHover(object);
|
||||
hoverChanged = true;
|
||||
if (object instanceof Node) {
|
||||
this.body.emitter.emit("hoverNode", {node: object.id});
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit("hoverEdge", {edge: object.id});
|
||||
}
|
||||
}
|
||||
if (object instanceof Node && this.options.hoverConnectedEdges === true) {
|
||||
this._hoverConnectedEdges(object);
|
||||
}
|
||||
}
|
||||
|
||||
if (hoverChanged === true) {
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* retrieve the currently selected objects
|
||||
* @return {{nodes: Array.<String>, edges: Array.<String>}} selection
|
||||
*/
|
||||
getSelection() {
|
||||
let nodeIds = this.getSelectedNodes();
|
||||
let edgeIds = this.getSelectedEdges();
|
||||
return {nodes:nodeIds, edges:edgeIds};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* retrieve the currently selected nodes
|
||||
* @return {String[]} selection An array with the ids of the
|
||||
* selected nodes.
|
||||
*/
|
||||
getSelectedNodes() {
|
||||
let idArray = [];
|
||||
if (this.options.selectable === true) {
|
||||
for (let nodeId in this.selectionObj.nodes) {
|
||||
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
idArray.push(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return idArray;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* retrieve the currently selected edges
|
||||
* @return {Array} selection An array with the ids of the
|
||||
* selected nodes.
|
||||
*/
|
||||
getSelectedEdges() {
|
||||
let idArray = [];
|
||||
if (this.options.selectable === true) {
|
||||
for (let edgeId in this.selectionObj.edges) {
|
||||
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
idArray.push(edgeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return idArray;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* select zero or more nodes with the option to highlight edges
|
||||
* @param {Number[] | String[]} selection An array with the ids of the
|
||||
* selected nodes.
|
||||
* @param {boolean} [highlightEdges]
|
||||
*/
|
||||
selectNodes(selection, highlightEdges = true) {
|
||||
let i, id;
|
||||
|
||||
if (!selection || (selection.length === undefined))
|
||||
throw 'Selection must be an array with ids';
|
||||
|
||||
// first unselect any selected node
|
||||
this.unselectAll();
|
||||
|
||||
for (i = 0; i < selection.length; i++) {
|
||||
id = selection[i];
|
||||
|
||||
let node = this.body.nodes[id];
|
||||
if (!node) {
|
||||
throw new RangeError('Node with id "' + id + '" not found');
|
||||
}
|
||||
this.selectObject(node,highlightEdges);
|
||||
}
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* select zero or more edges
|
||||
* @param {Number[] | String[]} selection An array with the ids of the
|
||||
* selected nodes.
|
||||
*/
|
||||
selectEdges(selection) {
|
||||
let i, id;
|
||||
|
||||
if (!selection || (selection.length === undefined))
|
||||
throw 'Selection must be an array with ids';
|
||||
|
||||
// first unselect any selected objects
|
||||
this.unselectAll();
|
||||
|
||||
for (i = 0; i < selection.length; i++) {
|
||||
id = selection[i];
|
||||
|
||||
let edge = this.body.edges[id];
|
||||
if (!edge) {
|
||||
throw new RangeError('Edge with id "' + id + '" not found');
|
||||
}
|
||||
this.selectObject(edge);
|
||||
}
|
||||
this.body.emitter.emit('_requestRedraw');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the selection: remove ids of nodes which no longer exist
|
||||
* @private
|
||||
*/
|
||||
updateSelection() {
|
||||
for (let nodeId in this.selectionObj.nodes) {
|
||||
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
|
||||
if (!this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
delete this.selectionObj.nodes[nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let edgeId in this.selectionObj.edges) {
|
||||
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
|
||||
if (!this.body.edges.hasOwnProperty(edgeId)) {
|
||||
delete this.selectionObj.edges[edgeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectionHandler;
|
||||
@@ -0,0 +1,335 @@
|
||||
var util = require('../../util');
|
||||
|
||||
class View {
|
||||
constructor(body, canvas) {
|
||||
this.body = body;
|
||||
this.canvas = canvas;
|
||||
|
||||
this.animationSpeed = 1/this.renderRefreshRate;
|
||||
this.animationEasingFunction = "easeInOutQuint";
|
||||
this.easingTime = 0;
|
||||
this.sourceScale = 0;
|
||||
this.targetScale = 0;
|
||||
this.sourceTranslation = 0;
|
||||
this.targetTranslation = 0;
|
||||
this.lockedOnNodeId = undefined;
|
||||
this.lockedOnNodeOffset = undefined;
|
||||
this.touchTime = 0;
|
||||
|
||||
this.viewFunction = undefined;
|
||||
|
||||
this.body.emitter.on("fit", this.fit.bind(this));
|
||||
this.body.emitter.on("animationFinished", () => {this.body.emitter.emit("_stopRendering");});
|
||||
this.body.emitter.on("unlockNode", this.releaseNode.bind(this));
|
||||
}
|
||||
|
||||
|
||||
setOptions(options = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find the center position of the network
|
||||
* @private
|
||||
*/
|
||||
_getRange(specificNodes = []) {
|
||||
var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node;
|
||||
if (specificNodes.length > 0) {
|
||||
for (var i = 0; i < specificNodes.length; i++) {
|
||||
node = this.body.nodes[specificNodes[i]];
|
||||
if (minX > (node.shape.boundingBox.left)) {
|
||||
minX = node.shape.boundingBox.left;
|
||||
}
|
||||
if (maxX < (node.shape.boundingBox.right)) {
|
||||
maxX = node.shape.boundingBox.right;
|
||||
}
|
||||
if (minY > (node.shape.boundingBox.top)) {
|
||||
minY = node.shape.boundingBox.top;
|
||||
} // top is negative, bottom is positive
|
||||
if (maxY < (node.shape.boundingBox.bottom)) {
|
||||
maxY = node.shape.boundingBox.bottom;
|
||||
} // top is negative, bottom is positive
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (var i = 0; i < this.body.nodeIndices.length; i++) {
|
||||
node = this.body.nodes[this.body.nodeIndices[i]];
|
||||
if (minX > (node.shape.boundingBox.left)) {
|
||||
minX = node.shape.boundingBox.left;
|
||||
}
|
||||
if (maxX < (node.shape.boundingBox.right)) {
|
||||
maxX = node.shape.boundingBox.right;
|
||||
}
|
||||
if (minY > (node.shape.boundingBox.top)) {
|
||||
minY = node.shape.boundingBox.top;
|
||||
} // top is negative, bottom is positive
|
||||
if (maxY < (node.shape.boundingBox.bottom)) {
|
||||
maxY = node.shape.boundingBox.bottom;
|
||||
} // top is negative, bottom is positive
|
||||
}
|
||||
}
|
||||
|
||||
if (minX === 1e9 && maxX === -1e9 && minY === 1e9 && maxY === -1e9) {
|
||||
minY = 0, maxY = 0, minX = 0, maxX = 0;
|
||||
}
|
||||
return {minX: minX, maxX: maxX, minY: minY, maxY: maxY};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY};
|
||||
* @returns {{x: number, y: number}}
|
||||
* @private
|
||||
*/
|
||||
_findCenter(range) {
|
||||
return {x: (0.5 * (range.maxX + range.minX)),
|
||||
y: (0.5 * (range.maxY + range.minY))};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function zooms out to fit all data on screen based on amount of nodes
|
||||
* @param {Object} Options
|
||||
* @param {Boolean} [initialZoom] | zoom based on fitted formula or range, true = fitted, default = false;
|
||||
*/
|
||||
fit(options = {nodes:[]}, initialZoom = false) {
|
||||
var range;
|
||||
var zoomLevel;
|
||||
|
||||
if (initialZoom === true) {
|
||||
// check if more than half of the nodes have a predefined position. If so, we use the range, not the approximation.
|
||||
var positionDefined = 0;
|
||||
for (var nodeId in this.body.nodes) {
|
||||
if (this.body.nodes.hasOwnProperty(nodeId)) {
|
||||
var node = this.body.nodes[nodeId];
|
||||
if (node.predefinedPosition === true) {
|
||||
positionDefined += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (positionDefined > 0.5 * this.body.nodeIndices.length) {
|
||||
this.fit(options,false);
|
||||
return;
|
||||
}
|
||||
|
||||
range = this._getRange(options.nodes);
|
||||
|
||||
var numberOfNodes = this.body.nodeIndices.length;
|
||||
zoomLevel = 12.662 / (numberOfNodes + 7.4147) + 0.0964822; // this is obtained from fitting a dataset from 5 points with scale levels that looked good.
|
||||
|
||||
// correct for larger canvasses.
|
||||
var factor = Math.min(this.canvas.frame.canvas.clientWidth / 600, this.canvas.frame.canvas.clientHeight / 600);
|
||||
zoomLevel *= factor;
|
||||
}
|
||||
else {
|
||||
this.body.emitter.emit("_resizeNodes");
|
||||
range = this._getRange(options.nodes);
|
||||
|
||||
var xDistance = Math.abs(range.maxX - range.minX) * 1.1;
|
||||
var yDistance = Math.abs(range.maxY - range.minY) * 1.1;
|
||||
|
||||
var xZoomLevel = this.canvas.frame.canvas.clientWidth / xDistance;
|
||||
var yZoomLevel = this.canvas.frame.canvas.clientHeight / yDistance;
|
||||
|
||||
zoomLevel = (xZoomLevel <= yZoomLevel) ? xZoomLevel : yZoomLevel;
|
||||
}
|
||||
|
||||
if (zoomLevel > 1.0) {
|
||||
zoomLevel = 1.0;
|
||||
}
|
||||
else if (zoomLevel === 0) {
|
||||
zoomLevel = 1.0;
|
||||
}
|
||||
|
||||
var center = this._findCenter(range);
|
||||
var animationOptions = {position: center, scale: zoomLevel, animation: options.animation};
|
||||
this.moveTo(animationOptions);
|
||||
}
|
||||
|
||||
// animation
|
||||
|
||||
/**
|
||||
* Center a node in view.
|
||||
*
|
||||
* @param {Number} nodeId
|
||||
* @param {Number} [options]
|
||||
*/
|
||||
focus(nodeId, options = {}) {
|
||||
if (this.body.nodes[nodeId] !== undefined) {
|
||||
var nodePosition = {x: this.body.nodes[nodeId].x, y: this.body.nodes[nodeId].y};
|
||||
options.position = nodePosition;
|
||||
options.lockedOnNode = nodeId;
|
||||
|
||||
this.moveTo(options)
|
||||
}
|
||||
else {
|
||||
console.log("Node: " + nodeId + " cannot be found.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} options | options.offset = {x:Number, y:Number} // offset from the center in DOM pixels
|
||||
* | options.scale = Number // scale to move to
|
||||
* | options.position = {x:Number, y:Number} // position to move to
|
||||
* | options.animation = {duration:Number, easingFunction:String} || Boolean // position to move to
|
||||
*/
|
||||
moveTo(options) {
|
||||
if (options === undefined) {
|
||||
options = {};
|
||||
return;
|
||||
}
|
||||
if (options.offset === undefined) {options.offset = {x: 0, y: 0}; }
|
||||
if (options.offset.x === undefined) {options.offset.x = 0; }
|
||||
if (options.offset.y === undefined) {options.offset.y = 0; }
|
||||
if (options.scale === undefined) {options.scale = this.body.view.scale; }
|
||||
if (options.position === undefined) {options.position = this.getViewPosition();}
|
||||
if (options.animation === undefined) {options.animation = {duration:0}; }
|
||||
if (options.animation === false ) {options.animation = {duration:0}; }
|
||||
if (options.animation === true ) {options.animation = {}; }
|
||||
if (options.animation.duration === undefined) {options.animation.duration = 1000; } // default duration
|
||||
if (options.animation.easingFunction === undefined) {options.animation.easingFunction = "easeInOutQuad"; } // default easing function
|
||||
|
||||
this.animateView(options);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} options | options.offset = {x:Number, y:Number} // offset from the center in DOM pixels
|
||||
* | options.time = Number // animation time in milliseconds
|
||||
* | options.scale = Number // scale to animate to
|
||||
* | options.position = {x:Number, y:Number} // position to animate to
|
||||
* | options.easingFunction = String // linear, easeInQuad, easeOutQuad, easeInOutQuad,
|
||||
* // easeInCubic, easeOutCubic, easeInOutCubic,
|
||||
* // easeInQuart, easeOutQuart, easeInOutQuart,
|
||||
* // easeInQuint, easeOutQuint, easeInOutQuint
|
||||
*/
|
||||
animateView(options) {
|
||||
if (options === undefined) {
|
||||
return;
|
||||
}
|
||||
this.animationEasingFunction = options.animation.easingFunction;
|
||||
// release if something focussed on the node
|
||||
this.releaseNode();
|
||||
if (options.locked === true) {
|
||||
this.lockedOnNodeId = options.lockedOnNode;
|
||||
this.lockedOnNodeOffset = options.offset;
|
||||
}
|
||||
|
||||
// forcefully complete the old animation if it was still running
|
||||
if (this.easingTime != 0) {
|
||||
this._transitionRedraw(true); // by setting easingtime to 1, we finish the animation.
|
||||
}
|
||||
|
||||
this.sourceScale = this.body.view.scale;
|
||||
this.sourceTranslation = this.body.view.translation;
|
||||
this.targetScale = options.scale;
|
||||
|
||||
// set the scale so the viewCenter is based on the correct zoom level. This is overridden in the transitionRedraw
|
||||
// but at least then we'll have the target transition
|
||||
this.body.view.scale = this.targetScale;
|
||||
var viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight});
|
||||
|
||||
var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node
|
||||
x: viewCenter.x - options.position.x,
|
||||
y: viewCenter.y - options.position.y
|
||||
};
|
||||
this.targetTranslation = {
|
||||
x: this.sourceTranslation.x + distanceFromCenter.x * this.targetScale + options.offset.x,
|
||||
y: this.sourceTranslation.y + distanceFromCenter.y * this.targetScale + options.offset.y
|
||||
};
|
||||
|
||||
// if the time is set to 0, don't do an animation
|
||||
if (options.animation.duration === 0) {
|
||||
if (this.lockedOnNodeId != undefined) {
|
||||
this.viewFunction = this._lockedRedraw.bind(this);
|
||||
this.body.emitter.on("initRedraw", this.viewFunction);
|
||||
}
|
||||
else {
|
||||
this.body.view.scale = this.targetScale;
|
||||
this.body.view.translation = this.targetTranslation;
|
||||
this.body.emitter.emit("_requestRedraw");
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.animationSpeed = 1 / (60 * options.animation.duration * 0.001) || 1 / 60; // 60 for 60 seconds, 0.001 for milli's
|
||||
this.animationEasingFunction = options.animation.easingFunction;
|
||||
|
||||
|
||||
this.viewFunction = this._transitionRedraw.bind(this);
|
||||
this.body.emitter.on("initRedraw", this.viewFunction);
|
||||
this.body.emitter.emit("_startRendering");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* used to animate smoothly by hijacking the redraw function.
|
||||
* @private
|
||||
*/
|
||||
_lockedRedraw() {
|
||||
var nodePosition = {x: this.body.nodes[this.lockedOnNodeId].x, y: this.body.nodes[this.lockedOnNodeId].y};
|
||||
var viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight});
|
||||
var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node
|
||||
x: viewCenter.x - nodePosition.x,
|
||||
y: viewCenter.y - nodePosition.y
|
||||
};
|
||||
var sourceTranslation = this.body.view.translation;
|
||||
var targetTranslation = {
|
||||
x: sourceTranslation.x + distanceFromCenter.x * this.body.view.scale + this.lockedOnNodeOffset.x,
|
||||
y: sourceTranslation.y + distanceFromCenter.y * this.body.view.scale + this.lockedOnNodeOffset.y
|
||||
};
|
||||
|
||||
this.body.view.translation = targetTranslation;
|
||||
}
|
||||
|
||||
releaseNode() {
|
||||
if (this.lockedOnNodeId !== undefined && this.viewFunction !== undefined) {
|
||||
this.body.emitter.off("initRedraw", this.viewFunction);
|
||||
this.lockedOnNodeId = undefined;
|
||||
this.lockedOnNodeOffset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param easingTime
|
||||
* @private
|
||||
*/
|
||||
_transitionRedraw(finished = false) {
|
||||
this.easingTime += this.animationSpeed;
|
||||
this.easingTime = finished === true ? 1.0 : this.easingTime;
|
||||
|
||||
var progress = util.easingFunctions[this.animationEasingFunction](this.easingTime);
|
||||
|
||||
this.body.view.scale = this.sourceScale + (this.targetScale - this.sourceScale) * progress;
|
||||
this.body.view.translation = {
|
||||
x: this.sourceTranslation.x + (this.targetTranslation.x - this.sourceTranslation.x) * progress,
|
||||
y: this.sourceTranslation.y + (this.targetTranslation.y - this.sourceTranslation.y) * progress
|
||||
};
|
||||
|
||||
// cleanup
|
||||
if (this.easingTime >= 1.0) {
|
||||
this.body.emitter.off("initRedraw", this.viewFunction);
|
||||
this.easingTime = 0;
|
||||
if (this.lockedOnNodeId != undefined) {
|
||||
this.viewFunction = this._lockedRedraw.bind(this);
|
||||
this.body.emitter.on("initRedraw", this.viewFunction);
|
||||
}
|
||||
this.body.emitter.emit("animationFinished");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
getScale() {
|
||||
return this.body.view.scale;
|
||||
}
|
||||
|
||||
getViewPosition() {
|
||||
return this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default View;
|
||||
@@ -0,0 +1,500 @@
|
||||
var util = require('../../../util');
|
||||
|
||||
import Label from './shared/Label'
|
||||
import BezierEdgeDynamic from './edges/BezierEdgeDynamic'
|
||||
import BezierEdgeStatic from './edges/BezierEdgeStatic'
|
||||
import StraightEdge from './edges/StraightEdge'
|
||||
|
||||
/**
|
||||
* @class Edge
|
||||
*
|
||||
* A edge connects two nodes
|
||||
* @param {Object} properties Object with options. Must contain
|
||||
* At least options from and to.
|
||||
* Available options: from (number),
|
||||
* to (number), label (string, color (string),
|
||||
* width (number), style (string),
|
||||
* length (number), title (string)
|
||||
* @param {Network} network A Network object, used to find and edge to
|
||||
* nodes.
|
||||
* @param {Object} constants An object with default values for
|
||||
* example for the color
|
||||
*/
|
||||
class Edge {
|
||||
constructor(options, body, globalOptions) {
|
||||
if (body === undefined) {
|
||||
throw "No body provided";
|
||||
}
|
||||
this.options = util.bridgeObject(globalOptions);
|
||||
this.body = body;
|
||||
|
||||
// initialize variables
|
||||
this.id = undefined;
|
||||
this.fromId = undefined;
|
||||
this.toId = undefined;
|
||||
this.selected = false;
|
||||
this.hover = false;
|
||||
this.labelDirty = true;
|
||||
this.colorDirty = true;
|
||||
|
||||
this.baseWidth = this.options.width;
|
||||
this.baseFontSize = this.options.font.size;
|
||||
|
||||
this.from = undefined; // a node
|
||||
this.to = undefined; // a node
|
||||
|
||||
this.edgeType = undefined;
|
||||
|
||||
this.connected = false;
|
||||
|
||||
this.labelModule = new Label(this.body, this.options);
|
||||
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set or overwrite options for the edge
|
||||
* @param {Object} options an object with options
|
||||
* @param doNotEmit
|
||||
*/
|
||||
setOptions(options) {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
this.colorDirty = true;
|
||||
|
||||
Edge.parseOptions(this.options, options, true);
|
||||
|
||||
if (options.id !== undefined) {this.id = options.id;}
|
||||
if (options.from !== undefined) {this.fromId = options.from;}
|
||||
if (options.to !== undefined) {this.toId = options.to;}
|
||||
if (options.title !== undefined) {this.title = options.title;}
|
||||
if (options.value !== undefined) {options.value = parseFloat(options.value);}
|
||||
|
||||
|
||||
// update label Module
|
||||
this.updateLabelModule();
|
||||
|
||||
let dataChanged = this.updateEdgeType();
|
||||
|
||||
// if anything has been updates, reset the selection width and the hover width
|
||||
this._setInteractionWidths();
|
||||
|
||||
// A node is connected when it has a from and to node that both exist in the network.body.nodes.
|
||||
this.connect();
|
||||
|
||||
if (options.hidden !== undefined || options.physics !== undefined) {
|
||||
dataChanged = true;
|
||||
}
|
||||
|
||||
return dataChanged;
|
||||
}
|
||||
|
||||
static parseOptions(parentOptions, newOptions, allowDeletion = false) {
|
||||
var fields = [
|
||||
'id',
|
||||
'from',
|
||||
'hidden',
|
||||
'hoverWidth',
|
||||
'label',
|
||||
'labelHighlightBold',
|
||||
'length',
|
||||
'line',
|
||||
'opacity',
|
||||
'physics',
|
||||
'selectionWidth',
|
||||
'selfReferenceSize',
|
||||
'to',
|
||||
'title',
|
||||
'value',
|
||||
'width'
|
||||
];
|
||||
|
||||
// only deep extend the items in the field array. These do not have shorthand.
|
||||
util.selectiveDeepExtend(fields, parentOptions, newOptions, allowDeletion);
|
||||
|
||||
util.mergeOptions(parentOptions, newOptions, 'smooth');
|
||||
util.mergeOptions(parentOptions, newOptions, 'shadow');
|
||||
|
||||
if (newOptions.dashes !== undefined && newOptions.dashes !== null) {
|
||||
parentOptions.dashes = newOptions.dashes;
|
||||
}
|
||||
else if (allowDeletion === true && newOptions.dashes === null) {
|
||||
parentOptions.dashes = undefined;
|
||||
delete parentOptions.dashes;
|
||||
}
|
||||
|
||||
// set the scaling newOptions
|
||||
if (newOptions.scaling !== undefined && newOptions.scaling !== null) {
|
||||
if (newOptions.scaling.min !== undefined) {parentOptions.scaling.min = newOptions.scaling.min;}
|
||||
if (newOptions.scaling.max !== undefined) {parentOptions.scaling.max = newOptions.scaling.max;}
|
||||
util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label');
|
||||
}
|
||||
else if (allowDeletion === true && newOptions.scaling === null) {
|
||||
parentOptions.scaling = undefined;
|
||||
delete parentOptions.scaling;
|
||||
}
|
||||
|
||||
// hanlde multiple input cases for arrows
|
||||
if (newOptions.arrows !== undefined && newOptions.arrows !== null) {
|
||||
if (typeof newOptions.arrows === 'string') {
|
||||
let arrows = newOptions.arrows.toLowerCase();
|
||||
if (arrows.indexOf("to") != -1) {parentOptions.arrows.to.enabled = true;}
|
||||
if (arrows.indexOf("middle") != -1) {parentOptions.arrows.middle.enabled = true;}
|
||||
if (arrows.indexOf("from") != -1) {parentOptions.arrows.from.enabled = true;}
|
||||
}
|
||||
else if (typeof newOptions.arrows === 'object') {
|
||||
util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'to');
|
||||
util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'middle');
|
||||
util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'from');
|
||||
}
|
||||
else {
|
||||
throw new Error("The arrow newOptions can only be an object or a string. Refer to the documentation. You used:" + JSON.stringify(newOptions.arrows));
|
||||
}
|
||||
}
|
||||
else if (allowDeletion === true && newOptions.arrows === null) {
|
||||
parentOptions.arrows = undefined;
|
||||
delete parentOptions.arrows;
|
||||
}
|
||||
|
||||
// hanlde multiple input cases for color
|
||||
if (newOptions.color !== undefined && newOptions.color !== null) {
|
||||
if (util.isString(newOptions.color)) {
|
||||
parentOptions.color.color = newOptions.color;
|
||||
parentOptions.color.highlight = newOptions.color;
|
||||
parentOptions.color.hover = newOptions.color;
|
||||
parentOptions.color.inherit = false;
|
||||
}
|
||||
else {
|
||||
let colorsDefined = false;
|
||||
if (newOptions.color.color !== undefined) {parentOptions.color.color = newOptions.color.color; colorsDefined = true;}
|
||||
if (newOptions.color.highlight !== undefined) {parentOptions.color.highlight = newOptions.color.highlight; colorsDefined = true;}
|
||||
if (newOptions.color.hover !== undefined) {parentOptions.color.hover = newOptions.color.hover; colorsDefined = true;}
|
||||
if (newOptions.color.inherit !== undefined) {parentOptions.color.inherit = newOptions.color.inherit;}
|
||||
if (newOptions.color.opacity !== undefined) {parentOptions.color.opacity = Math.min(1,Math.max(0,newOptions.color.opacity));}
|
||||
|
||||
if (newOptions.color.inherit === undefined && colorsDefined === true) {
|
||||
parentOptions.color.inherit = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (allowDeletion === true && newOptions.color === null) {
|
||||
parentOptions.color = undefined;
|
||||
delete parentOptions.color;
|
||||
}
|
||||
|
||||
// handle the font settings
|
||||
if (newOptions.font !== undefined) {
|
||||
Label.parseOptions(parentOptions.font, newOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* update the options in the label module
|
||||
*/
|
||||
updateLabelModule() {
|
||||
this.labelModule.setOptions(this.options, true);
|
||||
if (this.labelModule.baseSize !== undefined) {
|
||||
this.baseFontSize = this.labelModule.baseSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* update the edge type, set the options
|
||||
* @returns {boolean}
|
||||
*/
|
||||
updateEdgeType() {
|
||||
let dataChanged = false;
|
||||
let changeInType = true;
|
||||
if (this.edgeType !== undefined) {
|
||||
if (this.edgeType instanceof BezierEdgeDynamic && this.options.smooth.enabled === true && this.options.smooth.type === 'dynamic') {changeInType = false;}
|
||||
if (this.edgeType instanceof BezierEdgeStatic && this.options.smooth.enabled === true && this.options.smooth.type !== 'dynamic') {changeInType = false;}
|
||||
if (this.edgeType instanceof StraightEdge && this.options.smooth.enabled === false) {changeInType = false;}
|
||||
|
||||
if (changeInType === true) {
|
||||
dataChanged = this.edgeType.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (changeInType === true) {
|
||||
if (this.options.smooth.enabled === true) {
|
||||
if (this.options.smooth.type === 'dynamic') {
|
||||
dataChanged = true;
|
||||
this.edgeType = new BezierEdgeDynamic(this.options, this.body, this.labelModule);
|
||||
}
|
||||
else {
|
||||
this.edgeType = new BezierEdgeStatic(this.options, this.body, this.labelModule);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.edgeType = new StraightEdge(this.options, this.body, this.labelModule);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// if nothing changes, we just set the options.
|
||||
this.edgeType.setOptions(this.options);
|
||||
}
|
||||
|
||||
return dataChanged;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable or disable the physics.
|
||||
* @param status
|
||||
*/
|
||||
togglePhysics(status) {
|
||||
this.options.physics = status;
|
||||
this.edgeType.togglePhysics(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect an edge to its nodes
|
||||
*/
|
||||
connect() {
|
||||
this.disconnect();
|
||||
|
||||
this.from = this.body.nodes[this.fromId] || undefined;
|
||||
this.to = this.body.nodes[this.toId] || undefined;
|
||||
this.connected = (this.from !== undefined && this.to !== undefined);
|
||||
|
||||
if (this.connected === true) {
|
||||
this.from.attachEdge(this);
|
||||
this.to.attachEdge(this);
|
||||
}
|
||||
else {
|
||||
if (this.from) {
|
||||
this.from.detachEdge(this);
|
||||
}
|
||||
if (this.to) {
|
||||
this.to.detachEdge(this);
|
||||
}
|
||||
}
|
||||
|
||||
this.edgeType.connect();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Disconnect an edge from its nodes
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.from) {
|
||||
this.from.detachEdge(this);
|
||||
this.from = undefined;
|
||||
}
|
||||
if (this.to) {
|
||||
this.to.detachEdge(this);
|
||||
this.to = undefined;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* get the title of this edge.
|
||||
* @return {string} title The title of the edge, or undefined when no title
|
||||
* has been set.
|
||||
*/
|
||||
getTitle() {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* check if this node is selecte
|
||||
* @return {boolean} selected True if node is selected, else false
|
||||
*/
|
||||
isSelected() {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the value of the edge. Can be undefined
|
||||
* @return {Number} value
|
||||
*/
|
||||
getValue() {
|
||||
return this.options.value;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adjust the value range of the edge. The edge will adjust it's width
|
||||
* based on its value.
|
||||
* @param {Number} min
|
||||
* @param {Number} max
|
||||
* @param total
|
||||
*/
|
||||
setValueRange(min, max, total) {
|
||||
if (this.options.value !== undefined) {
|
||||
var scale = this.options.scaling.customScalingFunction(min, max, total, this.options.value);
|
||||
var widthDiff = this.options.scaling.max - this.options.scaling.min;
|
||||
if (this.options.scaling.label.enabled === true) {
|
||||
var fontDiff = this.options.scaling.label.max - this.options.scaling.label.min;
|
||||
this.options.font.size = this.options.scaling.label.min + scale * fontDiff;
|
||||
}
|
||||
this.options.width = this.options.scaling.min + scale * widthDiff;
|
||||
}
|
||||
else {
|
||||
this.options.width = this.baseWidth;
|
||||
this.options.font.size = this.baseFontSize;
|
||||
}
|
||||
|
||||
this._setInteractionWidths();
|
||||
}
|
||||
|
||||
_setInteractionWidths() {
|
||||
if (typeof this.options.hoverWidth === 'function') {
|
||||
this.edgeType.hoverWidth = this.options.hoverWidth(this.options.width);
|
||||
}
|
||||
else {
|
||||
this.edgeType.hoverWidth = this.options.hoverWidth + this.options.width;
|
||||
}
|
||||
|
||||
if (typeof this.options.selectionWidth === 'function') {
|
||||
this.edgeType.selectionWidth = this.options.selectionWidth(this.options.width);
|
||||
}
|
||||
else {
|
||||
this.edgeType.selectionWidth = this.options.selectionWidth + this.options.width;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Redraw a edge
|
||||
* Draw this edge in the given canvas
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
let via = this.edgeType.drawLine(ctx, this.selected, this.hover);
|
||||
this.drawArrows(ctx, via);
|
||||
this.drawLabel (ctx, via);
|
||||
}
|
||||
|
||||
drawArrows(ctx, viaNode) {
|
||||
if (this.options.arrows.from.enabled === true) {this.edgeType.drawArrowHead(ctx,'from', viaNode, this.selected, this.hover);}
|
||||
if (this.options.arrows.middle.enabled === true) {this.edgeType.drawArrowHead(ctx,'middle', viaNode, this.selected, this.hover);}
|
||||
if (this.options.arrows.to.enabled === true) {this.edgeType.drawArrowHead(ctx,'to', viaNode, this.selected, this.hover);}
|
||||
}
|
||||
|
||||
drawLabel(ctx, viaNode) {
|
||||
if (this.options.label !== undefined) {
|
||||
// set style
|
||||
var node1 = this.from;
|
||||
var node2 = this.to;
|
||||
var selected = (this.from.selected || this.to.selected || this.selected);
|
||||
if (node1.id != node2.id) {
|
||||
this.labelModule.pointToSelf = false;
|
||||
var point = this.edgeType.getPoint(0.5, viaNode);
|
||||
ctx.save();
|
||||
|
||||
// if the label has to be rotated:
|
||||
if (this.options.font.align !== "horizontal") {
|
||||
this.labelModule.calculateLabelSize(ctx,selected,point.x,point.y);
|
||||
ctx.translate(point.x, this.labelModule.size.yLine);
|
||||
this._rotateForLabelAlignment(ctx);
|
||||
}
|
||||
|
||||
// draw the label
|
||||
this.labelModule.draw(ctx, point.x, point.y, selected);
|
||||
ctx.restore();
|
||||
}
|
||||
else {
|
||||
// Ignore the orientations.
|
||||
this.labelModule.pointToSelf = true;
|
||||
var x, y;
|
||||
var radius = this.options.selfReferenceSize;
|
||||
if (node1.shape.width > node1.shape.height) {
|
||||
x = node1.x + node1.shape.width * 0.5;
|
||||
y = node1.y - radius;
|
||||
}
|
||||
else {
|
||||
x = node1.x + radius;
|
||||
y = node1.y - node1.shape.height * 0.5;
|
||||
}
|
||||
point = this._pointOnCircle(x, y, radius, 0.125);
|
||||
this.labelModule.draw(ctx, point.x, point.y, selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if this object is overlapping with the provided object
|
||||
* @param {Object} obj an object with parameters left, top
|
||||
* @return {boolean} True if location is located on the edge
|
||||
*/
|
||||
isOverlappingWith(obj) {
|
||||
if (this.connected) {
|
||||
var distMax = 10;
|
||||
var xFrom = this.from.x;
|
||||
var yFrom = this.from.y;
|
||||
var xTo = this.to.x;
|
||||
var yTo = this.to.y;
|
||||
var xObj = obj.left;
|
||||
var yObj = obj.top;
|
||||
|
||||
var dist = this.edgeType.getDistanceToEdge(xFrom, yFrom, xTo, yTo, xObj, yObj);
|
||||
|
||||
return (dist < distMax);
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Rotates the canvas so the text is most readable
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_rotateForLabelAlignment(ctx) {
|
||||
var dy = this.from.y - this.to.y;
|
||||
var dx = this.from.x - this.to.x;
|
||||
var angleInDegrees = Math.atan2(dy, dx);
|
||||
|
||||
// rotate so label it is readable
|
||||
if ((angleInDegrees < -1 && dx < 0) || (angleInDegrees > 0 && dx < 0)) {
|
||||
angleInDegrees = angleInDegrees + Math.PI;
|
||||
}
|
||||
|
||||
ctx.rotate(angleInDegrees);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a point on a circle
|
||||
* @param {Number} x
|
||||
* @param {Number} y
|
||||
* @param {Number} radius
|
||||
* @param {Number} percentage. Value between 0 (line start) and 1 (line end)
|
||||
* @return {Object} point
|
||||
* @private
|
||||
*/
|
||||
_pointOnCircle(x, y, radius, percentage) {
|
||||
var angle = percentage * 2 * Math.PI;
|
||||
return {
|
||||
x: x + radius * Math.cos(angle),
|
||||
y: y - radius * Math.sin(angle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
select() {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
|
||||
unselect() {
|
||||
this.selected = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default Edge;
|
||||
@@ -0,0 +1,212 @@
|
||||
var util = require('../../../util');
|
||||
var Hammer = require('../../../module/hammer');
|
||||
var hammerUtil = require('../../../hammerUtil');
|
||||
var keycharm = require('keycharm');
|
||||
|
||||
class NavigationHandler {
|
||||
constructor(body, canvas) {
|
||||
this.body = body;
|
||||
this.canvas = canvas;
|
||||
|
||||
this.iconsCreated = false;
|
||||
this.navigationHammers = [];
|
||||
this.boundFunctions = {};
|
||||
this.touchTime = 0;
|
||||
this.activated = false;
|
||||
|
||||
|
||||
this.body.emitter.on("activate", () => {this.activated = true; this.configureKeyboardBindings();});
|
||||
this.body.emitter.on("deactivate", () => {this.activated = false; this.configureKeyboardBindings();});
|
||||
this.body.emitter.on("destroy", () => {if (this.keycharm !== undefined) {this.keycharm.destroy();}});
|
||||
|
||||
this.options = {}
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (options !== undefined) {
|
||||
this.options = options;
|
||||
this.create();
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
if (this.options.navigationButtons === true) {
|
||||
if (this.iconsCreated === false) {
|
||||
this.loadNavigationElements();
|
||||
}
|
||||
}
|
||||
else if (this.iconsCreated === true) {
|
||||
this.cleanNavigation();
|
||||
}
|
||||
|
||||
this.configureKeyboardBindings();
|
||||
}
|
||||
|
||||
cleanNavigation() {
|
||||
// clean hammer bindings
|
||||
if (this.navigationHammers.length != 0) {
|
||||
for (var i = 0; i < this.navigationHammers.length; i++) {
|
||||
this.navigationHammers[i].destroy();
|
||||
}
|
||||
this.navigationHammers = [];
|
||||
}
|
||||
|
||||
// clean up previous navigation items
|
||||
if (this.navigationDOM && this.navigationDOM['wrapper'] && this.navigationDOM['wrapper'].parentNode) {
|
||||
this.navigationDOM['wrapper'].parentNode.removeChild(this.navigationDOM['wrapper']);
|
||||
}
|
||||
|
||||
this.iconsCreated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation of the navigation controls nodes. They are drawn over the rest of the nodes and are not affected by scale and translation
|
||||
* they have a triggerFunction which is called on click. If the position of the navigation controls is dependent
|
||||
* on this.frame.canvas.clientWidth or this.frame.canvas.clientHeight, we flag horizontalAlignLeft and verticalAlignTop false.
|
||||
* This means that the location will be corrected by the _relocateNavigation function on a size change of the canvas.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
loadNavigationElements() {
|
||||
this.cleanNavigation();
|
||||
|
||||
this.navigationDOM = {};
|
||||
var navigationDivs = ['up','down','left','right','zoomIn','zoomOut','zoomExtends'];
|
||||
var navigationDivActions = ['_moveUp','_moveDown','_moveLeft','_moveRight','_zoomIn','_zoomOut','_fit'];
|
||||
|
||||
this.navigationDOM['wrapper'] = document.createElement('div');
|
||||
this.navigationDOM['wrapper'].className = 'vis-navigation';
|
||||
this.canvas.frame.appendChild(this.navigationDOM['wrapper']);
|
||||
|
||||
for (var i = 0; i < navigationDivs.length; i++) {
|
||||
this.navigationDOM[navigationDivs[i]] = document.createElement('div');
|
||||
this.navigationDOM[navigationDivs[i]].className = 'vis-button vis-' + navigationDivs[i];
|
||||
this.navigationDOM['wrapper'].appendChild(this.navigationDOM[navigationDivs[i]]);
|
||||
|
||||
var hammer = new Hammer(this.navigationDOM[navigationDivs[i]]);
|
||||
if (navigationDivActions[i] === "_fit") {
|
||||
hammerUtil.onTouch(hammer, this._fit.bind(this));
|
||||
}
|
||||
else {
|
||||
hammerUtil.onTouch(hammer, this.bindToRedraw.bind(this,navigationDivActions[i]));
|
||||
}
|
||||
|
||||
this.navigationHammers.push(hammer);
|
||||
}
|
||||
|
||||
// use a hammer for the release so we do not require the one used in the rest of the network
|
||||
// the one the rest uses can be overloaded by the manipulation system.
|
||||
var hammerFrame = new Hammer(this.canvas.frame);
|
||||
hammerUtil.onRelease(hammerFrame, () => {this._stopMovement();});
|
||||
this.navigationHammers.push(hammerFrame);
|
||||
|
||||
this.iconsCreated = true;
|
||||
}
|
||||
|
||||
bindToRedraw(action) {
|
||||
if (this.boundFunctions[action] === undefined) {
|
||||
this.boundFunctions[action] = this[action].bind(this);
|
||||
this.body.emitter.on("initRedraw", this.boundFunctions[action]);
|
||||
this.body.emitter.emit("_startRendering");
|
||||
}
|
||||
}
|
||||
|
||||
unbindFromRedraw(action) {
|
||||
if (this.boundFunctions[action] !== undefined) {
|
||||
this.body.emitter.off("initRedraw", this.boundFunctions[action]);
|
||||
this.body.emitter.emit("_stopRendering");
|
||||
delete this.boundFunctions[action];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this stops all movement induced by the navigation buttons
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_fit() {
|
||||
if (new Date().valueOf() - this.touchTime > 700) { // TODO: fix ugly hack to avoid hammer's double fireing of event (because we use release?)
|
||||
this.body.emitter.emit("fit", {duration: 700});
|
||||
this.touchTime = new Date().valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this stops all movement induced by the navigation buttons
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_stopMovement() {
|
||||
for (let boundAction in this.boundFunctions) {
|
||||
if (this.boundFunctions.hasOwnProperty(boundAction)) {
|
||||
this.body.emitter.off("initRedraw", this.boundFunctions[boundAction]);
|
||||
this.body.emitter.emit("_stopRendering");
|
||||
}
|
||||
}
|
||||
this.boundFunctions = {};
|
||||
}
|
||||
|
||||
_moveUp() {this.body.view.translation.y += this.options.keyboard.speed.y;}
|
||||
_moveDown() {this.body.view.translation.y -= this.options.keyboard.speed.y;}
|
||||
_moveLeft() {this.body.view.translation.x += this.options.keyboard.speed.x;}
|
||||
_moveRight(){this.body.view.translation.x -= this.options.keyboard.speed.x;}
|
||||
_zoomIn() {
|
||||
this.body.view.scale *= 1+this.options.keyboard.speed.zoom;
|
||||
this.body.emitter.emit('zoom', {direction: '+', scale: this.body.view.scale});}
|
||||
_zoomOut() {
|
||||
this.body.view.scale /= 1+this.options.keyboard.speed.zoom;
|
||||
this.body.emitter.emit('zoom', {direction: '-', scale: this.body.view.scale});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* bind all keys using keycharm.
|
||||
*/
|
||||
configureKeyboardBindings() {
|
||||
if (this.keycharm !== undefined) {
|
||||
this.keycharm.destroy();
|
||||
}
|
||||
|
||||
if (this.options.keyboard.enabled === true) {
|
||||
if (this.options.keyboard.bindToWindow === true) {
|
||||
this.keycharm = keycharm({container: window, preventDefault: true});
|
||||
}
|
||||
else {
|
||||
this.keycharm = keycharm({container: this.canvas.frame, preventDefault: true});
|
||||
}
|
||||
|
||||
this.keycharm.reset();
|
||||
|
||||
if (this.activated === true) {
|
||||
this.keycharm.bind("up", () => {this.bindToRedraw("_moveUp") ;}, "keydown");
|
||||
this.keycharm.bind("down", () => {this.bindToRedraw("_moveDown") ;}, "keydown");
|
||||
this.keycharm.bind("left", () => {this.bindToRedraw("_moveLeft") ;}, "keydown");
|
||||
this.keycharm.bind("right", () => {this.bindToRedraw("_moveRight");}, "keydown");
|
||||
this.keycharm.bind("=", () => {this.bindToRedraw("_zoomIn") ;}, "keydown");
|
||||
this.keycharm.bind("num+", () => {this.bindToRedraw("_zoomIn") ;}, "keydown");
|
||||
this.keycharm.bind("num-", () => {this.bindToRedraw("_zoomOut") ;}, "keydown");
|
||||
this.keycharm.bind("-", () => {this.bindToRedraw("_zoomOut") ;}, "keydown");
|
||||
this.keycharm.bind("[", () => {this.bindToRedraw("_zoomOut") ;}, "keydown");
|
||||
this.keycharm.bind("]", () => {this.bindToRedraw("_zoomIn") ;}, "keydown");
|
||||
this.keycharm.bind("pageup", () => {this.bindToRedraw("_zoomIn") ;}, "keydown");
|
||||
this.keycharm.bind("pagedown", () => {this.bindToRedraw("_zoomOut") ;}, "keydown");
|
||||
|
||||
this.keycharm.bind("up", () => {this.unbindFromRedraw("_moveUp") ;}, "keyup");
|
||||
this.keycharm.bind("down", () => {this.unbindFromRedraw("_moveDown") ;}, "keyup");
|
||||
this.keycharm.bind("left", () => {this.unbindFromRedraw("_moveLeft") ;}, "keyup");
|
||||
this.keycharm.bind("right", () => {this.unbindFromRedraw("_moveRight");}, "keyup");
|
||||
this.keycharm.bind("=", () => {this.unbindFromRedraw("_zoomIn") ;}, "keyup");
|
||||
this.keycharm.bind("num+", () => {this.unbindFromRedraw("_zoomIn") ;}, "keyup");
|
||||
this.keycharm.bind("num-", () => {this.unbindFromRedraw("_zoomOut") ;}, "keyup");
|
||||
this.keycharm.bind("-", () => {this.unbindFromRedraw("_zoomOut") ;}, "keyup");
|
||||
this.keycharm.bind("[", () => {this.unbindFromRedraw("_zoomOut") ;}, "keyup");
|
||||
this.keycharm.bind("]", () => {this.unbindFromRedraw("_zoomIn") ;}, "keyup");
|
||||
this.keycharm.bind("pageup", () => {this.unbindFromRedraw("_zoomIn") ;}, "keyup");
|
||||
this.keycharm.bind("pagedown", () => {this.unbindFromRedraw("_zoomOut") ;}, "keyup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default NavigationHandler;
|
||||
@@ -0,0 +1,446 @@
|
||||
var util = require('../../../util');
|
||||
|
||||
import Label from './shared/Label'
|
||||
|
||||
import Box from './nodes/shapes/Box'
|
||||
import Circle from './nodes/shapes/Circle'
|
||||
import CircularImage from './nodes/shapes/CircularImage'
|
||||
import Database from './nodes/shapes/Database'
|
||||
import Diamond from './nodes/shapes/Diamond'
|
||||
import Dot from './nodes/shapes/Dot'
|
||||
import Ellipse from './nodes/shapes/Ellipse'
|
||||
import Icon from './nodes/shapes/Icon'
|
||||
import Image from './nodes/shapes/Image'
|
||||
import Square from './nodes/shapes/Square'
|
||||
import Star from './nodes/shapes/Star'
|
||||
import Text from './nodes/shapes/Text'
|
||||
import Triangle from './nodes/shapes/Triangle'
|
||||
import TriangleDown from './nodes/shapes/TriangleDown'
|
||||
import Validator from "../../../shared/Validator";
|
||||
import {printStyle} from "../../../shared/Validator";
|
||||
|
||||
|
||||
/**
|
||||
* @class Node
|
||||
* A node. A node can be connected to other nodes via one or multiple edges.
|
||||
* @param {object} options An object containing options for the node. All
|
||||
* options are optional, except for the id.
|
||||
* {number} id Id of the node. Required
|
||||
* {string} label Text label for the node
|
||||
* {number} x Horizontal position of the node
|
||||
* {number} y Vertical position of the node
|
||||
* {string} shape Node shape, available:
|
||||
* "database", "circle", "ellipse",
|
||||
* "box", "image", "text", "dot",
|
||||
* "star", "triangle", "triangleDown",
|
||||
* "square", "icon"
|
||||
* {string} image An image url
|
||||
* {string} title An title text, can be HTML
|
||||
* {anytype} group A group name or number
|
||||
* @param {Network.Images} imagelist A list with images. Only needed
|
||||
* when the node has an image
|
||||
* @param {Network.Groups} grouplist A list with groups. Needed for
|
||||
* retrieving group options
|
||||
* @param {Object} constants An object with default values for
|
||||
* example for the color
|
||||
*
|
||||
*/
|
||||
class Node {
|
||||
constructor(options, body, imagelist, grouplist, globalOptions) {
|
||||
this.options = util.bridgeObject(globalOptions);
|
||||
this.body = body;
|
||||
|
||||
this.edges = []; // all edges connected to this node
|
||||
|
||||
// set defaults for the options
|
||||
this.id = undefined;
|
||||
this.imagelist = imagelist;
|
||||
this.grouplist = grouplist;
|
||||
|
||||
// state options
|
||||
this.x = undefined;
|
||||
this.y = undefined;
|
||||
this.baseSize = this.options.size;
|
||||
this.baseFontSize = this.options.font.size;
|
||||
this.predefinedPosition = false; // used to check if initial fit should just take the range or approximate
|
||||
this.selected = false;
|
||||
this.hover = false;
|
||||
|
||||
this.labelModule = new Label(this.body, this.options);
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attach a edge to the node
|
||||
* @param {Edge} edge
|
||||
*/
|
||||
attachEdge(edge) {
|
||||
if (this.edges.indexOf(edge) === -1) {
|
||||
this.edges.push(edge);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detach a edge from the node
|
||||
* @param {Edge} edge
|
||||
*/
|
||||
detachEdge(edge) {
|
||||
var index = this.edges.indexOf(edge);
|
||||
if (index != -1) {
|
||||
this.edges.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the physics.
|
||||
* @param status
|
||||
*/
|
||||
togglePhysics(status) {
|
||||
this.options.physics = status;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set or overwrite options for the node
|
||||
* @param {Object} options an object with options
|
||||
* @param {Object} constants and object with default, global options
|
||||
*/
|
||||
setOptions(options) {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
// basic options
|
||||
if (options.id !== undefined) {this.id = options.id;}
|
||||
|
||||
if (this.id === undefined) {
|
||||
throw "Node must have an id";
|
||||
}
|
||||
|
||||
|
||||
// set these options locally
|
||||
// clear x and y positions
|
||||
if (options.x !== undefined) {
|
||||
if (options.x === null) {this.x = undefined; this.predefinedPosition = false;}
|
||||
else {this.x = parseInt(options.x); this.predefinedPosition = true;}
|
||||
}
|
||||
if (options.y !== undefined) {
|
||||
if (options.y === null) {this.y = undefined; this.predefinedPosition = false;}
|
||||
else {this.y = parseInt(options.y); this.predefinedPosition = true;}
|
||||
}
|
||||
if (options.size !== undefined) {this.baseSize = options.size;}
|
||||
if (options.value !== undefined) {options.value = parseFloat(options.value);}
|
||||
|
||||
|
||||
// copy group options
|
||||
if (typeof options.group === 'number' || (typeof options.group === 'string' && options.group != '')) {
|
||||
var groupObj = this.grouplist.get(options.group);
|
||||
util.deepExtend(this.options, groupObj);
|
||||
// the color object needs to be completely defined. Since groups can partially overwrite the colors, we parse it again, just in case.
|
||||
this.options.color = util.parseColor(this.options.color);
|
||||
}
|
||||
|
||||
// this transforms all shorthands into fully defined options
|
||||
Node.parseOptions(this.options, options, true);
|
||||
|
||||
// load the images
|
||||
if (this.options.image !== undefined) {
|
||||
if (this.imagelist) {
|
||||
this.imageObj = this.imagelist.load(this.options.image, this.options.brokenImage, this.id);
|
||||
}
|
||||
else {
|
||||
throw "No imagelist provided";
|
||||
}
|
||||
}
|
||||
|
||||
this.updateShape();
|
||||
this.updateLabelModule();
|
||||
|
||||
// reset the size of the node, this can be changed
|
||||
this._reset();
|
||||
|
||||
if (options.hidden !== undefined || options.physics !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This process all possible shorthands in the new options and makes sure that the parentOptions are fully defined.
|
||||
* Static so it can also be used by the handler.
|
||||
* @param parentOptions
|
||||
* @param newOptions
|
||||
*/
|
||||
static parseOptions(parentOptions, newOptions, allowDeletion = false) {
|
||||
var fields = [
|
||||
'color',
|
||||
'font',
|
||||
'fixed',
|
||||
'shadow'
|
||||
];
|
||||
util.selectiveNotDeepExtend(fields, parentOptions, newOptions, allowDeletion);
|
||||
|
||||
// merge the shadow options into the parent.
|
||||
util.mergeOptions(parentOptions, newOptions, 'shadow');
|
||||
|
||||
// individual shape newOptions
|
||||
if (newOptions.color !== undefined && newOptions.color !== null) {
|
||||
let parsedColor = util.parseColor(newOptions.color);
|
||||
util.fillIfDefined(parentOptions.color, parsedColor);
|
||||
}
|
||||
else if (allowDeletion === true && newOptions.color === null) {
|
||||
parentOptions.color = undefined;
|
||||
delete parentOptions.color;
|
||||
}
|
||||
|
||||
// handle the fixed options
|
||||
if (newOptions.fixed !== undefined && newOptions.fixed !== null) {
|
||||
if (typeof newOptions.fixed === 'boolean') {
|
||||
parentOptions.fixed.x = newOptions.fixed;
|
||||
parentOptions.fixed.y = newOptions.fixed;
|
||||
}
|
||||
else {
|
||||
if (newOptions.fixed.x !== undefined && typeof newOptions.fixed.x === 'boolean') {
|
||||
parentOptions.fixed.x = newOptions.fixed.x;
|
||||
}
|
||||
if (newOptions.fixed.y !== undefined && typeof newOptions.fixed.y === 'boolean') {
|
||||
parentOptions.fixed.y = newOptions.fixed.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle the font options
|
||||
if (newOptions.font !== undefined) {
|
||||
Label.parseOptions(parentOptions.font, newOptions);
|
||||
}
|
||||
|
||||
// handle the scaling options, specifically the label part
|
||||
if (newOptions.scaling !== undefined) {
|
||||
util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label');
|
||||
}
|
||||
}
|
||||
|
||||
updateLabelModule() {
|
||||
if (this.options.label === undefined || this.options.label === null) {
|
||||
this.options.label = '';
|
||||
}
|
||||
this.labelModule.setOptions(this.options, true);
|
||||
if (this.labelModule.baseSize !== undefined) {
|
||||
this.baseFontSize = this.labelModule.baseSize;
|
||||
}
|
||||
}
|
||||
|
||||
updateShape() {
|
||||
// choose draw method depending on the shape
|
||||
switch (this.options.shape) {
|
||||
case 'box':
|
||||
this.shape = new Box(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'circle':
|
||||
this.shape = new Circle(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'circularImage':
|
||||
this.shape = new CircularImage(this.options, this.body, this.labelModule, this.imageObj);
|
||||
break;
|
||||
case 'database':
|
||||
this.shape = new Database(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'diamond':
|
||||
this.shape = new Diamond(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'dot':
|
||||
this.shape = new Dot(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'ellipse':
|
||||
this.shape = new Ellipse(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'icon':
|
||||
this.shape = new Icon(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'image':
|
||||
this.shape = new Image(this.options, this.body, this.labelModule, this.imageObj);
|
||||
break;
|
||||
case 'square':
|
||||
this.shape = new Square(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'star':
|
||||
this.shape = new Star(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'text':
|
||||
this.shape = new Text(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'triangle':
|
||||
this.shape = new Triangle(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
case 'triangleDown':
|
||||
this.shape = new TriangleDown(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
default:
|
||||
this.shape = new Ellipse(this.options, this.body, this.labelModule);
|
||||
break;
|
||||
}
|
||||
this._reset();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* select this node
|
||||
*/
|
||||
select() {
|
||||
this.selected = true;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* unselect this node
|
||||
*/
|
||||
unselect() {
|
||||
this.selected = false;
|
||||
this._reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reset the calculated size of the node, forces it to recalculate its size
|
||||
* @private
|
||||
*/
|
||||
_reset() {
|
||||
this.shape.width = undefined;
|
||||
this.shape.height = undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* get the title of this node.
|
||||
* @return {string} title The title of the node, or undefined when no title
|
||||
* has been set.
|
||||
*/
|
||||
getTitle() {
|
||||
return this.options.title;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the distance to the border of the Node
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {Number} angle Angle in radians
|
||||
* @returns {number} distance Distance to the border in pixels
|
||||
*/
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this.shape.distanceToBorder(ctx,angle);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if this node has a fixed x and y position
|
||||
* @return {boolean} true if fixed, false if not
|
||||
*/
|
||||
isFixed() {
|
||||
return (this.options.fixed.x && this.options.fixed.y);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* check if this node is selecte
|
||||
* @return {boolean} selected True if node is selected, else false
|
||||
*/
|
||||
isSelected() {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the value of the node. Can be undefined
|
||||
* @return {Number} value
|
||||
*/
|
||||
getValue() {
|
||||
return this.options.value;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adjust the value range of the node. The node will adjust it's size
|
||||
* based on its value.
|
||||
* @param {Number} min
|
||||
* @param {Number} max
|
||||
*/
|
||||
setValueRange(min, max, total) {
|
||||
if (this.options.value !== undefined) {
|
||||
var scale = this.options.scaling.customScalingFunction(min, max, total, this.options.value);
|
||||
var sizeDiff = this.options.scaling.max - this.options.scaling.min;
|
||||
if (this.options.scaling.label.enabled === true) {
|
||||
var fontDiff = this.options.scaling.label.max - this.options.scaling.label.min;
|
||||
this.options.font.size = this.options.scaling.label.min + scale * fontDiff;
|
||||
}
|
||||
this.options.size = this.options.scaling.min + scale * sizeDiff;
|
||||
}
|
||||
else {
|
||||
this.options.size = this.baseSize;
|
||||
this.options.font.size = this.baseFontSize;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Draw this node in the given canvas
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
this.shape.draw(ctx, this.x, this.y, this.selected, this.hover);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the bounding box of the shape
|
||||
*/
|
||||
updateBoundingBox(ctx) {
|
||||
this.shape.updateBoundingBox(this.x,this.y,ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the size of this node in the given canvas
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
resize(ctx) {
|
||||
this.shape.resize(ctx, this.selected);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if this object is overlapping with the provided object
|
||||
* @param {Object} obj an object with parameters left, top, right, bottom
|
||||
* @return {boolean} True if location is located on node
|
||||
*/
|
||||
isOverlappingWith(obj) {
|
||||
return (
|
||||
this.shape.left < obj.right &&
|
||||
this.shape.left + this.shape.width > obj.left &&
|
||||
this.shape.top < obj.bottom &&
|
||||
this.shape.top + this.shape.height > obj.top
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this object is overlapping with the provided object
|
||||
* @param {Object} obj an object with parameters left, top, right, bottom
|
||||
* @return {boolean} True if location is located on node
|
||||
*/
|
||||
isBoundingBoxOverlappingWith(obj) {
|
||||
return (
|
||||
this.shape.boundingBox.left < obj.right &&
|
||||
this.shape.boundingBox.right > obj.left &&
|
||||
this.shape.boundingBox.top < obj.bottom &&
|
||||
this.shape.boundingBox.bottom > obj.top
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default Node;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Popup is a class to create a popup window with some text
|
||||
* @param {Element} container The container object.
|
||||
* @param {Number} [x]
|
||||
* @param {Number} [y]
|
||||
* @param {String} [text]
|
||||
* @param {Object} [style] An object containing borderColor,
|
||||
* backgroundColor, etc.
|
||||
*/
|
||||
class Popup {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.padding = 5;
|
||||
this.hidden = false;
|
||||
|
||||
// create the frame
|
||||
this.frame = document.createElement('div');
|
||||
this.frame.className = 'vis-network-tooltip';
|
||||
this.container.appendChild(this.frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x Horizontal position of the popup window
|
||||
* @param {number} y Vertical position of the popup window
|
||||
*/
|
||||
setPosition(x, y) {
|
||||
this.x = parseInt(x);
|
||||
this.y = parseInt(y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content for the popup window. This can be HTML code or text.
|
||||
* @param {string | Element} content
|
||||
*/
|
||||
setText(content) {
|
||||
if (content instanceof Element) {
|
||||
this.frame.innerHTML = '';
|
||||
this.frame.appendChild(content);
|
||||
}
|
||||
else {
|
||||
this.frame.innerHTML = content; // string containing text or HTML
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the popup window
|
||||
* @param {boolean} [doShow] Show or hide the window
|
||||
*/
|
||||
show(doShow) {
|
||||
if (doShow === undefined) {
|
||||
doShow = true;
|
||||
}
|
||||
|
||||
if (doShow === true) {
|
||||
var height = this.frame.clientHeight;
|
||||
var width = this.frame.clientWidth;
|
||||
var maxHeight = this.frame.parentNode.clientHeight;
|
||||
var maxWidth = this.frame.parentNode.clientWidth;
|
||||
|
||||
var top = (this.y - height);
|
||||
if (top + height + this.padding > maxHeight) {
|
||||
top = maxHeight - height - this.padding;
|
||||
}
|
||||
if (top < this.padding) {
|
||||
top = this.padding;
|
||||
}
|
||||
|
||||
var left = this.x;
|
||||
if (left + width + this.padding > maxWidth) {
|
||||
left = maxWidth - width - this.padding;
|
||||
}
|
||||
if (left < this.padding) {
|
||||
left = this.padding;
|
||||
}
|
||||
|
||||
this.frame.style.left = left + "px";
|
||||
this.frame.style.top = top + "px";
|
||||
this.frame.style.visibility = "visible";
|
||||
this.hidden = false;
|
||||
}
|
||||
else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the popup window
|
||||
*/
|
||||
hide() {
|
||||
this.hidden = true;
|
||||
this.frame.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
export default Popup;
|
||||
@@ -0,0 +1,126 @@
|
||||
import BezierEdgeBase from './util/BezierEdgeBase'
|
||||
|
||||
class BezierEdgeDynamic extends BezierEdgeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
//this.via = undefined; // Here for completeness but not allowed to defined before super() is invoked.
|
||||
super(options, body, labelModule); // --> this calls the setOptions below
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
this.id = this.options.id;
|
||||
this.setupSupportNode();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.from = this.body.nodes[this.options.from];
|
||||
this.to = this.body.nodes[this.options.to];
|
||||
if (this.from === undefined || this.to === undefined || this.options.physics === false) {
|
||||
this.via.setOptions({physics:false})
|
||||
}
|
||||
else {
|
||||
// fix weird behaviour where a selfreferencing node has physics enabled
|
||||
if (this.from.id === this.to.id) {
|
||||
this.via.setOptions({physics: false})
|
||||
}
|
||||
else {
|
||||
this.via.setOptions({physics: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.via !== undefined) {
|
||||
delete this.body.nodes[this.via.id];
|
||||
this.via = undefined;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
togglePhysics(status) {
|
||||
this.via.setOptions({physics:status});
|
||||
this.positionBezierNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bezier curves require an anchor point to calculate the smooth flow. These points are nodes. These nodes are invisible but
|
||||
* are used for the force calculation.
|
||||
*
|
||||
* The changed data is not called, if needed, it is returned by the main edge constructor.
|
||||
* @private
|
||||
*/
|
||||
setupSupportNode() {
|
||||
if (this.via === undefined) {
|
||||
var nodeId = "edgeId:" + this.id;
|
||||
var node = this.body.functions.createNode({
|
||||
id: nodeId,
|
||||
shape: 'circle',
|
||||
physics:true,
|
||||
hidden:true
|
||||
});
|
||||
this.body.nodes[nodeId] = node;
|
||||
this.via = node;
|
||||
this.via.parentEdgeId = this.id;
|
||||
this.positionBezierNode();
|
||||
}
|
||||
}
|
||||
|
||||
positionBezierNode() {
|
||||
if (this.via !== undefined && this.from !== undefined && this.to !== undefined) {
|
||||
this.via.x = 0.5 * (this.from.x + this.to.x);
|
||||
this.via.y = 0.5 * (this.from.y + this.to.y);
|
||||
}
|
||||
else if (this.via !== undefined) {
|
||||
this.via.x = 0;
|
||||
this.via.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line between two nodes
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_line(ctx) {
|
||||
// draw a straight line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.from.x, this.from.y);
|
||||
ctx.quadraticCurveTo(this.via.x, this.via.y, this.to.x, this.to.y);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.stroke();
|
||||
this.disableShadow(ctx);
|
||||
return this.via;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way
|
||||
* @param percentage
|
||||
* @param via
|
||||
* @returns {{x: number, y: number}}
|
||||
* @private
|
||||
*/
|
||||
getPoint(percentage) {
|
||||
let t = percentage;
|
||||
let x = Math.pow(1 - t, 2) * this.from.x + (2 * t * (1 - t)) * this.via.x + Math.pow(t, 2) * this.to.x;
|
||||
let y = Math.pow(1 - t, 2) * this.from.y + (2 * t * (1 - t)) * this.via.y + Math.pow(t, 2) * this.to.y;
|
||||
|
||||
return {x: x, y: y};
|
||||
}
|
||||
|
||||
_findBorderPosition(nearNode, ctx) {
|
||||
return this._findBorderPositionBezier(nearNode, ctx, this.via);
|
||||
}
|
||||
|
||||
_getDistanceToEdge(x1, y1, x2, y2, x3, y3) { // x3,y3 is the point
|
||||
return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, this.via);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default BezierEdgeDynamic;
|
||||
@@ -0,0 +1,238 @@
|
||||
import BezierEdgeBase from './util/BezierEdgeBase'
|
||||
|
||||
class BezierEdgeStatic extends BezierEdgeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line between two nodes
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_line(ctx) {
|
||||
// draw a straight line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.from.x, this.from.y);
|
||||
let via = this._getViaCoordinates();
|
||||
let returnValue = via;
|
||||
|
||||
// fallback to normal straight edges
|
||||
if (via.x === undefined) {
|
||||
ctx.lineTo(this.to.x, this.to.y);
|
||||
returnValue = undefined;
|
||||
}
|
||||
else {
|
||||
ctx.quadraticCurveTo(via.x, via.y, this.to.x, this.to.y);
|
||||
}
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.stroke();
|
||||
this.disableShadow(ctx);
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
_getViaCoordinates() {
|
||||
let xVia = undefined;
|
||||
let yVia = undefined;
|
||||
let factor = this.options.smooth.roundness;
|
||||
let type = this.options.smooth.type;
|
||||
let dx = Math.abs(this.from.x - this.to.x);
|
||||
let dy = Math.abs(this.from.y - this.to.y);
|
||||
if (type === 'discrete' || type === 'diagonalCross') {
|
||||
if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) {
|
||||
if (this.from.y >= this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dy;
|
||||
yVia = this.from.y - factor * dy;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dy;
|
||||
yVia = this.from.y - factor * dy;
|
||||
}
|
||||
}
|
||||
else if (this.from.y < this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dy;
|
||||
yVia = this.from.y + factor * dy;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dy;
|
||||
yVia = this.from.y + factor * dy;
|
||||
}
|
||||
}
|
||||
if (type === "discrete") {
|
||||
xVia = dx < factor * dy ? this.from.x : xVia;
|
||||
}
|
||||
}
|
||||
else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) {
|
||||
if (this.from.y >= this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dx;
|
||||
yVia = this.from.y - factor * dx;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dx;
|
||||
yVia = this.from.y - factor * dx;
|
||||
}
|
||||
}
|
||||
else if (this.from.y < this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dx;
|
||||
yVia = this.from.y + factor * dx;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dx;
|
||||
yVia = this.from.y + factor * dx;
|
||||
}
|
||||
}
|
||||
if (type === "discrete") {
|
||||
yVia = dy < factor * dx ? this.from.y : yVia;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (type === "straightCross") {
|
||||
if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) { // up - down
|
||||
xVia = this.from.x;
|
||||
if (this.from.y < this.to.y) {
|
||||
yVia = this.to.y - (1 - factor) * dy;
|
||||
}
|
||||
else {
|
||||
yVia = this.to.y + (1 - factor) * dy;
|
||||
}
|
||||
}
|
||||
else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) { // left - right
|
||||
if (this.from.x < this.to.x) {
|
||||
xVia = this.to.x - (1 - factor) * dx;
|
||||
}
|
||||
else {
|
||||
xVia = this.to.x + (1 - factor) * dx;
|
||||
}
|
||||
yVia = this.from.y;
|
||||
}
|
||||
}
|
||||
else if (type === 'horizontal') {
|
||||
if (this.from.x < this.to.x) {
|
||||
xVia = this.to.x - (1 - factor) * dx;
|
||||
}
|
||||
else {
|
||||
xVia = this.to.x + (1 - factor) * dx;
|
||||
}
|
||||
yVia = this.from.y;
|
||||
}
|
||||
else if (type === 'vertical') {
|
||||
xVia = this.from.x;
|
||||
if (this.from.y < this.to.y) {
|
||||
yVia = this.to.y - (1 - factor) * dy;
|
||||
}
|
||||
else {
|
||||
yVia = this.to.y + (1 - factor) * dy;
|
||||
}
|
||||
}
|
||||
else if (type === 'curvedCW') {
|
||||
dx = this.to.x - this.from.x;
|
||||
dy = this.from.y - this.to.y;
|
||||
let radius = Math.sqrt(dx * dx + dy * dy);
|
||||
let pi = Math.PI;
|
||||
|
||||
let originalAngle = Math.atan2(dy, dx);
|
||||
let myAngle = (originalAngle + ((factor * 0.5) + 0.5) * pi) % (2 * pi);
|
||||
|
||||
xVia = this.from.x + (factor * 0.5 + 0.5) * radius * Math.sin(myAngle);
|
||||
yVia = this.from.y + (factor * 0.5 + 0.5) * radius * Math.cos(myAngle);
|
||||
}
|
||||
else if (type === 'curvedCCW') {
|
||||
dx = this.to.x - this.from.x;
|
||||
dy = this.from.y - this.to.y;
|
||||
let radius = Math.sqrt(dx * dx + dy * dy);
|
||||
let pi = Math.PI;
|
||||
|
||||
let originalAngle = Math.atan2(dy, dx);
|
||||
let myAngle = (originalAngle + ((-factor * 0.5) + 0.5) * pi) % (2 * pi);
|
||||
|
||||
xVia = this.from.x + (factor * 0.5 + 0.5) * radius * Math.sin(myAngle);
|
||||
yVia = this.from.y + (factor * 0.5 + 0.5) * radius * Math.cos(myAngle);
|
||||
}
|
||||
else { // continuous
|
||||
if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) {
|
||||
if (this.from.y >= this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dy;
|
||||
yVia = this.from.y - factor * dy;
|
||||
xVia = this.to.x < xVia ? this.to.x : xVia;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dy;
|
||||
yVia = this.from.y - factor * dy;
|
||||
xVia = this.to.x > xVia ? this.to.x : xVia;
|
||||
}
|
||||
}
|
||||
else if (this.from.y < this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dy;
|
||||
yVia = this.from.y + factor * dy;
|
||||
xVia = this.to.x < xVia ? this.to.x : xVia;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dy;
|
||||
yVia = this.from.y + factor * dy;
|
||||
xVia = this.to.x > xVia ? this.to.x : xVia;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) {
|
||||
if (this.from.y >= this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dx;
|
||||
yVia = this.from.y - factor * dx;
|
||||
yVia = this.to.y > yVia ? this.to.y : yVia;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dx;
|
||||
yVia = this.from.y - factor * dx;
|
||||
yVia = this.to.y > yVia ? this.to.y : yVia;
|
||||
}
|
||||
}
|
||||
else if (this.from.y < this.to.y) {
|
||||
if (this.from.x <= this.to.x) {
|
||||
xVia = this.from.x + factor * dx;
|
||||
yVia = this.from.y + factor * dx;
|
||||
yVia = this.to.y < yVia ? this.to.y : yVia;
|
||||
}
|
||||
else if (this.from.x > this.to.x) {
|
||||
xVia = this.from.x - factor * dx;
|
||||
yVia = this.from.y + factor * dx;
|
||||
yVia = this.to.y < yVia ? this.to.y : yVia;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {x: xVia, y: yVia};
|
||||
}
|
||||
|
||||
_findBorderPosition(nearNode, ctx, options = {}) {
|
||||
return this._findBorderPositionBezier(nearNode, ctx, options.via);
|
||||
}
|
||||
|
||||
_getDistanceToEdge(x1, y1, x2, y2, x3, y3, via = this._getViaCoordinates()) { // x3,y3 is the point
|
||||
return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way
|
||||
* @param percentage
|
||||
* @param via
|
||||
* @returns {{x: number, y: number}}
|
||||
* @private
|
||||
*/
|
||||
getPoint(percentage, via = this._getViaCoordinates()) {
|
||||
var t = percentage;
|
||||
var x = Math.pow(1 - t, 2) * this.from.x + (2 * t * (1 - t)) * via.x + Math.pow(t, 2) * this.to.x;
|
||||
var y = Math.pow(1 - t, 2) * this.from.y + (2 * t * (1 - t)) * via.y + Math.pow(t, 2) * this.to.y;
|
||||
|
||||
return {x: x, y: y};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default BezierEdgeStatic;
|
||||
@@ -0,0 +1,67 @@
|
||||
import EdgeBase from './util/EdgeBase'
|
||||
|
||||
class StraightEdge extends EdgeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line between two nodes
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_line(ctx) {
|
||||
// draw a straight line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.from.x, this.from.y);
|
||||
ctx.lineTo(this.to.x, this.to.y);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.stroke();
|
||||
this.disableShadow(ctx);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way
|
||||
* @param percentage
|
||||
* @param via
|
||||
* @returns {{x: number, y: number}}
|
||||
* @private
|
||||
*/
|
||||
getPoint(percentage) {
|
||||
return {
|
||||
x: (1 - percentage) * this.from.x + percentage * this.to.x,
|
||||
y: (1 - percentage) * this.from.y + percentage * this.to.y
|
||||
}
|
||||
}
|
||||
|
||||
_findBorderPosition(nearNode, ctx) {
|
||||
let node1 = this.to;
|
||||
let node2 = this.from;
|
||||
if (nearNode.id === this.from.id) {
|
||||
node1 = this.from;
|
||||
node2 = this.to;
|
||||
}
|
||||
|
||||
let angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x));
|
||||
let dx = (node1.x - node2.x);
|
||||
let dy = (node1.y - node2.y);
|
||||
let edgeSegmentLength = Math.sqrt(dx * dx + dy * dy);
|
||||
let toBorderDist = nearNode.distanceToBorder(ctx, angle);
|
||||
let toBorderPoint = (edgeSegmentLength - toBorderDist) / edgeSegmentLength;
|
||||
|
||||
let borderPos = {};
|
||||
borderPos.x = (1 - toBorderPoint) * node2.x + toBorderPoint * node1.x;
|
||||
borderPos.y = (1 - toBorderPoint) * node2.y + toBorderPoint * node1.y;
|
||||
|
||||
return borderPos;
|
||||
}
|
||||
|
||||
_getDistanceToEdge(x1, y1, x2, y2, x3, y3) { // x3,y3 is the point
|
||||
return this._getDistanceToLine(x1, y1, x2, y2, x3, y3);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default StraightEdge;
|
||||
@@ -0,0 +1,109 @@
|
||||
import EdgeBase from './EdgeBase'
|
||||
|
||||
class BezierEdgeBase extends EdgeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function uses binary search to look for the point where the bezier curve crosses the border of the node.
|
||||
*
|
||||
* @param nearNode
|
||||
* @param ctx
|
||||
* @param viaNode
|
||||
* @param nearNode
|
||||
* @param ctx
|
||||
* @param viaNode
|
||||
* @param nearNode
|
||||
* @param ctx
|
||||
* @param viaNode
|
||||
*/
|
||||
_findBorderPositionBezier(nearNode, ctx, viaNode = this._getViaCoordinates()) {
|
||||
var maxIterations = 10;
|
||||
var iteration = 0;
|
||||
var low = 0;
|
||||
var high = 1;
|
||||
var pos, angle, distanceToBorder, distanceToPoint, difference;
|
||||
var threshold = 0.2;
|
||||
var node = this.to;
|
||||
var from = false;
|
||||
if (nearNode.id === this.from.id) {
|
||||
node = this.from;
|
||||
from = true;
|
||||
}
|
||||
|
||||
while (low <= high && iteration < maxIterations) {
|
||||
var middle = (low + high) * 0.5;
|
||||
|
||||
pos = this.getPoint(middle, viaNode);
|
||||
angle = Math.atan2((node.y - pos.y), (node.x - pos.x));
|
||||
distanceToBorder = node.distanceToBorder(ctx, angle);
|
||||
distanceToPoint = Math.sqrt(Math.pow(pos.x - node.x, 2) + Math.pow(pos.y - node.y, 2));
|
||||
difference = distanceToBorder - distanceToPoint;
|
||||
if (Math.abs(difference) < threshold) {
|
||||
break; // found
|
||||
}
|
||||
else if (difference < 0) { // distance to nodes is larger than distance to border --> t needs to be bigger if we're looking at the to node.
|
||||
if (from === false) {
|
||||
low = middle;
|
||||
}
|
||||
else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (from === false) {
|
||||
high = middle;
|
||||
}
|
||||
else {
|
||||
low = middle;
|
||||
}
|
||||
}
|
||||
|
||||
iteration++;
|
||||
}
|
||||
pos.t = middle;
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the distance between a point (x3,y3) and a line segment from
|
||||
* (x1,y1) to (x2,y2).
|
||||
* http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
* @param {number} x3
|
||||
* @param {number} y3
|
||||
* @private
|
||||
*/
|
||||
_getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via) { // x3,y3 is the point
|
||||
let xVia, yVia;
|
||||
xVia = via.x;
|
||||
yVia = via.y;
|
||||
let minDistance = 1e9;
|
||||
let distance;
|
||||
let i, t, x, y;
|
||||
let lastX = x1;
|
||||
let lastY = y1;
|
||||
for (i = 1; i < 10; i++) {
|
||||
t = 0.1 * i;
|
||||
x = Math.pow(1 - t, 2) * x1 + (2 * t * (1 - t)) * xVia + Math.pow(t, 2) * x2;
|
||||
y = Math.pow(1 - t, 2) * y1 + (2 * t * (1 - t)) * yVia + Math.pow(t, 2) * y2;
|
||||
if (i > 0) {
|
||||
distance = this._getDistanceToLine(lastX, lastY, x, y, x3, y3);
|
||||
minDistance = distance < minDistance ? distance : minDistance;
|
||||
}
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
}
|
||||
|
||||
return minDistance;
|
||||
}
|
||||
}
|
||||
|
||||
export default BezierEdgeBase;
|
||||
@@ -0,0 +1,531 @@
|
||||
let util = require("../../../../../util");
|
||||
|
||||
class EdgeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
this.body = body;
|
||||
this.labelModule = labelModule;
|
||||
this.setOptions(options);
|
||||
this.colorDirty = true;
|
||||
this.color = {};
|
||||
this.selectionWidth = 2;
|
||||
this.hoverWidth = 1.5;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.from = this.body.nodes[this.options.from];
|
||||
this.to = this.body.nodes[this.options.to];
|
||||
}
|
||||
cleanup() {return false}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
this.from = this.body.nodes[this.options.from];
|
||||
this.to = this.body.nodes[this.options.to];
|
||||
this.id = this.options.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* overloadable if the shape has to toggle the via node to disabled
|
||||
* @param status
|
||||
*/
|
||||
togglePhysics(status) {}
|
||||
|
||||
/**
|
||||
* Redraw a edge as a line
|
||||
* Draw this edge in the given canvas
|
||||
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
drawLine(ctx, selected, hover) {
|
||||
// set style
|
||||
ctx.strokeStyle = this.getColor(ctx, selected, hover);
|
||||
ctx.lineWidth = this.getLineWidth(selected, hover);
|
||||
let via = undefined;
|
||||
if (this.options.dashes !== false) {
|
||||
via = this._drawDashedLine(ctx);
|
||||
}
|
||||
else {
|
||||
via = this._drawLine(ctx);
|
||||
}
|
||||
return via;
|
||||
}
|
||||
|
||||
|
||||
_drawLine(ctx) {
|
||||
let via = undefined;
|
||||
if (this.from != this.to) {
|
||||
// draw line
|
||||
via = this._line(ctx);
|
||||
}
|
||||
else {
|
||||
let [x,y,radius] = this._getCircleData(ctx);
|
||||
this._circle(ctx, x, y, radius);
|
||||
}
|
||||
return via;
|
||||
}
|
||||
|
||||
_drawDashedLine(ctx) {
|
||||
let via = undefined;
|
||||
ctx.lineCap = 'round';
|
||||
let pattern = [5,5];
|
||||
if (Array.isArray(this.options.dashes) === true) {
|
||||
pattern = this.options.dashes;
|
||||
}
|
||||
|
||||
// only firefox and chrome support this method, else we use the legacy one.
|
||||
if (ctx.setLineDash !== undefined) {
|
||||
ctx.save();
|
||||
|
||||
// set dash settings for chrome or firefox
|
||||
ctx.setLineDash(pattern);
|
||||
ctx.lineDashOffset = 0;
|
||||
|
||||
// draw the line
|
||||
if (this.from != this.to) {
|
||||
// draw line
|
||||
via = this._line(ctx);
|
||||
}
|
||||
else {
|
||||
let [x,y,radius] = this._getCircleData(ctx);
|
||||
this._circle(ctx, x, y, radius);
|
||||
}
|
||||
|
||||
// restore the dash settings.
|
||||
ctx.setLineDash([0]);
|
||||
ctx.lineDashOffset = 0;
|
||||
ctx.restore();
|
||||
}
|
||||
else { // unsupporting smooth lines
|
||||
|
||||
|
||||
if (this.from != this.to) {
|
||||
// draw line
|
||||
ctx.dashedLine(this.from.x, this.from.y, this.to.x, this.to.y, pattern);
|
||||
}
|
||||
else {
|
||||
let [x,y,radius] = this._getCircleData(ctx);
|
||||
this._circle(ctx, x, y, radius);
|
||||
}
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
}
|
||||
return via;
|
||||
}
|
||||
|
||||
|
||||
findBorderPosition(nearNode, ctx, options) {
|
||||
if (this.from != this.to) {
|
||||
return this._findBorderPosition(nearNode, ctx, options);
|
||||
}
|
||||
else {
|
||||
return this._findBorderPositionCircle(nearNode, ctx, options);
|
||||
}
|
||||
}
|
||||
|
||||
findBorderPositions(ctx) {
|
||||
let from = {};
|
||||
let to = {};
|
||||
if (this.from != this.to) {
|
||||
from = this._findBorderPosition(this.from, ctx);
|
||||
to = this._findBorderPosition(this.to, ctx);
|
||||
}
|
||||
else {
|
||||
let [x,y,radius] = this._getCircleData(ctx);
|
||||
|
||||
from = this._findBorderPositionCircle(this.from, ctx, {x, y, low:0.25, high:0.6, direction:-1});
|
||||
to = this._findBorderPositionCircle(this.from, ctx, {x, y, low:0.6, high:0.8, direction:1});
|
||||
}
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
_getCircleData(ctx) {
|
||||
let x, y;
|
||||
let node = this.from;
|
||||
let radius = this.options.selfReferenceSize;
|
||||
|
||||
if (ctx !== undefined) {
|
||||
if (node.shape.width === undefined) {
|
||||
node.shape.resize(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// get circle coordinates
|
||||
if (node.shape.width > node.shape.height) {
|
||||
x = node.x + node.shape.width * 0.5;
|
||||
y = node.y - radius;
|
||||
}
|
||||
else {
|
||||
x = node.x + radius;
|
||||
y = node.y - node.shape.height * 0.5;
|
||||
}
|
||||
return [x,y,radius];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a point on a circle
|
||||
* @param {Number} x
|
||||
* @param {Number} y
|
||||
* @param {Number} radius
|
||||
* @param {Number} percentage. Value between 0 (line start) and 1 (line end)
|
||||
* @return {Object} point
|
||||
* @private
|
||||
*/
|
||||
_pointOnCircle(x, y, radius, percentage) {
|
||||
let angle = percentage * 2 * Math.PI;
|
||||
return {
|
||||
x: x + radius * Math.cos(angle),
|
||||
y: y - radius * Math.sin(angle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function uses binary search to look for the point where the circle crosses the border of the node.
|
||||
* @param node
|
||||
* @param ctx
|
||||
* @param options
|
||||
* @returns {*}
|
||||
* @private
|
||||
*/
|
||||
_findBorderPositionCircle(node, ctx, options) {
|
||||
let x = options.x;
|
||||
let y = options.y;
|
||||
let low = options.low;
|
||||
let high = options.high;
|
||||
let direction = options.direction;
|
||||
|
||||
let maxIterations = 10;
|
||||
let iteration = 0;
|
||||
let radius = this.options.selfReferenceSize;
|
||||
let pos, angle, distanceToBorder, distanceToPoint, difference;
|
||||
let threshold = 0.05;
|
||||
let middle = (low + high) * 0.5;
|
||||
|
||||
while (low <= high && iteration < maxIterations) {
|
||||
middle = (low + high) * 0.5;
|
||||
|
||||
pos = this._pointOnCircle(x, y, radius, middle);
|
||||
angle = Math.atan2((node.y - pos.y), (node.x - pos.x));
|
||||
distanceToBorder = node.distanceToBorder(ctx, angle);
|
||||
distanceToPoint = Math.sqrt(Math.pow(pos.x - node.x, 2) + Math.pow(pos.y - node.y, 2));
|
||||
difference = distanceToBorder - distanceToPoint;
|
||||
if (Math.abs(difference) < threshold) {
|
||||
break; // found
|
||||
}
|
||||
else if (difference > 0) { // distance to nodes is larger than distance to border --> t needs to be bigger if we're looking at the to node.
|
||||
if (direction > 0) {
|
||||
low = middle;
|
||||
}
|
||||
else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (direction > 0) {
|
||||
high = middle;
|
||||
}
|
||||
else {
|
||||
low = middle;
|
||||
}
|
||||
}
|
||||
iteration++;
|
||||
|
||||
}
|
||||
pos.t = middle;
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the line width of the edge. Depends on width and whether one of the
|
||||
* connected nodes is selected.
|
||||
* @return {Number} width
|
||||
* @private
|
||||
*/
|
||||
getLineWidth(selected, hover) {
|
||||
if (selected === true) {
|
||||
return Math.max(this.selectionWidth, 0.3 / this.body.view.scale);
|
||||
}
|
||||
else {
|
||||
if (hover === true) {
|
||||
return Math.max(this.hoverWidth, 0.3 / this.body.view.scale);
|
||||
}
|
||||
else {
|
||||
return Math.max(this.options.width, 0.3 / this.body.view.scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getColor(ctx, selected, hover) {
|
||||
let colorOptions = this.options.color;
|
||||
if (colorOptions.inherit !== false) {
|
||||
// when this is a loop edge, just use the 'from' method
|
||||
if (colorOptions.inherit === 'both' && this.from.id !== this.to.id) {
|
||||
let grd = ctx.createLinearGradient(this.from.x, this.from.y, this.to.x, this.to.y);
|
||||
let fromColor, toColor;
|
||||
fromColor = this.from.options.color.highlight.border;
|
||||
toColor = this.to.options.color.highlight.border;
|
||||
|
||||
if (this.from.selected === false && this.to.selected === false) {
|
||||
fromColor = util.overrideOpacity(this.from.options.color.border, this.options.color.opacity);
|
||||
toColor = util.overrideOpacity(this.to.options.color.border, this.options.color.opacity);
|
||||
}
|
||||
else if (this.from.selected === true && this.to.selected === false) {
|
||||
toColor = this.to.options.color.border;
|
||||
}
|
||||
else if (this.from.selected === false && this.to.selected === true) {
|
||||
fromColor = this.from.options.color.border;
|
||||
}
|
||||
grd.addColorStop(0, fromColor);
|
||||
grd.addColorStop(1, toColor);
|
||||
|
||||
// -------------------- this returns -------------------- //
|
||||
return grd;
|
||||
}
|
||||
|
||||
if (this.colorDirty === true) {
|
||||
if (colorOptions.inherit === "to") {
|
||||
this.color.highlight = this.to.options.color.highlight.border;
|
||||
this.color.hover = this.to.options.color.hover.border;
|
||||
this.color.color = util.overrideOpacity(this.to.options.color.border, colorOptions.opacity);
|
||||
}
|
||||
else { // (this.options.color.inherit.source === "from") {
|
||||
this.color.highlight = this.from.options.color.highlight.border;
|
||||
this.color.hover = this.from.options.color.hover.border;
|
||||
this.color.color = util.overrideOpacity(this.from.options.color.border, colorOptions.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (this.colorDirty === true) {
|
||||
this.color.highlight = colorOptions.highlight;
|
||||
this.color.hover = colorOptions.hover;
|
||||
this.color.color = util.overrideOpacity(colorOptions.color, colorOptions.opacity);
|
||||
}
|
||||
|
||||
// if color inherit is on and gradients are used, the function has already returned by now.
|
||||
this.colorDirty = false;
|
||||
|
||||
|
||||
if (selected === true) {
|
||||
return this.color.highlight;
|
||||
}
|
||||
else if (hover === true) {
|
||||
return this.color.hover;
|
||||
}
|
||||
else {
|
||||
return this.color.color;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line from a node to itself, a circle
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {Number} x
|
||||
* @param {Number} y
|
||||
* @param {Number} radius
|
||||
* @private
|
||||
*/
|
||||
_circle(ctx, x, y, radius) {
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
|
||||
// draw a circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
|
||||
ctx.stroke();
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the distance between a point (x3,y3) and a line segment from
|
||||
* (x1,y1) to (x2,y2).
|
||||
* http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
* @param {number} x3
|
||||
* @param {number} y3
|
||||
* @private
|
||||
*/
|
||||
getDistanceToEdge(x1, y1, x2, y2, x3, y3, via) { // x3,y3 is the point
|
||||
let returnValue = 0;
|
||||
if (this.from != this.to) {
|
||||
returnValue = this._getDistanceToEdge(x1, y1, x2, y2, x3, y3, via)
|
||||
}
|
||||
else {
|
||||
let [x,y,radius] = this._getCircleData();
|
||||
let dx = x - x3;
|
||||
let dy = y - y3;
|
||||
returnValue = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius);
|
||||
}
|
||||
|
||||
if (this.labelModule.size.left < x3 &&
|
||||
this.labelModule.size.left + this.labelModule.size.width > x3 &&
|
||||
this.labelModule.size.top < y3 &&
|
||||
this.labelModule.size.top + this.labelModule.size.height > y3) {
|
||||
return 0;
|
||||
}
|
||||
else {
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
|
||||
_getDistanceToLine(x1, y1, x2, y2, x3, y3) {
|
||||
let px = x2 - x1;
|
||||
let py = y2 - y1;
|
||||
let something = px * px + py * py;
|
||||
let u = ((x3 - x1) * px + (y3 - y1) * py) / something;
|
||||
|
||||
if (u > 1) {
|
||||
u = 1;
|
||||
}
|
||||
else if (u < 0) {
|
||||
u = 0;
|
||||
}
|
||||
|
||||
let x = x1 + u * px;
|
||||
let y = y1 + u * py;
|
||||
let dx = x - x3;
|
||||
let dy = y - y3;
|
||||
|
||||
//# Note: If the actual distance does not matter,
|
||||
//# if you only want to compare what this function
|
||||
//# returns to other results of this function, you
|
||||
//# can just return the squared distance instead
|
||||
//# (i.e. remove the sqrt) to gain a little performance
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ctx
|
||||
* @param position
|
||||
* @param viaNode
|
||||
*/
|
||||
drawArrowHead(ctx, position, viaNode, selected, hover) {
|
||||
// set style
|
||||
ctx.strokeStyle = this.getColor(ctx, selected, hover);
|
||||
ctx.fillStyle = ctx.strokeStyle;
|
||||
ctx.lineWidth = this.getLineWidth(selected, hover);
|
||||
|
||||
// set lets
|
||||
let angle;
|
||||
let length;
|
||||
let arrowPos;
|
||||
let node1;
|
||||
let node2;
|
||||
let guideOffset;
|
||||
let scaleFactor;
|
||||
|
||||
if (position === 'from') {
|
||||
node1 = this.from;
|
||||
node2 = this.to;
|
||||
guideOffset = 0.1;
|
||||
scaleFactor = this.options.arrows.from.scaleFactor;
|
||||
}
|
||||
else if (position === 'to') {
|
||||
node1 = this.to;
|
||||
node2 = this.from;
|
||||
guideOffset = -0.1;
|
||||
scaleFactor = this.options.arrows.to.scaleFactor;
|
||||
}
|
||||
else {
|
||||
node1 = this.to;
|
||||
node2 = this.from;
|
||||
scaleFactor = this.options.arrows.middle.scaleFactor;
|
||||
}
|
||||
|
||||
// if not connected to itself
|
||||
if (node1 != node2) {
|
||||
if (position !== 'middle') {
|
||||
// draw arrow head
|
||||
if (this.options.smooth.enabled === true) {
|
||||
arrowPos = this.findBorderPosition(node1, ctx, {via: viaNode});
|
||||
let guidePos = this.getPoint(Math.max(0.0, Math.min(1.0, arrowPos.t + guideOffset)), viaNode);
|
||||
angle = Math.atan2((arrowPos.y - guidePos.y), (arrowPos.x - guidePos.x));
|
||||
}
|
||||
else {
|
||||
angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x));
|
||||
arrowPos = this.findBorderPosition(node1, ctx);
|
||||
}
|
||||
}
|
||||
else {
|
||||
angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x));
|
||||
arrowPos = this.getPoint(0.6, viaNode); // this is 0.6 to account for the size of the arrow.
|
||||
}
|
||||
// draw arrow at the end of the line
|
||||
length = (10 + 5 * this.options.width) * scaleFactor;
|
||||
ctx.arrow(arrowPos.x, arrowPos.y, angle, length);
|
||||
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
ctx.stroke();
|
||||
}
|
||||
else {
|
||||
// draw circle
|
||||
let angle, point;
|
||||
let [x,y,radius] = this._getCircleData(ctx);
|
||||
|
||||
if (position === 'from') {
|
||||
point = this.findBorderPosition(this.from, ctx, {x, y, low:0.25, high:0.6, direction:-1});
|
||||
angle = point.t * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI;
|
||||
}
|
||||
else if (position === 'to') {
|
||||
point = this.findBorderPosition(this.from, ctx, {x, y, low:0.6, high:1.0, direction:1});
|
||||
angle = point.t * -2 * Math.PI + 1.5 * Math.PI - 1.1 * Math.PI;
|
||||
}
|
||||
else {
|
||||
point = this._pointOnCircle(x, y, radius, 0.175);
|
||||
angle = 3.9269908169872414; // === 0.175 * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI;
|
||||
}
|
||||
|
||||
// draw the arrowhead
|
||||
let length = (10 + 5 * this.options.width) * scaleFactor;
|
||||
ctx.arrow(point.x, point.y, angle, length);
|
||||
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enableShadow(ctx) {
|
||||
if (this.options.shadow.enabled === true) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = this.options.shadow.size;
|
||||
ctx.shadowOffsetX = this.options.shadow.x;
|
||||
ctx.shadowOffsetY = this.options.shadow.y;
|
||||
}
|
||||
}
|
||||
|
||||
disableShadow(ctx) {
|
||||
if (this.options.shadow.enabled === true) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0)';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EdgeBase;
|
||||
@@ -0,0 +1,16 @@
|
||||
import Node from '../Node'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class Cluster extends Node {
|
||||
constructor(options, body, imagelist, grouplist, globalOptions) {
|
||||
super(options, body, imagelist, grouplist, globalOptions);
|
||||
|
||||
this.isCluster = true;
|
||||
this.containedNodes = {};
|
||||
this.containedEdges = {};
|
||||
}
|
||||
}
|
||||
|
||||
export default Cluster;
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class Box extends NodeBase {
|
||||
constructor (options, body, labelModule) {
|
||||
super(options,body,labelModule);
|
||||
}
|
||||
|
||||
resize(ctx, selected) {
|
||||
if (this.width === undefined) {
|
||||
let margin = 5;
|
||||
let textSize = this.labelModule.getTextSize(ctx,selected);
|
||||
this.width = textSize.width + 2 * margin;
|
||||
this.height = textSize.height + 2 * margin;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx, selected);
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
let borderWidth = this.options.borderWidth;
|
||||
let selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth;
|
||||
|
||||
ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border;
|
||||
ctx.lineWidth = (selected ? selectionLineWidth : borderWidth);
|
||||
ctx.lineWidth /= this.body.view.scale;
|
||||
ctx.lineWidth = Math.min(this.width, ctx.lineWidth);
|
||||
|
||||
ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background;
|
||||
|
||||
let borderRadius = 6;
|
||||
ctx.roundRect(this.left, this.top, this.width, this.height, borderRadius);
|
||||
|
||||
//draw dashed border if enabled
|
||||
this.enableBorderDashes(ctx);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
//disable dashed border for other elements
|
||||
this.disableBorderDashes(ctx);
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
this.updateBoundingBox(x,y);
|
||||
this.labelModule.draw(ctx, x, y, selected);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.left = x - this.width * 0.5;
|
||||
this.top = y - this.height * 0.5;
|
||||
|
||||
this.boundingBox.left = this.left;
|
||||
this.boundingBox.top = this.top;
|
||||
this.boundingBox.bottom = this.top + this.height;
|
||||
this.boundingBox.right = this.left + this.width;
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
let a = this.width / 2;
|
||||
let b = this.height / 2;
|
||||
let w = (Math.sin(angle) * a);
|
||||
let h = (Math.cos(angle) * b);
|
||||
return a * b / Math.sqrt(w * w + h * h);
|
||||
}
|
||||
}
|
||||
|
||||
export default Box;
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
import CircleImageBase from '../util/CircleImageBase'
|
||||
|
||||
class Circle extends CircleImageBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx, selected) {
|
||||
if (this.width === undefined) {
|
||||
var margin = 5;
|
||||
var textSize = this.labelModule.getTextSize(ctx, selected);
|
||||
var diameter = Math.max(textSize.width, textSize.height) + 2 * margin;
|
||||
this.options.size = diameter / 2;
|
||||
|
||||
this.width = diameter;
|
||||
this.height = diameter;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx, selected);
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
this._drawRawCircle(ctx, x, y, selected, hover, this.options.size);
|
||||
|
||||
this.boundingBox.top = y - this.options.size;
|
||||
this.boundingBox.left = x - this.options.size;
|
||||
this.boundingBox.right = x + this.options.size;
|
||||
this.boundingBox.bottom = y + this.options.size;
|
||||
|
||||
this.updateBoundingBox(x,y);
|
||||
this.labelModule.draw(ctx, x, y, selected);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.boundingBox.top = y - this.options.size;
|
||||
this.boundingBox.left = x - this.options.size;
|
||||
this.boundingBox.right = x + this.options.size;
|
||||
this.boundingBox.bottom = y + this.options.size;
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
var a = this.width / 2;
|
||||
var b = this.height / 2;
|
||||
var w = (Math.sin(angle) * a);
|
||||
var h = (Math.cos(angle) * b);
|
||||
return a * b / Math.sqrt(w * w + h * h);
|
||||
}
|
||||
}
|
||||
|
||||
export default Circle;
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
|
||||
import CircleImageBase from '../util/CircleImageBase'
|
||||
|
||||
class CircularImage extends CircleImageBase {
|
||||
constructor (options, body, labelModule, imageObj) {
|
||||
super(options, body, labelModule);
|
||||
this.imageObj = imageObj;
|
||||
this._swapToImageResizeWhenImageLoaded = true;
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.imageObj.src === undefined || this.imageObj.width === undefined || this.imageObj.height === undefined ) {
|
||||
if (!this.width) {
|
||||
var diameter = this.options.size * 2;
|
||||
this.width = diameter;
|
||||
this.height = diameter;
|
||||
this._swapToImageResizeWhenImageLoaded = true;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this._swapToImageResizeWhenImageLoaded) {
|
||||
this.width = undefined;
|
||||
this.height = undefined;
|
||||
this._swapToImageResizeWhenImageLoaded = false;
|
||||
}
|
||||
this._resizeImage();
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize();
|
||||
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
let size = Math.min(0.5*this.height, 0.5*this.width);
|
||||
|
||||
this._drawRawCircle(ctx, x, y, selected, hover, size);
|
||||
|
||||
ctx.save();
|
||||
ctx.circle(x, y, size);
|
||||
ctx.stroke();
|
||||
ctx.clip();
|
||||
|
||||
this._drawImageAtPosition(ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
this._drawImageLabel(ctx, x, y, selected);
|
||||
|
||||
this.updateBoundingBox(x,y);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.boundingBox.top = y - this.options.size;
|
||||
this.boundingBox.left = x - this.options.size;
|
||||
this.boundingBox.right = x + this.options.size;
|
||||
this.boundingBox.bottom = y + this.options.size;
|
||||
this.boundingBox.left = Math.min(this.boundingBox.left, this.labelModule.size.left);
|
||||
this.boundingBox.right = Math.max(this.boundingBox.right, this.labelModule.size.left + this.labelModule.size.width);
|
||||
this.boundingBox.bottom = Math.max(this.boundingBox.bottom, this.boundingBox.bottom + this.labelOffset);
|
||||
}
|
||||
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default CircularImage;
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class Database extends NodeBase {
|
||||
constructor (options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
resize(ctx, selected) {
|
||||
if (this.width === undefined) {
|
||||
var margin = 5;
|
||||
var textSize = this.labelModule.getTextSize(ctx, selected);
|
||||
var size = textSize.width + 2 * margin;
|
||||
this.width = size;
|
||||
this.height = size;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx, selected);
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
var borderWidth = this.options.borderWidth;
|
||||
var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth;
|
||||
|
||||
ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border;
|
||||
ctx.lineWidth = (this.selected ? selectionLineWidth : borderWidth);
|
||||
ctx.lineWidth *= this.networkScaleInv;
|
||||
ctx.lineWidth = Math.min(this.width, ctx.lineWidth);
|
||||
|
||||
ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background;
|
||||
ctx.database(x - this.width / 2, y - this.height * 0.5, this.width, this.height);
|
||||
|
||||
//draw dashed border if enabled
|
||||
this.enableBorderDashes(ctx);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
//disable dashed border for other elements
|
||||
this.disableBorderDashes(ctx);
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
this.updateBoundingBox(x,y,ctx,selected);
|
||||
|
||||
this.labelModule.draw(ctx, x, y, selected);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y,ctx, selected) {
|
||||
this.resize(ctx, selected);
|
||||
|
||||
this.left = x - this.width * 0.5;
|
||||
this.top = y - this.height * 0.5;
|
||||
|
||||
this.boundingBox.left = this.left;
|
||||
this.boundingBox.top = this.top;
|
||||
this.boundingBox.bottom = this.top + this.height;
|
||||
this.boundingBox.right = this.left + this.width;
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
var a = this.width / 2;
|
||||
var b = this.height / 2;
|
||||
var w = (Math.sin(angle) * a);
|
||||
var h = (Math.cos(angle) * b);
|
||||
return a * b / Math.sqrt(w * w + h * h);
|
||||
}
|
||||
}
|
||||
|
||||
export default Database;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class Diamond extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'diamond', 4, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Diamond;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class Dot extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'circle', 2, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this.options.size + this.options.borderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export default Dot;
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class Ellipse extends NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
resize(ctx, selected) {
|
||||
if (this.width === undefined) {
|
||||
var textSize = this.labelModule.getTextSize(ctx, selected);
|
||||
|
||||
this.width = textSize.width * 1.5;
|
||||
this.height = textSize.height * 2;
|
||||
if (this.width < this.height) {
|
||||
this.width = this.height;
|
||||
}
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx, selected);
|
||||
this.left = x - this.width * 0.5;
|
||||
this.top = y - this.height * 0.5;
|
||||
|
||||
var borderWidth = this.options.borderWidth;
|
||||
var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth;
|
||||
|
||||
ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border;
|
||||
|
||||
ctx.lineWidth = (selected ? selectionLineWidth : borderWidth);
|
||||
ctx.lineWidth /= this.body.view.scale;
|
||||
ctx.lineWidth = Math.min(this.width, ctx.lineWidth);
|
||||
|
||||
ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background;
|
||||
ctx.ellipse(this.left, this.top, this.width, this.height);
|
||||
|
||||
//draw dashed border if enabled
|
||||
this.enableBorderDashes(ctx);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
//disable dashed border for other elements
|
||||
this.disableBorderDashes(ctx);
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
this.updateBoundingBox(x, y, ctx, selected);
|
||||
this.labelModule.draw(ctx, x, y, selected);
|
||||
}
|
||||
|
||||
updateBoundingBox(x, y, ctx, selected) {
|
||||
this.resize(ctx, selected); // just in case
|
||||
|
||||
this.left = x - this.width * 0.5;
|
||||
this.top = y - this.height * 0.5;
|
||||
|
||||
this.boundingBox.left = this.left;
|
||||
this.boundingBox.top = this.top;
|
||||
this.boundingBox.bottom = this.top + this.height;
|
||||
this.boundingBox.right = this.left + this.width;
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
var a = this.width * 0.5;
|
||||
var b = this.height * 0.5;
|
||||
var w = (Math.sin(angle) * a);
|
||||
var h = (Math.cos(angle) * b);
|
||||
return a * b / Math.sqrt(w * w + h * h);
|
||||
}
|
||||
}
|
||||
|
||||
export default Ellipse;
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class Icon extends NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
if (this.width === undefined) {
|
||||
var margin = 5;
|
||||
var iconSize = {
|
||||
width: Number(this.options.icon.size),
|
||||
height: Number(this.options.icon.size)
|
||||
};
|
||||
this.width = iconSize.width + 2 * margin;
|
||||
this.height = iconSize.height + 2 * margin;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx);
|
||||
this.options.icon.size = this.options.icon.size || 50;
|
||||
|
||||
this.left = x - this.width * 0.5;
|
||||
this.top = y - this.height * 0.5;
|
||||
this._icon(ctx, x, y, selected);
|
||||
|
||||
if (this.options.label !== undefined) {
|
||||
var iconTextSpacing = 5;
|
||||
this.labelModule.draw(ctx, x, y + this.height * 0.5 + iconTextSpacing, selected);
|
||||
}
|
||||
|
||||
this.updateBoundingBox(x,y)
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.boundingBox.top = y - this.options.icon.size * 0.5;
|
||||
this.boundingBox.left = x - this.options.icon.size * 0.5;
|
||||
this.boundingBox.right = x + this.options.icon.size * 0.5;
|
||||
this.boundingBox.bottom = y + this.options.icon.size * 0.5;
|
||||
|
||||
if (this.options.label !== undefined && this.labelModule.size.width > 0) {
|
||||
var iconTextSpacing = 5;
|
||||
this.boundingBox.left = Math.min(this.boundingBox.left, this.labelModule.size.left);
|
||||
this.boundingBox.right = Math.max(this.boundingBox.right, this.labelModule.size.left + this.labelModule.size.width);
|
||||
this.boundingBox.bottom = Math.max(this.boundingBox.bottom, this.boundingBox.bottom + this.labelModule.size.height + iconTextSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
_icon(ctx, x, y, selected) {
|
||||
let iconSize = Number(this.options.icon.size);
|
||||
|
||||
if (this.options.icon.code !== undefined) {
|
||||
ctx.font = (selected ? "bold " : "") + iconSize + "px " + this.options.icon.face;
|
||||
|
||||
// draw icon
|
||||
ctx.fillStyle = this.options.icon.color || "black";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fillText(this.options.icon.code, x, y);
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
}
|
||||
else {
|
||||
console.error('When using the icon shape, you need to define the code in the icon options object. This can be done per node or globally.')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Icon;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
import CircleImageBase from '../util/CircleImageBase'
|
||||
|
||||
class Image extends CircleImageBase {
|
||||
constructor (options, body, labelModule, imageObj) {
|
||||
super(options, body, labelModule);
|
||||
this.imageObj = imageObj;
|
||||
}
|
||||
|
||||
resize() {
|
||||
this._resizeImage();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize();
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
this._drawImageAtPosition(ctx);
|
||||
|
||||
this._drawImageLabel(ctx, x, y, selected || hover);
|
||||
|
||||
this.updateBoundingBox(x,y);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.resize();
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
this.boundingBox.top = this.top;
|
||||
this.boundingBox.left = this.left;
|
||||
this.boundingBox.right = this.left + this.width;
|
||||
this.boundingBox.bottom = this.top + this.height;
|
||||
|
||||
if (this.options.label !== undefined && this.labelModule.size.width > 0) {
|
||||
this.boundingBox.left = Math.min(this.boundingBox.left, this.labelModule.size.left);
|
||||
this.boundingBox.right = Math.max(this.boundingBox.right, this.labelModule.size.left + this.labelModule.size.width);
|
||||
this.boundingBox.bottom = Math.max(this.boundingBox.bottom, this.boundingBox.bottom + this.labelOffset);
|
||||
}
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
var a = this.width / 2;
|
||||
var b = this.height / 2;
|
||||
var w = (Math.sin(angle) * a);
|
||||
var h = (Math.cos(angle) * b);
|
||||
return a * b / Math.sqrt(w * w + h * h);
|
||||
}
|
||||
}
|
||||
|
||||
export default Image;
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class Square extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize() {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'square', 2, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize();
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Square;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class Star extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'star', 4, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Star;
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class Text extends NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
}
|
||||
|
||||
resize(ctx, selected) {
|
||||
if (this.width === undefined) {
|
||||
var margin = 5;
|
||||
var textSize = this.labelModule.getTextSize(ctx,selected);
|
||||
this.width = textSize.width + 2 * margin;
|
||||
this.height = textSize.height + 2 * margin;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this.resize(ctx, selected || hover);
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
this.labelModule.draw(ctx, x, y, selected || hover);
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
this.updateBoundingBox(x, y, ctx, selected);
|
||||
}
|
||||
|
||||
updateBoundingBox(x, y, ctx, selected) {
|
||||
this.resize(ctx, selected);
|
||||
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
this.boundingBox.top = this.top;
|
||||
this.boundingBox.left = this.left;
|
||||
this.boundingBox.right = this.left + this.width;
|
||||
this.boundingBox.bottom = this.top + this.height;
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
this.resize(ctx);
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Text;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class Triangle extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'triangle', 3, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default Triangle;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
import ShapeBase from '../util/ShapeBase'
|
||||
|
||||
class TriangleDown extends ShapeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
resize(ctx) {
|
||||
this._resizeShape();
|
||||
}
|
||||
|
||||
draw(ctx, x, y, selected, hover) {
|
||||
this._drawShape(ctx, 'triangleDown', 3, x, y, selected, hover);
|
||||
}
|
||||
|
||||
distanceToBorder(ctx, angle) {
|
||||
return this._distanceToBorder(angle);
|
||||
}
|
||||
}
|
||||
|
||||
export default TriangleDown;
|
||||
@@ -0,0 +1,115 @@
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class CircleImageBase extends NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule);
|
||||
this.labelOffset = 0;
|
||||
this.imageLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function resizes the image by the options size when the image has not yet loaded. If the image has loaded, we
|
||||
* force the update of the size again.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_resizeImage() {
|
||||
let force = false;
|
||||
if (!this.imageObj.width || !this.imageObj.height) { // undefined or 0
|
||||
this.imageLoaded = false;
|
||||
}
|
||||
else if (this.imageLoaded === false) {
|
||||
this.imageLoaded = true;
|
||||
force = true;
|
||||
}
|
||||
|
||||
if (!this.width || !this.height || force === true) { // undefined or 0
|
||||
var width, height, ratio;
|
||||
if (this.imageObj.width && this.imageObj.height) { // not undefined or 0
|
||||
width = 0;
|
||||
height = 0;
|
||||
}
|
||||
if (this.imageObj.width > this.imageObj.height) {
|
||||
ratio = this.imageObj.width / this.imageObj.height;
|
||||
width = this.options.size * 2 * ratio || this.imageObj.width;
|
||||
height = this.options.size * 2 || this.imageObj.height;
|
||||
}
|
||||
else {
|
||||
if (this.imageObj.width && this.imageObj.height) { // not undefined or 0
|
||||
ratio = this.imageObj.height / this.imageObj.width;
|
||||
}
|
||||
else {
|
||||
ratio = 1;
|
||||
}
|
||||
width = this.options.size * 2 || this.imageObj.width;
|
||||
height = this.options.size * 2 * ratio || this.imageObj.height;
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.radius = 0.5 * this.width;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_drawRawCircle(ctx, x, y, selected, hover, size) {
|
||||
var borderWidth = this.options.borderWidth;
|
||||
var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth;
|
||||
|
||||
ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border;
|
||||
|
||||
ctx.lineWidth = (selected ? selectionLineWidth : borderWidth);
|
||||
ctx.lineWidth *= this.networkScaleInv;
|
||||
ctx.lineWidth = Math.min(this.width, ctx.lineWidth);
|
||||
ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background;
|
||||
ctx.circle(x, y, size);
|
||||
|
||||
//draw dashed border if enabled
|
||||
this.enableBorderDashes(ctx);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
//disable dashed border for other elements
|
||||
this.disableBorderDashes(ctx);
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawImageAtPosition(ctx) {
|
||||
if (this.imageObj.width != 0) {
|
||||
// draw the image
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height);
|
||||
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
_drawImageLabel(ctx, x, y, selected) {
|
||||
var yLabel;
|
||||
var offset = 0;
|
||||
|
||||
if (this.height !== undefined) {
|
||||
offset = this.height * 0.5;
|
||||
var labelDimensions = this.labelModule.getTextSize(ctx);
|
||||
if (labelDimensions.lineCount >= 1) {
|
||||
offset += labelDimensions.height / 2;
|
||||
}
|
||||
}
|
||||
|
||||
yLabel = y + offset;
|
||||
|
||||
if (this.options.label) {
|
||||
this.labelOffset = offset;
|
||||
}
|
||||
this.labelModule.draw(ctx, x, yLabel, selected, 'hanging');
|
||||
}
|
||||
}
|
||||
|
||||
export default CircleImageBase;
|
||||
@@ -0,0 +1,56 @@
|
||||
class NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
this.body = body;
|
||||
this.labelModule = labelModule;
|
||||
this.setOptions(options);
|
||||
this.top = undefined;
|
||||
this.left = undefined;
|
||||
this.height = undefined;
|
||||
this.width = undefined;
|
||||
this.radius = undefined;
|
||||
this.boundingBox = {top: 0, left: 0, right: 0, bottom: 0};
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
_distanceToBorder(angle) {
|
||||
var borderWidth = 1;
|
||||
return Math.min(
|
||||
Math.abs(this.width / 2 / Math.cos(angle)),
|
||||
Math.abs(this.height / 2 / Math.sin(angle))) + borderWidth;
|
||||
}
|
||||
|
||||
enableShadow(ctx) {
|
||||
if (this.options.shadow.enabled === true) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = this.options.shadow.size;
|
||||
ctx.shadowOffsetX = this.options.shadow.x;
|
||||
ctx.shadowOffsetY = this.options.shadow.y;
|
||||
}
|
||||
}
|
||||
|
||||
disableShadow(ctx) {
|
||||
if (this.options.shadow.enabled === true) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0)';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
enableBorderDashes(ctx) {
|
||||
if (this.options.shapeProperties.borderDashes !== false) {
|
||||
ctx.setLineDash(this.options.shapeProperties.borderDashes);
|
||||
}
|
||||
}
|
||||
|
||||
disableBorderDashes(ctx) {
|
||||
if (this.options.shapeProperties.borderDashes == false) {
|
||||
ctx.setLineDash([0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NodeBase;
|
||||
@@ -0,0 +1,72 @@
|
||||
import NodeBase from '../util/NodeBase'
|
||||
|
||||
class ShapeBase extends NodeBase {
|
||||
constructor(options, body, labelModule) {
|
||||
super(options, body, labelModule)
|
||||
}
|
||||
|
||||
_resizeShape() {
|
||||
if (this.width === undefined) {
|
||||
var size = 2 * this.options.size;
|
||||
this.width = size;
|
||||
this.height = size;
|
||||
this.radius = 0.5*this.width;
|
||||
}
|
||||
}
|
||||
|
||||
_drawShape(ctx, shape, sizeMultiplier, x, y, selected, hover) {
|
||||
this._resizeShape();
|
||||
|
||||
this.left = x - this.width / 2;
|
||||
this.top = y - this.height / 2;
|
||||
|
||||
var borderWidth = this.options.borderWidth;
|
||||
var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth;
|
||||
|
||||
ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border;
|
||||
ctx.lineWidth = (selected ? selectionLineWidth : borderWidth);
|
||||
ctx.lineWidth /= this.body.view.scale;
|
||||
ctx.lineWidth = Math.min(this.width, ctx.lineWidth);
|
||||
ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background;
|
||||
ctx[shape](x, y, this.options.size);
|
||||
|
||||
//draw dashed border if enabled
|
||||
this.enableBorderDashes(ctx);
|
||||
// draw shadow if enabled
|
||||
this.enableShadow(ctx);
|
||||
ctx.fill();
|
||||
|
||||
//disable dashed border for other elements
|
||||
this.disableBorderDashes(ctx);
|
||||
// disable shadows for other elements.
|
||||
this.disableShadow(ctx);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
|
||||
if (this.options.label !== undefined) {
|
||||
let yLabel = y + 0.5 * this.height + 3; // the + 3 is to offset it a bit below the node.
|
||||
this.labelModule.draw(ctx, x, yLabel, selected, 'hanging');
|
||||
}
|
||||
|
||||
this.updateBoundingBox(x,y);
|
||||
}
|
||||
|
||||
updateBoundingBox(x,y) {
|
||||
this.boundingBox.top = y - this.options.size;
|
||||
this.boundingBox.left = x - this.options.size;
|
||||
this.boundingBox.right = x + this.options.size;
|
||||
this.boundingBox.bottom = y + this.options.size;
|
||||
|
||||
if (this.options.label !== undefined && this.labelModule.size.width > 0) {
|
||||
this.boundingBox.left = Math.min(this.boundingBox.left, this.labelModule.size.left);
|
||||
this.boundingBox.right = Math.max(this.boundingBox.right, this.labelModule.size.left + this.labelModule.size.width);
|
||||
this.boundingBox.bottom = Math.max(this.boundingBox.bottom, this.boundingBox.bottom + this.labelModule.size.height + 3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default ShapeBase;
|
||||
@@ -0,0 +1,462 @@
|
||||
|
||||
class BarnesHutSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.barnesHutTree;
|
||||
this.setOptions(options);
|
||||
this.randomSeed = 5;
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
this.thetaInversed = 1 / this.options.theta;
|
||||
this.overlapAvoidanceFactor = 1 - Math.max(0, Math.min(1,this.options.avoidOverlap)); // if 1 then min distance = 0.5, if 0.5 then min distance = 0.5 + 0.5*node.shape.radius
|
||||
}
|
||||
|
||||
seededRandom() {
|
||||
var x = Math.sin(this.randomSeed++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function calculates the forces the nodes apply on eachother based on a gravitational model.
|
||||
* The Barnes Hut method is used to speed up this N-body simulation.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
solve() {
|
||||
if (this.options.gravitationalConstant !== 0 && this.physicsBody.physicsNodeIndices.length > 0) {
|
||||
let node;
|
||||
let nodes = this.body.nodes;
|
||||
let nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
let nodeCount = nodeIndices.length;
|
||||
|
||||
// create the tree
|
||||
let barnesHutTree = this._formBarnesHutTree(nodes, nodeIndices);
|
||||
|
||||
// for debugging
|
||||
this.barnesHutTree = barnesHutTree;
|
||||
|
||||
// place the nodes one by one recursively
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
node = nodes[nodeIndices[i]];
|
||||
if (node.options.mass > 0) {
|
||||
// starting with root is irrelevant, it never passes the BarnesHutSolver condition
|
||||
this._getForceContribution(barnesHutTree.root.children.NW, node);
|
||||
this._getForceContribution(barnesHutTree.root.children.NE, node);
|
||||
this._getForceContribution(barnesHutTree.root.children.SW, node);
|
||||
this._getForceContribution(barnesHutTree.root.children.SE, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function traverses the barnesHutTree. It checks when it can approximate distant nodes with their center of mass.
|
||||
* If a region contains a single node, we check if it is not itself, then we apply the force.
|
||||
*
|
||||
* @param parentBranch
|
||||
* @param node
|
||||
* @private
|
||||
*/
|
||||
_getForceContribution(parentBranch, node) {
|
||||
// we get no force contribution from an empty region
|
||||
if (parentBranch.childrenCount > 0) {
|
||||
let dx, dy, distance;
|
||||
|
||||
// get the distance from the center of mass to the node.
|
||||
dx = parentBranch.centerOfMass.x - node.x;
|
||||
dy = parentBranch.centerOfMass.y - node.y;
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// BarnesHutSolver condition
|
||||
// original condition : s/d < theta = passed === d/s > 1/theta = passed
|
||||
// calcSize = 1/s --> d * 1/s > 1/theta = passed
|
||||
if (distance * parentBranch.calcSize > this.thetaInversed) {
|
||||
this._calculateForces(distance, dx, dy, node, parentBranch);
|
||||
}
|
||||
else {
|
||||
// Did not pass the condition, go into children if available
|
||||
if (parentBranch.childrenCount === 4) {
|
||||
this._getForceContribution(parentBranch.children.NW, node);
|
||||
this._getForceContribution(parentBranch.children.NE, node);
|
||||
this._getForceContribution(parentBranch.children.SW, node);
|
||||
this._getForceContribution(parentBranch.children.SE, node);
|
||||
}
|
||||
else { // parentBranch must have only one node, if it was empty we wouldnt be here
|
||||
if (parentBranch.children.data.id != node.id) { // if it is not self
|
||||
this._calculateForces(distance, dx, dy, node, parentBranch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the forces based on the distance.
|
||||
*
|
||||
* @param distance
|
||||
* @param dx
|
||||
* @param dy
|
||||
* @param node
|
||||
* @param parentBranch
|
||||
* @private
|
||||
*/
|
||||
_calculateForces(distance, dx, dy, node, parentBranch) {
|
||||
if (distance === 0) {
|
||||
distance = 0.1;
|
||||
dx = distance;
|
||||
}
|
||||
|
||||
if (this.overlapAvoidanceFactor < 1) {
|
||||
distance = Math.max(0.1 + (this.overlapAvoidanceFactor * node.shape.radius), distance - node.shape.radius);
|
||||
}
|
||||
|
||||
// the dividing by the distance cubed instead of squared allows us to get the fx and fy components without sines and cosines
|
||||
// it is shorthand for gravityforce with distance squared and fx = dx/distance * gravityForce
|
||||
let gravityForce = this.options.gravitationalConstant * parentBranch.mass * node.options.mass / Math.pow(distance,3);
|
||||
let fx = dx * gravityForce;
|
||||
let fy = dy * gravityForce;
|
||||
|
||||
this.physicsBody.forces[node.id].x += fx;
|
||||
this.physicsBody.forces[node.id].y += fy;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function constructs the barnesHut tree recursively. It creates the root, splits it and starts placing the nodes.
|
||||
*
|
||||
* @param nodes
|
||||
* @param nodeIndices
|
||||
* @private
|
||||
*/
|
||||
_formBarnesHutTree(nodes, nodeIndices) {
|
||||
let node;
|
||||
let nodeCount = nodeIndices.length;
|
||||
|
||||
let minX = nodes[nodeIndices[0]].x;
|
||||
let minY = nodes[nodeIndices[0]].y;
|
||||
let maxX = nodes[nodeIndices[0]].x;
|
||||
let maxY = nodes[nodeIndices[0]].y;
|
||||
|
||||
// get the range of the nodes
|
||||
for (let i = 1; i < nodeCount; i++) {
|
||||
let x = nodes[nodeIndices[i]].x;
|
||||
let y = nodes[nodeIndices[i]].y;
|
||||
if (nodes[nodeIndices[i]].options.mass > 0) {
|
||||
if (x < minX) {
|
||||
minX = x;
|
||||
}
|
||||
if (x > maxX) {
|
||||
maxX = x;
|
||||
}
|
||||
if (y < minY) {
|
||||
minY = y;
|
||||
}
|
||||
if (y > maxY) {
|
||||
maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
// make the range a square
|
||||
let sizeDiff = Math.abs(maxX - minX) - Math.abs(maxY - minY); // difference between X and Y
|
||||
if (sizeDiff > 0) {
|
||||
minY -= 0.5 * sizeDiff;
|
||||
maxY += 0.5 * sizeDiff;
|
||||
} // xSize > ySize
|
||||
else {
|
||||
minX += 0.5 * sizeDiff;
|
||||
maxX -= 0.5 * sizeDiff;
|
||||
} // xSize < ySize
|
||||
|
||||
|
||||
let minimumTreeSize = 1e-5;
|
||||
let rootSize = Math.max(minimumTreeSize, Math.abs(maxX - minX));
|
||||
let halfRootSize = 0.5 * rootSize;
|
||||
let centerX = 0.5 * (minX + maxX), centerY = 0.5 * (minY + maxY);
|
||||
|
||||
// construct the barnesHutTree
|
||||
let barnesHutTree = {
|
||||
root: {
|
||||
centerOfMass: {x: 0, y: 0},
|
||||
mass: 0,
|
||||
range: {
|
||||
minX: centerX - halfRootSize, maxX: centerX + halfRootSize,
|
||||
minY: centerY - halfRootSize, maxY: centerY + halfRootSize
|
||||
},
|
||||
size: rootSize,
|
||||
calcSize: 1 / rootSize,
|
||||
children: {data: null},
|
||||
maxWidth: 0,
|
||||
level: 0,
|
||||
childrenCount: 4
|
||||
}
|
||||
};
|
||||
this._splitBranch(barnesHutTree.root);
|
||||
|
||||
// place the nodes one by one recursively
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
node = nodes[nodeIndices[i]];
|
||||
if (node.options.mass > 0) {
|
||||
this._placeInTree(barnesHutTree.root, node);
|
||||
}
|
||||
}
|
||||
|
||||
// make global
|
||||
return barnesHutTree
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* this updates the mass of a branch. this is increased by adding a node.
|
||||
*
|
||||
* @param parentBranch
|
||||
* @param node
|
||||
* @private
|
||||
*/
|
||||
_updateBranchMass(parentBranch, node) {
|
||||
let totalMass = parentBranch.mass + node.options.mass;
|
||||
let totalMassInv = 1 / totalMass;
|
||||
|
||||
parentBranch.centerOfMass.x = parentBranch.centerOfMass.x * parentBranch.mass + node.x * node.options.mass;
|
||||
parentBranch.centerOfMass.x *= totalMassInv;
|
||||
|
||||
parentBranch.centerOfMass.y = parentBranch.centerOfMass.y * parentBranch.mass + node.y * node.options.mass;
|
||||
parentBranch.centerOfMass.y *= totalMassInv;
|
||||
|
||||
parentBranch.mass = totalMass;
|
||||
let biggestSize = Math.max(Math.max(node.height, node.radius), node.width);
|
||||
parentBranch.maxWidth = (parentBranch.maxWidth < biggestSize) ? biggestSize : parentBranch.maxWidth;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* determine in which branch the node will be placed.
|
||||
*
|
||||
* @param parentBranch
|
||||
* @param node
|
||||
* @param skipMassUpdate
|
||||
* @private
|
||||
*/
|
||||
_placeInTree(parentBranch, node, skipMassUpdate) {
|
||||
if (skipMassUpdate != true || skipMassUpdate === undefined) {
|
||||
// update the mass of the branch.
|
||||
this._updateBranchMass(parentBranch, node);
|
||||
}
|
||||
|
||||
if (parentBranch.children.NW.range.maxX > node.x) { // in NW or SW
|
||||
if (parentBranch.children.NW.range.maxY > node.y) { // in NW
|
||||
this._placeInRegion(parentBranch, node, "NW");
|
||||
}
|
||||
else { // in SW
|
||||
this._placeInRegion(parentBranch, node, "SW");
|
||||
}
|
||||
}
|
||||
else { // in NE or SE
|
||||
if (parentBranch.children.NW.range.maxY > node.y) { // in NE
|
||||
this._placeInRegion(parentBranch, node, "NE");
|
||||
}
|
||||
else { // in SE
|
||||
this._placeInRegion(parentBranch, node, "SE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* actually place the node in a region (or branch)
|
||||
*
|
||||
* @param parentBranch
|
||||
* @param node
|
||||
* @param region
|
||||
* @private
|
||||
*/
|
||||
_placeInRegion(parentBranch, node, region) {
|
||||
switch (parentBranch.children[region].childrenCount) {
|
||||
case 0: // place node here
|
||||
parentBranch.children[region].children.data = node;
|
||||
parentBranch.children[region].childrenCount = 1;
|
||||
this._updateBranchMass(parentBranch.children[region], node);
|
||||
break;
|
||||
case 1: // convert into children
|
||||
// if there are two nodes exactly overlapping (on init, on opening of cluster etc.)
|
||||
// we move one node a pixel and we do not put it in the tree.
|
||||
if (parentBranch.children[region].children.data.x === node.x &&
|
||||
parentBranch.children[region].children.data.y === node.y) {
|
||||
node.x += this.seededRandom();
|
||||
node.y += this.seededRandom();
|
||||
}
|
||||
else {
|
||||
this._splitBranch(parentBranch.children[region]);
|
||||
this._placeInTree(parentBranch.children[region], node);
|
||||
}
|
||||
break;
|
||||
case 4: // place in branch
|
||||
this._placeInTree(parentBranch.children[region], node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* this function splits a branch into 4 sub branches. If the branch contained a node, we place it in the subbranch
|
||||
* after the split is complete.
|
||||
*
|
||||
* @param parentBranch
|
||||
* @private
|
||||
*/
|
||||
_splitBranch(parentBranch) {
|
||||
// if the branch is shaded with a node, replace the node in the new subset.
|
||||
let containedNode = null;
|
||||
if (parentBranch.childrenCount === 1) {
|
||||
containedNode = parentBranch.children.data;
|
||||
parentBranch.mass = 0;
|
||||
parentBranch.centerOfMass.x = 0;
|
||||
parentBranch.centerOfMass.y = 0;
|
||||
}
|
||||
parentBranch.childrenCount = 4;
|
||||
parentBranch.children.data = null;
|
||||
this._insertRegion(parentBranch, "NW");
|
||||
this._insertRegion(parentBranch, "NE");
|
||||
this._insertRegion(parentBranch, "SW");
|
||||
this._insertRegion(parentBranch, "SE");
|
||||
|
||||
if (containedNode != null) {
|
||||
this._placeInTree(parentBranch, containedNode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function subdivides the region into four new segments.
|
||||
* Specifically, this inserts a single new segment.
|
||||
* It fills the children section of the parentBranch
|
||||
*
|
||||
* @param parentBranch
|
||||
* @param region
|
||||
* @param parentRange
|
||||
* @private
|
||||
*/
|
||||
_insertRegion(parentBranch, region) {
|
||||
let minX, maxX, minY, maxY;
|
||||
let childSize = 0.5 * parentBranch.size;
|
||||
switch (region) {
|
||||
case "NW":
|
||||
minX = parentBranch.range.minX;
|
||||
maxX = parentBranch.range.minX + childSize;
|
||||
minY = parentBranch.range.minY;
|
||||
maxY = parentBranch.range.minY + childSize;
|
||||
break;
|
||||
case "NE":
|
||||
minX = parentBranch.range.minX + childSize;
|
||||
maxX = parentBranch.range.maxX;
|
||||
minY = parentBranch.range.minY;
|
||||
maxY = parentBranch.range.minY + childSize;
|
||||
break;
|
||||
case "SW":
|
||||
minX = parentBranch.range.minX;
|
||||
maxX = parentBranch.range.minX + childSize;
|
||||
minY = parentBranch.range.minY + childSize;
|
||||
maxY = parentBranch.range.maxY;
|
||||
break;
|
||||
case "SE":
|
||||
minX = parentBranch.range.minX + childSize;
|
||||
maxX = parentBranch.range.maxX;
|
||||
minY = parentBranch.range.minY + childSize;
|
||||
maxY = parentBranch.range.maxY;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
parentBranch.children[region] = {
|
||||
centerOfMass: {x: 0, y: 0},
|
||||
mass: 0,
|
||||
range: {minX: minX, maxX: maxX, minY: minY, maxY: maxY},
|
||||
size: 0.5 * parentBranch.size,
|
||||
calcSize: 2 * parentBranch.calcSize,
|
||||
children: {data: null},
|
||||
maxWidth: 0,
|
||||
level: parentBranch.level + 1,
|
||||
childrenCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//--------------------------- DEBUGGING BELOW ---------------------------//
|
||||
|
||||
|
||||
/**
|
||||
* This function is for debugging purposed, it draws the tree.
|
||||
*
|
||||
* @param ctx
|
||||
* @param color
|
||||
* @private
|
||||
*/
|
||||
_debug(ctx, color) {
|
||||
if (this.barnesHutTree !== undefined) {
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
this._drawBranch(this.barnesHutTree.root, ctx, color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function is for debugging purposes. It draws the branches recursively.
|
||||
*
|
||||
* @param branch
|
||||
* @param ctx
|
||||
* @param color
|
||||
* @private
|
||||
*/
|
||||
_drawBranch(branch, ctx, color) {
|
||||
if (color === undefined) {
|
||||
color = "#FF0000";
|
||||
}
|
||||
|
||||
if (branch.childrenCount === 4) {
|
||||
this._drawBranch(branch.children.NW, ctx);
|
||||
this._drawBranch(branch.children.NE, ctx);
|
||||
this._drawBranch(branch.children.SE, ctx);
|
||||
this._drawBranch(branch.children.SW, ctx);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(branch.range.minX, branch.range.minY);
|
||||
ctx.lineTo(branch.range.maxX, branch.range.minY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(branch.range.maxX, branch.range.minY);
|
||||
ctx.lineTo(branch.range.maxX, branch.range.maxY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(branch.range.maxX, branch.range.maxY);
|
||||
ctx.lineTo(branch.range.minX, branch.range.maxY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(branch.range.minX, branch.range.maxY);
|
||||
ctx.lineTo(branch.range.minX, branch.range.minY);
|
||||
ctx.stroke();
|
||||
|
||||
/*
|
||||
if (branch.mass > 0) {
|
||||
ctx.circle(branch.centerOfMass.x, branch.centerOfMass.y, 3*branch.mass);
|
||||
ctx.stroke();
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default BarnesHutSolver;
|
||||
@@ -0,0 +1,41 @@
|
||||
class CentralGravitySolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
solve() {
|
||||
let dx, dy, distance, node;
|
||||
let nodes = this.body.nodes;
|
||||
let nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
let forces = this.physicsBody.forces;
|
||||
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
node = nodes[nodeId];
|
||||
dx = -node.x;
|
||||
dy = -node.y;
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
this._calculateForces(distance, dx, dy, forces, node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the forces based on the distance.
|
||||
* @private
|
||||
*/
|
||||
_calculateForces(distance, dx, dy, forces, node) {
|
||||
let gravityForce = (distance === 0) ? 0 : (this.options.centralGravity / distance);
|
||||
forces[node.id].x = dx * gravityForce;
|
||||
forces[node.id].y = dy * gravityForce;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default CentralGravitySolver;
|
||||
@@ -0,0 +1,23 @@
|
||||
import CentralGravitySolver from "./CentralGravitySolver"
|
||||
|
||||
class ForceAtlas2BasedCentralGravitySolver extends CentralGravitySolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
super(body, physicsBody, options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the forces based on the distance.
|
||||
* @private
|
||||
*/
|
||||
_calculateForces(distance, dx, dy, forces, node) {
|
||||
if (distance > 0) {
|
||||
let degree = (node.edges.length + 1);
|
||||
let gravityForce = this.options.centralGravity * degree * node.options.mass;
|
||||
forces[node.id].x = dx * gravityForce;
|
||||
forces[node.id].y = dy * gravityForce;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ForceAtlas2BasedCentralGravitySolver;
|
||||
@@ -0,0 +1,40 @@
|
||||
import BarnesHutSolver from "./BarnesHutSolver"
|
||||
|
||||
class ForceAtlas2BasedRepulsionSolver extends BarnesHutSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
super(body, physicsBody, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the forces based on the distance.
|
||||
*
|
||||
* @param distance
|
||||
* @param dx
|
||||
* @param dy
|
||||
* @param node
|
||||
* @param parentBranch
|
||||
* @private
|
||||
*/
|
||||
_calculateForces(distance, dx, dy, node, parentBranch) {
|
||||
if (distance === 0) {
|
||||
distance = 0.1 * Math.random();
|
||||
dx = distance;
|
||||
}
|
||||
|
||||
if (this.overlapAvoidanceFactor < 1) {
|
||||
distance = Math.max(0.1 + (this.overlapAvoidanceFactor * node.shape.radius), distance - node.shape.radius);
|
||||
}
|
||||
|
||||
let degree = (node.edges.length + 1);
|
||||
// the dividing by the distance cubed instead of squared allows us to get the fx and fy components without sines and cosines
|
||||
// it is shorthand for gravityforce with distance squared and fx = dx/distance * gravityForce
|
||||
let gravityForce = this.options.gravitationalConstant * parentBranch.mass * node.options.mass * degree / Math.pow(distance,2);
|
||||
let fx = dx * gravityForce;
|
||||
let fy = dy * gravityForce;
|
||||
|
||||
this.physicsBody.forces[node.id].x += fx;
|
||||
this.physicsBody.forces[node.id].y += fy;
|
||||
}
|
||||
}
|
||||
|
||||
export default ForceAtlas2BasedRepulsionSolver;
|
||||
@@ -0,0 +1,69 @@
|
||||
class HierarchicalRepulsionSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the forces the nodes apply on each other based on a repulsion field.
|
||||
* This field is linearly approximated.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
solve() {
|
||||
var dx, dy, distance, fx, fy, repulsingForce, node1, node2, i, j;
|
||||
|
||||
var nodes = this.body.nodes;
|
||||
var nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
var forces = this.physicsBody.forces;
|
||||
|
||||
// repulsing forces between nodes
|
||||
var nodeDistance = this.options.nodeDistance;
|
||||
|
||||
// we loop from i over all but the last entree in the array
|
||||
// j loops from i+1 to the last. This way we do not double count any of the indices, nor i === j
|
||||
for (i = 0; i < nodeIndices.length - 1; i++) {
|
||||
node1 = nodes[nodeIndices[i]];
|
||||
for (j = i + 1; j < nodeIndices.length; j++) {
|
||||
node2 = nodes[nodeIndices[j]];
|
||||
|
||||
// nodes only affect nodes on their level
|
||||
if (node1.level === node2.level) {
|
||||
dx = node2.x - node1.x;
|
||||
dy = node2.y - node1.y;
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
var steepness = 0.05;
|
||||
if (distance < nodeDistance) {
|
||||
repulsingForce = -Math.pow(steepness * distance, 2) + Math.pow(steepness * nodeDistance, 2);
|
||||
}
|
||||
else {
|
||||
repulsingForce = 0;
|
||||
}
|
||||
// normalize force with
|
||||
if (distance === 0) {
|
||||
distance = 0.01;
|
||||
}
|
||||
else {
|
||||
repulsingForce = repulsingForce / distance;
|
||||
}
|
||||
fx = dx * repulsingForce;
|
||||
fy = dy * repulsingForce;
|
||||
|
||||
forces[node1.id].x -= fx;
|
||||
forces[node1.id].y -= fy;
|
||||
forces[node2.id].x += fx;
|
||||
forces[node2.id].y += fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default HierarchicalRepulsionSolver;
|
||||
@@ -0,0 +1,107 @@
|
||||
class HierarchicalSpringSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calculates the springforces on the nodes, accounting for the support nodes.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
solve() {
|
||||
var edgeLength, edge;
|
||||
var dx, dy, fx, fy, springForce, distance;
|
||||
var edges = this.body.edges;
|
||||
var factor = 0.5;
|
||||
|
||||
var edgeIndices = this.physicsBody.physicsEdgeIndices;
|
||||
var nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
var forces = this.physicsBody.forces;
|
||||
|
||||
// initialize the spring force counters
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
forces[nodeId].springFx = 0;
|
||||
forces[nodeId].springFy = 0;
|
||||
}
|
||||
|
||||
|
||||
// forces caused by the edges, modelled as springs
|
||||
for (let i = 0; i < edgeIndices.length; i++) {
|
||||
edge = edges[edgeIndices[i]];
|
||||
if (edge.connected === true) {
|
||||
edgeLength = edge.options.length === undefined ? this.options.springLength : edge.options.length;
|
||||
|
||||
dx = (edge.from.x - edge.to.x);
|
||||
dy = (edge.from.y - edge.to.y);
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
distance = distance === 0 ? 0.01 : distance;
|
||||
|
||||
// the 1/distance is so the fx and fy can be calculated without sine or cosine.
|
||||
springForce = this.options.springConstant * (edgeLength - distance) / distance;
|
||||
|
||||
fx = dx * springForce;
|
||||
fy = dy * springForce;
|
||||
|
||||
if (edge.to.level != edge.from.level) {
|
||||
if (forces[edge.toId] !== undefined) {
|
||||
forces[edge.toId].springFx -= fx;
|
||||
forces[edge.toId].springFy -= fy;
|
||||
}
|
||||
if (forces[edge.fromId] !== undefined) {
|
||||
forces[edge.fromId].springFx += fx;
|
||||
forces[edge.fromId].springFy += fy;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (forces[edge.toId] !== undefined) {
|
||||
forces[edge.toId].x -= factor * fx;
|
||||
forces[edge.toId].y -= factor * fy;
|
||||
}
|
||||
if (forces[edge.fromId] !== undefined) {
|
||||
forces[edge.fromId].x += factor * fx;
|
||||
forces[edge.fromId].y += factor * fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalize spring forces
|
||||
var springForce = 1;
|
||||
var springFx, springFy;
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
springFx = Math.min(springForce,Math.max(-springForce,forces[nodeId].springFx));
|
||||
springFy = Math.min(springForce,Math.max(-springForce,forces[nodeId].springFy));
|
||||
|
||||
forces[nodeId].x += springFx;
|
||||
forces[nodeId].y += springFy;
|
||||
}
|
||||
|
||||
// retain energy balance
|
||||
var totalFx = 0;
|
||||
var totalFy = 0;
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
totalFx += forces[nodeId].x;
|
||||
totalFy += forces[nodeId].y;
|
||||
}
|
||||
var correctionFx = totalFx / nodeIndices.length;
|
||||
var correctionFy = totalFy / nodeIndices.length;
|
||||
|
||||
for (let i = 0; i < nodeIndices.length; i++) {
|
||||
let nodeId = nodeIndices[i];
|
||||
forces[nodeId].x -= correctionFx;
|
||||
forces[nodeId].y -= correctionFy;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default HierarchicalSpringSolver;
|
||||
@@ -0,0 +1,71 @@
|
||||
class RepulsionSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
/**
|
||||
* Calculate the forces the nodes apply on each other based on a repulsion field.
|
||||
* This field is linearly approximated.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
solve() {
|
||||
var dx, dy, distance, fx, fy, repulsingForce, node1, node2;
|
||||
|
||||
var nodes = this.body.nodes;
|
||||
var nodeIndices = this.physicsBody.physicsNodeIndices;
|
||||
var forces = this.physicsBody.forces;
|
||||
|
||||
// repulsing forces between nodes
|
||||
var nodeDistance = this.options.nodeDistance;
|
||||
|
||||
// approximation constants
|
||||
var a = (-2 / 3) / nodeDistance;
|
||||
var b = 4 / 3;
|
||||
|
||||
// we loop from i over all but the last entree in the array
|
||||
// j loops from i+1 to the last. This way we do not double count any of the indices, nor i === j
|
||||
for (let i = 0; i < nodeIndices.length - 1; i++) {
|
||||
node1 = nodes[nodeIndices[i]];
|
||||
for (let j = i + 1; j < nodeIndices.length; j++) {
|
||||
node2 = nodes[nodeIndices[j]];
|
||||
|
||||
dx = node2.x - node1.x;
|
||||
dy = node2.y - node1.y;
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// same condition as BarnesHutSolver, making sure nodes are never 100% overlapping.
|
||||
if (distance === 0) {
|
||||
distance = 0.1*Math.random();
|
||||
dx = distance;
|
||||
}
|
||||
|
||||
if (distance < 2 * nodeDistance) {
|
||||
if (distance < 0.5 * nodeDistance) {
|
||||
repulsingForce = 1.0;
|
||||
}
|
||||
else {
|
||||
repulsingForce = a * distance + b; // linear approx of 1 / (1 + Math.exp((distance / nodeDistance - 1) * steepness))
|
||||
}
|
||||
repulsingForce = repulsingForce / distance;
|
||||
|
||||
fx = dx * repulsingForce;
|
||||
fy = dy * repulsingForce;
|
||||
|
||||
forces[node1.id].x -= fx;
|
||||
forces[node1.id].y -= fy;
|
||||
forces[node2.id].x += fx;
|
||||
forces[node2.id].y += fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default RepulsionSolver;
|
||||
@@ -0,0 +1,82 @@
|
||||
class SpringSolver {
|
||||
constructor(body, physicsBody, options) {
|
||||
this.body = body;
|
||||
this.physicsBody = physicsBody;
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calculates the springforces on the nodes, accounting for the support nodes.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
solve() {
|
||||
let edgeLength, edge;
|
||||
let edgeIndices = this.physicsBody.physicsEdgeIndices;
|
||||
let edges = this.body.edges;
|
||||
let node1, node2, node3;
|
||||
|
||||
// forces caused by the edges, modelled as springs
|
||||
for (let i = 0; i < edgeIndices.length; i++) {
|
||||
edge = edges[edgeIndices[i]];
|
||||
if (edge.connected === true && edge.toId !== edge.fromId) {
|
||||
// only calculate forces if nodes are in the same sector
|
||||
if (this.body.nodes[edge.toId] !== undefined && this.body.nodes[edge.fromId] !== undefined) {
|
||||
if (edge.edgeType.via !== undefined) {
|
||||
edgeLength = edge.options.length === undefined ? this.options.springLength : edge.options.length;
|
||||
node1 = edge.to;
|
||||
node2 = edge.edgeType.via;
|
||||
node3 = edge.from;
|
||||
|
||||
this._calculateSpringForce(node1, node2, 0.5 * edgeLength);
|
||||
this._calculateSpringForce(node2, node3, 0.5 * edgeLength);
|
||||
}
|
||||
else {
|
||||
// the * 1.5 is here so the edge looks as large as a smooth edge. It does not initially because the smooth edges use
|
||||
// the support nodes which exert a repulsive force on the to and from nodes, making the edge appear larger.
|
||||
edgeLength = edge.options.length === undefined ? this.options.springLength * 1.5: edge.options.length;
|
||||
this._calculateSpringForce(edge.from, edge.to, edgeLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This is the code actually performing the calculation for the function above.
|
||||
*
|
||||
* @param node1
|
||||
* @param node2
|
||||
* @param edgeLength
|
||||
* @private
|
||||
*/
|
||||
_calculateSpringForce(node1, node2, edgeLength) {
|
||||
let dx = (node1.x - node2.x);
|
||||
let dy = (node1.y - node2.y);
|
||||
let distance = Math.max(Math.sqrt(dx * dx + dy * dy),0.01);
|
||||
|
||||
// the 1/distance is so the fx and fy can be calculated without sine or cosine.
|
||||
let springForce = this.options.springConstant * (edgeLength - distance) / distance;
|
||||
|
||||
let fx = dx * springForce;
|
||||
let fy = dy * springForce;
|
||||
|
||||
// handle the case where one node is not part of the physcis
|
||||
if (this.physicsBody.forces[node1.id] !== undefined) {
|
||||
this.physicsBody.forces[node1.id].x += fx;
|
||||
this.physicsBody.forces[node1.id].y += fy;
|
||||
}
|
||||
|
||||
if (this.physicsBody.forces[node2.id] !== undefined) {
|
||||
this.physicsBody.forces[node2.id].x -= fx;
|
||||
this.physicsBody.forces[node2.id].y -= fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SpringSolver;
|
||||
@@ -0,0 +1,261 @@
|
||||
let util = require('../../../../util');
|
||||
|
||||
class Label {
|
||||
constructor(body,options) {
|
||||
this.body = body;
|
||||
|
||||
this.pointToSelf = false;
|
||||
this.baseSize = undefined;
|
||||
this.setOptions(options);
|
||||
this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; // could be cached
|
||||
}
|
||||
|
||||
setOptions(options, allowDeletion = false) {
|
||||
this.options = options;
|
||||
|
||||
if (options.label !== undefined) {
|
||||
this.labelDirty = true;
|
||||
}
|
||||
|
||||
if (options.font !== undefined) {
|
||||
Label.parseOptions(this.options.font, options, allowDeletion);
|
||||
if (typeof options.font === 'string') {
|
||||
this.baseSize = this.options.font.size;
|
||||
}
|
||||
else if (typeof options.font === 'object') {
|
||||
if (options.font.size !== undefined) {
|
||||
this.baseSize = options.font.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static parseOptions(parentOptions, newOptions, allowDeletion = false) {
|
||||
if (typeof newOptions.font === 'string') {
|
||||
let newOptionsArray = newOptions.font.split(" ");
|
||||
parentOptions.size = newOptionsArray[0].replace("px",'');
|
||||
parentOptions.face = newOptionsArray[1];
|
||||
parentOptions.color = newOptionsArray[2];
|
||||
}
|
||||
else if (typeof newOptions.font === 'object') {
|
||||
util.fillIfDefined(parentOptions, newOptions.font, allowDeletion);
|
||||
}
|
||||
parentOptions.size = Number(parentOptions.size);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main function. This is called from anything that wants to draw a label.
|
||||
* @param ctx
|
||||
* @param x
|
||||
* @param y
|
||||
* @param selected
|
||||
* @param baseline
|
||||
*/
|
||||
draw(ctx, x, y, selected, baseline = 'middle') {
|
||||
// if no label, return
|
||||
if (this.options.label === undefined)
|
||||
return;
|
||||
|
||||
// check if we have to render the label
|
||||
let viewFontSize = this.options.font.size * this.body.view.scale;
|
||||
if (this.options.label && viewFontSize < this.options.scaling.label.drawThreshold - 1)
|
||||
return;
|
||||
|
||||
// update the size cache if required
|
||||
this.calculateLabelSize(ctx, selected, x, y, baseline);
|
||||
|
||||
// create the fontfill background
|
||||
this._drawBackground(ctx);
|
||||
// draw text
|
||||
this._drawText(ctx, selected, x, y, baseline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the label background
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @private
|
||||
*/
|
||||
_drawBackground(ctx) {
|
||||
if (this.options.font.background !== undefined && this.options.font.background !== "none") {
|
||||
ctx.fillStyle = this.options.font.background;
|
||||
|
||||
let lineMargin = 2;
|
||||
|
||||
switch (this.options.font.align) {
|
||||
case 'middle':
|
||||
ctx.fillRect(-this.size.width * 0.5, -this.size.height * 0.5, this.size.width, this.size.height);
|
||||
break;
|
||||
case 'top':
|
||||
ctx.fillRect(-this.size.width * 0.5, -(this.size.height + lineMargin), this.size.width, this.size.height);
|
||||
break;
|
||||
case 'bottom':
|
||||
ctx.fillRect(-this.size.width * 0.5, lineMargin, this.size.width, this.size.height);
|
||||
break;
|
||||
default:
|
||||
ctx.fillRect(this.size.left, this.size.top - 0.5*lineMargin, this.size.width, this.size.height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ctx
|
||||
* @param x
|
||||
* @param baseline
|
||||
* @private
|
||||
*/
|
||||
_drawText(ctx, selected, x, y, baseline = 'middle') {
|
||||
let fontSize = this.options.font.size;
|
||||
let viewFontSize = fontSize * this.body.view.scale;
|
||||
// this ensures that there will not be HUGE letters on screen by setting an upper limit on the visible text size (regardless of zoomLevel)
|
||||
if (viewFontSize >= this.options.scaling.label.maxVisible) {
|
||||
fontSize = Number(this.options.scaling.label.maxVisible) / this.body.view.scale;
|
||||
}
|
||||
|
||||
let yLine = this.size.yLine;
|
||||
let [fontColor, strokeColor] = this._getColor(viewFontSize);
|
||||
[x, yLine] = this._setAlignment(ctx, x, yLine, baseline);
|
||||
|
||||
// configure context for drawing the text
|
||||
ctx.font = (selected && this.options.labelHighlightBold ? 'bold ' : '') + fontSize + "px " + this.options.font.face;
|
||||
ctx.fillStyle = fontColor;
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// set the strokeWidth
|
||||
if (this.options.font.strokeWidth > 0) {
|
||||
ctx.lineWidth = this.options.font.strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineJoin = 'round';
|
||||
}
|
||||
|
||||
// draw the text
|
||||
for (let i = 0; i < this.lineCount; i++) {
|
||||
if (this.options.font.strokeWidth > 0) {
|
||||
ctx.strokeText(this.lines[i], x, yLine);
|
||||
}
|
||||
ctx.fillText(this.lines[i], x, yLine);
|
||||
yLine += fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
_setAlignment(ctx, x, yLine, baseline) {
|
||||
// check for label alignment (for edges)
|
||||
// TODO: make alignment for nodes
|
||||
if (this.options.font.align !== 'horizontal' && this.pointToSelf === false) {
|
||||
x = 0;
|
||||
yLine = 0;
|
||||
|
||||
let lineMargin = 2;
|
||||
if (this.options.font.align === 'top') {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
yLine -= 2 * lineMargin; // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers
|
||||
}
|
||||
else if (this.options.font.align === 'bottom') {
|
||||
ctx.textBaseline = 'hanging';
|
||||
yLine += 2 * lineMargin;// distance from edge, required because we use hanging. Hanging has less difference between browsers
|
||||
}
|
||||
else {
|
||||
ctx.textBaseline = 'middle';
|
||||
}
|
||||
}
|
||||
else {
|
||||
ctx.textBaseline = baseline;
|
||||
}
|
||||
|
||||
return [x,yLine];
|
||||
}
|
||||
|
||||
/**
|
||||
* fade in when relative scale is between threshold and threshold - 1.
|
||||
* If the relative scale would be smaller than threshold -1 the draw function would have returned before coming here.
|
||||
*
|
||||
* @param viewFontSize
|
||||
* @returns {*[]}
|
||||
* @private
|
||||
*/
|
||||
_getColor(viewFontSize) {
|
||||
let fontColor = this.options.font.color || '#000000';
|
||||
let strokeColor = this.options.font.strokeColor || '#ffffff';
|
||||
if (viewFontSize <= this.options.scaling.label.drawThreshold) {
|
||||
let opacity = Math.max(0, Math.min(1, 1 - (this.options.scaling.label.drawThreshold - viewFontSize)));
|
||||
fontColor = util.overrideOpacity(fontColor, opacity);
|
||||
strokeColor = util.overrideOpacity(strokeColor, opacity);
|
||||
}
|
||||
return [fontColor, strokeColor];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ctx
|
||||
* @param selected
|
||||
* @returns {{width: number, height: number}}
|
||||
*/
|
||||
getTextSize(ctx, selected = false) {
|
||||
let size = {
|
||||
width: this._processLabel(ctx,selected),
|
||||
height: this.options.font.size * this.lineCount,
|
||||
lineCount: this.lineCount
|
||||
};
|
||||
return size;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ctx
|
||||
* @param selected
|
||||
* @param x
|
||||
* @param y
|
||||
* @param baseline
|
||||
*/
|
||||
calculateLabelSize(ctx, selected, x = 0, y = 0, baseline = 'middle') {
|
||||
if (this.labelDirty === true) {
|
||||
this.size.width = this._processLabel(ctx,selected);
|
||||
}
|
||||
this.size.height = this.options.font.size * this.lineCount;
|
||||
this.size.left = x - this.size.width * 0.5;
|
||||
this.size.top = y - this.size.height * 0.5;
|
||||
this.size.yLine = y + (1 - this.lineCount) * 0.5 * this.options.font.size;
|
||||
if (baseline === "hanging") {
|
||||
this.size.top += 0.5 * this.options.font.size;
|
||||
this.size.top += 4; // distance from node, required because we use hanging. Hanging has less difference between browsers
|
||||
this.size.yLine += 4; // distance from node
|
||||
}
|
||||
|
||||
this.labelDirty = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This calculates the width as well as explodes the label string and calculates the amount of lines.
|
||||
* @param ctx
|
||||
* @param selected
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_processLabel(ctx,selected) {
|
||||
let width = 0;
|
||||
let lines = [''];
|
||||
let lineCount = 0;
|
||||
if (this.options.label !== undefined) {
|
||||
lines = String(this.options.label).split('\n');
|
||||
lineCount = lines.length;
|
||||
ctx.font = (selected && this.options.labelHighlightBold ? 'bold ' : '') + this.options.font.size + "px " + this.options.font.face;
|
||||
width = ctx.measureText(lines[0]).width;
|
||||
for (let i = 1; i < lineCount; i++) {
|
||||
let lineWidth = ctx.measureText(lines[i]).width;
|
||||
width = lineWidth > width ? lineWidth : width;
|
||||
}
|
||||
}
|
||||
this.lines = lines;
|
||||
this.lineCount = lineCount;
|
||||
|
||||
return width;
|
||||
}
|
||||
}
|
||||
|
||||
export default Label;
|
||||
Reference in New Issue
Block a user