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:
laf
2015-07-18 13:45:18 +01:00
parent 35db670c97
commit 23cdf440ab
410 changed files with 92312 additions and 87019 deletions
+313
View File
@@ -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;
+348
View File
@@ -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;
+721
View File
@@ -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;
+384
View File
@@ -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;
+116
View File
@@ -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;
+710
View File
@@ -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;
+486
View File
@@ -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
+424
View File
@@ -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;
+567
View File
@@ -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;
+680
View File
@@ -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;
+335
View File
@@ -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;
+500
View File
@@ -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;
+446
View File
@@ -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;
+98
View File
@@ -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;