diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..d3289f3a4 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["es2015"], + "plugins": [ + "transform-es3-property-literals", + "transform-es3-member-expression-literals", + "transform-runtime" + ] +} diff --git a/.csslintrc b/.csslintrc new file mode 100644 index 000000000..2b014c6a6 --- /dev/null +++ b/.csslintrc @@ -0,0 +1,6 @@ +{ + "adjoining-classes": false, + "box-model": false, + "box-sizing": false, + "order-alphabetical": false +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..9853e4117 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,42 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true, + "mocha": true + }, + + "parserOptions": { + "sourceType": "module", + }, + + "extends": "eslint:recommended", + + // For the full list of rules, see: http://eslint.org/docs/rules/ + "rules": { + "complexity": [2, 55], + "max-statements": [2, 115], + "no-unreachable": 1, + "no-useless-escape": 0, + + "no-console": 0, + // To flag presence of console.log without breaking linting: + //"no-console": ["warn", { allow: ["warn", "error"] }], + + "require-jsdoc": ["error", { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": false + } + }], + "valid-jsdoc": [2, { + "requireReturnDescription": false, + "requireReturn": false, + "requireParamDescription": false, + "requireReturnType": true + }], + "guard-for-in": 1, + }, +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..001bdd7ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +This project is no longer in active development. See #4259 for details. + +Please contribute to the [visjs community](https://github.com/visjs)! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..001bdd7ff --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +This project is no longer in active development. See #4259 for details. + +Please contribute to the [visjs community](https://github.com/visjs)! diff --git a/.gitignore b/.gitignore index 296c9b506..f9ccfc5d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # vis.js files test/ - # npm files node_modules npm-debug.log @@ -16,5 +15,6 @@ dist .settings/ .directory -# vim temporary files +# temporary files .*.sw[op] +.commits.tmp diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 000000000..bb5535c34 --- /dev/null +++ b/.mdlrc @@ -0,0 +1,7 @@ +// Markdown Lint Rules +// https://github.com/mivok/markdownlint/blob/master/docs/RULES.md + +rules + "~MD012", // alert on multiple consecutive blank lines + "~MD013", // line length should be no more than 80 characters + "~MD014", // Dollar signs used before commands without showing output diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..cbde6368a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: node_js +node_js: + - "6" +env: + - CXX=g++-4.8 +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - libgif-dev + - g++-4.8 + code_climate: + repo_token: 07de009e5f4d0a43c51b18f3443b2fe7ddcf3fea206e75c3a81b1c4030657f69 +cache: + directories: + - node_modules +before_script: + - npm run lint + - npm install gulp +script: + - gulp + - npm run-script test-cov +after_script: + - npm install -g codeclimate-test-reporter + - codeclimate-test-reporter < ./coverage/lcov.info diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 414327c74..41455bd64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,5 @@ -## Contributing +# Contributing -[Contributions](//github.com/almende/vis/blob/master/misc/how_to_help.md) to the vis.js library are very welcome! [We can't do this alone](//github.com/almende/vis/blob/master/misc/we_need_help.md). +This project is no longer in active development. See #4259 for details. -### Questions -If you have any *general question* on how to use the vis.js library in your own project please check out [stackoverflow](http://stackoverflow.com/questions/tagged/vis.js) for thinks like that. **This is NOT a JavaScript help forum!** - -### Bugs, Problems and Feature-Requests -If you really want to open a new issue: -* Please use the [search functionality](//github.com/almende/vis/issues) to make sure that there is not already an issue concerning the same topic. -* Please make sure to **mention which module** of vis.js (network, timeline, graph3d, ...) your are referring to. -* If you think you found a bug please **provide a simple example** (e.g. on [jsbin](jsbin.com)) that demonstrates the problem. -* If you want to propose a feature-request please **describe what you are looking for in detail**, ideally providing a screenshot, drawing or something similar. -* **Close the issue later**, when the issue is no longer needed. +Please consider contributing to the [visjs community](https://github.com/visjs)! diff --git a/HISTORY.md b/HISTORY.md index 0b7bad0d0..5decd18ba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,382 @@ # vis.js history -http://visjs.org + +## 2019-06-08, End of Live +- see #4259 for details + +## 2017-10-12, version 4.21.0 + +### General +- Added #3394: Adds unit tests for add, setOptions and on/off DataSet +- FIX #3406: Eliminate possibility of 'window is undefined' during travis test +- Added #3402: added @macleodbroad-wf to the support team +- REFA #3442: Strapping down of Extend-routines in util.js +- FIX #3392: Addresses TODOs in Queue unit test by adding unit tests for setOptions and destroy +- Added #3354: Adds missing jsdoc and adds lint rule require-jsdoc to build process +- Added #3331 - Enable linting for Travis +- Added #3312, #3311, #3310, #3309, #3308, #3304 - Add lint +- Added #3230 - Enable 'eslint' +- Added #3262 - Upgrade packages and tools for Travis unit testing +- Added #3287: Update module versions to latest stable +- Added #3295: Update the webpack example + +### Network +- FIX #3554: Relax clustering condition for adding already clustered nodes to cluster +- FIX #3517: Retain constraint values in label font handling +- REFA #3507: Cleanup and refactoring PhysicsEngine +- FIX #3500: re-adds edges if they are now connected and add does not add invalid edges +- FIX #3486: Add extra check on null value during label handling +- FEAT #824: Network detect clicks on labels +- FIX #3474: Adjust documentation for arrows.middle.scaleFactor +- FIX #3483: Prevent image loading for hidden cluster nodes +- FIX #3408, #2677: Fix handling of multi-fonts +- FIX #3425: IE performance improvements +- FIX #3356 and #3297: IE11 svg image fixes +- FIX #3474: Make negative scaleFactor reverse middle arrow correctly +- FIX #3464: Fix handling of space before huge word in label text +- FIX #3467: Adjust for-in loops so they can deal with added properties in Array and Object prototype +- FEAT #3412: Add endpoint 'bar' to Network +- FIX #3403: Fixes sorting on Layout, refactoring +- FIX #3421: Added default sizes for nodes without labels +- FEAT #3418: Added new Hexagon shape in the Network +- FEAT #3368: Cluster node handling due to dynamic data change +- FIX #3395: Allow for multiline titles +- FIX #3367: Network Clustering fixes on usage joinCondition for clusterOutliers() +- FIX #3350: Fix setting of edge color options via Network.setOptions() +- FEAT #3348: Add edge styles support for DOT lib +- FIX #2839: Re-words documentation to reflect symmetrical input/output of get() when passed multiple ids +- FIX #3316: Updates network documentation to account for edge +- FIX #1218, #1291, #1315: Dynamically adjust clustering when data changes +- FIX #2311: Block recalculation of level in LayoutEngine._determineLevelsDirected() +- FIX #3280: Cleanup mergeOptions() and fix missing ref on globalOptions in mergeOptions() +- FEAT #3131: Added dragStart event for adding edges +- FIX #3171 and #3185: Fix infinite loop on drawing of large labels +- FIX #3220: Update hierarchy when node level changes +- FIX #3245: Multiple base edges in clustered edge +- FEAT #1222: Add pointer data to hover events +- REFA #3106: Refactoring and unit testing of Validator module +- REFA #3227: Refactor LayoutEngine for further work +- FIX #3164: make 'hidden' and 'clustered' play nice together +- FIX #2579: Allow DOM elements for node titles +- FIX #2856: Fix manipulation examples for Network + +### Graph2D +- FIX #1852: Correct documentation for graph2d’s moveTo function + +### Graph3D +- FIX #3467: Adjust for-in loops so they can deal with added properties in Array and Object prototypes +- FEAT #3099: Add option definitions and validation to Graph3d +- REFA #3159: move Filter into DataGroup +- FEAT #3255: Add unit tests for Graph3D issue +- FIX #3251: Graph3d put guards before unsubscription in DataGroup +- FIX #3255: Fix missing reference to Graph3D instance in DataGroup + +### Timeline +- FEAT #3529: On timeline loaded +- FEAT #3505: Drag object in to item +- FEAT #3050: Allow disabling of RangeItem limitSize +- FIX #3475: Item Redraw Performance - Bug Fix +- FIX #3504: Fixing a bug with the timing of the final setting of the vertical scroll position +- FIX #3509: Added describe sections to PointItem unit tests +- FIX #2851: Vertical focus +- FEAT #620: Subgroup stacking +- FIX #3475: Improve Item redraw and initial draw performance +- FIX #3409: Group redraw performance +- FEAT #3428: Adds locale for Chinese (cn) +- FIX #3405: fix orientation option +- FIX #3360: Add performance tips to timeline docs +- FIX #3378: Add item with ctrlKey/metaKey when dagging on a selected item +- FIX #3126: Nested groups order logic +- FIX #3246: Fix issue when showMajorLabels == false is used with a weekly scale and weekly scale minor label fix +- FIX #3342: Bug fix for null parent +- FIX #2123: Disable the default handling of the pinch event when handling it +- FIX #3169: add parenthesis around ternary +- FIX #3249: Only draw non-visible items once when they are loaded, instead of continuously every frame +- FEAT #3162: Bidirectional scrolling in timeline - make horizontalScroll and verticalScroll work together + + +## 2017-07-01, version 4.20.1 + +### General +- Added Release checklist +- Added collapsible items for objects in graph3d doc + +### Network +- FIX #3203: Set dimensions properly of images on initialization +- FIX #3170: Refactoring of Node Drawing +- FIX #3108: Reverse nodes returned with 'from' and 'to' directions +- FIX #3122: Refactored line drawing for Bezier edges +- FIX #3121: Refactoring of `BezierEdgeStatic._getViaCoordinates()` +- FIX #3088: Consolidate code for determining the pixel ratio +- FIX #3036: Smooth type 'dynamic' adjusted for node-specific option in hierarchical +- FIX #1105: Fix usage of clustering with hierarchical networks +- FIX #3133: Protect Network from zero and negative mass values +- FIX #3163: Prevent crashes from invalid id's in `Clustering.findNode()` +- FIX #3106: Ensure start and end of stabilization progress events is sent +- FIX #3015: Properly handle newline escape sequences in strings for DOT +- FIX: Refactoring of LayoutEngine (#3110) +- FIX #2990: Edge labels turn bold on select and hover +- FIX #2959: Changed order of (de)select events for network +- FIX #3091: Added param 'direction' to Network.getConnectedNodes() +- FIX #3085: Add prefix to cancelAnimationFrame() + +### Graph3D +- FIX #3198: Small fix on ref usage in DataGroup +- FIX #2804: Add data group class to Graph3d + +### Timeline +- FIX #3172: Fix stacking when setting option +- FIX #3183: Fixes a race condition that set an item's group to be set to undefined +- FEAT #3154: Caching to Range getMillisecondsPerPixel function +- FIX #3105: Adjusting timeline TimeStep.roundToMinor +- FEAT #3107: Allow overriding `align` per item + +## 2017-05-21, version 4.20.0 + +### General + +- FIX #2934: Replacing all ES6 imports with CJS require calls (#3063) +- Add command line options to mocha for running tests (#3064) +- Added documentation on how labels are used (#2873) +- FIX: Fix typo in PR template (#2908) +- FIX #2912: updated moment.js (#2925) +- Added @wimrijnders to the support team (#2886) + +### Network + +- FIX: Fixes for loading images into image nodes (#2964) +- FIX #3025: Added check on mission var 'options', refactoring. (#3055) +- FIX #3057: Use get() to get data from DataSet/View instead of directly accessing member \_data. (#3069) +- FIX #3065: Avoid overriding standard context method ellipse() (#3072) +- FIX #2922: bold label for selected ShapeBase classes (#2924) +- FIX #2952: Pre-render node images for interpolation (#3010) +- FIX #1735: Fix for exploding directed network, first working version; refactored hierarchical state in LayoutEngine.(#3017) +- Refactoring of Label.propagateFonts() (#3052) +- FIX #2894: Set CircleImageBase.imageObjAlt always when options change (#3053) +- FIX #3047: Label.getFormattingValues() fix option fallback to main font for mod-fonts (#3054) +- FIX #2938: Fix handling of node id's in saveAndLoad example (#2943) +- FIX: Refactoring in Canvas.js (#3030) +- FIX #2968: Fix placement label for dot shape (#3018) +- FIX #2994: select edge with id zero (#2996) +- FIX #1847, #2436: Network: use separate refresh indicator in NodeBase, instead of width… (#2885) +- Fix #2914: Use option edges.chosen if present in global options (#2917) +- FIX #2940: Gephi consolidate double assignment of node title (#2962) +- FIX 2936: Fix check for nodes not present in EdgesHandler (#2963) +- FEAT: Reduce the time-complexity of the network initial positioning (#2759) + +### Timeline / Graph2D + +- FEAT: Add support for multiple class names in utils add/remove class methods (#3079) +- FEAT: Adds 'showTooltips' option to override popups displayed for items with titles (#3046) +- FIX #2818: LineGraph: Add an existingItemsMap to check if items are new or not before skipping (#3075) +- FEAT #2835: Improve timeline stack performance (#2848, #3078) +- FIX #3032: mouseup and mousedown events (#3059) +- FIX #2421: Fix click and doubleclick events on items (#2988) +- FEAT #1405, #1715, #3002: Implementation of a week scale feature (#3009) +- FIX #397: Eliminate repeatedly fired `rangechanged` events on mousewheel (#2989) +- FIX #2939: Add check for parent existence when changing group in Item.setData (#2985) +- FIX #2877: Add check for empty groupIds array and get full list from data set (#2986) +- FIX #2614: Timeline docs border overlaps (#2992) +- FIX: Doubleclick add (#2987) +- FIX #2679: Cannot read property 'hasOwnProperty' of null (#2973) +- FEAT #2863: Drag and drop custom fields (#2872) +- FEAT #2834: Control over the drop event (#2974) +- FIX #2918: Remove usages of elementsCensor (#2947) +- FEAT #2948: Rolling mode offset (#2950) +- FEAT #2805: Add callback functions to moveTo, zoomIn, zoomOut and setWindow (#2870) +- FIX: Do not corrupt class names at high zoom levels (#2909) +- FIX #2888: Fix error in class names (#2911) +- FIX #2835: Visible items bug (#2878) + +### Graph3D + +- FEAT: Configurable minimum and maximum sizes for dot-size graphs (#2849) + + +## 2017-03-19, version 4.19.1 + +### General + +* FIX: #2685 Fixed babel dependencies (#2875) + +### Timeline / Graph2D + +* FIX #2809: Fix docs typo in "showNested" (#2879) +* FIX #2594: Fixes for removing and adding items to subgroups (#2821) +* FIX: Allow nested groups to be removed (#2852) + + +## 2017-03-18, version 4.19.0 + +### General + +- FIX: Fix eslint problem on Travis. (#2744) +- added support for eslint (#2695) +- Trivial typo fix in how_to_help doc. (#2714) +- add link to a mentioned example (#2709) +- FEAT: use babel preset2015 for custom builds (#2678) +- FIX: use babel version compatible with webpack@1.14 (#2693) +- FEAT: run mocha tests in travis ci (#2687) +- Add note that PRs should be submitted against the `develop` branch (#2623) +- FIX: Fixes instanceof Object statements for objects from other windows and iFrames. (#2631) +- removed google-analytics from all examples (#2670) +- do not ignore test folder (#2648) +- updated dependencies and devDependencies (#2649) +- general improvements (#2652) + +### Network + +- FEAT: Improve the performance of the network layout engine (#2729) +- FEAT: Allow for image nodes to have a selected or broken image (#2601) + +### Timeline / Graph2D + +- FIX #2842: Prevent redirect to blank after drag and drop in FF (#2871) +- FIX #2810: Nested groups do not use "groupOrder" (#2817) +- FIX #2795: fix date for custom format function (#2826) +- FIX #2689: Add animation options for zoomIn/zoomOut funtions (#2830) +- FIX #2800: Removed all "Object.assign" from examples (#2829) +- FIX #2725: Background items positioning when orientation: top (#2831) +- FEAT: Added data as argument to the template function (#2802) +- FIX #2827: Update "progress bar" example to reflect values (#2828) +- FIX #2672: Item events original event (#2704) +- FIX #2696: Update serialization example to use ISOString dates (#2789) +- FIX #2790: Update examples to use ISOString format (#2791) +- FEAT: Added support to supply an end-time to bar charts to have them scale (#2760) +- FIX #1982, #1417: Modify redraw logic to treat scroll as needing restack (#2774) +- FEAT: Initial tests for timeline ItemSet (#2750) +- FIX #2720: Problems with option editable (#2743, #2796, #2806) +- FIX: Range.js "event" is undeclared (#2749) +- FEAT: added new locales for french and espanol (#2723) +- FIX: fixes timestep next issue (#2732) +- FEAT: #2647 Dynamic rolling mode option (#2705) +- FIX #2679: TypeError: Cannot read property 'hasOwnProperty' of null (#2735) +- Add initial tests for Timeline PointItem (#2716) +- FIX #778: Tooltip does not work with background items in timeline (#2703) +- FIX #2598: Flickering onUpdateTimeTooltip (#2702) +- FEAT: refactor tooltip to only use one dom-element (#2662) +- FEAT: Change setCustomTimeTitle title parameter to be a string or a function (#2611) + +### Graph3D + +- FEAT #2769: Graph3d tooltip styling (#2780) +- FEAT #2540: Adjusted graph3d doc for autoscaling (#2812) +- FIX #2536: 3d bar graph data array unsorted (#2803) +- FEAT: Added showX(YZ)Axis options to Graph3d (#2686) + + +## 2017-01-29, version 4.18.1 + +### General + +- updated dependencies +- FIX: moved babel plugins from devDependencies to dependencies (#2629) + +### Network + +- FIX #2604: Handle label composition for long words (#2650) +- FIX #2640: Network manipulation styles together with Bootstrap styles (#2654) +- FIX #2494: Fix tree collision in hierarchical layout (#2625) +- FIX #2589: Vertically center label in network circle node (#2593) +- FIX #2591: Self reference edge should now appear in all cases (#2595) +- FIX #2613: Fixed return value for zoom in/out callback (#2615) +- FIX #2609: Values should be passed to check values.borderDashes (#2599) + +### Timeline / Graph2D + +- FIX: Fixed htmlContents example (#2651) +- FIX #2590: Min zoom bug (#2646) +- FIX #2597: Zoom while dragging (#2645) +- FIX: Minor cleanups in Timeline Range. (#2633) +- FIX #2458: Allow graph2D options to be undefined (#2634) +- FIX: Fix typo (#2622) +- FIX #2585: Fixed React example (#2587) + + +## 2017-01-15, version 4.18.0 + +### General + +- Readme improvements (#2520) +- Babel updates and fixes (#2466, #2513, #2566) +- Removed dist folder from the develop-branch (#2497) +- updated and cleaned-up npm dependencies (#2518, #2406) +- FEAT: Added CodeClimate tests (#2411) +- FEAT: Added initial Travis-CI support: https://travis-ci.org/almende/vis (#2550) +- FIX #2500: Replace { bool } with { boolean: bool } (#2501, #2506, #2581) +- FIX #2445: Fix YUI Compressor incompatibilities (#2452) +- FIX #2402: make sure a given element isn’t undefined before accessing properties (#2403) +- FIX #2560: IE11 issue 'Symbol' is undefined with babel-polyfill (#2566) +- FIX #2490: Don't pass non-string values to Date.parse (#2534) + +### DataSet + +- FIX: Removed event oldData items (#2535) +- FIX #2528: Fixed deleting item with id 0 (#2530) + +### Network + +- FIX #1911: Fix missing blur edge event (#2554) +- FIX #2478: Fix tooltip issue causing exception when node becomes cluster (#2555) +- FEAT: Change styles if element is selected (#2446) +- FEAT #2306: Add example for network onLoad animation. (#2476) +- FEAT #1845: Adding example of cursor change (#2463) +- FEAT #1603 #1628 #1936 #2298 #2384: Font styles, width and height of network nodes (#2385) +- FEAT: Add pointer position to zoom event (#2377) +- FEAT #1653 #2342: label margins for box, circle, database, icon and text nodes. (#2343) +- FEAT #2233 #2068 #1756: Edit edge without endpoint dragging, and pass label in data (#2329) + +### Timeline / Graph2D + +- FIX: #2522 Right button while dragging item makes items uneditable (#2582) +- FIX #2538: Major axis labels displaying wrong value (#2551) +- FEAT #2516: Added followMouse & overflowMethod to tooltip options (#2544) +- FIX: Fixed tool-tip surviving after item deleted (#2545) +- FIX #2515: Fixed hover events for HTML elements (#2539) +- FIX: Timeline.setGroups for Array (#2529) +- FIX: Error in React example when adding a ranged item (#2521) +- FEAT #226 #2421 #2429: Added mouse events for the timeline (#2473) +- FEAT #497: new stackSubgroups option (#2519, #2527) +- FEAT #338: Added HTML tool-tip support (#2498) +- FIX #2511: readded throttleRedraw option; added DEPRECATED warning (#2514) +- FEAT #2300: Added nested groups (#2416) +- FEAT #2464: Add template support for minor/major labels (#2493) +- FIX #2379: Fix initial drag (#2474) +- FIX #2102: Fix error on click for graph2D when no data is provided (#2472) +- FIX #2469: Fix graph2D render issue (#2470) +- FIX #1126: Add visibleFrameTemplate option for higher item dom content (#2437) +- FIX #2467: Fix Range ctor with optional options parameter (#2468) +- FEAT #1746: Rolling mode (#2439, #2486) +- FIX #2422: Timeline onMove callback (#2427) +- FIX #2370: IE10 drag-and-drop support (#2426) +- FIX #1906: Pass through original hammer.js events (#2420) +- FIX #2327: Add support to fixed times drag and drop (#2372) +- FIX: \_origRedraw sometimes undefined (#2399) +- FIX #2367 #2328: Group editable bug (#2368) +- FIX #2336: Mouse wheel problem on custom time element (#2366) +- FIX #2307: Timeline async initial redraw bug (#2386) +- FIX #2312: Vertical scroll bug with groups and fixed height (#2363) +- FIX #2333: Scrollbar width on browser zoom (#2344) +- Fixed #2319: Bug in TimeStep.prototype.getClassName (#2335) +- FEAT #257: Added option to change the visibility of a group (#2315) +- FEAT: More editable control of timeline items (#2305) +- FIX #2273: Cannot scroll page when zoomKey is enabled (#2301) +- FIX #2295, 2263: Issues with vertical scroll and maxHeight (#2302) +- FIX #2285: onUpdate event (#2304) +- FIX: Timeline-docs: updated group.content description to show that it can be an element (#2296) +- FIX #2251: No axis after daylight saving (#2290) +- FEAT #2256: Timeline editable can override items (#2284) +- FEAT: Graph2d performance enhancement (#2281) + +### Graph3D + +- FEAT #2451: Allow pass the color of points in 'dot-color' mode of Graph3D (#2489) +- FEAT: Improvement for camera 3d moving (#2340) +- FEAT: Add ability to move graph3d by left mouse button while pressing ctrl key and rotate like before (#2357) +- FIX: Fixed label disappearing bug for large axis values in graph3d (#2348) +- FIX: Fixed Grpah3D-docs: Changed "an" to "and" in graph3D docs (#2313) +- FIX #2274: Graph3d disappears when setSize is called (#2293) +- FIX: Fixed typo in index.html of Graph3D (#2286) + ## 2016-11-05, version 4.17.0 @@ -14,7 +391,7 @@ http://visjs.org - Fixed #2170: Improved the contribution docs (#1991, #2158, #2178, #2183, #2213, #2218, #2219) - Implemented #1969: generate individual css files for network and timeline (#1970) - Cleanup bower.json (#1968) -- Removed feature-request page from website (TODO) +- Fixed #2114: Removed feature-request page from website - Distinguish better between `devDependencies` and `dependencies` (#1967) - Typos and minor docs improvements (#1958, #2028, #2050, #2093, #2222, #2223, #2224) - Replaced `gulp-minify-css` with `gulp-clean-css` (#1953) @@ -266,7 +643,7 @@ http://visjs.org ### General - Fixed #1353: Custom bundling with browserify requiring manual installation - of `babelify`. + of `babelify`. ### Network @@ -451,7 +828,7 @@ http://visjs.org - Fixed #1033: Moved item data not updated in DataSet when using an asynchronous `onMove` handler. - Fixed #239: Do not zoom/move the window when the mouse is on the left panel - with group labels. + with group labels. ## 2015-07-03, version 4.4.0 diff --git a/LICENSE-MIT b/LICENSE-MIT index 61da206c0..ca928f7bb 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014-2016 Almende B.V. +Copyright (c) 2014-2017 Almende B.V. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/NOTICE b/NOTICE deleted file mode 100644 index a5ef8dbf5..000000000 --- a/NOTICE +++ /dev/null @@ -1,33 +0,0 @@ -Vis.js -Copyright 2010-2016 Almende B.V. - -Vis.js is dual licensed under both - - * The Apache 2.0 License - http://www.apache.org/licenses/LICENSE-2.0 - - and - - * The MIT License - http://opensource.org/licenses/MIT - -Vis.js may be distributed under either license. - - -Vis.js uses and redistributes the following third-party libraries: - -- component-emitter - https://github.com/component/emitter - The MIT License - -- hammer.js - http://hammerjs.github.io/ - The MIT License - -- moment.js - http://momentjs.com/ - The MIT License - -- keycharm - https://github.com/AlexDM0/keycharm - The MIT License diff --git a/README.md b/README.md index e8a70a625..1cf09b61f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,24 @@ -vis.js -================== +# vis.js (deprecated!) +<<<<<<< HEAD [![Join the chat at https://gitter.im/vis-js/Lobby](https://badges.gitter.im/vis-js/Lobby.svg)](https://gitter.im/vis-js/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +======= +:exclamation: **This project is not maintained anymore! (See [Issue #4259](http://github.com/almende/vis/issues/4259#issue-412107497) for details)**
**We welcome you to use the libraries from the [visjs community](https://www.github.com/visjs) from now on.** + +--- +>>>>>>> 0b2a54a1684b3f15b4dd280cfbc68295d8edb48a Vis.js is a dynamic, browser based visualization library. The library is designed to be easy to use, handle large amounts of dynamic data, and enable manipulation of the data. The library consists of the following components: -- DataSet and DataView. A flexible key/value based data set. Add, update, and - remove items. Subscribe on changes in the data set. A DataSet can filter and +- DataSet and DataView. A flexible key/value based data set. Add, update, and + remove items. Subscribe on changes in the data set. A DataSet can filter and order items, and convert fields of items. - DataView. A filtered and/or formatted view on a DataSet. - Graph2d. Plot data on a timeline with lines or barcharts. @@ -21,6 +26,7 @@ The library consists of the following components: - Network. Display a network (force directed graph) with nodes and edges. - Timeline. Display different types of data on a timeline. +<<<<<<< HEAD The vis.js library was initialy developed by [Almende B.V](http://almende.com). ## Badges @@ -37,34 +43,39 @@ The vis.js library was initialy developed by [Almende B.V](http://almende.com). [![Pending Pull-Requests](http://githubbadges.herokuapp.com/almende/vis/pulls.svg)](https://github.com/almende/vis/pulls) [![Code Climate](https://codeclimate.com/github/almende/vis/badges/gpa.svg)](https://codeclimate.com/github/almende/vis) +======= +The vis.js library was initially developed by [Almende B.V](http://almende.com). +>>>>>>> 0b2a54a1684b3f15b4dd280cfbc68295d8edb48a ## Install Install via npm: - $ npm install vis +```sh +npm install vis +``` Install via bower: - $ bower install vis +```sh +bower install vis +``` -Link via cdnjs: http://cdnjs.com +Link via cdnjs: https://cdnjs.com/libraries/vis Or download the library from the github project: [https://github.com/almende/vis.git](https://github.com/almende/vis.git). - ## Load - To use a component, include the javascript and css files of vis in your web page: ```html - - + + - + + - +

Graph2d | Both Axis Example

diff --git a/examples/graph2d/06_interpolation.html b/examples/graph2d/06_interpolation.html index 9daa7e078..4dc4d2851 100644 --- a/examples/graph2d/06_interpolation.html +++ b/examples/graph2d/06_interpolation.html @@ -11,7 +11,7 @@ - +

Graph2d | Interpolation

diff --git a/examples/graph2d/07_scrollingAndSorting.html b/examples/graph2d/07_scrollingAndSorting.html index f9328dc75..9f0f4abc8 100644 --- a/examples/graph2d/07_scrollingAndSorting.html +++ b/examples/graph2d/07_scrollingAndSorting.html @@ -11,7 +11,7 @@ - +

Graph2d | Scrolling and Sorting

diff --git a/examples/graph2d/08_performance.html b/examples/graph2d/08_performance.html index 9a2ad2e10..17f051529 100644 --- a/examples/graph2d/08_performance.html +++ b/examples/graph2d/08_performance.html @@ -19,7 +19,7 @@ - +

Graph2d | Performance

@@ -147,4 +147,4 @@

Graph2d | Performance

var graph2d = new vis.Graph2d(container, dataset, options); - \ No newline at end of file + diff --git a/examples/graph2d/09_external_legend.html b/examples/graph2d/09_external_legend.html index 8ec82fa8d..07994b3de 100644 --- a/examples/graph2d/09_external_legend.html +++ b/examples/graph2d/09_external_legend.html @@ -190,7 +190,7 @@ - +

Graph2d | External custom legend

diff --git a/examples/graph2d/10_barsSideBySide.html b/examples/graph2d/10_barsSideBySide.html index 7afdf6ef2..6022df0c0 100644 --- a/examples/graph2d/10_barsSideBySide.html +++ b/examples/graph2d/10_barsSideBySide.html @@ -11,7 +11,7 @@ - +

Graph2d | Bar Graphs Side by Side Example

diff --git a/examples/graph2d/11_barsSideBySideGroups.html b/examples/graph2d/11_barsSideBySideGroups.html index b1ed15044..7eb946175 100644 --- a/examples/graph2d/11_barsSideBySideGroups.html +++ b/examples/graph2d/11_barsSideBySideGroups.html @@ -11,7 +11,7 @@ - +

Graph2d | Bar Graphs Side by Side Example with Groups

diff --git a/examples/graph2d/12_customRange.html b/examples/graph2d/12_customRange.html index 0e85ab53e..92fa59bec 100644 --- a/examples/graph2d/12_customRange.html +++ b/examples/graph2d/12_customRange.html @@ -11,7 +11,7 @@ - +

Graph2d | Custom axis range

diff --git a/examples/graph2d/13_localization.html b/examples/graph2d/13_localization.html index c0130d5d6..7fc7227f5 100644 --- a/examples/graph2d/13_localization.html +++ b/examples/graph2d/13_localization.html @@ -16,7 +16,7 @@ - +

Graph2d | Localization

diff --git a/examples/graph2d/14_toggleGroups.html b/examples/graph2d/14_toggleGroups.html index 60db882bf..54b0e93aa 100644 --- a/examples/graph2d/14_toggleGroups.html +++ b/examples/graph2d/14_toggleGroups.html @@ -17,7 +17,7 @@ - +

Graph2d | Groups Example

diff --git a/examples/graph2d/15_streaming_data.html b/examples/graph2d/15_streaming_data.html index 1a8223911..34696853f 100644 --- a/examples/graph2d/15_streaming_data.html +++ b/examples/graph2d/15_streaming_data.html @@ -14,7 +14,7 @@ - +

Graph2d | Streaming data

diff --git a/examples/graph2d/16_bothAxisTitles.html b/examples/graph2d/16_bothAxisTitles.html index 5fe0e6d8d..901fce515 100644 --- a/examples/graph2d/16_bothAxisTitles.html +++ b/examples/graph2d/16_bothAxisTitles.html @@ -36,7 +36,7 @@ } - +

Graph2d | Axis Titles and Styling

diff --git a/examples/graph2d/17_dynamicStyling.html b/examples/graph2d/17_dynamicStyling.html index 01beacf45..b5a01eee6 100644 --- a/examples/graph2d/17_dynamicStyling.html +++ b/examples/graph2d/17_dynamicStyling.html @@ -14,7 +14,7 @@ - +

Graph2d | Dynamic Styling Example

diff --git a/examples/graph2d/18_scatterplot.html b/examples/graph2d/18_scatterplot.html index bdeb63e45..a55ffd411 100644 --- a/examples/graph2d/18_scatterplot.html +++ b/examples/graph2d/18_scatterplot.html @@ -11,7 +11,7 @@ - +

Graph2d | Scatterplot

diff --git a/examples/graph2d/19_labels.html b/examples/graph2d/19_labels.html index f93853ebb..d7bb50a7e 100644 --- a/examples/graph2d/19_labels.html +++ b/examples/graph2d/19_labels.html @@ -18,7 +18,7 @@ - +

Graph2d | Label Example

diff --git a/examples/graph2d/20_shading.html b/examples/graph2d/20_shading.html index 1bfb4bdb8..e585c909a 100644 --- a/examples/graph2d/20_shading.html +++ b/examples/graph2d/20_shading.html @@ -12,7 +12,7 @@ - +

Graph2d | Shading Example

diff --git a/examples/graph2d/21_barsWithEnd.html b/examples/graph2d/21_barsWithEnd.html new file mode 100644 index 000000000..c5d131ff1 --- /dev/null +++ b/examples/graph2d/21_barsWithEnd.html @@ -0,0 +1,51 @@ + + + + Graph2d | Bar Graph Example + + + + + + + +

Graph2d | Bar Graph With End Example

+
+ This example shows how you can plot a bar chart and supply an end value to have it fill. +
+
+ +
+ + + + \ No newline at end of file diff --git a/examples/graph3d/01_basics.html b/examples/graph3d/01_basics.html index 10cd9e550..48a319a6e 100644 --- a/examples/graph3d/01_basics.html +++ b/examples/graph3d/01_basics.html @@ -50,7 +50,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/02_camera.html b/examples/graph3d/02_camera.html index bbc9f7d0e..52d938d08 100644 --- a/examples/graph3d/02_camera.html +++ b/examples/graph3d/02_camera.html @@ -79,7 +79,7 @@ graph.on('cameraPositionChange', onCameraPositionChange); } - + diff --git a/examples/graph3d/03_filter_data.html b/examples/graph3d/03_filter_data.html index 4fd4a7395..859dba674 100644 --- a/examples/graph3d/03_filter_data.html +++ b/examples/graph3d/03_filter_data.html @@ -53,7 +53,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/04_animation.html b/examples/graph3d/04_animation.html index c0d996643..ec4c46296 100644 --- a/examples/graph3d/04_animation.html +++ b/examples/graph3d/04_animation.html @@ -51,8 +51,7 @@ keepAspectRatio: true, verticalRatio: 0.5, animationInterval: 100, // milliseconds - animationPreload: true, - filterValue: 'time' + animationPreload: true }; // create our graph @@ -60,7 +59,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/05_line.html b/examples/graph3d/05_line.html index 40de188e6..fbae24d1f 100644 --- a/examples/graph3d/05_line.html +++ b/examples/graph3d/05_line.html @@ -49,7 +49,7 @@ graph.setCameraPosition(0.4, undefined, undefined); } - + diff --git a/examples/graph3d/06_moving_dots.html b/examples/graph3d/06_moving_dots.html index b24bb5208..91ca78811 100644 --- a/examples/graph3d/06_moving_dots.html +++ b/examples/graph3d/06_moving_dots.html @@ -67,7 +67,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/07_dot_cloud_colors.html b/examples/graph3d/07_dot_cloud_colors.html index 07cab6de8..cc10df6e6 100644 --- a/examples/graph3d/07_dot_cloud_colors.html +++ b/examples/graph3d/07_dot_cloud_colors.html @@ -13,6 +13,9 @@ var data = null; var graph = null; + function onclick(point) { + console.log(point); + } // Called when the Visualization API is loaded. function drawVisualization() { @@ -30,9 +33,9 @@ var x = pow(random(), 2); var y = pow(random(), 2); var z = pow(random(), 2); - var dist = sqrt(pow(x, 2) + pow(y, 2) + pow(z, 2)); + var style = (i%2==0) ? sqrt(pow(x, 2) + pow(y, 2) + pow(z, 2)) : "#00ffff"; - data.add({x:x,y:y,z:z,style:dist}); + data.add({x:x,y:y,z:z,style:style}); } // specify options @@ -45,6 +48,7 @@ keepAspectRatio: true, verticalRatio: 1.0, legendLabel: 'distance', + onclick: onclick, cameraPosition: { horizontal: -0.35, vertical: 0.22, @@ -57,7 +61,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/08_dot_cloud_size.html b/examples/graph3d/08_dot_cloud_size.html index 462181087..99ab8ffdd 100644 --- a/examples/graph3d/08_dot_cloud_size.html +++ b/examples/graph3d/08_dot_cloud_size.html @@ -49,7 +49,9 @@ horizontal: -0.54, vertical: 0.5, distance: 1.6 - } + }, + dotSizeMinFraction: 0.5, + dotSizeMaxFraction: 2.5 }; // create our graph @@ -57,7 +59,7 @@ graph = new vis.Graph3d(container, data, options); } - +
diff --git a/examples/graph3d/09_mobile.html b/examples/graph3d/09_mobile.html index b3a99598d..0c09ab127 100644 --- a/examples/graph3d/09_mobile.html +++ b/examples/graph3d/09_mobile.html @@ -70,7 +70,7 @@ graph = new vis.Graph3d(container, data, options); } - + diff --git a/examples/graph3d/10_styling.html b/examples/graph3d/10_styling.html index 033b7ba50..bd3eb5bbe 100644 --- a/examples/graph3d/10_styling.html +++ b/examples/graph3d/10_styling.html @@ -58,7 +58,7 @@ keepAspectRatio: true, verticalRatio: 0.5 }; - + var camera = graph ? graph.getCameraPosition() : null; // create our graph @@ -73,7 +73,7 @@ document.getElementById('yBarWidth').onchange = drawVisualization; } - + diff --git a/examples/graph3d/11_tooltips.html b/examples/graph3d/11_tooltips.html index c37b35f4d..d28210710 100644 --- a/examples/graph3d/11_tooltips.html +++ b/examples/graph3d/11_tooltips.html @@ -64,12 +64,26 @@ showShadow: false, // Option tooltip can be true, false, or a function returning a string with HTML contents - //tooltip: true, tooltip: function (point) { // parameter point contains properties x, y, z, and data // data is the original object passed to the point constructor return 'value: ' + point.z + '
' + point.data.extra; }, + + // Tooltip default styling can be overridden + tooltipStyle: { + content: { + background : 'rgba(255, 255, 255, 0.7)', + padding : '10px', + borderRadius : '10px' + }, + line: { + borderLeft : '1px dotted rgba(0, 0, 0, 0.5)' + }, + dot: { + border : '5px solid rgba(0, 0, 0, 0.5)' + } + }, keepAspectRatio: true, verticalRatio: 0.5 @@ -86,7 +100,7 @@ document.getElementById('style').onchange = drawVisualization; } - + diff --git a/examples/graph3d/12_custom_labels.html b/examples/graph3d/12_custom_labels.html index b6fe81761..457501782 100644 --- a/examples/graph3d/12_custom_labels.html +++ b/examples/graph3d/12_custom_labels.html @@ -85,7 +85,7 @@ document.getElementById('style').onchange = drawVisualization; } - + diff --git a/examples/graph3d/playground/csv2datatable.html b/examples/graph3d/playground/csv2datatable.html index 08d3c65de..35dc9bd0f 100644 --- a/examples/graph3d/playground/csv2datatable.html +++ b/examples/graph3d/playground/csv2datatable.html @@ -58,7 +58,7 @@ alert(csvArray.length + " rows converted"); } - +
diff --git a/examples/graph3d/playground/datasource.html b/examples/graph3d/playground/datasource.html index 7a5936041..efb47e1c9 100644 --- a/examples/graph3d/playground/datasource.html +++ b/examples/graph3d/playground/datasource.html @@ -6,7 +6,7 @@ - + diff --git a/examples/graph3d/playground/index.html b/examples/graph3d/playground/index.html index 134bb2647..25d891c5c 100644 --- a/examples/graph3d/playground/index.html +++ b/examples/graph3d/playground/index.html @@ -16,7 +16,7 @@ // TODO } - + @@ -112,6 +112,19 @@

Options

showGrid + + showXAxis + + + + showYAxis + + + + showZAxis + + + showPerspective diff --git a/examples/graph3d/playground/playground.js b/examples/graph3d/playground/playground.js index a2e287282..3e428ec7d 100644 --- a/examples/graph3d/playground/playground.js +++ b/examples/graph3d/playground/playground.js @@ -304,7 +304,7 @@ function getDataType() { /** * Retrieve the datatable from the entered contents of the csv text * @param {boolean} [skipValue] | if true, the 4th element is a filter value - * @return {vis DataSet} + * @return {vis.DataSet} */ function getDataCsv() { var csv = document.getElementById("csvTextarea").value; @@ -366,7 +366,7 @@ function trim(text) { /** * Retrieve the datatable from the entered contents of the javascript text - * @return {vis Dataset} + * @return {vis.DataSet} */ function getDataJson() { var json = document.getElementById("jsonTextarea").value; @@ -378,7 +378,7 @@ function getDataJson() { /** * Retrieve the datatable from the entered contents of the javascript text - * @return {vis Dataset} + * @return {vis.DataSet} */ function getDataJavascript() { var js = document.getElementById("javascriptTextarea").value; @@ -391,7 +391,7 @@ function getDataJavascript() { /** * Retrieve the datatable from the entered contents of the datasource text - * @return {vis Dataset} + * @return {vis.DataSet} */ function getDataDatasource() { } @@ -400,18 +400,21 @@ function getDataDatasource() { * Retrieve a JSON object with all options */ function getOptions() { - return { + var options = { width: document.getElementById("width").value, height: document.getElementById("height").value, style: document.getElementById("style").value, showAnimationControls: (document.getElementById("showAnimationControls").checked != false), showGrid: (document.getElementById("showGrid").checked != false), + showXAxis: (document.getElementById("showXAxis").checked != false), + showYAxis: (document.getElementById("showYAxis").checked != false), + showZAxis: (document.getElementById("showZAxis").checked != false), showPerspective: (document.getElementById("showPerspective").checked != false), showLegend: (document.getElementById("showLegend").checked != false), showShadow: (document.getElementById("showShadow").checked != false), keepAspectRatio: (document.getElementById("keepAspectRatio").checked != false), - verticalRatio: document.getElementById("verticalRatio").value, - animationInterval: document.getElementById("animationInterval").value, + verticalRatio: Number(document.getElementById("verticalRatio").value) || undefined, + animationInterval: Number(document.getElementById("animationInterval").value) || undefined, xLabel: document.getElementById("xLabel").value, yLabel: document.getElementById("yLabel").value, zLabel: document.getElementById("zLabel").value, @@ -420,8 +423,8 @@ function getOptions() { animationPreload: (document.getElementById("animationPreload").checked != false), animationAutoStart:(document.getElementById("animationAutoStart").checked != false), - xCenter: Number(document.getElementById("xCenter").value) || undefined, - yCenter: Number(document.getElementById("yCenter").value) || undefined, + xCenter: document.getElementById("xCenter").value, + yCenter: document.getElementById("yCenter").value, xMin: Number(document.getElementById("xMin").value) || undefined, xMax: Number(document.getElementById("xMax").value) || undefined, @@ -439,6 +442,8 @@ function getOptions() { xBarWidth: Number(document.getElementById("xBarWidth").value) || undefined, yBarWidth: Number(document.getElementById("yBarWidth").value) || undefined }; + + return options; } /** diff --git a/examples/network/basicUsage.html b/examples/network/basicUsage.html index 2b8b086c8..a89aefa0c 100644 --- a/examples/network/basicUsage.html +++ b/examples/network/basicUsage.html @@ -37,7 +37,8 @@ {from: 1, to: 3}, {from: 1, to: 2}, {from: 2, to: 4}, - {from: 2, to: 5} + {from: 2, to: 5}, + {from: 3, to: 3} ]); // create a network @@ -50,6 +51,6 @@ var network = new vis.Network(container, data, options); - + diff --git a/examples/network/data/datasets.html b/examples/network/data/datasets.html index c4bb5f8f2..81bb8ca64 100644 --- a/examples/network/data/datasets.html +++ b/examples/network/data/datasets.html @@ -135,6 +135,6 @@

Cleanly destroy the network and restart it:

startNetwork(); - + diff --git a/examples/network/data/dotLanguage/dotEdgeStyles.html b/examples/network/data/dotLanguage/dotEdgeStyles.html new file mode 100644 index 000000000..9d5644b08 --- /dev/null +++ b/examples/network/data/dotLanguage/dotEdgeStyles.html @@ -0,0 +1,197 @@ + + + + Network | DOT edge styles + + + + + + + + + + + + +
+
+ +
+ +
+ + + + diff --git a/examples/network/data/dotLanguage/dotLanguage.html b/examples/network/data/dotLanguage/dotLanguage.html index 318aa91d6..a57ebaf40 100644 --- a/examples/network/data/dotLanguage/dotLanguage.html +++ b/examples/network/data/dotLanguage/dotLanguage.html @@ -4,7 +4,7 @@ - +

diff --git a/examples/network/data/dotLanguage/dotPlayground.html b/examples/network/data/dotLanguage/dotPlayground.html index b31bfd48a..217736d3d 100644 --- a/examples/network/data/dotLanguage/dotPlayground.html +++ b/examples/network/data/dotLanguage/dotPlayground.html @@ -82,7 +82,7 @@ } - + diff --git a/examples/network/data/dynamicData.html b/examples/network/data/dynamicData.html index 1c54e34ba..8b53e601d 100644 --- a/examples/network/data/dynamicData.html +++ b/examples/network/data/dynamicData.html @@ -166,7 +166,7 @@ } - + diff --git a/examples/network/data/importingFromGephi.html b/examples/network/data/importingFromGephi.html index f39f15a4c..d57241635 100644 --- a/examples/network/data/importingFromGephi.html +++ b/examples/network/data/importingFromGephi.html @@ -51,7 +51,7 @@ color: red; } - + diff --git a/examples/network/data/scalingCustom.html b/examples/network/data/scalingCustom.html index b1ffede50..a059fe8e7 100644 --- a/examples/network/data/scalingCustom.html +++ b/examples/network/data/scalingCustom.html @@ -75,7 +75,7 @@ network = new vis.Network(container, data, options); } - +

diff --git a/examples/network/data/scalingNodesEdges.html b/examples/network/data/scalingNodesEdges.html index a19e7e100..748b421e4 100644 --- a/examples/network/data/scalingNodesEdges.html +++ b/examples/network/data/scalingNodesEdges.html @@ -68,7 +68,7 @@ network = new vis.Network(container, data, options); } - +

diff --git a/examples/network/data/scalingNodesEdgesLabels.html b/examples/network/data/scalingNodesEdgesLabels.html index fc41fbb7e..6316d8b7c 100644 --- a/examples/network/data/scalingNodesEdgesLabels.html +++ b/examples/network/data/scalingNodesEdgesLabels.html @@ -74,7 +74,7 @@ network = new vis.Network(container, data, options); } - +

diff --git a/examples/network/edgeStyles/arrowTypes.html b/examples/network/edgeStyles/arrowTypes.html index 25cf63bf2..4adcc1d22 100644 --- a/examples/network/edgeStyles/arrowTypes.html +++ b/examples/network/edgeStyles/arrowTypes.html @@ -17,7 +17,8 @@

- There two type of liner endings. The classical "arrow" (default) and "circle". + The types of endpoints are: 'arrow' 'circle' 'bar'. + The default is 'arrow'.

@@ -25,9 +26,10 @@ - + diff --git a/examples/network/edgeStyles/arrows.html b/examples/network/edgeStyles/arrows.html index b9d436361..517589261 100644 --- a/examples/network/edgeStyles/arrows.html +++ b/examples/network/edgeStyles/arrows.html @@ -56,6 +56,6 @@ var network = new vis.Network(container, data, options); - + diff --git a/examples/network/edgeStyles/colors.html b/examples/network/edgeStyles/colors.html index 99d98b114..7eb7a7ada 100644 --- a/examples/network/edgeStyles/colors.html +++ b/examples/network/edgeStyles/colors.html @@ -30,14 +30,14 @@ - + diff --git a/examples/network/edgeStyles/dashes.html b/examples/network/edgeStyles/dashes.html index 941fdc5ea..2fa846c8b 100644 --- a/examples/network/edgeStyles/dashes.html +++ b/examples/network/edgeStyles/dashes.html @@ -52,6 +52,6 @@ var network = new vis.Network(container, data, options); - + diff --git a/examples/network/edgeStyles/smooth.html b/examples/network/edgeStyles/smooth.html index 2b222bdbf..3f52423fb 100644 --- a/examples/network/edgeStyles/smooth.html +++ b/examples/network/edgeStyles/smooth.html @@ -13,7 +13,7 @@ border: 1px solid lightgray; } - + diff --git a/examples/network/edgeStyles/smoothWorldCup.html b/examples/network/edgeStyles/smoothWorldCup.html index 02b95d6be..03a8f8d22 100644 --- a/examples/network/edgeStyles/smoothWorldCup.html +++ b/examples/network/edgeStyles/smoothWorldCup.html @@ -20,7 +20,7 @@ height:280px; } - + diff --git a/examples/network/events/interactionEvents.html b/examples/network/events/interactionEvents.html index 33f1ae702..682ca0850 100644 --- a/examples/network/events/interactionEvents.html +++ b/examples/network/events/interactionEvents.html @@ -24,6 +24,7 @@

 
 
 
-
+
 
 
diff --git a/examples/network/events/physicsEvents.html b/examples/network/events/physicsEvents.html
index 418f403a0..bd11c3c7f 100644
--- a/examples/network/events/physicsEvents.html
+++ b/examples/network/events/physicsEvents.html
@@ -68,6 +68,6 @@
 
 
 
-
+
 
 
diff --git a/examples/network/events/renderEvents.html b/examples/network/events/renderEvents.html
index e28e4ea3a..426cad90a 100644
--- a/examples/network/events/renderEvents.html
+++ b/examples/network/events/renderEvents.html
@@ -78,6 +78,6 @@
 
 
 
-
+
 
 
diff --git a/examples/network/exampleApplications/disassemblerExample.html b/examples/network/exampleApplications/disassemblerExample.html
index 8ecd8e395..a7ab0eb1a 100644
--- a/examples/network/exampleApplications/disassemblerExample.html
+++ b/examples/network/exampleApplications/disassemblerExample.html
@@ -9,59 +9,16 @@
   
   
   
+  
  
 
 

Use VisJS to diagram the Control-Flow-Graph (CFG) of a function from a program you wish to analyze.


diff --git a/examples/network/exampleApplications/disassemblerExample.js b/examples/network/exampleApplications/disassemblerExample.js new file mode 100644 index 000000000..c22fb2897 --- /dev/null +++ b/examples/network/exampleApplications/disassemblerExample.js @@ -0,0 +1,53 @@ +var options = { + manipulation: false, + height: '90%', + layout: { + hierarchical: { + enabled: true, + levelSeparation: 300 + } + }, + physics: { + hierarchicalRepulsion: { + nodeDistance: 300 + } + } +}; + +var nodes = [ + {'id': 'cfg_0x00405a2e', 'size': 150, 'label': "0x00405a2e:\nmov DWORD PTR ss:[esp + 0x000000b0], 0x00000002\nmov DWORD PTR ss:[ebp + 0x00], esi\ntest bl, 0x02\nje 0x00405a49<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a49', 'size': 150, 'label': "0x00405a49:\ntest bl, 0x01\nje 0x00405a62<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a55', 'size': 150, 'label': "0x00405a55:\nmov ecx, DWORD PTR ss:[esp + 0x1c]\npush ecx\ncall 0x004095c6<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a62', 'size': 150, 'label': "0x00405a62:\nmov eax, 0x00000002\nmov ecx, DWORD PTR ss:[esp + 0x000000a8]\nmov DWORD PTR fs:[0x00000000], ecx\npop ecx\npop esi\npop ebp\npop ebx\nadd esp, 0x000000a4\nret\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x004095c6', 'size': 150, 'label': "0x004095c6:\nmov edi, edi\npush ebp\nmov ebp, esp\npop ebp\njmp 0x00417563<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a39', 'size': 150, 'label': "0x00405a39:\nand ebx, 0xfd<-0x03>\nlea ecx, [esp + 0x34]\nmov DWORD PTR ss:[esp + 0x10], ebx\ncall 0x00403450<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00403450', 'size': 150, 'label': "0x00403450:\npush 0xff<-0x01>\npush 0x0042fa64\nmov eax, DWORD PTR fs:[0x00000000]\npush eax\npush ecx\npush ebx\npush ebp\npush esi\npush edi\nmov eax, DWORD PTR ds:[0x0043dff0<.data+0x0ff0>]\nxor eax, esp\npush eax\nlea eax, [esp + 0x18]\nmov DWORD PTR fs:[0x00000000], eax\nmov esi, ecx\nmov DWORD PTR ss:[esp + 0x14], esi\npush esi\nmov DWORD PTR ss:[esp + 0x24], 0x00000004\ncall 0x0042f03f<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a4e', 'size': 150, 'label': "0x00405a4e:\ncmp DWORD PTR ss:[esp + 0x30], 0x10\njb 0x00405a62<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a5f', 'size': 150, 'label': "0x00405a5f:\nadd esp, 0x04\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, +]; + + +// +// Note: there are a couple of node id's present here which do not exist +// - cfg_0x00417563 +// - cfg_0x00403489 +// - cfg_0x0042f03f +// +// The edges with these id's will not load into the Network instance. +// +var edges = [ +{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a39", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a4e", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a55", 'to': "cfg_0x00405a5f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a55", 'to': "cfg_0x004095c6", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x004095c6", 'to': "cfg_0x00417563", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a39", 'to': "cfg_0x00403450", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a39", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00403450", 'to': "cfg_0x00403489", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00403450", 'to': "cfg_0x0042f03f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a55", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +{'from': "cfg_0x00405a5f", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}}, +]; diff --git a/examples/network/exampleApplications/lesMiserables.html b/examples/network/exampleApplications/lesMiserables.html index b97c32b0c..681cef128 100644 --- a/examples/network/exampleApplications/lesMiserables.html +++ b/examples/network/exampleApplications/lesMiserables.html @@ -383,7 +383,7 @@ } - + diff --git a/examples/network/exampleApplications/loadingBar.html b/examples/network/exampleApplications/loadingBar.html index 519289e4b..c00f5bdb0 100644 --- a/examples/network/exampleApplications/loadingBar.html +++ b/examples/network/exampleApplications/loadingBar.html @@ -481,7 +481,7 @@ } - + diff --git a/examples/network/exampleApplications/neighbourhoodHighlight.html b/examples/network/exampleApplications/neighbourhoodHighlight.html index 8e05d4824..f750e34c3 100644 --- a/examples/network/exampleApplications/neighbourhoodHighlight.html +++ b/examples/network/exampleApplications/neighbourhoodHighlight.html @@ -15,7 +15,7 @@ border: 1px solid lightgray; } - + diff --git a/examples/network/exampleApplications/nodeLegend.html b/examples/network/exampleApplications/nodeLegend.html index 69769d9b2..ca4b40e46 100644 --- a/examples/network/exampleApplications/nodeLegend.html +++ b/examples/network/exampleApplications/nodeLegend.html @@ -151,7 +151,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/exampleApplications/worldCupPerformance.html b/examples/network/exampleApplications/worldCupPerformance.html index 7de0667b0..dbd416117 100644 --- a/examples/network/exampleApplications/worldCupPerformance.html +++ b/examples/network/exampleApplications/worldCupPerformance.html @@ -17,7 +17,7 @@ border: 1px solid lightgray; } - + diff --git a/examples/network/imageSelected/broken-image.png b/examples/network/imageSelected/broken-image.png new file mode 100644 index 000000000..c91071959 Binary files /dev/null and b/examples/network/imageSelected/broken-image.png differ diff --git a/examples/network/imageSelected/imageSelected.html b/examples/network/imageSelected/imageSelected.html new file mode 100644 index 000000000..ea6f70ddb --- /dev/null +++ b/examples/network/imageSelected/imageSelected.html @@ -0,0 +1,82 @@ + + + Network | Selected/Unselected Image + + + + + + + + +
+ + + + diff --git a/examples/network/imageSelected/selected.svg b/examples/network/imageSelected/selected.svg new file mode 100644 index 000000000..a15c04af1 --- /dev/null +++ b/examples/network/imageSelected/selected.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/network/imageSelected/unselected.svg b/examples/network/imageSelected/unselected.svg new file mode 100644 index 000000000..538cb2559 --- /dev/null +++ b/examples/network/imageSelected/unselected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/network/labels/labelAlignment.html b/examples/network/labels/labelAlignment.html index 42eb9bd07..78b47717d 100644 --- a/examples/network/labels/labelAlignment.html +++ b/examples/network/labels/labelAlignment.html @@ -9,14 +9,14 @@ - + @@ -25,8 +25,12 @@

Text-alignment within node labels can be 'left' or 'center', other font alignments not implemented.

Label alignment (placement of label "box") for nodes (top, bottom, left, right, inside) is planned but not in vis yet.

+

The click event is captured and displayed to illustrate how the clicking on labels works. +You can drag the nodes over each other to see how this influences the click event values. +

+

 
 
 
 
diff --git a/examples/network/labels/labelBackground.html b/examples/network/labels/labelBackground.html
index 01664a67b..4341f965b 100644
--- a/examples/network/labels/labelBackground.html
+++ b/examples/network/labels/labelBackground.html
@@ -16,7 +16,7 @@
       max-width:600px;
     }
   
-  
+  
 
 
 
diff --git a/examples/network/labels/labelColorAndSize.html b/examples/network/labels/labelColorAndSize.html
index f91a0d22d..90c5767ce 100644
--- a/examples/network/labels/labelColorAndSize.html
+++ b/examples/network/labels/labelColorAndSize.html
@@ -16,7 +16,7 @@
       max-width:600px;
     }
   
-  
+  
 
 
 
diff --git a/examples/network/labels/labelMargins.html b/examples/network/labels/labelMargins.html
new file mode 100644
index 000000000..e91a9a38b
--- /dev/null
+++ b/examples/network/labels/labelMargins.html
@@ -0,0 +1,63 @@
+
+
+
+  Network | Label margins
+
+  
+  
+
+  
+  
+
+
+
+
+

The labels of box, circle, database, icon and text nodes may have different margin values. + Top, right, bottom and left margins may be different for each node.

+

Setting the margin value in the network's nodes property sets it as the default.

+

Setting a the value to a number uses that number for the margins. If the value is an object, a different value for each margin will be set.

+

Note that negative values appropriately push labels outside the node. + +

+ + + + + diff --git a/examples/network/labels/labelMultifont.html b/examples/network/labels/labelMultifont.html new file mode 100644 index 000000000..6f3b75e4b --- /dev/null +++ b/examples/network/labels/labelMultifont.html @@ -0,0 +1,115 @@ + + + + Network | Multifont Labels + + + + + + + + + + +

Node and edge labels may be marked up to be drawn with multiple fonts.

+ +
+ +

The value of the font.multi property may be set to 'html', 'markdown' or a boolean.

+ + + + + + + +
Embedded Font Markup
font modfont.multi setting
'html' or true'markdown' or 'md'false
bold<b> ... </b> * ... n/a
italic<i> ... </i> _ ... n/a
mono-spaced<code> ... </code> ` ... n/a
+ +

+The html and markdown rendering is limited: bolds may be embedded in italics, italics may be embedded in bolds, and mono-spaced may be embedded in bold or italic, but will not be altered by those font mods, nor will embedded bolds or italics be handled. +The only entities that will be observed in html are &lt; and &amp; and in markdown a backslash will escape the following character (including a backslash) from special processing. +Any font mod that is started in a label line will be implicitly terminated at the end of that line. +While this interpretation may not exactly match official rendering standards, it is a consistent compromise for drawing multifont strings in the non-multifont html canvas element underlying vis. +

+ +

This implies that four additional sets of font properties will be recognized in label processing.

+

font.bold designates the font used for rendering bold font mods. +
font.ital designates the font used for rendering italic font mods. +
font.boldital designates the font used for rendering bold-and-italic font mods. +
font.mono designates the font used for rendering monospaced font mods.

+

Any font mod without a matching font will be rendered using the normal font (or default) value.

+ +

The font.multi and extended font settings may be set in the network's nodes or edges properties, or on individual nodes and edges. +Node and edge label fonts are separate.

+ + + + + diff --git a/examples/network/labels/labelStroke.html b/examples/network/labels/labelStroke.html index 71a7dccda..6fb076692 100644 --- a/examples/network/labels/labelStroke.html +++ b/examples/network/labels/labelStroke.html @@ -16,7 +16,7 @@ max-width:600px; } - + diff --git a/examples/network/labels/multilineText.html b/examples/network/labels/multilineText.html index 99dc8eb25..6857312c9 100644 --- a/examples/network/labels/multilineText.html +++ b/examples/network/labels/multilineText.html @@ -43,7 +43,7 @@ var network = new vis.Network(container, data, options); } - + diff --git a/examples/network/layout/demo.jsonp b/examples/network/layout/demo.jsonp new file mode 100644 index 000000000..727db6fc5 --- /dev/null +++ b/examples/network/layout/demo.jsonp @@ -0,0 +1,1894 @@ +p( { + "nodes": [ + { + "level": 0, + "id": "0", + "label": "0" + }, + { + "level": 1, + "id": "1", + "label": "1" + }, + { + "level": 2, + "id": "2", + "label": "2" + }, + { + "level": 3, + "id": "3", + "label": "3" + }, + { + "level": 4, + "id": "4", + "label": "4" + }, + { + "level": 5, + "id": "5", + "label": "5" + }, + { + "level": 6, + "id": "6", + "label": "6" + }, + { + "level": 7, + "id": "7", + "label": "7" + }, + { + "level": 8, + "id": "8", + "label": "8" + }, + { + "level": 0, + "id": "9", + "label": "9" + }, + { + "level": 9, + "id": "10", + "label": "10" + }, + { + "level": 10, + "id": "11", + "label": "11" + }, + { + "level": 11, + "id": "12", + "label": "12" + }, + { + "level": 12, + "id": "13", + "label": "13" + }, + { + "level": 13, + "id": "14", + "label": "14" + }, + { + "level": 13, + "id": "15", + "label": "15" + }, + { + "level": 14, + "id": "16", + "label": "16" + }, + { + "level": 15, + "id": "17", + "label": "17" + }, + { + "level": 16, + "id": "18", + "label": "18" + }, + { + "level": 17, + "id": "19", + "label": "19" + }, + { + "level": 18, + "id": "20", + "label": "20" + }, + { + "level": 19, + "id": "21", + "label": "21" + }, + { + "level": 20, + "id": "22", + "label": "22" + }, + { + "level": 21, + "id": "23", + "label": "23" + }, + { + "level": 22, + "id": "24", + "label": "24" + }, + { + "level": 23, + "id": "25", + "label": "25" + }, + { + "level": 24, + "id": "26", + "label": "26" + }, + { + "level": 25, + "id": "27", + "label": "27" + }, + { + "level": 26, + "id": "28", + "label": "28" + }, + { + "level": 27, + "id": "29", + "label": "29" + }, + { + "level": 28, + "id": "30", + "label": "30" + }, + { + "level": 29, + "id": "31", + "label": "31" + }, + { + "level": 30, + "id": "32", + "label": "32" + }, + { + "level": 31, + "id": "33", + "label": "33" + }, + { + "level": 32, + "id": "34", + "label": "34" + }, + { + "level": 32, + "id": "35", + "label": "35" + }, + { + "level": 33, + "id": "36", + "label": "36" + }, + { + "level": 32, + "id": "37", + "label": "37" + }, + { + "level": 34, + "id": "38", + "label": "38" + }, + { + "level": 35, + "id": "39", + "label": "39" + }, + { + "level": 36, + "id": "40", + "label": "40" + }, + { + "level": 37, + "id": "41", + "label": "41" + }, + { + "level": 38, + "id": "42", + "label": "42" + }, + { + "level": 33, + "id": "43", + "label": "43" + }, + { + "level": 39, + "id": "44", + "label": "44" + }, + { + "level": 40, + "id": "45", + "label": "45" + }, + { + "level": 41, + "id": "46", + "label": "46" + }, + { + "level": 42, + "id": "47", + "label": "47" + }, + { + "level": 43, + "id": "48", + "label": "48" + }, + { + "level": 44, + "id": "49", + "label": "49" + }, + { + "level": 45, + "id": "50", + "label": "50" + }, + { + "level": 46, + "id": "51", + "label": "51" + }, + { + "level": 47, + "id": "52", + "label": "52" + }, + { + "level": 48, + "id": "53", + "label": "53" + }, + { + "level": 49, + "id": "54", + "label": "54" + }, + { + "level": 50, + "id": "55", + "label": "55" + }, + { + "level": 51, + "id": "56", + "label": "56" + }, + { + "level": 52, + "id": "57", + "label": "57" + }, + { + "level": 53, + "id": "58", + "label": "58" + }, + { + "level": 54, + "id": "59", + "label": "59" + }, + { + "level": 55, + "id": "60", + "label": "60" + }, + { + "level": 56, + "id": "61", + "label": "61" + }, + { + "level": 57, + "id": "62", + "label": "62" + }, + { + "level": 58, + "id": "63", + "label": "63" + }, + { + "level": 59, + "id": "64", + "label": "64" + }, + { + "level": 55, + "id": "65", + "label": "65" + }, + { + "level": 60, + "id": "66", + "label": "66" + }, + { + "level": 61, + "id": "67", + "label": "67" + }, + { + "level": 62, + "id": "68", + "label": "68" + }, + { + "level": 63, + "id": "69", + "label": "69" + }, + { + "level": 64, + "id": "70", + "label": "70" + }, + { + "level": 65, + "id": "71", + "label": "71" + }, + { + "level": 66, + "id": "72", + "label": "72" + }, + { + "level": 67, + "id": "73", + "label": "73" + }, + { + "level": 68, + "id": "74", + "label": "74" + }, + { + "level": 69, + "id": "75", + "label": "75" + }, + { + "level": 70, + "id": "76", + "label": "76" + }, + { + "level": 71, + "id": "77", + "label": "77" + }, + { + "level": 72, + "id": "78", + "label": "78" + }, + { + "level": 73, + "id": "79", + "label": "79" + }, + { + "level": 74, + "id": "80", + "label": "80" + }, + { + "level": 75, + "id": "81", + "label": "81" + }, + { + "level": 76, + "id": "82", + "label": "82" + }, + { + "level": 77, + "id": "83", + "label": "83" + }, + { + "level": 78, + "id": "84", + "label": "84" + }, + { + "level": 79, + "id": "85", + "label": "85" + }, + { + "level": 80, + "id": "86", + "label": "86" + }, + { + "level": 81, + "id": "87", + "label": "87" + }, + { + "level": 82, + "id": "88", + "label": "88" + }, + { + "level": 83, + "id": "89", + "label": "89" + }, + { + "level": 84, + "id": "90", + "label": "90" + }, + { + "level": 80, + "id": "91", + "label": "91" + }, + { + "level": 85, + "id": "92", + "label": "92" + }, + { + "level": 86, + "id": "93", + "label": "93" + }, + { + "level": 87, + "id": "94", + "label": "94" + }, + { + "level": 88, + "id": "95", + "label": "95" + }, + { + "level": 89, + "id": "96", + "label": "96" + }, + { + "level": 90, + "id": "97", + "label": "97" + }, + { + "level": 91, + "id": "98", + "label": "98" + }, + { + "level": 92, + "id": "99", + "label": "99" + }, + { + "level": 93, + "id": "100", + "label": "100" + }, + { + "level": 94, + "id": "101", + "label": "101" + }, + { + "level": 95, + "id": "102", + "label": "102" + }, + { + "level": 96, + "id": "103", + "label": "103" + }, + { + "level": 97, + "id": "104", + "label": "104" + }, + { + "level": 98, + "id": "105", + "label": "105" + }, + { + "level": 99, + "id": "106", + "label": "106" + }, + { + "level": 100, + "id": "107", + "label": "107" + }, + { + "level": 101, + "id": "108", + "label": "108" + }, + { + "level": 102, + "id": "109", + "label": "109" + }, + { + "level": 103, + "id": "110", + "label": "110" + }, + { + "level": 104, + "id": "111", + "label": "111" + }, + { + "level": 105, + "id": "112", + "label": "112" + }, + { + "level": 106, + "id": "113", + "label": "113" + }, + { + "level": 107, + "id": "114", + "label": "114" + }, + { + "level": 108, + "id": "115", + "label": "115" + }, + { + "level": 109, + "id": "116", + "label": "116" + }, + { + "level": 110, + "id": "117", + "label": "117" + }, + { + "level": 111, + "id": "118", + "label": "118" + }, + { + "level": 112, + "id": "119", + "label": "119" + }, + { + "level": 113, + "id": "120", + "label": "120" + }, + { + "level": 114, + "id": "121", + "label": "121" + }, + { + "level": 115, + "id": "122", + "label": "122" + }, + { + "level": 116, + "id": "123", + "label": "123" + }, + { + "level": 117, + "id": "124", + "label": "124" + }, + { + "level": 118, + "id": "125", + "label": "125" + }, + { + "level": 119, + "id": "126", + "label": "126" + }, + { + "level": 120, + "id": "127", + "label": "127" + }, + { + "level": 121, + "id": "128", + "label": "128" + }, + { + "level": 122, + "id": "129", + "label": "129" + }, + { + "level": 123, + "id": "130", + "label": "130" + }, + { + "level": 124, + "id": "131", + "label": "131" + }, + { + "level": 125, + "id": "132", + "label": "132" + }, + { + "level": 126, + "id": "133", + "label": "133" + }, + { + "level": 126, + "id": "134", + "label": "134" + }, + { + "level": 127, + "id": "135", + "label": "135" + }, + { + "level": 128, + "id": "136", + "label": "136" + }, + { + "level": 129, + "id": "137", + "label": "137" + }, + { + "level": 130, + "id": "138", + "label": "138" + }, + { + "level": 131, + "id": "139", + "label": "139" + }, + { + "level": 131, + "id": "140", + "label": "140" + }, + { + "level": 132, + "id": "141", + "label": "141" + }, + { + "level": 133, + "id": "142", + "label": "142" + }, + { + "level": 134, + "id": "143", + "label": "143" + }, + { + "level": 135, + "id": "144", + "label": "144" + }, + { + "level": 136, + "id": "145", + "label": "145" + }, + { + "level": 137, + "id": "146", + "label": "146" + }, + { + "level": 138, + "id": "147", + "label": "147" + }, + { + "level": 139, + "id": "148", + "label": "148" + }, + { + "level": 140, + "id": "149", + "label": "149" + }, + { + "level": 141, + "id": "150", + "label": "150" + }, + { + "level": 142, + "id": "151", + "label": "151" + }, + { + "level": 143, + "id": "152", + "label": "152" + }, + { + "level": 144, + "id": "153", + "label": "153" + }, + { + "level": 145, + "id": "154", + "label": "154" + }, + { + "level": 146, + "id": "155", + "label": "155" + }, + { + "level": 147, + "id": "156", + "label": "156" + }, + { + "level": 147, + "id": "157", + "label": "157" + }, + { + "level": 148, + "id": "158", + "label": "158" + }, + { + "level": 149, + "id": "159", + "label": "159" + }, + { + "level": 150, + "id": "160", + "label": "160" + }, + { + "level": 151, + "id": "161", + "label": "161" + }, + { + "level": 150, + "id": "162", + "label": "162" + }, + { + "level": 152, + "id": "163", + "label": "163" + }, + { + "level": 151, + "id": "164", + "label": "164" + }, + { + "level": 153, + "id": "165", + "label": "165" + }, + { + "level": 154, + "id": "166", + "label": "166" + }, + { + "level": 155, + "id": "167", + "label": "167" + }, + { + "level": 156, + "id": "168", + "label": "168" + }, + { + "level": 157, + "id": "169", + "label": "169" + }, + { + "level": 157, + "id": "170", + "label": "170" + }, + { + "level": 158, + "id": "171", + "label": "171" + }, + { + "level": 159, + "id": "172", + "label": "172" + }, + { + "level": 160, + "id": "173", + "label": "173" + }, + { + "level": 161, + "id": "174", + "label": "174" + }, + { + "level": 162, + "id": "175", + "label": "175" + }, + { + "level": 163, + "id": "176", + "label": "176" + }, + { + "level": 164, + "id": "177", + "label": "177" + }, + { + "level": 165, + "id": "178", + "label": "178" + }, + { + "level": 166, + "id": "179", + "label": "179" + }, + { + "level": 166, + "id": "180", + "label": "180" + }, + { + "level": 167, + "id": "181", + "label": "181" + }, + { + "level": 168, + "id": "182", + "label": "182" + }, + { + "level": 168, + "id": "183", + "label": "183" + }, + { + "level": 169, + "id": "184", + "label": "184" + }, + { + "level": 170, + "id": "185", + "label": "185" + }, + { + "level": 170, + "id": "186", + "label": "186" + }, + { + "level": 171, + "id": "187", + "label": "187" + }, + { + "level": 172, + "id": "188", + "label": "188" + }, + { + "level": 172, + "id": "189", + "label": "189" + }, + { + "level": 173, + "id": "190", + "label": "190" + }, + { + "level": 174, + "id": "191", + "label": "191" + }, + { + "level": 175, + "id": "192", + "label": "192" + }, + { + "level": 176, + "id": "193", + "label": "193" + }, + { + "level": 177, + "id": "194", + "label": "194" + }, + { + "level": 178, + "id": "195", + "label": "195" + }, + { + "level": 179, + "id": "196", + "label": "196" + }, + { + "level": 179, + "id": "197", + "label": "197" + }, + { + "level": 180, + "id": "198", + "label": "198" + }, + { + "level": 181, + "id": "199", + "label": "199" + } + ], + "edges": [ + { + "to": "0", + "from": "1" + }, + { + "to": "1", + "from": "2" + }, + { + "to": "2", + "from": "3" + }, + { + "to": "3", + "from": "4" + }, + { + "to": "4", + "from": "5" + }, + { + "to": "5", + "from": "6" + }, + { + "to": "6", + "from": "7" + }, + { + "to": "7", + "from": "8" + }, + { + "to": "8", + "from": "10" + }, + { + "to": "9", + "from": "11" + }, + { + "to": "10", + "from": "11" + }, + { + "to": "11", + "from": "12" + }, + { + "to": "12", + "from": "13" + }, + { + "to": "13", + "from": "15" + }, + { + "to": "13", + "from": "14" + }, + { + "to": "14", + "from": "16" + }, + { + "to": "15", + "from": "16" + }, + { + "to": "16", + "from": "17" + }, + { + "to": "17", + "from": "18" + }, + { + "to": "18", + "from": "19" + }, + { + "to": "19", + "from": "20" + }, + { + "to": "20", + "from": "21" + }, + { + "to": "21", + "from": "22" + }, + { + "to": "22", + "from": "23" + }, + { + "to": "23", + "from": "24" + }, + { + "to": "24", + "from": "25" + }, + { + "to": "25", + "from": "26" + }, + { + "to": "26", + "from": "27" + }, + { + "to": "27", + "from": "28" + }, + { + "to": "28", + "from": "29" + }, + { + "to": "29", + "from": "30" + }, + { + "to": "30", + "from": "31" + }, + { + "to": "31", + "from": "32" + }, + { + "to": "32", + "from": "33" + }, + { + "to": "33", + "from": "34" + }, + { + "to": "33", + "from": "35" + }, + { + "to": "33", + "from": "37" + }, + { + "to": "34", + "from": "36" + }, + { + "to": "34", + "from": "43" + }, + { + "to": "35", + "from": "36" + }, + { + "to": "35", + "from": "43" + }, + { + "to": "36", + "from": "38" + }, + { + "to": "37", + "from": "39" + }, + { + "to": "37", + "from": "43" + }, + { + "to": "38", + "from": "39" + }, + { + "to": "39", + "from": "40" + }, + { + "to": "40", + "from": "41" + }, + { + "to": "41", + "from": "42" + }, + { + "to": "42", + "from": "44" + }, + { + "to": "43", + "from": "45" + }, + { + "to": "44", + "from": "45" + }, + { + "to": "45", + "from": "46" + }, + { + "to": "46", + "from": "47" + }, + { + "to": "47", + "from": "48" + }, + { + "to": "48", + "from": "49" + }, + { + "to": "49", + "from": "50" + }, + { + "to": "50", + "from": "51" + }, + { + "to": "51", + "from": "52" + }, + { + "to": "52", + "from": "53" + }, + { + "to": "53", + "from": "54" + }, + { + "to": "54", + "from": "55" + }, + { + "to": "55", + "from": "56" + }, + { + "to": "56", + "from": "57" + }, + { + "to": "57", + "from": "58" + }, + { + "to": "58", + "from": "59" + }, + { + "to": "59", + "from": "60" + }, + { + "to": "59", + "from": "65" + }, + { + "to": "60", + "from": "61" + }, + { + "to": "61", + "from": "62" + }, + { + "to": "62", + "from": "63" + }, + { + "to": "63", + "from": "64" + }, + { + "to": "64", + "from": "66" + }, + { + "to": "65", + "from": "67" + }, + { + "to": "65", + "from": "124" + }, + { + "to": "66", + "from": "67" + }, + { + "to": "67", + "from": "68" + }, + { + "to": "68", + "from": "69" + }, + { + "to": "69", + "from": "70" + }, + { + "to": "70", + "from": "71" + }, + { + "to": "71", + "from": "72" + }, + { + "to": "72", + "from": "73" + }, + { + "to": "73", + "from": "74" + }, + { + "to": "74", + "from": "75" + }, + { + "to": "75", + "from": "76" + }, + { + "to": "76", + "from": "77" + }, + { + "to": "77", + "from": "78" + }, + { + "to": "78", + "from": "79" + }, + { + "to": "79", + "from": "80" + }, + { + "to": "80", + "from": "81" + }, + { + "to": "81", + "from": "82" + }, + { + "to": "82", + "from": "83" + }, + { + "to": "83", + "from": "84" + }, + { + "to": "84", + "from": "85" + }, + { + "to": "85", + "from": "91" + }, + { + "to": "85", + "from": "86" + }, + { + "to": "86", + "from": "87" + }, + { + "to": "87", + "from": "88" + }, + { + "to": "88", + "from": "89" + }, + { + "to": "89", + "from": "90" + }, + { + "to": "90", + "from": "92" + }, + { + "to": "91", + "from": "195" + }, + { + "to": "91", + "from": "93" + }, + { + "to": "91", + "from": "125" + }, + { + "to": "92", + "from": "93" + }, + { + "to": "93", + "from": "94" + }, + { + "to": "94", + "from": "95" + }, + { + "to": "95", + "from": "96" + }, + { + "to": "96", + "from": "97" + }, + { + "to": "97", + "from": "98" + }, + { + "to": "98", + "from": "99" + }, + { + "to": "99", + "from": "100" + }, + { + "to": "100", + "from": "101" + }, + { + "to": "101", + "from": "102" + }, + { + "to": "102", + "from": "103" + }, + { + "to": "103", + "from": "104" + }, + { + "to": "104", + "from": "105" + }, + { + "to": "105", + "from": "106" + }, + { + "to": "106", + "from": "107" + }, + { + "to": "107", + "from": "108" + }, + { + "to": "108", + "from": "109" + }, + { + "to": "109", + "from": "110" + }, + { + "to": "110", + "from": "111" + }, + { + "to": "111", + "from": "112" + }, + { + "to": "112", + "from": "113" + }, + { + "to": "113", + "from": "114" + }, + { + "to": "114", + "from": "115" + }, + { + "to": "115", + "from": "116" + }, + { + "to": "116", + "from": "117" + }, + { + "to": "117", + "from": "118" + }, + { + "to": "118", + "from": "119" + }, + { + "to": "119", + "from": "120" + }, + { + "to": "120", + "from": "121" + }, + { + "to": "121", + "from": "122" + }, + { + "to": "122", + "from": "123" + }, + { + "to": "123", + "from": "124" + }, + { + "to": "124", + "from": "125" + }, + { + "to": "125", + "from": "126" + }, + { + "to": "126", + "from": "127" + }, + { + "to": "127", + "from": "128" + }, + { + "to": "128", + "from": "129" + }, + { + "to": "129", + "from": "130" + }, + { + "to": "130", + "from": "131" + }, + { + "to": "131", + "from": "132" + }, + { + "to": "132", + "from": "134" + }, + { + "to": "132", + "from": "133" + }, + { + "to": "133", + "from": "135" + }, + { + "to": "134", + "from": "135" + }, + { + "to": "135", + "from": "136" + }, + { + "to": "136", + "from": "137" + }, + { + "to": "137", + "from": "138" + }, + { + "to": "138", + "from": "139" + }, + { + "to": "138", + "from": "140" + }, + { + "to": "139", + "from": "141" + }, + { + "to": "140", + "from": "141" + }, + { + "to": "141", + "from": "142" + }, + { + "to": "142", + "from": "143" + }, + { + "to": "143", + "from": "144" + }, + { + "to": "144", + "from": "145" + }, + { + "to": "145", + "from": "146" + }, + { + "to": "146", + "from": "147" + }, + { + "to": "147", + "from": "148" + }, + { + "to": "148", + "from": "149" + }, + { + "to": "149", + "from": "150" + }, + { + "to": "150", + "from": "151" + }, + { + "to": "151", + "from": "152" + }, + { + "to": "152", + "from": "153" + }, + { + "to": "153", + "from": "154" + }, + { + "to": "154", + "from": "155" + }, + { + "to": "155", + "from": "157" + }, + { + "to": "155", + "from": "156" + }, + { + "to": "156", + "from": "158" + }, + { + "to": "157", + "from": "158" + }, + { + "to": "158", + "from": "159" + }, + { + "to": "159", + "from": "162" + }, + { + "to": "159", + "from": "160" + }, + { + "to": "160", + "from": "164" + }, + { + "to": "160", + "from": "161" + }, + { + "to": "161", + "from": "163" + }, + { + "to": "162", + "from": "164" + }, + { + "to": "163", + "from": "165" + }, + { + "to": "164", + "from": "166" + }, + { + "to": "165", + "from": "166" + }, + { + "to": "166", + "from": "167" + }, + { + "to": "167", + "from": "168" + }, + { + "to": "168", + "from": "170" + }, + { + "to": "168", + "from": "169" + }, + { + "to": "169", + "from": "171" + }, + { + "to": "170", + "from": "171" + }, + { + "to": "171", + "from": "172" + }, + { + "to": "172", + "from": "173" + }, + { + "to": "173", + "from": "174" + }, + { + "to": "174", + "from": "175" + }, + { + "to": "175", + "from": "176" + }, + { + "to": "176", + "from": "177" + }, + { + "to": "177", + "from": "178" + }, + { + "to": "178", + "from": "180" + }, + { + "to": "178", + "from": "179" + }, + { + "to": "179", + "from": "181" + }, + { + "to": "180", + "from": "181" + }, + { + "to": "181", + "from": "183" + }, + { + "to": "181", + "from": "182" + }, + { + "to": "182", + "from": "184" + }, + { + "to": "183", + "from": "184" + }, + { + "to": "184", + "from": "186" + }, + { + "to": "184", + "from": "185" + }, + { + "to": "185", + "from": "187" + }, + { + "to": "186", + "from": "189" + }, + { + "to": "186", + "from": "188" + }, + { + "to": "187", + "from": "188" + }, + { + "to": "187", + "from": "189" + }, + { + "to": "188", + "from": "190" + }, + { + "to": "189", + "from": "191" + }, + { + "to": "190", + "from": "191" + }, + { + "to": "191", + "from": "192" + }, + { + "to": "192", + "from": "193" + }, + { + "to": "193", + "from": "194" + }, + { + "to": "194", + "from": "195" + }, + { + "to": "195", + "from": "197" + }, + { + "to": "195", + "from": "196" + }, + { + "to": "196", + "from": "198" + }, + { + "to": "197", + "from": "198" + }, + { + "to": "198", + "from": "199" + } + ] +} ); diff --git a/examples/network/layout/demo_big.jsonp b/examples/network/layout/demo_big.jsonp new file mode 100644 index 000000000..e9f2c0b9d --- /dev/null +++ b/examples/network/layout/demo_big.jsonp @@ -0,0 +1,3762 @@ +p( { + "nodes": [ + { + "level": 0, + "id": "0", + "label": "0" + }, + { + "level": 1, + "id": "1", + "label": "1" + }, + { + "level": 2, + "id": "2", + "label": "2" + }, + { + "level": 3, + "id": "3", + "label": "3" + }, + { + "level": 4, + "id": "4", + "label": "4" + }, + { + "level": 5, + "id": "5", + "label": "5" + }, + { + "level": 6, + "id": "6", + "label": "6" + }, + { + "level": 7, + "id": "7", + "label": "7" + }, + { + "level": 8, + "id": "8", + "label": "8" + }, + { + "level": 0, + "id": "9", + "label": "9" + }, + { + "level": 9, + "id": "10", + "label": "10" + }, + { + "level": 10, + "id": "11", + "label": "11" + }, + { + "level": 11, + "id": "12", + "label": "12" + }, + { + "level": 12, + "id": "13", + "label": "13" + }, + { + "level": 13, + "id": "14", + "label": "14" + }, + { + "level": 13, + "id": "15", + "label": "15" + }, + { + "level": 14, + "id": "16", + "label": "16" + }, + { + "level": 15, + "id": "17", + "label": "17" + }, + { + "level": 16, + "id": "18", + "label": "18" + }, + { + "level": 17, + "id": "19", + "label": "19" + }, + { + "level": 18, + "id": "20", + "label": "20" + }, + { + "level": 19, + "id": "21", + "label": "21" + }, + { + "level": 20, + "id": "22", + "label": "22" + }, + { + "level": 21, + "id": "23", + "label": "23" + }, + { + "level": 22, + "id": "24", + "label": "24" + }, + { + "level": 23, + "id": "25", + "label": "25" + }, + { + "level": 24, + "id": "26", + "label": "26" + }, + { + "level": 25, + "id": "27", + "label": "27" + }, + { + "level": 26, + "id": "28", + "label": "28" + }, + { + "level": 27, + "id": "29", + "label": "29" + }, + { + "level": 28, + "id": "30", + "label": "30" + }, + { + "level": 29, + "id": "31", + "label": "31" + }, + { + "level": 30, + "id": "32", + "label": "32" + }, + { + "level": 31, + "id": "33", + "label": "33" + }, + { + "level": 32, + "id": "34", + "label": "34" + }, + { + "level": 32, + "id": "35", + "label": "35" + }, + { + "level": 33, + "id": "36", + "label": "36" + }, + { + "level": 32, + "id": "37", + "label": "37" + }, + { + "level": 34, + "id": "38", + "label": "38" + }, + { + "level": 35, + "id": "39", + "label": "39" + }, + { + "level": 36, + "id": "40", + "label": "40" + }, + { + "level": 37, + "id": "41", + "label": "41" + }, + { + "level": 38, + "id": "42", + "label": "42" + }, + { + "level": 33, + "id": "43", + "label": "43" + }, + { + "level": 39, + "id": "44", + "label": "44" + }, + { + "level": 40, + "id": "45", + "label": "45" + }, + { + "level": 41, + "id": "46", + "label": "46" + }, + { + "level": 42, + "id": "47", + "label": "47" + }, + { + "level": 43, + "id": "48", + "label": "48" + }, + { + "level": 44, + "id": "49", + "label": "49" + }, + { + "level": 45, + "id": "50", + "label": "50" + }, + { + "level": 46, + "id": "51", + "label": "51" + }, + { + "level": 47, + "id": "52", + "label": "52" + }, + { + "level": 48, + "id": "53", + "label": "53" + }, + { + "level": 49, + "id": "54", + "label": "54" + }, + { + "level": 50, + "id": "55", + "label": "55" + }, + { + "level": 51, + "id": "56", + "label": "56" + }, + { + "level": 52, + "id": "57", + "label": "57" + }, + { + "level": 53, + "id": "58", + "label": "58" + }, + { + "level": 54, + "id": "59", + "label": "59" + }, + { + "level": 55, + "id": "60", + "label": "60" + }, + { + "level": 56, + "id": "61", + "label": "61" + }, + { + "level": 57, + "id": "62", + "label": "62" + }, + { + "level": 58, + "id": "63", + "label": "63" + }, + { + "level": 59, + "id": "64", + "label": "64" + }, + { + "level": 55, + "id": "65", + "label": "65" + }, + { + "level": 60, + "id": "66", + "label": "66" + }, + { + "level": 61, + "id": "67", + "label": "67" + }, + { + "level": 62, + "id": "68", + "label": "68" + }, + { + "level": 63, + "id": "69", + "label": "69" + }, + { + "level": 64, + "id": "70", + "label": "70" + }, + { + "level": 65, + "id": "71", + "label": "71" + }, + { + "level": 66, + "id": "72", + "label": "72" + }, + { + "level": 67, + "id": "73", + "label": "73" + }, + { + "level": 68, + "id": "74", + "label": "74" + }, + { + "level": 69, + "id": "75", + "label": "75" + }, + { + "level": 70, + "id": "76", + "label": "76" + }, + { + "level": 71, + "id": "77", + "label": "77" + }, + { + "level": 72, + "id": "78", + "label": "78" + }, + { + "level": 73, + "id": "79", + "label": "79" + }, + { + "level": 74, + "id": "80", + "label": "80" + }, + { + "level": 75, + "id": "81", + "label": "81" + }, + { + "level": 76, + "id": "82", + "label": "82" + }, + { + "level": 77, + "id": "83", + "label": "83" + }, + { + "level": 78, + "id": "84", + "label": "84" + }, + { + "level": 79, + "id": "85", + "label": "85" + }, + { + "level": 80, + "id": "86", + "label": "86" + }, + { + "level": 81, + "id": "87", + "label": "87" + }, + { + "level": 82, + "id": "88", + "label": "88" + }, + { + "level": 83, + "id": "89", + "label": "89" + }, + { + "level": 84, + "id": "90", + "label": "90" + }, + { + "level": 80, + "id": "91", + "label": "91" + }, + { + "level": 85, + "id": "92", + "label": "92" + }, + { + "level": 86, + "id": "93", + "label": "93" + }, + { + "level": 87, + "id": "94", + "label": "94" + }, + { + "level": 88, + "id": "95", + "label": "95" + }, + { + "level": 89, + "id": "96", + "label": "96" + }, + { + "level": 90, + "id": "97", + "label": "97" + }, + { + "level": 91, + "id": "98", + "label": "98" + }, + { + "level": 92, + "id": "99", + "label": "99" + }, + { + "level": 93, + "id": "100", + "label": "100" + }, + { + "level": 94, + "id": "101", + "label": "101" + }, + { + "level": 95, + "id": "102", + "label": "102" + }, + { + "level": 96, + "id": "103", + "label": "103" + }, + { + "level": 97, + "id": "104", + "label": "104" + }, + { + "level": 98, + "id": "105", + "label": "105" + }, + { + "level": 99, + "id": "106", + "label": "106" + }, + { + "level": 100, + "id": "107", + "label": "107" + }, + { + "level": 101, + "id": "108", + "label": "108" + }, + { + "level": 102, + "id": "109", + "label": "109" + }, + { + "level": 103, + "id": "110", + "label": "110" + }, + { + "level": 104, + "id": "111", + "label": "111" + }, + { + "level": 105, + "id": "112", + "label": "112" + }, + { + "level": 106, + "id": "113", + "label": "113" + }, + { + "level": 107, + "id": "114", + "label": "114" + }, + { + "level": 108, + "id": "115", + "label": "115" + }, + { + "level": 109, + "id": "116", + "label": "116" + }, + { + "level": 110, + "id": "117", + "label": "117" + }, + { + "level": 111, + "id": "118", + "label": "118" + }, + { + "level": 112, + "id": "119", + "label": "119" + }, + { + "level": 113, + "id": "120", + "label": "120" + }, + { + "level": 114, + "id": "121", + "label": "121" + }, + { + "level": 115, + "id": "122", + "label": "122" + }, + { + "level": 116, + "id": "123", + "label": "123" + }, + { + "level": 117, + "id": "124", + "label": "124" + }, + { + "level": 118, + "id": "125", + "label": "125" + }, + { + "level": 119, + "id": "126", + "label": "126" + }, + { + "level": 120, + "id": "127", + "label": "127" + }, + { + "level": 121, + "id": "128", + "label": "128" + }, + { + "level": 122, + "id": "129", + "label": "129" + }, + { + "level": 123, + "id": "130", + "label": "130" + }, + { + "level": 124, + "id": "131", + "label": "131" + }, + { + "level": 125, + "id": "132", + "label": "132" + }, + { + "level": 126, + "id": "133", + "label": "133" + }, + { + "level": 126, + "id": "134", + "label": "134" + }, + { + "level": 127, + "id": "135", + "label": "135" + }, + { + "level": 128, + "id": "136", + "label": "136" + }, + { + "level": 129, + "id": "137", + "label": "137" + }, + { + "level": 130, + "id": "138", + "label": "138" + }, + { + "level": 131, + "id": "139", + "label": "139" + }, + { + "level": 131, + "id": "140", + "label": "140" + }, + { + "level": 132, + "id": "141", + "label": "141" + }, + { + "level": 133, + "id": "142", + "label": "142" + }, + { + "level": 134, + "id": "143", + "label": "143" + }, + { + "level": 135, + "id": "144", + "label": "144" + }, + { + "level": 136, + "id": "145", + "label": "145" + }, + { + "level": 137, + "id": "146", + "label": "146" + }, + { + "level": 138, + "id": "147", + "label": "147" + }, + { + "level": 139, + "id": "148", + "label": "148" + }, + { + "level": 140, + "id": "149", + "label": "149" + }, + { + "level": 141, + "id": "150", + "label": "150" + }, + { + "level": 142, + "id": "151", + "label": "151" + }, + { + "level": 143, + "id": "152", + "label": "152" + }, + { + "level": 144, + "id": "153", + "label": "153" + }, + { + "level": 145, + "id": "154", + "label": "154" + }, + { + "level": 146, + "id": "155", + "label": "155" + }, + { + "level": 147, + "id": "156", + "label": "156" + }, + { + "level": 147, + "id": "157", + "label": "157" + }, + { + "level": 148, + "id": "158", + "label": "158" + }, + { + "level": 149, + "id": "159", + "label": "159" + }, + { + "level": 150, + "id": "160", + "label": "160" + }, + { + "level": 151, + "id": "161", + "label": "161" + }, + { + "level": 150, + "id": "162", + "label": "162" + }, + { + "level": 152, + "id": "163", + "label": "163" + }, + { + "level": 151, + "id": "164", + "label": "164" + }, + { + "level": 153, + "id": "165", + "label": "165" + }, + { + "level": 154, + "id": "166", + "label": "166" + }, + { + "level": 155, + "id": "167", + "label": "167" + }, + { + "level": 156, + "id": "168", + "label": "168" + }, + { + "level": 157, + "id": "169", + "label": "169" + }, + { + "level": 157, + "id": "170", + "label": "170" + }, + { + "level": 158, + "id": "171", + "label": "171" + }, + { + "level": 159, + "id": "172", + "label": "172" + }, + { + "level": 160, + "id": "173", + "label": "173" + }, + { + "level": 161, + "id": "174", + "label": "174" + }, + { + "level": 162, + "id": "175", + "label": "175" + }, + { + "level": 163, + "id": "176", + "label": "176" + }, + { + "level": 164, + "id": "177", + "label": "177" + }, + { + "level": 165, + "id": "178", + "label": "178" + }, + { + "level": 166, + "id": "179", + "label": "179" + }, + { + "level": 166, + "id": "180", + "label": "180" + }, + { + "level": 167, + "id": "181", + "label": "181" + }, + { + "level": 168, + "id": "182", + "label": "182" + }, + { + "level": 168, + "id": "183", + "label": "183" + }, + { + "level": 169, + "id": "184", + "label": "184" + }, + { + "level": 170, + "id": "185", + "label": "185" + }, + { + "level": 170, + "id": "186", + "label": "186" + }, + { + "level": 171, + "id": "187", + "label": "187" + }, + { + "level": 172, + "id": "188", + "label": "188" + }, + { + "level": 172, + "id": "189", + "label": "189" + }, + { + "level": 173, + "id": "190", + "label": "190" + }, + { + "level": 174, + "id": "191", + "label": "191" + }, + { + "level": 175, + "id": "192", + "label": "192" + }, + { + "level": 176, + "id": "193", + "label": "193" + }, + { + "level": 177, + "id": "194", + "label": "194" + }, + { + "level": 178, + "id": "195", + "label": "195" + }, + { + "level": 179, + "id": "196", + "label": "196" + }, + { + "level": 179, + "id": "197", + "label": "197" + }, + { + "level": 180, + "id": "198", + "label": "198" + }, + { + "level": 181, + "id": "199", + "label": "199" + }, + { + "level": 182, + "id": "200", + "label": "200" + }, + { + "level": 183, + "id": "201", + "label": "201" + }, + { + "level": 184, + "id": "202", + "label": "202" + }, + { + "level": 185, + "id": "203", + "label": "203" + }, + { + "level": 186, + "id": "204", + "label": "204" + }, + { + "level": 187, + "id": "205", + "label": "205" + }, + { + "level": 188, + "id": "206", + "label": "206" + }, + { + "level": 189, + "id": "207", + "label": "207" + }, + { + "level": 190, + "id": "208", + "label": "208" + }, + { + "level": 191, + "id": "209", + "label": "209" + }, + { + "level": 192, + "id": "210", + "label": "210" + }, + { + "level": 193, + "id": "211", + "label": "211" + }, + { + "level": 194, + "id": "212", + "label": "212" + }, + { + "level": 195, + "id": "213", + "label": "213" + }, + { + "level": 196, + "id": "214", + "label": "214" + }, + { + "level": 197, + "id": "215", + "label": "215" + }, + { + "level": 198, + "id": "216", + "label": "216" + }, + { + "level": 199, + "id": "217", + "label": "217" + }, + { + "level": 200, + "id": "218", + "label": "218" + }, + { + "level": 201, + "id": "219", + "label": "219" + }, + { + "level": 202, + "id": "220", + "label": "220" + }, + { + "level": 203, + "id": "221", + "label": "221" + }, + { + "level": 204, + "id": "222", + "label": "222" + }, + { + "level": 205, + "id": "223", + "label": "223" + }, + { + "level": 206, + "id": "224", + "label": "224" + }, + { + "level": 207, + "id": "225", + "label": "225" + }, + { + "level": 208, + "id": "226", + "label": "226" + }, + { + "level": 209, + "id": "227", + "label": "227" + }, + { + "level": 210, + "id": "228", + "label": "228" + }, + { + "level": 211, + "id": "229", + "label": "229" + }, + { + "level": 212, + "id": "230", + "label": "230" + }, + { + "level": 213, + "id": "231", + "label": "231" + }, + { + "level": 214, + "id": "232", + "label": "232" + }, + { + "level": 215, + "id": "233", + "label": "233" + }, + { + "level": 216, + "id": "234", + "label": "234" + }, + { + "level": 217, + "id": "235", + "label": "235" + }, + { + "level": 218, + "id": "236", + "label": "236" + }, + { + "level": 219, + "id": "237", + "label": "237" + }, + { + "level": 220, + "id": "238", + "label": "238" + }, + { + "level": 221, + "id": "239", + "label": "239" + }, + { + "level": 222, + "id": "240", + "label": "240" + }, + { + "level": 223, + "id": "241", + "label": "241" + }, + { + "level": 212, + "id": "242", + "label": "242" + }, + { + "level": 213, + "id": "243", + "label": "243" + }, + { + "level": 214, + "id": "244", + "label": "244" + }, + { + "level": 215, + "id": "245", + "label": "245" + }, + { + "level": 215, + "id": "246", + "label": "246" + }, + { + "level": 216, + "id": "247", + "label": "247" + }, + { + "level": 217, + "id": "248", + "label": "248" + }, + { + "level": 218, + "id": "249", + "label": "249" + }, + { + "level": 219, + "id": "250", + "label": "250" + }, + { + "level": 220, + "id": "251", + "label": "251" + }, + { + "level": 221, + "id": "252", + "label": "252" + }, + { + "level": 222, + "id": "253", + "label": "253" + }, + { + "level": 223, + "id": "254", + "label": "254" + }, + { + "level": 223, + "id": "255", + "label": "255" + }, + { + "level": 224, + "id": "256", + "label": "256" + }, + { + "level": 225, + "id": "257", + "label": "257" + }, + { + "level": 226, + "id": "258", + "label": "258" + }, + { + "level": 227, + "id": "259", + "label": "259" + }, + { + "level": 228, + "id": "260", + "label": "260" + }, + { + "level": 229, + "id": "261", + "label": "261" + }, + { + "level": 230, + "id": "262", + "label": "262" + }, + { + "level": 231, + "id": "263", + "label": "263" + }, + { + "level": 232, + "id": "264", + "label": "264" + }, + { + "level": 233, + "id": "265", + "label": "265" + }, + { + "level": 234, + "id": "266", + "label": "266" + }, + { + "level": 235, + "id": "267", + "label": "267" + }, + { + "level": 234, + "id": "268", + "label": "268" + }, + { + "level": 236, + "id": "269", + "label": "269" + }, + { + "level": 237, + "id": "270", + "label": "270" + }, + { + "level": 238, + "id": "271", + "label": "271" + }, + { + "level": 239, + "id": "272", + "label": "272" + }, + { + "level": 240, + "id": "273", + "label": "273" + }, + { + "level": 241, + "id": "274", + "label": "274" + }, + { + "level": 242, + "id": "275", + "label": "275" + }, + { + "level": 243, + "id": "276", + "label": "276" + }, + { + "level": 244, + "id": "277", + "label": "277" + }, + { + "level": 245, + "id": "278", + "label": "278" + }, + { + "level": 246, + "id": "279", + "label": "279" + }, + { + "level": 246, + "id": "280", + "label": "280" + }, + { + "level": 247, + "id": "281", + "label": "281" + }, + { + "level": 247, + "id": "282", + "label": "282" + }, + { + "level": 248, + "id": "283", + "label": "283" + }, + { + "level": 248, + "id": "284", + "label": "284" + }, + { + "level": 249, + "id": "285", + "label": "285" + }, + { + "level": 249, + "id": "286", + "label": "286" + }, + { + "level": 250, + "id": "287", + "label": "287" + }, + { + "level": 251, + "id": "288", + "label": "288" + }, + { + "level": 252, + "id": "289", + "label": "289" + }, + { + "level": 253, + "id": "290", + "label": "290" + }, + { + "level": 254, + "id": "291", + "label": "291" + }, + { + "level": 255, + "id": "292", + "label": "292" + }, + { + "level": 256, + "id": "293", + "label": "293" + }, + { + "level": 257, + "id": "294", + "label": "294" + }, + { + "level": 258, + "id": "295", + "label": "295" + }, + { + "level": 259, + "id": "296", + "label": "296" + }, + { + "level": 260, + "id": "297", + "label": "297" + }, + { + "level": 261, + "id": "298", + "label": "298" + }, + { + "level": 262, + "id": "299", + "label": "299" + }, + { + "level": 263, + "id": "300", + "label": "300" + }, + { + "level": 264, + "id": "301", + "label": "301" + }, + { + "level": 265, + "id": "302", + "label": "302" + }, + { + "level": 266, + "id": "303", + "label": "303" + }, + { + "level": 267, + "id": "304", + "label": "304" + }, + { + "level": 268, + "id": "305", + "label": "305" + }, + { + "level": 269, + "id": "306", + "label": "306" + }, + { + "level": 270, + "id": "307", + "label": "307" + }, + { + "level": 271, + "id": "308", + "label": "308" + }, + { + "level": 272, + "id": "309", + "label": "309" + }, + { + "level": 273, + "id": "310", + "label": "310" + }, + { + "level": 274, + "id": "311", + "label": "311" + }, + { + "level": 275, + "id": "312", + "label": "312" + }, + { + "level": 276, + "id": "313", + "label": "313" + }, + { + "level": 277, + "id": "314", + "label": "314" + }, + { + "level": 278, + "id": "315", + "label": "315" + }, + { + "level": 279, + "id": "316", + "label": "316" + }, + { + "level": 280, + "id": "317", + "label": "317" + }, + { + "level": 280, + "id": "318", + "label": "318" + }, + { + "level": 281, + "id": "319", + "label": "319" + }, + { + "level": 282, + "id": "320", + "label": "320" + }, + { + "level": 283, + "id": "321", + "label": "321" + }, + { + "level": 284, + "id": "322", + "label": "322" + }, + { + "level": 285, + "id": "323", + "label": "323" + }, + { + "level": 286, + "id": "324", + "label": "324" + }, + { + "level": 287, + "id": "325", + "label": "325" + }, + { + "level": 288, + "id": "326", + "label": "326" + }, + { + "level": 289, + "id": "327", + "label": "327" + }, + { + "level": 290, + "id": "328", + "label": "328" + }, + { + "level": 291, + "id": "329", + "label": "329" + }, + { + "level": 292, + "id": "330", + "label": "330" + }, + { + "level": 293, + "id": "331", + "label": "331" + }, + { + "level": 281, + "id": "332", + "label": "332" + }, + { + "level": 282, + "id": "333", + "label": "333" + }, + { + "level": 283, + "id": "334", + "label": "334" + }, + { + "level": 284, + "id": "335", + "label": "335" + }, + { + "level": 285, + "id": "336", + "label": "336" + }, + { + "level": 286, + "id": "337", + "label": "337" + }, + { + "level": 287, + "id": "338", + "label": "338" + }, + { + "level": 288, + "id": "339", + "label": "339" + }, + { + "level": 289, + "id": "340", + "label": "340" + }, + { + "level": 290, + "id": "341", + "label": "341" + }, + { + "level": 291, + "id": "342", + "label": "342" + }, + { + "level": 292, + "id": "343", + "label": "343" + }, + { + "level": 293, + "id": "344", + "label": "344" + }, + { + "level": 294, + "id": "345", + "label": "345" + }, + { + "level": 295, + "id": "346", + "label": "346" + }, + { + "level": 296, + "id": "347", + "label": "347" + }, + { + "level": 297, + "id": "348", + "label": "348" + }, + { + "level": 298, + "id": "349", + "label": "349" + }, + { + "level": 299, + "id": "350", + "label": "350" + }, + { + "level": 300, + "id": "351", + "label": "351" + }, + { + "level": 301, + "id": "352", + "label": "352" + }, + { + "level": 302, + "id": "353", + "label": "353" + }, + { + "level": 303, + "id": "354", + "label": "354" + }, + { + "level": 304, + "id": "355", + "label": "355" + }, + { + "level": 305, + "id": "356", + "label": "356" + }, + { + "level": 306, + "id": "357", + "label": "357" + }, + { + "level": 307, + "id": "358", + "label": "358" + }, + { + "level": 308, + "id": "359", + "label": "359" + }, + { + "level": 309, + "id": "360", + "label": "360" + }, + { + "level": 310, + "id": "361", + "label": "361" + }, + { + "level": 311, + "id": "362", + "label": "362" + }, + { + "level": 312, + "id": "363", + "label": "363" + }, + { + "level": 313, + "id": "364", + "label": "364" + }, + { + "level": 314, + "id": "365", + "label": "365" + }, + { + "level": 315, + "id": "366", + "label": "366" + }, + { + "level": 316, + "id": "367", + "label": "367" + }, + { + "level": 317, + "id": "368", + "label": "368" + }, + { + "level": 318, + "id": "369", + "label": "369" + }, + { + "level": 319, + "id": "370", + "label": "370" + }, + { + "level": 320, + "id": "371", + "label": "371" + }, + { + "level": 321, + "id": "372", + "label": "372" + }, + { + "level": 322, + "id": "373", + "label": "373" + }, + { + "level": 323, + "id": "374", + "label": "374" + }, + { + "level": 324, + "id": "375", + "label": "375" + }, + { + "level": 325, + "id": "376", + "label": "376" + }, + { + "level": 326, + "id": "377", + "label": "377" + }, + { + "level": 327, + "id": "378", + "label": "378" + }, + { + "level": 328, + "id": "379", + "label": "379" + }, + { + "level": 329, + "id": "380", + "label": "380" + }, + { + "level": 330, + "id": "381", + "label": "381" + }, + { + "level": 331, + "id": "382", + "label": "382" + }, + { + "level": 332, + "id": "383", + "label": "383" + }, + { + "level": 333, + "id": "384", + "label": "384" + }, + { + "level": 334, + "id": "385", + "label": "385" + }, + { + "level": 14, + "id": "386", + "label": "386" + }, + { + "level": 335, + "id": "387", + "label": "387" + }, + { + "level": 15, + "id": "388", + "label": "388" + }, + { + "level": 336, + "id": "389", + "label": "389" + }, + { + "level": 337, + "id": "390", + "label": "390" + }, + { + "level": 338, + "id": "391", + "label": "391" + }, + { + "level": 339, + "id": "392", + "label": "392" + }, + { + "level": 340, + "id": "393", + "label": "393" + }, + { + "level": 341, + "id": "394", + "label": "394" + }, + { + "level": 342, + "id": "395", + "label": "395" + }, + { + "level": 343, + "id": "396", + "label": "396" + }, + { + "level": 344, + "id": "397", + "label": "397" + }, + { + "level": 345, + "id": "398", + "label": "398" + }, + { + "level": 346, + "id": "399", + "label": "399" + } + ], + "edges": [ + { + "to": "0", + "from": "1" + }, + { + "to": "1", + "from": "2" + }, + { + "to": "2", + "from": "3" + }, + { + "to": "3", + "from": "4" + }, + { + "to": "4", + "from": "5" + }, + { + "to": "5", + "from": "6" + }, + { + "to": "6", + "from": "7" + }, + { + "to": "7", + "from": "8" + }, + { + "to": "8", + "from": "10" + }, + { + "to": "9", + "from": "11" + }, + { + "to": "10", + "from": "11" + }, + { + "to": "11", + "from": "12" + }, + { + "to": "12", + "from": "13" + }, + { + "to": "13", + "from": "15" + }, + { + "to": "13", + "from": "14" + }, + { + "to": "14", + "from": "386" + }, + { + "to": "14", + "from": "16" + }, + { + "to": "15", + "from": "386" + }, + { + "to": "15", + "from": "16" + }, + { + "to": "16", + "from": "17" + }, + { + "to": "17", + "from": "18" + }, + { + "to": "18", + "from": "19" + }, + { + "to": "19", + "from": "20" + }, + { + "to": "20", + "from": "21" + }, + { + "to": "21", + "from": "22" + }, + { + "to": "22", + "from": "23" + }, + { + "to": "23", + "from": "24" + }, + { + "to": "24", + "from": "25" + }, + { + "to": "25", + "from": "26" + }, + { + "to": "26", + "from": "27" + }, + { + "to": "27", + "from": "28" + }, + { + "to": "28", + "from": "29" + }, + { + "to": "29", + "from": "30" + }, + { + "to": "30", + "from": "31" + }, + { + "to": "31", + "from": "32" + }, + { + "to": "32", + "from": "33" + }, + { + "to": "33", + "from": "34" + }, + { + "to": "33", + "from": "35" + }, + { + "to": "33", + "from": "37" + }, + { + "to": "34", + "from": "36" + }, + { + "to": "34", + "from": "43" + }, + { + "to": "35", + "from": "36" + }, + { + "to": "35", + "from": "43" + }, + { + "to": "36", + "from": "38" + }, + { + "to": "37", + "from": "39" + }, + { + "to": "37", + "from": "43" + }, + { + "to": "38", + "from": "39" + }, + { + "to": "39", + "from": "40" + }, + { + "to": "40", + "from": "41" + }, + { + "to": "41", + "from": "42" + }, + { + "to": "42", + "from": "44" + }, + { + "to": "43", + "from": "45" + }, + { + "to": "44", + "from": "45" + }, + { + "to": "45", + "from": "46" + }, + { + "to": "46", + "from": "47" + }, + { + "to": "47", + "from": "48" + }, + { + "to": "48", + "from": "49" + }, + { + "to": "49", + "from": "50" + }, + { + "to": "50", + "from": "51" + }, + { + "to": "51", + "from": "52" + }, + { + "to": "52", + "from": "53" + }, + { + "to": "53", + "from": "54" + }, + { + "to": "54", + "from": "55" + }, + { + "to": "55", + "from": "56" + }, + { + "to": "56", + "from": "57" + }, + { + "to": "57", + "from": "58" + }, + { + "to": "58", + "from": "59" + }, + { + "to": "59", + "from": "60" + }, + { + "to": "59", + "from": "65" + }, + { + "to": "60", + "from": "61" + }, + { + "to": "61", + "from": "62" + }, + { + "to": "62", + "from": "63" + }, + { + "to": "63", + "from": "64" + }, + { + "to": "64", + "from": "66" + }, + { + "to": "65", + "from": "67" + }, + { + "to": "65", + "from": "124" + }, + { + "to": "65", + "from": "229" + }, + { + "to": "65", + "from": "322" + }, + { + "to": "65", + "from": "363" + }, + { + "to": "66", + "from": "67" + }, + { + "to": "67", + "from": "68" + }, + { + "to": "68", + "from": "69" + }, + { + "to": "69", + "from": "70" + }, + { + "to": "70", + "from": "71" + }, + { + "to": "71", + "from": "72" + }, + { + "to": "72", + "from": "73" + }, + { + "to": "73", + "from": "74" + }, + { + "to": "74", + "from": "75" + }, + { + "to": "75", + "from": "76" + }, + { + "to": "76", + "from": "77" + }, + { + "to": "77", + "from": "78" + }, + { + "to": "78", + "from": "79" + }, + { + "to": "79", + "from": "80" + }, + { + "to": "80", + "from": "81" + }, + { + "to": "81", + "from": "82" + }, + { + "to": "82", + "from": "83" + }, + { + "to": "83", + "from": "84" + }, + { + "to": "84", + "from": "85" + }, + { + "to": "85", + "from": "91" + }, + { + "to": "85", + "from": "86" + }, + { + "to": "86", + "from": "87" + }, + { + "to": "87", + "from": "88" + }, + { + "to": "88", + "from": "89" + }, + { + "to": "89", + "from": "90" + }, + { + "to": "90", + "from": "92" + }, + { + "to": "91", + "from": "195" + }, + { + "to": "91", + "from": "93" + }, + { + "to": "91", + "from": "397" + }, + { + "to": "91", + "from": "233" + }, + { + "to": "91", + "from": "125" + }, + { + "to": "92", + "from": "93" + }, + { + "to": "93", + "from": "94" + }, + { + "to": "94", + "from": "95" + }, + { + "to": "95", + "from": "96" + }, + { + "to": "96", + "from": "97" + }, + { + "to": "97", + "from": "98" + }, + { + "to": "98", + "from": "99" + }, + { + "to": "99", + "from": "100" + }, + { + "to": "100", + "from": "101" + }, + { + "to": "101", + "from": "102" + }, + { + "to": "102", + "from": "103" + }, + { + "to": "103", + "from": "104" + }, + { + "to": "104", + "from": "105" + }, + { + "to": "105", + "from": "106" + }, + { + "to": "106", + "from": "107" + }, + { + "to": "107", + "from": "108" + }, + { + "to": "108", + "from": "109" + }, + { + "to": "109", + "from": "110" + }, + { + "to": "110", + "from": "111" + }, + { + "to": "111", + "from": "112" + }, + { + "to": "112", + "from": "113" + }, + { + "to": "113", + "from": "114" + }, + { + "to": "114", + "from": "115" + }, + { + "to": "115", + "from": "116" + }, + { + "to": "116", + "from": "117" + }, + { + "to": "117", + "from": "118" + }, + { + "to": "118", + "from": "119" + }, + { + "to": "119", + "from": "120" + }, + { + "to": "120", + "from": "121" + }, + { + "to": "121", + "from": "122" + }, + { + "to": "122", + "from": "123" + }, + { + "to": "123", + "from": "124" + }, + { + "to": "124", + "from": "125" + }, + { + "to": "125", + "from": "126" + }, + { + "to": "126", + "from": "127" + }, + { + "to": "127", + "from": "128" + }, + { + "to": "128", + "from": "129" + }, + { + "to": "129", + "from": "130" + }, + { + "to": "130", + "from": "131" + }, + { + "to": "131", + "from": "132" + }, + { + "to": "132", + "from": "134" + }, + { + "to": "132", + "from": "133" + }, + { + "to": "133", + "from": "135" + }, + { + "to": "134", + "from": "135" + }, + { + "to": "135", + "from": "136" + }, + { + "to": "136", + "from": "137" + }, + { + "to": "137", + "from": "138" + }, + { + "to": "138", + "from": "139" + }, + { + "to": "138", + "from": "140" + }, + { + "to": "139", + "from": "141" + }, + { + "to": "140", + "from": "141" + }, + { + "to": "141", + "from": "142" + }, + { + "to": "142", + "from": "143" + }, + { + "to": "143", + "from": "144" + }, + { + "to": "144", + "from": "145" + }, + { + "to": "145", + "from": "146" + }, + { + "to": "146", + "from": "147" + }, + { + "to": "147", + "from": "148" + }, + { + "to": "148", + "from": "149" + }, + { + "to": "149", + "from": "150" + }, + { + "to": "150", + "from": "151" + }, + { + "to": "151", + "from": "152" + }, + { + "to": "152", + "from": "153" + }, + { + "to": "153", + "from": "154" + }, + { + "to": "154", + "from": "155" + }, + { + "to": "155", + "from": "157" + }, + { + "to": "155", + "from": "156" + }, + { + "to": "156", + "from": "158" + }, + { + "to": "157", + "from": "158" + }, + { + "to": "158", + "from": "159" + }, + { + "to": "159", + "from": "162" + }, + { + "to": "159", + "from": "160" + }, + { + "to": "160", + "from": "164" + }, + { + "to": "160", + "from": "161" + }, + { + "to": "161", + "from": "163" + }, + { + "to": "162", + "from": "164" + }, + { + "to": "163", + "from": "165" + }, + { + "to": "164", + "from": "166" + }, + { + "to": "165", + "from": "166" + }, + { + "to": "166", + "from": "167" + }, + { + "to": "167", + "from": "168" + }, + { + "to": "168", + "from": "170" + }, + { + "to": "168", + "from": "169" + }, + { + "to": "169", + "from": "171" + }, + { + "to": "170", + "from": "171" + }, + { + "to": "171", + "from": "172" + }, + { + "to": "172", + "from": "173" + }, + { + "to": "173", + "from": "174" + }, + { + "to": "174", + "from": "175" + }, + { + "to": "175", + "from": "176" + }, + { + "to": "176", + "from": "177" + }, + { + "to": "177", + "from": "178" + }, + { + "to": "178", + "from": "180" + }, + { + "to": "178", + "from": "179" + }, + { + "to": "179", + "from": "181" + }, + { + "to": "180", + "from": "181" + }, + { + "to": "181", + "from": "183" + }, + { + "to": "181", + "from": "182" + }, + { + "to": "182", + "from": "184" + }, + { + "to": "183", + "from": "184" + }, + { + "to": "184", + "from": "186" + }, + { + "to": "184", + "from": "185" + }, + { + "to": "185", + "from": "187" + }, + { + "to": "186", + "from": "189" + }, + { + "to": "186", + "from": "188" + }, + { + "to": "187", + "from": "188" + }, + { + "to": "187", + "from": "189" + }, + { + "to": "188", + "from": "190" + }, + { + "to": "189", + "from": "191" + }, + { + "to": "190", + "from": "191" + }, + { + "to": "191", + "from": "192" + }, + { + "to": "192", + "from": "193" + }, + { + "to": "193", + "from": "194" + }, + { + "to": "194", + "from": "195" + }, + { + "to": "195", + "from": "197" + }, + { + "to": "195", + "from": "196" + }, + { + "to": "196", + "from": "198" + }, + { + "to": "197", + "from": "198" + }, + { + "to": "198", + "from": "199" + }, + { + "to": "199", + "from": "200" + }, + { + "to": "200", + "from": "201" + }, + { + "to": "201", + "from": "202" + }, + { + "to": "202", + "from": "203" + }, + { + "to": "203", + "from": "204" + }, + { + "to": "204", + "from": "205" + }, + { + "to": "205", + "from": "206" + }, + { + "to": "206", + "from": "207" + }, + { + "to": "207", + "from": "208" + }, + { + "to": "208", + "from": "209" + }, + { + "to": "209", + "from": "210" + }, + { + "to": "210", + "from": "211" + }, + { + "to": "211", + "from": "212" + }, + { + "to": "212", + "from": "213" + }, + { + "to": "213", + "from": "214" + }, + { + "to": "214", + "from": "215" + }, + { + "to": "215", + "from": "216" + }, + { + "to": "216", + "from": "217" + }, + { + "to": "217", + "from": "218" + }, + { + "to": "218", + "from": "219" + }, + { + "to": "219", + "from": "220" + }, + { + "to": "220", + "from": "221" + }, + { + "to": "221", + "from": "222" + }, + { + "to": "222", + "from": "223" + }, + { + "to": "223", + "from": "224" + }, + { + "to": "224", + "from": "225" + }, + { + "to": "225", + "from": "226" + }, + { + "to": "226", + "from": "227" + }, + { + "to": "227", + "from": "228" + }, + { + "to": "228", + "from": "229" + }, + { + "to": "229", + "from": "230" + }, + { + "to": "229", + "from": "242" + }, + { + "to": "230", + "from": "231" + }, + { + "to": "231", + "from": "232" + }, + { + "to": "232", + "from": "233" + }, + { + "to": "233", + "from": "234" + }, + { + "to": "234", + "from": "235" + }, + { + "to": "235", + "from": "236" + }, + { + "to": "236", + "from": "237" + }, + { + "to": "237", + "from": "238" + }, + { + "to": "238", + "from": "239" + }, + { + "to": "239", + "from": "240" + }, + { + "to": "240", + "from": "241" + }, + { + "to": "242", + "from": "243" + }, + { + "to": "243", + "from": "244" + }, + { + "to": "244", + "from": "246" + }, + { + "to": "244", + "from": "245" + }, + { + "to": "245", + "from": "247" + }, + { + "to": "246", + "from": "247" + }, + { + "to": "247", + "from": "248" + }, + { + "to": "248", + "from": "249" + }, + { + "to": "249", + "from": "250" + }, + { + "to": "250", + "from": "251" + }, + { + "to": "251", + "from": "252" + }, + { + "to": "252", + "from": "253" + }, + { + "to": "253", + "from": "254" + }, + { + "to": "253", + "from": "255" + }, + { + "to": "254", + "from": "256" + }, + { + "to": "255", + "from": "256" + }, + { + "to": "256", + "from": "257" + }, + { + "to": "257", + "from": "258" + }, + { + "to": "258", + "from": "259" + }, + { + "to": "259", + "from": "260" + }, + { + "to": "260", + "from": "261" + }, + { + "to": "261", + "from": "262" + }, + { + "to": "262", + "from": "263" + }, + { + "to": "263", + "from": "264" + }, + { + "to": "264", + "from": "265" + }, + { + "to": "265", + "from": "268" + }, + { + "to": "265", + "from": "266" + }, + { + "to": "266", + "from": "267" + }, + { + "to": "267", + "from": "269" + }, + { + "to": "268", + "from": "269" + }, + { + "to": "269", + "from": "270" + }, + { + "to": "270", + "from": "271" + }, + { + "to": "271", + "from": "272" + }, + { + "to": "272", + "from": "273" + }, + { + "to": "273", + "from": "274" + }, + { + "to": "274", + "from": "275" + }, + { + "to": "275", + "from": "276" + }, + { + "to": "276", + "from": "277" + }, + { + "to": "277", + "from": "278" + }, + { + "to": "278", + "from": "280" + }, + { + "to": "278", + "from": "279" + }, + { + "to": "279", + "from": "282" + }, + { + "to": "280", + "from": "281" + }, + { + "to": "280", + "from": "282" + }, + { + "to": "281", + "from": "284" + }, + { + "to": "281", + "from": "283" + }, + { + "to": "282", + "from": "284" + }, + { + "to": "282", + "from": "283" + }, + { + "to": "283", + "from": "286" + }, + { + "to": "283", + "from": "285" + }, + { + "to": "284", + "from": "286" + }, + { + "to": "284", + "from": "285" + }, + { + "to": "285", + "from": "287" + }, + { + "to": "286", + "from": "287" + }, + { + "to": "287", + "from": "288" + }, + { + "to": "288", + "from": "289" + }, + { + "to": "289", + "from": "290" + }, + { + "to": "290", + "from": "291" + }, + { + "to": "291", + "from": "292" + }, + { + "to": "292", + "from": "293" + }, + { + "to": "293", + "from": "294" + }, + { + "to": "294", + "from": "295" + }, + { + "to": "295", + "from": "296" + }, + { + "to": "296", + "from": "297" + }, + { + "to": "297", + "from": "298" + }, + { + "to": "298", + "from": "299" + }, + { + "to": "299", + "from": "300" + }, + { + "to": "300", + "from": "301" + }, + { + "to": "301", + "from": "302" + }, + { + "to": "302", + "from": "303" + }, + { + "to": "303", + "from": "304" + }, + { + "to": "304", + "from": "305" + }, + { + "to": "305", + "from": "306" + }, + { + "to": "306", + "from": "307" + }, + { + "to": "307", + "from": "308" + }, + { + "to": "308", + "from": "309" + }, + { + "to": "309", + "from": "310" + }, + { + "to": "310", + "from": "311" + }, + { + "to": "311", + "from": "312" + }, + { + "to": "312", + "from": "313" + }, + { + "to": "313", + "from": "314" + }, + { + "to": "314", + "from": "315" + }, + { + "to": "315", + "from": "316" + }, + { + "to": "316", + "from": "317" + }, + { + "to": "316", + "from": "318" + }, + { + "to": "317", + "from": "332" + }, + { + "to": "317", + "from": "319" + }, + { + "to": "318", + "from": "319" + }, + { + "to": "319", + "from": "320" + }, + { + "to": "320", + "from": "321" + }, + { + "to": "321", + "from": "322" + }, + { + "to": "322", + "from": "323" + }, + { + "to": "323", + "from": "324" + }, + { + "to": "324", + "from": "325" + }, + { + "to": "325", + "from": "326" + }, + { + "to": "326", + "from": "327" + }, + { + "to": "327", + "from": "328" + }, + { + "to": "328", + "from": "329" + }, + { + "to": "329", + "from": "330" + }, + { + "to": "330", + "from": "331" + }, + { + "to": "332", + "from": "333" + }, + { + "to": "333", + "from": "334" + }, + { + "to": "334", + "from": "335" + }, + { + "to": "335", + "from": "336" + }, + { + "to": "336", + "from": "337" + }, + { + "to": "337", + "from": "338" + }, + { + "to": "338", + "from": "339" + }, + { + "to": "339", + "from": "340" + }, + { + "to": "340", + "from": "341" + }, + { + "to": "341", + "from": "342" + }, + { + "to": "342", + "from": "343" + }, + { + "to": "343", + "from": "344" + }, + { + "to": "344", + "from": "345" + }, + { + "to": "345", + "from": "346" + }, + { + "to": "346", + "from": "347" + }, + { + "to": "347", + "from": "348" + }, + { + "to": "348", + "from": "349" + }, + { + "to": "349", + "from": "350" + }, + { + "to": "350", + "from": "351" + }, + { + "to": "351", + "from": "352" + }, + { + "to": "352", + "from": "353" + }, + { + "to": "353", + "from": "354" + }, + { + "to": "354", + "from": "355" + }, + { + "to": "355", + "from": "356" + }, + { + "to": "356", + "from": "357" + }, + { + "to": "357", + "from": "358" + }, + { + "to": "358", + "from": "359" + }, + { + "to": "359", + "from": "360" + }, + { + "to": "360", + "from": "361" + }, + { + "to": "361", + "from": "362" + }, + { + "to": "362", + "from": "363" + }, + { + "to": "363", + "from": "364" + }, + { + "to": "364", + "from": "365" + }, + { + "to": "365", + "from": "366" + }, + { + "to": "366", + "from": "367" + }, + { + "to": "367", + "from": "368" + }, + { + "to": "368", + "from": "369" + }, + { + "to": "369", + "from": "370" + }, + { + "to": "370", + "from": "371" + }, + { + "to": "371", + "from": "372" + }, + { + "to": "372", + "from": "373" + }, + { + "to": "373", + "from": "374" + }, + { + "to": "374", + "from": "375" + }, + { + "to": "375", + "from": "376" + }, + { + "to": "376", + "from": "377" + }, + { + "to": "377", + "from": "378" + }, + { + "to": "378", + "from": "379" + }, + { + "to": "379", + "from": "380" + }, + { + "to": "380", + "from": "381" + }, + { + "to": "381", + "from": "382" + }, + { + "to": "382", + "from": "383" + }, + { + "to": "383", + "from": "384" + }, + { + "to": "384", + "from": "385" + }, + { + "to": "385", + "from": "387" + }, + { + "to": "386", + "from": "388" + }, + { + "to": "387", + "from": "389" + }, + { + "to": "388", + "from": "390" + }, + { + "to": "389", + "from": "390" + }, + { + "to": "390", + "from": "391" + }, + { + "to": "391", + "from": "392" + }, + { + "to": "392", + "from": "393" + }, + { + "to": "393", + "from": "394" + }, + { + "to": "394", + "from": "395" + }, + { + "to": "395", + "from": "396" + }, + { + "to": "396", + "from": "397" + }, + { + "to": "397", + "from": "398" + }, + { + "to": "398", + "from": "399" + } + ] +} ); diff --git a/examples/network/layout/hierarchicalLayout.html b/examples/network/layout/hierarchicalLayout.html index d8e317ec5..390009483 100644 --- a/examples/network/layout/hierarchicalLayout.html +++ b/examples/network/layout/hierarchicalLayout.html @@ -57,7 +57,7 @@ } - + diff --git a/examples/network/layout/hierarchicalLayoutBigUserDefined.html b/examples/network/layout/hierarchicalLayoutBigUserDefined.html new file mode 100644 index 000000000..a0ecd3563 --- /dev/null +++ b/examples/network/layout/hierarchicalLayoutBigUserDefined.html @@ -0,0 +1,46 @@ + + + + Network | Hierarchical layout + + + + + + + + + + +

Hierarchical Layout

+ +
+ + + + + diff --git a/examples/network/layout/hierarchicalLayoutMethods.html b/examples/network/layout/hierarchicalLayoutMethods.html index 6664bd2d1..44fcab16b 100644 --- a/examples/network/layout/hierarchicalLayoutMethods.html +++ b/examples/network/layout/hierarchicalLayoutMethods.html @@ -77,7 +77,7 @@ } - + diff --git a/examples/network/layout/hierarchicalLayoutUserdefined.html b/examples/network/layout/hierarchicalLayoutUserdefined.html index de536e549..e0f6cd0d8 100644 --- a/examples/network/layout/hierarchicalLayoutUserdefined.html +++ b/examples/network/layout/hierarchicalLayoutUserdefined.html @@ -103,7 +103,7 @@ } - + diff --git a/examples/network/layout/randomSeed.html b/examples/network/layout/randomSeed.html index 1ca075ae8..103103262 100644 --- a/examples/network/layout/randomSeed.html +++ b/examples/network/layout/randomSeed.html @@ -55,6 +55,6 @@ var network = new vis.Network(container, data, options); - + diff --git a/examples/network/nodeStyles/HTMLInNodes.html b/examples/network/nodeStyles/HTMLInNodes.html index e80590b73..5f0f53da9 100644 --- a/examples/network/nodeStyles/HTMLInNodes.html +++ b/examples/network/nodeStyles/HTMLInNodes.html @@ -68,7 +68,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/circularImages.html b/examples/network/nodeStyles/circularImages.html index b0f144c54..56100056f 100644 --- a/examples/network/nodeStyles/circularImages.html +++ b/examples/network/nodeStyles/circularImages.html @@ -92,7 +92,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/circularOverImage.html b/examples/network/nodeStyles/circularOverImage.html index 457f3da4d..ab9e4780d 100644 --- a/examples/network/nodeStyles/circularOverImage.html +++ b/examples/network/nodeStyles/circularOverImage.html @@ -29,31 +29,32 @@ var DIR = '../img/indonesia/'; var DIR2 = '../img/cluster-images/' nodes = [ - {id: 1, shape: 'circularOverImage', image: DIR2 + 'type.svg', - circularImage: DIR + '1.png', - ovrImgRatio: 0.6, + {id: 1, shape: 'circularOverImage', image: DIR2 + 'secrets.png', + circularImage: DIR2 + 'internet.svg', + ovrImgRatio: .8, + showAsBadge: true, label: "Circular Over Image-1", }, // test for undefined image urls: - {id: 2, shape: 'circularOverImage', //image: DIR2 + 'type.svg', - //circularImage: DIR + '2.png', - ovrImgRatio: 0.6, + {id: 2, shape: 'circularOverImage', image: DIR2 + 'type.svg', + circularImage: DIR + '2.png', + ovrImgRatio: 0.8, label: "Circular Over Image-2", }, - {id: 3, shape: 'circularImage', image: DIR + '3.png'}, - {id: 4, shape: 'circularImage', image: DIR + '4.png', label:"pictures by this guy!"}, - {id: 5, shape: 'circularImage', image: DIR + '5.png'}, - {id: 6, shape: 'circularImage', image: DIR + '6.png'}, - {id: 7, shape: 'circularImage', image: DIR + '7.png'}, - {id: 8, shape: 'circularImage', image: DIR + '8.png'}, - {id: 9, shape: 'circularImage', image: DIR + '9.png'}, - {id: 10, shape: 'circularImage', image: DIR + '10.png'}, - {id: 11, shape: 'circularImage', image: DIR + '11.png'}, - {id: 12, shape: 'circularImage', image: DIR + '12.png'}, - {id: 13, shape: 'circularImage', image: DIR + '13.png'}, - {id: 14, shape: 'circularImage', image: DIR + '14.png'}, - {id: 15, shape: 'circularImage', image: DIR + 'missing.png', brokenImage: DIR + 'missingBrokenImage.png', label:"when images\nfail\nto load"}, - {id: 16, shape: 'circularImage', image: DIR + 'anotherMissing.png', brokenImage: DIR + '9.png', label:"fallback image in action"} + {id: 3, shape: 'image', image: DIR + '1.png'}, + // {id: 4, shape: 'circularImage', image: DIR + '1.png', label:"pictures by this guy!"}, + // {id: 5, shape: 'circularImage', image: DIR + '5.png'}, + // {id: 6, shape: 'circularImage', image: DIR + '6.png'}, + // {id: 7, shape: 'circularImage', image: DIR + '7.png'}, + // {id: 8, shape: 'circularImage', image: DIR + '8.png'}, + // {id: 9, shape: 'circularImage', image: DIR + '9.png'}, + // {id: 10, shape: 'circularImage', image: DIR + '10.png'}, + // {id: 11, shape: 'circularImage', image: DIR + '11.png'}, + // {id: 12, shape: 'circularImage', image: DIR + '12.png'}, + // {id: 13, shape: 'circularImage', image: DIR + '13.png'}, + // {id: 14, shape: 'circularImage', image: DIR + '14.png'}, + // {id: 15, shape: 'circularImage', image: DIR + 'missing.png', brokenImage: DIR + 'missingBrokenImage.png', label:"when images\nfail\nto load"}, + // {id: 16, shape: 'circularImage', image: DIR + 'anotherMissing.png', brokenImage: DIR + '9.png', label:"fallback image in action"} ]; // create connections between people @@ -61,19 +62,19 @@ edges = [ {from: 1, to: 2}, {from: 2, to: 3}, - {from: 2, to: 4}, - {from: 4, to: 5}, - {from: 4, to: 10}, - {from: 4, to: 6}, - {from: 6, to: 7}, - {from: 7, to: 8}, - {from: 8, to: 9}, - {from: 8, to: 10}, - {from: 10, to: 11}, - {from: 11, to: 12}, - {from: 12, to: 13}, - {from: 13, to: 14}, - {from: 9, to: 16} + // {from: 2, to: 4}, + // {from: 4, to: 5}, + // {from: 4, to: 10}, + // {from: 4, to: 6}, + // {from: 6, to: 7}, + // {from: 7, to: 8}, + // {from: 8, to: 9}, + // {from: 8, to: 10}, + // {from: 10, to: 11}, + // {from: 11, to: 12}, + // {from: 12, to: 13}, + // {from: 13, to: 14}, + // {from: 9, to: 16} ]; // create a network diff --git a/examples/network/nodeStyles/colors.html b/examples/network/nodeStyles/colors.html index 05bae1dd0..c1c4dc118 100644 --- a/examples/network/nodeStyles/colors.html +++ b/examples/network/nodeStyles/colors.html @@ -62,6 +62,6 @@ var network = new vis.Network(container, data, options); - + diff --git a/examples/network/nodeStyles/customGroups.html b/examples/network/nodeStyles/customGroups.html index 5c4b11c97..e14a2acdd 100644 --- a/examples/network/nodeStyles/customGroups.html +++ b/examples/network/nodeStyles/customGroups.html @@ -22,7 +22,7 @@ - + diff --git a/examples/network/nodeStyles/groups.html b/examples/network/nodeStyles/groups.html index 148ffa64b..1d56c09da 100644 --- a/examples/network/nodeStyles/groups.html +++ b/examples/network/nodeStyles/groups.html @@ -21,7 +21,7 @@ - + diff --git a/examples/network/nodeStyles/icons.html b/examples/network/nodeStyles/icons.html index 859fc065f..c44e5becc 100644 --- a/examples/network/nodeStyles/icons.html +++ b/examples/network/nodeStyles/icons.html @@ -174,7 +174,7 @@ var networkIO = new vis.Network(containerIO, dataIO, optionsIO); } - +

diff --git a/examples/network/nodeStyles/images.html b/examples/network/nodeStyles/images.html index 9a06136d8..998ed1d02 100644 --- a/examples/network/nodeStyles/images.html +++ b/examples/network/nodeStyles/images.html @@ -69,7 +69,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/imagesWithBorders.html b/examples/network/nodeStyles/imagesWithBorders.html index 7c65cee6f..542e69d84 100644 --- a/examples/network/nodeStyles/imagesWithBorders.html +++ b/examples/network/nodeStyles/imagesWithBorders.html @@ -95,7 +95,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/shadows.html b/examples/network/nodeStyles/shadows.html index 277b8b002..8126067f5 100644 --- a/examples/network/nodeStyles/shadows.html +++ b/examples/network/nodeStyles/shadows.html @@ -21,7 +21,7 @@ - + diff --git a/examples/network/nodeStyles/shapes.html b/examples/network/nodeStyles/shapes.html index 26625301f..d60901f6b 100644 --- a/examples/network/nodeStyles/shapes.html +++ b/examples/network/nodeStyles/shapes.html @@ -59,7 +59,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/shapesWithDashedBorders.html b/examples/network/nodeStyles/shapesWithDashedBorders.html index a2d65aed7..a129782eb 100644 --- a/examples/network/nodeStyles/shapesWithDashedBorders.html +++ b/examples/network/nodeStyles/shapesWithDashedBorders.html @@ -47,7 +47,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/network/nodeStyles/widthHeight.html b/examples/network/nodeStyles/widthHeight.html new file mode 100644 index 000000000..ab68eb6a1 --- /dev/null +++ b/examples/network/nodeStyles/widthHeight.html @@ -0,0 +1,121 @@ + + + + Network | Label Width and Height Settings + + + + + + + + + + +

Nodes may be set to have fixed, minimum and maximum widths and minimum heights. +Nodes with minimum heights may also have a vertical alignment set.

+ +

Edges may be set to have maximum widths.

+ +
+ +

The widthConstraint: value option means a fixed width, the minimum and maximum width of the element are set to the value (respecting left and right margins). Lines exceeding the maximum width will be broken at space boundaries to fit.

+

The widthConstraint: { minimum: value } option sets the minimum width of the element to the value.

+

The widthConstraint: { maximum: value } option sets the maximum width of the element to the value (respecting left and right margins). Lines exceeding the maximum width will be broken at space boundaries to fit.

+

Minimum width line sizing is applied after maximum width line breaking, so counterintuitively, the minimum being greater than the maximum has a meaningful interpretation.

+ +
+ +

The heightConstraint: value option sets the minimum height of the element to the value (respecting top and bottom margins).

+

The heightConstraint: { minimum: value } option also sets the minimum height of the element to the value (respecting top and bottom margins).

+

The heightConstraint: { valign: value } option (with value 'top', 'middle', or 'bottom', sets the alignment of the text in the element's label to the elements top, middle or bottom (respecting top and bottom margins) when it's height is less than the minimum. The middle value is the default.

+ +
+ +

Node width and height constraints may both be applied together, of course.

+

The constraint options may be applied to elements individually, or at the whole-set level. +Whole-set node and edge constraints are exclusive.

+ + + + + diff --git a/examples/network/other/animationShowcase.html b/examples/network/other/animationShowcase.html index 1e0d82aed..b16f0544d 100644 --- a/examples/network/other/animationShowcase.html +++ b/examples/network/other/animationShowcase.html @@ -192,7 +192,7 @@ } } - + diff --git a/examples/network/other/changingClusteredEdgesNodes.html b/examples/network/other/changingClusteredEdgesNodes.html index 145dcb726..4bb324d98 100644 --- a/examples/network/other/changingClusteredEdgesNodes.html +++ b/examples/network/other/changingClusteredEdgesNodes.html @@ -20,16 +20,16 @@ margin-bottom:3px; } - +

-Demonstrating getBaseEdge, getClusteredEdges updateEdge and updateClusteredNode.

  • Clicking on the cluster will change it to a star (updateClusteredNode).
  • +Demonstrating getBaseEdges, getClusteredEdges updateEdge and updateClusteredNode.
    • Clicking on the cluster will change it to a star (updateClusteredNode).
    • Clicking on an edge will make it red regardless of whether it is a clusteredEdge or not (updateEdge)
    • -
    • Clicking on an edge will also show the results of getBaseEdge and getClusteredEdge
    • +
    • Clicking on an edge will also show the results of getBaseEdges and getClusteredEdge

    @@ -94,7 +94,7 @@ var obj = {}; obj.clicked_id = params.edges[0]; network.clustering.updateEdge(params.edges[0], {color : '#aa0000'}); - obj.base_edge = network.clustering.getBaseEdge(params.edges[0]); + obj.base_edges = network.clustering.getBaseEdges(params.edges[0]); obj.all_clustered_edges = network.clustering.getClusteredEdges(params.edges[0]); document.getElementById('eventSpan').innerHTML = '

    selectEdge event:

    ' + JSON.stringify(obj, null, 4); } diff --git a/examples/network/other/chosen.html b/examples/network/other/chosen.html new file mode 100644 index 000000000..6717571ce --- /dev/null +++ b/examples/network/other/chosen.html @@ -0,0 +1,466 @@ + + + + Network | Chosen Elements + + + + + + + + + + +

    When a node or edge is selected or hovered its visible characteristics can be changed.

    + +
    + +

    In this network, an element (node, edge or label) will change a characteristic when hovered, and it will be locked in when selected. + This is managed by setting up a 'chosen' function that will be called when the element containing the function is chosen. + These functions may be set on nodes, edges and labels, at the individual or group level.

    + +

    All states (unselected, hover-over-unselected, selected, and hover-over selected) may be handled as needed by the application using vis, as the select and hover states are passed to the chosen function when called. + Additionally, the id of the element is passed to allow context-specific characteristic adjustment on select or hover as needed.

    + +

    It should be noted that the characteristics which might affect the position of elements have been left out on purpose. + While it might be interesting to make them changeable, this is problematic on hovering. + Consider that the user hovers over an object. + If it changed characteristics that moved it outside of the hover-distance, it would then no longer be hovering. + So it would be moved back to its original prosition, within the hover-distance and then again be hovering over the object. + This hysteresis loop is kept from occurring by leaving out the characteristics that could cause it. + Some seemingly innocuous changes (such as resizing a node's label on hover that would in turn cause the node to resize and move out of hover-distance) may still cause hysteresis, but with care they should be avoidable.

    + + + + + + diff --git a/examples/network/other/clustering.html b/examples/network/other/clustering.html index bd68c42f8..c82304588 100644 --- a/examples/network/other/clustering.html +++ b/examples/network/other/clustering.html @@ -20,7 +20,7 @@ margin-bottom:3px; } - + diff --git a/examples/network/other/clusteringByZoom.html b/examples/network/other/clusteringByZoom.html index e2391f161..e403f899e 100644 --- a/examples/network/other/clusteringByZoom.html +++ b/examples/network/other/clusteringByZoom.html @@ -21,7 +21,7 @@ margin-bottom: 3px; } - + diff --git a/examples/network/other/configuration.html b/examples/network/other/configuration.html index a13470d44..5a86c0e5d 100644 --- a/examples/network/other/configuration.html +++ b/examples/network/other/configuration.html @@ -63,7 +63,7 @@ } - + diff --git a/examples/network/other/cursorChange.html b/examples/network/other/cursorChange.html new file mode 100644 index 000000000..a35a7230a --- /dev/null +++ b/examples/network/other/cursorChange.html @@ -0,0 +1,140 @@ + + + + Network | Cursor Change + + + + + + + + +

    Here is a simple network with nodes and edges that demonstrates how to change the cursor on hover for nodes and edges.

    +

    Use the dropdowns and button below to change the cursor type bound to an event.

    +
    +Event Type - + +  +Cursor Type - + +  + + +
    +
    + + + + + + diff --git a/examples/network/other/manipulation.html b/examples/network/other/manipulation.html index b399c098c..f86b6b90c 100644 --- a/examples/network/other/manipulation.html +++ b/examples/network/other/manipulation.html @@ -153,7 +153,7 @@ } - + diff --git a/examples/network/other/manipulationEditEdgeNoDrag.html b/examples/network/other/manipulationEditEdgeNoDrag.html new file mode 100644 index 000000000..525416df8 --- /dev/null +++ b/examples/network/other/manipulationEditEdgeNoDrag.html @@ -0,0 +1,255 @@ + + + + + Network | Manipulation | Edit Edge Without Drag + + + + + + + + + + + +

    Editing the nodes and edges-without-drag (localized)

    +

    + The localization is only relevant to the manipulation buttons. +

    + +

    + + +

    + +
    + node
    + + + + + + + +
    id
    label
    + + +
    + +
    + edge
    + + + +
    label
    + + +
    + +
    +
    + + + diff --git a/examples/network/other/navigation.html b/examples/network/other/navigation.html index 760bb539c..22fd3b8c6 100644 --- a/examples/network/other/navigation.html +++ b/examples/network/other/navigation.html @@ -83,7 +83,7 @@ }); } - + diff --git a/examples/network/other/onLoadAnimation.html b/examples/network/other/onLoadAnimation.html new file mode 100644 index 000000000..c6c2b1905 --- /dev/null +++ b/examples/network/other/onLoadAnimation.html @@ -0,0 +1,85 @@ + + + + Network | On Load Animation + + + + + +

    Vis.js network onLoad animation

    +

    easeIn functions accelerate from zero velocity.

    +

    easeOut functions decelerate to zero velocity.

    +

    easeInOut functions accelerate from zero till halfway then after the halfway point they decrease until zero.

    +
    + Onload Animation Easing Function - + + +
    +

    For more information on easing functions check out easings.net

    +
    + + + + diff --git a/examples/network/other/performance.html b/examples/network/other/performance.html index 0b3ea6636..7b8c65970 100644 --- a/examples/network/other/performance.html +++ b/examples/network/other/performance.html @@ -71,7 +71,7 @@ - +

    diff --git a/examples/network/other/saveAndLoad.html b/examples/network/other/saveAndLoad.html index 08165c854..6f96df347 100644 --- a/examples/network/other/saveAndLoad.html +++ b/examples/network/other/saveAndLoad.html @@ -36,7 +36,7 @@ - + @@ -71,15 +71,6 @@ draw(); } - function addContextualInformation(elem, index, array) { - addId(elem, index); - addConnections(elem, index); - } - - function addId(elem, index) { - elem.id = index; - } - function addConnections(elem, index) { // need to replace this with a tree of the network, then get child direct children of the element elem.connections = network.getConnectedNodes(index); @@ -107,7 +98,7 @@ var nodes = objectToArray(network.getPositions()); - nodes.forEach(addContextualInformation); + nodes.forEach(addConnections); // pretty print node data var exportValue = JSON.stringify(nodes, undefined, 2); @@ -141,30 +132,47 @@ return new vis.DataSet(networkNodes); } + function getNodeById(data, id) { + for (var n = 0; n < data.length; n++) { + if (data[n].id == id) { // double equals since id can be numeric or string + return data[n]; + } + }; + + throw 'Can not find id \'' + id + '\' in data'; + } + function getEdgeData(data) { var networkEdges = []; - data.forEach(function(node, index, array) { + data.forEach(function(node) { // add the connection node.connections.forEach(function(connId, cIndex, conns) { networkEdges.push({from: node.id, to: connId}); + let cNode = getNodeById(data, connId); - var elementConnections = array[connId].connections; + var elementConnections = cNode.connections; // remove the connection from the other node to prevent duplicate connections var duplicateIndex = elementConnections.findIndex(function(connection) { - connection === node.id; + return connection == node.id; // double equals since id can be numeric or string }); - elementConnections = elementConnections.splice(0, duplicateIndex - 1).concat(elementConnections.splice(duplicateIndex + 1, elementConnections.length)) - }); + + if (duplicateIndex != -1) { + elementConnections.splice(duplicateIndex, 1); + }; + }); }); return new vis.DataSet(networkEdges); } function objectToArray(obj) { - return Object.keys(obj).map(function (key) { return obj[key]; }); + return Object.keys(obj).map(function (key) { + obj[key].id = key; + return obj[key]; + }); } function resizeExportArea() { @@ -174,4 +182,4 @@ init(); - \ No newline at end of file + diff --git a/examples/network/physics/physicsConfiguration.html b/examples/network/physics/physicsConfiguration.html index d28d20db3..b700f2487 100644 --- a/examples/network/physics/physicsConfiguration.html +++ b/examples/network/physics/physicsConfiguration.html @@ -60,7 +60,7 @@ network = new vis.Network(container, data, options); } - + diff --git a/examples/timeline/basicUsage.html b/examples/timeline/basicUsage.html index 8ad5fda15..4a1106cd3 100644 --- a/examples/timeline/basicUsage.html +++ b/examples/timeline/basicUsage.html @@ -11,7 +11,7 @@ - + @@ -42,4 +42,4 @@ var timeline = new vis.Timeline(container, items, options); - \ No newline at end of file + diff --git a/examples/timeline/dataHandling/dataSerialization.html b/examples/timeline/dataHandling/dataSerialization.html index 79b6aebf4..65e8d01bd 100644 --- a/examples/timeline/dataHandling/dataSerialization.html +++ b/examples/timeline/dataHandling/dataSerialization.html @@ -25,7 +25,7 @@ - + @@ -35,12 +35,38 @@

    Serialization and deserialization

    diff --git a/examples/timeline/dataHandling/loadExternalData.html b/examples/timeline/dataHandling/loadExternalData.html index f594d2aa7..a6e566894 100644 --- a/examples/timeline/dataHandling/loadExternalData.html +++ b/examples/timeline/dataHandling/loadExternalData.html @@ -14,7 +14,7 @@ - +

    diff --git a/examples/timeline/editing/customSnappingOfItems.html b/examples/timeline/editing/customSnappingOfItems.html index b1c8ef758..2b85305af 100644 --- a/examples/timeline/editing/customSnappingOfItems.html +++ b/examples/timeline/editing/customSnappingOfItems.html @@ -5,7 +5,7 @@ - +

    diff --git a/examples/timeline/editing/editingItems.html b/examples/timeline/editing/editingItems.html index 186a363c0..93c4ad8e1 100644 --- a/examples/timeline/editing/editingItems.html +++ b/examples/timeline/editing/editingItems.html @@ -12,7 +12,7 @@ - + diff --git a/examples/timeline/editing/editingItemsCallbacks.html b/examples/timeline/editing/editingItemsCallbacks.html index 7c15d8788..4c6ad5b23 100644 --- a/examples/timeline/editing/editingItemsCallbacks.html +++ b/examples/timeline/editing/editingItemsCallbacks.html @@ -15,7 +15,7 @@ - +

    diff --git a/examples/timeline/editing/individualEditableItems.html b/examples/timeline/editing/individualEditableItems.html index d62103051..095c15742 100644 --- a/examples/timeline/editing/individualEditableItems.html +++ b/examples/timeline/editing/individualEditableItems.html @@ -25,7 +25,7 @@ - + @@ -34,16 +34,21 @@

    - \ No newline at end of file + diff --git a/examples/timeline/editing/itemsAlwaysDraggable.html b/examples/timeline/editing/itemsAlwaysDraggable.html new file mode 100644 index 000000000..24ab53c2a --- /dev/null +++ b/examples/timeline/editing/itemsAlwaysDraggable.html @@ -0,0 +1,38 @@ + + + Timeline | itemsAlwaysDraggable Option + + + + + +

    The itemsAlwaysDraggable option allows to drag items around without first selecting them. When itemsAlwaysDraggable.range is set to true, the range can be changed without selection as well.

    +
    + + + + diff --git a/examples/timeline/editing/overrideEditingItems.html b/examples/timeline/editing/overrideEditingItems.html new file mode 100644 index 000000000..d035e8f73 --- /dev/null +++ b/examples/timeline/editing/overrideEditingItems.html @@ -0,0 +1,99 @@ + + + + Timeline | Individual editable items + + + + + + + + + +

    Specify individual items to be editable or readonly. Toggle edit options and override behavior from timeline.editable

    + +
    +

    +

    +Timeline.editable = {
    +add
    +remove
    +updateGroup
    +updateTime
    +overrideItems
    +} +
    +

    + + + diff --git a/examples/timeline/editing/tooltipOnItemChange.html b/examples/timeline/editing/tooltipOnItemChange.html index 18380beb6..fbdcb33ba 100644 --- a/examples/timeline/editing/tooltipOnItemChange.html +++ b/examples/timeline/editing/tooltipOnItemChange.html @@ -2,6 +2,7 @@ Timeline | Tooltip on item onUpdateTime Option + @@ -11,7 +12,7 @@ } - + @@ -63,20 +64,20 @@

    With groups

    editable: true }; - var options1 = Object.assign({ + var options1 = jQuery.extend(options, { tooltipOnItemUpdateTime: true - }, options) + }) var container1 = document.getElementById('mytimeline1'); timeline1 = new vis.Timeline(container1, items, null, options1); - var options2 = Object.assign({ + var options2 = jQuery.extend(options, { orientation: 'top', tooltipOnItemUpdateTime: { template: function(item) { return 'html template for tooltip with item.start: ' + item.start; } } - }, options) + }) var container2 = document.getElementById('mytimeline2'); timeline2 = new vis.Timeline(container2, items, null, options2); @@ -117,10 +118,10 @@

    With groups

    } - var options3 = Object.assign({ + var options3 = jQuery.extend(options, { orientation: 'top', tooltipOnItemUpdateTime: true - }, options) + }) var container3 = document.getElementById('mytimeline3'); timeline3 = new vis.Timeline(container3, itemsWithGroups, groups, options3); diff --git a/examples/timeline/editing/updateDataOnEvent.html b/examples/timeline/editing/updateDataOnEvent.html index 985551ee0..2d442baa3 100644 --- a/examples/timeline/editing/updateDataOnEvent.html +++ b/examples/timeline/editing/updateDataOnEvent.html @@ -15,7 +15,7 @@ - + diff --git a/examples/timeline/groups/groups.html b/examples/timeline/groups/groups.html index 56bad5991..8f1ba7590 100644 --- a/examples/timeline/groups/groups.html +++ b/examples/timeline/groups/groups.html @@ -21,7 +21,7 @@ - +

    diff --git a/examples/timeline/groups/groupsEditable.html b/examples/timeline/groups/groupsEditable.html index a7d275faf..b10adada0 100644 --- a/examples/timeline/groups/groupsEditable.html +++ b/examples/timeline/groups/groupsEditable.html @@ -24,11 +24,12 @@ - +

    - This example demonstrates editable groups (for now only reordering). + This example demonstrates editable groups (reordering and hiding). +

    @@ -55,7 +56,14 @@ {"content": "WEC", "id": "WEC", "value": 18, className:'endurance'}, {"content": "GP2", "id": "GP2", "value": 19, className:'openwheel'} ]); - + + // function to make all groups visible again + function showAllGroups(){ + groups.forEach(function(group){ + groups.update({id: group.id, visible: true}); + }) + }; + // create a dataset with items // note that months are zero-based in the JavaScript Date object, so month 3 is April var items = new vis.DataSet([ @@ -299,6 +307,20 @@ a.value = b.value; b.value = v; }, + groupTemplate: function(group){ + var container = document.createElement('div'); + var label = document.createElement('span'); + label.innerHTML = group.content + ' '; + container.insertAdjacentElement('afterBegin',label); + var hide = document.createElement('button'); + hide.innerHTML = 'hide'; + hide.style.fontSize = 'small'; + hide.addEventListener('click',function(){ + groups.update({id: group.id, visible: false}); + }); + container.insertAdjacentElement('beforeEnd',hide); + return container; + }, orientation: 'both', editable: true, groupEditable: true, diff --git a/examples/timeline/groups/groupsOrdering.html b/examples/timeline/groups/groupsOrdering.html index b4da7755a..617de53f2 100644 --- a/examples/timeline/groups/groupsOrdering.html +++ b/examples/timeline/groups/groupsOrdering.html @@ -18,7 +18,7 @@ - +

    diff --git a/examples/timeline/groups/nestedGroups.html b/examples/timeline/groups/nestedGroups.html new file mode 100644 index 000000000..f8abfb1a7 --- /dev/null +++ b/examples/timeline/groups/nestedGroups.html @@ -0,0 +1,113 @@ + + + + Timeline | Nested Groups example + + + + + + + + + + +

    + This example demonstrate using groups. Note that a DataSet is used for both + items and groups, allowing to dynamically add, update or remove both items + and groups via the DataSet. +

    +
    + + + + \ No newline at end of file diff --git a/examples/timeline/groups/subgroups.html b/examples/timeline/groups/subgroups.html index 0056705de..e17db9b36 100644 --- a/examples/timeline/groups/subgroups.html +++ b/examples/timeline/groups/subgroups.html @@ -1,7 +1,10 @@ - Timeline | Background areas + Timeline | Subgroups + + + - - - + table { + border: 1px solid gray; + } + + td { + text-align: center + } + + code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; + } + -

    This example shows the workings of the subgroups. Subgroups do not use stacking, and only work when stacking is disabled.

    +

    This example shows the workings of the subgroups. Subgroups can be stacked on each other, and the items within each subgroup can be stacked.

    +

    When stacking is on for the whole timeline, all items in the timeline will be stacked with respect to each other unless the stackSubgroups option is set to true + and at least one subgroup has stacking enabled. In that case the subgroups will be stacked with respect to each other and the elements in each subgroup will be stacked based on the individual + stacking settings for each subgroup. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptionStatusToggle
    Stackingfalse
    stackSubgroupstrue
    Stack Subgroup 0false
    Stack Subgroup 1false
    Stack Subgroup 2false
    +
    @@ -39,7 +95,7 @@ type: { start: 'ISODate', end: 'ISODate' } }); var groups = new vis.DataSet([{ - id: 'bar', content:'bar', subgroupOrder: function (a,b) {return a.subgroupOrder - b.subgroupOrder;} + id: 'bar', content:'bar', subgroupOrder: function (a,b) {return a.subgroupOrder - b.subgroupOrder;}, subgroupStack: {'sg_1': false, 'sg_2': false, 'sg_3': false } },{ id: 'foo', content:'foo', subgroupOrder: 'subgroupOrder' // this group has no subgroups but this would be the other method to do the sorting. }]); @@ -51,26 +107,55 @@ {id: 'SG_1_1',start: '2014-01-25', end: '2014-01-27', type: 'background', group:'bar', subgroup:'sg_1', subgroupOrder:0}, {id: 'SG_1_2', start: '2014-01-26', end: '2014-01-27', type: 'background', className: 'positive',group:'bar', subgroup:'sg_1', subgroupOrder:0}, - {id: 1, content: 'subgroup0', start: '2014-01-23 12:00:00', end: '2014-01-26 12:00:00',group:'bar', subgroup:'sg_1', subgroupOrder:0}, - {id: 'SG_2_1', start: '2014-01-27', end: '2014-01-29', type: 'background', group:'bar', subgroup:'sg_2', subgroupOrder:1}, - {id: 'SG_2_2', start: '2014-01-27', end: '2014-01-28', type: 'background', className: 'negative',group:'bar', subgroup:'sg_2', subgroupOrder:1}, - {id: 2, content: 'subgroup1', start: '2014-01-27', end: '2014-01-29',group:'bar', subgroup:'sg_2', subgroupOrder:1}, + {id: 1, content: 'subgroup0_1', start: '2014-01-23T12:00:00', end: '2014-01-26T12:00:00',group:'bar', subgroup:'sg_1', subgroupOrder:0}, + {id: 2, content: 'subgroup0_2', start: '2014-01-22T12:00:01', end: '2014-01-25T12:00:00',group:'bar', subgroup:'sg_1', subgroupOrder:0}, + + {id: 'SG_2_1', start: '2014-02-01', end: '2014-02-02', type: 'background', group:'bar', subgroup:'sg_2', subgroupOrder:1}, + {id: 'SG_2_2', start: '2014-02-2', end: '2014-02-03', type: 'background', className: 'negative',group:'bar', subgroup:'sg_2', subgroupOrder:1}, + {id: 3, content: 'subgroup1_1', start: '2014-01-27T02:00:00', end: '2014-01-29',group:'bar', subgroup:'sg_2', subgroupOrder:1}, + {id: 4, content: 'subgroup1_2', start: '2014-01-28', end: '2014-02-02',group:'bar', subgroup:'sg_2', subgroupOrder:1}, + + {id: 'SG_3_1',start: '2014-01-23', end: '2014-01-25', type: 'background', group:'bar', subgroup:'sg_3', subgroupOrder:2, content:"a"}, + {id: 'SG_3_2', start: '2014-01-26', end: '2014-01-28', type: 'background', className: 'positive',group:'bar', subgroup:'sg_3', subgroupOrder:2, content:"b"}, + {id: 5, content: 'subgroup2_1', start: '2014-01-23T12:00:00', end: '2014-01-26T12:00:00',group:'bar', subgroup:'sg_3', subgroupOrder:2}, + {id: 6, content: 'subgroup2_2', start: '2014-01-26T12:00:01', end: '2014-01-29T12:00:00',group:'bar', subgroup:'sg_3', subgroupOrder:2}, {id: 'background', start: '2014-01-29', end: '2014-01-30', type: 'background', className: 'negative',group:'bar'}, {id: 'background_all', start: '2014-01-31', end: '2014-02-02', type: 'background', className: 'positive'}, ]); var container = document.getElementById('visualization'); + var stackingStatus = document.getElementById('stackingStatus'); + var stackSubgroupsStatus = document.getElementById('stackSubgroupsStatus'); var options = { // orientation:'top' start: '2014-01-10', end: '2014-02-10', editable: true, - stack: false + stack: false, + stackSubgroups: true }; var timeline = new vis.Timeline(container, items, groups, options); + function toggleStacking() { + options.stack = !options.stack; + stackingStatus.innerHTML = options.stack.toString(); + timeline.setOptions(options); + } + + function toggleStackSubgroups() { + options.stackSubgroups = !options.stackSubgroups; + stackSubgroupsStatus.innerHTML = options.stackSubgroups.toString(); + timeline.setOptions(options); + } + + function toggleSubgroupStack(subgroup) { + groups.get("bar").subgroupStack[subgroup] = !groups.get("bar").subgroupStack[subgroup]; + document.getElementById('stack' + subgroup).innerHTML = groups.get("bar").subgroupStack[subgroup].toString(); + timeline.setGroups(groups); + } + - \ No newline at end of file + diff --git a/examples/timeline/groups/verticalItemsHide.html b/examples/timeline/groups/verticalItemsHide.html index 1f38fffef..ff81a08cc 100644 --- a/examples/timeline/groups/verticalItemsHide.html +++ b/examples/timeline/groups/verticalItemsHide.html @@ -12,7 +12,7 @@ font: 10pt arial; } - + @@ -106,12 +106,6 @@

    maxHeight: 400, start: new Date(), end: new Date(1000*60*60*24 + (new Date()).valueOf()), - editable: true, - margin: { - item: 10, // minimal margin between items - axis: 5 // minimal margin between items and the axis - }, - orientation: 'top' }; diff --git a/examples/timeline/interaction/animateWindow.html b/examples/timeline/interaction/animateWindow.html index 40a416f14..1ecad7bed 100644 --- a/examples/timeline/interaction/animateWindow.html +++ b/examples/timeline/interaction/animateWindow.html @@ -15,7 +15,7 @@ - + diff --git a/examples/timeline/interaction/clickToUse.html b/examples/timeline/interaction/clickToUse.html index d00f4428a..45afe5162 100644 --- a/examples/timeline/interaction/clickToUse.html +++ b/examples/timeline/interaction/clickToUse.html @@ -19,7 +19,7 @@ - +
    diff --git a/examples/timeline/interaction/eventListeners.html b/examples/timeline/interaction/eventListeners.html index f3a9bcc51..6b07d5b63 100644 --- a/examples/timeline/interaction/eventListeners.html +++ b/examples/timeline/interaction/eventListeners.html @@ -11,11 +11,11 @@ - +

    - This example listens for events select, rangechange, and rangechanged of the Timeline, and listens for changes in the DataSet (add, update, or remove items). + This example listens for events select, click, doubleClick, rangechange, and rangechanged of the Timeline (other possible events: mouseDown, mouseUp, mouseOver, mouseMove), and listens for changes in the DataSet (add, update, or remove items).

    @@ -34,16 +34,19 @@ var container = document.getElementById('visualization'); var options = { - editable: true + editable: true, + onInitialDrawComplete: function() { logEvent('Timeline initial draw completed', {}); }, }; var timeline = new vis.Timeline(container, items, options); timeline.on('rangechange', function (properties) { logEvent('rangechange', properties); }); + timeline.on('rangechanged', function (properties) { logEvent('rangechanged', properties); }); + timeline.on('select', function (properties) { logEvent('select', properties); }); @@ -52,20 +55,63 @@ logEvent('itemover', properties); setHoveredItem(properties.item); }); + timeline.on('itemout', function (properties) { logEvent('itemout', properties); setHoveredItem('none'); }); + timeline.on('click', function (properties) { + logEvent('click', properties); + }); + + timeline.on('doubleClick', function (properties) { + logEvent('doubleClick', properties); + }); + + timeline.on('contextmenu', function (properties) { + logEvent('contextmenu', properties); + }); + + timeline.on('mouseDown', function (properties) { + logEvent('mouseDown', properties); + }); + + timeline.on('mouseUp', function (properties) { + logEvent('mouseUp', properties); + }); + + // other possible events: + + // timeline.on('mouseOver', function (properties) { + // logEvent('mouseOver', properties); + // }); + + // timeline.on("mouseMove", function(properties) { + // logEvent('mouseMove', properties); + // }); + items.on('*', function (event, properties) { logEvent(event, properties); }); + function stringifyObject (object) { + if (!object) return; + var replacer = function(key, value) { + if (value && value.tagName) { + return "DOM Element"; + } else { + return value; + } + } + return JSON.stringify(object, replacer) + } + function logEvent(event, properties) { var log = document.getElementById('log'); var msg = document.createElement('div'); msg.innerHTML = 'event=' + JSON.stringify(event) + ', ' + - 'properties=' + JSON.stringify(properties); + 'properties=' + stringifyObject(properties); log.firstChild ? log.insertBefore(msg, log.firstChild) : log.appendChild(msg); } diff --git a/examples/timeline/interaction/limitMoveAndZoom.html b/examples/timeline/interaction/limitMoveAndZoom.html index 4eadd36ab..527d3f309 100644 --- a/examples/timeline/interaction/limitMoveAndZoom.html +++ b/examples/timeline/interaction/limitMoveAndZoom.html @@ -12,7 +12,7 @@ - +

    diff --git a/examples/timeline/interaction/navigationMenu.html b/examples/timeline/interaction/navigationMenu.html index cb7c19ad1..9f2dcd68f 100755 --- a/examples/timeline/interaction/navigationMenu.html +++ b/examples/timeline/interaction/navigationMenu.html @@ -24,7 +24,7 @@ - + @@ -38,6 +38,7 @@ +

@@ -74,6 +75,8 @@ document.getElementById('zoomOut').onclick = function () { timeline.zoomOut( 0.2); }; document.getElementById('moveLeft').onclick = function () { move( 0.2); }; document.getElementById('moveRight').onclick = function () { move(-0.2); }; + document.getElementById('toggleRollingMode').onclick = function () { timeline.toggleRollingMode() }; + diff --git a/examples/timeline/interaction/rollingMode.html b/examples/timeline/interaction/rollingMode.html new file mode 100644 index 000000000..199d3e391 --- /dev/null +++ b/examples/timeline/interaction/rollingMode.html @@ -0,0 +1,48 @@ + + + Timeline | rolling mode Option + + + + + + + + + +

Timeline rolling mode option

+ +
+ + + + + + diff --git a/examples/timeline/interaction/setSelection.html b/examples/timeline/interaction/setSelection.html index f6a038ce8..45901bf19 100644 --- a/examples/timeline/interaction/setSelection.html +++ b/examples/timeline/interaction/setSelection.html @@ -12,7 +12,7 @@ - +

Set selection

@@ -26,6 +26,21 @@

Set selection

+
+

If the height of the timeline is limited some items may be vertically offscreen. This demo uses Timeline.setSelection(ids, {focus: true}) and demonstrates that focusing on an item will +cause the timeline to scroll vertically to the item that is being focused on. You can use the buttons below select a random item either above or below the currently selected item. +

+ + +
+ +

If focusing on multiple items only the first item will be scrolled to. Try entering several ids and hitting select.

+

+Select item(s):
+

+ +
+ \ No newline at end of file diff --git a/examples/timeline/items/backgroundAreas.html b/examples/timeline/items/backgroundAreas.html index 3ec18a66e..94c27a08c 100644 --- a/examples/timeline/items/backgroundAreas.html +++ b/examples/timeline/items/backgroundAreas.html @@ -16,7 +16,7 @@ - + diff --git a/examples/timeline/items/backgroundAreasWithGroups.html b/examples/timeline/items/backgroundAreasWithGroups.html index f8a786084..7602791fb 100644 --- a/examples/timeline/items/backgroundAreasWithGroups.html +++ b/examples/timeline/items/backgroundAreasWithGroups.html @@ -12,7 +12,7 @@ - + diff --git a/examples/timeline/items/expectedVsActualTimesItems.html b/examples/timeline/items/expectedVsActualTimesItems.html new file mode 100644 index 000000000..7a5facca1 --- /dev/null +++ b/examples/timeline/items/expectedVsActualTimesItems.html @@ -0,0 +1,130 @@ + + + + Timeline | expected vs actual times items + + + + + + + + +
+ + + + diff --git a/examples/timeline/items/htmlContents.html b/examples/timeline/items/htmlContents.html index d790133af..431e7fd10 100644 --- a/examples/timeline/items/htmlContents.html +++ b/examples/timeline/items/htmlContents.html @@ -19,7 +19,7 @@ - +

@@ -48,7 +48,7 @@ item5.appendChild(document.createTextNode('item 5')); item5.appendChild(document.createElement('br')); var img5 = document.createElement('img'); - img5.src = 'img/attachment-icon.png'; + img5.src = '../resources/img/attachment-icon.png'; img5.style.width = '48px'; img5.style.height = '48px'; item5.appendChild(img5); @@ -72,4 +72,4 @@ var timeline = new vis.Timeline(container, items, options); - \ No newline at end of file + diff --git a/examples/timeline/items/itemOrdering.html b/examples/timeline/items/itemOrdering.html index 323c9501a..43fe83b8e 100644 --- a/examples/timeline/items/itemOrdering.html +++ b/examples/timeline/items/itemOrdering.html @@ -15,7 +15,7 @@ - +

Item ordering

diff --git a/examples/timeline/items/pointItems.html b/examples/timeline/items/pointItems.html index 682018019..40f705ede 100755 --- a/examples/timeline/items/pointItems.html +++ b/examples/timeline/items/pointItems.html @@ -11,7 +11,7 @@ - +

World War II timeline

diff --git a/examples/timeline/items/rangeOverflowItem.html b/examples/timeline/items/rangeOverflowItem.html index fbea2f37c..a760bb194 100644 --- a/examples/timeline/items/rangeOverflowItem.html +++ b/examples/timeline/items/rangeOverflowItem.html @@ -16,7 +16,7 @@ } - +

diff --git a/examples/timeline/items/tooltip.html b/examples/timeline/items/tooltip.html new file mode 100644 index 000000000..8908241a0 --- /dev/null +++ b/examples/timeline/items/tooltip.html @@ -0,0 +1,99 @@ + + + + Timeline | Tooltips + + + + + + + + + +

Tooltips

+ +

+ Setting the tooltip in various ways. +

+ +
+ +

+ The example below has the tooltip follow the mouse. +

+ +
+ +

+ The example below has the tooltip overflow set to 'cap'. Compare this to the one above, + to see how they differ. For the best results, move the cursor to the top right, + where the tool-tip is going to overflow out of the timeline. +

+ +
+ +

+ Disable item tooltips. +

+ +
+ + + + + diff --git a/examples/timeline/items/visibleFrameTemplateContent.html b/examples/timeline/items/visibleFrameTemplateContent.html new file mode 100644 index 000000000..f01d57ed5 --- /dev/null +++ b/examples/timeline/items/visibleFrameTemplateContent.html @@ -0,0 +1,67 @@ + + + + Timeline | Dynamic Content + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/examples/timeline/other/customTimeBars.html b/examples/timeline/other/customTimeBars.html index 2c1a5f7cf..41d8d3602 100644 --- a/examples/timeline/other/customTimeBars.html +++ b/examples/timeline/other/customTimeBars.html @@ -12,7 +12,7 @@ - + diff --git a/examples/timeline/other/customTimeBarsTooltip.html b/examples/timeline/other/customTimeBarsTooltip.html new file mode 100644 index 000000000..0fe62f36a --- /dev/null +++ b/examples/timeline/other/customTimeBarsTooltip.html @@ -0,0 +1,95 @@ + + + + Timeline | Show current and custom time bars + + + + + + + + + +

+ The Timeline has functions to add multiple custom time bars which can be dragged by the user. +

+

+ + +

+

+ + +

+

+ timechange event, index: , time: +

+

+ timechanged event, index: , time: +


+ +
+ + + + \ No newline at end of file diff --git a/examples/timeline/other/dataAttributes.html b/examples/timeline/other/dataAttributes.html index 0aa1f14ea..e94e82747 100644 --- a/examples/timeline/other/dataAttributes.html +++ b/examples/timeline/other/dataAttributes.html @@ -11,7 +11,7 @@ - +

diff --git a/examples/timeline/other/dataAttributesAll.html b/examples/timeline/other/dataAttributesAll.html index 5b926b54c..219f1c0b0 100644 --- a/examples/timeline/other/dataAttributesAll.html +++ b/examples/timeline/other/dataAttributesAll.html @@ -11,7 +11,7 @@ - +

diff --git a/examples/timeline/other/drag&drop.html b/examples/timeline/other/drag&drop.html index 81bcb1f17..fa323156f 100644 --- a/examples/timeline/other/drag&drop.html +++ b/examples/timeline/other/drag&drop.html @@ -7,7 +7,7 @@ - + @@ -37,19 +58,33 @@

Timeline Drag & Drop Example

For this to work, you will have to define your own 'dragstart' eventListener on each item in your list of items (make sure that any new item added to the list is attached to this eventListener 'dragstart' handler). This 'dragstart' handler must set dataTransfer - notice you can set the item's information as you want this way.

-
-

Items:

-
    -
  • - item 1 - box -
  • -
  • - item 2 - point -
  • -
  • - item 3 - range + +
    +
    +

    Items:

    +
      +
    • + item 1 - box +
    • +
    • + item 2 - point +
    • +
    • + item 3 - range +
    • +
    • + item 3 - range - fixed times -
      + (start: now, end: now + 10 min) +
    • +
    +
    + +
    +

    Object with "target:'item'":

    +
  • + object with target:'item'
  • -
+
diff --git a/examples/timeline/other/functionLabelFormats.html b/examples/timeline/other/functionLabelFormats.html index 9de9023bf..f8e442bda 100644 --- a/examples/timeline/other/functionLabelFormats.html +++ b/examples/timeline/other/functionLabelFormats.html @@ -21,7 +21,7 @@ - +

diff --git a/examples/timeline/other/groupsPerformance.html b/examples/timeline/other/groupsPerformance.html index 1b16af303..93fbcf47f 100644 --- a/examples/timeline/other/groupsPerformance.html +++ b/examples/timeline/other/groupsPerformance.html @@ -11,7 +11,7 @@ font: 10pt arial; } - + diff --git a/examples/timeline/other/hidingPeriods.html b/examples/timeline/other/hidingPeriods.html index 52ec6f9a2..07edfd1fc 100644 --- a/examples/timeline/other/hidingPeriods.html +++ b/examples/timeline/other/hidingPeriods.html @@ -11,7 +11,7 @@ - +

diff --git a/examples/timeline/other/horizontalScroll.html b/examples/timeline/other/horizontalScroll.html index a999cd512..a2ad8b23f 100644 --- a/examples/timeline/other/horizontalScroll.html +++ b/examples/timeline/other/horizontalScroll.html @@ -5,7 +5,7 @@ - + diff --git a/examples/timeline/other/localization.html b/examples/timeline/other/localization.html index 5b58923b7..caa5d38a9 100644 --- a/examples/timeline/other/localization.html +++ b/examples/timeline/other/localization.html @@ -13,7 +13,7 @@ - +

diff --git a/examples/timeline/other/performance.html b/examples/timeline/other/performance.html index 45b22aaba..52b4c184f 100644 --- a/examples/timeline/other/performance.html +++ b/examples/timeline/other/performance.html @@ -15,7 +15,7 @@ - +

diff --git a/examples/timeline/other/requirejs/requirejs_example.html b/examples/timeline/other/requirejs/requirejs_example.html index 363845fe7..88125b6cb 100644 --- a/examples/timeline/other/requirejs/requirejs_example.html +++ b/examples/timeline/other/requirejs/requirejs_example.html @@ -6,7 +6,7 @@ - +

diff --git a/examples/timeline/other/rtl.html b/examples/timeline/other/rtl.html index f53b18020..7939c51ca 100644 --- a/examples/timeline/other/rtl.html +++ b/examples/timeline/other/rtl.html @@ -3,9 +3,10 @@ Timeline | RTL example + - + @@ -39,10 +40,10 @@

Using options.rtl = true

height: '300px', }; - var options1 = Object.assign({}, options) + var options1 = jQuery.extend(options, {}) var timeline1 = new vis.Timeline(container1, items, options1); - var options2 = Object.assign({rtl: true}, options) + var options2 = jQuery.extend(options, {rtl: true}) var timeline2 = new vis.Timeline(container2, items, options2); diff --git a/examples/timeline/other/stressPerformance.html b/examples/timeline/other/stressPerformance.html new file mode 100644 index 000000000..94906e061 --- /dev/null +++ b/examples/timeline/other/stressPerformance.html @@ -0,0 +1,66 @@ + + + + + + Timeline | Stress Performance example + + + + + + +
+ + + + + \ No newline at end of file diff --git a/examples/timeline/other/timezone.html b/examples/timeline/other/timezone.html index 8994ba984..438a91642 100644 --- a/examples/timeline/other/timezone.html +++ b/examples/timeline/other/timezone.html @@ -12,7 +12,7 @@ - + diff --git a/examples/timeline/other/usingReact.html b/examples/timeline/other/usingReact.html index f6d1e1f71..8c9ade420 100644 --- a/examples/timeline/other/usingReact.html +++ b/examples/timeline/other/usingReact.html @@ -88,10 +88,12 @@ end: new Date(1000*60*60*24 + (new Date()).valueOf()), editable: true, template: function (item, element) { + if (!item) { return } ReactDOM.unmountComponentAtNode(element); return ReactDOM.render(, element); }, groupTemplate: function (group, element) { + if (!group) { return } ReactDOM.unmountComponentAtNode(element); return ReactDOM.render(, element); } diff --git a/examples/timeline/other/verticalScroll.html b/examples/timeline/other/verticalScroll.html index ddf946f0b..1aebae6fb 100644 --- a/examples/timeline/other/verticalScroll.html +++ b/examples/timeline/other/verticalScroll.html @@ -2,10 +2,11 @@ Timeline | Vertical Scroll Option + - + @@ -52,12 +53,13 @@

With date.setHours(date.getHours() + 2 + Math.floor(Math.random()*4)); var end = new Date(date); + var orderIndex = order + itemsPerGroup * truck items.add({ - id: order + itemsPerGroup * truck, + id: orderIndex, group: truck, start: start, end: end, - content: 'Order ' + order + content: 'Order ' + orderIndex }); } } @@ -70,20 +72,15 @@

With maxHeight: 200, start: new Date(), end: new Date(1000*60*60*24 + (new Date()).valueOf()), - editable: true, - margin: { - item: 10, // minimal margin between items - axis: 5 // minimal margin between items and the axis - }, - orientation: 'top' }; // create a Timeline - options1 = Object.assign({}, options) + + options1 = jQuery.extend(options, {}); var container1 = document.getElementById('mytimeline1'); timeline1 = new vis.Timeline(container1, items, groups, options1); - options2 = Object.assign({horizontalScroll: true}, options) + options2 = jQuery.extend(options, {horizontalScroll: true}); var container2 = document.getElementById('mytimeline2'); timeline2 = new vis.Timeline(container2, items, groups, options2); diff --git a/examples/timeline/resources/data/wk2014.json b/examples/timeline/resources/data/wk2014.json index 2bcb3d752..519169d36 100644 --- a/examples/timeline/resources/data/wk2014.json +++ b/examples/timeline/resources/data/wk2014.json @@ -7,7 +7,7 @@ "abbr2": "cl", "score2": "1 (2)", "description": "round of 16", - "start": "2014-06-28 13:00" + "start": "2014-06-28T13:00" }, { "player1": "Colombia", @@ -17,7 +17,7 @@ "abbr2": "uy", "score2": 0, "description": "round of 16", - "start": "2014-06-28 17:00" + "start": "2014-06-28T17:00" }, { "player1": "Netherlands", @@ -27,7 +27,7 @@ "abbr2": "mx", "score2": 1, "description": "round of 16", - "start": "2014-06-29 13:00" + "start": "2014-06-29T13:00" }, { "player1": "Costa Rica", @@ -37,7 +37,7 @@ "abbr2": "gr", "score2": "1 (3)", "description": "round of 16", - "start": "2014-06-29 17:00" + "start": "2014-06-29T17:00" }, { "player1": "France", @@ -47,7 +47,7 @@ "abbr2": "ng", "score2": 0, "description": "round of 16", - "start": "2014-06-30 13:00" + "start": "2014-06-30T13:00" }, { "player1": "Germany", @@ -57,7 +57,7 @@ "abbr2": "dz", "score2": 1, "description": "round of 16", - "start": "2014-06-30 17:00" + "start": "2014-06-30T17:00" }, { "player1": "Argentina", @@ -67,7 +67,7 @@ "abbr2": "ch", "score2": 0, "description": "round of 16", - "start": "2014-07-01 13:00" + "start": "2014-07-01T13:00" }, { "player1": "Belgium", @@ -77,7 +77,7 @@ "abbr2": "us", "score2": 1, "description": "round of 16", - "start": "2014-07-01 17:00" + "start": "2014-07-01T17:00" }, { "player1": "France", @@ -87,7 +87,7 @@ "abbr2": "de", "score2": 1, "description": "quarter-finals", - "start": "2014-07-04 13:00" + "start": "2014-07-04T13:00" }, { "player1": "Brazil", @@ -97,7 +97,7 @@ "abbr2": "co", "score2": 1, "description": "quarter-finals", - "start": "2014-07-04 17:00" + "start": "2014-07-04T17:00" }, { "player1": "Argentina", @@ -107,7 +107,7 @@ "abbr2": "be", "score2": 0, "description": "quarter-finals", - "start": "2014-07-05 13:00" + "start": "2014-07-05T13:00" }, { "player1": "Netherlands", @@ -117,7 +117,7 @@ "abbr2": "cr", "score2": "0 (3)", "description": "quarter-finals", - "start": "2014-07-05 17:00" + "start": "2014-07-05T17:00" }, { "player1": "Brazil", @@ -127,7 +127,7 @@ "abbr2": "de", "score2": 7, "description": "semi-finals", - "start": "2014-07-08 17:00" + "start": "2014-07-08T17:00" }, { "player1": "Netherlands", @@ -137,7 +137,7 @@ "abbr2": "ar", "score2": "0 (4)", "description": "semi-finals", - "start": "2014-07-09 17:00" + "start": "2014-07-09T17:00" }, { "player1": "Germany", @@ -147,6 +147,6 @@ "abbr2": "ar", "score2": 0, "description": "final", - "start": "2014-07-13 16:00" + "start": "2014-07-13T16:00" } -] \ No newline at end of file +] diff --git a/examples/timeline/styling/axisOrientation.html b/examples/timeline/styling/axisOrientation.html index b3978f179..c52e1ed1e 100644 --- a/examples/timeline/styling/axisOrientation.html +++ b/examples/timeline/styling/axisOrientation.html @@ -12,7 +12,7 @@ - + diff --git a/examples/timeline/styling/customCss.html b/examples/timeline/styling/customCss.html index ed700a1dc..2ad9f5b51 100644 --- a/examples/timeline/styling/customCss.html +++ b/examples/timeline/styling/customCss.html @@ -62,7 +62,7 @@ } - + diff --git a/examples/timeline/styling/gridStyling.html b/examples/timeline/styling/gridStyling.html index 9fec28b21..cc1543801 100644 --- a/examples/timeline/styling/gridStyling.html +++ b/examples/timeline/styling/gridStyling.html @@ -26,7 +26,7 @@ color: white; } - +
diff --git a/examples/timeline/styling/itemClassNames.html b/examples/timeline/styling/itemClassNames.html index 624334845..9c06d7b58 100755 --- a/examples/timeline/styling/itemClassNames.html +++ b/examples/timeline/styling/itemClassNames.html @@ -65,7 +65,7 @@ } - +

This page demonstrates the Timeline with custom css classes for individual items.

diff --git a/examples/timeline/styling/itemTemplates.html b/examples/timeline/styling/itemTemplates.html index 13e43c466..d7df592a3 100644 --- a/examples/timeline/styling/itemTemplates.html +++ b/examples/timeline/styling/itemTemplates.html @@ -57,7 +57,7 @@ } - +

WK 2014

@@ -88,7 +88,7 @@

WK 2014

abbr2: 'cl', score2: '1 (2)', description: 'round of 16', - start: '2014-06-28 13:00' + start: '2014-06-28T13:00:00' }, { player1: 'Colombia', @@ -98,7 +98,7 @@

WK 2014

abbr2: 'uy', score2: 0, description: 'round of 16', - start: '2014-06-28 17:00' + start: '2014-06-28T17:00:00' }, { player1: 'Netherlands', @@ -108,7 +108,7 @@

WK 2014

abbr2: 'mx', score2: 1, description: 'round of 16', - start: '2014-06-29 13:00' + start: '2014-06-29T13:00:00' }, { player1: 'Costa Rica', @@ -118,7 +118,7 @@

WK 2014

abbr2: 'gr', score2: '1 (3)', description: 'round of 16', - start: '2014-06-29 17:00' + start: '2014-06-29T17:00:00' }, { player1: 'France', @@ -128,7 +128,7 @@

WK 2014

abbr2: 'ng', score2: 0, description: 'round of 16', - start: '2014-06-30 13:00' + start: '2014-06-30T13:00:00' }, { player1: 'Germany', @@ -138,7 +138,7 @@

WK 2014

abbr2: 'dz', score2: 1, description: 'round of 16', - start: '2014-06-30 17:00' + start: '2014-06-30T17:00:00' }, { player1: 'Argentina', @@ -148,7 +148,7 @@

WK 2014

abbr2: 'ch', score2: 0, description: 'round of 16', - start: '2014-07-01 13:00' + start: '2014-07-01T13:00:00' }, { player1: 'Belgium', @@ -158,7 +158,7 @@

WK 2014

abbr2: 'us', score2: 1, description: 'round of 16', - start: '2014-07-01 17:00' + start: '2014-07-01T17:00:00' }, // quarter-finals @@ -170,7 +170,7 @@

WK 2014

abbr2: 'de', score2: 1, description: 'quarter-finals', - start: '2014-07-04 13:00' + start: '2014-07-04T13:00:00' }, { player1: 'Brazil', @@ -180,7 +180,7 @@

WK 2014

abbr2: 'co', score2: 1, description: 'quarter-finals', - start: '2014-07-04 17:00' + start: '2014-07-04T17:00:00' }, { player1: 'Argentina', @@ -190,7 +190,7 @@

WK 2014

abbr2: 'be', score2: 0, description: 'quarter-finals', - start: '2014-07-05 13:00' + start: '2014-07-05T13:00:00' }, { player1: 'Netherlands', @@ -200,7 +200,7 @@

WK 2014

abbr2: 'cr', score2: '0 (3)', description: 'quarter-finals', - start: '2014-07-05 17:00' + start: '2014-07-05T17:00:00' }, // semi-finals @@ -212,7 +212,7 @@

WK 2014

abbr2: 'de', score2: 7, description: 'semi-finals', - start: '2014-07-08 17:00' + start: '2014-07-08T17:00:00' }, { player1: 'Netherlands', @@ -222,7 +222,7 @@

WK 2014

abbr2: 'ar', score2: '0 (4)', description: 'semi-finals', - start: '2014-07-09 17:00' + start: '2014-07-09T17:00:00' }, // final @@ -234,7 +234,7 @@

WK 2014

abbr2: 'ar', score2: 0, description: 'final', - start: '2014-07-13 16:00' + start: '2014-07-13T16:00:00' } ]); @@ -248,4 +248,4 @@

WK 2014

var timeline = new vis.Timeline(container, items, options); - \ No newline at end of file + diff --git a/examples/timeline/styling/weekStyling.html b/examples/timeline/styling/weekStyling.html new file mode 100644 index 000000000..9a3a919e5 --- /dev/null +++ b/examples/timeline/styling/weekStyling.html @@ -0,0 +1,111 @@ + + + + Timeline | Grid styling + + + + + + + + + + +

+ Week numbers are calculated based on locales. For this to properly work, the timeline must be loaded with a version + of moment.js including locales.

+

To set a locale for the timeline, specify the option + {locale: STRING}.

+

To set the scale to use week numbers, use for example {scale: 'week', step: 1}.

+

The following timeline is initialized with the 'de' locale and items are added with locally localized moment.js + objects. The timeline locale can be switched to another locale at runtime. If you choose the 'en' locale, the week + numbers will be calculated according to the US week calendar numbering scheme.

+ +

+ + +

+ +
+ + + + \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index f83ba597c..ea0944233 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,6 +1,7 @@ var fs = require('fs'); var async = require('async'); var gulp = require('gulp'); +var eslint = require('gulp-eslint'); var gutil = require('gulp-util'); var concat = require('gulp-concat'); var cleanCSS = require('gulp-clean-css'); @@ -8,12 +9,11 @@ var rename = require("gulp-rename"); var webpack = require('webpack'); var uglify = require('uglify-js'); var rimraf = require('rimraf'); -var merge = require('merge-stream'); var argv = require('yargs').argv; var ENTRY = './index.js'; var HEADER = './lib/header.js'; -var DIST = './dist'; +var DIST = __dirname + '/dist'; var VIS_JS = 'vis.js'; var VIS_MAP = 'vis.map'; var VIS_MIN_JS = 'vis.min.js'; @@ -39,7 +39,8 @@ function createBanner() { .replace('@@version', version); } -var bannerPlugin = new webpack.BannerPlugin(createBanner(), { +var bannerPlugin = new webpack.BannerPlugin({ + banner: createBanner(), entryOnly: true, raw: true }); @@ -49,10 +50,10 @@ var webpackModule = { { test: /\.js$/, exclude: /node_modules/, - loader: 'babel', + loader: 'babel-loader', query: { - cacheDirectory: true, - presets: ['es2015'] + cacheDirectory: true, // use cache to improve speed + babelrc: true // use the .baberc file } } ], @@ -175,14 +176,14 @@ gulp.task('bundle-css-individual', function (cb) { }, cb); }); -gulp.task('copy', ['clean'], function () { +gulp.task('copy', gulp.series('clean', function () { var network = gulp.src('./lib/network/img/**/*') .pipe(gulp.dest(DIST + '/img/network')); return network; -}); +})); -gulp.task('minify', ['bundle-js'], function (cb) { +gulp.task('minify', gulp.series('bundle-js', function (cb) { var result = uglify.minify([DIST + '/' + VIS_JS], uglifyConfig); // note: we add a newline '\n' to the end of the minified file to prevent @@ -192,11 +193,11 @@ gulp.task('minify', ['bundle-js'], function (cb) { fs.writeFileSync(DIST + '/' + VIS_MAP, result.map.replace(/"\.\/dist\//g, '"')); cb(); -}); +})); -gulp.task('bundle', ['bundle-js', 'bundle-js-individual', 'bundle-css', 'bundle-css-individual', 'copy']); +gulp.task('bundle', gulp.series('bundle-js', 'bundle-js-individual', 'bundle-css', 'bundle-css-individual', 'copy')); -gulp.task('quick', ['bundle-js']); +gulp.task('quick', gulp.series('bundle-js')); // read command line arguments --bundle and --minify and --quick var bundle = 'bundle' in argv; @@ -216,9 +217,24 @@ else { } // The watch task (to automatically rebuild when the source code changes) -gulp.task('watch', watchTasks, function () { - gulp.watch(['index.js', 'lib/**/*'], watchTasks); +gulp.task('watch',gulp.series( watchTasks, function () { + gulp.watch(['index.js', 'lib/**/*'], gulp.series(watchTasks)); +})); + + +// +// Linting usage: +// +// > gulp lint +// or > npm run lint +// +gulp.task('lint', function () { + return gulp.src(['lib/**/*.js', '!node_modules/**']) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError()); }); + // The default task (called when you run `gulp`) -gulp.task('default', ['clean', 'bundle', 'minify']); +gulp.task('default', gulp.series('clean', 'bundle', 'minify')); diff --git a/lib/DOMutil.js b/lib/DOMutil.js index 1e778bc0f..52d507806 100644 --- a/lib/DOMutil.js +++ b/lib/DOMutil.js @@ -2,7 +2,7 @@ /** * this prepares the JSON container for allocating SVG elements - * @param JSONcontainer + * @param {Object} JSONcontainer * @private */ exports.prepareElements = function(JSONcontainer) { @@ -19,7 +19,7 @@ exports.prepareElements = function(JSONcontainer) { * this cleans up all the unused SVG elements. By asking for the parentNode, we only need to supply the JSON container from * which to remove the redundant elements. * - * @param JSONcontainer + * @param {Object} JSONcontainer * @private */ exports.cleanupElements = function(JSONcontainer) { @@ -38,22 +38,22 @@ exports.cleanupElements = function(JSONcontainer) { /** * Ensures that all elements are removed first up so they can be recreated cleanly - * @param JSONcontainer + * @param {Object} JSONcontainer */ exports.resetElements = function(JSONcontainer) { exports.prepareElements(JSONcontainer); exports.cleanupElements(JSONcontainer); exports.prepareElements(JSONcontainer); -} +}; /** * Allocate or generate an SVG element if needed. Store a reference to it in the JSON container and draw it in the svgContainer * the JSON container and the SVG container have to be supplied so other svg containers (like the legend) can use this. * - * @param elementType - * @param JSONcontainer - * @param svgContainer - * @returns {*} + * @param {string} elementType + * @param {Object} JSONcontainer + * @param {Object} svgContainer + * @returns {Element} * @private */ exports.getSVGElement = function (elementType, JSONcontainer, svgContainer) { @@ -86,11 +86,11 @@ exports.getSVGElement = function (elementType, JSONcontainer, svgContainer) { * Allocate or generate an SVG element if needed. Store a reference to it in the JSON container and draw it in the svgContainer * the JSON container and the SVG container have to be supplied so other svg containers (like the legend) can use this. * - * @param elementType - * @param JSONcontainer - * @param DOMContainer + * @param {string} elementType + * @param {Object} JSONcontainer + * @param {Element} DOMContainer + * @param {Element} insertBefore * @returns {*} - * @private */ exports.getDOMElement = function (elementType, JSONcontainer, DOMContainer, insertBefore) { var element; @@ -135,13 +135,13 @@ exports.getDOMElement = function (elementType, JSONcontainer, DOMContainer, inse * The reason the JSONcontainer and the target SVG svgContainer have to be supplied is so the legend can use these functions * as well. * - * @param x - * @param y - * @param groupTemplate: A template containing the necessary information to draw the datapoint e.g., {style: 'circle', size: 5, className: 'className' } - * @param JSONcontainer - * @param svgContainer - * @param labelObj - * @returns {*} + * @param {number} x + * @param {number} y + * @param {Object} groupTemplate: A template containing the necessary information to draw the datapoint e.g., {style: 'circle', size: 5, className: 'className' } + * @param {Object} JSONcontainer + * @param {Object} svgContainer + * @param {Object} labelObj + * @returns {vis.PointItem} */ exports.drawPoint = function(x, y, groupTemplate, JSONcontainer, svgContainer, labelObj) { var point; @@ -192,9 +192,14 @@ exports.drawPoint = function(x, y, groupTemplate, JSONcontainer, svgContainer, l /** * draw a bar SVG element centered on the X coordinate * - * @param x - * @param y - * @param className + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @param {string} className + * @param {Object} JSONcontainer + * @param {Object} svgContainer + * @param {string} style */ exports.drawBar = function (x, y, width, height, className, JSONcontainer, svgContainer, style) { if (height != 0) { diff --git a/lib/DataSet.js b/lib/DataSet.js index 8f3b910fb..19eaabe9d 100644 --- a/lib/DataSet.js +++ b/lib/DataSet.js @@ -3,6 +3,7 @@ var Queue = require('./Queue'); /** * DataSet + * // TODO: add a DataSet constructor DataSet(data, options) * * Usage: * var dataSet = new DataSet({ @@ -31,9 +32,9 @@ var Queue = require('./Queue'); * * @param {Array} [data] Optional array with initial data * @param {Object} [options] Available options: - * {String} fieldId Field name of the id in the + * {string} fieldId Field name of the id in the * items, 'id' by default. - * {Object.} addedIds Array with the ids of the added items */ DataSet.prototype.add = function (data, senderId) { var addedIds = [], @@ -211,7 +195,7 @@ DataSet.prototype.add = function (data, senderId) { addedIds.push(id); } } - else if (data instanceof Object) { + else if (data && typeof data === 'object') { // Single item id = me._addItem(data); addedIds.push(id); @@ -230,8 +214,9 @@ DataSet.prototype.add = function (data, senderId) { /** * Update existing items. When an item does not exist, it will be created * @param {Object | Array} data - * @param {String} [senderId] Optional sender id - * @return {Array} updatedIds The ids of the added or updated items + * @param {string} [senderId] Optional sender id + * @return {Array.} updatedIds The ids of the added or updated items + * @throws {Error} Unknown Datatype */ DataSet.prototype.update = function (data, senderId) { var addedIds = []; @@ -261,14 +246,14 @@ DataSet.prototype.update = function (data, senderId) { if (Array.isArray(data)) { // Array for (var i = 0, len = data.length; i < len; i++) { - if (data[i] instanceof Object){ + if (data[i] && typeof data[i] === 'object'){ addOrUpdate(data[i]); } else { console.warn('Ignoring input item, which is not an object at index ' + i); } } } - else if (data instanceof Object) { + else if (data && typeof data === 'object') { // Single item addOrUpdate(data); } @@ -302,26 +287,28 @@ DataSet.prototype.update = function (data, senderId) { * get() * get(options: Object) * - * get(id: Number | String) - * get(id: Number | String, options: Object) + * get(id: number | string) + * get(id: number | string, options: Object) * - * get(ids: Number[] | String[]) - * get(ids: Number[] | String[], options: Object) + * get(ids: number[] | string[]) + * get(ids: number[] | string[], options: Object) * * Where: * - * {Number | String} id The id of an item - * {Number[] | String{}} ids An array with ids of items + * {number | string} id The id of an item + * {number[] | string{}} ids An array with ids of items * {Object} options An Object with options. Available options: - * {String} [returnType] Type of data to be returned. + * {string} [returnType] Type of data to be returned. * Can be 'Array' (default) or 'Object'. - * {Object.} [type] - * {String[]} [fields] field names to be returned + * {Object.} [type] + * {string[]} [fields] field names to be returned * {function} [filter] filter items - * {String | function} [order] Order the items by a field name or custom sort function. + * {string | function} [order] Order the items by a field name or custom sort function. + * @param {Array} args + * @returns {DataSet} * @throws Error */ -DataSet.prototype.get = function (args) { +DataSet.prototype.get = function (args) { // eslint-disable-line no-unused-vars var me = this; // parse the arguments @@ -430,9 +417,9 @@ DataSet.prototype.get = function (args) { * Get ids of all items or from a filtered set of items. * @param {Object} [options] An Object with options. Available options: * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. - * @return {Array} ids + * @return {Array.} ids */ DataSet.prototype.getIds = function (options) { var data = this._data, @@ -509,6 +496,7 @@ DataSet.prototype.getIds = function (options) { /** * Returns the DataSet itself. Is overwritten for example by the DataView, * which returns the DataSet it is connected to instead. + * @returns {DataSet} */ DataSet.prototype.getDataSet = function () { return this; @@ -518,10 +506,10 @@ DataSet.prototype.getDataSet = function () { * Execute a callback function for every item in the dataset. * @param {function} callback * @param {Object} [options] Available options: - * {Object.} [type] - * {String[]} [fields] filter fields + * {Object.} [type] + * {string[]} [fields] filter fields * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. */ DataSet.prototype.forEach = function (callback, options) { @@ -560,10 +548,10 @@ DataSet.prototype.forEach = function (callback, options) { * Map every item in the dataset. * @param {function} callback * @param {Object} [options] Available options: - * {Object.} [type] - * {String[]} [fields] filter fields + * {Object.} [type] + * {string[]} [fields] filter fields * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. * @return {Object[]} mappedItems */ @@ -598,7 +586,7 @@ DataSet.prototype.map = function (callback, options) { /** * Filter the fields of an item * @param {Object | null} item - * @param {String[]} fields Field names + * @param {string[]} fields Field names * @return {Object | null} filteredItem or null if no item is provided * @private */ @@ -635,7 +623,7 @@ DataSet.prototype._filterFields = function (item, fields) { /** * Sort the provided array with items * @param {Object[]} items - * @param {String | function} order A field name or custom sort function. + * @param {string | function} order A field name or custom sort function. * @private */ DataSet.prototype._sort = function (items, order) { @@ -652,7 +640,7 @@ DataSet.prototype._sort = function (items, order) { // order by sort function items.sort(order); } - // TODO: extend order by an Object {field:String, direction:String} + // TODO: extend order by an Object {field:string, direction:string} // where direction can be 'asc' or 'desc' else { throw new TypeError('Order must be a function or a string'); @@ -661,10 +649,10 @@ DataSet.prototype._sort = function (items, order) { /** * Remove an object by pointer or by id - * @param {String | Number | Object | Array} id Object or id, or an array with + * @param {string | number | Object | Array.} id Object or id, or an array with * objects or ids to be removed - * @param {String} [senderId] Optional sender id - * @return {Array} removedIds + * @param {string} [senderId] Optional sender id + * @return {Array.} removedIds */ DataSet.prototype.remove = function (id, senderId) { var removedIds = [], @@ -679,7 +667,7 @@ DataSet.prototype.remove = function (id, senderId) { item = this._remove(ids[i]); if (item) { itemId = item[this._fieldId]; - if (itemId) { + if (itemId != undefined) { removedIds.push(itemId); removedItems.push(item); } @@ -695,8 +683,8 @@ DataSet.prototype.remove = function (id, senderId) { /** * Remove an item by its id - * @param {Number | String | Object} id id or item - * @returns {Number | String | null} id + * @param {number | string | Object} id id or item + * @returns {number | string | null} id * @private */ DataSet.prototype._remove = function (id) { @@ -707,7 +695,7 @@ DataSet.prototype._remove = function (id) { if (util.isNumber(id) || util.isString(id)) { ident = id; } - else if (id instanceof Object) { + else if (id && typeof id === 'object') { ident = id[this._fieldId]; // look for the identifier field using _fieldId } @@ -723,14 +711,14 @@ DataSet.prototype._remove = function (id) { /** * Clear the data - * @param {String} [senderId] Optional sender id - * @return {Array} removedIds The ids of all removed items + * @param {string} [senderId] Optional sender id + * @return {Array.} removedIds The ids of all removed items */ DataSet.prototype.clear = function (senderId) { var i, len; var ids = Object.keys(this._data); var items = []; - + for (i = 0, len = ids.length; i < len; i++) { items.push(this._data[ids[i]]); } @@ -745,7 +733,7 @@ DataSet.prototype.clear = function (senderId) { /** * Find the item with maximum value of a specified field - * @param {String} field + * @param {string} field * @return {Object | null} item Item containing max value, or null if no items */ DataSet.prototype.max = function (field) { @@ -771,7 +759,7 @@ DataSet.prototype.max = function (field) { /** * Find the item with minimum value of a specified field - * @param {String} field + * @param {string} field * @return {Object | null} item Item containing max value, or null if no items */ DataSet.prototype.min = function (field) { @@ -797,7 +785,7 @@ DataSet.prototype.min = function (field) { /** * Find all distinct values of a specified field - * @param {String} field + * @param {string} field * @return {Array} values Array containing all distinct values. If data items * do not contain the specified field are ignored. * The returned array is unordered. @@ -841,7 +829,7 @@ DataSet.prototype.distinct = function (field) { /** * Add a single item. Will fail when an item with the same id already exists. * @param {Object} item - * @return {String} id + * @return {string} id * @private */ DataSet.prototype._addItem = function (item) { @@ -877,8 +865,8 @@ DataSet.prototype._addItem = function (item) { /** * Get an item. Fields can be converted to a specific type - * @param {String} id - * @param {Object.} [types] field types to convert + * @param {string} id + * @param {Object.} [types] field types to convert * @return {Object | null} item * @private */ @@ -910,6 +898,11 @@ DataSet.prototype._getItem = function (id, types) { converted[field] = value; } } + + if (!converted[this._fieldId]) { + converted[this._fieldId] = raw.id; + } + return converted; }; @@ -918,7 +911,7 @@ DataSet.prototype._getItem = function (id, types) { * Will fail when the item has no id, or when there does not exist an item * with the same id. * @param {Object} item - * @return {String} id + * @return {string} id * @private */ DataSet.prototype._updateItem = function (item) { diff --git a/lib/DataView.js b/lib/DataView.js index 59aa2ffc9..d80960b87 100644 --- a/lib/DataView.js +++ b/lib/DataView.js @@ -108,7 +108,7 @@ DataView.prototype.refresh = function () { id = oldIds[i]; if (!newIds[id]) { removedIds.push(id); - removedItems.push(this._data[id]); + removedItems.push(this._data._data[id]); delete this._ids[id]; } } @@ -143,22 +143,23 @@ DataView.prototype.refresh = function () { * * Where: * - * {Number | String} id The id of an item - * {Number[] | String{}} ids An array with ids of items + * {number | string} id The id of an item + * {number[] | string{}} ids An array with ids of items * {Object} options An Object with options. Available options: - * {String} [type] Type of data to be returned. Can + * {string} [type] Type of data to be returned. Can * be 'DataTable' or 'Array' (default) - * {Object.} [convert] - * {String[]} [fields] field names to be returned + * {Object.} [convert] + * {string[]} [fields] field names to be returned * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. * {Array | DataTable} [data] If provided, items will be appended to this * array or table. Required in case of Google * DataTable. - * @param args + * @param {Array} args + * @return {DataSet|DataView} */ -DataView.prototype.get = function (args) { +DataView.prototype.get = function (args) { // eslint-disable-line no-unused-vars var me = this; // parse the arguments @@ -201,9 +202,9 @@ DataView.prototype.get = function (args) { * Get ids of all items or from a filtered set of items. * @param {Object} [options] An Object with options. Available options: * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. - * @return {Array} ids + * @return {Array.} ids */ DataView.prototype.getIds = function (options) { var ids; @@ -242,10 +243,10 @@ DataView.prototype.getIds = function (options) { * Map every item in the dataset. * @param {function} callback * @param {Object} [options] Available options: - * {Object.} [type] - * {String[]} [fields] filter fields + * {Object.} [type] + * {string[]} [fields] filter fields * {function} [filter] filter items - * {String | function} [order] Order the items by + * {string | function} [order] Order the items by * a field name or custom sort function. * @return {Object[]} mappedItems */ @@ -298,9 +299,9 @@ DataView.prototype.getDataSet = function () { * Event listener. Will propagate all events from the connected data set to * the subscribers of the DataView, but will filter the items and only trigger * when there are changes in the filtered data set. - * @param {String} event + * @param {string} event * @param {Object | null} params - * @param {String} senderId + * @param {string} senderId * @private */ DataView.prototype._onEvent = function (event, params, senderId) { diff --git a/lib/Queue.js b/lib/Queue.js index 4e9f56a4e..9ad5eaf6d 100644 --- a/lib/Queue.js +++ b/lib/Queue.js @@ -9,7 +9,7 @@ * - max: number When the queue exceeds the given maximum number * of entries, the queue is flushed automatically. * Default value of max is Infinity. - * @constructor + * @constructor Queue */ function Queue(options) { // options @@ -35,7 +35,6 @@ function Queue(options) { * - max: number When the queue exceeds the given maximum number * of entries, the queue is flushed automatically. * Default value of max is Infinity. - * @param options */ Queue.prototype.setOptions = function (options) { if (options && typeof options.delay !== 'undefined') { diff --git a/lib/graph3d/Camera.js b/lib/graph3d/Camera.js index 1468370e7..c75502004 100644 --- a/lib/graph3d/Camera.js +++ b/lib/graph3d/Camera.js @@ -1,7 +1,6 @@ var Point3d = require('./Point3d'); /** - * @class Camera * The camera is mounted on a (virtual) camera arm. The camera arm can rotate * The camera is always looking in the direction of the origin of the arm. * This way, the camera always rotates around one fixed point, the location @@ -9,6 +8,7 @@ var Point3d = require('./Point3d'); * * Documentation: * http://en.wikipedia.org/wiki/3D_projection + * @class Camera */ function Camera() { this.armLocation = new Point3d(); @@ -16,6 +16,8 @@ function Camera() { this.armRotation.horizontal = 0; this.armRotation.vertical = 0; this.armLength = 1.7; + this.cameraOffset = new Point3d(); + this.offsetMultiplier = 0.6; this.cameraLocation = new Point3d(); this.cameraRotation = new Point3d(0.5*Math.PI, 0, 0); @@ -23,11 +25,42 @@ function Camera() { this.calculateCameraOrientation(); } +/** + * Set offset camera in camera coordinates + * @param {number} x offset by camera horisontal + * @param {number} y offset by camera vertical + */ +Camera.prototype.setOffset = function(x, y) { + var abs = Math.abs, + sign = Math.sign, + mul = this.offsetMultiplier, + border = this.armLength * mul; + + if (abs(x) > border) { + x = sign(x) * border; + } + if (abs(y) > border) { + y = sign(y) * border; + } + this.cameraOffset.x = x; + this.cameraOffset.y = y; + this.calculateCameraOrientation(); +}; + + +/** + * Get camera offset by horizontal and vertical + * @returns {number} + */ +Camera.prototype.getOffset = function() { + return this.cameraOffset; +}; + /** * Set the location (origin) of the arm - * @param {Number} x Normalized value of x - * @param {Number} y Normalized value of y - * @param {Number} z Normalized value of z + * @param {number} x Normalized value of x + * @param {number} y Normalized value of y + * @param {number} z Normalized value of z */ Camera.prototype.setArmLocation = function(x, y, z) { this.armLocation.x = x; @@ -39,9 +72,9 @@ Camera.prototype.setArmLocation = function(x, y, z) { /** * Set the rotation of the camera arm - * @param {Number} horizontal The horizontal rotation, between 0 and 2*PI. + * @param {number} horizontal The horizontal rotation, between 0 and 2*PI. * Optional, can be left undefined. - * @param {Number} vertical The vertical rotation, between 0 and 0.5*PI + * @param {number} vertical The vertical rotation, between 0 and 0.5*PI * if vertical=0.5*PI, the graph is shown from the * top. Optional, can be left undefined. */ @@ -75,7 +108,7 @@ Camera.prototype.getArmRotation = function() { /** * Set the (normalized) length of the camera arm. - * @param {Number} length A length between 0.71 and 5.0 + * @param {number} length A length between 0.71 and 5.0 */ Camera.prototype.setArmLength = function(length) { if (length === undefined) @@ -89,12 +122,13 @@ Camera.prototype.setArmLength = function(length) { if (this.armLength < 0.71) this.armLength = 0.71; if (this.armLength > 5.0) this.armLength = 5.0; + this.setOffset(this.cameraOffset.x, this.cameraOffset.y); this.calculateCameraOrientation(); }; /** * Retrieve the arm length - * @return {Number} length + * @return {number} length */ Camera.prototype.getArmLength = function() { return this.armLength; @@ -130,6 +164,16 @@ Camera.prototype.calculateCameraOrientation = function() { this.cameraRotation.x = Math.PI/2 - this.armRotation.vertical; this.cameraRotation.y = 0; this.cameraRotation.z = -this.armRotation.horizontal; + + var xa = this.cameraRotation.x; + var za = this.cameraRotation.z; + var dx = this.cameraOffset.x; + var dy = this.cameraOffset.y; + var sin = Math.sin, cos = Math.cos; + + this.cameraLocation.x = this.cameraLocation.x + dx * cos(za) + dy * - sin(za) * cos(xa); + this.cameraLocation.y = this.cameraLocation.y + dx * sin(za) + dy * cos(za) * cos(xa); + this.cameraLocation.z = this.cameraLocation.z + dy * sin(xa); }; -module.exports = Camera; \ No newline at end of file +module.exports = Camera; diff --git a/lib/graph3d/DataGroup.js b/lib/graph3d/DataGroup.js new file mode 100644 index 000000000..f7a12094c --- /dev/null +++ b/lib/graph3d/DataGroup.js @@ -0,0 +1,504 @@ +var DataSet = require('../DataSet'); +var DataView = require('../DataView'); +var Range = require('./Range'); +var Filter = require('./Filter'); +var Settings = require('./Settings'); +var Point3d = require('./Point3d'); + + +/** + * Creates a container for all data of one specific 3D-graph. + * + * On construction, the container is totally empty; the data + * needs to be initialized with method initializeData(). + * Failure to do so will result in the following exception begin thrown + * on instantiation of Graph3D: + * + * Error: Array, DataSet, or DataView expected + * + * @constructor DataGroup + */ +function DataGroup() { + this.dataTable = null; // The original data table +} + + +/** + * Initializes the instance from the passed data. + * + * Calculates minimum and maximum values and column index values. + * + * The graph3d instance is used internally to access the settings for + * the given instance. + * TODO: Pass settings only instead. + * + * @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance. + * @param {Array | DataSet | DataView} rawData The data containing the items for + * the Graph. + * @param {number} style Style Number + * @returns {Array.} + */ +DataGroup.prototype.initializeData = function(graph3d, rawData, style) { + if (rawData === undefined) return; + + if (Array.isArray(rawData)) { + rawData = new DataSet(rawData); + } + + var data; + if (rawData instanceof DataSet || rawData instanceof DataView) { + data = rawData.get(); + } + else { + throw new Error('Array, DataSet, or DataView expected'); + } + + if (data.length == 0) return; + + this.style = style; + + // unsubscribe from the dataTable + if (this.dataSet) { + this.dataSet.off('*', this._onChange); + } + + this.dataSet = rawData; + this.dataTable = data; + + // subscribe to changes in the dataset + var me = this; + this._onChange = function () { + graph3d.setData(me.dataSet); + }; + this.dataSet.on('*', this._onChange); + + // determine the location of x,y,z,value,filter columns + this.colX = 'x'; + this.colY = 'y'; + this.colZ = 'z'; + + + var withBars = graph3d.hasBars(style); + + // determine barWidth from data + if (withBars) { + if (graph3d.defaultXBarWidth !== undefined) { + this.xBarWidth = graph3d.defaultXBarWidth; + } + else { + this.xBarWidth = this.getSmallestDifference(data, this.colX) || 1; + } + + if (graph3d.defaultYBarWidth !== undefined) { + this.yBarWidth = graph3d.defaultYBarWidth; + } + else { + this.yBarWidth = this.getSmallestDifference(data, this.colY) || 1; + } + } + + // calculate minima and maxima + this._initializeRange(data, this.colX, graph3d, withBars); + this._initializeRange(data, this.colY, graph3d, withBars); + this._initializeRange(data, this.colZ, graph3d, false); + + if (data[0].hasOwnProperty('style')) { + this.colValue = 'style'; + var valueRange = this.getColumnRange(data, this.colValue); + this._setRangeDefaults(valueRange, graph3d.defaultValueMin, graph3d.defaultValueMax); + this.valueRange = valueRange; + } + + // Initialize data filter if a filter column is provided + var table = this.getDataTable(); + if (table[0].hasOwnProperty('filter')) { + if (this.dataFilter === undefined) { + this.dataFilter = new Filter(this, 'filter', graph3d); + this.dataFilter.setOnLoadCallback(function() { graph3d.redraw(); }); + } + } + + + var dataPoints; + if (this.dataFilter) { + // apply filtering + dataPoints = this.dataFilter._getDataPoints(); + } + else { + // no filtering. load all data + dataPoints = this._getDataPoints(this.getDataTable()); + } + return dataPoints; +}; + + +/** + * Collect the range settings for the given data column. + * + * This internal method is intended to make the range + * initalization more generic. + * + * TODO: if/when combined settings per axis defined, get rid of this. + * + * @private + * + * @param {'x'|'y'|'z'} column The data column to process + * @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance; + * required for access to settings + * @returns {Object} + */ +DataGroup.prototype._collectRangeSettings = function(column, graph3d) { + var index = ['x', 'y', 'z'].indexOf(column); + + if (index == -1) { + throw new Error('Column \'' + column + '\' invalid'); + } + + var upper = column.toUpperCase(); + + return { + barWidth : this[column + 'BarWidth'], + min : graph3d['default' + upper + 'Min'], + max : graph3d['default' + upper + 'Max'], + step : graph3d['default' + upper + 'Step'], + range_label: column + 'Range', // Name of instance field to write to + step_label : column + 'Step' // Name of instance field to write to + }; +}; + + +/** + * Initializes the settings per given column. + * + * TODO: if/when combined settings per axis defined, rewrite this. + * + * @private + * + * @param {DataSet | DataView} data The data containing the items for the Graph + * @param {'x'|'y'|'z'} column The data column to process + * @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance; + * required for access to settings + * @param {boolean} withBars True if initializing for bar graph + */ +DataGroup.prototype._initializeRange = function(data, column, graph3d, withBars) { + var NUMSTEPS = 5; + var settings = this._collectRangeSettings(column, graph3d); + + var range = this.getColumnRange(data, column); + if (withBars && column != 'z') { // Safeguard for 'z'; it doesn't have a bar width + range.expand(settings.barWidth / 2); + } + + this._setRangeDefaults(range, settings.min, settings.max); + this[settings.range_label] = range; + this[settings.step_label ] = (settings.step !== undefined) ? settings.step : range.range()/NUMSTEPS; +} + + +/** + * Creates a list with all the different values in the data for the given column. + * + * If no data passed, use the internal data of this instance. + * + * @param {'x'|'y'|'z'} column The data column to process + * @param {DataSet|DataView|undefined} data The data containing the items for the Graph + * + * @returns {Array} All distinct values in the given column data, sorted ascending. + */ +DataGroup.prototype.getDistinctValues = function(column, data) { + if (data === undefined) { + data = this.dataTable; + } + + var values = []; + + for (var i = 0; i < data.length; i++) { + var value = data[i][column] || 0; + if (values.indexOf(value) === -1) { + values.push(value); + } + } + + return values.sort(function(a,b) { return a - b; }); +}; + + +/** + * Determine the smallest difference between the values for given + * column in the passed data set. + * + * @param {DataSet|DataView|undefined} data The data containing the items for the Graph + * @param {'x'|'y'|'z'} column The data column to process + * + * @returns {number|null} Smallest difference value or + * null, if it can't be determined. + */ +DataGroup.prototype.getSmallestDifference = function(data, column) { + var values = this.getDistinctValues(data, column); + + // Get all the distinct diffs + // Array values is assumed to be sorted here + var smallest_diff = null; + + for (var i = 1; i < values.length; i++) { + var diff = values[i] - values[i - 1]; + + if (smallest_diff == null || smallest_diff > diff ) { + smallest_diff = diff; + } + } + + return smallest_diff; +} + + +/** + * Get the absolute min/max values for the passed data column. + * + * @param {DataSet|DataView|undefined} data The data containing the items for the Graph + * @param {'x'|'y'|'z'} column The data column to process + * + * @returns {Range} A Range instance with min/max members properly set. + */ +DataGroup.prototype.getColumnRange = function(data, column) { + var range = new Range(); + + // Adjust the range so that it covers all values in the passed data elements. + for (var i = 0; i < data.length; i++) { + var item = data[i][column]; + range.adjust(item); + } + + return range; +}; + + +/** + * Determines the number of rows in the current data. + * + * @returns {number} + */ +DataGroup.prototype.getNumberOfRows = function() { + return this.dataTable.length; +}; + + +/** + * Set default values for range + * + * The default values override the range values, if defined. + * + * Because it's possible that only defaultMin or defaultMax is set, it's better + * to pass in a range already set with the min/max set from the data. Otherwise, + * it's quite hard to process the min/max properly. + * + * @param {vis.Range} range + * @param {number} [defaultMin=range.min] + * @param {number} [defaultMax=range.max] + * @private + */ +DataGroup.prototype._setRangeDefaults = function (range, defaultMin, defaultMax) { + if (defaultMin !== undefined) { + range.min = defaultMin; + } + + if (defaultMax !== undefined) { + range.max = defaultMax; + } + + // This is the original way that the default min/max values were adjusted. + // TODO: Perhaps it's better if an error is thrown if the values do not agree. + // But this will change the behaviour. + if (range.max <= range.min) range.max = range.min + 1; +}; + + +DataGroup.prototype.getDataTable = function() { + return this.dataTable; +}; + + +DataGroup.prototype.getDataSet = function() { + return this.dataSet; +}; + + +/** + * Return all data values as a list of Point3d objects + * @param {Array.} data + * @returns {Array.} + */ +DataGroup.prototype.getDataPoints = function(data) { + var dataPoints = []; + + for (var i = 0; i < data.length; i++) { + var point = new Point3d(); + point.x = data[i][this.colX] || 0; + point.y = data[i][this.colY] || 0; + point.z = data[i][this.colZ] || 0; + point.data = data[i]; + + if (this.colValue !== undefined) { + point.value = data[i][this.colValue] || 0; + } + + var obj = {}; + obj.point = point; + obj.bottom = new Point3d(point.x, point.y, this.zRange.min); + obj.trans = undefined; + obj.screen = undefined; + + dataPoints.push(obj); + } + + return dataPoints; +}; + + +/** + * Copy all values from the data table to a matrix. + * + * The provided values are supposed to form a grid of (x,y) positions. + * @param {Array.} data + * @returns {Array.} + * @private + */ +DataGroup.prototype.initDataAsMatrix = function(data) { + // TODO: store the created matrix dataPoints in the filters instead of + // reloading each time. + var x, y, i, obj; + + // create two lists with all present x and y values + var dataX = this.getDistinctValues(this.colX, data); + var dataY = this.getDistinctValues(this.colY, data); + + var dataPoints = this.getDataPoints(data); + + // create a grid, a 2d matrix, with all values. + var dataMatrix = []; // temporary data matrix + for (i = 0; i < dataPoints.length; i++) { + obj = dataPoints[i]; + + // TODO: implement Array().indexOf() for Internet Explorer + var xIndex = dataX.indexOf(obj.point.x); + var yIndex = dataY.indexOf(obj.point.y); + + if (dataMatrix[xIndex] === undefined) { + dataMatrix[xIndex] = []; + } + + dataMatrix[xIndex][yIndex] = obj; + } + + // fill in the pointers to the neighbors. + for (x = 0; x < dataMatrix.length; x++) { + for (y = 0; y < dataMatrix[x].length; y++) { + if (dataMatrix[x][y]) { + dataMatrix[x][y].pointRight = (x < dataMatrix.length-1) ? dataMatrix[x+1][y] : undefined; + dataMatrix[x][y].pointTop = (y < dataMatrix[x].length-1) ? dataMatrix[x][y+1] : undefined; + dataMatrix[x][y].pointCross = + (x < dataMatrix.length-1 && y < dataMatrix[x].length-1) ? + dataMatrix[x+1][y+1] : + undefined; + } + } + } + + return dataPoints; +}; + + +/** + * Return common information, if present + * + * @returns {string} + */ +DataGroup.prototype.getInfo = function() { + var dataFilter = this.dataFilter; + if (!dataFilter) return undefined; + + return dataFilter.getLabel() + ': ' + dataFilter.getSelectedValue(); +}; + + +/** + * Reload the data + */ +DataGroup.prototype.reload = function() { + if (this.dataTable) { + this.setData(this.dataTable); + } +}; + + +/** + * Filter the data based on the current filter + * + * @param {Array} data + * @returns {Array} dataPoints Array with point objects which can be drawn on + * screen + */ +DataGroup.prototype._getDataPoints = function (data) { + var dataPoints = []; + + if (this.style === Settings.STYLE.GRID || this.style === Settings.STYLE.SURFACE) { + dataPoints = this.initDataAsMatrix(data); + } + else { // 'dot', 'dot-line', etc. + this._checkValueField(data); + dataPoints = this.getDataPoints(data); + + if (this.style === Settings.STYLE.LINE) { + // Add next member points for line drawing + for (var i = 0; i < dataPoints.length; i++) { + if (i > 0) { + dataPoints[i - 1].pointNext = dataPoints[i]; + } + } + } + } + + return dataPoints; +}; + + +/** + * Check if the state is consistent for the use of the value field. + * + * Throws if a problem is detected. + * + * @param {Array.} data + * @private + */ +DataGroup.prototype._checkValueField = function (data) { + + var hasValueField = this.style === Settings.STYLE.BARCOLOR + || this.style === Settings.STYLE.BARSIZE + || this.style === Settings.STYLE.DOTCOLOR + || this.style === Settings.STYLE.DOTSIZE; + + if (!hasValueField) { + return; // No need to check further + } + + + // Following field must be present for the current graph style + if (this.colValue === undefined) { + throw new Error('Expected data to have ' + + ' field \'style\' ' + + ' for graph style \'' + this.style + '\'' + ); + } + + // The data must also contain this field. + // Note that only first data element is checked. + if (data[0][this.colValue] === undefined) { + throw new Error('Expected data to have ' + + ' field \'' + this.colValue + '\' ' + + ' for graph style \'' + this.style + '\'' + ); + } +}; + + +module.exports = DataGroup; diff --git a/lib/graph3d/Filter.js b/lib/graph3d/Filter.js index 41d734a33..a6250dd01 100644 --- a/lib/graph3d/Filter.js +++ b/lib/graph3d/Filter.js @@ -3,12 +3,12 @@ var DataView = require('../DataView'); /** * @class Filter * - * @param {DataSet} data The google data table - * @param {Number} column The index of the column to be filtered - * @param {Graph} graph The graph + * @param {DataGroup} dataGroup the data group + * @param {number} column The index of the column to be filtered + * @param {Graph3d} graph The graph */ -function Filter (data, column, graph) { - this.data = data; +function Filter (dataGroup, column, graph) { + this.dataGroup = dataGroup; this.column = column; this.graph = graph; // the parent graph @@ -16,12 +16,7 @@ function Filter (data, column, graph) { this.value = undefined; // read all distinct values and select the first one - this.values = graph.getDistinctValues(data.get(), this.column); - - // sort both numeric and string values correctly - this.values.sort(function (a, b) { - return a > b ? 1 : a < b ? -1 : 0; - }); + this.values = dataGroup.getDistinctValues(this.column); if (this.values.length > 0) { this.selectValue(0); @@ -40,7 +35,7 @@ function Filter (data, column, graph) { else { this.loaded = true; } -}; +} /** @@ -54,7 +49,7 @@ Filter.prototype.isLoaded = function() { /** * Return the loaded progress - * @return {Number} percentage between 0 and 100 + * @return {number} percentage between 0 and 100 */ Filter.prototype.getLoadedProgress = function() { var len = this.values.length; @@ -79,7 +74,7 @@ Filter.prototype.getLabel = function() { /** * Return the columnIndex of the filter - * @return {Number} columnIndex + * @return {number} columnIndex */ Filter.prototype.getColumn = function() { return this.column; @@ -106,7 +101,7 @@ Filter.prototype.getValues = function() { /** * Retrieve one value of the filter - * @param {Number} index + * @param {number} index * @return {*} value */ Filter.prototype.getValue = function(index) { @@ -119,7 +114,7 @@ Filter.prototype.getValue = function(index) { /** * Retrieve the (filtered) dataPoints for the currently selected filter index - * @param {Number} [index] (optional) + * @param {number} [index] (optional) * @return {Array} dataPoints */ Filter.prototype._getDataPoints = function(index) { @@ -138,8 +133,8 @@ Filter.prototype._getDataPoints = function(index) { f.column = this.column; f.value = this.values[index]; - var dataView = new DataView(this.data,{filter: function (item) {return (item[f.column] == f.value);}}).get(); - dataPoints = this.graph._getDataPoints(dataView); + var dataView = new DataView(this.dataGroup.getDataSet(), {filter: function (item) {return (item[f.column] == f.value);}}).get(); + dataPoints = this.dataGroup._getDataPoints(dataView); this.dataPoints[index] = dataPoints; } @@ -151,6 +146,8 @@ Filter.prototype._getDataPoints = function(index) { /** * Set a callback function when the filter is fully loaded. + * + * @param {function} callback */ Filter.prototype.setOnLoadCallback = function(callback) { this.onLoadCallback = callback; @@ -160,7 +157,7 @@ Filter.prototype.setOnLoadCallback = function(callback) { /** * Add a value to the list with available values for this filter * No double entries will be created. - * @param {Number} index + * @param {number} index */ Filter.prototype.selectValue = function(index) { if (index >= this.values.length) @@ -173,6 +170,8 @@ Filter.prototype.selectValue = function(index) { /** * Load all filtered rows in the background one by one * Start this method without providing an index! + * + * @param {number} [index=0] */ Filter.prototype.loadInBackground = function(index) { if (index === undefined) @@ -181,9 +180,6 @@ Filter.prototype.loadInBackground = function(index) { var frame = this.graph.frame; if (index < this.values.length) { - var dataPointsTemp = this._getDataPoints(index); - //this.graph.redrawInfo(); // TODO: not neat - // create a progress box if (frame.progress === undefined) { frame.progress = document.createElement('DIV'); diff --git a/lib/graph3d/Graph3d.js b/lib/graph3d/Graph3d.js old mode 100644 new mode 100755 index f66912e88..8f816e6d7 --- a/lib/graph3d/Graph3d.js +++ b/lib/graph3d/Graph3d.js @@ -1,30 +1,29 @@ -var Emitter = require('emitter-component'); var DataSet = require('../DataSet'); -var DataView = require('../DataView'); +var Emitter = require('emitter-component'); var util = require('../util'); var Point3d = require('./Point3d'); var Point2d = require('./Point2d'); -var Camera = require('./Camera'); -var Filter = require('./Filter'); var Slider = require('./Slider'); var StepNumber = require('./StepNumber'); -var Range = require('./Range'); var Settings = require('./Settings'); +var Validator = require("./../shared/Validator").default; +var {printStyle} = require('./../shared/Validator'); +var {allOptions} = require('./options.js'); +var DataGroup = require('./DataGroup'); /// enumerate the available styles -Graph3d.STYLE = Settings.STYLE; +Graph3d.STYLE = Settings.STYLE; /** * Following label is used in the settings to describe values which should be * determined by the code while running, from the current data and graph style. - * + * * Using 'undefined' directly achieves the same thing, but this is more * descriptive by describing the intent. */ var autoByDefault = undefined; - /** * Default values for option settings. * @@ -34,7 +33,7 @@ var autoByDefault = undefined; * If a field is not in this list, a default value of 'autoByDefault' is assumed, * which is just an alias for 'undefined'. */ -var DEFAULTS = { +Graph3d.DEFAULTS = { width : '400px', height : '400px', filterLabel : 'time', @@ -45,12 +44,18 @@ var DEFAULTS = { xValueLabel : function(v) { return v; }, yValueLabel : function(v) { return v; }, zValueLabel : function(v) { return v; }, + showXAxis : true, + showYAxis : true, + showZAxis : true, showGrid : true, showPerspective : true, showShadow : false, keepAspectRatio : true, verticalRatio : 0.5, // 0.1 to 1.0, where 1.0 results in a 'cube' - dotSizeRatio : 0.02, // size of the dots as a fraction of the graph width + + dotSizeRatio : 0.02, // size of the dots as a fraction of the graph width + dotSizeMinFraction: 0.5, // size of min-value dot as a fraction of dotSizeRatio + dotSizeMaxFraction: 2.5, // size of max-value dot as a fraction of dotSizeRatio showAnimationControls: autoByDefault, animationInterval : 1000, // milliseconds @@ -64,8 +69,28 @@ var DEFAULTS = { style : Graph3d.STYLE.DOT, tooltip : false, - showLegend : autoByDefault, // determined by graph style - backgroundColor : autoByDefault, + + tooltipStyle : { + content : { + padding : '10px', + border : '1px solid #4d4d4d', + color : '#1a1a1a', + background : 'rgba(255,255,255,0.7)', + borderRadius : '2px', + boxShadow : '5px 5px 10px rgba(128,128,128,0.5)' + }, + line : { + height : '40px', + width : '0', + borderLeft : '1px solid #4d4d4d' + }, + dot : { + height : '0', + width : '0', + border : '5px solid #4d4d4d', + borderRadius : '5px' + } + }, dataColor : { fill : '#7DC1FF', @@ -79,6 +104,12 @@ var DEFAULTS = { distance : 1.7 }, +/* + The following fields are 'auto by default', see above. + */ + showLegend : autoByDefault, // determined by graph style + backgroundColor : autoByDefault, + xBarWidth : autoByDefault, yBarWidth : autoByDefault, valueMin : autoByDefault, @@ -101,11 +132,11 @@ var DEFAULTS = { /** - * @constructor Graph3d * Graph3d displays data in 3d. * * Graph3d is developed in javascript as a Google Visualization Chart. * + * @constructor Graph3d * @param {Element} container The DOM element in which the Graph3d will * be created. Normally a div element. * @param {DataSet | DataView | Array} [data] @@ -119,20 +150,19 @@ function Graph3d(container, data, options) { // create variables and set default values this.containerElement = container; - this.dataTable = null; // The original data table + this.dataGroup = new DataGroup(); this.dataPoints = null; // The table with point objects // create a frame and canvas this.create(); - Settings.setDefaults(DEFAULTS, this); + Settings.setDefaults(Graph3d.DEFAULTS, this); // the column indexes this.colX = undefined; this.colY = undefined; this.colZ = undefined; this.colValue = undefined; - this.colFilter = undefined; // TODO: customize axis range @@ -140,9 +170,7 @@ function Graph3d(container, data, options) { this.setOptions(options); // apply data - if (data) { - this.setData(data); - } + this.setData(data); } // Extend Graph3d with an Emitter mixin @@ -273,12 +301,11 @@ Graph3d.prototype._convertTranslationToScreen = function(translation) { /** * Calculate the translations and screen positions of all points + * + * @param {Array.} points + * @private */ -Graph3d.prototype._calcTranslations = function(points, sort) { - if (sort === undefined) { - sort = true; - } - +Graph3d.prototype._calcTranslations = function(points) { for (var i = 0; i < points.length; i++) { var point = points[i]; point.trans = this._convertPointToTranslation(point.point); @@ -289,10 +316,6 @@ Graph3d.prototype._calcTranslations = function(points, sort) { point.dist = this.showPerspective ? transBottom.length() : -transBottom.z; } - if (!sort) { - return; - } - // sort the points on depth of their (x,y) position (not on z) var sortDepth = function (a, b) { return b.dist - a.dist; @@ -301,233 +324,68 @@ Graph3d.prototype._calcTranslations = function(points, sort) { }; -Graph3d.prototype.getNumberOfRows = function(data) { - return data.length; -} - - -Graph3d.prototype.getNumberOfColumns = function(data) { - var counter = 0; - for (var column in data[0]) { - if (data[0].hasOwnProperty(column)) { - counter++; - } - } - return counter; -} - - -Graph3d.prototype.getDistinctValues = function(data, column) { - var distinctValues = []; - for (var i = 0; i < data.length; i++) { - if (distinctValues.indexOf(data[i][column]) == -1) { - distinctValues.push(data[i][column]); - } - } - return distinctValues; -} - - -/** - * Get the absolute min/max values for the passed data column. - * - * @returns {Range} A Range instance with min/max members properly set. - */ -Graph3d.prototype.getColumnRange = function(data,column) { - var range = new Range(); - - // Adjust the range so that it covers all values in the passed data elements. - for (var i = 0; i < data.length; i++) { - var item = data[i][column]; - range.adjust(item); - } - - return range; -}; - - /** - * Check if the state is consistent for the use of the value field. - * - * Throws if a problem is detected. + * Transfer min/max values to the Graph3d instance. */ -Graph3d.prototype._checkValueField = function (data) { - - var hasValueField = this.style === Graph3d.STYLE.BARCOLOR - || this.style === Graph3d.STYLE.BARSIZE - || this.style === Graph3d.STYLE.DOTCOLOR - || this.style === Graph3d.STYLE.DOTSIZE; - - if (!hasValueField) { - return; // No need to check further - } +Graph3d.prototype._initializeRanges = function() { + // TODO: later on, all min/maxes of all datagroups will be combined here + var dg = this.dataGroup; + this.xRange = dg.xRange; + this.yRange = dg.yRange; + this.zRange = dg.zRange; + this.valueRange = dg.valueRange; - // Following field must be present for the current graph style - if (this.colValue === undefined) { - throw new Error('Expected data to have ' - + ' field \'style\' ' - + ' for graph style \'' + this.style + '\'' - ); - } + // Values currently needed but which need to be sorted out for + // the multiple graph case. + this.xStep = dg.xStep; + this.yStep = dg.yStep; + this.zStep = dg.zStep; + this.xBarWidth = dg.xBarWidth; + this.yBarWidth = dg.yBarWidth; + this.colX = dg.colX; + this.colY = dg.colY; + this.colZ = dg.colZ; + this.colValue = dg.colValue; - // The data must also contain this field. - // Note that only first data element is checked. - if (data[0][this.colValue] === undefined) { - throw new Error('Expected data to have ' - + ' field \'' + this.colValue + '\' ' - + ' for graph style \'' + this.style + '\'' - ); - } + + // set the scale dependent on the ranges. + this._setScale(); }; /** - * Set default values for range + * Return all data values as a list of Point3d objects * - * The default values override the range values, if defined. - * - * Because it's possible that only defaultMin or defaultMax is set, it's better - * to pass in a range already set with the min/max set from the data. Otherwise, - * it's quite hard to process the min/max properly. - */ -Graph3d.prototype._setRangeDefaults = function (range, defaultMin, defaultMax) { - if (defaultMin !== undefined) { - range.min = defaultMin; - } - - if (defaultMax !== undefined) { - range.max = defaultMax; - } - - // This is the original way that the default min/max values were adjusted. - // TODO: Perhaps it's better if an error is thrown if the values do not agree. - // But this will change the behaviour. - if (range.max <= range.min) range.max = range.min + 1; -}; - - -/** - * Initialize the data from the data table. Calculate minimum and maximum values - * and column index values - * @param {Array | DataSet | DataView} rawData The data containing the items for - * the Graph. - * @param {Number} style Style Number + * @param {vis.DataSet} data + * @returns {Array.} */ -Graph3d.prototype._dataInitialize = function (rawData, style) { - var me = this; - - // unsubscribe from the dataTable - if (this.dataSet) { - this.dataSet.off('*', this._onChange); - } - - if (rawData === undefined) - return; - - if (Array.isArray(rawData)) { - rawData = new DataSet(rawData); - } - - var data; - if (rawData instanceof DataSet || rawData instanceof DataView) { - data = rawData.get(); - } - else { - throw new Error('Array, DataSet, or DataView expected'); - } - - if (data.length == 0) - return; - - this.dataSet = rawData; - this.dataTable = data; - - // subscribe to changes in the dataset - this._onChange = function () { - me.setData(me.dataSet); - }; - this.dataSet.on('*', this._onChange); - - // determine the location of x,y,z,value,filter columns - this.colX = 'x'; - this.colY = 'y'; - this.colZ = 'z'; - - - var withBars = this.style == Graph3d.STYLE.BAR || - this.style == Graph3d.STYLE.BARCOLOR || - this.style == Graph3d.STYLE.BARSIZE; - - // determine barWidth from data - if (withBars) { - if (this.defaultXBarWidth !== undefined) { - this.xBarWidth = this.defaultXBarWidth; - } - else { - var dataX = this.getDistinctValues(data,this.colX); - this.xBarWidth = (dataX[1] - dataX[0]) || 1; - } +Graph3d.prototype.getDataPoints = function(data) { + var dataPoints = []; - if (this.defaultYBarWidth !== undefined) { - this.yBarWidth = this.defaultYBarWidth; - } - else { - var dataY = this.getDistinctValues(data,this.colY); - this.yBarWidth = (dataY[1] - dataY[0]) || 1; + for (var i = 0; i < data.length; i++) { + var point = new Point3d(); + point.x = data[i][this.colX] || 0; + point.y = data[i][this.colY] || 0; + point.z = data[i][this.colZ] || 0; + point.data = data[i]; + + if (this.colValue !== undefined) { + point.value = data[i][this.colValue] || 0; } - } - - // calculate minimums and maximums - var NUMSTEPS = 5; - - var xRange = this.getColumnRange(data, this.colX); - if (withBars) { - xRange.expand(this.xBarWidth / 2); - } - this._setRangeDefaults(xRange, this.defaultXMin, this.defaultXMax); - this.xRange = xRange; - this.xStep = (this.defaultXStep !== undefined) ? this.defaultXStep : xRange.range()/NUMSTEPS; - - var yRange = this.getColumnRange(data, this.colY); - if (withBars) { - yRange.expand(this.yBarWidth / 2); - } - this._setRangeDefaults(yRange, this.defaultYMin, this.defaultYMax); - this.yRange = yRange; - this.yStep = (this.defaultYStep !== undefined) ? this.defaultYStep : yRange.range()/NUMSTEPS; - - var zRange = this.getColumnRange(data, this.colZ); - this._setRangeDefaults(zRange, this.defaultZMin, this.defaultZMax); - this.zRange = zRange; - this.zStep = (this.defaultZStep !== undefined) ? this.defaultZStep : zRange.range()/NUMSTEPS; - - if (data[0].hasOwnProperty('style')) { - this.colValue = 'style'; - var valueRange = this.getColumnRange(data,this.colValue); - this._setRangeDefaults(valueRange, this.defaultValueMin, this.defaultValueMax); - this.valueRange = valueRange; - } - - // check if a filter column is provided - // Needs to be started after zRange is defined - if (data[0].hasOwnProperty('filter')) { - // Only set this field if it's actually present - this.colFilter = 'filter'; + var obj = {}; + obj.point = point; + obj.bottom = new Point3d(point.x, point.y, this.zRange.min); + obj.trans = undefined; + obj.screen = undefined; - if (this.dataFilter === undefined) { - this.dataFilter = new Filter(rawData, this.colFilter, this); - this.dataFilter.setOnLoadCallback(function() {me.redraw();}); - } + dataPoints.push(obj); } - - // set the scale dependent on the ranges. - this._setScale(); + return dataPoints; }; - /** * Filter the data based on the current filter * @@ -538,66 +396,35 @@ Graph3d.prototype._dataInitialize = function (rawData, style) { Graph3d.prototype._getDataPoints = function (data) { // TODO: store the created matrix dataPoints in the filters instead of // reloading each time. - var x, y, i, z, obj, point; + var x, y, i, obj; var dataPoints = []; if (this.style === Graph3d.STYLE.GRID || this.style === Graph3d.STYLE.SURFACE) { - // copy all values from the google data table to a matrix + // copy all values from the data table to a matrix // the provided values are supposed to form a grid of (x,y) positions // create two lists with all present x and y values - var dataX = []; - var dataY = []; - for (i = 0; i < this.getNumberOfRows(data); i++) { - x = data[i][this.colX] || 0; - y = data[i][this.colY] || 0; - - if (dataX.indexOf(x) === -1) { - dataX.push(x); - } - if (dataY.indexOf(y) === -1) { - dataY.push(y); - } - } + var dataX = this.dataGroup.getDistinctValues(this.colX, data); + var dataY = this.dataGroup.getDistinctValues(this.colY, data); - var sortNumber = function (a, b) { - return a - b; - }; - dataX.sort(sortNumber); - dataY.sort(sortNumber); + dataPoints = this.getDataPoints(data); // create a grid, a 2d matrix, with all values. var dataMatrix = []; // temporary data matrix - for (i = 0; i < data.length; i++) { - x = data[i][this.colX] || 0; - y = data[i][this.colY] || 0; - z = data[i][this.colZ] || 0; + for (i = 0; i < dataPoints.length; i++) { + obj = dataPoints[i]; // TODO: implement Array().indexOf() for Internet Explorer - var xIndex = dataX.indexOf(x); - var yIndex = dataY.indexOf(y); + var xIndex = dataX.indexOf(obj.point.x); + var yIndex = dataY.indexOf(obj.point.y); if (dataMatrix[xIndex] === undefined) { dataMatrix[xIndex] = []; } - var point3d = new Point3d(); - point3d.x = x; - point3d.y = y; - point3d.z = z; - point3d.data = data[i]; - - obj = {}; - obj.point = point3d; - obj.trans = undefined; - obj.screen = undefined; - obj.bottom = new Point3d(x, y, this.zRange.min); - dataMatrix[xIndex][yIndex] = obj; - - dataPoints.push(obj); } // fill in the pointers to the neighbors. @@ -616,39 +443,22 @@ Graph3d.prototype._getDataPoints = function (data) { } else { // 'dot', 'dot-line', etc. this._checkValueField(data); + dataPoints = this.getDataPoints(data); - // copy all values from the google data table to a list with Point3d objects - for (i = 0; i < data.length; i++) { - point = new Point3d(); - point.x = data[i][this.colX] || 0; - point.y = data[i][this.colY] || 0; - point.z = data[i][this.colZ] || 0; - point.data = data[i]; - - if (this.colValue !== undefined) { - point.value = data[i][this.colValue] || 0; - } - - obj = {}; - obj.point = point; - obj.bottom = new Point3d(point.x, point.y, this.zRange.min); - obj.trans = undefined; - obj.screen = undefined; - - if (this.style === Graph3d.STYLE.LINE) { + if (this.style === Graph3d.STYLE.LINE) { + // Add next member points for line drawing + for (i = 0; i < dataPoints.length; i++) { if (i > 0) { - // Add next point for line drawing - dataPoints[i - 1].pointNext = obj; + dataPoints[i - 1].pointNext = dataPoints[i]; } } - - dataPoints.push(obj); } } return dataPoints; }; + /** * Create the main frame for the Graph3d. * @@ -693,13 +503,14 @@ Graph3d.prototype.create = function () { var ontouchstart = function (event) {me._onTouchStart(event);}; var onmousewheel = function (event) {me._onWheel(event);}; var ontooltip = function (event) {me._onTooltip(event);}; + var onclick = function(event) {me._onClick(event);}; // TODO: these events are never cleaned up... can give a 'memory leakage' - util.addEventListener(this.frame.canvas, 'keydown', onkeydown); util.addEventListener(this.frame.canvas, 'mousedown', onmousedown); util.addEventListener(this.frame.canvas, 'touchstart', ontouchstart); util.addEventListener(this.frame.canvas, 'mousewheel', onmousewheel); util.addEventListener(this.frame.canvas, 'mousemove', ontooltip); + util.addEventListener(this.frame.canvas, 'click', onclick); // add the new graph to the container element this.containerElement.appendChild(this.frame); @@ -709,18 +520,18 @@ Graph3d.prototype.create = function () { /** * Set a new size for the graph * - * @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%') + * @param {number} width + * @param {number} height + * @private */ -Graph3d.prototype.setSize = function(width, height) { +Graph3d.prototype._setSize = function(width, height) { this.frame.style.width = width; this.frame.style.height = height; this._resizeCanvas(); }; + /** * Resize the canvas to the current size of the frame */ @@ -735,10 +546,15 @@ Graph3d.prototype._resizeCanvas = function() { this.frame.filter.style.width = (this.frame.canvas.clientWidth - 2 * 10) + 'px'; }; + /** - * Start animation + * Start playing the animation, if requested and filter present. Only applicable + * when animation data is available. */ Graph3d.prototype.animationStart = function() { + // start animation when option is true + if (!this.animationAutoStart || !this.dataGroup.dataFilter) return; + if (!this.frame.filter || !this.frame.filter.slider) throw new Error('No animation available'); @@ -800,22 +616,15 @@ Graph3d.prototype.getCameraPosition = function() { /** * Load data into the 3D Graph + * + * @param {vis.DataSet} data + * @private */ Graph3d.prototype._readData = function(data) { // read the data - this._dataInitialize(data, this.style); - - - if (this.dataFilter) { - // apply filtering - this.dataPoints = this.dataFilter._getDataPoints(); - } - else { - // no filtering. load all data - this.dataPoints = this._getDataPoints(this.dataTable); - } + this.dataPoints = this.dataGroup.initializeData(this, data, this.style); - // draw the filter + this._initializeRanges(); this._redrawFilter(); }; @@ -825,13 +634,11 @@ Graph3d.prototype._readData = function(data) { * @param {Array | DataSet | DataView} data */ Graph3d.prototype.setData = function (data) { + if (data === undefined || data === null) return; + this._readData(data); this.redraw(); - - // start animation when option is true - if (this.animationAutoStart && this.dataFilter) { - this.animationStart(); - } + this.animationStart(); }; /** @@ -840,24 +647,21 @@ Graph3d.prototype.setData = function (data) { * @param {Object} options */ Graph3d.prototype.setOptions = function (options) { - var cameraPosition = undefined; + if (options === undefined) return; + + let errorFound = Validator.validate(options, allOptions); + if (errorFound === true) { + console.log('%cErrors have been found in the supplied options object.', printStyle); + } this.animationStop(); Settings.setOptions(options, this); - this.setPointDrawingMethod(); - this.setSize(this.width, this.height); + this._setSize(this.width, this.height); - // re-load the data - if (this.dataTable) { - this.setData(this.dataTable); - } - - // start animation when option is true - if (this.animationAutoStart && this.dataFilter) { - this.animationStart(); - } + this.setData(this.dataGroup.getDataTable()); + this.animationStart(); }; @@ -901,7 +705,6 @@ Graph3d.prototype.setPointDrawingMethod = function() { default: throw new Error('Can not determine point drawing method ' + 'for graph style \'' + this.style + '\''); - break; } this._pointDrawingMethod = method; @@ -931,6 +734,9 @@ Graph3d.prototype.redraw = function() { /** * Get drawing context without exposing canvas + * + * @returns {CanvasRenderingContext2D} + * @private */ Graph3d.prototype._getContext = function() { var canvas = this.frame.canvas; @@ -960,52 +766,56 @@ Graph3d.prototype._dotSize = function() { /** - * Get legend width + * Get legend width + * + * @returns {*} + * @private */ Graph3d.prototype._getLegendWidth = function() { - var width; + var width; if (this.style === Graph3d.STYLE.DOTSIZE) { var dotSize = this._dotSize(); - width = dotSize / 2 + dotSize * 2; + //width = dotSize / 2 + dotSize * 2; + width = dotSize * this.dotSizeMaxFraction; } else if (this.style === Graph3d.STYLE.BARSIZE) { width = this.xBarWidth ; } else { - width = 20; + width = 20; } return width; -} +}; /** - * Redraw the legend based on size, dot color, or surface height + * Redraw the legend based on size, dot color, or surface height */ Graph3d.prototype._redrawLegend = function() { - - //Return without drawing anything, if no legend is specified + + //Return without drawing anything, if no legend is specified if (this.showLegend !== true) { return; - } + } // Do not draw legend when graph style does not support if (this.style === Graph3d.STYLE.LINE - || this.style === Graph3d.STYLE.BARSIZE //TODO add legend support for BARSIZE + || this.style === Graph3d.STYLE.BARSIZE //TODO add legend support for BARSIZE ){ return; - } + } - // Legend types - size and color. Determine if size legend. - var isSizeLegend = (this.style === Graph3d.STYLE.BARSIZE + // Legend types - size and color. Determine if size legend. + var isSizeLegend = (this.style === Graph3d.STYLE.BARSIZE || this.style === Graph3d.STYLE.DOTSIZE) ; - // Legend is either tracking z values or style values. This flag if false means use z values. - var isValueLegend = (this.style === Graph3d.STYLE.DOTSIZE - || this.style === Graph3d.STYLE.DOTCOLOR + // Legend is either tracking z values or style values. This flag if false means use z values. + var isValueLegend = (this.style === Graph3d.STYLE.DOTSIZE + || this.style === Graph3d.STYLE.DOTCOLOR || this.style === Graph3d.STYLE.BARCOLOR); var height = Math.max(this.frame.clientHeight * 0.25, 100); var top = this.margin; - var width = this._getLegendWidth() ; // px - overwritten by size legend + var width = this._getLegendWidth() ; // px - overwritten by size legend var right = this.frame.clientWidth - this.margin; var left = right - width; var bottom = top + height; @@ -1035,36 +845,35 @@ Graph3d.prototype._redrawLegend = function() { ctx.strokeRect(left, top, width, height); } else { - - // draw the size legend box + + // draw the size legend box var widthMin; - if (this.style === Graph3d.STYLE.DOTSIZE) { - var dotSize = this._dotSize(); - widthMin = dotSize / 2; // px - } else if (this.style === Graph3d.STYLE.BARSIZE) { - //widthMin = this.xBarWidth * 0.2 this is wrong - barwidth measures in terms of xvalues + if (this.style === Graph3d.STYLE.DOTSIZE) { + // Get the proportion to max and min right + widthMin = width * (this.dotSizeMinFraction / this.dotSizeMaxFraction); + } else if (this.style === Graph3d.STYLE.BARSIZE) { + //widthMin = this.xBarWidth * 0.2 this is wrong - barwidth measures in terms of xvalues } ctx.strokeStyle = this.axisColor; ctx.fillStyle = this.dataColor.fill; ctx.beginPath(); ctx.moveTo(left, top); ctx.lineTo(right, top); - ctx.lineTo(right - width + widthMin, bottom); + ctx.lineTo(left + widthMin, bottom); ctx.lineTo(left, bottom); ctx.closePath(); ctx.fill(); ctx.stroke(); } - // print value text along the legend edge + // print value text along the legend edge var gridLineLen = 5; // px - - var legendMin = isValueLegend ? this.valueRange.min : this.zRange.min; + + var legendMin = isValueLegend ? this.valueRange.min : this.zRange.min; var legendMax = isValueLegend ? this.valueRange.max : this.zRange.max; var step = new StepNumber(legendMin, legendMax, (legendMax-legendMin)/5, true); step.start(true); - var y; var from; var to; while (!step.end()) { @@ -1087,43 +896,49 @@ Graph3d.prototype._redrawLegend = function() { ctx.fillText(label, right, bottom + this.margin); }; + /** * Redraw the filter */ Graph3d.prototype._redrawFilter = function() { - this.frame.filter.innerHTML = ''; + var dataFilter = this.dataGroup.dataFilter; + var filter = this.frame.filter; + filter.innerHTML = ''; - if (this.dataFilter) { - var options = { - 'visible': this.showAnimationControls - }; - var slider = new Slider(this.frame.filter, options); - this.frame.filter.slider = slider; + if (!dataFilter) { + filter.slider = undefined; + return; + } - // TODO: css here is not nice here... - this.frame.filter.style.padding = '10px'; - //this.frame.filter.style.backgroundColor = '#EFEFEF'; + var options = { + 'visible': this.showAnimationControls + }; + var slider = new Slider(filter, options); + filter.slider = slider; - slider.setValues(this.dataFilter.values); - slider.setPlayInterval(this.animationInterval); + // TODO: css here is not nice here... + filter.style.padding = '10px'; + //this.frame.filter.style.backgroundColor = '#EFEFEF'; - // create an event handler - var me = this; - var onchange = function () { - var index = slider.getIndex(); + slider.setValues(dataFilter.values); + slider.setPlayInterval(this.animationInterval); + + // create an event handler + var me = this; + var onchange = function () { + var dataFilter = me.dataGroup.dataFilter; + var index = slider.getIndex(); - me.dataFilter.selectValue(index); - me.dataPoints = me.dataFilter._getDataPoints(); + dataFilter.selectValue(index); + me.dataPoints = dataFilter._getDataPoints(); - me.redraw(); - }; - slider.setOnChangeCallback(onchange); - } - else { - this.frame.filter.slider = undefined; - } + me.redraw(); + }; + + slider.setOnChangeCallback(onchange); }; + /** * Redraw the slider */ @@ -1138,19 +953,20 @@ Graph3d.prototype._redrawSlider = function() { * Redraw common information */ Graph3d.prototype._redrawInfo = function() { - if (this.dataFilter) { - var ctx = this._getContext(); + var info = this.dataGroup.getInfo(); + if (info === undefined) return; - ctx.font = '14px arial'; // TODO: put in options - ctx.lineStyle = 'gray'; - ctx.fillStyle = 'gray'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; + var ctx = this._getContext(); - var x = this.margin; - var y = this.margin; - ctx.fillText(this.dataFilter.getLabel() + ': ' + this.dataFilter.getSelectedValue(), x, y); - } + ctx.font = '14px arial'; // TODO: put in options + ctx.lineStyle = 'gray'; + ctx.fillStyle = 'gray'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + var x = this.margin; + var y = this.margin; + ctx.fillText(info, x, y); }; @@ -1158,6 +974,12 @@ Graph3d.prototype._redrawInfo = function() { * Draw a line between 2d points 'from' and 'to'. * * If stroke style specified, set that as well. + * + * @param {CanvasRenderingContext2D} ctx + * @param {vis.Point2d} from + * @param {vis.Point2d} to + * @param {string} [strokeStyle] + * @private */ Graph3d.prototype._line = function(ctx, from, to, strokeStyle) { if (strokeStyle !== undefined) { @@ -1168,9 +990,16 @@ Graph3d.prototype._line = function(ctx, from, to, strokeStyle) { ctx.moveTo(from.x, from.y); ctx.lineTo(to.x , to.y ); ctx.stroke(); -} - +}; +/** + * + * @param {CanvasRenderingContext2D} ctx + * @param {vis.Point3d} point3d + * @param {string} text + * @param {number} armAngle + * @param {number} [yMargin=0] + */ Graph3d.prototype.drawAxisLabelX = function(ctx, point3d, text, armAngle, yMargin) { if (yMargin === undefined) { yMargin = 0; @@ -1194,9 +1023,17 @@ Graph3d.prototype.drawAxisLabelX = function(ctx, point3d, text, armAngle, yMargi ctx.fillStyle = this.axisColor; ctx.fillText(text, point2d.x, point2d.y); -} +}; +/** + * + * @param {CanvasRenderingContext2D} ctx + * @param {vis.Point3d} point3d + * @param {string} text + * @param {number} armAngle + * @param {number} [yMargin=0] + */ Graph3d.prototype.drawAxisLabelY = function(ctx, point3d, text, armAngle, yMargin) { if (yMargin === undefined) { yMargin = 0; @@ -1220,9 +1057,16 @@ Graph3d.prototype.drawAxisLabelY = function(ctx, point3d, text, armAngle, yMargi ctx.fillStyle = this.axisColor; ctx.fillText(text, point2d.x, point2d.y); -} +}; +/** + * + * @param {CanvasRenderingContext2D} ctx + * @param {vis.Point3d} point3d + * @param {string} text + * @param {number} [offset=0] + */ Graph3d.prototype.drawAxisLabelZ = function(ctx, point3d, text, offset) { if (offset === undefined) { offset = 0; @@ -1243,13 +1087,19 @@ Graph3d.prototype.drawAxisLabelZ = function(ctx, point3d, text, offset) { * Draw a line between 2d points 'from' and 'to'. * * If stroke style specified, set that as well. + * + * @param {CanvasRenderingContext2D} ctx + * @param {vis.Point2d} from + * @param {vis.Point2d} to + * @param {string} [strokeStyle] + * @private */ Graph3d.prototype._line3d = function(ctx, from, to, strokeStyle) { var from2d = this._convert3Dto2D(from); var to2d = this._convert3Dto2D(to); this._line(ctx, from2d, to2d, strokeStyle); -} +}; /** @@ -1275,6 +1125,7 @@ Graph3d.prototype._redrawAxis = function() { var xRange = this.xRange; var yRange = this.yRange; var zRange = this.zRange; + var point3d; // draw x-grid lines ctx.lineWidth = 1; @@ -1290,7 +1141,7 @@ Graph3d.prototype._redrawAxis = function() { to = new Point3d(x, yRange.max, zRange.min); this._line3d(ctx, from, to, this.gridColor); } - else { + else if (this.showXAxis) { from = new Point3d(x, yRange.min, zRange.min); to = new Point3d(x, yRange.min+gridLenX, zRange.min); this._line3d(ctx, from, to, this.axisColor); @@ -1300,10 +1151,12 @@ Graph3d.prototype._redrawAxis = function() { this._line3d(ctx, from, to, this.axisColor); } - yText = (armVector.x > 0) ? yRange.min : yRange.max; - var point3d = new Point3d(x, yText, zRange.min); - var msg = ' ' + this.xValueLabel(x) + ' '; - this.drawAxisLabelX(ctx, point3d, msg, armAngle, textMargin); + if (this.showXAxis) { + yText = (armVector.x > 0) ? yRange.min : yRange.max; + point3d = new Point3d(x, yText, zRange.min); + let msg = ' ' + this.xValueLabel(x) + ' '; + this.drawAxisLabelX(ctx, point3d, msg, armAngle, textMargin); + } step.next(); } @@ -1322,7 +1175,7 @@ Graph3d.prototype._redrawAxis = function() { to = new Point3d(xRange.max, y, zRange.min); this._line3d(ctx, from, to, this.gridColor); } - else { + else if (this.showYAxis){ from = new Point3d(xRange.min, y, zRange.min); to = new Point3d(xRange.min+gridLenY, y, zRange.min); this._line3d(ctx, from, to, this.axisColor); @@ -1332,73 +1185,81 @@ Graph3d.prototype._redrawAxis = function() { this._line3d(ctx, from, to, this.axisColor); } - xText = (armVector.y > 0) ? xRange.min : xRange.max; - point3d = new Point3d(xText, y, zRange.min); - var msg = ' ' + this.yValueLabel(y) + ' '; - this.drawAxisLabelY(ctx, point3d, msg, armAngle, textMargin); + if (this.showYAxis) { + xText = (armVector.y > 0) ? xRange.min : xRange.max; + point3d = new Point3d(xText, y, zRange.min); + let msg = ' ' + this.yValueLabel(y) + ' '; + this.drawAxisLabelY(ctx, point3d, msg, armAngle, textMargin); + } step.next(); } // draw z-grid lines and axis - ctx.lineWidth = 1; - prettyStep = (this.defaultZStep === undefined); - step = new StepNumber(zRange.min, zRange.max, this.zStep, prettyStep); - step.start(true); + if (this.showZAxis) { + ctx.lineWidth = 1; + prettyStep = (this.defaultZStep === undefined); + step = new StepNumber(zRange.min, zRange.max, this.zStep, prettyStep); + step.start(true); - xText = (armVector.x > 0) ? xRange.min : xRange.max; - yText = (armVector.y < 0) ? yRange.min : yRange.max; + xText = (armVector.x > 0) ? xRange.min : xRange.max; + yText = (armVector.y < 0) ? yRange.min : yRange.max; - while (!step.end()) { - var z = step.getCurrent(); + while (!step.end()) { + var z = step.getCurrent(); - // TODO: make z-grid lines really 3d? - var from3d = new Point3d(xText, yText, z); - var from2d = this._convert3Dto2D(from3d); - to = new Point2d(from2d.x - textMargin, from2d.y); - this._line(ctx, from2d, to, this.axisColor); + // TODO: make z-grid lines really 3d? + var from3d = new Point3d(xText, yText, z); + var from2d = this._convert3Dto2D(from3d); + to = new Point2d(from2d.x - textMargin, from2d.y); + this._line(ctx, from2d, to, this.axisColor); - var msg = this.zValueLabel(z) + ' '; - this.drawAxisLabelZ(ctx, from3d, msg, 5); + let msg = this.zValueLabel(z) + ' '; + this.drawAxisLabelZ(ctx, from3d, msg, 5); - step.next(); - } + step.next(); + } - ctx.lineWidth = 1; - from = new Point3d(xText, yText, zRange.min); - to = new Point3d(xText, yText, zRange.max); - this._line3d(ctx, from, to, this.axisColor); + ctx.lineWidth = 1; + from = new Point3d(xText, yText, zRange.min); + to = new Point3d(xText, yText, zRange.max); + this._line3d(ctx, from, to, this.axisColor); + } // draw x-axis - var xMin2d; - var xMax2d; - ctx.lineWidth = 1; + if (this.showXAxis) { + var xMin2d; + var xMax2d; + ctx.lineWidth = 1; - // line at yMin - xMin2d = new Point3d(xRange.min, yRange.min, zRange.min); - xMax2d = new Point3d(xRange.max, yRange.min, zRange.min); - this._line3d(ctx, xMin2d, xMax2d, this.axisColor); - // line at ymax - xMin2d = new Point3d(xRange.min, yRange.max, zRange.min); - xMax2d = new Point3d(xRange.max, yRange.max, zRange.min); - this._line3d(ctx, xMin2d, xMax2d, this.axisColor); + // line at yMin + xMin2d = new Point3d(xRange.min, yRange.min, zRange.min); + xMax2d = new Point3d(xRange.max, yRange.min, zRange.min); + this._line3d(ctx, xMin2d, xMax2d, this.axisColor); + // line at ymax + xMin2d = new Point3d(xRange.min, yRange.max, zRange.min); + xMax2d = new Point3d(xRange.max, yRange.max, zRange.min); + this._line3d(ctx, xMin2d, xMax2d, this.axisColor); + } // draw y-axis - ctx.lineWidth = 1; - // line at xMin - from = new Point3d(xRange.min, yRange.min, zRange.min); - to = new Point3d(xRange.min, yRange.max, zRange.min); - this._line3d(ctx, from, to, this.axisColor); - // line at xMax - from = new Point3d(xRange.max, yRange.min, zRange.min); - to = new Point3d(xRange.max, yRange.max, zRange.min); - this._line3d(ctx, from, to, this.axisColor); + if (this.showYAxis) { + ctx.lineWidth = 1; + // line at xMin + from = new Point3d(xRange.min, yRange.min, zRange.min); + to = new Point3d(xRange.min, yRange.max, zRange.min); + this._line3d(ctx, from, to, this.axisColor); + // line at xMax + from = new Point3d(xRange.max, yRange.min, zRange.min); + to = new Point3d(xRange.max, yRange.max, zRange.min); + this._line3d(ctx, from, to, this.axisColor); + } // draw x-label var xLabel = this.xLabel; - if (xLabel.length > 0) { + if (xLabel.length > 0 && this.showXAxis) { yOffset = 0.1 / this.scale.y; - xText = xRange.center() / 2; + xText = (xRange.max + 3*xRange.min)/4; yText = (armVector.x > 0) ? yRange.min - yOffset: yRange.max + yOffset; text = new Point3d(xText, yText, zRange.min); this.drawAxisLabelX(ctx, text, xLabel, armAngle); @@ -1406,10 +1267,10 @@ Graph3d.prototype._redrawAxis = function() { // draw y-label var yLabel = this.yLabel; - if (yLabel.length > 0) { + if (yLabel.length > 0 && this.showYAxis) { xOffset = 0.1 / this.scale.x; xText = (armVector.y > 0) ? xRange.min - xOffset : xRange.max + xOffset; - yText = yRange.center() / 2; + yText = (yRange.max + 3*yRange.min)/4; text = new Point3d(xText, yText, zRange.min); this.drawAxisLabelY(ctx, text, yLabel, armAngle); @@ -1417,11 +1278,11 @@ Graph3d.prototype._redrawAxis = function() { // draw z-label var zLabel = this.zLabel; - if (zLabel.length > 0) { + if (zLabel.length > 0 && this.showZAxis) { offset = 30; // pixels. // TODO: relate to the max width of the values on the z axis? xText = (armVector.x > 0) ? xRange.min : xRange.max; yText = (armVector.y < 0) ? yRange.min : yRange.max; - zText = zRange.center() / 2; + zText = (zRange.max + 3*zRange.min)/4; text = new Point3d(xText, yText, zText); this.drawAxisLabelZ(ctx, text, zLabel, offset); @@ -1430,9 +1291,11 @@ Graph3d.prototype._redrawAxis = function() { /** * Calculate the color based on the given value. - * @param {Number} H Hue, a value be between 0 and 360 - * @param {Number} S Saturation, a value between 0 and 1 - * @param {Number} V Value, a value between 0 and 1 + * @param {number} H Hue, a value be between 0 and 360 + * @param {number} S Saturation, a value between 0 and 1 + * @param {number} V Value, a value between 0 and 1 + * @returns {string} + * @private */ Graph3d.prototype._hsv2rgb = function(H, S, V) { var R, G, B, C, Hi, X; @@ -1456,6 +1319,12 @@ Graph3d.prototype._hsv2rgb = function(H, S, V) { }; +/** + * + * @param {vis.Point3d} point + * @returns {*} + * @private + */ Graph3d.prototype._getStrokeWidth = function(point) { if (point !== undefined) { if (this.showPerspective) { @@ -1477,9 +1346,17 @@ Graph3d.prototype._getStrokeWidth = function(point) { /** * Draw a bar element in the view with the given properties. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @param {number} xWidth + * @param {number} yWidth + * @param {string} color + * @param {string} borderColor + * @private */ Graph3d.prototype._redrawBar = function(ctx, point, xWidth, yWidth, color, borderColor) { - var i, j, surface; + var surface; // calculate all corner points var me = this; @@ -1517,7 +1394,7 @@ Graph3d.prototype._redrawBar = function(ctx, point, xWidth, yWidth, color, borde point.surfaces = surfaces; // calculate the distance of each of the surface centers to the camera - for (j = 0; j < surfaces.length; j++) { + for (let j = 0; j < surfaces.length; j++) { surface = surfaces[j]; var transCenter = this._convertPointToTranslation(surface.center); surface.dist = this.showPerspective ? transCenter.length() : -transCenter.z; @@ -1544,7 +1421,7 @@ Graph3d.prototype._redrawBar = function(ctx, point, xWidth, yWidth, color, borde ctx.strokeStyle = borderColor; ctx.fillStyle = color; // NOTE: we start at j=2 instead of j=0 as we don't need to draw the two surfaces at the backside - for (j = 2; j < surfaces.length; j++) { + for (let j = 2; j < surfaces.length; j++) { surface = surfaces[j]; this._polygon(ctx, surface.corners); } @@ -1554,9 +1431,10 @@ Graph3d.prototype._redrawBar = function(ctx, point, xWidth, yWidth, color, borde /** * Draw a polygon using the passed points and fill it with the passed style and stroke. * - * @param points an array of points. - * @param fillStyle optional; the fill style to set - * @param strokeStyle optional; the stroke style to set + * @param {CanvasRenderingContext2D} ctx + * @param {Array.} points an array of points. + * @param {string} [fillStyle] the fill style to set + * @param {string} [strokeStyle] the stroke style to set */ Graph3d.prototype._polygon = function(ctx, points, fillStyle, strokeStyle) { if (points.length < 2) { @@ -1584,7 +1462,12 @@ Graph3d.prototype._polygon = function(ctx, points, fillStyle, strokeStyle) { /** - * @param size optional; if not specified use value from 'this._dotSize()` + * @param {CanvasRenderingContext2D} ctx + * @param {object} point + * @param {string} color + * @param {string} borderColor + * @param {number} [size=this._dotSize()] + * @private */ Graph3d.prototype._drawCircle = function(ctx, point, color, borderColor, size) { var radius = this._calcRadius(point, size); @@ -1601,6 +1484,10 @@ Graph3d.prototype._drawCircle = function(ctx, point, color, borderColor, size) { /** * Determine the colors for the 'regular' graph styles. + * + * @param {object} point + * @returns {{fill, border}} + * @private */ Graph3d.prototype._getColorsRegular = function(point) { // calculate Hue from the current value. At zMin the hue is 240, at zMax the hue is 0 @@ -1618,16 +1505,31 @@ Graph3d.prototype._getColorsRegular = function(point) { /** * Get the colors for the 'color' graph styles. * These styles are currently: 'bar-color' and 'dot-color' + * Color may be set as a string representation of HTML color, like #ff00ff, + * or calculated from a number, for example, distance from this point + * The first option is useful when we have some pre-given legend, to which we have to adjust ourselves + * The second option is useful when we are interested in automatically setting the color, from some value, + * using some color scale + * @param {object} point + * @returns {{fill: *, border: *}} + * @private */ Graph3d.prototype._getColorsColor = function(point) { // calculate the color based on the value - var hue = (1 - (point.point.value - this.valueRange.min) * this.scale.value) * 240; - var color = this._hsv2rgb(hue, 1, 1); - var borderColor = this._hsv2rgb(hue, 1, 0.8); + var color, borderColor; + if (typeof point.point.value === "string") { + color = point.point.value; + borderColor = point.point.value; + } + else { + var hue = (1 - (point.point.value - this.valueRange.min) * this.scale.value) * 240; + color = this._hsv2rgb(hue, 1, 1); + borderColor = this._hsv2rgb(hue, 1, 0.8); + } return { fill : color, - border : borderColor + border : borderColor }; }; @@ -1635,6 +1537,9 @@ Graph3d.prototype._getColorsColor = function(point) { /** * Get the colors for the 'size' graph styles. * These styles are currently: 'bar-size' and 'dot-size' + * + * @returns {{fill: *, border: (string|colorOptions.stroke|{string, undefined}|string|colorOptions.stroke|{string}|*)}} + * @private */ Graph3d.prototype._getColorsSize = function() { return { @@ -1648,8 +1553,11 @@ Graph3d.prototype._getColorsSize = function() { * Determine the size of a point on-screen, as determined by the * distance to the camera. * - * @param size the size that needs to be translated to screen coordinates. + * @param {Object} point + * @param {number} [size=this._dotSize()] the size that needs to be translated to screen coordinates. * optional; if not passed, use the default point size. + * @returns {number} + * @private */ Graph3d.prototype._calcRadius = function(point, size) { if (size === undefined) { @@ -1678,6 +1586,10 @@ Graph3d.prototype._calcRadius = function(point, size) { /** * Draw single datapoint for graph style 'bar'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawBarGraphPoint = function(ctx, point) { var xWidth = this.xBarWidth / 2; @@ -1690,6 +1602,10 @@ Graph3d.prototype._redrawBarGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'bar-color'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawBarColorGraphPoint = function(ctx, point) { var xWidth = this.xBarWidth / 2; @@ -1702,6 +1618,10 @@ Graph3d.prototype._redrawBarColorGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'bar-size'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawBarSizeGraphPoint = function(ctx, point) { // calculate size for the bar @@ -1717,6 +1637,10 @@ Graph3d.prototype._redrawBarSizeGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'dot'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawDotGraphPoint = function(ctx, point) { var colors = this._getColorsRegular(point); @@ -1727,6 +1651,10 @@ Graph3d.prototype._redrawDotGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'dot-line'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawDotLineGraphPoint = function(ctx, point) { // draw a vertical line from the XY-plane to the graph value @@ -1740,6 +1668,10 @@ Graph3d.prototype._redrawDotLineGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'dot-color'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawDotColorGraphPoint = function(ctx, point) { var colors = this._getColorsColor(point); @@ -1750,12 +1682,20 @@ Graph3d.prototype._redrawDotColorGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'dot-size'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawDotSizeGraphPoint = function(ctx, point) { - var dotSize = this._dotSize(); - var fraction = (point.point.value - this.valueRange.min) / this.valueRange.range(); - var size = dotSize/2 + 2*dotSize * fraction; - var colors = this._getColorsSize(); + var dotSize = this._dotSize(); + var fraction = (point.point.value - this.valueRange.min) / this.valueRange.range(); + + var sizeMin = dotSize*this.dotSizeMinFraction; + var sizeRange = dotSize*this.dotSizeMaxFraction - sizeMin; + var size = sizeMin + sizeRange*fraction; + + var colors = this._getColorsSize(); this._drawCircle(ctx, point, colors.fill, colors.border, size); }; @@ -1763,6 +1703,10 @@ Graph3d.prototype._redrawDotSizeGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'surface'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawSurfaceGraphPoint = function(ctx, point) { var right = point.pointRight; @@ -1776,7 +1720,6 @@ Graph3d.prototype._redrawSurfaceGraphPoint = function(ctx, point) { var topSideVisible = true; var fillStyle; var strokeStyle; - var lineWidth; if (this.showGrayBottom || this.showShadow) { // calculate the cross product of the two vectors from center @@ -1826,6 +1769,11 @@ Graph3d.prototype._redrawSurfaceGraphPoint = function(ctx, point) { /** * Helper method for _redrawGridGraphPoint() + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} from + * @param {Object} to + * @private */ Graph3d.prototype._drawGridLine = function(ctx, from, to) { if (from === undefined || to === undefined) { @@ -1844,6 +1792,10 @@ Graph3d.prototype._drawGridLine = function(ctx, from, to) { /** * Draw single datapoint for graph style 'Grid'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawGridGraphPoint = function(ctx, point) { this._drawGridLine(ctx, point, point.pointRight); @@ -1853,6 +1805,10 @@ Graph3d.prototype._redrawGridGraphPoint = function(ctx, point) { /** * Draw single datapoint for graph style 'line'. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} point + * @private */ Graph3d.prototype._redrawLineGraphPoint = function(ctx, point) { if (point.pointNext === undefined) { @@ -1892,6 +1848,19 @@ Graph3d.prototype._redrawDataGraph = function() { // End methods for drawing points per graph style. // ----------------------------------------------------------------------------- +/** + * Store startX, startY and startOffset for mouse operations + * + * @param {Event} event The event that occurred + */ +Graph3d.prototype._storeMousePosition = function(event) { + // get mouse position (different code for IE and all other browsers) + this.startMouseX = getMouseX(event); + this.startMouseY = getMouseY(event); + + this._startCameraOffset = this.camera.getOffset(); +}; + /** * Start a moving operation inside the provided parent element @@ -1911,9 +1880,7 @@ Graph3d.prototype._onMouseDown = function(event) { this.leftButtonDown = event.which ? (event.which === 1) : (event.button === 1); if (!this.leftButtonDown && !this.touchDown) return; - // get mouse position (different code for IE and all other browsers) - this.startMouseX = getMouseX(event); - this.startMouseY = getMouseY(event); + this._storeMousePosition(event); this.startStart = new Date(this.start); this.startEnd = new Date(this.end); @@ -1939,36 +1906,50 @@ Graph3d.prototype._onMouseDown = function(event) { * @param {Event} event Well, eehh, the event */ Graph3d.prototype._onMouseMove = function (event) { + this.moving = true; event = event || window.event; // calculate change in mouse position var diffX = parseFloat(getMouseX(event)) - this.startMouseX; var diffY = parseFloat(getMouseY(event)) - this.startMouseY; - var horizontalNew = this.startArmRotation.horizontal + diffX / 200; - var verticalNew = this.startArmRotation.vertical + diffY / 200; + // move with ctrl or rotate by other + if (event && event.ctrlKey === true) { + // calculate change in mouse position + var scaleX = this.frame.clientWidth * 0.5; + var scaleY = this.frame.clientHeight * 0.5; - var snapAngle = 4; // degrees - var snapValue = Math.sin(snapAngle / 360 * 2 * Math.PI); + var offXNew = (this._startCameraOffset.x || 0) - ((diffX / scaleX) * this.camera.armLength) * 0.8; + var offYNew = (this._startCameraOffset.y || 0) + ((diffY / scaleY) * this.camera.armLength) * 0.8; - // snap horizontally to nice angles at 0pi, 0.5pi, 1pi, 1.5pi, etc... - // the -0.001 is to take care that the vertical axis is always drawn at the left front corner - if (Math.abs(Math.sin(horizontalNew)) < snapValue) { - horizontalNew = Math.round((horizontalNew / Math.PI)) * Math.PI - 0.001; - } - if (Math.abs(Math.cos(horizontalNew)) < snapValue) { - horizontalNew = (Math.round((horizontalNew/ Math.PI - 0.5)) + 0.5) * Math.PI - 0.001; - } + this.camera.setOffset(offXNew, offYNew); + this._storeMousePosition(event); + } else { + var horizontalNew = this.startArmRotation.horizontal + diffX / 200; + var verticalNew = this.startArmRotation.vertical + diffY / 200; - // snap vertically to nice angles - if (Math.abs(Math.sin(verticalNew)) < snapValue) { - verticalNew = Math.round((verticalNew / Math.PI)) * Math.PI; - } - if (Math.abs(Math.cos(verticalNew)) < snapValue) { - verticalNew = (Math.round((verticalNew/ Math.PI - 0.5)) + 0.5) * Math.PI; + var snapAngle = 4; // degrees + var snapValue = Math.sin(snapAngle / 360 * 2 * Math.PI); + + // snap horizontally to nice angles at 0pi, 0.5pi, 1pi, 1.5pi, etc... + // the -0.001 is to take care that the vertical axis is always drawn at the left front corner + if (Math.abs(Math.sin(horizontalNew)) < snapValue) { + horizontalNew = Math.round(horizontalNew / Math.PI) * Math.PI - 0.001; + } + if (Math.abs(Math.cos(horizontalNew)) < snapValue) { + horizontalNew = (Math.round(horizontalNew / Math.PI - 0.5) + 0.5) * Math.PI - 0.001; + } + + // snap vertically to nice angles + if (Math.abs(Math.sin(verticalNew)) < snapValue) { + verticalNew = Math.round(verticalNew / Math.PI) * Math.PI; + } + if (Math.abs(Math.cos(verticalNew)) < snapValue) { + verticalNew = (Math.round(verticalNew / Math.PI - 0.5) + 0.5) * Math.PI; + } + this.camera.setArmRotation(horizontalNew, verticalNew); } - this.camera.setArmRotation(horizontalNew, verticalNew); this.redraw(); // fire a cameraPositionChange event @@ -1982,7 +1963,7 @@ Graph3d.prototype._onMouseMove = function (event) { /** * Stop moving operating. * This function activated from within the funcion Graph.mouseDown(). - * @param {event} event The event + * @param {Event} event The event */ Graph3d.prototype._onMouseUp = function (event) { this.frame.style.cursor = 'auto'; @@ -1994,6 +1975,26 @@ Graph3d.prototype._onMouseUp = function (event) { util.preventDefault(event); }; +/** + * @param {Event} event The event + */ +Graph3d.prototype._onClick = function (event) { + if (!this.onclick_callback) + return; + if (!this.moving) { + var boundingRect = this.frame.getBoundingClientRect(); + var mouseX = getMouseX(event) - boundingRect.left; + var mouseY = getMouseY(event) - boundingRect.top; + var dataPoint = this._dataPointFromXY(mouseX, mouseY); + if (dataPoint) + this.onclick_callback(dataPoint.point.data); + } + else { // disable onclick callback, if it came immediately after rotate/pan + this.moving = false; + } + util.preventDefault(event); +}; + /** * After having moved the mouse, a tooltip should pop up when the mouse is resting on a data point * @param {Event} event A mouse move event @@ -2048,6 +2049,7 @@ Graph3d.prototype._onTooltip = function (event) { /** * Event handler for touchstart event on mobile devices + * @param {Event} event The event */ Graph3d.prototype._onTouchStart = function(event) { this.touchDown = true; @@ -2063,6 +2065,7 @@ Graph3d.prototype._onTouchStart = function(event) { /** * Event handler for touchmove event on mobile devices + * @param {Event} event The event */ Graph3d.prototype._onTouchMove = function(event) { this._onMouseMove(event); @@ -2070,6 +2073,7 @@ Graph3d.prototype._onTouchMove = function(event) { /** * Event handler for touchend event on mobile devices + * @param {Event} event The event */ Graph3d.prototype._onTouchEnd = function(event) { this.touchDown = false; @@ -2084,7 +2088,7 @@ Graph3d.prototype._onTouchEnd = function(event) { /** * Event handler for mouse wheel event, used to zoom the graph * Code from http://adomas.org/javascript-mouse-wheel/ - * @param {event} event The event + * @param {Event} event The event */ Graph3d.prototype._onWheel = function(event) { if (!event) /* For IE. */ @@ -2126,8 +2130,8 @@ Graph3d.prototype._onWheel = function(event) { /** * Test whether a point lies inside given 2D triangle * - * @param {Point2d} point - * @param {Point2d[]} triangle + * @param {vis.Point2d} point + * @param {vis.Point2d[]} triangle * @returns {boolean} true if given point lies inside or on the edge of the * triangle, false otherwise * @private @@ -2137,6 +2141,11 @@ Graph3d.prototype._insideTriangle = function (point, triangle) { b = triangle[1], c = triangle[2]; + /** + * + * @param {number} x + * @returns {number} + */ function sign (x) { return x > 0 ? 1 : x < 0 ? -1 : 0; } @@ -2154,8 +2163,8 @@ Graph3d.prototype._insideTriangle = function (point, triangle) { /** * Find a data point close to given screen position (x, y) * - * @param {Number} x - * @param {Number} y + * @param {number} x + * @param {number} y * @returns {Object | null} The closest data point or null if not close to any * data point * @private @@ -2213,6 +2222,20 @@ Graph3d.prototype._dataPointFromXY = function (x, y) { return closestDataPoint; }; + +/** + * Determine if the given style has bars + * + * @param {number} style the style to check + * @returns {boolean} true if bar style, false otherwise + */ +Graph3d.prototype.hasBars = function(style) { + return style == Graph3d.STYLE.BAR || + style == Graph3d.STYLE.BARCOLOR || + style == Graph3d.STYLE.BARSIZE; +}; + + /** * Display a tooltip for given data point * @param {Object} dataPoint @@ -2223,26 +2246,16 @@ Graph3d.prototype._showTooltip = function (dataPoint) { if (!this.tooltip) { content = document.createElement('div'); + Object.assign(content.style, {}, this.tooltipStyle.content); content.style.position = 'absolute'; - content.style.padding = '10px'; - content.style.border = '1px solid #4d4d4d'; - content.style.color = '#1a1a1a'; - content.style.background = 'rgba(255,255,255,0.7)'; - content.style.borderRadius = '2px'; - content.style.boxShadow = '5px 5px 10px rgba(128,128,128,0.5)'; - + line = document.createElement('div'); + Object.assign(line.style, {}, this.tooltipStyle.line); line.style.position = 'absolute'; - line.style.height = '40px'; - line.style.width = '0'; - line.style.borderLeft = '1px solid #4d4d4d'; dot = document.createElement('div'); + Object.assign(dot.style, {}, this.tooltipStyle.dot); dot.style.position = 'absolute'; - dot.style.height = '0'; - dot.style.width = '0'; - dot.style.border = '5px solid #4d4d4d'; - dot.style.borderRadius = '5px'; this.tooltip = { dataPoint: null, @@ -2323,7 +2336,7 @@ Graph3d.prototype._hideTooltip = function () { * Get the horizontal mouse position from a mouse event * * @param {Event} event - * @returns {Number} mouse x + * @returns {number} mouse x */ function getMouseX (event) { if ('clientX' in event) return event.clientX; @@ -2334,7 +2347,7 @@ function getMouseX (event) { * Get the vertical mouse position from a mouse event * * @param {Event} event - * @returns {Number} mouse y + * @returns {number} mouse y */ function getMouseY (event) { if ('clientY' in event) return event.clientY; @@ -2350,12 +2363,12 @@ function getMouseY (event) { * Set the rotation and distance of the camera * * @param {Object} pos An object with the camera position - * @param {?Number} pos.horizontal The horizontal rotation, between 0 and 2*PI. + * @param {number} [pos.horizontal] The horizontal rotation, between 0 and 2*PI. * Optional, can be left undefined. - * @param {?Number} pos.vertical The vertical rotation, between 0 and 0.5*PI. + * @param {number} [pos.vertical] The vertical rotation, between 0 and 0.5*PI. * if vertical=0.5*PI, the graph is shown from * the top. Optional, can be left undefined. - * @param {?Number} pos.distance The (normalized) distance of the camera to the + * @param {number} [pos.distance] The (normalized) distance of the camera to the * center of the graph, a value between 0.71 and * 5.0. Optional, can be left undefined. */ @@ -2365,6 +2378,19 @@ Graph3d.prototype.setCameraPosition = function(pos) { }; +/** + * Set a new size for the graph + * + * @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%') + */ +Graph3d.prototype.setSize = function(width, height) { + this._setSize(width, height); + this.redraw(); +}; + // ----------------------------------------------------------------------------- // End public methods for specific settings // ----------------------------------------------------------------------------- diff --git a/lib/graph3d/Point2d.js b/lib/graph3d/Point2d.js index c247e0203..0fa980069 100644 --- a/lib/graph3d/Point2d.js +++ b/lib/graph3d/Point2d.js @@ -1,7 +1,7 @@ /** * @prototype Point2d - * @param {Number} [x] - * @param {Number} [y] + * @param {number} [x] + * @param {number} [y] */ function Point2d (x, y) { this.x = x !== undefined ? x : 0; diff --git a/lib/graph3d/Point3d.js b/lib/graph3d/Point3d.js index 0892e6936..4dba71e06 100644 --- a/lib/graph3d/Point3d.js +++ b/lib/graph3d/Point3d.js @@ -1,14 +1,14 @@ /** * @prototype Point3d - * @param {Number} [x] - * @param {Number} [y] - * @param {Number} [z] + * @param {number} [x] + * @param {number} [y] + * @param {number} [z] */ function Point3d(x, y, z) { this.x = x !== undefined ? x : 0; this.y = y !== undefined ? y : 0; this.z = z !== undefined ? z : 0; -}; +} /** * Subtract the two provided points, returns a-b @@ -72,7 +72,7 @@ Point3d.crossProduct = function(a, b) { /** * Rtrieve the length of the vector (or the distance from this point to the origin - * @return {Number} length + * @return {number} length */ Point3d.prototype.length = function() { return Math.sqrt( diff --git a/lib/graph3d/Settings.js b/lib/graph3d/Settings.js old mode 100644 new mode 100755 index 7a702580f..7b2c0c9cd --- a/lib/graph3d/Settings.js +++ b/lib/graph3d/Settings.js @@ -2,6 +2,7 @@ // This modules handles the options for Graph3d. // //////////////////////////////////////////////////////////////////////////////// +var util = require('../util'); var Camera = require('./Camera'); var Point3d = require('./Point3d'); @@ -53,12 +54,17 @@ var OPTIONKEYS = [ 'xValueLabel', 'yValueLabel', 'zValueLabel', + 'showXAxis', + 'showYAxis', + 'showZAxis', 'showGrid', 'showPerspective', 'showShadow', 'keepAspectRatio', 'verticalRatio', 'dotSizeRatio', + 'dotSizeMinFraction', + 'dotSizeMaxFraction', 'showAnimationControls', 'animationInterval', 'animationPreload', @@ -66,7 +72,7 @@ var OPTIONKEYS = [ 'axisColor', 'gridColor', 'xCenter', - 'yCenter' + 'yCenter', ]; @@ -101,6 +107,9 @@ var DEFAULTS = undefined; * Check if given hash is empty. * * Source: http://stackoverflow.com/a/679937 + * + * @param {object} obj + * @returns {boolean} */ function isEmpty(obj) { for(var prop in obj) { @@ -112,14 +121,16 @@ function isEmpty(obj) { } - /** * Make first letter of parameter upper case. * * Source: http://stackoverflow.com/a/1026087 + * + * @param {string} str + * @returns {string} */ function capitalize(str) { - if (str === undefined || str === "") { + if (str === undefined || str === "" || typeof str != "string") { return str; } @@ -129,6 +140,10 @@ function capitalize(str) { /** * Add a prefix to a field name, taking style guide into account + * + * @param {string} prefix + * @param {string} fieldName + * @returns {string} */ function prefixFieldName(prefix, fieldName) { if (prefix === undefined || prefix === "") { @@ -150,14 +165,16 @@ function prefixFieldName(prefix, fieldName) { * * Only the fields mentioned in array 'fields' will be handled. * - * @param fields array with names of fields to copy - * @param prefix optional; prefix to use for the target fields. + * @param {object} src + * @param {object} dst + * @param {array} fields array with names of fields to copy + * @param {string} [prefix] prefix to use for the target fields. */ function forceCopy(src, dst, fields, prefix) { var srcKey; var dstKey; - for (var i in fields) { + for (var i = 0; i < fields.length; ++i) { srcKey = fields[i]; dstKey = prefixFieldName(prefix, srcKey); @@ -172,14 +189,16 @@ function forceCopy(src, dst, fields, prefix) { * Only the fields mentioned in array 'fields' will be copied over, * and only if these are actually defined. * - * @param fields array with names of fields to copy - * @param prefix optional; prefix to use for the target fields. + * @param {object} src + * @param {object} dst + * @param {array} fields array with names of fields to copy + * @param {string} [prefix] prefix to use for the target fields. */ function safeCopy(src, dst, fields, prefix) { var srcKey; var dstKey; - for (var i in fields) { + for (var i = 0; i < fields.length; ++i) { srcKey = fields[i]; if (src[srcKey] === undefined) continue; @@ -198,6 +217,8 @@ function safeCopy(src, dst, fields, prefix) { * further handling. * * For now, dst is assumed to be a Graph3d instance. + * @param {object} src + * @param {object} dst */ function setDefaults(src, dst) { if (src === undefined || isEmpty(src)) { @@ -221,10 +242,15 @@ function setDefaults(src, dst) { dst.margin = 10; // px dst.showGrayBottom = false; // TODO: this does not work correctly dst.showTooltip = false; + dst.onclick_callback = null; dst.eye = new Point3d(0, 0, -1); // TODO: set eye.z about 3/4 of the width of the window? } - +/** + * + * @param {object} options + * @param {object} dst + */ function setOptions(options, dst) { if (options === undefined) { return; @@ -237,7 +263,6 @@ function setOptions(options, dst) { throw new Error('DEFAULTS not set for module Settings'); } - // Handle the parameters which can be simply copied over safeCopy(options, dst, OPTIONKEYS); safeCopy(options, dst, PREFIXEDOPTIONKEYS, 'default'); @@ -246,11 +271,13 @@ function setOptions(options, dst) { setSpecialSettings(options, dst); } - /** * Special handling for certain parameters * * 'Special' here means: setting requires more than a simple copy + * + * @param {object} src + * @param {object} dst */ function setSpecialSettings(src, dst) { if (src.backgroundColor !== undefined) { @@ -267,6 +294,13 @@ function setSpecialSettings(src, dst) { if (src.tooltip !== undefined) { dst.showTooltip = src.tooltip; } + if (src.onclick != undefined) { + dst.onclick_callback = src.onclick; + } + + if (src.tooltipStyle !== undefined) { + util.selectiveDeepExtend(['tooltipStyle'], dst, src); + } } @@ -275,6 +309,9 @@ function setSpecialSettings(src, dst) { * * This depends on the value of the style fields, so it must be called * after the style field has been initialized. + * + * @param {boolean} showLegend + * @param {object} dst */ function setShowLegend(showLegend, dst) { if (showLegend === undefined) { @@ -299,7 +336,7 @@ function setShowLegend(showLegend, dst) { /** * Retrieve the style index from given styleName * @param {string} styleName Style name such as 'dot', 'grid', 'dot-line' - * @return {Number} styleNumber Enumeration value representing the style, or -1 + * @return {number} styleNumber Enumeration value representing the style, or -1 * when not found */ function getStyleNumberByName(styleName) { @@ -316,7 +353,8 @@ function getStyleNumberByName(styleName) { /** * Check if given number is a valid style number. * - * @return true if valid, false otherwise + * @param {string | number} style + * @return {boolean} true if valid, false otherwise */ function checkStyleNumber(style) { var valid = false; @@ -331,7 +369,11 @@ function checkStyleNumber(style) { return valid; } - +/** + * + * @param {string | number} style + * @param {Object} dst + */ function setStyle(style, dst) { if (style === undefined) { return; // Nothing to do @@ -361,6 +403,7 @@ function setStyle(style, dst) { /** * Set the background styling for the graph * @param {string | {fill: string, stroke: string, strokeWidth: string}} backgroundColor + * @param {Object} dst */ function setBackgroundColor(backgroundColor, dst) { var fill = 'white'; @@ -387,7 +430,11 @@ function setBackgroundColor(backgroundColor, dst) { dst.frame.style.borderStyle = 'solid'; } - +/** + * + * @param {string | Object} dataColor + * @param {Object} dst + */ function setDataColor(dataColor, dst) { if (dataColor === undefined) { return; // Nothing to do @@ -414,7 +461,11 @@ function setDataColor(dataColor, dst) { } } - +/** + * + * @param {Object} cameraPosition + * @param {Object} dst + */ function setCameraPosition(cameraPosition, dst) { var camPos = cameraPosition; if (camPos === undefined) { diff --git a/lib/graph3d/Slider.js b/lib/graph3d/Slider.js index f6f21ecc0..c34951071 100644 --- a/lib/graph3d/Slider.js +++ b/lib/graph3d/Slider.js @@ -1,9 +1,9 @@ var util = require('../util'); /** - * @constructor Slider - * * An html slider control with start/stop/prev/next buttons + * + * @constructor Slider * @param {Element} container The element where the slider will be created * @param {Object} options Available options: * {boolean} visible If true (default) the @@ -167,6 +167,8 @@ Slider.prototype.stop = function() { /** * Set a callback function which will be triggered when the value of the * slider bar has changed. + * + * @param {function} callback */ Slider.prototype.setOnChangeCallback = function(callback) { this.onChangeCallback = callback; @@ -174,7 +176,7 @@ Slider.prototype.setOnChangeCallback = function(callback) { /** * Set the interval for playing the list - * @param {Number} interval The interval in milliseconds + * @param {number} interval The interval in milliseconds */ Slider.prototype.setPlayInterval = function(interval) { this.playInterval = interval; @@ -182,17 +184,18 @@ Slider.prototype.setPlayInterval = function(interval) { /** * Retrieve the current play interval - * @return {Number} interval The interval in milliseconds + * @return {number} interval The interval in milliseconds */ -Slider.prototype.getPlayInterval = function(interval) { +Slider.prototype.getPlayInterval = function() { return this.playInterval; }; /** * Set looping on or off - * @pararm {boolean} doLoop If true, the slider will jump to the start when + * @param {boolean} doLoop If true, the slider will jump to the start when * the end is passed, and will jump to the end * when the start is passed. + * */ Slider.prototype.setPlayLoop = function(doLoop) { this.playLoop = doLoop; @@ -243,7 +246,7 @@ Slider.prototype.setValues = function(values) { /** * Select a value by its index - * @param {Number} index + * @param {number} index */ Slider.prototype.setIndex = function(index) { if (index < this.values.length) { @@ -259,7 +262,7 @@ Slider.prototype.setIndex = function(index) { /** * retrieve the index of the currently selected vaue - * @return {Number} index + * @return {number} index */ Slider.prototype.getIndex = function() { return this.index; @@ -333,7 +336,7 @@ Slider.prototype._onMouseMove = function (event) { }; -Slider.prototype._onMouseUp = function (event) { +Slider.prototype._onMouseUp = function (event) { // eslint-disable-line no-unused-vars this.frame.style.cursor = 'auto'; // remove event listeners diff --git a/lib/graph3d/StepNumber.js b/lib/graph3d/StepNumber.js index 9675b4f8f..641b8adec 100644 --- a/lib/graph3d/StepNumber.js +++ b/lib/graph3d/StepNumber.js @@ -17,9 +17,9 @@ * * Version: 1.0 * - * @param {Number} start The start value - * @param {Number} end The end value - * @param {Number} step Optional. Step size. Must be a positive value. + * @param {number} start The start value + * @param {number} end The end value + * @param {number} step Optional. Step size. Must be a positive value. * @param {boolean} prettyStep Optional. If true, the step size is rounded * To a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ @@ -33,13 +33,16 @@ function StepNumber(start, end, step, prettyStep) { this._current = 0; this.setRange(start, end, step, prettyStep); -}; +} /** * Check for input values, to prevent disasters from happening * * Source: http://stackoverflow.com/a/1830844 + * + * @param {string} n + * @returns {boolean} */ StepNumber.prototype.isNumeric = function(n) { return !isNaN(parseFloat(n)) && isFinite(n); @@ -49,9 +52,9 @@ StepNumber.prototype.isNumeric = function(n) { /** * Set a new range: start, end and step. * - * @param {Number} start The start value - * @param {Number} end The end value - * @param {Number} step Optional. Step size. Must be a positive value. + * @param {number} start The start value + * @param {number} end The end value + * @param {number} step Optional. Step size. Must be a positive value. * @param {boolean} prettyStep Optional. If true, the step size is rounded * To a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ @@ -74,7 +77,7 @@ StepNumber.prototype.setRange = function(start, end, step, prettyStep) { /** * Set a new step size - * @param {Number} step New step size. Must be a positive value + * @param {number} step New step size. Must be a positive value * @param {boolean} prettyStep Optional. If true, the provided step is rounded * to a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ @@ -95,8 +98,8 @@ StepNumber.prototype.setStep = function(step, prettyStep) { * Calculate a nice step size, closest to the desired step size. * Returns a value in one of the ranges 1*10^n, 2*10^n, or 5*10^n, where n is an * integer Number. For example 1, 2, 5, 10, 20, 50, etc... - * @param {Number} step Desired step size - * @return {Number} Nice step size + * @param {number} step Desired step size + * @return {number} Nice step size */ StepNumber.calculatePrettyStep = function (step) { var log10 = function (x) {return Math.log(x) / Math.LN10;}; @@ -121,7 +124,7 @@ StepNumber.calculatePrettyStep = function (step) { /** * returns the current value of the step - * @return {Number} current value + * @return {number} current value */ StepNumber.prototype.getCurrent = function () { return parseFloat(this._current.toPrecision(this.precision)); @@ -129,7 +132,7 @@ StepNumber.prototype.getCurrent = function () { /** * returns the current step size - * @return {Number} current step size + * @return {number} current step size */ StepNumber.prototype.getStep = function () { return this._step; @@ -143,6 +146,8 @@ StepNumber.prototype.getStep = function () { * * Parameters checkFirst is optional, default false. * If set to true, move the current value one step if smaller than start. + * + * @param {boolean} [checkFirst=false] */ StepNumber.prototype.start = function(checkFirst) { if (checkFirst === undefined) { diff --git a/lib/graph3d/options.js b/lib/graph3d/options.js new file mode 100644 index 000000000..95ff370c7 --- /dev/null +++ b/lib/graph3d/options.js @@ -0,0 +1,132 @@ +/** + * This object contains all possible options. It will check if the types are correct, if required if the option is one + * of the allowed values. + * + * __any__ means that the name of the property does not matter. + * __type__ is a required field for all objects and contains the allowed types of all objects + */ +let string = 'string'; +let bool = 'boolean'; +let number = 'number'; +let object = 'object'; // should only be in a __type__ property +// Following not used here, but useful for reference +//let array = 'array'; +//let dom = 'dom'; +//let any = 'any'; + + +let colorOptions = { + fill : { string }, + stroke : { string }, + strokeWidth: { number }, + __type__ : { string, object, 'undefined': 'undefined' } +}; + + +/** + * Order attempted to be alphabetical. + * - x/y/z-prefixes ignored in sorting + * - __type__ always at end + * - globals at end + */ +let allOptions = { + animationAutoStart: { boolean: bool, 'undefined': 'undefined' }, + animationInterval : { number }, + animationPreload : { boolean: bool }, + axisColor : { string }, + backgroundColor : colorOptions, + xBarWidth : { number, 'undefined': 'undefined' }, + yBarWidth : { number, 'undefined': 'undefined' }, + cameraPosition : { + distance : { number }, + horizontal: { number }, + vertical : { number }, + __type__ : { object } + }, + xCenter : { string }, + yCenter : { string }, + dataColor : colorOptions, + dotSizeMinFraction: { number }, + dotSizeMaxFraction: { number }, + dotSizeRatio : { number }, + filterLabel : { string }, + gridColor : { string }, + onclick : { 'function': 'function' }, + keepAspectRatio : { boolean: bool }, + xLabel : { string }, + yLabel : { string }, + zLabel : { string }, + legendLabel : { string }, + xMin : { number, 'undefined': 'undefined' }, + yMin : { number, 'undefined': 'undefined' }, + zMin : { number, 'undefined': 'undefined' }, + xMax : { number, 'undefined': 'undefined' }, + yMax : { number, 'undefined': 'undefined' }, + zMax : { number, 'undefined': 'undefined' }, + showAnimationControls: { boolean: bool, 'undefined': 'undefined' }, + showGrid : { boolean: bool }, + showLegend : { boolean: bool, 'undefined': 'undefined' }, + showPerspective : { boolean: bool }, + showShadow : { boolean: bool }, + showXAxis : { boolean: bool }, + showYAxis : { boolean: bool }, + showZAxis : { boolean: bool }, + xStep : { number, 'undefined': 'undefined' }, + yStep : { number, 'undefined': 'undefined' }, + zStep : { number, 'undefined': 'undefined' }, + style: { + number, // TODO: either Graph3d.DEFAULT has string, or number allowed in documentation + string: [ + 'bar', + 'bar-color', + 'bar-size', + 'dot', + 'dot-line', + 'dot-color', + 'dot-size', + 'line', + 'grid', + 'surface' + ] + }, + tooltip : { boolean: bool, 'function': 'function' }, + tooltipStyle : { + content: { + color : { string }, + background : { string }, + border : { string }, + borderRadius: { string }, + boxShadow : { string }, + padding : { string }, + __type__ : { object } + }, + line: { + borderLeft: { string }, + height : { string }, + width : { string }, + __type__ : { object } + }, + dot: { + border : { string }, + borderRadius: { string }, + height : { string }, + width : { string }, + __type__ : { object}, + }, + __type__: { object} + }, + xValueLabel : { 'function': 'function' }, + yValueLabel : { 'function': 'function' }, + zValueLabel : { 'function': 'function' }, + valueMax : { number, 'undefined': 'undefined' }, + valueMin : { number, 'undefined': 'undefined' }, + verticalRatio : { number }, + + //globals : + height: { string }, + width: { string }, + __type__: { object } +}; + + +export {allOptions}; diff --git a/lib/hammerUtil.js b/lib/hammerUtil.js index caafb7dec..e9cb66655 100644 --- a/lib/hammerUtil.js +++ b/lib/hammerUtil.js @@ -1,5 +1,3 @@ -var Hammer = require('./module/hammer'); - /** * Register a touch event, taking place before a gesture * @param {Hammer} hammer A hammer instance @@ -19,6 +17,7 @@ exports.onTouch = function (hammer, callback) { * Register a release event, taking place after a gesture * @param {Hammer} hammer A hammer instance * @param {function} callback Callback, called as callback(event) + * @returns {*} */ exports.onRelease = function (hammer, callback) { callback.inputHandler = function (event) { diff --git a/lib/header.js b/lib/header.js index 3045a7971..0cdc0e21e 100644 --- a/lib/header.js +++ b/lib/header.js @@ -8,7 +8,7 @@ * @date @@date * * @license - * Copyright (C) 2011-2016 Almende B.V, http://almende.com + * Copyright (C) 2011-2017 Almende B.V, http://almende.com * * Vis.js is dual licensed under both * diff --git a/lib/module/hammer.js b/lib/module/hammer.js index 76c272a6f..7e5e3cae3 100644 --- a/lib/module/hammer.js +++ b/lib/module/hammer.js @@ -1,5 +1,28 @@ -// Only load hammer.js when in a browser environment -// (loading hammer.js in a node.js environment gives errors) +/** + * Setup a mock hammer.js object, for unit testing. + * + * Inspiration: https://github.com/uber/deck.gl/pull/658 + * + * @returns {{on: noop, off: noop, destroy: noop, emit: noop, get: get}} + */ +function hammerMock() { + const noop = () => {}; + + return { + on: noop, + off: noop, + destroy: noop, + emit: noop, + + get: function(m) { //eslint-disable-line no-unused-vars + return { + set: noop + }; + } + }; +} + + if (typeof window !== 'undefined') { var propagating = require('propagating-hammerjs'); var Hammer = window['Hammer'] || require('hammerjs'); @@ -9,6 +32,7 @@ if (typeof window !== 'undefined') { } else { module.exports = function () { - throw Error('hammer.js is only available in a browser, not in node.js.'); + // hammer.js is only available in a browser, not in node.js. Replacing it with a mock object. + return hammerMock(); } } diff --git a/lib/module/uuid.js b/lib/module/uuid.js index e44dd84b7..b0b0270d4 100644 --- a/lib/module/uuid.js +++ b/lib/module/uuid.js @@ -1,3 +1,5 @@ +/* eslint-disable require-jsdoc */ + var _rng; var globalVar = typeof window !== 'undefined' diff --git a/lib/network/CachedImage.js b/lib/network/CachedImage.js new file mode 100644 index 000000000..317aa4ce8 --- /dev/null +++ b/lib/network/CachedImage.js @@ -0,0 +1,160 @@ + +/** + * Associates a canvas to a given image, containing a number of renderings + * of the image at various sizes. + * + * This technique is known as 'mipmapping'. + * + * NOTE: Images can also be of type 'data:svg+xml`. This code also works + * for svg, but the mipmapping may not be necessary. + * + * @param {Image} image + */ +class CachedImage { + /** + * @ignore + */ + constructor() { // eslint-disable-line no-unused-vars + this.NUM_ITERATIONS = 4; // Number of items in the coordinates array + + this.image = new Image(); + this.canvas = document.createElement('canvas'); + } + + + /** + * Called when the image has been successfully loaded. + */ + init() { + if (this.initialized()) return; + + this.src = this.image.src; // For same interface with Image + var w = this.image.width; + var h = this.image.height; + + // Ease external access + this.width = w; + this.height = h; + + var h2 = Math.floor(h/2); + var h4 = Math.floor(h/4); + var h8 = Math.floor(h/8); + var h16 = Math.floor(h/16); + + var w2 = Math.floor(w/2); + var w4 = Math.floor(w/4); + var w8 = Math.floor(w/8); + var w16 = Math.floor(w/16); + + // Make canvas as small as possible + this.canvas.width = 3*w4; + this.canvas.height = h2; + + // Coordinates and sizes of images contained in the canvas + // Values per row: [top x, left y, width, height] + + this.coordinates = [ + [ 0 , 0 , w2 , h2], + [ w2 , 0 , w4 , h4], + [ w2 , h4, w8 , h8], + [ 5*w8, h4, w16, h16] + ]; + + this._fillMipMap(); + } + + + /** + * @return {Boolean} true if init() has been called, false otherwise. + */ + initialized() { + return (this.coordinates !== undefined); + } + + + /** + * Redraw main image in various sizes to the context. + * + * The rationale behind this is to reduce artefacts due to interpolation + * at differing zoom levels. + * + * Source: http://stackoverflow.com/q/18761404/1223531 + * + * This methods takes the resizing out of the drawing loop, in order to + * reduce performance overhead. + * + * TODO: The code assumes that a 2D context can always be gotten. This is + * not necessarily true! OTOH, if not true then usage of this class + * is senseless. + * + * @private + */ + _fillMipMap() { + var ctx = this.canvas.getContext('2d'); + + // First zoom-level comes from the image + var to = this.coordinates[0]; + ctx.drawImage(this.image, to[0], to[1], to[2], to[3]); + + // The rest are copy actions internal to the canvas/context + for (let iterations = 1; iterations < this.NUM_ITERATIONS; iterations++) { + let from = this.coordinates[iterations - 1]; + let to = this.coordinates[iterations]; + + ctx.drawImage(this.canvas, + from[0], from[1], from[2], from[3], + to[0], to[1], to[2], to[3] + ); + } + } + + + /** + * Draw the image, using the mipmap if necessary. + * + * MipMap is only used if param factor > 2; otherwise, original bitmap + * is resized. This is also used to skip mipmap usage, e.g. by setting factor = 1 + * + * Credits to 'Alex de Mulder' for original implementation. + * + * @param {CanvasRenderingContext2D} ctx context on which to draw zoomed image + * @param {Float} factor scale factor at which to draw + * @param {number} left + * @param {number} top + * @param {number} width + * @param {number} height + */ + drawImageAtPosition(ctx, factor, left, top, width, height) { + + if(!this.initialized()) + return; //can't draw image yet not intialized + + if (factor > 2) { + // Determine which zoomed image to use + factor *= 0.5; + let iterations = 0; + while (factor > 2 && iterations < this.NUM_ITERATIONS) { + factor *= 0.5; + iterations += 1; + } + + if (iterations >= this.NUM_ITERATIONS) { + iterations = this.NUM_ITERATIONS - 1; + } + //console.log("iterations: " + iterations); + + let from = this.coordinates[iterations]; + ctx.drawImage(this.canvas, + from[0], from[1], from[2], from[3], + left, top, width, height + ); + } else { + // Draw image directly + ctx.drawImage(this.image, left, top, width, height); + } + } + +} + + +export default CachedImage; diff --git a/lib/network/Images.js b/lib/network/Images.js index 464e4585b..850a7be05 100644 --- a/lib/network/Images.js +++ b/lib/network/Images.js @@ -1,54 +1,55 @@ +import CachedImage from './CachedImage'; + +/** + * This callback is a callback that accepts an Image. + * @callback ImageCallback + * @param {Image} image + */ + /** - * @class Images * This class loads images and keeps them stored. + * + * @param {ImageCallback} callback */ -class Images{ - constructor(callback){ +class Images { + /** + * @param {ImageCallback} callback + */ + constructor(callback){ this.images = {}; this.imageBroken = {}; this.callback = callback; } - /** - * @param {string} url The Url to cache the image as - * @return {Image} imageToLoadBrokenUrlOn The image object - */ - _addImageToCache (url, imageToCache) { - // IE11 fix -- thanks dponch! - if (imageToCache.width === 0) { - document.body.appendChild(imageToCache); - imageToCache.width = imageToCache.offsetWidth; - imageToCache.height = imageToCache.offsetHeight; - document.body.removeChild(imageToCache); - } - - this.images[url] = imageToCache; - } - /** * @param {string} url The original Url that failed to load, if the broken image is successfully loaded it will be added to the cache using this Url as the key so that subsequent requests for this Url will return the broken image * @param {string} brokenUrl Url the broken image to try and load - * @return {Image} imageToLoadBrokenUrlOn The image object + * @param {Image} imageToLoadBrokenUrlOn The image object */ _tryloadBrokenUrl (url, brokenUrl, imageToLoadBrokenUrlOn) { - //If any of the parameters aren't specified then exit the function because nothing constructive can be done - if (url === undefined || brokenUrl === undefined || imageToLoadBrokenUrlOn === undefined) return; + //If these parameters aren't specified then exit the function because nothing constructive can be done + if (url === undefined || imageToLoadBrokenUrlOn === undefined) return; + if (brokenUrl === undefined) { + console.warn("No broken url image defined"); + return; + } //Clear the old subscription to the error event and put a new in place that only handle errors in loading the brokenImageUrl imageToLoadBrokenUrlOn.onerror = () => { console.error("Could not load brokenImage:", brokenUrl); - //Add an empty image to the cache so that when subsequent load calls are made for the url we don't try load the image and broken image again - this._addImageToCache(url, new Image()); + // cache item will contain empty image, this should be OK for default }; //Set the source of the image to the brokenUrl, this is actually what kicks off the loading of the broken image - imageToLoadBrokenUrlOn.src = brokenUrl; + imageToLoadBrokenUrlOn.image.src = brokenUrl; } - /** - * @return {Image} imageToRedrawWith The images that will be passed to the callback when it is invoked - */ - _redrawWithImage (imageToRedrawWith) { + /** + * + * @param {vis.Image} imageToRedrawWith + * @private + */ + _redrawWithImage (imageToRedrawWith) { if (this.callback) { this.callback(imageToRedrawWith); } @@ -59,34 +60,56 @@ class Images{ * @param {string} brokenUrl Url of an image to use if the url image is not found * @return {Image} img The image object */ - load (url, brokenUrl, id) { + load (url, brokenUrl) { //Try and get the image from the cache, if successful then return the cached image var cachedImage = this.images[url]; if (cachedImage) return cachedImage; //Create a new image - var img = new Image(); + var img = new CachedImage(); + + // Need to add to cache here, otherwise final return will spawn different copies of the same image, + // Also, there will be multiple loads of the same image. + this.images[url] = img; //Subscribe to the event that is raised if the image loads successfully - img.onload = () => { - //Add the image to the cache and then request a redraw - this._addImageToCache(url, img); + img.image.onload = () => { + // Properly init the cached item and then request a redraw + this._fixImageCoordinates(img.image); + img.init(); this._redrawWithImage(img); }; //Subscribe to the event that is raised if the image fails to load - img.onerror = () => { + img.image.onerror = () => { console.error("Could not load image:", url); //Try and load the image specified by the brokenUrl using this._tryloadBrokenUrl(url, brokenUrl, img); - } + }; - //Set the source of the image to the url, this is actuall what kicks off the loading of the image - img.src = url; + //Set the source of the image to the url, this is what actually kicks off the loading of the image + img.image.src = url; //Return the new image return img; - } + } + + + /** + * IE11 fix -- thanks dponch! + * + * Local helper function + * @param {vis.Image} imageToCache + * @private + */ + _fixImageCoordinates(imageToCache) { + if (imageToCache.width === 0) { + document.body.appendChild(imageToCache); + imageToCache.width = imageToCache.offsetWidth; + imageToCache.height = imageToCache.offsetHeight; + document.body.removeChild(imageToCache); + } + } } -export default Images; \ No newline at end of file +export default Images; diff --git a/lib/network/Network.js b/lib/network/Network.js index 884c3352a..f5980b205 100644 --- a/lib/network/Network.js +++ b/lib/network/Network.js @@ -3,35 +3,32 @@ require('./shapes'); let Emitter = require('emitter-component'); let util = require('../util'); -let DataSet = require('../DataSet'); -let DataView = require('../DataView'); let dotparser = require('./dotparser'); let gephiParser = require('./gephiParser'); let Activator = require('../shared/Activator'); let locales = require('./locales'); -import Images from './Images'; -import Groups from './modules/Groups'; -import NodesHandler from './modules/NodesHandler'; -import EdgesHandler from './modules/EdgesHandler'; -import PhysicsEngine from './modules/PhysicsEngine'; -import ClusterEngine from './modules/Clustering'; -import CanvasRenderer from './modules/CanvasRenderer'; -import Canvas from './modules/Canvas'; -import View from './modules/View'; -import InteractionHandler from './modules/InteractionHandler'; -import SelectionHandler from "./modules/SelectionHandler"; -import LayoutEngine from "./modules/LayoutEngine"; -import ManipulationSystem from "./modules/ManipulationSystem"; -import Configurator from "./../shared/Configurator"; -import Validator from "./../shared/Validator"; -import {printStyle} from "./../shared/Validator"; -import {allOptions, configureOptions} from './options.js'; -import KamadaKawai from "./modules/KamadaKawai.js" +var Images = require('./Images').default; +var Groups = require('./modules/Groups').default; +var NodesHandler = require('./modules/NodesHandler').default; +var EdgesHandler = require('./modules/EdgesHandler').default; +var PhysicsEngine = require('./modules/PhysicsEngine').default; +var ClusterEngine = require('./modules/Clustering').default; +var CanvasRenderer = require('./modules/CanvasRenderer').default; +var Canvas = require('./modules/Canvas').default; +var View = require('./modules/View').default; +var InteractionHandler = require('./modules/InteractionHandler').default; +var SelectionHandler = require("./modules/SelectionHandler").default; +var LayoutEngine = require("./modules/LayoutEngine").default; +var ManipulationSystem = require("./modules/ManipulationSystem").default; +var Configurator = require("./../shared/Configurator").default; +var Validator = require("./../shared/Validator").default; +var {printStyle} = require('./../shared/Validator'); +var {allOptions, configureOptions} = require('./options.js'); +var KamadaKawai = require("./modules/KamadaKawai.js").default; /** - * @constructor Network * Create a network visualization, displaying nodes and edges. * * @param {Element} container The DOM element in which the Network will @@ -40,6 +37,7 @@ import KamadaKawai from "./modules/KamadaKawai.js" * {Array} nodes * {Array} edges * @param {Object} options Options + * @constructor Network */ function Network(container, data, options) { if (!(this instanceof Network)) { @@ -55,13 +53,27 @@ function Network(container, data, options) { }; util.extend(this.options, this.defaultOptions); - // containers for nodes and edges + /** + * Containers for nodes and edges. + * + * 'edges' and 'nodes' contain the full definitions of all the network elements. + * 'nodeIndices' and 'edgeIndices' contain the id's of the active elements. + * + * The distinction is important, because a defined node need not be active, i.e. + * visible on the canvas. This happens in particular when clusters are defined, in + * that case there will be nodes and edges not displayed. + * The bottom line is that all code with actions related to visibility, *must* use + * 'nodeIndices' and 'edgeIndices', not 'nodes' and 'edges' directly. + */ this.body = { container: container, + + // See comment above for following fields nodes: {}, nodeIndices: [], edges: {}, edgeIndices: [], + emitter: { on: this.on.bind(this), off: this.off.bind(this), @@ -233,7 +245,12 @@ Network.prototype.setOptions = function (options) { /** - * Update the this.body.nodeIndices with the most recent node index list + * Update the visible nodes and edges list with the most recent node state. + * + * Visible nodes are stored in this.body.nodeIndices. + * Visible edges are stored in this.body.edgeIndices. + * A node or edges is visible if it is not hidden or clustered. + * * @private */ Network.prototype._updateVisibleIndices = function () { @@ -244,7 +261,7 @@ Network.prototype._updateVisibleIndices = function () { for (let nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { - if (nodes[nodeId].options.hidden === false) { + if (!this.clustering._isClusteredNode(nodeId) && nodes[nodeId].options.hidden === false) { this.body.nodeIndices.push(nodes[nodeId].id); } } @@ -252,8 +269,23 @@ Network.prototype._updateVisibleIndices = function () { for (let edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { - if (edges[edgeId].options.hidden === false) { - this.body.edgeIndices.push(edges[edgeId].id); + let edge = edges[edgeId]; + + // It can happen that this is executed *after* a node edge has been removed, + // but *before* the edge itself has been removed. Taking this into account. + let fromNode = nodes[edge.fromId]; + let toNode = nodes[edge.toId]; + let edgeNodesPresent = (fromNode !== undefined) && (toNode !== undefined); + + let isVisible = + !this.clustering._isClusteredEdge(edgeId) + && edge.options.hidden === false + && edgeNodesPresent + && fromNode.options.hidden === false // Also hidden if any of its connecting nodes are hidden + && toNode.options.hidden === false; // idem + + if (isVisible) { + this.body.edgeIndices.push(edge.id); } } } @@ -264,18 +296,19 @@ Network.prototype._updateVisibleIndices = function () { * Bind all events */ Network.prototype.bindEventListeners = function () { - // this event will trigger a rebuilding of the cache everything. Used when nodes or edges have been added or removed. + // This event will trigger a rebuilding of the cache everything. + // Used when nodes or edges have been added or removed. this.body.emitter.on("_dataChanged", () => { - // update shortcut lists - this._updateVisibleIndices(); - this.body.emitter.emit("_requestRedraw"); - // call the dataUpdated event because the only difference between the two is the updating of the indices + this.edgesHandler._updateState(); this.body.emitter.emit("_dataUpdated"); }); // this is called when options of EXISTING nodes or edges have changed. this.body.emitter.on("_dataUpdated", () => { - // update values + // Order important in following block + this.clustering._updateState(); + this._updateVisibleIndices(); + this._updateValueRange(this.body.nodes); this._updateValueRange(this.body.edges); // start simulation (can be called safely, even if already running) @@ -370,9 +403,12 @@ Network.prototype.destroy = function () { delete this.images; for (var nodeId in this.body.nodes) { + if (!this.body.nodes.hasOwnProperty(nodeId)) continue; delete this.body.nodes[nodeId]; } + for (var edgeId in this.body.edges) { + if (!this.body.edges.hasOwnProperty(edgeId)) continue; delete this.body.edges[edgeId]; } diff --git a/lib/network/NetworkUtil.js b/lib/network/NetworkUtil.js index 07fa313f3..ec608c22a 100644 --- a/lib/network/NetworkUtil.js +++ b/lib/network/NetworkUtil.js @@ -1,9 +1,21 @@ let util = require("../util"); + +/** + * Utility Class + */ class NetworkUtil { + /** + * @ignore + */ constructor() {} /** * Find the center position of the network considering the bounding boxes + * + * @param {Array.} allNodes + * @param {Array.} [specificNodes=[]] + * @returns {{minX: number, maxX: number, minY: number, maxY: number}} + * @static */ static getRange(allNodes, specificNodes = []) { var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; @@ -33,6 +45,11 @@ class NetworkUtil { /** * Find the center position of the network + * + * @param {Array.} allNodes + * @param {Array.} [specificNodes=[]] + * @returns {{minX: number, maxX: number, minY: number, maxY: number}} + * @static */ static getRangeCore(allNodes, specificNodes = []) { var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; @@ -64,6 +81,7 @@ class NetworkUtil { /** * @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; * @returns {{x: number, y: number}} + * @static */ static findCenter(range) { return {x: (0.5 * (range.maxX + range.minX)), @@ -73,9 +91,10 @@ class NetworkUtil { /** * 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 item - * @param type + * @param {vis.Item} item + * @param {'node'|undefined} type * @returns {{}} + * @static */ static cloneOptions(item, type) { let clonedOptions = {}; diff --git a/lib/network/css/network-manipulation.css b/lib/network/css/network-manipulation.css index 89c1dc2cb..d40de077f 100644 --- a/lib/network/css/network-manipulation.css +++ b/lib/network/css/network-manipulation.css @@ -1,4 +1,6 @@ div.vis-network div.vis-manipulation { + box-sizing: content-box; + border-width: 0; border-bottom: 1px; border-style:solid; @@ -146,4 +148,4 @@ div.network-navigation_wrapper { width: 100%; height: 100%; } -*/ \ No newline at end of file +*/ diff --git a/lib/network/dotparser.js b/lib/network/dotparser.js index 6e38c9e8d..6b2740e1a 100644 --- a/lib/network/dotparser.js +++ b/lib/network/dotparser.js @@ -6,10 +6,33 @@ * * DOT language attributes: http://graphviz.org/content/attrs * - * @param {String} data Text containing a graph in DOT-notation + * @param {string} data Text containing a graph in DOT-notation * @return {Object} graph An object containing two parameters: * {Object[]} nodes * {Object[]} edges + * + * ------------------------------------------- + * TODO + * ==== + * + * For label handling, this is an incomplete implementation. From docs (quote #3015): + * + * > the escape sequences "\n", "\l" and "\r" divide the label into lines, centered, + * > left-justified, and right-justified, respectively. + * + * Source: http://www.graphviz.org/content/attrs#kescString + * + * > As another aid for readability, dot allows double-quoted strings to span multiple physical + * > lines using the standard C convention of a backslash immediately preceding a newline + * > character + * > In addition, double-quoted strings can be concatenated using a '+' operator. + * > As HTML strings can contain newline characters, which are used solely for formatting, + * > the language does not allow escaped newlines or concatenation operators to be used + * > within them. + * + * - Currently, only '\\n' is handled + * - Note that text explicitly says 'labels'; the dot parser currently handles escape + * sequences in **all** strings. */ function parseDOT (data) { dot = data; @@ -29,6 +52,7 @@ var NODE_ATTR_MAPPING = { }; var EDGE_ATTR_MAPPING = Object.create(NODE_ATTR_MAPPING); EDGE_ATTR_MAPPING.color = 'color.color'; +EDGE_ATTR_MAPPING.style = 'dashes'; // token types enumeration var TOKENTYPE = { @@ -80,18 +104,18 @@ function next() { /** * Preview the next character from the dot file. - * @return {String} cNext + * @return {string} cNext */ function nextPreview() { return dot.charAt(index + 1); } +var regexAlphaNumeric = /[a-zA-Z_0-9.:#]/; /** * Test whether given character is alphabetic or numeric - * @param {String} c + * @param {string} c * @return {Boolean} isAlphaNumeric */ -var regexAlphaNumeric = /[a-zA-Z_0-9.:#]/; function isAlphaNumeric(c) { return regexAlphaNumeric.test(c); } @@ -125,7 +149,7 @@ function merge (a, b) { * setValue(obj, 'b.c', 3); // obj = {a: 2, b: {c: 3}} * * @param {Object} obj - * @param {String} path A parameter name or dot-separated parameter path, + * @param {string} path A parameter name or dot-separated parameter path, * like "color.highlight.border". * @param {*} value */ @@ -224,9 +248,9 @@ function addEdge(graph, edge) { /** * Create an edge to a graph object * @param {Object} graph - * @param {String | Number | Object} from - * @param {String | Number | Object} to - * @param {String} type + * @param {string | number | Object} from + * @param {string | number | Object} to + * @param {string} type * @param {Object | null} attr * @return {Object} edge */ @@ -358,9 +382,14 @@ function getToken() { if (c === '"') { next(); while (c != '' && (c != '"' || (c === '"' && nextPreview() === '"'))) { - token += c; - if (c === '"') { // skip the escape character + if (c === '"') { // skip the escape character + token += c; + next(); + } else if (c === '\\' && nextPreview() === 'n') { // Honor a newline escape sequence + token += '\n'; next(); + } else { + token += c; } next(); } @@ -592,7 +621,7 @@ function parseAttributeStatement (graph) { /** * parse a node statement * @param {Object} graph - * @param {String | Number} id + * @param {string | number} id */ function parseNodeStatement(graph, id) { // node statement @@ -612,7 +641,7 @@ function parseNodeStatement(graph, id) { /** * Parse an edge or a series of edges * @param {Object} graph - * @param {String | Number} from Id of the from node + * @param {string | number} from Id of the from node */ function parseEdge(graph, from) { while (token === '->' || token === '--') { @@ -654,6 +683,13 @@ function parseEdge(graph, from) { function parseAttributeList() { var attr = null; + // edge styles of dot and vis + var edgeStyles = { + 'dashed': true, + 'solid': false, + 'dotted': [1, 5] + }; + while (token === '[') { getToken(); attr = {}; @@ -673,6 +709,12 @@ function parseAttributeList() { throw newSyntaxError('Attribute value expected'); } var value = token; + + // convert from dot style to vis + if (name === 'style') { + value = edgeStyles[value]; + } + setValue(attr, name, value); // name can be a path getToken(); @@ -692,7 +734,7 @@ function parseAttributeList() { /** * Create a syntax error with extra information on current token and index. - * @param {String} message + * @param {string} message * @returns {SyntaxError} err */ function newSyntaxError(message) { @@ -701,8 +743,8 @@ function newSyntaxError(message) { /** * Chop off text after a maximum length - * @param {String} text - * @param {Number} maxLength + * @param {string} text + * @param {number} maxLength * @returns {String} */ function chop (text, maxLength) { @@ -801,7 +843,7 @@ function convertAttr (attr, mapping) { /** * Convert a string containing a graph in DOT language into a map containing * with nodes and edges in the format of graph. - * @param {String} data Text containing a graph in DOT-notation + * @param {string} data Text containing a graph in DOT-notation * @return {Object} graphData */ function DOTToGraph (data) { @@ -857,7 +899,6 @@ function DOTToGraph (data) { } } - // TODO: support of solid/dotted/dashed edges (attr = 'style') // TODO: support for attributes 'dir' and 'arrowhead' (edge arrows) if (dotEdge.to instanceof Object) { diff --git a/lib/network/gephiParser.js b/lib/network/gephiParser.js index 124d6b454..807cffbc7 100644 --- a/lib/network/gephiParser.js +++ b/lib/network/gephiParser.js @@ -1,4 +1,9 @@ - +/** + * + * @param {json} gephiJSON + * @param {obj} optionsObj + * @returns {{nodes: Array, edges: Array}} + */ function parseGephi(gephiJSON, optionsObj) { var edges = []; var nodes = []; @@ -40,16 +45,15 @@ function parseGephi(gephiJSON, optionsObj) { edges.push(edge); } - for (var i = 0; i < gNodes.length; i++) { + for (var j = 0; j < gNodes.length; j++) { var node = {}; - var gNode = gNodes[i]; + var gNode = gNodes[j]; node['id'] = gNode.id; node['attributes'] = gNode.attributes; - node['title'] = gNode.title; node['x'] = gNode.x; node['y'] = gNode.y; node['label'] = gNode.label; - node['title'] = gNode.attributes !== undefined ? gNode.attributes.title : undefined; + node['title'] = gNode.attributes !== undefined ? gNode.attributes.title : gNode.title; if (options.nodes.parseColor === true) { node['color'] = gNode.color; } @@ -64,4 +68,4 @@ function parseGephi(gephiJSON, optionsObj) { return {nodes:nodes, edges:edges}; } -exports.parseGephi = parseGephi; \ No newline at end of file +exports.parseGephi = parseGephi; diff --git a/lib/network/locales.js b/lib/network/locales.js index e71a971f0..1abbe4149 100644 --- a/lib/network/locales.js +++ b/lib/network/locales.js @@ -127,3 +127,21 @@ exports['ru'] = { editClusterError: 'Кластеры недоступны для редактирования.' }; exports['ru_RU'] = exports['ru']; + +// Chinese +exports['cn'] = { + edit: '编辑', + del: '删除选定', + back: '返回', + addNode: '添加节点', + addEdge: '添加连接线', + editNode: '编辑节点', + editEdge: '编辑连接线', + addDescription: '单击空白处放置新节点。', + edgeDescription: '单击某个节点并将该连接线拖动到另一个节点以连接它们。', + editEdgeDescription: '单击控制节点并将它们拖到节点上连接。', + createEdgeError: '无法将连接线连接到群集。', + deleteClusterError: '无法删除群集。', + editClusterError: '无法编辑群集。' +}; +exports['zh_CN'] = exports['cn']; diff --git a/lib/network/modules/Canvas.js b/lib/network/modules/Canvas.js index c378c1837..753e56cb9 100644 --- a/lib/network/modules/Canvas.js +++ b/lib/network/modules/Canvas.js @@ -8,9 +8,11 @@ let util = require('../../util'); * 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 { + /** + * @param {Object} body + */ constructor(body) { this.body = body; this.pixelRatio = 1; @@ -31,6 +33,9 @@ class Canvas { this.bindEventListeners(); } + /** + * Binds event listeners + */ bindEventListeners() { // bind the events this.body.emitter.once("resize", (obj) => { @@ -47,10 +52,11 @@ class Canvas { this.hammer.destroy(); this._cleanUp(); }); - - } + /** + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { let fields = ['width','height','autoResize']; @@ -71,6 +77,9 @@ class Canvas { } } + /** + * @private + */ _cleanUp() { // automatically adapt to a changing size of the browser. if (this.resizeTimer !== undefined) { @@ -80,6 +89,9 @@ class Canvas { this.resizeFunction = undefined; } + /** + * @private + */ _onResize() { this.setSize(); this.body.emitter.emit("_redraw"); @@ -87,6 +99,8 @@ class Canvas { /** * Get and store the cameraState + * + * @param {number} [pixelRatio=this.pixelRatio] * @private */ _getCameraState(pixelRatio = this.pixelRatio) { @@ -142,6 +156,12 @@ class Canvas { } } + /** + * + * @param {number|string} value + * @returns {string} + * @private + */ _prepareValue(value) { if (typeof value === 'number') { return value + 'px'; @@ -188,14 +208,8 @@ class 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); + this._setPixelRatio(); + this.setTransform(); } // add the frame to the container element @@ -252,6 +266,7 @@ class Canvas { * or '50%') * @param {string} height Height in pixels or percentage (for example '400px' * or '30%') + * @returns {boolean} */ setSize(width = this.options.width, height = this.options.height) { width = this._prepareValue(width); @@ -262,13 +277,19 @@ class Canvas { let oldHeight = this.frame.canvas.height; // update the pixel ratio - let ctx = this.frame.canvas.getContext("2d"); + // + // NOTE: Comment in following is rather inconsistent; this is the ONLY place in the code + // where it is assumed that the pixel ratio could change at runtime. + // The only way I can think of this happening is a rotating screen or tablet; but then + // there should be a mechanism for reloading the data (TODO: check if this is present). + // + // If the assumption is true (i.e. pixel ratio can change at runtime), then *all* usage + // of pixel ratio must be overhauled for this. + // + // For the time being, I will humor the assumption here, and in the rest of the code assume it is + // constant. let previousRatio = this.pixelRatio; // we cache this because the camera state storage needs the old value - this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || - ctx.mozBackingStorePixelRatio || - ctx.msBackingStorePixelRatio || - ctx.oBackingStorePixelRatio || - ctx.backingStorePixelRatio || 1); + this._setPixelRatio(); if (width != this.options.width || height != this.options.height || this.frame.style.width != width || this.frame.style.height != height) { this._getCameraState(previousRatio); @@ -296,26 +317,29 @@ class Canvas { // this would adapt the width of the canvas to the width from 100% if and only if // there is a change. + let newWidth = Math.round(this.frame.canvas.clientWidth * this.pixelRatio); + let newHeight = Math.round(this.frame.canvas.clientHeight * this.pixelRatio); + // store the camera if there is a change in size. - if (this.frame.canvas.width != Math.round(this.frame.canvas.clientWidth * this.pixelRatio) || this.frame.canvas.height != Math.round(this.frame.canvas.clientHeight * this.pixelRatio)) { + if (this.frame.canvas.width !== newWidth || this.frame.canvas.height !== newHeight) { this._getCameraState(previousRatio); } - 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); + if (this.frame.canvas.width !== newWidth) { + this.frame.canvas.width = newWidth; 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); + if (this.frame.canvas.height !== newHeight) { + this.frame.canvas.height = newHeight; 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), + 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) }); @@ -327,7 +351,63 @@ class Canvas { // set initialized so the get and set camera will work from now on. this.initialized = true; return emitEvent; - }; + } + + /** + * + * @returns {CanvasRenderingContext2D} + */ + getContext() { + return this.frame.canvas.getContext("2d"); + } + + /** + * Determine the pixel ratio for various browsers. + * + * @returns {number} + * @private + */ + _determinePixelRatio() { + let ctx = this.getContext(); + if (ctx === undefined) { + throw new Error("Could not get canvax context"); + } + + var numerator = 1; + if(typeof window !== 'undefined') { // (window !== undefined) doesn't work here! + // Protection during unit tests, where 'window' can be missing + numerator = (window.devicePixelRatio || 1); + } + + var denominator = (ctx.webkitBackingStorePixelRatio || + ctx.mozBackingStorePixelRatio || + ctx.msBackingStorePixelRatio || + ctx.oBackingStorePixelRatio || + ctx.backingStorePixelRatio || 1); + + return numerator / denominator; + } + + /** + * Lazy determination of pixel ratio. + * + * @private + */ + _setPixelRatio() { + this.pixelRatio = this._determinePixelRatio(); + } + + /** + * Set the transform in the contained context, based on its pixelRatio + */ + setTransform() { + let ctx = this.getContext(); + if (ctx === undefined) { + throw new Error("Could not get canvax context"); + } + + ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); + } /** @@ -376,10 +456,8 @@ class Canvas { /** - * - * @param {object} pos = {x: number, y: number} - * @returns {{x: number, y: number}} - * @constructor + * @param {point} pos + * @returns {point} */ canvasToDOM (pos) { return {x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y)}; @@ -387,9 +465,8 @@ class Canvas { /** * - * @param {object} pos = {x: number, y: number} - * @returns {{x: number, y: number}} - * @constructor + * @param {point} pos + * @returns {point} */ DOMtoCanvas (pos) { return {x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y)}; @@ -397,4 +474,4 @@ class Canvas { } -export default Canvas; \ No newline at end of file +export default Canvas; diff --git a/lib/network/modules/CanvasRenderer.js b/lib/network/modules/CanvasRenderer.js index a6c4ae1b2..56b9e5183 100644 --- a/lib/network/modules/CanvasRenderer.js +++ b/lib/network/modules/CanvasRenderer.js @@ -1,13 +1,58 @@ -if (typeof window !== 'undefined') { - window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || - window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; +/** + * Initializes window.requestAnimationFrame() to a usable form. + * + * Specifically, set up this method for the case of running on node.js with jsdom enabled. + * + * NOTES: + * + * * On node.js, when calling this directly outside of this class, `window` is not defined. + * This happens even if jsdom is used. + * * For node.js + jsdom, `window` is available at the moment the constructor is called. + * For this reason, the called is placed within the constructor. + * * Even then, `window.requestAnimationFrame()` is not defined, so it still needs to be added. + * * During unit testing, it happens that the window object is reset during execution, causing + * a runtime error due to missing `requestAnimationFrame()`. This needs to be compensated for, + * see `_requestNextFrame()`. + * * Since this is a global object, it may affect other modules besides `Network`. With normal + * usage, this does not cause any problems. During unit testing, errors may occur. These have + * been compensated for, see comment block in _requestNextFrame(). + * + * @private + */ +function _initRequestAnimationFrame() { + var func; + + if (window !== undefined) { + func = window.requestAnimationFrame + || window.mozRequestAnimationFrame + || window.webkitRequestAnimationFrame + || window.msRequestAnimationFrame; + } + + if (func === undefined) { + // window or method not present, setting mock requestAnimationFrame + window.requestAnimationFrame = + function(callback) { + //console.log("Called mock requestAnimationFrame"); + callback(); + } + } else { + window.requestAnimationFrame = func; + } } let util = require('../../util'); - +/** + * The canvas renderer + */ class CanvasRenderer { + /** + * @param {Object} body + * @param {Canvas} canvas + */ constructor(body, canvas) { + _initRequestAnimationFrame(); this.body = body; this.canvas = canvas; @@ -16,7 +61,6 @@ class CanvasRenderer { this.requiresTimeout = true; this.renderingActive = false; this.renderRequests = 0; - this.pixelRatio = undefined; this.allowRedraw = true; this.dragging = false; @@ -31,12 +75,13 @@ class CanvasRenderer { this.bindEventListeners(); } + /** + * Binds event listeners + */ 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("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(); @@ -63,13 +108,17 @@ class CanvasRenderer { clearTimeout(this.renderTimer); } else { - cancelAnimationFrame(this.renderTimer); + window.cancelAnimationFrame(this.renderTimer); } this.body.emitter.off(); }); } + /** + * + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { let fields = ['hideEdgesOnDrag','hideNodesOnDrag']; @@ -77,19 +126,64 @@ class CanvasRenderer { } } + + /** + * Prepare the drawing of the next frame. + * + * Calls the callback when the next frame can or will be drawn. + * + * @param {function} callback + * @param {number} delay - timeout case only, wait this number of milliseconds + * @returns {function|undefined} + * @private + */ + _requestNextFrame(callback, delay) { + // During unit testing, it happens that the mock window object is reset while + // the next frame is still pending. Then, either 'window' is not present, or + // 'requestAnimationFrame()' is not present because it is not defined on the + // mock window object. + // + // As a consequence, unrelated unit tests may appear to fail, even if the problem + // described happens in the current unit test. + // + // This is not something that will happen in normal operation, but we still need + // to take it into account. + // + if (typeof window === 'undefined') return; // Doing `if (window === undefined)` does not work here! + + let timer; + + var myWindow = window; // Grab a reference to reduce the possibility that 'window' is reset + // while running this method. + + if (this.requiresTimeout === true) { + // wait given number of milliseconds and perform the animation step function + timer = myWindow.setTimeout(callback, delay); + } else { + if (myWindow.requestAnimationFrame) { + timer = myWindow.requestAnimationFrame(callback); + } + } + + return timer; + } + + /** + * + * @private + */ _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 - } + this.renderTimer = this._requestNextFrame(this._renderStep.bind(this), this.simulationInterval); } } } + /** + * + * @private + */ _renderStep() { if (this.renderingActive === true) { // reset the renderTimer so a new scheduled animation step can be set @@ -120,40 +214,35 @@ class CanvasRenderer { /** * 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);}); - } + this._requestNextFrame(() => {this._redraw(false);}, 0); } } + /** + * Redraw the network with the current data + * @param {boolean} [hidden=false] | Used to get the first estimate of the node sizes. + * Only the nodes are drawn after which they are quickly drawn over. + * @private + */ _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(); } - this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || - ctx.mozBackingStorePixelRatio || - ctx.msBackingStorePixelRatio || - ctx.oBackingStorePixelRatio || - ctx.backingStorePixelRatio || 1); + this.canvas.setTransform(); - ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); + let ctx = this.canvas.getContext(); // clear the canvas let w = this.canvas.frame.canvas.clientWidth; @@ -200,21 +289,14 @@ class CanvasRenderer { /** * Redraw all nodes - * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d'); + * * @param {CanvasRenderingContext2D} ctx - * @param {Boolean} [alwaysShow] + * @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); + this.canvas.setTransform(); + let ctx = this.canvas.getContext(); ctx.save(); ctx.translate(this.body.view.translation.x, this.body.view.translation.y); ctx.scale(this.body.view.scale, this.body.view.scale); @@ -237,9 +319,9 @@ class CanvasRenderer { /** * Redraw all nodes - * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d'); - * @param {CanvasRenderingContext2D} ctx - * @param {Boolean} [alwaysShow] + * + * @param {CanvasRenderingContext2D} ctx 2D context of a HTML canvas + * @param {boolean} [alwaysShow] * @private */ _drawNodes(ctx, alwaysShow = false) { @@ -285,8 +367,7 @@ class CanvasRenderer { /** * Redraw all edges - * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d'); - * @param {CanvasRenderingContext2D} ctx + * @param {CanvasRenderingContext2D} ctx 2D context of a HTML canvas * @private */ _drawEdges(ctx) { @@ -324,7 +405,6 @@ class CanvasRenderer { this.requiresTimeout = true; } } - } -export default CanvasRenderer; \ No newline at end of file +export default CanvasRenderer; diff --git a/lib/network/modules/Clustering.js b/lib/network/modules/Clustering.js index 3de5198ed..6d9a1a59e 100644 --- a/lib/network/modules/Clustering.js +++ b/lib/network/modules/Clustering.js @@ -1,12 +1,112 @@ -let util = require("../../util"); -import NetworkUtil from '../NetworkUtil'; -import Cluster from './components/nodes/Cluster' +/* =========================================================================== + +# TODO + +- `edgeReplacedById` not cleaned up yet on cluster edge removal +- allowSingleNodeCluster could be a global option as well; currently needs to always + be passed to clustering methods + +---------------------------------------------- + +# State Model for Clustering + +The total state for clustering is non-trivial. It is useful to have a model +available as to how it works. The following documents the relevant state items. + + +## Network State + +The following `network`-members are relevant to clustering: + +- `body.nodes` - all nodes actively participating in the network +- `body.edges` - same for edges +- `body.nodeIndices` - id's of nodes that are visible at a given moment +- `body.edgeIndices` - same for edges + +This includes: + +- helper nodes for dragging in `manipulation` +- helper nodes for edge type `dynamic` +- cluster nodes and edges +- there may be more than this. + +A node/edge may be missing in the `Indices` member if: + +- it is a helper node +- the node or edge state has option `hidden` set +- It is not visible due to clustering + + +## Clustering State + +For the hashes, the id's of the nodes/edges are used as key. + +Member `network.clustering` contains the following items: + +- `clusteredNodes` - hash with values: { clusterId: , node: } +- `clusteredEdges` - hash with values: restore information for given edge + + +Due to nesting of clusters, these members can contain cluster nodes and edges as well. + +The important thing to note here, is that the clustered nodes and edges also +appear in the members of the cluster nodes. For data update, it is therefore +important to scan these lists as well as the cluster nodes. + + +### Cluster Node + +A cluster node has the following extra fields: + +- `isCluster : true` - indication that this is a cluster node +- `containedNodes` - hash of nodes contained in this cluster +- `containedEdges` - same for edges +- `edges` - array of cluster edges for this node + + +**NOTE:** + +- `containedEdges` can also contain edges which are not clustered; e.g. an edge + connecting two nodes in the same cluster. + + +### Cluster Edge + +These are the items in the `edges` member of a clustered node. They have the +following relevant members: + +- 'clusteringEdgeReplacingIds` - array of id's of edges replaced by this edge + +Note that it's possible to nest clusters, so that `clusteringEdgeReplacingIds` +can contain edge id's of other clusters. + +### Clustered Edge + +This is any edge contained by a cluster edge. It gets the following additional +member: + +- `edgeReplacedById` - id of the cluster edge in which current edge is clustered + + + =========================================================================== */ +let util = require("../../util"); +var NetworkUtil = require('../NetworkUtil').default; +var Cluster = require('./components/nodes/Cluster').default; +var Edge = require('./components/Edge').default; // Only needed for check on type! +var Node = require('./components/Node').default; // Only needed for check on type! + +/** + * The clustering engine + */ class ClusterEngine { + /** + * @param {Object} body + */ constructor(body) { this.body = body; - this.clusteredNodes = {}; - this.clusteredEdges = {}; + this.clusteredNodes = {}; // key: node id, value: { clusterId: , node: } + this.clusteredEdges = {}; // key: edge id, value: restore information for given edge this.options = {}; this.defaultOptions = {}; @@ -17,8 +117,8 @@ class ClusterEngine { /** * - * @param hubsize - * @param options + * @param {number} hubsize + * @param {Object} options */ clusterByHubsize(hubsize, options) { if (hubsize === undefined) { @@ -46,10 +146,10 @@ class ClusterEngine { /** - * loop over all nodes, check if they adhere to the condition and cluster if needed. - * @param options - * @param refreshData - */ + * loop over all nodes, check if they adhere to the condition and cluster if needed. + * @param {Object} options + * @param {boolean} [refreshData=true] + */ cluster(options = {}, refreshData = true) { if (options.joinCondition === undefined) {throw new Error("Cannot call clusterByNodeData without a joinCondition function in the options.");} @@ -60,22 +160,19 @@ class ClusterEngine { 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]; + util.forEach(this.body.nodes, (node, nodeId) => { let clonedOptions = NetworkUtil.cloneOptions(node); if (options.joinCondition(clonedOptions) === true) { - childNodesObj[nodeId] = this.body.nodes[nodeId]; + childNodesObj[nodeId] = node; - // collect the nodes that will be in the cluster - for (let i = 0; i < node.edges.length; i++) { - let edge = node.edges[i]; + // collect the edges that will be in the cluster + util.forEach(node.edges, (edge) => { if (this.clusteredEdges[edge.id] === undefined) { childEdgesObj[edge.id] = edge; } - } + }); } - } + }); this._cluster(childNodesObj, childEdgesObj, options, refreshData); } @@ -83,25 +180,25 @@ class ClusterEngine { /** * Cluster all nodes in the network that have only X edges - * @param edgeCount - * @param options - * @param refreshData + * @param {number} edgeCount + * @param {Object} options + * @param {boolean} [refreshData=true] */ clusterByEdgeCount(edgeCount, options, refreshData = true) { options = this._checkOptions(options); let clusters = []; let usedNodes = {}; - let edge, edges, node, nodeId, relevantEdgeCount; + let edge, edges, relevantEdgeCount; // collect the nodes that will be in the cluster for (let i = 0; i < this.body.nodeIndices.length; i++) { let childNodesObj = {}; let childEdgesObj = {}; - nodeId = this.body.nodeIndices[i]; + let nodeId = this.body.nodeIndices[i]; + let node = this.body.nodes[nodeId]; // if this node is already used in another cluster this session, we do not have to re-evaluate it. if (usedNodes[nodeId] === undefined) { relevantEdgeCount = 0; - node = this.body.nodes[nodeId]; edges = []; for (let j = 0; j < node.edges.length; j++) { edge = node.edges[j]; @@ -115,35 +212,73 @@ class ClusterEngine { // this node qualifies, we collect its neighbours to start the clustering process. if (relevantEdgeCount === edgeCount) { + var checkJoinCondition = function(node) { + if (options.joinCondition === undefined || options.joinCondition === null) { + return true; + } + + let clonedOptions = NetworkUtil.cloneOptions(node); + return options.joinCondition(clonedOptions); + } + let gatheringSuccessful = true; for (let j = 0; j < edges.length; j++) { edge = edges[j]; let childNodeId = this._getConnectedId(edge, nodeId); // add the nodes to the list by the join condition. - if (options.joinCondition === undefined) { + if (checkJoinCondition(node)) { childEdgesObj[edge.id] = edge; - childNodesObj[nodeId] = this.body.nodes[nodeId]; + childNodesObj[nodeId] = node; childNodesObj[childNodeId] = this.body.nodes[childNodeId]; usedNodes[nodeId] = true; - } - else { - let clonedOptions = NetworkUtil.cloneOptions(this.body.nodes[nodeId]); - if (options.joinCondition(clonedOptions) === true) { - childEdgesObj[edge.id] = edge; - childNodesObj[nodeId] = this.body.nodes[nodeId]; - usedNodes[nodeId] = true; - } - else { - // this node does not qualify after all. - gatheringSuccessful = false; - break; - } + } else { + // this node does not qualify after all. + gatheringSuccessful = false; + break; } } // add to the cluster queue if (Object.keys(childNodesObj).length > 0 && Object.keys(childEdgesObj).length > 0 && gatheringSuccessful === true) { - clusters.push({nodes: childNodesObj, edges: childEdgesObj}) + /** + * Search for cluster data that contains any of the node id's + * @returns {Boolean} true if no joinCondition, otherwise return value of joinCondition + */ + var findClusterData = function() { + for (let n = 0; n < clusters.length; ++n) { + // Search for a cluster containing any of the node id's + for (var m in childNodesObj) { + if (clusters[n].nodes[m] !== undefined) { + return clusters[n]; + } + } + } + + return undefined; + }; + + + // If any of the found nodes is part of a cluster found in this method, + // add the current values to that cluster + var foundCluster = findClusterData(); + if (foundCluster !== undefined) { + // Add nodes to found cluster if not present + for (let m in childNodesObj) { + if (foundCluster.nodes[m] === undefined) { + foundCluster.nodes[m] = childNodesObj[m]; + } + } + + // Add edges to found cluster, if not present + for (let m in childEdgesObj) { + if (foundCluster.edges[m] === undefined) { + foundCluster.edges[m] = childEdgesObj[m]; + } + } + } else { + // Create a new cluster group + clusters.push({nodes: childNodesObj, edges: childEdgesObj}) + } } } } @@ -159,18 +294,18 @@ class ClusterEngine { } /** - * Cluster all nodes in the network that have only 1 edge - * @param options - * @param refreshData - */ + * Cluster all nodes in the network that have only 1 edge + * @param {Object} options + * @param {boolean} [refreshData=true] + */ clusterOutliers(options, refreshData = true) { this.clusterByEdgeCount(1,options,refreshData); } /** * Cluster all nodes in the network that have only 2 edge - * @param options - * @param refreshData + * @param {Object} options + * @param {boolean} [refreshData=true] */ clusterBridges(options, refreshData = true) { this.clusterByEdgeCount(2,options,refreshData); @@ -180,9 +315,9 @@ class ClusterEngine { /** * suck all connected nodes of a node into the node. - * @param nodeId - * @param options - * @param refreshData + * @param {Node.id} nodeId + * @param {Object} options + * @param {boolean} [refreshData=true] */ clusterByConnection(nodeId, options, refreshData = true) { // kill conditions @@ -240,6 +375,8 @@ class ClusterEngine { }) for (childNode in childNodesObj) { + if (!childNodesObj.hasOwnProperty(childNode)) continue; + var childNode = childNodesObj[childNode]; for (var y=0; y < childNode.edges.length; y++){ var childEdge = childNode.edges[y]; @@ -256,10 +393,10 @@ class ClusterEngine { * This function creates the edges that will be attached to the cluster * It looks for edges that are connected to the nodes from the "outside' of the cluster. * - * @param childNodesObj - * @param childEdgesObj - * @param clusterNodeProperties - * @param clusterEdgeProperties + * @param {{Node.id: vis.Node}} childNodesObj + * @param {{vis.Edge.id: vis.Edge}} childEdgesObj + * @param {Object} clusterNodeProperties + * @param {Object} clusterEdgeProperties * @private */ _createClusterEdges (childNodesObj, childEdgesObj, clusterNodeProperties, clusterEdgeProperties) { @@ -304,43 +441,70 @@ class ClusterEngine { } } - // here we actually create the replacement edges. We could not do this in the loop above as the creation process + + // + // Here we actually create the replacement edges. + // + // We could not do this in the loop above as the creation process // would add an edge to the edges array we are iterating over. + // + // NOTE: a clustered edge can have multiple base edges! + // + var newEdges = []; + + /** + * Find a cluster edge which matches the given created edge. + * @param {vis.Edge} createdEdge + * @returns {vis.Edge} + */ + var getNewEdge = function(createdEdge) { + for (let j = 0; j < newEdges.length; j++) { + let newEdge = newEdges[j]; + + // We replace both to and from edges with a single cluster edge + let matchToDirection = (createdEdge.fromId === newEdge.fromId && createdEdge.toId === newEdge.toId); + let matchFromDirection = (createdEdge.fromId === newEdge.toId && createdEdge.toId === newEdge.fromId); + + if (matchToDirection || matchFromDirection ) { + return newEdge; + } + } + + return null; + }; + + for (let j = 0; j < createEdges.length; j++) { - let edge = createEdges[j].edge; - // copy the options of the edge we will replace - let clonedOptions = NetworkUtil.cloneOptions(edge, 'edge'); - // make sure the properties of clusterEdges are superimposed on it - util.deepExtend(clonedOptions, clusterEdgeProperties); - - // set up the edge - clonedOptions.from = createEdges[j].fromId; - clonedOptions.to = createEdges[j].toId; - clonedOptions.id = 'clusterEdge:' + util.randomUUID(); - //clonedOptions.id = '(cf: ' + createEdges[j].fromId + " to: " + createEdges[j].toId + ")" + Math.random(); - - // create the edge and give a reference to the one it replaced. - let newEdge = this.body.functions.createEdge(clonedOptions); - newEdge.clusteringEdgeReplacingId = edge.id; + let createdEdge = createEdges[j]; + let edge = createdEdge.edge; + let newEdge = getNewEdge(createdEdge); + + if (newEdge === null) { + // Create a clustered edge for this connection + newEdge = this._createClusteredEdge( + createdEdge.fromId, + createdEdge.toId, + edge, + clusterEdgeProperties); + + newEdges.push(newEdge); + } else { + newEdge.clusteringEdgeReplacingIds.push(edge.id); + } // also reference the new edge in the old edge this.body.edges[edge.id].edgeReplacedById = newEdge.id; - // connect the edge. - this.body.edges[newEdge.id] = newEdge; - newEdge.connect(); - // hide the replaced edge this._backupEdgeOptions(edge); - edge.setOptions({physics:false, hidden:true}); + edge.setOptions({physics:false}); } - } /** * This function checks the options that can be supplied to the different cluster functions * for certain fields and inserts defaults if needed - * @param options + * @param {Object} options * @returns {*} * @private */ @@ -356,25 +520,30 @@ class ClusterEngine { * @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 + * @param {boolean} refreshData | when true, do not wrap up * @private */ _cluster(childNodesObj, childEdgesObj, options, refreshData = true) { - // kill condition: no nodes don't bother - if (Object.keys(childNodesObj).length == 0) {return;} - - // allow clusters of 1 if options allow - if (Object.keys(childNodesObj).length == 1 && options.clusterNodeProperties.allowSingleNodeCluster != true) {return;} - - // check if this cluster call is not trying to cluster anything that is in another cluster. + // Remove nodes which are already clustered + var tmpNodesToRemove = [] for (let nodeId in childNodesObj) { if (childNodesObj.hasOwnProperty(nodeId)) { if (this.clusteredNodes[nodeId] !== undefined) { - return; + tmpNodesToRemove.push(nodeId); } } } + for (var n = 0; n < tmpNodesToRemove.length; ++n) { + delete childNodesObj[tmpNodesToRemove[n]]; + } + + // kill condition: no nodes don't bother + if (Object.keys(childNodesObj).length == 0) {return;} + + // allow clusters of 1 if options allow + if (Object.keys(childNodesObj).length == 1 && options.clusterNodeProperties.allowSingleNodeCluster != true) {return;} + let clusterNodeProperties = util.deepExtend({},options.clusterNodeProperties); // construct the clusterNodeProperties @@ -429,9 +598,9 @@ class ClusterEngine { // force the ID to remain the same clusterNodeProperties.id = clusterId; - // create the clusterNode + // create the cluster Node + // Note that allowSingleNodeCluster, if present, is stored in the options as well 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 @@ -440,29 +609,7 @@ class ClusterEngine { // finally put the cluster node into global this.body.nodes[clusterNodeProperties.id] = clusterNode; - // create the new edges that will connect to the cluster, all self-referencing edges will be added to childEdgesObject here. - this._createClusterEdges(childNodesObj, childEdgesObj, 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]; - // cache the options before changing - this._backupEdgeOptions(edge); - // disable physics and hide the edge - edge.setOptions({physics:false, 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].setOptions({hidden:true, physics:false}); - } - } + this._clusterEdges(childNodesObj, childEdgesObj, clusterNodeProperties, options.clusterEdgeProperties); // set ID to undefined so no duplicates arise clusterNodeProperties.id = undefined; @@ -473,16 +620,26 @@ class ClusterEngine { } } + /** + * + * @param {Edge} edge + * @private + */ _backupEdgeOptions(edge) { if (this.clusteredEdges[edge.id] === undefined) { - this.clusteredEdges[edge.id] = {physics: edge.options.physics, hidden: edge.options.hidden}; + this.clusteredEdges[edge.id] = {physics: edge.options.physics}; } } + /** + * + * @param {Edge} edge + * @private + */ _restoreEdge(edge) { let originalOptions = this.clusteredEdges[edge.id]; if (originalOptions !== undefined) { - edge.setOptions({physics: originalOptions.physics, hidden: originalOptions.hidden}); + edge.setOptions({physics: originalOptions.physics}); delete this.clusteredEdges[edge.id]; } } @@ -490,7 +647,7 @@ class ClusterEngine { /** * Check if a node is a cluster. - * @param nodeId + * @param {Node.id} nodeId * @returns {*} */ isCluster(nodeId) { @@ -531,19 +688,49 @@ class ClusterEngine { /** - * 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 - */ + * Open a cluster by calling this function. + * @param {vis.Edge.id} clusterNodeId | the ID of the cluster node + * @param {Object} options + * @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 + if (clusterNodeId === undefined) { + throw new Error("No clusterNodeId supplied to openCluster."); } + let clusterNode = this.body.nodes[clusterNodeId]; + + if (clusterNode === undefined) { + throw new Error("The clusterNodeId supplied to openCluster does not exist."); + } + if (clusterNode.isCluster !== true + || clusterNode.containedNodes === undefined + || clusterNode.containedEdges === undefined) { + throw new Error("The node:" + clusterNodeId + " is not a valid cluster."); + } + + // Check if current cluster is clustered itself + let stack = this.findNode(clusterNodeId); + let parentIndex = stack.indexOf(clusterNodeId) - 1; + if (parentIndex >= 0) { + // Current cluster is clustered; transfer contained nodes and edges to parent + let parentClusterNodeId = stack[parentIndex]; + let parentClusterNode = this.body.nodes[parentClusterNodeId]; + + // clustering.clusteredNodes and clustering.clusteredEdges remain unchanged + parentClusterNode._openChildCluster(clusterNodeId); + + // All components of child cluster node have been transferred. It can die now. + delete this.body.nodes[clusterNodeId]; + if (refreshData === true) { + this.body.emitter.emit('_dataChanged'); + } + + return; + } + + // main body let containedNodes = clusterNode.containedNodes; let containedEdges = clusterNode.containedEdges; @@ -571,15 +758,11 @@ class ClusterEngine { } 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 - if (containedNode.options.fixed.x === false) {containedNode.x = clusterNode.x;} - if (containedNode.options.fixed.y === false) {containedNode.y = clusterNode.y;} - } - } + util.forEach(containedNodes, function(containedNode) { + // inherit position + if (containedNode.options.fixed.x === false) {containedNode.x = clusterNode.x;} + if (containedNode.options.fixed.y === false) {containedNode.y = clusterNode.y;} + }); } // release nodes @@ -591,8 +774,7 @@ class ClusterEngine { containedNode.vx = clusterNode.vx; containedNode.vy = clusterNode.vy; - // we use these methods to avoid re-instantiating the shape, which happens with setOptions. - containedNode.setOptions({hidden:false, physics:true}); + containedNode.setOptions({physics:true}); delete this.clusteredNodes[nodeId]; } @@ -606,56 +788,48 @@ class ClusterEngine { // actually handling the deleting. for (let i = 0; i < edgesToBeDeleted.length; i++) { - let edge = edgesToBeDeleted[i]; - - let otherNodeId = this._getConnectedId(edge, clusterNodeId); - // if the other node is in another cluster, we transfer ownership of this edge to the other cluster - if (this.clusteredNodes[otherNodeId] !== undefined) { - // transfer ownership: - let otherCluster = this.body.nodes[this.clusteredNodes[otherNodeId].clusterId]; - let transferEdge = this.body.edges[edge.clusteringEdgeReplacingId]; - if (transferEdge !== undefined) { + let edge = edgesToBeDeleted[i]; + let otherNodeId = this._getConnectedId(edge, clusterNodeId); + let otherNode = this.clusteredNodes[otherNodeId]; + + for (let j = 0; j < edge.clusteringEdgeReplacingIds.length; j++) { + let transferId = edge.clusteringEdgeReplacingIds[j]; + let transferEdge = this.body.edges[transferId]; + if (transferEdge === undefined) continue; + + // if the other node is in another cluster, we transfer ownership of this edge to the other cluster + if (otherNode !== undefined) { + // transfer ownership: + let otherCluster = this.body.nodes[otherNode.clusterId]; otherCluster.containedEdges[transferEdge.id] = transferEdge; // delete local reference delete containedEdges[transferEdge.id]; - // create new cluster edge from the otherCluster: // get to and from let fromId = transferEdge.fromId; let toId = transferEdge.toId; if (transferEdge.toId == otherNodeId) { - toId = this.clusteredNodes[otherNodeId].clusterId; + toId = otherNode.clusterId; } else { - fromId = this.clusteredNodes[otherNodeId].clusterId; + fromId = otherNode.clusterId; } - // clone the options and apply the cluster options to them - let clonedOptions = NetworkUtil.cloneOptions(transferEdge, 'edge'); - util.deepExtend(clonedOptions, otherCluster.clusterEdgeProperties); + // create new cluster edge from the otherCluster + this._createClusteredEdge( + fromId, + toId, + transferEdge, + otherCluster.clusterEdgeProperties, + {hidden: false, physics: true}); - // apply the edge specific options to it. - let id = 'clusterEdge:' + util.randomUUID(); - util.deepExtend(clonedOptions, {from: fromId, to: toId, hidden: false, physics: true, id: id}); - - // create it - let newEdge = this.body.functions.createEdge(clonedOptions); - newEdge.clusteringEdgeReplacingId = transferEdge.id; - this.body.edges[id] = newEdge; - this.body.edges[id].connect(); - } - } - else { - let replacedEdge = this.body.edges[edge.clusteringEdgeReplacingId]; - if (replacedEdge !== undefined) { - this._restoreEdge(replacedEdge); + } else { + this._restoreEdge(transferEdge); } } - edge.cleanup(); - // this removes the edge from node.edges, which is why edgeIds is formed - edge.disconnect(); - delete this.body.edges[edge.id]; + + edge.remove(); } // handle the releasing of the edges @@ -673,6 +847,11 @@ class ClusterEngine { } } + /** + * + * @param {Cluster.id} clusterId + * @returns {Array.} + */ getNodesInCluster(clusterId) { let nodesArray = []; if (this.isCluster(clusterId) === true) { @@ -689,28 +868,38 @@ class ClusterEngine { /** * Get the stack clusterId's that a certain node resides in. cluster A -> cluster B -> cluster C -> node - * @param nodeId + * + * If a node can't be found in the chain, return an empty array. + * + * @param {string|number} nodeId * @returns {Array} */ findNode(nodeId) { let stack = []; let max = 100; let counter = 0; + let node; while (this.clusteredNodes[nodeId] !== undefined && counter < max) { - stack.push(this.body.nodes[nodeId].id); + node = this.body.nodes[nodeId] + if (node === undefined) return []; + stack.push(node.id); + nodeId = this.clusteredNodes[nodeId].clusterId; counter++; } - stack.push(this.body.nodes[nodeId].id); - stack.reverse(); + node = this.body.nodes[nodeId] + if (node === undefined) return []; + stack.push(node.id); + + stack.reverse(); return stack; } /** * Using a clustered nodeId, update with the new options - * @param clusteredNodeId + * @param {vis.Edge.id} clusteredNodeId * @param {object} newOptions */ updateClusteredNode(clusteredNodeId, newOptions) { @@ -724,7 +913,7 @@ class ClusterEngine { /** * Using a base edgeId, update all related clustered edges with the new options - * @param startEdgeId + * @param {vis.Edge.id} startEdgeId * @param {object} newOptions */ updateEdge(startEdgeId, newOptions) { @@ -742,8 +931,8 @@ class ClusterEngine { /** * Get a stack of clusterEdgeId's (+base edgeid) that a base edge is the same as. cluster edge C -> cluster edge B -> cluster edge A -> base edge(edgeId) - * @param edgeId - * @returns {Array} + * @param {vis.Edge.id} edgeId + * @returns {Array.} */ getClusteredEdges(edgeId) { let stack = []; @@ -761,28 +950,67 @@ class ClusterEngine { /** * Get the base edge id of clusterEdgeId. cluster edge (clusteredEdgeId) -> cluster edge B -> cluster edge C -> base edge - * @param clusteredEdgeId - * @returns baseEdgeId + * @param {vis.Edge.id} clusteredEdgeId + * @returns {vis.Edge.id} baseEdgeId + * + * TODO: deprecate in 5.0.0. Method getBaseEdges() is the correct one to use. */ getBaseEdge(clusteredEdgeId) { - let baseEdgeId = clusteredEdgeId; - let max = 100; + // Just kludge this by returning the first base edge id found + return this.getBaseEdges(clusteredEdgeId)[0]; + } + + + /** + * Get all regular edges for this clustered edge id. + * + * @param {vis.Edge.id} clusteredEdgeId + * @returns {Array.} all baseEdgeId's under this clustered edge + */ + getBaseEdges(clusteredEdgeId) { + let IdsToHandle = [clusteredEdgeId]; + let doneIds = []; + let foundIds = []; + let max = 100; let counter = 0; - while (clusteredEdgeId !== undefined && this.body.edges[clusteredEdgeId] !== undefined && counter < max) { - clusteredEdgeId = this.body.edges[clusteredEdgeId].clusteringEdgeReplacingId; + while (IdsToHandle.length > 0 && counter < max) { + let nextId = IdsToHandle.pop(); + if (nextId === undefined) continue; // Paranoia here and onwards + let nextEdge = this.body.edges[nextId]; + if (nextEdge === undefined) continue; counter++; - if (clusteredEdgeId !== undefined) { - baseEdgeId = clusteredEdgeId; + + let replacingIds = nextEdge.clusteringEdgeReplacingIds; + if (replacingIds === undefined) { + // nextId is a base id + foundIds.push(nextId); + } else { + // Another cluster edge, unravel this one as well + for (let i = 0; i < replacingIds.length; ++i) { + let replacingId = replacingIds[i]; + + // Don't add if already handled + // TODO: never triggers; find a test-case which does + if (IdsToHandle.indexOf(replacingIds) !== -1 || doneIds.indexOf(replacingIds) !== -1) { + continue; + } + + IdsToHandle.push(replacingId); + } } + + doneIds.push(nextId); } - return baseEdgeId; + + return foundIds; } + /** * Get the Id the node is connected to - * @param edge - * @param nodeId + * @param {vis.Edge} edge + * @param {Node.id} nodeId * @returns {*} * @private */ @@ -802,6 +1030,7 @@ class ClusterEngine { * 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%) * + * @returns {number} * @private */ _getHubSize() { @@ -833,8 +1062,385 @@ class ClusterEngine { } return hubThreshold; - }; + } + + + /** + * Create an edge for the cluster representation. + * + * @param {Node.id} fromId + * @param {Node.id} toId + * @param {vis.Edge} baseEdge + * @param {Object} clusterEdgeProperties + * @param {Object} extraOptions + * @returns {Edge} newly created clustered edge + * @private + */ + _createClusteredEdge(fromId, toId, baseEdge, clusterEdgeProperties, extraOptions) { + // copy the options of the edge we will replace + let clonedOptions = NetworkUtil.cloneOptions(baseEdge, 'edge'); + // make sure the properties of clusterEdges are superimposed on it + util.deepExtend(clonedOptions, clusterEdgeProperties); + + // set up the edge + clonedOptions.from = fromId; + clonedOptions.to = toId; + clonedOptions.id = 'clusterEdge:' + util.randomUUID(); + + // apply the edge specific options to it if specified + if (extraOptions !== undefined) { + util.deepExtend(clonedOptions, extraOptions); + } + + let newEdge = this.body.functions.createEdge(clonedOptions); + newEdge.clusteringEdgeReplacingIds = [baseEdge.id]; + newEdge.connect(); + + // Register the new edge + this.body.edges[newEdge.id] = newEdge; + + return newEdge; + } + + + /** + * Add the passed child nodes and edges to the given cluster node. + * + * @param {Object|Node} childNodes hash of nodes or single node to add in cluster + * @param {Object|Edge} childEdges hash of edges or single edge to take into account when clustering + * @param {Node} clusterNode cluster node to add nodes and edges to + * @param {Object} [clusterEdgeProperties] + * @private + */ + _clusterEdges(childNodes, childEdges, clusterNode, clusterEdgeProperties) { + if (childEdges instanceof Edge) { + let edge = childEdges; + let obj = {}; + obj[edge.id] = edge; + childEdges = obj; + } + + if (childNodes instanceof Node) { + let node = childNodes; + let obj = {}; + obj[node.id] = node; + childNodes = obj; + } + + if (clusterNode === undefined || clusterNode === null) { + throw new Error("_clusterEdges: parameter clusterNode required"); + } + + if (clusterEdgeProperties === undefined) { + // Take the required properties from the cluster node + clusterEdgeProperties = clusterNode.clusterEdgeProperties; + } + + // create the new edges that will connect to the cluster. + // All self-referencing edges will be added to childEdges here. + this._createClusterEdges(childNodes, childEdges, clusterNode, clusterEdgeProperties); + + // disable the childEdges + for (let edgeId in childEdges) { + if (childEdges.hasOwnProperty(edgeId)) { + if (this.body.edges[edgeId] !== undefined) { + let edge = this.body.edges[edgeId]; + // cache the options before changing + this._backupEdgeOptions(edge); + // disable physics and hide the edge + edge.setOptions({physics:false}); + } + } + } + + // disable the childNodes + for (let nodeId in childNodes) { + if (childNodes.hasOwnProperty(nodeId)) { + this.clusteredNodes[nodeId] = {clusterId:clusterNode.id, node: this.body.nodes[nodeId]}; + this.body.nodes[nodeId].setOptions({physics:false}); + } + } + } + + + /** + * Determine in which cluster given nodeId resides. + * + * If not in cluster, return undefined. + * + * NOTE: If you know a cleaner way to do this, please enlighten me (wimrijnders). + * + * @param {Node.id} nodeId + * @returns {Node|undefined} Node instance for cluster, if present + * @private + */ + _getClusterNodeForNode(nodeId) { + if (nodeId === undefined) return undefined; + let clusteredNode = this.clusteredNodes[nodeId]; + + // NOTE: If no cluster info found, it should actually be an error + if (clusteredNode === undefined) return undefined; + let clusterId = clusteredNode.clusterId; + if (clusterId === undefined) return undefined; + + return this.body.nodes[clusterId]; + } + + + /** + * Internal helper function for conditionally removing items in array + * + * Done like this because Array.filter() is not fully supported by all IE's. + * + * @param {Array} arr + * @param {function} callback + * @returns {Array} + * @private + */ + _filter(arr, callback) { + let ret = []; + + util.forEach(arr, (item) => { + if (callback(item)) { + ret.push(item); + } + }); + + return ret; + } + + + /** + * Scan all edges for changes in clustering and adjust this if necessary. + * + * Call this (internally) after there has been a change in node or edge data. + * + * Pre: States of this.body.nodes and this.body.edges consistent + * Pre: this.clusteredNodes and this.clusteredEdge consistent with containedNodes and containedEdges + * of cluster nodes. + */ + _updateState() { + let nodeId; + let deletedNodeIds = []; + let deletedEdgeIds = []; + + /** + * Utility function to iterate over clustering nodes only + * + * @param {Function} callback function to call for each cluster node + */ + let eachClusterNode = (callback) => { + util.forEach(this.body.nodes, (node) => { + if (node.isCluster === true) { + callback(node); + } + }); + }; + + + // + // Remove deleted regular nodes from clustering + // + + // Determine the deleted nodes + for (nodeId in this.clusteredNodes) { + if (!this.clusteredNodes.hasOwnProperty(nodeId)) continue; + let node = this.body.nodes[nodeId]; + + if (node === undefined) { + deletedNodeIds.push(nodeId); + } + } + + // Remove nodes from cluster nodes + eachClusterNode(function(clusterNode) { + for (let n = 0; n < deletedNodeIds.length; n++) { + delete clusterNode.containedNodes[deletedNodeIds[n]]; + } + }); + + // Remove nodes from cluster list + for (let n = 0; n < deletedNodeIds.length; n++) { + delete this.clusteredNodes[deletedNodeIds[n]]; + } + + + // + // Remove deleted edges from clustering + // + + // Add the deleted clustered edges to the list + util.forEach(this.clusteredEdges, (edgeId) => { + let edge = this.body.edges[edgeId]; + if (edge === undefined || !edge.endPointsValid()) { + deletedEdgeIds.push(edgeId); + } + }); + + // Cluster nodes can also contain edges which are not clustered, + // i.e. nodes 1-2 within cluster with an edge in between. + // So the cluster nodes also need to be scanned for invalid edges + eachClusterNode(function(clusterNode) { + util.forEach(clusterNode.containedEdges, (edge, edgeId) => { + if (!edge.endPointsValid() && deletedEdgeIds.indexOf(edgeId) === -1) { + deletedEdgeIds.push(edgeId); + } + }); + }); + + // Also scan for cluster edges which need to be removed in the active list. + // Regular edges have been removed beforehand, so this only picks up the cluster edges. + util.forEach(this.body.edges, (edge, edgeId) => { + // Explicitly scan the contained edges for validity + let isValid = true; + let replacedIds = edge.clusteringEdgeReplacingIds; + if (replacedIds !== undefined) { + let numValid = 0; + + util.forEach(replacedIds, (containedEdgeId) => { + let containedEdge = this.body.edges[containedEdgeId]; + + if (containedEdge !== undefined && containedEdge.endPointsValid()) { + numValid += 1; + } + }); + + isValid = (numValid > 0); + } + + if (!edge.endPointsValid() || !isValid) { + deletedEdgeIds.push(edgeId); + } + }); + + // Remove edges from cluster nodes + eachClusterNode((clusterNode) => { + util.forEach(deletedEdgeIds, (deletedEdgeId) => { + delete clusterNode.containedEdges[deletedEdgeId]; + + util.forEach(clusterNode.edges, (edge, m) => { + if (edge.id === deletedEdgeId) { + clusterNode.edges[m] = null; // Don't want to directly delete here, because in the loop + return; + } + + edge.clusteringEdgeReplacingIds = this._filter(edge.clusteringEdgeReplacingIds, function(id) { + return deletedEdgeIds.indexOf(id) === -1; + }); + }); + + // Clean up the nulls + clusterNode.edges = this._filter(clusterNode.edges, function(item) {return item !== null}); + }); + }); + + // Remove from cluster list + util.forEach(deletedEdgeIds, (edgeId) => { + delete this.clusteredEdges[edgeId]; + }); + + // Remove cluster edges from active list (this.body.edges). + // deletedEdgeIds still contains id of regular edges, but these should all + // be gone when you reach here. + util.forEach(deletedEdgeIds, (edgeId) => { + delete this.body.edges[edgeId]; + }); + + + // + // Check changed cluster state of edges + // + + // Iterating over keys here, because edges may be removed in the loop + let ids = Object.keys(this.body.edges); + util.forEach(ids, (edgeId) => { + let edge = this.body.edges[edgeId]; + + let shouldBeClustered = this._isClusteredNode(edge.fromId) || this._isClusteredNode(edge.toId); + if (shouldBeClustered === this._isClusteredEdge(edge.id)) { + return; // all is well + } + + if (shouldBeClustered) { + // add edge to clustering + let clusterFrom = this._getClusterNodeForNode(edge.fromId); + if (clusterFrom !== undefined) { + this._clusterEdges(this.body.nodes[edge.fromId], edge, clusterFrom); + } + + let clusterTo = this._getClusterNodeForNode(edge.toId); + if (clusterTo !== undefined) { + this._clusterEdges(this.body.nodes[edge.toId], edge, clusterTo); + } + + // TODO: check that it works for both edges clustered + // (This might be paranoia) + } else { + // This should not be happening, the state should + // be properly updated at this point. + // + // If it *is* reached during normal operation, then we have to implement + // undo clustering for this edge here. + throw new Error('remove edge from clustering not implemented!'); + } + }); + + + // Clusters may be nested to any level. Keep on opening until nothing to open + var changed = false; + var continueLoop = true; + while (continueLoop) { + let clustersToOpen = []; + + // Determine the id's of clusters that need opening + eachClusterNode(function(clusterNode) { + let numNodes = Object.keys(clusterNode.containedNodes).length; + let allowSingle = (clusterNode.options.allowSingleNodeCluster === true); + if ((allowSingle && numNodes < 1) || (!allowSingle && numNodes < 2)) { + clustersToOpen.push(clusterNode.id); + } + }); + + // Open them + for (let n = 0; n < clustersToOpen.length; ++n) { + this.openCluster(clustersToOpen[n], {}, false /* Don't refresh, we're in an refresh/update already */); + } + + continueLoop = (clustersToOpen.length > 0); + changed = changed || continueLoop; + } + + if (changed) { + this._updateState() // Redo this method (recursion possible! should be safe) + } + } + + + /** + * Determine if node with given id is part of a cluster. + * + * @param {Node.id} nodeId + * @return {boolean} true if part of a cluster. + */ + _isClusteredNode(nodeId) { + return this.clusteredNodes[nodeId] !== undefined; + } + + + /** + * Determine if edge with given id is not visible due to clustering. + * + * An edge is considered clustered if: + * - it is directly replaced by a clustering edge + * - any of its connecting nodes is in a cluster + * + * @param {vis.Edge.id} edgeId + * @return {boolean} true if part of a cluster. + */ + _isClusteredEdge(edgeId) { + return this.clusteredEdges[edgeId] !== undefined; + } } diff --git a/lib/network/modules/EdgesHandler.js b/lib/network/modules/EdgesHandler.js index e57e86e58..e5935d20d 100644 --- a/lib/network/modules/EdgesHandler.js +++ b/lib/network/modules/EdgesHandler.js @@ -1,11 +1,17 @@ var util = require("../../util"); var DataSet = require('../../DataSet'); var DataView = require('../../DataView'); +var Edge = require("./components/Edge").default; -import Edge from "./components/Edge" -import Label from "./components/shared/Label" - +/** + * Handler for Edges + */ class EdgesHandler { + /** + * @param {Object} body + * @param {Array.} images + * @param {Array.} groups + */ constructor(body, images, groups) { this.body = body; this.images = images; @@ -23,7 +29,7 @@ class EdgesHandler { this.options = {}; this.defaultOptions = { arrows: { - to: {enabled: false, scaleFactor:1, type: 'arrow'}, // boolean / {arrowScaleFactor:1} / {enabled: false, arrowScaleFactor:1} + to: {enabled: false, scaleFactor:1, type: 'arrow'},// boolean / {arrowScaleFactor:1} / {enabled: false, arrowScaleFactor:1} middle: {enabled: false, scaleFactor:1, type: 'arrow'}, from: {enabled: false, scaleFactor:1, type: 'arrow'} }, @@ -43,7 +49,24 @@ class EdgesHandler { background: 'none', strokeWidth: 2, // px strokeColor: '#ffffff', - align:'horizontal' + align:'horizontal', + multi: false, + vadjust: 0, + bold: { + mod: 'bold' + }, + boldital: { + mod: 'bold italic' + }, + ital: { + mod: 'italic' + }, + mono: { + mod: '', + size: 15, // px + face: 'courier new', + vadjust: 2 + } }, hidden: false, hoverWidth: 1.5, @@ -91,18 +114,21 @@ class EdgesHandler { value: undefined }; - util.extend(this.options, this.defaultOptions); + util.deepExtend(this.options, this.defaultOptions); this.bindEventListeners(); } + /** + * Binds event listeners + */ bindEventListeners() { // this allows external modules to force all dynamic curves to turn static. - this.body.emitter.on("_forceDisableDynamicCurves", (type) => { + this.body.emitter.on("_forceDisableDynamicCurves", (type, emit = true) => { if (type === 'dynamic') { type = 'continuous'; } - let emitChange = false; + let dataChanged = false; for (let edgeId in this.body.edges) { if (this.body.edges.hasOwnProperty(edgeId)) { let edge = this.body.edges[edgeId]; @@ -111,30 +137,36 @@ class EdgesHandler { // only forcibly 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') { + let smoothOptions = edgeData.smooth; + if (smoothOptions !== undefined) { + if (smoothOptions.enabled === true && smoothOptions.type === 'dynamic') { if (type === undefined) { edge.setOptions({smooth: false}); } else { edge.setOptions({smooth: {type: type}}); } - emitChange = true; + dataChanged = true; } } } } } - if (emitChange === true) { + if (emit === true && dataChanged === true) { this.body.emitter.emit("_dataChanged"); } }); // this is called when options of EXISTING nodes or edges have changed. + // + // NOTE: Not true, called when options have NOT changed, for both existing as well as new nodes. + // See update() for logic. + // TODO: Verify and examine the consequences of this. It might still trigger when + // non-option fields have changed, but then reconnecting edges is still useless. + // Alternatively, it might also be called when edges are removed. + // this.body.emitter.on("_dataUpdated", () => { this.reconnectEdges(); - this.markAllEdgesAsDirty(); }); // refresh the edges. Used when reverting from hierarchical layout @@ -154,15 +186,14 @@ class EdgesHandler { } + /** + * + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { // use the parser from the Edge class to fill in all shorthand notations - Edge.parseOptions(this.options, options); - - // handle multiple input cases for color - if (options.color !== undefined) { - this.markAllEdgesAsDirty(); - } + Edge.parseOptions(this.options, options, true, this.defaultOptions, true); // update smooth settings in all edges let dataChanged = false; @@ -176,8 +207,6 @@ class EdgesHandler { // 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(); @@ -196,7 +225,7 @@ class EdgesHandler { /** * Load edges by reading the data table * @param {Array | DataSet | DataView} edges The data containing the edges. - * @private + * @param {boolean} [doNotEmit=false] * @private */ setData(edges, doNotEmit = false) { @@ -235,6 +264,7 @@ class EdgesHandler { this.add(ids, true); } + this.body.emitter.emit('_adjustEdgesForHierarchicalLayout'); if (doNotEmit === false) { this.body.emitter.emit("_dataChanged"); } @@ -243,7 +273,8 @@ class EdgesHandler { /** * Add edges - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids + * @param {boolean} [doNotEmit=false] * @private */ add(ids, doNotEmit = false) { @@ -262,6 +293,8 @@ class EdgesHandler { edges[id] = this.create(data); } + this.body.emitter.emit('_adjustEdgesForHierarchicalLayout'); + if (doNotEmit === false) { this.body.emitter.emit("_dataChanged"); } @@ -271,7 +304,7 @@ class EdgesHandler { /** * Update existing edges, or create them when not yet existing - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids * @private */ update(ids) { @@ -296,6 +329,7 @@ class EdgesHandler { } if (dataChanged === true) { + this.body.emitter.emit('_adjustEdgesForHierarchicalLayout'); this.body.emitter.emit("_dataChanged"); } else { @@ -304,50 +338,47 @@ class EdgesHandler { } - /** * Remove existing edges. Non existing ids will be ignored - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids + * @param {boolean} [emit=true] * @private */ - remove(ids) { + remove(ids, emit = true) { + if (ids.length === 0) return; // early out + var edges = this.body.edges; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; + util.forEach(ids, (id) => { var edge = edges[id]; if (edge !== undefined) { - edge.cleanup(); - edge.disconnect(); - delete edges[id]; + edge.remove(); } - } + }); - this.body.emitter.emit("_dataChanged"); + if (emit) { + this.body.emitter.emit("_dataChanged"); + } } + /** + * Refreshes Edge Handler + */ refresh() { - let edges = this.body.edges; - for (let edgeId in edges) { - let edge = undefined; - if (edges.hasOwnProperty(edgeId)) { - edge = edges[edgeId]; - } + util.forEach(this.body.edges, (edge, edgeId) => { let data = this.body.data.edges._data[edgeId]; - if (edge !== undefined && data !== undefined) { + if (data !== undefined) { edge.setOptions(data); } - } + }); } + /** + * + * @param {Object} properties + * @returns {Edge} + */ 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; - } + return new Edge(properties, this.body, this.options, this.defaultOptions) } /** @@ -375,17 +406,74 @@ class EdgesHandler { } } - + /** + * + * @param {Edge.id} edgeId + * @returns {Array} + */ 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);} + if (edge.fromId !== undefined) {nodeList.push(edge.fromId);} + if (edge.toId !== undefined) {nodeList.push(edge.toId);} } return nodeList; } + /** + * There is no direct relation between the nodes and the edges DataSet, + * so the right place to do call this is in the handler for event `_dataUpdated`. + */ + _updateState() { + this._addMissingEdges(); + this._removeInvalidEdges(); + } + + /** + * Scan for missing nodes and remove corresponding edges, if any. + * @private + */ + _removeInvalidEdges() { + + let edgesToDelete = []; + + util.forEach(this.body.edges, (edge, id) => { + let toNode = this.body.nodes[edge.toId]; + let fromNode = this.body.nodes[edge.fromId]; + + // Skip clustering edges here, let the Clustering module handle those + if ((toNode !== undefined && toNode.isCluster === true) + || (fromNode !== undefined && fromNode.isCluster === true)) { + return; + } + + if (toNode === undefined || fromNode === undefined) { + edgesToDelete.push(id); + } + }); + + this.remove(edgesToDelete, false); + } + + /** + * add all edges from dataset that are not in the cached state + * @private + */ + _addMissingEdges() { + let edges = this.body.edges; + let edgesData = this.body.data.edges; + let addIds = []; + + edgesData.forEach((edgeData, edgeId) => { + let edge = edges[edgeId]; + if(edge===undefined) { + addIds.push(edgeId); + } + }); + + this.add(addIds,true); + } } export default EdgesHandler; diff --git a/lib/network/modules/Groups.js b/lib/network/modules/Groups.js index 60cf006ed..bddd13ad1 100644 --- a/lib/network/modules/Groups.js +++ b/lib/network/modules/Groups.js @@ -1,10 +1,12 @@ let util = require('../../util'); /** - * @class Groups * This class can store groups and options specific for groups. */ class Groups { + /** + * @ignore + */ constructor() { this.clear(); this.defaultIndex = 0; @@ -44,7 +46,10 @@ class Groups { util.extend(this.options, this.defaultOptions); } - + /** + * + * @param {Object} options + */ setOptions(options) { let optionFields = ['useDefaultGroups']; @@ -70,14 +75,17 @@ class Groups { } /** - * 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 group options of a groupname. + * If groupname is not found, a new group may be created. + * + * @param {*} groupname Can be a number, string, Date, etc. + * @param {boolean} [shouldCreate=true] If true, create a new group + * @return {Object} The found or created group */ - get(groupname) { + get(groupname, shouldCreate = true) { let group = this.groups[groupname]; - if (group === undefined) { + + if (group === undefined && shouldCreate) { if (this.options.useDefaultGroups === false && this.groupsArray.length > 0) { // create new group let index = this.groupIndex % this.groupsArray.length; @@ -101,7 +109,7 @@ class Groups { /** * Add a custom group style - * @param {String} groupName + * @param {string} groupName * @param {Object} style An object containing borderColor, * backgroundColor, etc. * @return {Object} group The created group object diff --git a/lib/network/modules/InteractionHandler.js b/lib/network/modules/InteractionHandler.js index fb9daa028..52c335765 100644 --- a/lib/network/modules/InteractionHandler.js +++ b/lib/network/modules/InteractionHandler.js @@ -1,9 +1,17 @@ let util = require('../../util'); +var NavigationHandler = require('./components/NavigationHandler').default; +var Popup = require('./../../shared/Popup').default; -import NavigationHandler from './components/NavigationHandler' -import Popup from './components/Popup' +/** + * Handler for interactions + */ class InteractionHandler { + /** + * @param {Object} body + * @param {Canvas} canvas + * @param {SelectionHandler} selectionHandler + */ constructor(body, canvas, selectionHandler) { this.body = body; this.canvas = canvas; @@ -58,6 +66,9 @@ class InteractionHandler { this.bindEventListeners() } + /** + * Binds event listeners + */ bindEventListeners() { this.body.emitter.on('destroy', () => { clearTimeout(this.popupTimer); @@ -65,6 +76,10 @@ class InteractionHandler { }) } + /** + * + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { // extend all but the values in fields @@ -88,8 +103,8 @@ class InteractionHandler { /** * Get the pointer location from a touch location - * @param {{x: Number, y: Number}} touch - * @return {{x: Number, y: Number}} pointer + * @param {{x: number, y: number}} touch + * @return {{x: number, y: number}} pointer * @private */ getPointer(touch) { @@ -102,7 +117,7 @@ class InteractionHandler { /** * On start of a touch gesture, store the pointer - * @param event + * @param {Event} event The event * @private */ onTouch(event) { @@ -115,8 +130,10 @@ class InteractionHandler { } } + /** * handle tap/click event: select/unselect a node + * @param {Event} event * @private */ onTap(event) { @@ -140,6 +157,7 @@ class InteractionHandler { /** * handle doubletap event + * @param {Event} event * @private */ onDoubleTap(event) { @@ -148,9 +166,9 @@ class InteractionHandler { } - /** * handle long tap event: multi select nodes + * @param {Event} event * @private */ onHold(event) { @@ -167,6 +185,7 @@ class InteractionHandler { /** * handle the release of the screen * + * @param {Event} event * @private */ onRelease(event) { @@ -178,6 +197,10 @@ class InteractionHandler { } } + /** + * + * @param {Event} event + */ onContext(event) { let pointer = this.getPointer({x:event.clientX, y:event.clientY}); this.selectionHandler._generateClickEvent('oncontext', event, pointer); @@ -185,60 +208,54 @@ class InteractionHandler { /** + * Select and deselect nodes depending current selection change. * - * @param pointer - * @param add + * For changing nodes, select/deselect events are fired. + * + * NOTE: For a given edge, if one connecting node is deselected and with the same + * click the other node is selected, no events for the edge will fire. + * It was selected and it will remain selected. + * + * TODO: This is all SelectionHandler calls; the method should be moved to there. + * + * @param {{x: number, y: number}} pointer + * @param {Event} event + * @param {boolean} [add=false] */ checkSelectionChanges(pointer, event, add = false) { - let previouslySelectedEdgeCount = this.selectionHandler._getSelectedEdgeCount(); - let previouslySelectedNodeCount = this.selectionHandler._getSelectedNodeCount(); let previousSelection = this.selectionHandler.getSelection(); - let selected; + let selected = false; 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 {nodesChanged, edgesChanged} = this._determineIfDifferent(previousSelection, currentSelection); - let nodeSelected = false; + // See NOTE in method comment for the reason to do it like this + let deselectedItems = this._determineDifference(previousSelection, currentSelection); + let selectedItems = this._determineDifference(currentSelection , previousSelection); - if (selectedNodesCount - previouslySelectedNodeCount > 0) { // node was selected - this.selectionHandler._generateClickEvent('selectNode', event, pointer); - selected = true; - nodeSelected = true; - } - else if (nodesChanged === true && selectedNodesCount > 0) { - this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); - this.selectionHandler._generateClickEvent('selectNode', event, pointer); - nodeSelected = true; + if (deselectedItems.edges.length > 0) { + this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection); selected = true; } - else if (selectedNodesCount - previouslySelectedNodeCount < 0) { // node was deselected + + if (deselectedItems.nodes.length > 0) { this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); selected = true; } - - // handle the selected edges - if (selectedEdgesCount - previouslySelectedEdgeCount > 0 && nodeSelected === false) { // edge was selected - this.selectionHandler._generateClickEvent('selectEdge', event, pointer); + if (selectedItems.nodes.length > 0) { + this.selectionHandler._generateClickEvent('selectNode', event, pointer); selected = true; } - else if (selectedEdgesCount > 0 && edgesChanged === true) { - this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection); + + if (selectedItems.edges.length > 0) { 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; - } - // fire the select event if anything has been selected or deselected if (selected === true) { // select or unselect @@ -248,38 +265,31 @@ class InteractionHandler { /** - * This function checks if the nodes and edges previously selected have changed. - * @param previousSelection - * @param currentSelection - * @returns {{nodesChanged: boolean, edgesChanged: boolean}} + * Remove all node and edge id's from the first set that are present in the second one. + * + * @param {{nodes: Array., edges: Array.}} firstSet + * @param {{nodes: Array., edges: Array.}} secondSet + * @returns {{nodes: Array., edges: Array.}} * @private */ - _determineIfDifferent(previousSelection,currentSelection) { - let nodesChanged = false; - let edgesChanged = false; - - for (let i = 0; i < previousSelection.nodes.length; i++) { - if (currentSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) { - nodesChanged = true; - } - } - for (let i = 0; i < currentSelection.nodes.length; i++) { - if (previousSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) { - nodesChanged = true; - } - } - for (let i = 0; i < previousSelection.edges.length; i++) { - if (currentSelection.edges.indexOf(previousSelection.edges[i]) === -1) { - edgesChanged = true; - } - } - for (let i = 0; i < currentSelection.edges.length; i++) { - if (previousSelection.edges.indexOf(previousSelection.edges[i]) === -1) { - edgesChanged = true; + _determineDifference(firstSet, secondSet) { + let arrayDiff = function(firstArr, secondArr) { + let result = []; + + for (let i = 0; i < firstArr.length; i++) { + let value = firstArr[i]; + if (secondArr.indexOf(value) === -1) { + result.push(value); + } } - } - return {nodesChanged, edgesChanged}; + return result; + }; + + return { + nodes: arrayDiff(firstSet.nodes, secondSet.nodes), + edges: arrayDiff(firstSet.edges, secondSet.edges) + }; } @@ -287,6 +297,7 @@ class InteractionHandler { * This function is called by onDragStart. * It is separated out because we can then overload it for the datamanipulation system. * + * @param {Event} event * @private */ onDragStart(event) { @@ -346,6 +357,7 @@ class InteractionHandler { /** * handle drag event + * @param {Event} event * @private */ onDrag(event) { @@ -396,7 +408,7 @@ class InteractionHandler { 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'); + this.body.emitter.emit('_requestRedraw'); } } } @@ -404,6 +416,7 @@ class InteractionHandler { /** * handle drag start event + * @param {Event} event * @private */ onDragEnd(event) { @@ -428,7 +441,7 @@ class InteractionHandler { /** * Handle pinch event - * @param event + * @param {Event} event The event * @private */ onPinch(event) { @@ -447,9 +460,8 @@ class InteractionHandler { /** * 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 + * @param {number} scale a number around 1, and between 0.01 and 10 + * @param {{x: number, y: number}} pointer Position on screen * @private */ zoom(scale, pointer) { @@ -488,10 +500,10 @@ class InteractionHandler { this.body.emitter.emit('_requestRedraw'); if (scaleOld < scale) { - this.body.emitter.emit('zoom', {direction: '+', scale: this.body.view.scale}); + this.body.emitter.emit('zoom', {direction: '+', scale: this.body.view.scale, pointer: pointer}); } else { - this.body.emitter.emit('zoom', {direction: '-', scale: this.body.view.scale}); + this.body.emitter.emit('zoom', {direction: '-', scale: this.body.view.scale, pointer: pointer}); } } } @@ -622,16 +634,9 @@ class InteractionHandler { } } - /** - * Adding hover highlights - */ + // 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); + this.selectionHandler.hoverObject(event, pointer); } } @@ -642,7 +647,7 @@ class InteractionHandler { * (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 + * @param {{x:number, y:number}} pointer * @private */ _checkShowPopup(pointer) { @@ -755,7 +760,7 @@ class InteractionHandler { /** * 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 + * @param {{x:number, y:number}} pointer * @private */ _checkHidePopup(pointer) { @@ -770,7 +775,7 @@ class InteractionHandler { // 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; + stillOnObj = overNode === undefined ? false : overNode.id === this.popup.popupTargetId; } } } @@ -787,7 +792,6 @@ class InteractionHandler { this._hide(); } } - } export default InteractionHandler; diff --git a/lib/network/modules/KamadaKawai.js b/lib/network/modules/KamadaKawai.js index 63d620ff8..415b021dc 100644 --- a/lib/network/modules/KamadaKawai.js +++ b/lib/network/modules/KamadaKawai.js @@ -11,6 +11,11 @@ import FloydWarshall from "./components/algorithms/FloydWarshall.js" * Possible optimizations in the distance calculation can be implemented. */ class KamadaKawai { + /** + * @param {Object} body + * @param {number} edgeLength + * @param {number} edgeStrength + */ constructor(body, edgeLength, edgeStrength) { this.body = body; this.springLength = edgeLength; @@ -20,7 +25,7 @@ class KamadaKawai { /** * Not sure if needed but can be used to update the spring length and spring constant - * @param options + * @param {Object} options */ setOptions(options) { if (options) { @@ -36,8 +41,9 @@ class KamadaKawai { /** * Position the system - * @param nodesArray - * @param edgesArray + * @param {Array.} nodesArray + * @param {Array.} edgesArray + * @param {boolean} [ignoreClusters=false] */ solve(nodesArray, edgesArray, ignoreClusters = false) { // get distance matrix @@ -49,11 +55,14 @@ class KamadaKawai { // get the K Matrix this._createK_matrix(D_matrix); + // initial E Matrix + this._createE_matrix(); + // calculate positions let threshold = 0.01; let innerThreshold = 1; let iterations = 0; - let maxIterations = Math.max(1000,Math.min(10*this.body.nodeIndices.length,6000)); + let maxIterations = Math.max(1000, Math.min(10 * this.body.nodeIndices.length, 6000)); let maxInnerIterations = 5; let maxEnergy = 1e9; @@ -64,17 +73,18 @@ class KamadaKawai { [highE_nodeId, maxEnergy, dE_dx, dE_dy] = this._getHighestEnergyNode(ignoreClusters); delta_m = maxEnergy; subIterations = 0; - while(delta_m > innerThreshold && subIterations < maxInnerIterations) { + while (delta_m > innerThreshold && subIterations < maxInnerIterations) { subIterations += 1; this._moveNode(highE_nodeId, dE_dx, dE_dy); - [delta_m,dE_dx,dE_dy] = this._getEnergy(highE_nodeId); + [delta_m, dE_dx, dE_dy] = this._getEnergy(highE_nodeId); } } } /** * get the node with the highest energy - * @returns {*[]} + * @param {boolean} ignoreClusters + * @returns {number[]} * @private */ _getHighestEnergyNode(ignoreClusters) { @@ -87,7 +97,7 @@ class KamadaKawai { for (let nodeIdx = 0; nodeIdx < nodesArray.length; nodeIdx++) { let m = nodesArray[nodeIdx]; // by not evaluating nodes with predefined positions we should only move nodes that have no positions. - if ((nodes[m].predefinedPosition === false || nodes[m].isCluster === true && ignoreClusters === true) || nodes[m].options.fixed.x === true || nodes[m].options.fixed.y === true) { + if ((nodes[m].predefinedPosition === false || nodes[m].isCluster === true && ignoreClusters === true) || nodes[m].options.fixed.x === true || nodes[m].options.fixed.y === true) { let [delta_m,dE_dx,dE_dy] = this._getEnergy(m); if (maxEnergy < delta_m) { maxEnergy = delta_m; @@ -103,29 +113,12 @@ class KamadaKawai { /** * calculate the energy of a single node - * @param m - * @returns {*[]} + * @param {Node.id} m + * @returns {number[]} * @private */ _getEnergy(m) { - let nodesArray = this.body.nodeIndices; - let nodes = this.body.nodes; - - let x_m = nodes[m].x; - let y_m = nodes[m].y; - let dE_dx = 0; - let dE_dy = 0; - for (let iIdx = 0; iIdx < nodesArray.length; iIdx++) { - let i = nodesArray[iIdx]; - if (i !== m) { - let x_i = nodes[i].x; - let y_i = nodes[i].y; - let denominator = 1.0 / Math.sqrt(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2)); - dE_dx += this.K_matrix[m][i] * ((x_m - x_i) - this.L_matrix[m][i] * (x_m - x_i) * denominator); - dE_dy += this.K_matrix[m][i] * ((y_m - y_i) - this.L_matrix[m][i] * (y_m - y_i) * denominator); - } - } - + let [dE_dx,dE_dy] = this.E_sums[m]; let delta_m = Math.sqrt(Math.pow(dE_dx, 2) + Math.pow(dE_dy, 2)); return [delta_m, dE_dx, dE_dy]; } @@ -133,9 +126,9 @@ class KamadaKawai { /** * move the node based on it's energy * the dx and dy are calculated from the linear system proposed by Kamada and Kawai - * @param m - * @param dE_dx - * @param dE_dy + * @param {number} m + * @param {number} dE_dx + * @param {number} dE_dy * @private */ _moveNode(m, dE_dx, dE_dy) { @@ -147,15 +140,20 @@ class KamadaKawai { let x_m = nodes[m].x; let y_m = nodes[m].y; + let km = this.K_matrix[m]; + let lm = this.L_matrix[m]; + for (let iIdx = 0; iIdx < nodesArray.length; iIdx++) { let i = nodesArray[iIdx]; if (i !== m) { let x_i = nodes[i].x; let y_i = nodes[i].y; + let kmat = km[i]; + let lmat = lm[i]; let denominator = 1.0 / Math.pow(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2), 1.5); - d2E_dx2 += this.K_matrix[m][i] * (1 - this.L_matrix[m][i] * Math.pow(y_m - y_i, 2) * denominator); - d2E_dxdy += this.K_matrix[m][i] * (this.L_matrix[m][i] * (x_m - x_i) * (y_m - y_i) * denominator); - d2E_dy2 += this.K_matrix[m][i] * (1 - this.L_matrix[m][i] * Math.pow(x_m - x_i, 2) * denominator); + d2E_dx2 += kmat * (1 - lmat * Math.pow(y_m - y_i, 2) * denominator); + d2E_dxdy += kmat * (lmat * (x_m - x_i) * (y_m - y_i) * denominator); + d2E_dy2 += kmat * (1 - lmat * Math.pow(x_m - x_i, 2) * denominator); } } // make the variable names easier to make the solving of the linear system easier to read @@ -168,12 +166,15 @@ class KamadaKawai { // move the node nodes[m].x += dx; nodes[m].y += dy; + + // Recalculate E_matrix (should be incremental) + this._updateE_matrix(m); } /** * Create the L matrix: edge length times shortest path - * @param D_matrix + * @param {Object} D_matrix * @private */ _createL_matrix(D_matrix) { @@ -192,7 +193,7 @@ class KamadaKawai { /** * Create the K matrix: spring constants times shortest path - * @param D_matrix + * @param {Object} D_matrix * @private */ _createK_matrix(D_matrix) { @@ -208,8 +209,87 @@ class KamadaKawai { } } + /** + * Create matrix with all energies between nodes + * @private + */ + _createE_matrix() { + let nodesArray = this.body.nodeIndices; + let nodes = this.body.nodes; + this.E_matrix = {}; + this.E_sums = {}; + for (let mIdx = 0; mIdx < nodesArray.length; mIdx++) { + this.E_matrix[nodesArray[mIdx]] = []; + } + for (let mIdx = 0; mIdx < nodesArray.length; mIdx++) { + let m = nodesArray[mIdx]; + let x_m = nodes[m].x; + let y_m = nodes[m].y; + let dE_dx = 0; + let dE_dy = 0; + for (let iIdx = mIdx; iIdx < nodesArray.length; iIdx++) { + let i = nodesArray[iIdx]; + if (i !== m) { + let x_i = nodes[i].x; + let y_i = nodes[i].y; + let denominator = 1.0 / Math.sqrt(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2)); + this.E_matrix[m][iIdx] = [ + this.K_matrix[m][i] * ((x_m - x_i) - this.L_matrix[m][i] * (x_m - x_i) * denominator), + this.K_matrix[m][i] * ((y_m - y_i) - this.L_matrix[m][i] * (y_m - y_i) * denominator) + ]; + this.E_matrix[i][mIdx] = this.E_matrix[m][iIdx]; + dE_dx += this.E_matrix[m][iIdx][0]; + dE_dy += this.E_matrix[m][iIdx][1]; + } + } + //Store sum + this.E_sums[m] = [dE_dx, dE_dy]; + } + } + + /** + * Update method, just doing single column (rows are auto-updated) (update all sums) + * + * @param {number} m + * @private + */ + _updateE_matrix(m) { + let nodesArray = this.body.nodeIndices; + let nodes = this.body.nodes; + let colm = this.E_matrix[m]; + let kcolm = this.K_matrix[m]; + let lcolm = this.L_matrix[m]; + let x_m = nodes[m].x; + let y_m = nodes[m].y; + let dE_dx = 0; + let dE_dy = 0; + for (let iIdx = 0; iIdx < nodesArray.length; iIdx++) { + let i = nodesArray[iIdx]; + if (i !== m) { + //Keep old energy value for sum modification below + let cell = colm[iIdx]; + let oldDx = cell[0]; + let oldDy = cell[1]; + //Calc new energy: + let x_i = nodes[i].x; + let y_i = nodes[i].y; + let denominator = 1.0 / Math.sqrt(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2)); + let dx = kcolm[i] * ((x_m - x_i) - lcolm[i] * (x_m - x_i) * denominator); + let dy = kcolm[i] * ((y_m - y_i) - lcolm[i] * (y_m - y_i) * denominator); + colm[iIdx] = [dx, dy]; + dE_dx += dx; + dE_dy += dy; + //add new energy to sum of each column + let sum = this.E_sums[i]; + sum[0] += (dx-oldDx); + sum[1] += (dy-oldDy); + } + } + //Store sum at -1 index + this.E_sums[m] = [dE_dx, dE_dy]; + } } export default KamadaKawai; \ No newline at end of file diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js index 1db0d231e..ffadbbca4 100644 --- a/lib/network/modules/LayoutEngine.js +++ b/lib/network/modules/LayoutEngine.js @@ -1,9 +1,329 @@ 'use strict'; - +/** + * There's a mix-up with terms in the code. Following are the formal definitions: + * + * tree - a strict hierarchical network, i.e. every node has at most one parent + * forest - a collection of trees. These distinct trees are thus not connected. + * + * So: + * - in a network that is not a tree, there exist nodes with multiple parents. + * - a network consisting of unconnected sub-networks, of which at least one + * is not a tree, is not a forest. + * + * In the code, the definitions are: + * + * tree - any disconnected sub-network, strict hierarchical or not. + * forest - a bunch of these sub-networks + * + * The difference between tree and not-tree is important in the code, notably within + * to the block-shifting algorithm. The algorithm assumes formal trees and fails + * for not-trees, often in a spectacular manner (search for 'exploding network' in the issues). + * + * In order to distinguish the definitions in the following code, the adjective 'formal' is + * used. If 'formal' is absent, you must assume the non-formal definition. + * + * ---------------------------------------------------------------------------------- + * NOTES + * ===== + * + * A hierarchical layout is a different thing from a hierarchical network. + * The layout is a way to arrange the nodes in the view; this can be done + * on non-hierarchical networks as well. The converse is also possible. + */ let util = require('../../util'); -import NetworkUtil from '../NetworkUtil'; +var NetworkUtil = require('../NetworkUtil').default; +var {HorizontalStrategy, VerticalStrategy} = require('./components/DirectionStrategy.js'); + + +/** + * Container for derived data on current network, relating to hierarchy. + * + * @private + */ +class HierarchicalStatus { + /** + * @ignore + */ + constructor() { + this.childrenReference = {}; // child id's per node id + this.parentReference = {}; // parent id's per node id + this.trees = {}; // tree id per node id; i.e. to which tree does given node id belong + + this.distributionOrdering = {}; // The nodes per level, in the display order + this.levels = {}; // hierarchy level per node id + this.distributionIndex = {}; // The position of the node in the level sorting order, per node id. + this.isTree = false; // True if current network is a formal tree + this.treeIndex = -1; // Highest tree id in current network. + } + + /** + * Add the relation between given nodes to the current state. + * + * @param {Node.id} parentNodeId + * @param {Node.id} childNodeId + */ + addRelation(parentNodeId, childNodeId) { + if (this.childrenReference[parentNodeId] === undefined) { + this.childrenReference[parentNodeId] = []; + } + this.childrenReference[parentNodeId].push(childNodeId); + + if (this.parentReference[childNodeId] === undefined) { + this.parentReference[childNodeId] = []; + } + this.parentReference[childNodeId].push(parentNodeId); + } + + + /** + * Check if the current state is for a formal tree or formal forest. + * + * This is the case if every node has at most one parent. + * + * Pre: parentReference init'ed properly for current network + */ + checkIfTree() { + for (let i in this.parentReference) { + if (this.parentReference[i].length > 1) { + this.isTree = false; + return; + } + } + + this.isTree = true; + } + + + /** + * Return the number of separate trees in the current network. + * @returns {number} + */ + numTrees() { + return (this.treeIndex + 1); // This assumes the indexes are assigned consecitively + } + + + /** + * Assign a tree id to a node + * @param {Node} node + * @param {string|number} treeId + */ + setTreeIndex(node, treeId) { + if (treeId === undefined) return; // Don't bother + + if (this.trees[node.id] === undefined) { + this.trees[node.id] = treeId; + this.treeIndex = Math.max(treeId, this.treeIndex); + } + } + + + /** + * Ensure level for given id is defined. + * + * Sets level to zero for given node id if not already present + * + * @param {Node.id} nodeId + */ + ensureLevel(nodeId) { + if (this.levels[nodeId] === undefined) { + this.levels[nodeId] = 0; + } + } + + + /** + * get the maximum level of a branch. + * + * TODO: Never entered; find a test case to test this! + * @param {Node.id} nodeId + * @returns {number} + */ + getMaxLevel(nodeId) { + let accumulator = {}; + + let _getMaxLevel = (nodeId) => { + if (accumulator[nodeId] !== undefined) { + return accumulator[nodeId]; + } + let level = this.levels[nodeId]; + if (this.childrenReference[nodeId]) { + let children = this.childrenReference[nodeId]; + if (children.length > 0) { + for (let i = 0; i < children.length; i++) { + level = Math.max(level,_getMaxLevel(children[i])); + } + } + } + accumulator[nodeId] = level; + return level; + }; + + return _getMaxLevel(nodeId); + } + + + /** + * + * @param {Node} nodeA + * @param {Node} nodeB + */ + levelDownstream(nodeA, nodeB) { + if (this.levels[nodeB.id] === undefined) { + // set initial level + if (this.levels[nodeA.id] === undefined) { + this.levels[nodeA.id] = 0; + } + // set level + this.levels[nodeB.id] = this.levels[nodeA.id] + 1; + } + } + + + /** + * Small util method to set the minimum levels of the nodes to zero. + * + * @param {Array.} nodes + */ + setMinLevelToZero(nodes) { + let minLevel = 1e9; + // get the minimum level + for (let nodeId in nodes) { + if (nodes.hasOwnProperty(nodeId)) { + if (this.levels[nodeId] !== undefined) { + minLevel = Math.min(this.levels[nodeId], minLevel); + } + } + } + + // subtract the minimum from the set so we have a range starting from 0 + for (let nodeId in nodes) { + if (nodes.hasOwnProperty(nodeId)) { + if (this.levels[nodeId] !== undefined) { + this.levels[nodeId] -= minLevel; + } + } + } + } + + + /** + * Get the min and max xy-coordinates of a given tree + * + * @param {Array.} nodes + * @param {number} index + * @returns {{min_x: number, max_x: number, min_y: number, max_y: number}} + */ + getTreeSize(nodes, index) { + let min_x = 1e9; + let max_x = -1e9; + let min_y = 1e9; + let max_y = -1e9; + + for (let nodeId in this.trees) { + if (this.trees.hasOwnProperty(nodeId)) { + if (this.trees[nodeId] === index) { + let node = nodes[nodeId]; + min_x = Math.min(node.x, min_x); + max_x = Math.max(node.x, max_x); + min_y = Math.min(node.y, min_y); + max_y = Math.max(node.y, max_y); + } + } + } + + return { + min_x: min_x, + max_x: max_x, + min_y: min_y, + max_y: max_y + }; + } + + + /** + * Check if two nodes have the same parent(s) + * + * @param {Node} node1 + * @param {Node} node2 + * @return {boolean} true if the two nodes have a same ancestor node, false otherwise + */ + hasSameParent(node1, node2) { + let parents1 = this.parentReference[node1.id]; + let parents2 = this.parentReference[node2.id]; + if (parents1 === undefined || parents2 === undefined) { + return false; + } + + for (let i = 0; i < parents1.length; i++) { + for (let j = 0; j < parents2.length; j++) { + if (parents1[i] == parents2[j]) { + return true; + } + } + } + return false; + } + + + /** + * Check if two nodes are in the same tree. + * + * @param {Node} node1 + * @param {Node} node2 + * @return {Boolean} true if this is so, false otherwise + */ + inSameSubNetwork(node1, node2) { + return (this.trees[node1.id] === this.trees[node2.id]); + } + + + /** + * Get a list of the distinct levels in the current network + * + * @returns {Array} + */ + getLevels() { + return Object.keys(this.distributionOrdering); + } + + + /** + * Add a node to the ordering per level + * + * @param {Node} node + * @param {number} level + */ + addToOrdering(node, level) { + if (this.distributionOrdering[level] === undefined) { + this.distributionOrdering[level] = []; + } + + var isPresent = false; + var curLevel = this.distributionOrdering[level]; + for (var n in curLevel) { + //if (curLevel[n].id === node.id) { + if (curLevel[n] === node) { + isPresent = true; + break; + } + } + + if (!isPresent) { + this.distributionOrdering[level].push(node); + this.distributionIndex[node.id] = this.distributionOrdering[level].length - 1; + } + } +} + +/** + * The Layout Engine + */ class LayoutEngine { + /** + * @param {Object} body + */ constructor(body) { this.body = body; @@ -32,6 +352,9 @@ class LayoutEngine { this.bindEventListeners(); } + /** + * Binds event listeners + */ bindEventListeners() { this.body.emitter.on('_dataChanged', () => { this.setupHierarchicalLayout(); @@ -42,35 +365,55 @@ class LayoutEngine { this.body.emitter.on('_resetHierarchicalLayout', () => { this.setupHierarchicalLayout(); }); + this.body.emitter.on('_adjustEdgesForHierarchicalLayout', () => { + if (this.options.hierarchical.enabled !== true) { + return; + } + // get the type of static smooth curve in case it is required + let type = this.direction.curveType(); + + // force all edges into static smooth curves. + this.body.emitter.emit('_forceDisableDynamicCurves', type, false); + }); } + /** + * + * @param {Object} options + * @param {Object} allOptions + * @returns {Object} + */ setOptions(options, allOptions) { if (options !== undefined) { - let prevHierarchicalState = this.options.hierarchical.enabled; + let hierarchical = this.options.hierarchical; + let prevHierarchicalState = hierarchical.enabled; util.selectiveDeepExtend(["randomSeed", "improvedLayout"],this.options, options); util.mergeOptions(this.options, options, 'hierarchical'); if (options.randomSeed !== undefined) {this.initialRandomSeed = options.randomSeed;} - if (this.options.hierarchical.enabled === true) { + if (hierarchical.enabled === true) { if (prevHierarchicalState === true) { // refresh the overridden options for nodes and edges. this.body.emitter.emit('refresh', true); } // make sure the level separation 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; + if (hierarchical.direction === 'RL' || hierarchical.direction === 'DU') { + if (hierarchical.levelSeparation > 0) { + hierarchical.levelSeparation *= -1; } } else { - if (this.options.hierarchical.levelSeparation < 0) { - this.options.hierarchical.levelSeparation *= -1; + if (hierarchical.levelSeparation < 0) { + hierarchical.levelSeparation *= -1; } } + this.setDirectionStrategy(); + 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. + // because the hierarchical system needs it's own physics and smooth curve settings, + // we adapt the other options if needed. return this.adaptAllOptionsForHierarchicalLayout(allOptions); } else { @@ -84,34 +427,39 @@ class LayoutEngine { return allOptions; } + /** + * + * @param {Object} allOptions + * @returns {Object} + */ adaptAllOptionsForHierarchicalLayout(allOptions) { if (this.options.hierarchical.enabled === true) { + let backupPhysics = this.optionsBackup.physics; + // set the physics if (allOptions.physics === undefined || allOptions.physics === true) { allOptions.physics = { - enabled:this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled, - solver:'hierarchicalRepulsion' + enabled: backupPhysics.enabled === undefined ? true : backupPhysics.enabled, + solver :'hierarchicalRepulsion' }; - this.optionsBackup.physics.enabled = this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled; - this.optionsBackup.physics.solver = this.optionsBackup.physics.solver || 'barnesHut'; + backupPhysics.enabled = backupPhysics.enabled === undefined ? true : backupPhysics.enabled; + backupPhysics.solver = backupPhysics.solver || 'barnesHut'; } else if (typeof allOptions.physics === 'object') { - this.optionsBackup.physics.enabled = allOptions.physics.enabled === undefined ? true : allOptions.physics.enabled; - this.optionsBackup.physics.solver = allOptions.physics.solver || 'barnesHut'; + backupPhysics.enabled = allOptions.physics.enabled === undefined ? true : allOptions.physics.enabled; + backupPhysics.solver = allOptions.physics.solver || 'barnesHut'; allOptions.physics.solver = 'hierarchicalRepulsion'; } else if (allOptions.physics !== false) { - this.optionsBackup.physics.solver ='barnesHut'; + backupPhysics.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'; - } + let type = this.direction.curveType(); - // disable smooth curves if nothing is defined. If smooth curves have been turned on, turn them into static smooth curves. + // 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}; @@ -126,44 +474,59 @@ class LayoutEngine { allOptions.edges.smooth = {enabled: allOptions.edges.smooth, type:type} } else { + let smooth = allOptions.edges.smooth; + // allow custom types except for dynamic - if (allOptions.edges.smooth.type !== undefined && allOptions.edges.smooth.type !== 'dynamic') { - type = allOptions.edges.smooth.type; + if (smooth.type !== undefined && smooth.type !== 'dynamic') { + type = smooth.type; } + // TODO: this is options merging; see if the standard routines can be used here. 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, - forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection + smooth : smooth.enabled === undefined ? true : smooth.enabled, + type : smooth.type === undefined ? 'dynamic': smooth.type, + roundness : smooth.roundness === undefined ? 0.5 : smooth.roundness, + forceDirection: smooth.forceDirection === undefined ? false : smooth.forceDirection }; + + + // NOTE: Copying an object to self; this is basically setting defaults for undefined variables 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, - forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection + enabled : smooth.enabled === undefined ? true : smooth.enabled, + type : type, + roundness : smooth.roundness === undefined ? 0.5 : smooth.roundness, + forceDirection: smooth.forceDirection === undefined ? false: smooth.forceDirection } } } - // force all edges into static smooth curves. Only applies to edges that do not use the global options for smooth. + // 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; } + /** + * + * @returns {number} + */ seededRandom() { let x = Math.sin(this.randomSeed++) * 10000; return x - Math.floor(x); } + /** + * + * @param {Array.} nodesArray + */ positionInitially(nodesArray) { if (this.options.hierarchical.enabled !== true) { this.randomSeed = this.initialRandomSeed; + let radius = nodesArray.length + 50; 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); @@ -182,62 +545,102 @@ class LayoutEngine { */ layoutNetwork() { if (this.options.hierarchical.enabled !== true && this.options.improvedLayout === true) { + let indices = this.body.nodeIndices; + // first check if we should Kamada Kawai to layout. The threshold is if less than half of the visible // nodes have predefined positions we use this. let positionDefined = 0; - for (let i = 0; i < this.body.nodeIndices.length; i++) { - let node = this.body.nodes[this.body.nodeIndices[i]]; + for (let i = 0; i < indices.length; i++) { + let node = this.body.nodes[indices[i]]; if (node.predefinedPosition === true) { positionDefined += 1; } } // if less than half of the nodes have a predefined position we continue - if (positionDefined < 0.5 * this.body.nodeIndices.length) { + if (positionDefined < 0.5 * indices.length) { let MAX_LEVELS = 10; let level = 0; - let clusterThreshold = 100; + let clusterThreshold = 150; // TODO add this to options + + // + // Define the options for the hidden cluster nodes + // These options don't propagate outside the clustering phase. + // + // Some options are explicitly disabled, because they may be set in group or default node options. + // The clusters are never displayed, so most explicit settings here serve as performance optimizations. + // + // The explicit setting of 'shape' is to avoid `shape: 'image'`; images are not passed to the hidden + // cluster nodes, leading to an exception on creation. + // + // All settings here are performance related, except when noted otherwise. + // + let clusterOptions = { + clusterNodeProperties:{ + shape: 'ellipse', // Bugfix: avoid type 'image', no images supplied + label: '', // avoid label handling + group: '', // avoid group handling + font: {multi: false}, // avoid font propagation + }, + clusterEdgeProperties:{ + label: '', // avoid label handling + font: {multi: false}, // avoid font propagation + smooth: { + enabled: false // avoid drawing penalty for complex edges + } + } + }; + // if there are a lot of nodes, we cluster before we run the algorithm. - if (this.body.nodeIndices.length > clusterThreshold) { - let startLength = this.body.nodeIndices.length; - while (this.body.nodeIndices.length > clusterThreshold) { + // NOTE: this part fails to find clusters for large scale-free networks, which should + // be easily clusterable. + // TODO: examine why this is so + if (indices.length > clusterThreshold) { + let startLength = indices.length; + while (indices.length > clusterThreshold && level <= MAX_LEVELS) { //console.time("clustering") level += 1; - let before = this.body.nodeIndices.length; + let before = indices.length; // if there are many nodes we do a hubsize cluster if (level % 3 === 0) { - this.body.modules.clustering.clusterBridges(); + this.body.modules.clustering.clusterBridges(clusterOptions); } else { - this.body.modules.clustering.clusterOutliers(); + this.body.modules.clustering.clusterOutliers(clusterOptions); } - let after = this.body.nodeIndices.length; - if ((before == after && level % 3 !== 0) || level > MAX_LEVELS) { + let after = indices.length; + if (before == after && level % 3 !== 0) { this._declusterAll(); this.body.emitter.emit("_layoutFailed"); - console.info("This network could not be positioned by this version of the improved layout algorithm. Please disable improvedLayout for better performance."); + console.info("This network could not be positioned by this version of the improved layout algorithm." + + " Please disable improvedLayout for better performance."); return; } //console.timeEnd("clustering") - //console.log(level,after) + //console.log(before,level,after); } // increase the size of the edges this.body.modules.kamadaKawai.setOptions({springLength: Math.max(150, 2 * startLength)}) } + if (level > MAX_LEVELS){ + console.info("The clustering didn't succeed within the amount of interations allowed," + + " progressing with partial result."); + } // position the system for these nodes and edges - this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); + this.body.modules.kamadaKawai.solve(indices, this.body.edgeIndices, true); // shift to center point this._shiftToCenter(); // perturb the nodes a little bit to force the physics to kick in let offset = 70; - for (let i = 0; i < this.body.nodeIndices.length; i++) { + for (let i = 0; i < indices.length; i++) { // Only perturb the nodes that aren't fixed - if (this.body.nodes[this.body.nodeIndices[i]].predefinedPosition === false) { - this.body.nodes[this.body.nodeIndices[i]].x += (0.5 - this.seededRandom())*offset; - this.body.nodes[this.body.nodeIndices[i]].y += (0.5 - this.seededRandom())*offset; + let node = this.body.nodes[indices[i]]; + if (node.predefinedPosition === false) { + node.x += (0.5 - this.seededRandom())*offset; + node.y += (0.5 - this.seededRandom())*offset; } } @@ -258,11 +661,16 @@ class LayoutEngine { let range = NetworkUtil.getRangeCore(this.body.nodes, this.body.nodeIndices); let center = NetworkUtil.findCenter(range); for (let i = 0; i < this.body.nodeIndices.length; i++) { - this.body.nodes[this.body.nodeIndices[i]].x -= center.x; - this.body.nodes[this.body.nodeIndices[i]].y -= center.y; + let node = this.body.nodes[this.body.nodeIndices[i]]; + node.x -= center.x; + node.y -= center.y; } } + /** + * Expands all clusters + * @private + */ _declusterAll() { let clustersPresent = true; while (clustersPresent === true) { @@ -279,6 +687,10 @@ class LayoutEngine { } } + /** + * + * @returns {number|*} + */ getSeed() { return this.initialRandomSeed; } @@ -294,29 +706,16 @@ class LayoutEngine { // 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 definedPositions = true; let undefinedLevel = false; - this.hierarchicalLevels = {}; this.lastNodeOnLevel = {}; - this.hierarchicalChildrenReference = {}; - this.hierarchicalParentReference = {}; - this.hierarchicalTrees = {}; - this.treeIndex = -1; - - this.distributionOrdering = {}; - this.distributionIndex = {}; - this.distributionOrderingPresence = {}; - + this.hierarchical = new HierarchicalStatus(); for (nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { node = this.body.nodes[nodeId]; - if (node.options.x === undefined && node.options.y === undefined) { - definedPositions = false; - } if (node.options.level !== undefined) { definedLevel = true; - this.hierarchicalLevels[nodeId] = node.options.level; + this.hierarchical.levels[nodeId] = node.options.level; } else { undefinedLevel = true; @@ -326,19 +725,20 @@ class LayoutEngine { // 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; + throw new Error('To use the hierarchical layout, nodes require either no predefined levels' + + ' or levels have to be defined for all nodes.'); } else { // define levels if undefined by the users. Based on hubsize. if (undefinedLevel === true) { - if (this.options.hierarchical.sortMethod === 'hubsize') { + let sortMethod = this.options.hierarchical.sortMethod; + if (sortMethod === 'hubsize') { this._determineLevelsByHubsize(); } - else if (this.options.hierarchical.sortMethod === 'directed') { + else if (sortMethod === 'directed') { this._determineLevelsDirected(); } - else if (this.options.hierarchical.sortMethod === 'custom') { + else if (sortMethod === 'custom') { this._determineLevelsCustomCallback(); } } @@ -347,9 +747,7 @@ class LayoutEngine { // fallback for cases where there are nodes but no edges for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - if (this.hierarchicalLevels[nodeId] === undefined) { - this.hierarchicalLevels[nodeId] = 0; - } + this.hierarchical.ensureLevel(nodeId); } } @@ -383,46 +781,32 @@ class LayoutEngine { // the main method to shift the trees let shiftTrees = () => { let treeSizes = getTreeSizes(); + let shiftBy = 0; for (let i = 0; i < treeSizes.length - 1; i++) { let diff = treeSizes[i].max - treeSizes[i+1].min; - shiftTree(i + 1, diff + this.options.hierarchical.treeSpacing); + shiftBy += diff + this.options.hierarchical.treeSpacing; + shiftTree(i + 1, shiftBy); } }; // shift a single tree by an offset let shiftTree = (index, offset) => { - for (let nodeId in this.hierarchicalTrees) { - if (this.hierarchicalTrees.hasOwnProperty(nodeId)) { - if (this.hierarchicalTrees[nodeId] === index) { - let node = this.body.nodes[nodeId]; - let pos = this._getPositionForHierarchy(node); - this._setPositionForHierarchy(node, pos + offset, undefined, true); - } - } - } - }; + let trees = this.hierarchical.trees; - // get the width of a tree - let getTreeSize = (index) => { - let min = 1e9; - let max = -1e9; - for (let nodeId in this.hierarchicalTrees) { - if (this.hierarchicalTrees.hasOwnProperty(nodeId)) { - if (this.hierarchicalTrees[nodeId] === index) { - let pos = this._getPositionForHierarchy(this.body.nodes[nodeId]); - min = Math.min(pos, min); - max = Math.max(pos, max); + for (let nodeId in trees) { + if (trees.hasOwnProperty(nodeId)) { + if (trees[nodeId] === index) { + this.direction.shift(nodeId, offset); } } } - return {min:min, max:max}; }; // get the width of all trees let getTreeSizes = () => { let treeWidths = []; - for (let i = 0; i <= this.treeIndex; i++) { - treeWidths.push(getTreeSize(i)); + for (let i = 0; i < this.hierarchical.numTrees(); i++) { + treeWidths.push(this.direction.getTreeSize(i)); } return treeWidths; }; @@ -430,9 +814,12 @@ class LayoutEngine { // get a map of all nodes in this branch let getBranchNodes = (source, map) => { + if (map[source.id]) { + return; + } map[source.id] = true; - if (this.hierarchicalChildrenReference[source.id]) { - let children = this.hierarchicalChildrenReference[source.id]; + if (this.hierarchical.childrenReference[source.id]) { + let children = this.hierarchical.childrenReference[source.id]; if (children.length > 0) { for (let i = 0; i < children.length; i++) { getBranchNodes(this.body.nodes[children[i]], map); @@ -451,8 +838,8 @@ class LayoutEngine { for (let branchNode in branchMap) { if (branchMap.hasOwnProperty(branchNode)) { let node = this.body.nodes[branchNode]; - let level = this.hierarchicalLevels[node.id]; - let position = this._getPositionForHierarchy(node); + let level = this.hierarchical.levels[node.id]; + let position = this.direction.getPosition(node); // get the space around the node. let [minSpaceNode, maxSpaceNode] = this._getSpaceAroundNode(node,branchMap); @@ -468,88 +855,76 @@ class LayoutEngine { } return [min, max, minSpace, maxSpace]; - }; + } - // get the maximum level of a branch. - let getMaxLevel = (nodeId) => { - let level = this.hierarchicalLevels[nodeId]; - if (this.hierarchicalChildrenReference[nodeId]) { - let children = this.hierarchicalChildrenReference[nodeId]; - if (children.length > 0) { - for (let i = 0; i < children.length; i++) { - level = Math.max(level,getMaxLevel(children[i])); - } - } - } - return level; - }; // check what the maximum level is these nodes have in common. let getCollisionLevel = (node1, node2) => { - let maxLevel1 = getMaxLevel(node1.id); - let maxLevel2 = getMaxLevel(node2.id); + let maxLevel1 = this.hierarchical.getMaxLevel(node1.id); + let maxLevel2 = this.hierarchical.getMaxLevel(node2.id); return Math.min(maxLevel1, maxLevel2); }; - // check if two nodes have the same parent(s) - let hasSameParent = (node1, node2) => { - let parents1 = this.hierarchicalParentReference[node1.id]; - let parents2 = this.hierarchicalParentReference[node2.id]; - if (parents1 === undefined || parents2 === undefined) { - return false; - } - - for (let i = 0; i < parents1.length; i++) { - for (let j = 0; j < parents2.length; j++) { - if (parents1[i] == parents2[j]) { - return true; - } - } - } - return false; - }; - // condense elements. These can be nodes or branches depending on the callback. + /** + * Condense elements. These can be nodes or branches depending on the callback. + * + * @param {function} callback + * @param {Array.} levels + * @param {*} centerParents + */ let shiftElementsCloser = (callback, levels, centerParents) => { + let hier = this.hierarchical; + for (let i = 0; i < levels.length; i++) { let level = levels[i]; - let levelNodes = this.distributionOrdering[level]; + let levelNodes = hier.distributionOrdering[level]; if (levelNodes.length > 1) { for (let j = 0; j < levelNodes.length - 1; j++) { - if (hasSameParent(levelNodes[j],levelNodes[j+1]) === true) { - if (this.hierarchicalTrees[levelNodes[j].id] === this.hierarchicalTrees[levelNodes[j+1].id]) { - callback(levelNodes[j],levelNodes[j+1], centerParents); - } - }} + let node1 = levelNodes[j]; + let node2 = levelNodes[j+1]; + + // NOTE: logic maintained as it was; if nodes have same ancestor, + // then of course they are in the same sub-network. + if (hier.hasSameParent(node1, node2) && hier.inSameSubNetwork(node1, node2) ) { + callback(node1, node2, centerParents); + } + } } } }; + // callback for shifting branches let branchShiftCallback = (node1, node2, centerParent = false) => { //window.CALLBACKS.push(() => { - let pos1 = this._getPositionForHierarchy(node1); - let pos2 = this._getPositionForHierarchy(node2); + let pos1 = this.direction.getPosition(node1); + let pos2 = this.direction.getPosition(node2); let diffAbs = Math.abs(pos2 - pos1); - //console.log("NOW CHEcKING:", node1.id, node2.id, diffAbs); - if (diffAbs > this.options.hierarchical.nodeSpacing) { - let branchNodes1 = {}; branchNodes1[node1.id] = true; - let branchNodes2 = {}; branchNodes2[node2.id] = true; + let nodeSpacing = this.options.hierarchical.nodeSpacing; + //console.log("NOW CHECKING:", node1.id, node2.id, diffAbs); + if (diffAbs > nodeSpacing) { + let branchNodes1 = {}; + let branchNodes2 = {}; getBranchNodes(node1, branchNodes1); getBranchNodes(node2, branchNodes2); // check the largest distance between the branches let maxLevel = getCollisionLevel(node1, node2); - let [min1,max1, minSpace1, maxSpace1] = getBranchBoundary(branchNodes1, maxLevel); - let [min2,max2, minSpace2, maxSpace2] = getBranchBoundary(branchNodes2, maxLevel); - - //console.log(node1.id, getBranchBoundary(branchNodes1, maxLevel), node2.id, getBranchBoundary(branchNodes2, maxLevel), maxLevel); + let branchNodeBoundary1 = getBranchBoundary(branchNodes1, maxLevel); + let branchNodeBoundary2 = getBranchBoundary(branchNodes2, maxLevel); + let max1 = branchNodeBoundary1[1]; + let min2 = branchNodeBoundary2[0]; + let minSpace2 = branchNodeBoundary2[2]; + + //console.log(node1.id, getBranchBoundary(branchNodes1, maxLevel), node2.id, + // getBranchBoundary(branchNodes2, maxLevel), maxLevel); let diffBranch = Math.abs(max1 - min2); - if (diffBranch > this.options.hierarchical.nodeSpacing) { - let offset = max1 - min2 + this.options.hierarchical.nodeSpacing; - if (offset < -minSpace2 + this.options.hierarchical.nodeSpacing) { - offset = -minSpace2 + this.options.hierarchical.nodeSpacing; + if (diffBranch > nodeSpacing) { + let offset = max1 - min2 + nodeSpacing; + if (offset < -minSpace2 + nodeSpacing) { + offset = -minSpace2 + nodeSpacing; //console.log("RESETTING OFFSET", max1 - min2 + this.options.hierarchical.nodeSpacing, -minSpace2, offset); } if (offset < 0) { @@ -571,7 +946,7 @@ class LayoutEngine { // console.log("ts",node.id); let nodeId = node.id; let allEdges = node.edges; - let nodeLevel = this.hierarchicalLevels[node.id]; + let nodeLevel = this.hierarchical.levels[node.id]; // gather constants let C2 = this.options.hierarchical.levelSeparation * this.options.hierarchical.levelSeparation; @@ -582,7 +957,7 @@ class LayoutEngine { if (edge.toId != edge.fromId) { let otherNode = edge.toId == nodeId ? edge.from : edge.to; referenceNodes[allEdges[i].id] = otherNode; - if (this.hierarchicalLevels[otherNode.id] < nodeLevel) { + if (this.hierarchical.levels[otherNode.id] < nodeLevel) { aboveEdges.push(edge); } } @@ -593,7 +968,7 @@ class LayoutEngine { let sum = 0; for (let i = 0; i < edges.length; i++) { if (referenceNodes[edges[i].id] !== undefined) { - let a = this._getPositionForHierarchy(referenceNodes[edges[i].id]) - point; + let a = this.direction.getPosition(referenceNodes[edges[i].id]) - point; sum += a / Math.sqrt(a * a + C2); } } @@ -605,7 +980,7 @@ class LayoutEngine { let sum = 0; for (let i = 0; i < edges.length; i++) { if (referenceNodes[edges[i].id] !== undefined) { - let a = this._getPositionForHierarchy(referenceNodes[edges[i].id]) - point; + let a = this.direction.getPosition(referenceNodes[edges[i].id]) - point; sum -= (C2 * Math.pow(a * a + C2, -1.5)); } } @@ -613,7 +988,7 @@ class LayoutEngine { }; let getGuess = (iterations, edges) => { - let guess = this._getPositionForHierarchy(node); + let guess = this.direction.getPosition(node); // Newton's method for optimization let guessMap = {}; for (let i = 0; i < iterations; i++) { @@ -635,16 +1010,17 @@ class LayoutEngine { let moveBranch = (guess) => { // position node if there is space - let nodePosition = this._getPositionForHierarchy(node); + let nodePosition = this.direction.getPosition(node); // check movable area of the branch if (branches[node.id] === undefined) { let branchNodes = {}; - branchNodes[node.id] = true; getBranchNodes(node, branchNodes); branches[node.id] = branchNodes; } - let [minBranch, maxBranch, minSpaceBranch, maxSpaceBranch] = getBranchBoundary(branches[node.id]); + let branchBoundary = getBranchBoundary(branches[node.id]); + let minSpaceBranch = branchBoundary[2]; + let maxSpaceBranch = branchBoundary[3]; let diff = guess - nodePosition; @@ -666,7 +1042,7 @@ class LayoutEngine { }; let moveNode = (guess) => { - let nodePosition = this._getPositionForHierarchy(node); + let nodePosition = this.direction.getPosition(node); // position node if there is space let [minSpace, maxSpace] = this._getSpaceAroundNode(node); @@ -682,7 +1058,7 @@ class LayoutEngine { if (newPosition !== nodePosition) { //console.log("moving Node:",diff, minSpace, maxSpace); - this._setPositionForHierarchy(node, newPosition, undefined, true); + this.direction.setPosition(node, newPosition); //this.body.emitter.emit("_redraw"); stillShifting = true; } @@ -697,13 +1073,13 @@ class LayoutEngine { // method to remove whitespace between branches. Because we do bottom up, we can center the parents. let minimizeEdgeLengthBottomUp = (iterations) => { - let levels = Object.keys(this.distributionOrdering); + let levels = this.hierarchical.getLevels(); levels = levels.reverse(); for (let i = 0; i < iterations; i++) { stillShifting = false; for (let j = 0; j < levels.length; j++) { let level = levels[j]; - let levelNodes = this.distributionOrdering[level]; + let levelNodes = this.hierarchical.distributionOrdering[level]; for (let k = 0; k < levelNodes.length; k++) { minimizeEdgeLength(1000, levelNodes[k]); } @@ -717,7 +1093,7 @@ class LayoutEngine { // method to remove whitespace between branches. Because we do bottom up, we can center the parents. let shiftBranchesCloserBottomUp = (iterations) => { - let levels = Object.keys(this.distributionOrdering); + let levels = this.hierarchical.getLevels(); levels = levels.reverse(); for (let i = 0; i < iterations; i++) { stillShifting = false; @@ -739,11 +1115,11 @@ class LayoutEngine { // center all parents let centerAllParentsBottomUp = () => { - let levels = Object.keys(this.distributionOrdering); + let levels = this.hierarchical.getLevels(); levels = levels.reverse(); for (let i = 0; i < levels.length; i++) { let level = levels[i]; - let levelNodes = this.distributionOrdering[level]; + let levelNodes = this.hierarchical.distributionOrdering[level]; for (let j = 0; j < levelNodes.length; j++) { this._centerParent(levelNodes[j]); } @@ -771,9 +1147,9 @@ class LayoutEngine { /** * This gives the space around the node. IF a map is supplied, it will only check against nodes NOT in the map. * This is used to only get the distances to nodes outside of a branch. - * @param node - * @param map - * @returns {*[]} + * @param {Node} node + * @param {{Node.id: vis.Node}} map + * @returns {number[]} * @private */ _getSpaceAroundNode(node, map) { @@ -781,24 +1157,25 @@ class LayoutEngine { if (map === undefined) { useMap = false; } - let level = this.hierarchicalLevels[node.id]; + let level = this.hierarchical.levels[node.id]; if (level !== undefined) { - let index = this.distributionIndex[node.id]; - let position = this._getPositionForHierarchy(node); + let index = this.hierarchical.distributionIndex[node.id]; + let position = this.direction.getPosition(node); + let ordering = this.hierarchical.distributionOrdering[level]; let minSpace = 1e9; let maxSpace = 1e9; if (index !== 0) { - let prevNode = this.distributionOrdering[level][index - 1]; + let prevNode = ordering[index - 1]; if ((useMap === true && map[prevNode.id] === undefined) || useMap === false) { - let prevPos = this._getPositionForHierarchy(prevNode); + let prevPos = this.direction.getPosition(prevNode); minSpace = position - prevPos; } } - if (index != this.distributionOrdering[level].length - 1) { - let nextNode = this.distributionOrdering[level][index + 1]; + if (index != ordering.length - 1) { + let nextNode = ordering[index + 1]; if ((useMap === true && map[nextNode.id] === undefined) || useMap === false) { - let nextPos = this._getPositionForHierarchy(nextNode); + let nextPos = this.direction.getPosition(nextNode); maxSpace = Math.min(maxSpace, nextPos - position); } } @@ -810,36 +1187,30 @@ class LayoutEngine { } } + /** * We use this method to center a parent node and check if it does not cross other nodes when it does. - * @param node + * @param {Node} node * @private */ _centerParent(node) { - if (this.hierarchicalParentReference[node.id]) { - let parents = this.hierarchicalParentReference[node.id]; + if (this.hierarchical.parentReference[node.id]) { + let parents = this.hierarchical.parentReference[node.id]; for (var i = 0; i < parents.length; i++) { let parentId = parents[i]; let parentNode = this.body.nodes[parentId]; - if (this.hierarchicalChildrenReference[parentId]) { + let children = this.hierarchical.childrenReference[parentId]; + + if (children !== undefined) { // get the range of the children - let minPos = 1e9; - let maxPos = -1e9; - let children = this.hierarchicalChildrenReference[parentId]; - if (children.length > 0) { - for (let i = 0; i < children.length; i++) { - let childNode = this.body.nodes[children[i]]; - minPos = Math.min(minPos, this._getPositionForHierarchy(childNode)); - maxPos = Math.max(maxPos, this._getPositionForHierarchy(childNode)); - } - } + let newPosition = this._getCenterPosition(children); - let position = this._getPositionForHierarchy(parentNode); + let position = this.direction.getPosition(parentNode); let [minSpace, maxSpace] = this._getSpaceAroundNode(parentNode); - let newPosition = 0.5 * (minPos + maxPos); let diff = position - newPosition; - if ((diff < 0 && Math.abs(diff) < maxSpace - this.options.hierarchical.nodeSpacing) || (diff > 0 && Math.abs(diff) < minSpace - this.options.hierarchical.nodeSpacing)) { - this._setPositionForHierarchy(parentNode, newPosition, undefined, true); + if ((diff < 0 && Math.abs(diff) < maxSpace - this.options.hierarchical.nodeSpacing) || + (diff > 0 && Math.abs(diff) < minSpace - this.options.hierarchical.nodeSpacing)) { + this.direction.setPosition(parentNode, newPosition); } } } @@ -847,7 +1218,6 @@ class LayoutEngine { } - /** * This function places the nodes on the canvas based on the hierarchial distribution. * @@ -862,17 +1232,21 @@ class LayoutEngine { // sort nodes in level by position: let nodeArray = Object.keys(distribution[level]); nodeArray = this._indexArrayToNodes(nodeArray); - this._sortNodeArray(nodeArray); + this.direction.sort(nodeArray); let handledNodeCount = 0; for (let i = 0; i < nodeArray.length; i++) { let node = nodeArray[i]; if (this.positionedNodes[node.id] === undefined) { - let pos = this.options.hierarchical.nodeSpacing * handledNodeCount; - // we get the X or Y values we need and store them in pos and previousPos. The get and set make sure we get X or Y - if (handledNodeCount > 0) {pos = this._getPositionForHierarchy(nodeArray[i-1]) + this.options.hierarchical.nodeSpacing;} - this._setPositionForHierarchy(node, pos, level); - this._validataPositionAndContinue(node, level, pos); + let spacing = this.options.hierarchical.nodeSpacing; + let pos = spacing * handledNodeCount; + // We get the X or Y values we need and store them in pos and previousPos. + // The get and set make sure we get X or Y + if (handledNodeCount > 0) { + pos = this.direction.getPosition(nodeArray[i-1]) + spacing; + } + this.direction.setPosition(node, pos, level); + this._validatePositionAndContinue(node, level, pos); handledNodeCount++; } @@ -886,39 +1260,46 @@ class LayoutEngine { * 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 parentId - * @param parentLevel + * @param {Node.id} parentId + * @param {number} parentLevel * @private */ _placeBranchNodes(parentId, parentLevel) { + let childRef = this.hierarchical.childrenReference[parentId]; + // if this is not a parent, cancel the placing. This can happen with multiple parents to one child. - if (this.hierarchicalChildrenReference[parentId] === undefined) { + if (childRef === undefined) { return; } // get a list of childNodes let childNodes = []; - for (let i = 0; i < this.hierarchicalChildrenReference[parentId].length; i++) { - childNodes.push(this.body.nodes[this.hierarchicalChildrenReference[parentId][i]]); + for (let i = 0; i < childRef.length; i++) { + childNodes.push(this.body.nodes[childRef[i]]); } // use the positions to order the nodes. - this._sortNodeArray(childNodes); + this.direction.sort(childNodes); // position the childNodes for (let i = 0; i < childNodes.length; i++) { let childNode = childNodes[i]; - let childNodeLevel = this.hierarchicalLevels[childNode.id]; + let childNodeLevel = this.hierarchical.levels[childNode.id]; // check if the child node is below the parent node and if it has already been positioned. if (childNodeLevel > parentLevel && this.positionedNodes[childNode.id] === undefined) { // get the amount of space required for this node. If parent the width is based on the amount of children. + let spacing = this.options.hierarchical.nodeSpacing; let pos; - // we get the X or Y values we need and store them in pos and previousPos. The get and set make sure we get X or Y - if (i === 0) {pos = this._getPositionForHierarchy(this.body.nodes[parentId]);} - else {pos = this._getPositionForHierarchy(childNodes[i-1]) + this.options.hierarchical.nodeSpacing;} - this._setPositionForHierarchy(childNode, pos, childNodeLevel); - this._validataPositionAndContinue(childNode, childNodeLevel, pos); + // we get the X or Y values we need and store them in pos and previousPos. + // The get and set make sure we get X or Y + if (i === 0) { + pos = this.direction.getPosition(this.body.nodes[parentId]); + } else { + pos = this.direction.getPosition(childNodes[i-1]) + spacing; + } + this.direction.setPosition(childNode, pos, childNodeLevel); + this._validatePositionAndContinue(childNode, childNodeLevel, pos); } else { return; @@ -926,29 +1307,27 @@ class LayoutEngine { } // center the parent nodes. - let minPos = 1e9; - let maxPos = -1e9; - for (let i = 0; i < childNodes.length; i++) { - let childNodeId = childNodes[i].id; - minPos = Math.min(minPos, this._getPositionForHierarchy(this.body.nodes[childNodeId])); - maxPos = Math.max(maxPos, this._getPositionForHierarchy(this.body.nodes[childNodeId])); - } - this._setPositionForHierarchy(this.body.nodes[parentId], 0.5 * (minPos + maxPos), parentLevel); + let center = this._getCenterPosition(childNodes); + this.direction.setPosition(this.body.nodes[parentId], center, parentLevel); } /** * This method checks for overlap and if required shifts the branch. It also keeps records of positioned nodes. * Finally it will call _placeBranchNodes to place the branch nodes. - * @param node - * @param level - * @param pos + * @param {Node} node + * @param {number} level + * @param {number} pos * @private */ - _validataPositionAndContinue(node, level, pos) { + _validatePositionAndContinue(node, level, pos) { + // This method only works for formal trees and formal forests + // Early exit if this is not the case + if (!this.hierarchical.isTree) return; + // if overlap has been detected, we shift the branch if (this.lastNodeOnLevel[level] !== undefined) { - let previousPos = this._getPositionForHierarchy(this.body.nodes[this.lastNodeOnLevel[level]]); + let previousPos = this.direction.getPosition(this.body.nodes[this.lastNodeOnLevel[level]]); if (pos - previousPos < this.options.hierarchical.nodeSpacing) { let diff = (previousPos + this.options.hierarchical.nodeSpacing) - pos; let sharedParent = this._findCommonParent(this.lastNodeOnLevel[level], node.id); @@ -956,18 +1335,16 @@ class LayoutEngine { } } - // store change in position. - this.lastNodeOnLevel[level] = node.id; - + this.lastNodeOnLevel[level] = node.id; // store change in position. this.positionedNodes[node.id] = true; - this._placeBranchNodes(node.id, level); } /** - * Receives an array with node indices and returns an array with the actual node references. Used for sorting based on - * node properties. - * @param idArray + * Receives an array with node indices and returns an array with the actual node references. + * Used for sorting based on node properties. + * @param {Array.} idArray + * @returns {Array.} */ _indexArrayToNodes(idArray) { let array = []; @@ -987,20 +1364,14 @@ class LayoutEngine { 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. + // 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; - } + let level = this.hierarchical.levels[nodeId] === undefined ? 0 : this.hierarchical.levels[nodeId]; + this.direction.fix(node, level); if (distribution[level] === undefined) { distribution[level] = {}; } @@ -1012,78 +1383,102 @@ class LayoutEngine { /** - * Get the hubsize from all remaining unlevelled nodes. + * Return the active (i.e. visible) edges for this node * - * @returns {number} + * @param {Node} node + * @returns {Array.} Array of edge instances * @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; - } + _getActiveEdges(node) { + let result = []; + + util.forEach(node.edges, (edge) => { + if (this.body.edgeIndices.indexOf(edge.id) !== -1) { + result.push(edge); } - } - return hubSize; + }); + + return result; + } + + + /** + * Get the hubsizes for all active nodes. + * + * @returns {number} + * @private + */ + _getHubSizes() { + let hubSizes = {}; + let nodeIds = this.body.nodeIndices; + + util.forEach(nodeIds, (nodeId) => { + let node = this.body.nodes[nodeId]; + let hubSize = this._getActiveEdges(node).length; + hubSizes[hubSize] = true; + }); + + // Make an array of the size sorted descending + let result = []; + util.forEach(hubSizes, (size) => { + result.push(Number(size)); + }); + + result.sort(function(a, b) { + return b - a; + }); + + return result; } /** * this function allocates nodes in levels based on the recursive branching from the largest hubs. * - * @param hubsize * @private */ _determineLevelsByHubsize() { - let hubSize = 1; - let levelDownstream = (nodeA, nodeB) => { - if (this.hierarchicalLevels[nodeB.id] === undefined) { - // set initial level - if (this.hierarchicalLevels[nodeA.id] === undefined) { - this.hierarchicalLevels[nodeA.id] = 0; - } - // set level - this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + 1; - } - }; + this.hierarchical.levelDownstream(nodeA, nodeB); + } - while (hubSize > 0) { - // determine hubs - hubSize = this._getHubSize(); - if (hubSize === 0) - break; + let hubSizes = this._getHubSizes(); - for (let nodeId in this.body.nodes) { - if (this.body.nodes.hasOwnProperty(nodeId)) { - let node = this.body.nodes[nodeId]; - if (node.edges.length === hubSize) { - this._crawlNetwork(levelDownstream,nodeId); - } + for (let i = 0; i < hubSizes.length; ++i ) { + let hubSize = hubSizes[i]; + if (hubSize === 0) break; + + util.forEach(this.body.nodeIndices, (nodeId) => { + let node = this.body.nodes[nodeId]; + + if (hubSize === this._getActiveEdges(node).length) { + this._crawlNetwork(levelDownstream, nodeId); } - } + }); } } + /** * TODO: release feature + * TODO: Determine if this feature is needed at all + * * @private */ _determineLevelsCustomCallback() { let minLevel = 100000; // TODO: this should come from options. - let customCallback = function(nodeA, nodeB, edge) { + let customCallback = function(nodeA, nodeB, edge) { // eslint-disable-line no-unused-vars }; + // TODO: perhaps move to HierarchicalStatus. + // But I currently don't see the point, this method is not used. let levelByDirection = (nodeA, nodeB, edge) => { - let levelA = this.hierarchicalLevels[nodeA.id]; + let levelA = this.hierarchical.levels[nodeA.id]; // set initial level - if (levelA === undefined) {this.hierarchicalLevels[nodeA.id] = minLevel;} + if (levelA === undefined) { levelA = this.hierarchical.levels[nodeA.id] = minLevel;} let diff = customCallback( NetworkUtil.cloneOptions(nodeA,'node'), @@ -1091,60 +1486,59 @@ class LayoutEngine { NetworkUtil.cloneOptions(edge,'edge') ); - this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + diff; + this.hierarchical.levels[nodeB.id] = levelA + diff; }; this._crawlNetwork(levelByDirection); - this._setMinLevelToZero(); + this.hierarchical.setMinLevelToZero(this.body.nodes); } /** - * this function allocates nodes in levels based on the direction of the edges + * Allocate nodes in levels based on the direction of the edges. * - * @param hubsize * @private */ _determineLevelsDirected() { let minLevel = 10000; + + /** + * Check if there is an edge going the opposite direction for given edge + * + * @param {Edge} edge edge to check + * @returns {boolean} true if there's another edge going into the opposite direction + */ + let isBidirectional = (edge) => { + util.forEach(this.body.edges, (otherEdge) => { + if (otherEdge.toId === edge.fromId && otherEdge.fromId === edge.toId) { + return true; + } + }); + + return false; + }; + + let levelByDirection = (nodeA, nodeB, edge) => { - let levelA = this.hierarchicalLevels[nodeA.id]; + let levelA = this.hierarchical.levels[nodeA.id]; + let levelB = this.hierarchical.levels[nodeB.id]; + + if (isBidirectional(edge) && levelA !== undefined && levelB !== undefined) { + // Don't redo the level determination if already done in this case. + return; + } + // set initial level - if (levelA === undefined) {this.hierarchicalLevels[nodeA.id] = minLevel;} + if (levelA === undefined) { levelA = this.hierarchical.levels[nodeA.id] = minLevel;} if (edge.toId == nodeB.id) { - this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + 1; + this.hierarchical.levels[nodeB.id] = levelA + 1; } else { - this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] - 1; + this.hierarchical.levels[nodeB.id] = levelA - 1; } }; - this._crawlNetwork(levelByDirection); - this._setMinLevelToZero(); - } - - - /** - * Small util method to set the minimum levels of the nodes to zero. - * @private - */ - _setMinLevelToZero() { - let minLevel = 1e9; - // get the minimum level - for (let nodeId in this.body.nodes) { - if (this.body.nodes.hasOwnProperty(nodeId)) { - if (this.hierarchicalLevels[nodeId] !== undefined) { - minLevel = Math.min(this.hierarchicalLevels[nodeId], minLevel); - } - } - } - // subtract the minimum from the set so we have a range starting from 0 - for (let nodeId in this.body.nodes) { - if (this.body.nodes.hasOwnProperty(nodeId)) { - if (this.hierarchicalLevels[nodeId] !== undefined) { - this.hierarchicalLevels[nodeId] -= minLevel; - } - } - } + this._crawlNetwork(levelByDirection); + this.hierarchical.setMinLevelToZero(this.body.nodes); } @@ -1154,55 +1548,44 @@ class LayoutEngine { */ _generateMap() { let fillInRelations = (parentNode, childNode) => { - if (this.hierarchicalLevels[childNode.id] > this.hierarchicalLevels[parentNode.id]) { - let parentNodeId = parentNode.id; - let childNodeId = childNode.id; - if (this.hierarchicalChildrenReference[parentNodeId] === undefined) { - this.hierarchicalChildrenReference[parentNodeId] = []; - } - this.hierarchicalChildrenReference[parentNodeId].push(childNodeId); - if (this.hierarchicalParentReference[childNodeId] === undefined) { - this.hierarchicalParentReference[childNodeId] = []; - } - this.hierarchicalParentReference[childNodeId].push(parentNodeId); + if (this.hierarchical.levels[childNode.id] > this.hierarchical.levels[parentNode.id]) { + this.hierarchical.addRelation(parentNode.id, childNode.id); } }; this._crawlNetwork(fillInRelations); + this.hierarchical.checkIfTree(); } /** * Crawl over the entire network and use a callback on each node couple that is connected to each other. - * @param callback | will receive nodeA nodeB and the connecting edge. A and B are unique. - * @param startingNodeId + * @param {function} [callback=function(){}] | will receive nodeA, nodeB and the connecting edge. A and B are distinct. + * @param {Node.id} startingNodeId * @private */ _crawlNetwork(callback = function() {}, startingNodeId) { let progress = {}; - let treeIndex = 0; let crawler = (node, tree) => { if (progress[node.id] === undefined) { - - if (this.hierarchicalTrees[node.id] === undefined) { - this.hierarchicalTrees[node.id] = tree; - this.treeIndex = Math.max(tree, this.treeIndex); - } + this.hierarchical.setTreeIndex(node, tree); progress[node.id] = true; let childNode; - for (let i = 0; i < node.edges.length; i++) { - if (node.edges[i].connected === true) { - if (node.edges[i].toId === node.id) { - childNode = node.edges[i].from; + let edges = this._getActiveEdges(node); + for (let i = 0; i < edges.length; i++) { + let edge = edges[i]; + if (edge.connected === true) { + if (edge.toId == node.id) { // Not '===' because id's can be string and numeric + childNode = edge.from; } else { - childNode = node.edges[i].to; + childNode = edge.to; } - if (node.id !== childNode.id) { - callback(node, childNode, node.edges[i]); + if (node.id != childNode.id) { // Not '!==' because id's can be string and numeric + callback(node, childNode, edge); crawler(childNode, tree); } } @@ -1211,17 +1594,22 @@ class LayoutEngine { }; - // we can crawl from a specific node or over all nodes. if (startingNodeId === undefined) { + // Crawl over all nodes + let treeIndex = 0; // Serves to pass a unique id for the current distinct tree + for (let i = 0; i < this.body.nodeIndices.length; i++) { - let node = this.body.nodes[this.body.nodeIndices[i]]; - if (progress[node.id] === undefined) { + let nodeId = this.body.nodeIndices[i]; + + if (progress[nodeId] === undefined) { + let node = this.body.nodes[nodeId]; crawler(node, treeIndex); treeIndex += 1; } } } else { + // Crawl from the given starting node let node = this.body.nodes[startingNodeId]; if (node === undefined) { console.error("Node not found:", startingNodeId); @@ -1234,47 +1622,54 @@ class LayoutEngine { /** * Shift a branch a certain distance - * @param parentId - * @param diff + * @param {Node.id} parentId + * @param {number} diff * @private */ _shiftBlock(parentId, diff) { - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - this.body.nodes[parentId].x += diff; - } - else { - this.body.nodes[parentId].y += diff; - } - if (this.hierarchicalChildrenReference[parentId] !== undefined) { - for (let i = 0; i < this.hierarchicalChildrenReference[parentId].length; i++) { - this._shiftBlock(this.hierarchicalChildrenReference[parentId][i], diff); + let progress = {}; + let shifter = (parentId) => { + if (progress[parentId]) { + return; } - } + progress[parentId] = true; + this.direction.shift(parentId, diff); + + let childRef = this.hierarchical.childrenReference[parentId]; + if (childRef !== undefined) { + for (let i = 0; i < childRef.length; i++) { + shifter(childRef[i]); + } + } + }; + shifter(parentId); } /** * Find a common parent between branches. - * @param childA - * @param childB + * @param {Node.id} childA + * @param {Node.id} childB * @returns {{foundParent, withChild}} * @private */ _findCommonParent(childA,childB) { let parents = {}; let iterateParents = (parents,child) => { - if (this.hierarchicalParentReference[child] !== undefined) { - for (let i = 0; i < this.hierarchicalParentReference[child].length; i++) { - let parent = this.hierarchicalParentReference[child][i]; + let parentRef = this.hierarchical.parentReference[child]; + if (parentRef !== undefined) { + for (let i = 0; i < parentRef.length; i++) { + let parent = parentRef[i]; parents[parent] = true; iterateParents(parents, parent) } } }; let findParent = (parents, child) => { - if (this.hierarchicalParentReference[child] !== undefined) { - for (let i = 0; i < this.hierarchicalParentReference[child].length; i++) { - let parent = this.hierarchicalParentReference[child][i]; + let parentRef = this.hierarchical.parentReference[child]; + if (parentRef !== undefined) { + for (let i = 0; i < parentRef.length; i++) { + let parent = parentRef[i]; if (parents[parent] !== undefined) { return {foundParent:parent, withChild:child}; } @@ -1291,73 +1686,58 @@ class LayoutEngine { return findParent(parents, childB); } - /** - * Abstract the getting of the position so we won't have to repeat the check for direction all the time - * @param node - * @param position - * @param level - * @private - */ - _setPositionForHierarchy(node, position, level, doNotUpdate = false) { - //console.log('_setPositionForHierarchy',node.id, position) - if (doNotUpdate !== true) { - if (this.distributionOrdering[level] === undefined) { - this.distributionOrdering[level] = []; - this.distributionOrderingPresence[level] = {}; - } - - if (this.distributionOrderingPresence[level][node.id] === undefined) { - this.distributionOrdering[level].push(node); - this.distributionIndex[node.id] = this.distributionOrdering[level].length - 1; - } - this.distributionOrderingPresence[level][node.id] = true; - } - - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - node.x = position; - } - else { - node.y = position; - } - } /** - * Abstract the getting of the position of a node so we do not have to repeat the direction check all the time. - * @param node - * @returns {number|*} + * Set the strategy pattern for handling the coordinates given the current direction. + * + * The individual instances contain all the operations and data specific to a layout direction. + * + * @param {Node} node + * @param {{x: number, y: number}} position + * @param {number} level + * @param {boolean} [doNotUpdate=false] * @private */ - _getPositionForHierarchy(node) { - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - return node.x; - } - else { - return node.y; + setDirectionStrategy() { + var isVertical = (this.options.hierarchical.direction === 'UD' + || this.options.hierarchical.direction === 'DU'); + + if(isVertical) { + this.direction = new VerticalStrategy(this); + } else { + this.direction = new HorizontalStrategy(this); } } + /** - * Use the x or y value to sort the array, allowing users to specify order. - * @param nodeArray + * Determine the center position of a branch from the passed list of child nodes + * + * This takes into account the positions of all the child nodes. + * @param {Array.} childNodes Array of either child nodes or node id's + * @return {number} * @private */ - _sortNodeArray(nodeArray) { - if (nodeArray.length > 1) { - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - nodeArray.sort(function (a, b) { - return a.x - b.x; - }) - } - else { - nodeArray.sort(function (a, b) { - return a.y - b.y; - }) - } - } - } + _getCenterPosition(childNodes) { + let minPos = 1e9; + let maxPos = -1e9; + for (let i = 0; i < childNodes.length; i++) { + let childNode; + if (childNodes[i].id !== undefined) { + childNode = childNodes[i]; + } else { + let childNodeId = childNodes[i]; + childNode = this.body.nodes[childNodeId]; + } + let position = this.direction.getPosition(childNode); + minPos = Math.min(minPos, position); + maxPos = Math.max(maxPos, position); + } + return 0.5 * (minPos + maxPos); + } } -export default LayoutEngine; \ No newline at end of file +export default LayoutEngine; diff --git a/lib/network/modules/ManipulationSystem.js b/lib/network/modules/ManipulationSystem.js index 1334b3977..4fe2d2783 100644 --- a/lib/network/modules/ManipulationSystem.js +++ b/lib/network/modules/ManipulationSystem.js @@ -4,11 +4,16 @@ let Hammer = require('../../module/hammer'); let hammerUtil = require('../../hammerUtil'); /** - * clears the toolbar div element of children + * Clears the toolbar div element of children * * @private */ class ManipulationSystem { + /** + * @param {Object} body + * @param {Canvas} canvas + * @param {SelectionHandler} selectionHandler + */ constructor(body, canvas, selectionHandler) { this.body = body; this.canvas = canvas; @@ -72,7 +77,10 @@ class ManipulationSystem { /** * Set the Options - * @param options + * + * @param {Object} options + * @param {Object} allOptions + * @param {Object} globalOptions */ setOptions(options, allOptions, globalOptions) { if (allOptions !== undefined) { @@ -111,6 +119,9 @@ class ManipulationSystem { } + /** + * Enables Edit Mode + */ enableEditMode() { this.editMode = true; @@ -123,6 +134,9 @@ class ManipulationSystem { } } + /** + * Disables Edit Mode + */ disableEditMode() { this.editMode = false; @@ -322,8 +336,7 @@ class ManipulationSystem { this._temporaryBindUI('onDragEnd', this._finishConnect.bind(this)); this._temporaryBindUI('onDrag', this._dragControlNode.bind(this)); this._temporaryBindUI('onRelease', this._finishConnect.bind(this)); - - this._temporaryBindUI('onDragStart', () => {}); + this._temporaryBindUI('onDragStart',this._dragStartEdge.bind(this)); this._temporaryBindUI('onHold', () => {}); } @@ -340,6 +353,14 @@ class ManipulationSystem { this._clean(); this.inMode = 'editEdge'; + if (typeof this.options.editEdge === 'object' && typeof this.options.editEdge.editWithoutDrag === "function") { + this.edgeBeingEditedId = this.selectionHandler.getSelectedEdges()[0]; + if (this.edgeBeingEditedId !== undefined) { + var edge = this.body.edges[this.edgeBeingEditedId]; + this._performEditEdge(edge.from, edge.to); + return; + } + } if (this.guiEnabled === true) { let locale = this.options.locales[this.options.locale]; this.manipulationDOM = {}; @@ -534,9 +555,10 @@ class ManipulationSystem { /** * generate a new target node. Used for creating new edges and editing edges - * @param x - * @param y - * @returns {*} + * + * @param {number} x + * @param {number} y + * @returns {Node} * @private */ _getNewTargetNode(x,y) { @@ -653,7 +675,7 @@ class ManipulationSystem { /** * create a seperator line. the index is to differentiate in the manipulation dom - * @param index + * @param {number} [index=1] * @private */ _createSeperator(index = 1) { @@ -664,47 +686,87 @@ class ManipulationSystem { // ---------------------- DOM functions for buttons --------------------------// + /** + * + * @param {Locale} locale + * @private + */ _createAddNodeButton(locale) { let button = this._createButton('addNode', 'vis-button vis-add', locale['addNode'] || this.options.locales['en']['addNode']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.addNodeMode.bind(this)); } + /** + * + * @param {Locale} locale + * @private + */ _createAddEdgeButton(locale) { let button = this._createButton('addEdge', 'vis-button vis-connect', locale['addEdge'] || this.options.locales['en']['addEdge']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.addEdgeMode.bind(this)); } + /** + * + * @param {Locale} locale + * @private + */ _createEditNodeButton(locale) { let button = this._createButton('editNode', 'vis-button vis-edit', locale['editNode'] || this.options.locales['en']['editNode']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.editNode.bind(this)); } + /** + * + * @param {Locale} locale + * @private + */ _createEditEdgeButton(locale) { let button = this._createButton('editEdge', 'vis-button vis-edit', locale['editEdge'] || this.options.locales['en']['editEdge']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.editEdgeMode.bind(this)); } + /** + * + * @param {Locale} locale + * @private + */ _createDeleteButton(locale) { + var deleteBtnClass; if (this.options.rtl) { - var deleteBtnClass = 'vis-button vis-delete-rtl'; + deleteBtnClass = 'vis-button vis-delete-rtl'; } else { - var deleteBtnClass = 'vis-button vis-delete'; + deleteBtnClass = 'vis-button vis-delete'; } let button = this._createButton('delete', deleteBtnClass, locale['del'] || this.options.locales['en']['del']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.deleteSelected.bind(this)); } + /** + * + * @param {Locale} locale + * @private + */ _createBackButton(locale) { let button = this._createButton('back', 'vis-button vis-back', locale['back'] || this.options.locales['en']['back']); this.manipulationDiv.appendChild(button); this._bindHammerToDiv(button, this.showManipulatorToolbar.bind(this)); } + /** + * + * @param {number|string} id + * @param {string} className + * @param {label} label + * @param {string} labelClassName + * @returns {HTMLElement} + * @private + */ _createButton(id, className, label, labelClassName = 'vis-label') { this.manipulationDOM[id+'Div'] = document.createElement('div'); @@ -716,6 +778,11 @@ class ManipulationSystem { return this.manipulationDOM[id+'Div']; } + /** + * + * @param {Label} label + * @private + */ _createDescription(label) { this.manipulationDiv.appendChild( this._createButton('description', 'vis-button vis-none', label) @@ -726,8 +793,8 @@ class ManipulationSystem { /** * this binds an event until cleanup by the clean functions. - * @param event - * @param newFunction + * @param {Event} event The event + * @param {function} newFunction * @private */ _temporaryBindEvent(event, newFunction) { @@ -737,8 +804,8 @@ class ManipulationSystem { /** * this overrides an UI function until cleanup by the clean function - * @param UIfunctionName - * @param newFunction + * @param {string} UIfunctionName + * @param {function} newFunction * @private */ _temporaryBindUI(UIfunctionName, newFunction) { @@ -781,8 +848,9 @@ class ManipulationSystem { /** * Bind an hammer instance to a DOM element. - * @param domElement - * @param funct + * + * @param {Element} domElement + * @param {function} boundFunction */ _bindHammerToDiv(domElement, boundFunction) { let hammer = new Hammer(domElement, {}); @@ -818,7 +886,7 @@ class ManipulationSystem { /** * the touch is used to get the position of the initial click - * @param event + * @param {Event} event The event * @private */ _controlNodeTouch(event) { @@ -830,10 +898,10 @@ class ManipulationSystem { /** * the drag start is used to mark one of the control nodes as selected. - * @param event + * @param {Event} event The event * @private */ - _controlNodeDragStart(event) { + _controlNodeDragStart(event) { // eslint-disable-line no-unused-vars let pointer = this.lastTouch; let pointerObj = this.selectionHandler._pointerToPositionObject(pointer); let from = this.body.nodes[this.temporaryIds.nodes[0]]; @@ -863,7 +931,7 @@ class ManipulationSystem { /** * dragging the control nodes or the canvas - * @param event + * @param {Event} event The event * @private */ _controlNodeDrag(event) { @@ -886,7 +954,7 @@ class ManipulationSystem { /** * connecting or restoring the control nodes. - * @param event + * @param {Event} event The event * @private */ _controlNodeDragEnd(event) { @@ -927,6 +995,7 @@ class ManipulationSystem { edge.updateEdgeType(); this.body.emitter.emit('restorePhysics'); } + this.body.emitter.emit('_redraw'); } @@ -939,6 +1008,7 @@ class ManipulationSystem { * the function bound to the selection event. It checks if you want to connect a cluster and changes the description * to walk the user through the process. * + * @param {Event} event * @private */ _handleConnect(event) { @@ -983,6 +1053,11 @@ class ManipulationSystem { } } + /** + * + * @param {Event} event + * @private + */ _dragControlNode(event) { let pointer = this.body.functions.getPointer(event.center); if (this.temporaryIds.nodes[0] !== undefined) { @@ -1002,7 +1077,7 @@ class ManipulationSystem { /** * Connect the new edge to the target if one exists, otherwise remove temp line - * @param event + * @param {Event} event The event * @private */ _finishConnect(event) { @@ -1040,9 +1115,23 @@ class ManipulationSystem { } } } + + + // No need to do _generateclickevent('dragEnd') here, the regular dragEnd event fires. this.body.emitter.emit('_redraw'); } + + /** + * + * @param {Event} event + * @private + */ + _dragStartEdge(event) { + let pointer = this.lastTouch; + this.selectionHandler._generateClickEvent('dragStart', event, pointer, undefined, true); + } + // --------------------------------------- END OF ADD EDGE FUNCTIONS -------------------------------------// @@ -1050,6 +1139,9 @@ class ManipulationSystem { /** * Adds a node on the specified location + * + * @param {Object} clickData + * @private */ _performAddNode(clickData) { let defaultData = { @@ -1069,8 +1161,8 @@ class ManipulationSystem { }); } else { - throw new Error('The function for add does not support two arguments (data,callback)'); this.showManipulatorToolbar(); + throw new Error('The function for add does not support two arguments (data,callback)'); } } else { @@ -1083,6 +1175,8 @@ class ManipulationSystem { /** * connect two nodes with a new edge. * + * @param {Node.id} sourceNodeId + * @param {Node.id} targetNodeId * @private */ _performAddEdge(sourceNodeId, targetNodeId) { @@ -1111,16 +1205,23 @@ class ManipulationSystem { /** * connect two nodes with a new edge. * + * @param {Node.id} sourceNodeId + * @param {Node.id} targetNodeId * @private */ _performEditEdge(sourceNodeId, targetNodeId) { - let defaultData = {id: this.edgeBeingEditedId, from: sourceNodeId, to: targetNodeId}; - if (typeof this.options.editEdge === 'function') { - if (this.options.editEdge.length === 2) { - this.options.editEdge(defaultData, (finalizedData) => { + let defaultData = {id: this.edgeBeingEditedId, from: sourceNodeId, to: targetNodeId, label: this.body.data.edges._data[this.edgeBeingEditedId].label }; + let eeFunct = this.options.editEdge; + if (typeof eeFunct === 'object') { + eeFunct = eeFunct.editWithoutDrag; + } + if (typeof eeFunct === 'function') { + if (eeFunct.length === 2) { + eeFunct(defaultData, (finalizedData) => { if (finalizedData === null || finalizedData === undefined || this.inMode !== 'editEdge') { // if for whatever reason the mode has changes (due to dataset change) disregard the callback) { this.body.edges[defaultData.id].updateEdgeType(); this.body.emitter.emit('_redraw'); + this.showManipulatorToolbar(); } else { this.body.data.edges.getDataSet().update(finalizedData); @@ -1144,4 +1245,3 @@ class ManipulationSystem { } export default ManipulationSystem; - diff --git a/lib/network/modules/NodesHandler.js b/lib/network/modules/NodesHandler.js index e548bd417..c726df338 100644 --- a/lib/network/modules/NodesHandler.js +++ b/lib/network/modules/NodesHandler.js @@ -1,11 +1,19 @@ let util = require("../../util"); let DataSet = require('../../DataSet'); let DataView = require('../../DataView'); +var Node = require("./components/Node").default; -import Node from "./components/Node"; -import Label from "./components/shared/Label"; +/** + * Handler for Nodes + */ class NodesHandler { + /** + * @param {Object} body + * @param {Images} images + * @param {Array.} groups + * @param {LayoutEngine} layoutEngine + */ constructor(body, images, groups, layoutEngine) { this.body = body; this.images = images; @@ -17,11 +25,10 @@ class NodesHandler { this.nodesListeners = { add: (event, params) => { this.add(params.items); }, - update: (event, params) => { this.update(params.items, params.data); }, + update: (event, params) => { this.update(params.items, params.data, params.oldData); }, remove: (event, params) => { this.remove(params.items); } }; - this.options = {}; this.defaultOptions = { borderWidth: 1, borderWidthSelected: 2, @@ -49,7 +56,24 @@ class NodesHandler { background: 'none', strokeWidth: 0, // px strokeColor: '#ffffff', - align: 'center' + align: 'center', + vadjust: 0, + multi: false, + bold: { + mod: 'bold' + }, + boldital: { + mod: 'bold italic' + }, + ital: { + mod: 'italic' + }, + mono: { + mod: '', + size: 15, // px + face: 'monospace', + vadjust: 2 + } }, group: undefined, hidden: false, @@ -63,6 +87,12 @@ class NodesHandler { label: undefined, labelHighlightBold: true, level: undefined, + margin: { + top: 5, + right: 5, + bottom: 5, + left: 5 + }, mass: 1, physics: true, scaling: { @@ -106,11 +136,20 @@ class NodesHandler { x: undefined, y: undefined }; - util.extend(this.options, this.defaultOptions); + + // Protect from idiocy + if (this.defaultOptions.mass <= 0) { + throw 'Internal error: mass in defaultOptions of NodesHandler may not be zero or negative'; + } + + this.options = util.bridgeObject(this.defaultOptions); this.bindEventListeners(); } + /** + * Binds event listeners + */ bindEventListeners() { // refresh the nodes. Used when reverting from hierarchical layout this.body.emitter.on('refreshNodes', this.refresh.bind(this)); @@ -128,6 +167,10 @@ class NodesHandler { }); } + /** + * + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { Node.parseOptions(this.options, options); @@ -143,11 +186,10 @@ class NodesHandler { // update the font 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(); + this.body.nodes[nodeId].needsRefresh(); } } } @@ -156,12 +198,12 @@ class NodesHandler { if (options.size !== undefined) { for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - this.body.nodes[nodeId]._reset(); + this.body.nodes[nodeId].needsRefresh(); } } } - // update the state of the letiables if needed + // update the state of the variables if needed if (options.hidden !== undefined || options.physics !== undefined) { this.body.emitter.emit('_dataChanged'); } @@ -171,6 +213,7 @@ class NodesHandler { /** * Set a data set with nodes for the network * @param {Array | DataSet | DataView} nodes The data containing the nodes. + * @param {boolean} [doNotEmit=false] * @private */ setData(nodes, doNotEmit = false) { @@ -220,7 +263,8 @@ class NodesHandler { /** * Add nodes - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids + * @param {boolean} [doNotEmit=false] * @private */ add(ids, doNotEmit = false) { @@ -243,10 +287,12 @@ class NodesHandler { /** * Update existing nodes, or create them when not yet existing - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids id's of changed nodes + * @param {Array} changedData array with changed data + * @param {Array|undefined} oldData optional; array with previous data * @private */ - update(ids, changedData) { + update(ids, changedData, oldData) { let nodes = this.body.nodes; let dataChanged = false; for (let i = 0; i < ids.length; i++) { @@ -255,7 +301,9 @@ class NodesHandler { let data = changedData[i]; if (node !== undefined) { // update node - dataChanged = node.setOptions(data); + if (node.setOptions(data)) { + dataChanged = true; + } } else { dataChanged = true; @@ -264,6 +312,17 @@ class NodesHandler { nodes[id] = node; } } + + if (!dataChanged && oldData !== undefined) { + // Check for any changes which should trigger a layout recalculation + // For now, this is just 'level' for hierarchical layout + // Assumption: old and new data arranged in same order; at time of writing, this holds. + dataChanged = changedData.some(function(newValue, index) { + let oldValue = oldData[index]; + return (oldValue && oldValue.level !== newValue.level); + }); + } + if (dataChanged === true) { this.body.emitter.emit("_dataChanged"); } @@ -274,7 +333,7 @@ class NodesHandler { /** * Remove existing nodes. If nodes do not exist, the method will just ignore it. - * @param {Number[] | String[]} ids + * @param {number[] | string[]} ids * @private */ remove(ids) { @@ -291,35 +350,36 @@ class NodesHandler { /** * create a node - * @param properties - * @param constructorClass + * @param {Object} properties + * @param {class} [constructorClass=Node.default] + * @returns {*} */ create(properties, constructorClass = Node) { - return new constructorClass(properties, this.body, this.images, this.groups, this.options) + return new constructorClass(properties, this.body, this.images, this.groups, this.options, this.defaultOptions) } + /** + * + * @param {boolean} [clearPositions=false] + */ refresh(clearPositions = false) { - 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) { + util.forEach(this.body.nodes, (node, nodeId) => { + let data = this.body.data.nodes.get(nodeId); + if (data !== undefined) { if (clearPositions === true) { node.setOptions({x:null, y:null}); } node.setOptions({ fixed: false }); node.setOptions(data); } - } + }); } + /** * Returns the positions of the nodes. - * @param ids --> optional, can be array of nodeIds, can be string + * @param {Array.|String} [ids] --> optional, can be array of nodeIds, can be string * @returns {{}} */ getPositions(ids) { @@ -371,7 +431,7 @@ class NodesHandler { /** * get the bounding box of a node. - * @param nodeId + * @param {Node.id} nodeId * @returns {j|*} */ getBoundingBox(nodeId) { @@ -383,23 +443,25 @@ class NodesHandler { /** * Get the Ids of nodes connected to this node. - * @param nodeId + * @param {Node.id} nodeId + * @param {'to'|'from'|undefined} direction values 'from' and 'to' select respectively parent and child nodes only. + * Any other value returns both parent and child nodes. * @returns {Array} */ - getConnectedNodes(nodeId) { + getConnectedNodes(nodeId, direction) { 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 == node.id) { // these are double equals since ids can be numeric or string + if (direction !== 'to' && edge.toId == node.id) { // 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 == node.id) { // these are double equals since ids can be numeric or string + else if (direction !== 'from' && edge.fromId == node.id) { // these are double equals since ids can be numeric or string if (nodeObj[edge.toId] === undefined) { nodeList.push(edge.toId); nodeObj[edge.toId] = true; @@ -412,7 +474,7 @@ class NodesHandler { /** * Get the ids of the edges connected to this node. - * @param nodeId + * @param {Node.id} nodeId * @returns {*} */ getConnectedEdges(nodeId) { @@ -432,9 +494,10 @@ class NodesHandler { /** * Move a node. - * @param String nodeId - * @param Number x - * @param Number y + * + * @param {Node.id} nodeId + * @param {number} x + * @param {number} y */ moveNode(nodeId, x, y) { if (this.body.nodes[nodeId] !== undefined) { @@ -448,4 +511,4 @@ class NodesHandler { } } -export default NodesHandler; \ No newline at end of file +export default NodesHandler; diff --git a/lib/network/modules/PhysicsEngine.js b/lib/network/modules/PhysicsEngine.js index 3555c2ffb..8c1c3c596 100644 --- a/lib/network/modules/PhysicsEngine.js +++ b/lib/network/modules/PhysicsEngine.js @@ -1,16 +1,22 @@ -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 BarnesHutSolver = require('./components/physics/BarnesHutSolver').default; +var Repulsion = require('./components/physics/RepulsionSolver').default; +var HierarchicalRepulsion = require('./components/physics/HierarchicalRepulsionSolver').default; +var SpringSolver = require('./components/physics/SpringSolver').default; +var HierarchicalSpringSolver = require('./components/physics/HierarchicalSpringSolver').default; +var CentralGravitySolver = require('./components/physics/CentralGravitySolver').default; +var ForceAtlas2BasedRepulsionSolver = require('./components/physics/FA2BasedRepulsionSolver').default; +var ForceAtlas2BasedCentralGravitySolver = require('./components/physics/FA2BasedCentralGravitySolver').default; var util = require('../../util'); +var EndPoints = require('./components/edges/util/EndPoints').default; // for debugging with _drawForces() +/** + * The physics engine + */ class PhysicsEngine { + /** + * @param {Object} body + */ constructor(body) { this.body = body; this.physicsBody = {physicsNodeIndices:[], physicsEdgeIndices:[], forces: {}, velocities: {}}; @@ -91,6 +97,9 @@ class PhysicsEngine { this.bindEventListeners(); } + /** + * Binds event listeners + */ bindEventListeners() { this.body.emitter.on('initPhysics', () => {this.initPhysics();}); this.body.emitter.on('_layoutFailed', () => {this.layoutFailed = true;}); @@ -112,9 +121,8 @@ class PhysicsEngine { this.stopSimulation(false); this.body.emitter.off(); }); - // this event will trigger a rebuilding of the cache everything. Used when nodes or edges have been added or removed. this.body.emitter.on("_dataChanged", () => { - // update shortcut lists + // Nodes and/or edges have been added or removed, update shortcut lists. this.updatePhysicsData(); }); @@ -125,7 +133,7 @@ class PhysicsEngine { /** * set the physics options - * @param options + * @param {Object} options */ setOptions(options) { if (options !== undefined) { @@ -134,6 +142,11 @@ class PhysicsEngine { this.physicsEnabled = false; this.stopSimulation(); } + else if (options === true) { + this.options.enabled = true; + this.physicsEnabled = true; + this.startSimulation(); + } else { this.physicsEnabled = true; util.selectiveNotDeepExtend(['stabilization'], this.options, options); @@ -237,6 +250,7 @@ class PhysicsEngine { /** * Stop the simulation, force stabilization. + * @param {boolean} [emit=true] */ stopSimulation(emit = true) { this.stabilized = true; @@ -279,6 +293,8 @@ class PhysicsEngine { /** * trigger the stabilized event. + * + * @param {number} [amountOfIterations=this.stabilizationIterations] * @private */ _emitStabilized(amountOfIterations = this.stabilizationIterations) { @@ -291,85 +307,95 @@ class PhysicsEngine { } } + /** - * A single simulation step (or 'tick') in the physics simulation + * Calculate the forces for one physics iteration and move the nodes. + * @private + */ + physicsStep() { + this.gravitySolver.solve(); + this.nodesSolver.solve(); + this.edgesSolver.solve(); + this.moveNodes(); + } + + + /** + * Make dynamic adjustments to the timestep, based on current state. * + * Helper function for physicsTick(). * @private */ - physicsTick() { - // 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; - } - - if (this.stabilized === false) { - // adaptivity means the timestep adapts to the situation, only applicable for stabilization - if (this.adaptiveTimestep === true && this.adaptiveTimestepEnabled === true) { - // this is the factor for increasing the timestep on success. - let factor = 1.2; - - // we assume the adaptive interval is - if (this.adaptiveCounter % this.adaptiveInterval === 0) { // we leave the timestep stable for "interval" iterations. - // first the big step and revert. Revert saves the reference state. - this.timestep = 2 * this.timestep; - this.calculateForces(); - this.moveNodes(); - this.revert(); - - // now the normal step. Since this is the last step, it is the more stable one and we will take this. - this.timestep = 0.5 * this.timestep; - - // since it's half the step, we do it twice. - this.calculateForces(); - this.moveNodes(); - this.calculateForces(); - this.moveNodes(); - - // we compare the two steps. if it is acceptable we double the step. - if (this._evaluateStepQuality() === true) { - this.timestep = factor * this.timestep; - } - else { - // if not, we decrease the step to a minimum of the options timestep. - // if the decreased timestep is smaller than the options step, we do not reset the counter - // we assume that the options timestep is stable enough. - if (this.timestep/factor < this.options.timestep) { - this.timestep = this.options.timestep; - } - else { - // if the timestep was larger than 2 times the option one we check the adaptivity again to ensure - // that large instabilities do not form. - this.adaptiveCounter = -1; // check again next iteration - this.timestep = Math.max(this.options.timestep, this.timestep/factor); - } - } - } - else { - // normal step, keeping timestep constant - this.calculateForces(); - this.moveNodes(); - } + adjustTimeStep() { + const factor = 1.2; // Factor for increasing the timestep on success. - // increment the counter - this.adaptiveCounter += 1; + // we compare the two steps. if it is acceptable we double the step. + if (this._evaluateStepQuality() === true) { + this.timestep = factor * this.timestep; + } + else { + // if not, we decrease the step to a minimum of the options timestep. + // if the decreased timestep is smaller than the options step, we do not reset the counter + // we assume that the options timestep is stable enough. + if (this.timestep/factor < this.options.timestep) { + this.timestep = this.options.timestep; } else { - // case for the static timestep, we reset it to the one in options and take a normal step. - this.timestep = this.options.timestep; - this.calculateForces(); - this.moveNodes(); + // if the timestep was larger than 2 times the option one we check the adaptivity again to ensure + // that large instabilities do not form. + this.adaptiveCounter = -1; // check again next iteration + this.timestep = Math.max(this.options.timestep, this.timestep/factor); } + } + } + + + /** + * A single simulation step (or 'tick') in the physics simulation + * + * @private + */ + physicsTick() { + this._startStabilizing(); // this ensures that there is no start event when the network is already stable. + if (this.stabilized === true) return; - // determine if the network has stabilzied - if (this.stabilized === true) { - this.revert(); + // adaptivity means the timestep adapts to the situation, only applicable for stabilization + if (this.adaptiveTimestep === true && this.adaptiveTimestepEnabled === true) { + // timestep remains stable for "interval" iterations. + let doAdaptive = (this.adaptiveCounter % this.adaptiveInterval === 0); + + if (doAdaptive) { + // first the big step and revert. + this.timestep = 2 * this.timestep; + this.physicsStep(); + this.revert(); // saves the reference state + + // now the normal step. Since this is the last step, it is the more stable one and we will take this. + this.timestep = 0.5 * this.timestep; + + // since it's half the step, we do it twice. + this.physicsStep(); + this.physicsStep(); + + this.adjustTimeStep(); + } + else { + this.physicsStep(); // normal step, keeping timestep constant } - this.stabilizationIterations++; + this.adaptiveCounter += 1; } + else { + // case for the static timestep, we reset it to the one in options and take a normal step. + this.timestep = this.options.timestep; + this.physicsStep(); + } + + if (this.stabilized === true) this.revert(); + 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. * @@ -450,6 +476,9 @@ class PhysicsEngine { /** * This compares the reference state to the current state + * + * @returns {boolean} + * @private */ _evaluateStepQuality() { let dx, dy, dpos; @@ -474,11 +503,9 @@ class PhysicsEngine { /** * move the nodes one timestep and check if they are stabilized - * @returns {boolean} */ moveNodes() { var nodeIndices = this.physicsBody.physicsNodeIndices; - var maxVelocity = this.options.maxVelocity ? this.options.maxVelocity : 1e9; var maxNodeVelocity = 0; var averageNodeVelocity = 0; @@ -487,9 +514,9 @@ class PhysicsEngine { for (let i = 0; i < nodeIndices.length; i++) { let nodeId = nodeIndices[i]; - let nodeVelocity = this._performStep(nodeId, maxVelocity); + let nodeVelocity = this._performStep(nodeId); // stabilized is true if stabilized is true and velocity is smaller than vmin --> all nodes must be stabilized - maxNodeVelocity = Math.max(maxNodeVelocity,nodeVelocity); + maxNodeVelocity = Math.max(maxNodeVelocity, nodeVelocity); averageNodeVelocity += nodeVelocity; } @@ -499,66 +526,72 @@ class PhysicsEngine { } + /** + * Calculate new velocity for a coordinate direction + * + * @param {number} v velocity for current coordinate + * @param {number} f regular force for current coordinate + * @param {number} m mass of current node + * @returns {number} new velocity for current coordinate + * @private + */ + calculateComponentVelocity(v,f, m) { + let df = this.modelOptions.damping * v; // damping force + let a = (f - df) / m; // acceleration + + v += a * this.timestep; + + // Put a limit on the velocities if it is really high + let maxV = this.options.maxVelocity || 1e9; + if (Math.abs(v) > maxV) { + v = ((v > 0) ? maxV: -maxV); + } + + return v; + } + + /** * Perform the actual step * - * @param nodeId - * @param maxVelocity - * @returns {number} + * @param {Node.id} nodeId + * @returns {number} the new velocity of given node * @private */ - _performStep(nodeId,maxVelocity) { + _performStep(nodeId) { let node = this.body.nodes[nodeId]; - let timestep = this.timestep; - let forces = this.physicsBody.forces; - let velocities = this.physicsBody.velocities; + let force = this.physicsBody.forces[nodeId]; + let velocity = this.physicsBody.velocities[nodeId]; // store the state so we can revert - this.previousStates[nodeId] = {x:node.x, y:node.y, vx:velocities[nodeId].x, vy:velocities[nodeId].y}; + this.previousStates[nodeId] = {x:node.x, y:node.y, vx:velocity.x, vy:velocity.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 + velocity.x = this.calculateComponentVelocity(velocity.x, force.x, node.options.mass); + node.x += velocity.x * this.timestep; } else { - forces[nodeId].x = 0; - velocities[nodeId].x = 0; + force.x = 0; + velocity.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 + velocity.y = this.calculateComponentVelocity(velocity.y, force.y, node.options.mass); + node.y += velocity.y * this.timestep; } else { - forces[nodeId].y = 0; - velocities[nodeId].y = 0; + force.y = 0; + velocity.y = 0; } - let totalVelocity = Math.sqrt(Math.pow(velocities[nodeId].x,2) + Math.pow(velocities[nodeId].y,2)); + let totalVelocity = Math.sqrt(Math.pow(velocity.x,2) + Math.pow(velocity.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. + * 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 */ @@ -567,14 +600,16 @@ class PhysicsEngine { 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; + let fixed = nodes[id].options.fixed; + this.freezeCache[id] = {x:fixed.x, y:fixed.y}; + fixed.x = true; + fixed.y = true; } } } } + /** * Unfreezes the nodes that have been frozen by _freezeDefinedNodes. * @@ -595,11 +630,13 @@ class PhysicsEngine { /** * Find a stable position for all nodes + * + * @param {number} [iterations=this.options.stabilization.iterations] */ 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; + console.log('The stabilize method needs a numeric amount of iterations. Switching to default: ', iterations); } if (this.physicsBody.physicsNodeIndices.length === 0) { @@ -613,10 +650,7 @@ class PhysicsEngine { // 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.stopSimulation(); // stop the render loop this.stabilized = false; // block redraw requests @@ -633,25 +667,48 @@ class PhysicsEngine { } + /** + * If not already stabilizing, start it and emit a start event. + * + * @returns {boolean} true if stabilization started with this call + * @private + */ + _startStabilizing() { + if (this.startedStabilization === true) return false; + + this.body.emitter.emit('startStabilizing'); + this.startedStabilization = true; + return true; + } + + /** * One batch of stabilization * @private */ _stabilizationBatch() { - // this is here to ensure that there is at least one start event. - if (this.startedStabilization === false) { - this.body.emitter.emit('startStabilizing'); - this.startedStabilization = true; + var running = () => (this.stabilized === false && this.stabilizationIterations < this.targetIterations); + + var sendProgress = () => { + this.body.emitter.emit('stabilizationProgress', { + iterations: this.stabilizationIterations, + total: this.targetIterations + }); + }; + + if (this._startStabilizing()) { + sendProgress(); // Ensure that there is at least one start event. } var count = 0; - while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) { + while (running() && count < this.options.stabilization.updateInterval) { this.physicsTick(); count++; } - if (this.stabilized === false && this.stabilizationIterations < this.targetIterations) { - this.body.emitter.emit('stabilizationProgress', {iterations: this.stabilizationIterations, total: this.targetIterations}); + sendProgress(); + + if (running()) { setTimeout(this._stabilizationBatch.bind(this),0); } else { @@ -688,10 +745,22 @@ class PhysicsEngine { } + //--------------------------- DEBUGGING BELOW ---------------------------// + + + /** + * Debug function that display arrows for the forces currently active in the network. + * + * Use this when debugging only. + * + * @param {CanvasRenderingContext2D} ctx + * @private + */ _drawForces(ctx) { for (var i = 0; i < this.physicsBody.physicsNodeIndices.length; i++) { - let node = this.body.nodes[this.physicsBody.physicsNodeIndices[i]]; - let force = this.physicsBody.forces[this.physicsBody.physicsNodeIndices[i]]; + let index = this.physicsBody.physicsNodeIndices[i]; + let node = this.body.nodes[index]; + let force = this.physicsBody.forces[index]; let factor = 20; let colorFactor = 0.03; let forceSize = Math.sqrt(Math.pow(force.x,2) + Math.pow(force.x,2)); @@ -701,20 +770,25 @@ class PhysicsEngine { let color = util.HSVToHex((180 - Math.min(1,Math.max(0,colorFactor*forceSize))*180) / 360,1,1); + let point = { + x: node.x + factor*force.x, + y: node.y + factor*force.y + }; + ctx.lineWidth = size; ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(node.x,node.y); - ctx.lineTo(node.x+factor*force.x, node.y+factor*force.y); + ctx.lineTo(point.x, point.y); ctx.stroke(); let angle = Math.atan2(force.y, force.x); ctx.fillStyle = color; - ctx.arrowEndpoint(node.x + factor*force.x + Math.cos(angle)*arrowSize, node.y + factor*force.y+Math.sin(angle)*arrowSize, angle, arrowSize); + EndPoints.draw(ctx, {type: 'arrow', point: point, angle: angle, length: arrowSize}); ctx.fill(); + } } - } export default PhysicsEngine; diff --git a/lib/network/modules/SelectionHandler.js b/lib/network/modules/SelectionHandler.js index 8c5eaf37e..53c617041 100644 --- a/lib/network/modules/SelectionHandler.js +++ b/lib/network/modules/SelectionHandler.js @@ -1,9 +1,16 @@ -import Node from './components/Node'; -import Edge from './components/Edge'; +var Node = require('./components/Node').default; +var Edge = require('./components/Edge').default; let util = require('../../util'); +/** + * The handler for selections + */ class SelectionHandler { + /** + * @param {Object} body + * @param {Canvas} canvas + */ constructor(body, canvas) { this.body = body; this.canvas = canvas; @@ -25,6 +32,10 @@ class SelectionHandler { } + /** + * + * @param {Object} [options] + */ setOptions(options) { if (options !== undefined) { let fields = ['multiselect','hoverConnectedEdges','selectable','selectConnectedEdges']; @@ -36,8 +47,8 @@ class SelectionHandler { /** * handles the selection part of the tap; * - * @param {Object} pointer - * @private + * @param {{x: number, y: number}} pointer + * @returns {boolean} */ selectOnPoint(pointer) { let selected = false; @@ -55,6 +66,11 @@ class SelectionHandler { return selected; } + /** + * + * @param {{x: number, y: number}} pointer + * @returns {boolean} + */ selectAdditionalOnPoint(pointer) { let selectionChanged = false; if (this.options.selectable === true) { @@ -75,26 +91,72 @@ class SelectionHandler { return selectionChanged; } - _generateClickEvent(eventType, event, pointer, oldSelection, emptySelection = false) { - let properties; - if (emptySelection === true) { - properties = {nodes:[], edges:[]}; - } - else { - properties = this.getSelection(); - } + + /** + * Create an object containing the standard fields for an event. + * + * @param {Event} event + * @param {{x: number, y: number}} pointer Object with the x and y screen coordinates of the mouse + * @returns {{}} + * @private + */ + _initBaseEvent(event, pointer) { + let properties = {}; + properties['pointer'] = { DOM: {x: pointer.x, y: pointer.y}, canvas: this.canvas.DOMtoCanvas(pointer) }; properties['event'] = event; + return properties; + } + + + /** + * Generate an event which the user can catch. + * + * This adds some extra data to the event with respect to cursor position and + * selected nodes and edges. + * + * @param {string} eventType Name of event to send + * @param {Event} event + * @param {{x: number, y: number}} pointer Object with the x and y screen coordinates of the mouse + * @param {Object|undefined} oldSelection If present, selection state before event occured + * @param {boolean|undefined} [emptySelection=false] Indicate if selection data should be passed + */ + _generateClickEvent(eventType, event, pointer, oldSelection, emptySelection = false) { + let properties = this._initBaseEvent(event, pointer); + + if (emptySelection === true) { + properties.nodes = []; + properties.edges = []; + } + else { + let tmp = this.getSelection(); + properties.nodes = tmp.nodes; + properties.edges = tmp.edges; + } + if (oldSelection !== undefined) { properties['previousSelection'] = oldSelection; } + + if (eventType == 'click') { + // For the time being, restrict this functionality to + // just the click event. + properties.items = this.getClickedItems(pointer); + } + this.body.emitter.emit(eventType, properties); } + /** + * + * @param {Object} obj + * @param {boolean} [highlightEdges=this.options.selectConnectedEdges] + * @returns {boolean} + */ selectObject(obj, highlightEdges = this.options.selectConnectedEdges) { if (obj !== undefined) { if (obj instanceof Node) { @@ -109,6 +171,10 @@ class SelectionHandler { return false; } + /** + * + * @param {Object} obj + */ deselectObject(obj) { if (obj.isSelected() === true) { obj.selected = false; @@ -121,7 +187,7 @@ class SelectionHandler { /** * 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 + * @return {number[]} An array with id's of the overlapping nodes * @private */ _getAllNodesOverlappingWith(object) { @@ -140,7 +206,7 @@ class SelectionHandler { /** * Return a position object in canvasspace from a single point in screenspace * - * @param pointer + * @param {{x: number, y: number}} pointer * @returns {{left: number, top: number, right: number, bottom: number}} * @private */ @@ -156,9 +222,10 @@ class SelectionHandler { /** - * Get the top node at the a specific point (like a click) + * Get the top node at the passed point (like a click) * - * @param {{x: Number, y: Number}} pointer + * @param {{x: number, y: number}} pointer + * @param {boolean} [returnNode=true] * @return {Node | undefined} node */ getNodeAt(pointer, returnNode = true) { @@ -184,7 +251,7 @@ class SelectionHandler { /** * 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 + * @param {number[]} overlappingEdges An array with id's of the overlapping nodes * @private */ _getEdgesOverlappingWith(object, overlappingEdges) { @@ -201,7 +268,7 @@ class SelectionHandler { /** * 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 + * @return {number[]} An array with id's of the overlapping nodes * @private */ _getAllEdgesOverlappingWith(object) { @@ -212,11 +279,11 @@ class SelectionHandler { /** - * Place holder. To implement change the getNodeAt to a _getObjectAt. Have the _getObjectAt call - * getNodeAt and _getEdgesAt, then priortize the selection to user preferences. + * Get the edges nearest to the passed point (like a click) * - * @param pointer - * @returns {undefined} + * @param {{x: number, y: number}} pointer + * @param {boolean} [returnEdge=true] + * @return {Edge | undefined} node */ getEdgeAt(pointer, returnEdge = true) { // Iterate over edges, pick closest within 10 @@ -239,7 +306,7 @@ class SelectionHandler { } } } - if (overlappingEdge) { + if (overlappingEdge !== null) { if (returnEdge === true) { return this.body.edges[overlappingEdge]; } @@ -256,7 +323,7 @@ class SelectionHandler { /** * Add object to the selection array. * - * @param obj + * @param {Object} obj * @private */ _addToSelection(obj) { @@ -271,7 +338,7 @@ class SelectionHandler { /** * Add object to the selection array. * - * @param obj + * @param {Object} obj * @private */ _addToHover(obj) { @@ -487,38 +554,79 @@ class SelectionHandler { /** - * 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 + * Remove the highlight from a node or edge, in response to mouse movement * - * @param {Node || Edge} object + * @param {Event} event + * @param {{x: number, y: number}} pointer object with the x and y screen coordinates of the mouse + * @param {Node|vis.Edge} object * @private */ - blurObject(object) { + emitBlurEvent(event, pointer, object) { + let properties = this._initBaseEvent(event, pointer); + if (object.hover === true) { object.hover = false; if (object instanceof Node) { - this.body.emitter.emit("blurNode", {node: object.id}); + properties.node = object.id; + this.body.emitter.emit("blurNode", properties); } else { - this.body.emitter.emit("blurEdge", {edge: object.id}); + properties.edge = object.id; + this.body.emitter.emit("blurEdge", properties); } } } + /** - * 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 + * Create the highlight for a node or edge, in response to mouse movement * - * @param {Node || Edge} object + * @param {Event} event + * @param {{x: number, y: number}} pointer object with the x and y screen coordinates of the mouse + * @param {Node|vis.Edge} object + * @returns {boolean} hoverChanged * @private */ - hoverObject(object) { + emitHoverEvent(event, pointer, object) { + let properties = this._initBaseEvent(event, pointer); + let hoverChanged = false; + + if (object.hover === false) { + object.hover = true; + this._addToHover(object); + hoverChanged = true; + if (object instanceof Node) { + properties.node = object.id; + this.body.emitter.emit("hoverNode", properties); + } + else { + properties.edge = object.id; + this.body.emitter.emit("hoverEdge", properties); + } + } + + return hoverChanged; + } + + + /** + * Perform actions in response to a mouse movement. + * + * @param {Event} event + * @param {{x: number, y: number}} pointer | object with the x and y screen coordinates of the mouse + */ + hoverObject(event, pointer) { + let object = this.getNodeAt(pointer); + if (object === undefined) { + object = this.getEdgeAt(pointer); + } + 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]); + this.emitBlurEvent(event, pointer, this.hoverObj.nodes[nodeId]); delete this.hoverObj.nodes[nodeId]; hoverChanged = true; } @@ -529,15 +637,15 @@ class SelectionHandler { 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. + // we then do not use the emitBlurEvent 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) or another - // edge has been hovered, we blur the edge - else if (object === undefined || object instanceof Edge) { - this.blurObject(this.hoverObj.edges[edgeId]); + // edge has been hovered, or another node has been hovered we blur the edge. + else if (object === undefined || (object instanceof Edge && object.id != edgeId) || (object instanceof Node && !object.hover)) { + this.emitBlurEvent(event, pointer, this.hoverObj.edges[edgeId]); delete this.hoverObj.edges[edgeId]; hoverChanged = true; } @@ -545,17 +653,7 @@ class SelectionHandler { } 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}); - } - } + hoverChanged = hoverChanged || this.emitHoverEvent(event, pointer, object); if (object instanceof Node && this.options.hoverConnectedEdges === true) { this._hoverConnectedEdges(object); } @@ -572,7 +670,7 @@ class SelectionHandler { /** * * retrieve the currently selected objects - * @return {{nodes: Array., edges: Array.}} selection + * @return {{nodes: Array., edges: Array.}} selection */ getSelection() { let nodeIds = this.getSelectedNodes(); @@ -583,7 +681,7 @@ class SelectionHandler { /** * * retrieve the currently selected nodes - * @return {String[]} selection An array with the ids of the + * @return {string[]} selection An array with the ids of the * selected nodes. */ getSelectedNodes() { @@ -618,7 +716,7 @@ class SelectionHandler { /** * Updates the current selection - * @param {{nodes: Array., edges: Array.}} Selection + * @param {{nodes: Array., edges: Array.}} selection * @param {Object} options Options */ setSelection(selection, options = {}) { @@ -660,7 +758,7 @@ class SelectionHandler { /** * select zero or more nodes with the option to highlight edges - * @param {Number[] | String[]} selection An array with the ids of the + * @param {number[] | string[]} selection An array with the ids of the * selected nodes. * @param {boolean} [highlightEdges] */ @@ -674,7 +772,7 @@ class SelectionHandler { /** * select zero or more edges - * @param {Number[] | String[]} selection An array with the ids of the + * @param {number[] | string[]} selection An array with the ids of the * selected nodes. */ selectEdges(selection) { @@ -704,6 +802,57 @@ class SelectionHandler { } } } + + + /** + * Determine all the visual elements clicked which are on the given point. + * + * All elements are returned; this includes nodes, edges and their labels. + * The order returned is from highest to lowest, i.e. element 0 of the return + * value is the topmost item clicked on. + * + * The return value consists of an array of the following possible elements: + * + * - `{nodeId:number}` - node with given id clicked on + * - `{nodeId:number, labelId:0}` - label of node with given id clicked on + * - `{edgeId:number}` - edge with given id clicked on + * - `{edge:number, labelId:0}` - label of edge with given id clicked on + * + * ## NOTES + * + * - Currently, there is only one label associated with a node or an edge, + * but this is expected to change somewhere in the future. + * - Since there is no z-indexing yet, it is not really possible to set the nodes and + * edges in the correct order. For the time being, nodes come first. + * + * @param {point} pointer mouse position in screen coordinates + * @returns {Array.} + * @private + */ + getClickedItems(pointer) { + let point = this.canvas.DOMtoCanvas(pointer); + var items = []; + + // Note reverse order; we want the topmost clicked items to be first in the array + // Also note that selected nodes are disregarded here; these normally display on top + let nodeIndices = this.body.nodeIndices; + let nodes = this.body.nodes; + for (let i = nodeIndices.length - 1; i >= 0; i--) { + let node = nodes[nodeIndices[i]]; + let ret = node.getItemsOnPoint(point); + items.push.apply(items, ret); // Append the return value to the running list. + } + + let edgeIndices = this.body.edgeIndices; + let edges = this.body.edges; + for (let i = edgeIndices.length - 1; i >= 0; i--) { + let edge = edges[edgeIndices[i]]; + let ret = edge.getItemsOnPoint(point); + items.push.apply(items, ret); // Append the return value to the running list. + } + + return items; + } } -export default SelectionHandler; \ No newline at end of file +export default SelectionHandler; diff --git a/lib/network/modules/View.js b/lib/network/modules/View.js index d1004c805..f476dfb5a 100644 --- a/lib/network/modules/View.js +++ b/lib/network/modules/View.js @@ -1,8 +1,15 @@ let util = require('../../util'); -import NetworkUtil from '../NetworkUtil'; +var NetworkUtil = require('../NetworkUtil').default; +/** + * The view + */ class View { + /** + * @param {Object} body + * @param {Canvas} canvas + */ constructor(body, canvas) { this.body = body; this.canvas = canvas; @@ -25,7 +32,10 @@ class View { this.body.emitter.on("unlockNode", this.releaseNode.bind(this)); } - + /** + * + * @param {Object} [options={}] + */ setOptions(options = {}) { this.options = options; } @@ -33,8 +43,8 @@ class View { /** * 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; + * @param {Object} [options={{nodes=Array}}] + * @param {boolean} [initialZoom=false] | zoom based on fitted formula or range, true = fitted, default = false; */ fit(options = {nodes:[]}, initialZoom = false) { let range; @@ -99,8 +109,8 @@ class View { /** * Center a node in view. * - * @param {Number} nodeId - * @param {Number} [options] + * @param {number} nodeId + * @param {number} [options] */ focus(nodeId, options = {}) { if (this.body.nodes[nodeId] !== undefined) { @@ -117,10 +127,10 @@ class View { /** * - * @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 + * @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) { @@ -143,10 +153,10 @@ class View { /** * - * @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 + * @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, @@ -230,6 +240,9 @@ class View { this.body.view.translation = targetTranslation; } + /** + * Resets state of a locked on Node + */ releaseNode() { if (this.lockedOnNodeId !== undefined && this.viewFunction !== undefined) { this.body.emitter.off("initRedraw", this.viewFunction); @@ -239,8 +252,7 @@ class View { } /** - * - * @param easingTime + * @param {boolean} [finished=false] * @private */ _transitionRedraw(finished = false) { @@ -265,13 +277,21 @@ class View { } this.body.emitter.emit("animationFinished"); } - }; + } + /** + * + * @returns {number} + */ getScale() { return this.body.view.scale; } + /** + * + * @returns {{x: number, y: number}} + */ getViewPosition() { return this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight}); } diff --git a/lib/network/modules/components/DirectionStrategy.js b/lib/network/modules/components/DirectionStrategy.js new file mode 100644 index 000000000..b72e127eb --- /dev/null +++ b/lib/network/modules/components/DirectionStrategy.js @@ -0,0 +1,244 @@ +/** + * Helper classes for LayoutEngine. + * + * Strategy pattern for usage of direction methods for hierarchical layouts. + */ + + +/** + * Interface definition for direction strategy classes. + * + * This class describes the interface for the Strategy + * pattern classes used to differentiate horizontal and vertical + * direction of hierarchical results. + * + * For a given direction, one coordinate will be 'fixed', meaning that it is + * determined by level. + * The other coordinate is 'unfixed', meaning that the nodes on a given level + * can still move along that coordinate. So: + * + * - `vertical` layout: `x` unfixed, `y` fixed per level + * - `horizontal` layout: `x` fixed per level, `y` unfixed + * + * The local methods are stubs and should be regarded as abstract. + * Derived classes **must** implement all the methods themselves. + * + * @private + */ +class DirectionInterface { + /** @ignore **/ + abstract() { + throw new Error("Can't instantiate abstract class!"); + } + + /** + * This is a dummy call which is used to suppress the jsdoc errors of type: + * + * "'param' is assigned a value but never used" + * + * @ignore + **/ + fake_use() { + // Do nothing special + } + + /** + * Type to use to translate dynamic curves to, in the case of hierarchical layout. + * Dynamic curves do not work for these. + * + * The value should be perpendicular to the actual direction of the layout. + * + * @return {string} Direction, either 'vertical' or 'horizontal' + */ + curveType() { return this.abstract(); } + + + /** + * Return the value of the coordinate that is not fixed for this direction. + * + * @param {Node} node The node to read + * @return {number} Value of the unfixed coordinate + */ + getPosition(node) { this.fake_use(node); return this.abstract(); } + + + /** + * Set the value of the coordinate that is not fixed for this direction. + * + * @param {Node} node The node to adjust + * @param {number} position + * @param {number} [level] if specified, the hierarchy level that this node should be fixed to + */ + setPosition(node, position, level = undefined) { this.fake_use(node, position, level); this.abstract(); } + + + /** + * Get the width of a tree. + * + * A `tree` here is a subset of nodes within the network which are not connected to other nodes, + * only among themselves. In essence, it is a sub-network. + * + * @param {number} index The index number of a tree + * @return {number} the width of a tree in the view coordinates + */ + getTreeSize(index) { this.fake_use(index); return this.abstract(); } + + + /** + * Sort array of nodes on the unfixed coordinates. + * + * @param {Array.} nodeArray array of nodes to sort + */ + sort(nodeArray) { this.fake_use(nodeArray); this.abstract(); } + + + /** + * Assign the fixed coordinate of the node to the given level + * + * @param {Node} node The node to adjust + * @param {number} level The level to fix to + */ + fix(node, level) { this.fake_use(node, level); this.abstract(); } + + + /** + * Add an offset to the unfixed coordinate of the given node. + * + * @param {NodeId} nodeId Id of the node to adjust + * @param {number} diff Offset to add to the unfixed coordinate + */ + shift(nodeId, diff) { this.fake_use(nodeId, diff); this.abstract(); } +} + + +/** + * Vertical Strategy + * + * Coordinate `y` is fixed on levels, coordinate `x` is unfixed. + * + * @extends DirectionInterface + * @private + */ +class VerticalStrategy extends DirectionInterface { + /** + * Constructor + * + * @param {Object} layout reference to the parent LayoutEngine instance. + */ + constructor(layout) { + super(); + this.layout = layout; + } + + /** @inheritdoc */ + curveType() { + return 'horizontal'; + } + + /** @inheritdoc */ + getPosition(node) { + return node.x; + } + + /** @inheritdoc */ + setPosition(node, position, level = undefined) { + if (level !== undefined) { + this.layout.hierarchical.addToOrdering(node, level); + } + node.x = position; + } + + /** @inheritdoc */ + getTreeSize(index) { + let res = this.layout.hierarchical.getTreeSize(this.layout.body.nodes, index); + return {min: res.min_x, max: res.max_x}; + } + + /** @inheritdoc */ + sort(nodeArray) { + nodeArray.sort(function(a, b) { + // Test on 'undefined' takes care of divergent behaviour in chrome + if (a.x === undefined || b.x === undefined) return 0; // THIS HAPPENS + return a.x - b.x; + }); + } + + /** @inheritdoc */ + fix(node, level) { + node.y = this.layout.options.hierarchical.levelSeparation * level; + node.options.fixed.y = true; + } + + /** @inheritdoc */ + shift(nodeId, diff) { + this.layout.body.nodes[nodeId].x += diff; + } +} + + +/** + * Horizontal Strategy + * + * Coordinate `x` is fixed on levels, coordinate `y` is unfixed. + * + * @extends DirectionInterface + * @private + */ +class HorizontalStrategy extends DirectionInterface { + /** + * Constructor + * + * @param {Object} layout reference to the parent LayoutEngine instance. + */ + constructor(layout) { + super(); + this.layout = layout; + } + + /** @inheritdoc */ + curveType() { + return 'vertical'; + } + + /** @inheritdoc */ + getPosition(node) { + return node.y; + } + + /** @inheritdoc */ + setPosition(node, position, level = undefined) { + if (level !== undefined) { + this.layout.hierarchical.addToOrdering(node, level); + } + node.y = position; + } + + /** @inheritdoc */ + getTreeSize(index) { + let res = this.layout.hierarchical.getTreeSize(this.layout.body.nodes, index); + return {min: res.min_y, max: res.max_y}; + } + + /** @inheritdoc */ + sort(nodeArray) { + nodeArray.sort(function(a, b) { + // Test on 'undefined' takes care of divergent behaviour in chrome + if (a.y === undefined || b.y === undefined) return 0; // THIS HAPPENS + return a.y - b.y; + }); + } + + /** @inheritdoc */ + fix(node, level) { + node.x = this.layout.options.hierarchical.levelSeparation * level; + node.options.fixed.x = true; + } + + /** @inheritdoc */ + shift(nodeId, diff) { + this.layout.body.nodes[nodeId].y += diff; + } +} + + +export {HorizontalStrategy, VerticalStrategy}; diff --git a/lib/network/modules/components/Edge.js b/lib/network/modules/components/Edge.js index 8e383233f..653eb9f6f 100644 --- a/lib/network/modules/components/Edge.js +++ b/lib/network/modules/components/Edge.js @@ -1,33 +1,33 @@ var util = require('../../../util'); +var Label = require('./shared/Label').default; +var ComponentUtil = require('./shared/ComponentUtil').default; +var CubicBezierEdge = require('./edges/CubicBezierEdge').default; +var BezierEdgeDynamic = require('./edges/BezierEdgeDynamic').default; +var BezierEdgeStatic = require('./edges/BezierEdgeStatic').default; +var StraightEdge = require('./edges/StraightEdge').default; -import Label from './shared/Label' -import CubicBezierEdge from './edges/CubicBezierEdge' -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 + * An edge connects two nodes and has a specific direction. */ class Edge { - constructor(options, body, globalOptions) { + /** + * @param {Object} options values specific to this edge, must contain at least 'from' and 'to' + * @param {Object} body shared state from Network instance + * @param {Object} globalOptions options from the EdgesHandler instance + * @param {Object} defaultOptions default options from the EdgeHandler instance. Value and reference are constant + */ + constructor(options, body, globalOptions, defaultOptions) { if (body === undefined) { - throw "No body provided"; + throw new Error("No body provided"); } + + // Since globalOptions is constant in values as well as reference, + // Following needs to be done only once. + this.options = util.bridgeObject(globalOptions); this.globalOptions = globalOptions; + this.defaultOptions = defaultOptions; this.body = body; // initialize variables @@ -37,7 +37,6 @@ class Edge { this.selected = false; this.hover = false; this.labelDirty = true; - this.colorDirty = true; this.baseWidth = this.options.width; this.baseFontSize = this.options.font.size; @@ -50,7 +49,6 @@ class Edge { this.connected = false; this.labelModule = new Label(this.body, this.options, true /* It's an edge label */); - this.setOptions(options); } @@ -58,25 +56,36 @@ class Edge { /** * Set or overwrite options for the edge * @param {Object} options an object with options - * @param doNotEmit + * @returns {null|boolean} null if no options, boolean if date changed */ setOptions(options) { if (!options) { return; } - this.colorDirty = true; Edge.parseOptions(this.options, options, true, this.globalOptions); - 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);} + 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); + } + let pile = [options, this.options, this.defaultOptions]; + this.chooser = ComponentUtil.choosify('edge', pile); // update label Module - this.updateLabelModule(); + this.updateLabelModule(options); let dataChanged = this.updateEdgeType(); @@ -93,14 +102,22 @@ class Edge { return dataChanged; } - static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}) { + + /** + * + * @param {Object} parentOptions + * @param {Object} newOptions + * @param {boolean} [allowDeletion=false] + * @param {Object} [globalOptions={}] + * @param {boolean} [copyFromGlobals=false] + */ + static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}, copyFromGlobals = false) { var fields = [ 'arrowStrikethrough', 'id', 'from', 'hidden', 'hoverWidth', - 'label', 'labelHighlightBold', 'length', 'line', @@ -112,14 +129,24 @@ class Edge { 'to', 'title', 'value', - 'width' + 'width', + 'font', + 'chosen', + 'widthConstraint' ]; // 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', allowDeletion, globalOptions); - util.mergeOptions(parentOptions, newOptions, 'shadow', allowDeletion, globalOptions); + // Only copy label if it's a legal value. + if (ComponentUtil.isValidLabel(newOptions.label)) { + parentOptions.label = newOptions.label; + } else { + parentOptions.label = undefined; + } + + util.mergeOptions(parentOptions, newOptions, 'smooth', globalOptions); + util.mergeOptions(parentOptions, newOptions, 'shadow', globalOptions); if (newOptions.dashes !== undefined && newOptions.dashes !== null) { parentOptions.dashes = newOptions.dashes; @@ -132,7 +159,7 @@ class Edge { 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', allowDeletion, globalOptions.scaling); + util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', globalOptions.scaling); } else if (allowDeletion === true && newOptions.scaling === null) { parentOptions.scaling = Object.create(globalOptions.scaling); // this sets the pointer of the option back to the global option. @@ -147,9 +174,9 @@ class Edge { parentOptions.arrows.from.enabled = arrows.indexOf("from") != -1; } else if (typeof newOptions.arrows === 'object') { - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'to', allowDeletion, globalOptions.arrows); - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'middle', allowDeletion, globalOptions.arrows); - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'from', allowDeletion, globalOptions.arrows); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'to', globalOptions.arrows); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'middle', globalOptions.arrows); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'from', globalOptions.arrows); } 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)); @@ -161,24 +188,44 @@ class Edge { // handle multiple input cases for color if (newOptions.color !== undefined && newOptions.color !== null) { - // make a copy of the parent object in case this is referring to the global one (due to object create once, then update) - parentOptions.color = util.deepExtend({}, parentOptions.color, true); - if (util.isString(newOptions.color)) { - parentOptions.color.color = newOptions.color; - parentOptions.color.highlight = newOptions.color; - parentOptions.color.hover = newOptions.color; - parentOptions.color.inherit = false; + let fromColor = newOptions.color; + let toColor = parentOptions.color; + + // If passed, fill in values from default options - required in the case of no prototype bridging + if (copyFromGlobals) { + util.deepExtend(toColor, globalOptions.color, false, allowDeletion); + } else { + // Clear local properties - need to do it like this in order to retain prototype bridges + for (var i in toColor) { + if (toColor.hasOwnProperty(i)) { + delete toColor[i]; + } + } + } + + if (util.isString(toColor)) { + toColor.color = toColor; + toColor.highlight = toColor; + toColor.hover = toColor; + toColor.inherit = false; + if (fromColor.opacity === undefined) { + toColor.opacity = 1.0; // set default + } } 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; + if (fromColor.color !== undefined) {toColor.color = fromColor.color; colorsDefined = true;} + if (fromColor.highlight !== undefined) {toColor.highlight = fromColor.highlight; colorsDefined = true;} + if (fromColor.hover !== undefined) {toColor.hover = fromColor.hover; colorsDefined = true;} + if (fromColor.inherit !== undefined) {toColor.inherit = fromColor.inherit;} + if (fromColor.opacity !== undefined) {toColor.opacity = Math.min(1,Math.max(0,fromColor.opacity));} + + if (colorsDefined === true) { + toColor.inherit = false; + } else { + if (toColor.inherit === undefined) { + toColor.inherit = 'from'; // Set default + } } } } @@ -186,21 +233,104 @@ class Edge { parentOptions.color = util.bridgeObject(globalOptions.color); // set the object back to the global options } - // handle the font settings - if (newOptions.font !== undefined && newOptions.font !== null) { - Label.parseOptions(parentOptions.font, newOptions); - } - else if (allowDeletion === true && newOptions.font === null) { + if (allowDeletion === true && newOptions.font === null) { parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options } } + /** + * + * @returns {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} + */ + getFormattingValues() { + let toArrow = (this.options.arrows.to === true) || (this.options.arrows.to.enabled === true) + let fromArrow = (this.options.arrows.from === true) || (this.options.arrows.from.enabled === true) + let middleArrow = (this.options.arrows.middle === true) || (this.options.arrows.middle.enabled === true) + let inheritsColor = this.options.color.inherit; + let values = { + toArrow: toArrow, + toArrowScale: this.options.arrows.to.scaleFactor, + toArrowType: this.options.arrows.to.type, + middleArrow: middleArrow, + middleArrowScale: this.options.arrows.middle.scaleFactor, + middleArrowType: this.options.arrows.middle.type, + fromArrow: fromArrow, + fromArrowScale: this.options.arrows.from.scaleFactor, + fromArrowType: this.options.arrows.from.type, + arrowStrikethrough: this.options.arrowStrikethrough, + color: (inheritsColor? undefined : this.options.color.color), + inheritsColor: inheritsColor, + opacity: this.options.color.opacity, + hidden: this.options.hidden, + length: this.options.length, + shadow: this.options.shadow.enabled, + shadowColor: this.options.shadow.color, + shadowSize: this.options.shadow.size, + shadowX: this.options.shadow.x, + shadowY: this.options.shadow.y, + dashes: this.options.dashes, + width: this.options.width + }; + if (this.selected || this.hover) { + if (this.chooser === true) { + if (this.selected) { + let selectedWidth = this.options.selectionWidth; + if (typeof selectedWidth === 'function') { + values.width = selectedWidth(values.width); + } else if (typeof selectedWidth === 'number') { + values.width += selectedWidth; + } + values.width = Math.max(values.width, 0.3 / this.body.view.scale); + values.color = this.options.color.highlight; + values.shadow = this.options.shadow.enabled; + } else if (this.hover) { + let hoverWidth = this.options.hoverWidth; + if (typeof hoverWidth === 'function') { + values.width = hoverWidth(values.width); + } else if (typeof hoverWidth === 'number') { + values.width += hoverWidth; + } + values.width = Math.max(values.width, 0.3 / this.body.view.scale); + values.color = this.options.color.hover; + values.shadow = this.options.shadow.enabled; + } + } else if (typeof this.chooser === 'function') { + this.chooser(values, this.options.id, this.selected, this.hover); + if (values.color !== undefined) { + values.inheritsColor = false; + } + if (values.shadow === false) { + if ((values.shadowColor !== this.options.shadow.color) || + (values.shadowSize !== this.options.shadow.size) || + (values.shadowX !== this.options.shadow.x) || + (values.shadowY !== this.options.shadow.y)) { + values.shadow = true; + } + } + } + } else { + values.shadow = this.options.shadow.enabled; + values.width = Math.max(values.width, 0.3 / this.body.view.scale); + } + return values; + } + /** * update the options in the label module + * + * @param {Object} options */ - updateLabelModule() { - this.labelModule.setOptions(this.options, true); + updateLabelModule(options) { + let pile = [ + options, + this.options, + this.globalOptions, // Currently set global edge options + this.defaultOptions + ]; + + this.labelModule.update(this.options, pile); + if (this.labelModule.baseSize !== undefined) { this.baseFontSize = this.labelModule.baseSize; } @@ -211,46 +341,47 @@ class Edge { * @returns {boolean} */ updateEdgeType() { + let smooth = this.options.smooth; let dataChanged = false; let changeInType = true; - let smooth = this.options.smooth; if (this.edgeType !== undefined) { - if (this.edgeType instanceof BezierEdgeDynamic && smooth.enabled === true && smooth.type === 'dynamic') {changeInType = false;} - if (this.edgeType instanceof CubicBezierEdge && smooth.enabled === true && smooth.type === 'cubicBezier') {changeInType = false;} - if (this.edgeType instanceof BezierEdgeStatic && smooth.enabled === true && smooth.type !== 'dynamic' && smooth.type !== 'cubicBezier') {changeInType = false;} - if (this.edgeType instanceof StraightEdge && smooth.enabled === false) {changeInType = false;} - + if ((((this.edgeType instanceof BezierEdgeDynamic) && + (smooth.enabled === true) && + (smooth.type === 'dynamic'))) || + (((this.edgeType instanceof CubicBezierEdge) && + (smooth.enabled === true) && + (smooth.type === 'cubicBezier'))) || + (((this.edgeType instanceof BezierEdgeStatic) && + (smooth.enabled === true) && + (smooth.type !== 'dynamic') && + (smooth.type !== 'cubicBezier'))) || + (((this.edgeType instanceof StraightEdge) && + (smooth.type.enabled === false)))) { + changeInType = false; + } if (changeInType === true) { dataChanged = this.cleanup(); } } - if (changeInType === true) { - if (this.options.smooth.enabled === true) { - if (this.options.smooth.type === 'dynamic') { + if (smooth.enabled === true) { + if (smooth.type === 'dynamic') { dataChanged = true; this.edgeType = new BezierEdgeDynamic(this.options, this.body, this.labelModule); - } - else if (this.options.smooth.type === 'cubicBezier') { + } else if (smooth.type === 'cubicBezier') { this.edgeType = new CubicBezierEdge(this.options, this.body, this.labelModule); - } - else { + } else { this.edgeType = new BezierEdgeStatic(this.options, this.body, this.labelModule); } - } - else { + } else { this.edgeType = new StraightEdge(this.options, this.body, this.labelModule); } - } - else { - // if nothing changes, we just set the options. + } else { // if nothing changes, we just set the options. this.edgeType.setOptions(this.options); } - return dataChanged; } - /** * Connect an edge to its nodes */ @@ -314,10 +445,9 @@ class Edge { } - /** * Retrieve the value of the edge. Can be undefined - * @return {Number} value + * @return {number} value */ getValue() { return this.options.value; @@ -327,9 +457,9 @@ class Edge { /** * 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 + * @param {number} min + * @param {number} max + * @param {number} total */ setValueRange(min, max, total) { if (this.options.value !== undefined) { @@ -350,21 +480,22 @@ class Edge { this.updateLabelModule(); } - _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; - } - } + /** + * + * @private + */ + _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; + } + } /** @@ -374,6 +505,11 @@ class Edge { * @param {CanvasRenderingContext2D} ctx */ draw(ctx) { + let values = this.getFormattingValues(); + if (values.hidden) { + return; + } + // get the via node from the edge type let viaNode = this.edgeType.getViaNode(); let arrowData = {}; @@ -383,56 +519,84 @@ class Edge { this.edgeType.toPoint = this.edgeType.to; // from and to arrows give a different end point for edges. we set them here - if (this.options.arrows.from.enabled === true) { - arrowData.from = this.edgeType.getArrowData(ctx,'from', viaNode, this.selected, this.hover); - if (this.options.arrowStrikethrough === false) + if (values.fromArrow) { + arrowData.from = this.edgeType.getArrowData(ctx, 'from', viaNode, this.selected, this.hover, values); + if (values.arrowStrikethrough === false) this.edgeType.fromPoint = arrowData.from.core; } - if (this.options.arrows.to.enabled === true) { - arrowData.to = this.edgeType.getArrowData(ctx,'to', viaNode, this.selected, this.hover); - if (this.options.arrowStrikethrough === false) + if (values.toArrow) { + arrowData.to = this.edgeType.getArrowData(ctx, 'to', viaNode, this.selected, this.hover, values); + if (values.arrowStrikethrough === false) this.edgeType.toPoint = arrowData.to.core; } // the middle arrow depends on the line, which can depend on the to and from arrows so we do this one lastly. - if (this.options.arrows.middle.enabled === true) { - arrowData.middle = this.edgeType.getArrowData(ctx,'middle', viaNode, this.selected, this.hover); + if (values.middleArrow) { + arrowData.middle = this.edgeType.getArrowData(ctx,'middle', viaNode, this.selected, this.hover, values); } // draw everything - this.edgeType.drawLine(ctx, this.selected, this.hover, viaNode); - this.drawArrows(ctx, arrowData); - this.drawLabel (ctx, viaNode); + this.edgeType.drawLine(ctx, values, this.selected, this.hover, viaNode); + this.drawArrows(ctx, arrowData, values); + this.drawLabel(ctx, viaNode); } - - drawArrows(ctx, arrowData) { - if (this.options.arrows.from.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.from);} - if (this.options.arrows.middle.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.middle);} - if (this.options.arrows.to.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.to);} + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} arrowData + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + drawArrows(ctx, arrowData, values) { + if (values.fromArrow) { + this.edgeType.drawArrowHead(ctx, values, this.selected, this.hover, arrowData.from); + } + if (values.middleArrow) { + this.edgeType.drawArrowHead(ctx, values, this.selected, this.hover, arrowData.middle); + } + if (values.toArrow) { + this.edgeType.drawArrowHead(ctx, values, this.selected, this.hover, arrowData.to); + } } - + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {Node} viaNode + */ 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 (this.labelModule.differentState(this.selected, this.hover)) { + this.labelModule.getTextSize(ctx, this.selected, this.hover); + } + 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); + let rotationPoint = this._getRotation(ctx); + if (rotationPoint.angle != 0) { + ctx.translate(rotationPoint.x, rotationPoint.y); + ctx.rotate(rotationPoint.angle); } // draw the label - this.labelModule.draw(ctx, point.x, point.y, selected); + this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover); + +/* + // Useful debug code: draw a border around the label + // This should **not** be enabled in production! + var size = this.labelModule.getSize();; // ;; intentional so lint catches it + ctx.strokeStyle = "#ff0000"; + ctx.strokeRect(size.left, size.top, size.width, size.height); + // End debug code +*/ + ctx.restore(); } else { @@ -449,9 +613,39 @@ class Edge { 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); + this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover); + } + } + } + + + /** + * Determine all visual elements of this edge instance, in which the given + * point falls within the bounding shape. + * + * @param {point} point + * @returns {Array.} list with the items which are on the point + */ + getItemsOnPoint(point) { + var ret = []; + + if (this.labelModule.visible()) { + let rotationPoint = this._getRotation(); + if (ComponentUtil.pointInRect(this.labelModule.getSize(), point, rotationPoint)) { + ret.push({edgeId:this.id, labelId:0}); } } + + let obj = { + left: point.x, + top: point.y + }; + + if (this.isOverlappingWith(obj)) { + ret.push({edgeId:this.id}); + } + + return ret; } @@ -480,31 +674,55 @@ class Edge { } - /** - * Rotates the canvas so the text is most readable - * @param {CanvasRenderingContext2D} ctx + /** + * Determine the rotation point, if any. + * + * @param {CanvasRenderingContext2D} [ctx] if passed, do a recalculation of the label size + * @returns {rotationPoint} the point to rotate around and the angle in radians to rotate * @private */ - _rotateForLabelAlignment(ctx) { + _getRotation(ctx) { + let viaNode = this.edgeType.getViaNode(); + let point = this.edgeType.getPoint(0.5, viaNode); + + if (ctx !== undefined) { + this.labelModule.calculateLabelSize(ctx, this.selected, this.hover, point.x, point.y); + } + + let ret = { + x: point.x, + y: this.labelModule.size.yLine, + angle: 0 + }; + + if (!this.labelModule.visible()) { + return ret; // Don't even bother doing the atan2, there's nothing to draw + } + + if (this.options.font.align === "horizontal") { + return ret; // No need to calculate angle + } + var dy = this.from.y - this.to.y; var dx = this.from.x - this.to.x; - var angleInDegrees = Math.atan2(dy, dx); + var angle = Math.atan2(dy, dx); // radians - // rotate so label it is readable - if ((angleInDegrees < -1 && dx < 0) || (angleInDegrees > 0 && dx < 0)) { - angleInDegrees = angleInDegrees + Math.PI; + // rotate so that label is readable + if ((angle < -1 && dx < 0) || (angle > 0 && dx < 0)) { + angle += Math.PI; } + ret.angle = angle; - ctx.rotate(angleInDegrees); + return ret; } /** * 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) + * @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 */ @@ -516,12 +734,16 @@ class Edge { } } - + /** + * Sets selected state to true + */ select() { this.selected = true; } - + /** + * Sets selected state to false + */ unselect() { this.selected = false; } @@ -534,6 +756,26 @@ class Edge { cleanup() { return this.edgeType.cleanup(); } + + + /** + * Remove edge from the list and perform necessary cleanup. + */ + remove() { + this.cleanup(); + this.disconnect(); + delete this.body.edges[this.id]; + } + + + /** + * Check if both connecting nodes exist + * @returns {boolean} + */ + endPointsValid() { + return this.body.nodes[this.fromId] !== undefined + && this.body.nodes[this.toId] !== undefined; + } } export default Edge; diff --git a/lib/network/modules/components/NavigationHandler.js b/lib/network/modules/components/NavigationHandler.js index 143478146..73bec0b43 100644 --- a/lib/network/modules/components/NavigationHandler.js +++ b/lib/network/modules/components/NavigationHandler.js @@ -1,9 +1,15 @@ -var util = require('../../../util'); var Hammer = require('../../../module/hammer'); var hammerUtil = require('../../../hammerUtil'); var keycharm = require('keycharm'); +/** + * Navigation Handler + */ class NavigationHandler { + /** + * @param {Object} body + * @param {Canvas} canvas + */ constructor(body, canvas) { this.body = body; this.canvas = canvas; @@ -22,6 +28,10 @@ class NavigationHandler { this.options = {} } + /** + * + * @param {Object} options + */ setOptions(options) { if (options !== undefined) { this.options = options; @@ -29,6 +39,9 @@ class NavigationHandler { } } + /** + * Creates or refreshes navigation and sets key bindings + */ create() { if (this.options.navigationButtons === true) { if (this.iconsCreated === false) { @@ -42,6 +55,9 @@ class NavigationHandler { this.configureKeyboardBindings(); } + /** + * Cleans up previous navigation items + */ cleanNavigation() { // clean hammer bindings if (this.navigationHammers.length != 0) { @@ -103,6 +119,10 @@ class NavigationHandler { this.iconsCreated = true; } + /** + * + * @param {string} action + */ bindToRedraw(action) { if (this.boundFunctions[action] === undefined) { this.boundFunctions[action] = this[action].bind(this); @@ -111,6 +131,10 @@ class NavigationHandler { } } + /** + * + * @param {string} action + */ unbindFromRedraw(action) { if (this.boundFunctions[action] !== undefined) { this.body.emitter.off("initRedraw", this.boundFunctions[action]); @@ -145,11 +169,30 @@ class NavigationHandler { } this.boundFunctions = {}; } - + /** + * + * @private + */ _moveUp() {this.body.view.translation.y += this.options.keyboard.speed.y;} + /** + * + * @private + */ _moveDown() {this.body.view.translation.y -= this.options.keyboard.speed.y;} + /** + * + * @private + */ _moveLeft() {this.body.view.translation.x += this.options.keyboard.speed.x;} + /** + * + * @private + */ _moveRight(){this.body.view.translation.x -= this.options.keyboard.speed.x;} + /** + * + * @private + */ _zoomIn() { var scaleOld = this.body.view.scale; var scale = this.body.view.scale * (1 + this.options.keyboard.speed.zoom); @@ -160,8 +203,14 @@ class NavigationHandler { this.body.view.scale = scale; this.body.view.translation = { x: tx, y: ty }; - this.body.emitter.emit('zoom', { direction: '+', scale: this.body.view.scale }); + this.body.emitter.emit('zoom', { direction: '+', scale: this.body.view.scale, pointer: null }); + } + + /** + * + * @private + */ _zoomOut() { var scaleOld = this.body.view.scale; var scale = this.body.view.scale / (1 + this.options.keyboard.speed.zoom); @@ -172,7 +221,7 @@ class NavigationHandler { this.body.view.scale = scale; this.body.view.translation = { x: tx, y: ty }; - this.body.emitter.emit('zoom', { direction: '-', scale: this.body.view.scale }); + this.body.emitter.emit('zoom', { direction: '-', scale: this.body.view.scale, pointer: null }); } @@ -226,4 +275,4 @@ class NavigationHandler { } -export default NavigationHandler; \ No newline at end of file +export default NavigationHandler; diff --git a/lib/network/modules/components/Node.js b/lib/network/modules/components/Node.js index 2a12564ea..6c0b3c582 100644 --- a/lib/network/modules/components/Node.js +++ b/lib/network/modules/components/Node.js @@ -1,55 +1,53 @@ 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 CircularOverImageBase from './nodes/shapes/CircularOverImageBase' -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"; +var Label = require('./shared/Label').default; +var ComponentUtil = require('./shared/ComponentUtil').default; +var Box = require('./nodes/shapes/Box').default; +var Circle = require('./nodes/shapes/Circle').default; +var CircularImage = require('./nodes/shapes/CircularImage').default; +var CircularOverImageBase = require('./nodes/shapes/CircularOverImageBase').default; +var Database = require('./nodes/shapes/Database').default; +var Diamond = require('./nodes/shapes/Diamond').default; +var Dot = require('./nodes/shapes/Dot').default; +var Ellipse = require('./nodes/shapes/Ellipse').default; +var Icon = require('./nodes/shapes/Icon').default; +var Image = require('./nodes/shapes/Image').default; +var Square = require('./nodes/shapes/Square').default; +var Hexagon = require('./nodes/shapes/Hexagon').default; +var Star = require('./nodes/shapes/Star').default; +var Text = require('./nodes/shapes/Text').default; +var Triangle = require('./nodes/shapes/Triangle').default; +var TriangleDown = require('./nodes/shapes/TriangleDown').default; +var { printStyle } = require("../../../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) { + /** + * + * @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 + * {string} image An image url + * {string} title A title text, can be HTML + * {anytype} group A group name or number + * + * @param {Object} body Shared state of current network instance + * @param {Network.Images} imagelist A list with images. Only needed when the node has an image + * @param {Groups} grouplist A list with groups. Needed for retrieving group options + * @param {Object} globalOptions Current global node options; these serve as defaults for the node instance + * @param {Object} defaultOptions Global default options for nodes; note that this is also the prototype + * for parameter `globalOptions`. + */ + constructor(options, body, imagelist, grouplist, globalOptions, defaultOptions) { this.options = util.bridgeObject(globalOptions); this.globalOptions = globalOptions; + this.defaultOptions = defaultOptions; this.body = body; this.edges = []; // all edges connected to this node @@ -86,6 +84,7 @@ class Node { /** * Detach a edge from the node + * * @param {Edge} edge */ detachEdge(edge) { @@ -98,27 +97,30 @@ class Node { /** * Set or overwrite options for the node + * * @param {Object} options an object with options - * @param {Object} constants and object with default, global options + * @returns {null|boolean} */ setOptions(options) { let currentShape = this.options.shape; if (!options) { - return; + return; // Note that the return value will be 'undefined'! This is OK. } + // basic options if (options.id !== undefined) {this.id = options.id;} if (this.id === undefined) { - throw "Node must have an id"; + throw new Error("Node must have an id"); } + Node.checkMass(options, this.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;} + else {this.x = parseInt(options.x); this.predefinedPosition = true;} } if (options.y !== undefined) { if (options.y === null) {this.y = undefined; this.predefinedPosition = false;} @@ -127,60 +129,129 @@ class Node { 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, this.globalOptions, this.grouplist); + + let pile = [options, this.options, this.defaultOptions]; + this.chooser = ComponentUtil.choosify('node', pile); + + this._load_images(); + this.updateLabelModule(options); + this.updateShape(currentShape); + + return (options.hidden !== undefined || options.physics !== undefined); + } + + + /** + * Load the images from the options, for the nodes that need them. + * + * TODO: The imageObj members should be moved to CircularImageBase. + * It's the only place where they are required. + * + * @private + */ + _load_images() { + // Don't bother loading for nodes without images + if (this.options.shape !== 'circularImage' && this.options.shape !== 'image') { + return; } - // this transforms all shorthands into fully defined options - Node.parseOptions(this.options, options, true, this.globalOptions); - - // load the images - if (this.options.image !== undefined) { - if (this.imagelist) { - this.imageObj = this.imagelist.load(this.options.image, this.options.brokenImage, this.id); - if(this.options.circularImage !== undefined) { - this.circularImageObj = this.imagelist.load(this.options.circularImage, + if (this.options.image === undefined) { + throw new Error("Option image must be defined for node type '" + this.options.shape + "'"); + } + + if (this.imagelist === undefined) { + throw new Error("Internal Error: No images provided"); + } + + if (typeof this.options.image === 'string') { + this.imageObj = this.imagelist.load(this.options.image, this.options.brokenImage, this.id); + if(this.options.circularImage !== undefined) { + this.circularImageObj = this.imagelist.load(this.options.circularImage, this.options.brokenImage, this.id); - } } - else { - throw "No imagelist provided"; + } else { + if (this.options.image.unselected === undefined) { + throw new Error("No unselected image provided"); + } + + this.imageObj = this.imagelist.load(this.options.image.unselected, this.options.brokenImage, this.id); + if(this.options.circularImage !== undefined) { + this.circularImageObj = this.imagelist.load(this.options.circularImage, + this.options.brokenImage, this.id); + } + + if (this.options.image.selected !== undefined) { + this.imageObjAlt = this.imagelist.load(this.options.image.selected, this.options.brokenImage, this.id); + } else { + this.imageObjAlt = undefined; } } + } - this.updateLabelModule(); - this.updateShape(currentShape); - if (options.hidden !== undefined || options.physics !== undefined) { - return true; + /** + * Copy group option values into the node options. + * + * The group options override the global node options, so the copy of group options + * must happen *after* the global node options have been set. + * + * This method must also be called also if the global node options have changed and the group options did not. + * + * @param {Object} parentOptions + * @param {Object} newOptions new values for the options, currently only passed in for check + * @param {Object} groupList + */ + static updateGroupOptions(parentOptions, newOptions, groupList) { + if (groupList === undefined) return; // No groups, nothing to do + + var group = parentOptions.group; + + // paranoia: the selected group is already merged into node options, check. + if (newOptions !== undefined && newOptions.group !== undefined && group !== newOptions.group) { + throw new Error("updateGroupOptions: group values in options don't match."); } - return false; + + var hasGroup = (typeof group === 'number' || (typeof group === 'string' && group != '')); + if (!hasGroup) return; // current node has no group, no need to merge + + var groupObj = groupList.get(group); + + // Skip merging of group font options into parent; these are required to be distinct for labels + // TODO: It might not be a good idea either to merge the rest of the options, investigate this. + util.selectiveNotDeepExtend(['font'], parentOptions, groupObj); + + // the color object needs to be completely defined. + // Since groups can partially overwrite the colors, we parse it again, just in case. + parentOptions.color = util.parseColor(parentOptions.color); } /** * 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 - * @param allowDeletion - * @param globalOptions + * + * @param {Object} parentOptions + * @param {Object} newOptions + * @param {boolean} [allowDeletion=false] + * @param {Object} [globalOptions={}] + * @param {Object} [groupList] + * @static */ - static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}) { + static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}, groupList) { + var fields = [ 'color', - 'font', 'fixed', 'shadow' ]; util.selectiveNotDeepExtend(fields, parentOptions, newOptions, allowDeletion); + Node.checkMass(newOptions); + // merge the shadow options into the parent. - util.mergeOptions(parentOptions, newOptions, 'shadow', allowDeletion, globalOptions); + util.mergeOptions(parentOptions, newOptions, 'shadow', globalOptions); // individual shape newOptions if (newOptions.color !== undefined && newOptions.color !== null) { @@ -207,33 +278,111 @@ class Node { } } - // handle the font options - if (newOptions.font !== undefined && newOptions.font !== null) { - Label.parseOptions(parentOptions.font, newOptions); - } - else if (allowDeletion === true && newOptions.font === null) { + if (allowDeletion === true && newOptions.font === null) { parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options } + Node.updateGroupOptions(parentOptions, newOptions, groupList); + // handle the scaling options, specifically the label part if (newOptions.scaling !== undefined) { - util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', allowDeletion, globalOptions.scaling); + util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', globalOptions.scaling); } } - updateLabelModule() { + + /** + * + * @returns {{color: *, borderWidth: *, borderColor: *, size: *, borderDashes: (boolean|Array|allOptions.nodes.shapeProperties.borderDashes|{boolean, array}), borderRadius: (number|allOptions.nodes.shapeProperties.borderRadius|{number}|Array), shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *}} + */ + getFormattingValues() { + let values = { + color: this.options.color.background, + borderWidth: this.options.borderWidth, + borderColor: this.options.color.border, + size: this.options.size, + borderDashes: this.options.shapeProperties.borderDashes, + borderRadius: this.options.shapeProperties.borderRadius, + shadow: this.options.shadow.enabled, + shadowColor: this.options.shadow.color, + shadowSize: this.options.shadow.size, + shadowX: this.options.shadow.x, + shadowY: this.options.shadow.y + }; + if (this.selected || this.hover) { + if (this.chooser === true) { + if (this.selected) { + values.borderWidth *= 2; + values.color = this.options.color.highlight.background; + values.borderColor = this.options.color.highlight.border; + values.shadow = this.options.shadow.enabled; + } else if (this.hover) { + values.color = this.options.color.hover.background; + values.borderColor = this.options.color.hover.border; + values.shadow = this.options.shadow.enabled; + } + } else if (typeof this.chooser === 'function') { + this.chooser(values, this.options.id, this.selected, this.hover); + if (values.shadow === false) { + if ((values.shadowColor !== this.options.shadow.color) || + (values.shadowSize !== this.options.shadow.size) || + (values.shadowX !== this.options.shadow.x) || + (values.shadowY !== this.options.shadow.y)) { + values.shadow = true; + } + } + } + } else { + values.shadow = this.options.shadow.enabled; + } + return values; + } + + + /** + * + * @param {Object} options + */ + updateLabelModule(options) { if (this.options.label === undefined || this.options.label === null) { this.options.label = ''; } - this.labelModule.setOptions(this.options, true); + + Node.updateGroupOptions(this.options, options, this.grouplist); + + // + // Note:The prototype chain for this.options is: + // + // this.options -> NodesHandler.options -> NodesHandler.defaultOptions + // (also: this.globalOptions) + // + // Note that the prototypes are mentioned explicitly in the pile list below; + // WE DON'T WANT THE ORDER OF THE PROTOTYPES!!!! At least, not for font handling of labels. + // This is a good indication that the prototype usage of options is deficient. + // + var currentGroup = this.grouplist.get(this.options.group, false); + let pile = [ + options, // new options + this.options, // current node options, see comment above for prototype + currentGroup, // group options, if any + this.globalOptions, // Currently set global node options + this.defaultOptions // Default global node options + ]; + this.labelModule.update(this.options, pile); + if (this.labelModule.baseSize !== undefined) { this.baseFontSize = this.labelModule.baseSize; } } + + /** + * + * @param {string} currentShape + */ updateShape(currentShape) { if (currentShape === this.options.shape && this.shape) { - this.shape.setOptions(this.options, this.imageObj); + this.shape.setOptions(this.options, this.imageObj, this.imageObjAlt); } else { // choose draw method depending on the shape @@ -245,7 +394,7 @@ class Node { 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); + this.shape = new CircularImage(this.options, this.body, this.labelModule, this.imageObj, this.imageObjAlt); break; case 'circularOverImage': this.shape = new CircularOverImageBase(this.options, this.body, this.labelModule, @@ -267,11 +416,14 @@ class Node { 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); + this.shape = new Image(this.options, this.body, this.labelModule, this.imageObj, this.imageObjAlt); break; case 'square': this.shape = new Square(this.options, this.body, this.labelModule); break; + case 'hexagon': + this.shape = new Hexagon(this.options, this.body, this.labelModule); + break; case 'star': this.shape = new Star(this.options, this.body, this.labelModule); break; @@ -289,7 +441,7 @@ class Node { break; } } - this._reset(); + this.needsRefresh(); } @@ -298,7 +450,7 @@ class Node { */ select() { this.selected = true; - this._reset(); + this.needsRefresh(); } @@ -307,18 +459,16 @@ class Node { */ unselect() { this.selected = false; - this._reset(); + this.needsRefresh(); } /** * Reset the calculated size of the node, forces it to recalculate its size - * @private */ - _reset() { - this.shape.width = undefined; - this.shape.height = undefined; + needsRefresh() { + this.shape.refreshNeeded = true; } @@ -335,7 +485,7 @@ class Node { /** * Calculate the distance to the border of the Node * @param {CanvasRenderingContext2D} ctx - * @param {Number} angle Angle in radians + * @param {number} angle Angle in radians * @returns {number} distance Distance to the border in pixels */ distanceToBorder(ctx, angle) { @@ -363,18 +513,29 @@ class Node { /** * Retrieve the value of the node. Can be undefined - * @return {Number} value + * @return {number} value */ getValue() { return this.options.value; } + /** + * Get the current dimensions of the label + * + * @return {rect} + */ + getLabelSize() { + return this.labelModule.size(); + } + + /** * Adjust the value range of the node. The node will adjust it's size * based on its value. - * @param {Number} min - * @param {Number} max + * @param {number} min + * @param {number} max + * @param {number} total */ setValueRange(min, max, total) { if (this.options.value !== undefined) { @@ -401,24 +562,52 @@ class Node { * @param {CanvasRenderingContext2D} ctx */ draw(ctx) { - this.shape.draw(ctx, this.x, this.y, this.selected, this.hover); + let values = this.getFormattingValues(); + this.shape.draw(ctx, this.x, this.y, this.selected, this.hover, values); } /** * Update the bounding box of the shape + * @param {CanvasRenderingContext2D} ctx */ 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); + let values = this.getFormattingValues(); + this.shape.resize(ctx, this.selected, this.hover, values); + } + + + /** + * Determine all visual elements of this node instance, in which the given + * point falls within the bounding shape. + * + * @param {point} point + * @returns {Array.} list with the items which are on the point + */ + getItemsOnPoint(point) { + var ret = []; + + if (this.labelModule.visible()) { + if (ComponentUtil.pointInRect(this.labelModule.getSize(), point)) { + ret.push({nodeId:this.id, labelId:0}); + } + } + + if (ComponentUtil.pointInRect(this.shape.boundingBox, point)) { + ret.push({nodeId:this.id}); + } + + return ret; } @@ -436,6 +625,7 @@ class Node { ); } + /** * Check if this object is overlapping with the provided object * @param {Object} obj an object with parameters left, top, right, bottom @@ -449,6 +639,28 @@ class Node { this.shape.boundingBox.bottom > obj.top ); } + + + /** + * Check valid values for mass + * + * The mass may not be negative or zero. If it is, reset to 1 + * + * @param {object} options + * @param {Node.id} id + * @static + */ + static checkMass(options, id) { + if (options.mass !== undefined && options.mass <= 0) { + let strId = ''; + if (id !== undefined) { + strId = ' in node id: ' + id; + } + console.log('%cNegative or zero mass disallowed' + strId + + ', setting mass to 1.' , printStyle); + options.mass = 1; + } + } } export default Node; diff --git a/lib/network/modules/components/algorithms/FloydWarshall.js b/lib/network/modules/components/algorithms/FloydWarshall.js index e0d3f92d4..5b481c18b 100644 --- a/lib/network/modules/components/algorithms/FloydWarshall.js +++ b/lib/network/modules/components/algorithms/FloydWarshall.js @@ -1,22 +1,33 @@ /** - * Created by Alex on 10-Aug-15. + * The Floyd–Warshall algorithm is an algorithm for finding shortest paths in + * a weighted graph with positive or negative edge weights (but with no negative + * cycles). - https://en.wikipedia.org/wiki/Floyd–Warshall_algorithm */ - - class FloydWarshall { - constructor(){} + /** + * @ignore + */ + constructor() { + } + /** + * + * @param {Object} body + * @param {Array.} nodesArray + * @param {Array.} edgesArray + * @returns {{}} + */ getDistances(body, nodesArray, edgesArray) { let D_matrix = {}; let edges = body.edges; // prepare matrix with large numbers for (let i = 0; i < nodesArray.length; i++) { - D_matrix[nodesArray[i]] = {}; - D_matrix[nodesArray[i]] = {}; + let node = nodesArray[i]; + let cell = {}; + D_matrix[node] = cell; for (let j = 0; j < nodesArray.length; j++) { - D_matrix[nodesArray[i]][nodesArray[j]] = (i == j ? 0 : 1e9); - D_matrix[nodesArray[i]][nodesArray[j]] = (i == j ? 0 : 1e9); + cell[nodesArray[j]] = (i == j ? 0 : 1e9); } } @@ -34,10 +45,18 @@ class FloydWarshall { // Adapted FloydWarshall based on unidirectionality to greatly reduce complexity. for (let k = 0; k < nodeCount; k++) { - for (let i = 0; i < nodeCount-1; i++) { - for (let j = i+1; j < nodeCount; j++) { - D_matrix[nodesArray[i]][nodesArray[j]] = Math.min(D_matrix[nodesArray[i]][nodesArray[j]],D_matrix[nodesArray[i]][nodesArray[k]] + D_matrix[nodesArray[k]][nodesArray[j]]) - D_matrix[nodesArray[j]][nodesArray[i]] = D_matrix[nodesArray[i]][nodesArray[j]]; + let knode = nodesArray[k]; + let kcolm = D_matrix[knode]; + for (let i = 0; i < nodeCount - 1; i++) { + let inode = nodesArray[i]; + let icolm = D_matrix[inode]; + for (let j = i + 1; j < nodeCount; j++) { + let jnode = nodesArray[j]; + let jcolm = D_matrix[jnode]; + + let val = Math.min(icolm[jnode], icolm[knode] + kcolm[jnode]); + icolm[jnode] = val; + jcolm[inode] = val; } } } diff --git a/lib/network/modules/components/edges/BezierEdgeDynamic.js b/lib/network/modules/components/edges/BezierEdgeDynamic.js index c35d641a4..02640e8d7 100644 --- a/lib/network/modules/components/edges/BezierEdgeDynamic.js +++ b/lib/network/modules/components/edges/BezierEdgeDynamic.js @@ -1,6 +1,18 @@ import BezierEdgeBase from './util/BezierEdgeBase' +/** + * A Dynamic Bezier Edge. Bezier curves are used to model smooth gradual + * curves in paths between nodes. The Dynamic piece refers to how the curve + * reacts to physics changes. + * + * @extends BezierEdgeBase + */ class BezierEdgeDynamic extends BezierEdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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 @@ -8,6 +20,10 @@ class BezierEdgeDynamic extends BezierEdgeBase { this.body.emitter.on("_repositionBezierNodes", this._boundFunction); } + /** + * + * @param {Object} options + */ setOptions(options) { // check if the physics has changed. let physicsChange = false; @@ -27,11 +43,14 @@ class BezierEdgeDynamic extends BezierEdgeBase { // when we change the physics state of the edge, we reposition the support node. if (physicsChange === true) { - this.via.setOptions({physics: this.options.physics}) + this.via.setOptions({physics: this.options.physics}); this.positionBezierNode(); } } + /** + * Connects an edge to node(s) + */ connect() { this.from = this.body.nodes[this.options.from]; this.to = this.body.nodes[this.options.to]; @@ -86,6 +105,9 @@ class BezierEdgeDynamic extends BezierEdgeBase { } } + /** + * Positions bezier node + */ positionBezierNode() { if (this.via !== undefined && this.from !== undefined && this.to !== undefined) { this.via.x = 0.5 * (this.from.x + this.to.x); @@ -100,25 +122,18 @@ class BezierEdgeDynamic extends BezierEdgeBase { /** * Draw a line between two nodes * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @param {Node} viaNode * @private */ - _line(ctx, viaNode) { - // draw a straight line - ctx.beginPath(); - ctx.moveTo(this.fromPoint.x, this.fromPoint.y); - // fallback to normal straight edges - if (viaNode.x === undefined) { - ctx.lineTo(this.toPoint.x, this.toPoint.y); - } - else { - ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y); - } - // draw shadow if enabled - this.enableShadow(ctx); - ctx.stroke(); - this.disableShadow(ctx); + _line(ctx, values, viaNode) { + this._bezierCurve(ctx, values, viaNode); } + /** + * + * @returns {Node|undefined|*|{index, line, column}} + */ getViaNode() { return this.via; } @@ -126,8 +141,9 @@ class BezierEdgeDynamic extends BezierEdgeBase { /** * 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 viaNode + * + * @param {number} percentage + * @param {Node} viaNode * @returns {{x: number, y: number}} * @private */ @@ -135,7 +151,7 @@ class BezierEdgeDynamic extends BezierEdgeBase { let t = percentage; let x, y; if (this.from === this.to){ - let [cx,cy,cr] = this._getCircleData(this.from) + let [cx,cy,cr] = this._getCircleData(this.from); let a = 2 * Math.PI * (1 - t); x = cx + cr * Math.sin(a); y = cy + cr - cr * (1 - Math.cos(a)); @@ -147,15 +163,31 @@ class BezierEdgeDynamic extends BezierEdgeBase { return {x: x, y: y}; } + /** + * + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @returns {*} + * @private + */ _findBorderPosition(nearNode, ctx) { return this._findBorderPositionBezier(nearNode, ctx, this.via); } + /** + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @returns {number} + * @private + */ _getDistanceToEdge(x1, y1, x2, y2, x3, y3) { // x3,y3 is the point return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, this.via); } - - } diff --git a/lib/network/modules/components/edges/BezierEdgeStatic.js b/lib/network/modules/components/edges/BezierEdgeStatic.js index d70ed7c0d..39cb99397 100644 --- a/lib/network/modules/components/edges/BezierEdgeStatic.js +++ b/lib/network/modules/components/edges/BezierEdgeStatic.js @@ -1,6 +1,17 @@ import BezierEdgeBase from './util/BezierEdgeBase' +/** + * A Static Bezier Edge. Bezier curves are used to model smooth gradual + * curves in paths between nodes. + * + * @extends BezierEdgeBase + */ class BezierEdgeStatic extends BezierEdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } @@ -8,26 +19,18 @@ class BezierEdgeStatic extends BezierEdgeBase { /** * Draw a line between two nodes * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @param {Node} viaNode * @private */ - _line(ctx, viaNode) { - // draw a straight line - ctx.beginPath(); - ctx.moveTo(this.fromPoint.x, this.fromPoint.y); - - // fallback to normal straight edges - if (viaNode.x === undefined) { - ctx.lineTo(this.toPoint.x, this.toPoint.y); - } - else { - ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y); - } - // draw shadow if enabled - this.enableShadow(ctx); - ctx.stroke(); - this.disableShadow(ctx); + _line(ctx, values, viaNode) { + this._bezierCurve(ctx, values, viaNode); } + /** + * + * @returns {Array.<{x: number, y: number}>} + */ getViaNode() { return this._getViaCoordinates(); } @@ -39,6 +42,7 @@ class BezierEdgeStatic extends BezierEdgeBase { * @private */ _getViaCoordinates() { + // Assumption: x/y coordinates in from/to always defined let xVia = undefined; let yVia = undefined; let factor = this.options.smooth.roundness; @@ -46,94 +50,55 @@ class BezierEdgeStatic extends BezierEdgeBase { 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; - } + let stepX; + let stepY; + + if (dx <= dy) { + stepX = stepY = factor * dy; + } else { + stepX = stepY = factor * dx; } - 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") { + + if (this.from.x > this.to.x) stepX = -stepX; + if (this.from.y >= this.to.y) stepY = -stepY; + + xVia = this.from.x + stepX; + yVia = this.from.y + stepY; + + if (type === "discrete") { + if (dx <= dy) { + xVia = dx < factor * dy ? this.from.x : xVia; + } else { 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; - } + let stepX = (1 - factor) * dx; + let stepY = (1 - factor) * dy; + + if (dx <= dy) { // up - down + stepX = 0; + if (this.from.y < this.to.y) stepY = -stepY; } - 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 { // left - right + if (this.from.x < this.to.x) stepX = -stepX; + stepY = 0; } + xVia = this.to.x + stepX; + yVia = this.to.y + stepY; } 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; - } + let stepX = (1 - factor) * dx; + if (this.from.x < this.to.x) stepX = -stepX; + xVia = this.to.x + stepX; yVia = this.from.y; } else if (type === 'vertical') { + let stepY = (1 - factor) * dy; + if (this.from.y < this.to.y) stepY = -stepY; 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; - } + yVia = this.to.y + stepY; } else if (type === 'curvedCW') { dx = this.to.x - this.from.x; @@ -160,74 +125,72 @@ class BezierEdgeStatic extends BezierEdgeBase { 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; - } + let stepX; + let stepY; + + if (dx <= dy) { + stepX = stepY = factor * dy; + } else { + stepX = stepY = factor * dx; + } + + if (this.from.x > this.to.x) stepX = -stepX; + if (this.from.y >= this.to.y) stepY = -stepY; + + xVia = this.from.x + stepX; + yVia = this.from.y + stepY; + + if (dx <= dy) { + if (this.from.x <= this.to.x) { + 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 { + 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)) { + 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; - } - } - 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; - } + yVia = this.to.y > yVia ? this.to.y : yVia; + } else { + yVia = this.to.y < yVia ? this.to.y : yVia; } } } return {x: xVia, y: yVia}; } + /** + * + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @param {Object} options + * @returns {*} + * @private + */ _findBorderPosition(nearNode, ctx, options = {}) { return this._findBorderPositionBezier(nearNode, ctx, options.via); } + /** + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @param {Node} viaNode + * @returns {number} + * @private + */ _getDistanceToEdge(x1, y1, x2, y2, x3, y3, viaNode = this._getViaCoordinates()) { // x3,y3 is the point return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, viaNode); } /** * 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 viaNode + * @param {number} percentage + * @param {Node} viaNode * @returns {{x: number, y: number}} * @private */ @@ -241,4 +204,4 @@ class BezierEdgeStatic extends BezierEdgeBase { } -export default BezierEdgeStatic; \ No newline at end of file +export default BezierEdgeStatic; diff --git a/lib/network/modules/components/edges/CubicBezierEdge.js b/lib/network/modules/components/edges/CubicBezierEdge.js index e7b766952..1ac243f5e 100644 --- a/lib/network/modules/components/edges/CubicBezierEdge.js +++ b/lib/network/modules/components/edges/CubicBezierEdge.js @@ -1,6 +1,17 @@ import CubicBezierEdgeBase from './util/CubicBezierEdgeBase' +/** + * A Cubic Bezier Edge. Bezier curves are used to model smooth gradual + * curves in paths between nodes. + * + * @extends CubicBezierEdgeBase + */ class CubicBezierEdge extends CubicBezierEdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } @@ -8,30 +19,22 @@ class CubicBezierEdge extends CubicBezierEdgeBase { /** * Draw a line between two nodes * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @param {Array.} viaNodes * @private */ - _line(ctx, viaNodes) { + _line(ctx, values, viaNodes) { // get the coordinates of the support points. let via1 = viaNodes[0]; let via2 = viaNodes[1]; - - // start drawing the line. - ctx.beginPath(); - ctx.moveTo(this.fromPoint.x, this.fromPoint.y); - - // fallback to normal straight edges - if (viaNodes === undefined || via1.x === undefined) { - ctx.lineTo(this.toPoint.x, this.toPoint.y); - } - else { - ctx.bezierCurveTo(via1.x, via1.y, via2.x, via2.y, this.toPoint.x, this.toPoint.y); - } - // draw shadow if enabled - this.enableShadow(ctx); - ctx.stroke(); - this.disableShadow(ctx); + this._bezierCurve(ctx, values, via1, via2); } + /** + * + * @returns {Array.<{x: number, y: number}>} + * @private + */ _getViaCoordinates() { let dx = this.from.x - this.to.x; let dy = this.from.y - this.to.y; @@ -56,22 +59,47 @@ class CubicBezierEdge extends CubicBezierEdgeBase { return [{x: x1, y: y1},{x: x2, y: y2}]; } + /** + * + * @returns {Array.<{x: number, y: number}>} + */ getViaNode() { return this._getViaCoordinates(); } + /** + * + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @returns {{x: number, y: number, t: number}} + * @private + */ _findBorderPosition(nearNode, ctx) { return this._findBorderPositionBezier(nearNode, ctx); } + /** + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @param {Node} via1 + * @param {Node} via2 + * @returns {number} + * @private + */ _getDistanceToEdge(x1, y1, x2, y2, x3, y3, [via1, via2] = this._getViaCoordinates()) { // x3,y3 is the point return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via1, via2); } /** * 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 + * @param {number} percentage + * @param {{x: number, y: number}} [via1=this._getViaCoordinates()[0]] + * @param {{x: number, y: number}} [via2=this._getViaCoordinates()[1]] * @returns {{x: number, y: number}} * @private */ @@ -90,4 +118,4 @@ class CubicBezierEdge extends CubicBezierEdgeBase { } -export default CubicBezierEdge; \ No newline at end of file +export default CubicBezierEdge; diff --git a/lib/network/modules/components/edges/StraightEdge.js b/lib/network/modules/components/edges/StraightEdge.js index 6d7bf231d..073a08efb 100644 --- a/lib/network/modules/components/edges/StraightEdge.js +++ b/lib/network/modules/components/edges/StraightEdge.js @@ -1,6 +1,16 @@ import EdgeBase from './util/EdgeBase' +/** + * A Straight Edge. + * + * @extends EdgeBase + */ class StraightEdge extends EdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } @@ -8,27 +18,32 @@ class StraightEdge extends EdgeBase { /** * Draw a line between two nodes * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values * @private */ - _line(ctx) { + _line(ctx, values) { // draw a straight line ctx.beginPath(); ctx.moveTo(this.fromPoint.x, this.fromPoint.y); ctx.lineTo(this.toPoint.x, this.toPoint.y); // draw shadow if enabled - this.enableShadow(ctx); + this.enableShadow(ctx, values); ctx.stroke(); - this.disableShadow(ctx); + this.disableShadow(ctx, values); } + /** + * + * @returns {undefined} + */ getViaNode() { 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 + * + * @param {number} percentage * @returns {{x: number, y: number}} * @private */ @@ -39,6 +54,13 @@ class StraightEdge extends EdgeBase { } } + /** + * + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @returns {{x: number, y: number}} + * @private + */ _findBorderPosition(nearNode, ctx) { let node1 = this.to; let node2 = this.from; @@ -61,6 +83,17 @@ class StraightEdge extends EdgeBase { return borderPos; } + /** + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @returns {number} + * @private + */ _getDistanceToEdge(x1, y1, x2, y2, x3, y3) { // x3,y3 is the point return this._getDistanceToLine(x1, y1, x2, y2, x3, y3); } diff --git a/lib/network/modules/components/edges/util/BezierEdgeBase.js b/lib/network/modules/components/edges/util/BezierEdgeBase.js index 48b663bf6..f3442d3a8 100644 --- a/lib/network/modules/components/edges/util/BezierEdgeBase.js +++ b/lib/network/modules/components/edges/util/BezierEdgeBase.js @@ -1,6 +1,17 @@ import EdgeBase from './EdgeBase' +/** + * The Base Class for all Bezier edges. Bezier curves are used to model smooth + * gradual curves in paths between nodes. + * + * @extends EdgeBase + */ class BezierEdgeBase extends EdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } @@ -8,15 +19,11 @@ class BezierEdgeBase extends EdgeBase { /** * 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 + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @param {Node} viaNode + * @returns {*} + * @private */ _findBorderPositionBezier(nearNode, ctx, viaNode = this._getViaCoordinates()) { var maxIterations = 10; @@ -79,6 +86,8 @@ class BezierEdgeBase extends EdgeBase { * @param {number} y2 to y * @param {number} x3 point to check x * @param {number} y3 point to check y + * @param {Node} via + * @returns {number} * @private */ _getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via) { // x3,y3 is the point @@ -101,6 +110,50 @@ class BezierEdgeBase extends EdgeBase { return minDistance; } + + + /** + * Draw a bezier curve between two nodes + * + * The method accepts zero, one or two control points. + * Passing zero control points just draws a straight line + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} values | options for shadow drawing + * @param {Object|undefined} viaNode1 | first control point for curve drawing + * @param {Object|undefined} viaNode2 | second control point for curve drawing + * + * @protected + */ + _bezierCurve(ctx, values, viaNode1, viaNode2) { + var hasNode1 = (viaNode1 !== undefined && viaNode1.x !== undefined); + var hasNode2 = (viaNode2 !== undefined && viaNode2.x !== undefined); + + ctx.beginPath(); + ctx.moveTo(this.fromPoint.x, this.fromPoint.y); + + if (hasNode1 && hasNode2) { + ctx.bezierCurveTo(viaNode1.x, viaNode1.y, viaNode2.x, viaNode2.y, this.toPoint.x, this.toPoint.y); + } else if (hasNode1) { + ctx.quadraticCurveTo(viaNode1.x, viaNode1.y, this.toPoint.x, this.toPoint.y); + } else { + // fallback to normal straight edge + ctx.lineTo(this.toPoint.x, this.toPoint.y); + } + + // draw shadow if enabled + this.enableShadow(ctx, values); + ctx.stroke(); + this.disableShadow(ctx, values); + } + + /** + * + * @returns {*|{x, y}|{x: undefined, y: undefined}} + */ + getViaNode() { + return this._getViaCoordinates(); + } } -export default BezierEdgeBase; \ No newline at end of file +export default BezierEdgeBase; diff --git a/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js b/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js index a454eb48b..6a7f2a73d 100644 --- a/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js +++ b/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js @@ -1,6 +1,17 @@ import BezierEdgeBase from './BezierEdgeBase' +/** + * A Base Class for all Cubic Bezier Edges. Bezier curves are used to model + * smooth gradual curves in paths between nodes. + * + * @extends BezierEdgeBase + */ class CubicBezierEdgeBase extends BezierEdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } @@ -16,6 +27,9 @@ class CubicBezierEdgeBase extends BezierEdgeBase { * @param {number} y2 to y * @param {number} x3 point to check x * @param {number} y3 point to check y + * @param {Node} via1 + * @param {Node} via2 + * @returns {number} * @private */ _getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via1, via2) { // x3,y3 is the point diff --git a/lib/network/modules/components/edges/util/EdgeBase.js b/lib/network/modules/components/edges/util/EdgeBase.js index 1371ab4ec..59b1e67af 100644 --- a/lib/network/modules/components/edges/util/EdgeBase.js +++ b/lib/network/modules/components/edges/util/EdgeBase.js @@ -1,6 +1,17 @@ let util = require("../../../../../util"); +let EndPoints = require("./EndPoints").default; + +/** + * The Base Class for all edges. + * + */ class EdgeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { this.body = body; this.labelModule = labelModule; @@ -14,12 +25,26 @@ class EdgeBase { this.toPoint = this.to; } + /** + * Connects a node to itself + */ connect() { this.from = this.body.nodes[this.options.from]; this.to = this.body.nodes[this.options.to]; } - cleanup() {return false} + /** + * + * @returns {boolean} always false + */ + cleanup() { + return false; + } + + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; this.from = this.body.nodes[this.options.from]; @@ -31,39 +56,62 @@ class EdgeBase { * 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 + * @param {Array} values + * @param {boolean} selected + * @param {boolean} hover + * @param {Node} viaNode * @private */ - drawLine(ctx, selected, hover, viaNode) { + drawLine(ctx, values, selected, hover, viaNode) { // set style - ctx.strokeStyle = this.getColor(ctx, selected, hover); - ctx.lineWidth = this.getLineWidth(selected, hover); + ctx.strokeStyle = this.getColor(ctx, values, selected, hover); + ctx.lineWidth = values.width; - if (this.options.dashes !== false) { - this._drawDashedLine(ctx, viaNode); + if (values.dashes !== false) { + this._drawDashedLine(ctx, values, viaNode); } else { - this._drawLine(ctx, viaNode); + this._drawLine(ctx, values, viaNode); } } - _drawLine(ctx, viaNode, fromPoint, toPoint) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {Array} values + * @param {Node} viaNode + * @param {{x: number, y: number}} [fromPoint] + * @param {{x: number, y: number}} [toPoint] + * @private + */ + _drawLine(ctx, values, viaNode, fromPoint, toPoint) { if (this.from != this.to) { // draw line - this._line(ctx, viaNode, fromPoint, toPoint); + this._line(ctx, values, viaNode, fromPoint, toPoint); } else { let [x,y,radius] = this._getCircleData(ctx); - this._circle(ctx, x, y, radius); + this._circle(ctx, values, x, y, radius); } } - _drawDashedLine(ctx, viaNode, fromPoint, toPoint) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {Array} values + * @param {Node} viaNode + * @param {{x: number, y: number}} [fromPoint] TODO: Remove in next major release + * @param {{x: number, y: number}} [toPoint] TODO: Remove in next major release + * @private + */ + _drawDashedLine(ctx, values, viaNode, fromPoint, toPoint) { // eslint-disable-line no-unused-vars ctx.lineCap = 'round'; let pattern = [5,5]; - if (Array.isArray(this.options.dashes) === true) { - pattern = this.options.dashes; + if (Array.isArray(values.dashes) === true) { + pattern = values.dashes; } // only firefox and chrome support this method, else we use the legacy one. @@ -77,11 +125,11 @@ class EdgeBase { // draw the line if (this.from != this.to) { // draw line - this._line(ctx, viaNode); + this._line(ctx, values, viaNode); } else { let [x,y,radius] = this._getCircleData(ctx); - this._circle(ctx, x, y, radius); + this._circle(ctx, values, x, y, radius); } // restore the dash settings. @@ -96,19 +144,26 @@ class EdgeBase { } else { let [x,y,radius] = this._getCircleData(ctx); - this._circle(ctx, x, y, radius); + this._circle(ctx, values, x, y, radius); } // draw shadow if enabled - this.enableShadow(ctx); + this.enableShadow(ctx, values); ctx.stroke(); // disable shadows for other elements. - this.disableShadow(ctx); + this.disableShadow(ctx, values); } } + /** + * + * @param {Node} nearNode + * @param {CanvasRenderingContext2D} ctx + * @param {Object} options + * @returns {{x: number, y: number}} + */ findBorderPosition(nearNode, ctx, options) { if (this.from != this.to) { return this._findBorderPosition(nearNode, ctx, options); @@ -118,6 +173,11 @@ class EdgeBase { } } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @returns {{from: ({x: number, y: number, t: number}|*), to: ({x: number, y: number, t: number}|*)}} + */ findBorderPositions(ctx) { let from = {}; let to = {}; @@ -126,7 +186,7 @@ class EdgeBase { to = this._findBorderPosition(this.to, ctx); } else { - let [x,y,radius] = this._getCircleData(ctx); + let [x,y] = this._getCircleData(ctx).slice(0, 2); 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}); @@ -134,6 +194,12 @@ class EdgeBase { return {from, to}; } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @returns {Array.} x, y, radius + * @private + */ _getCircleData(ctx) { let x, y; let node = this.from; @@ -159,10 +225,10 @@ class EdgeBase { /** * 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) + * @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 */ @@ -176,9 +242,9 @@ class EdgeBase { /** * 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 + * @param {Node} node + * @param {CanvasRenderingContext2D} ctx + * @param {Object} options * @returns {*} * @private */ @@ -234,7 +300,9 @@ class EdgeBase { /** * Get the line width of the edge. Depends on width and whether one of the * connected nodes is selected. - * @return {Number} width + * @param {boolean} selected + * @param {boolean} hover + * @returns {number} width * @private */ getLineWidth(selected, hover) { @@ -251,25 +319,31 @@ class EdgeBase { } } - - getColor(ctx, selected, hover) { - let colorOptions = this.options.color; - if (colorOptions.inherit !== false) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @param {boolean} selected - Unused + * @param {boolean} hover - Unused + * @returns {string} + */ + getColor(ctx, values, selected, hover) { // eslint-disable-line no-unused-vars + if (values.inheritsColor !== false) { // when this is a loop edge, just use the 'from' method - if (colorOptions.inherit === 'both' && this.from.id !== this.to.id) { + if ((values.inheritsColor === '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); + if ((this.from.selected === false) && (this.to.selected === false)) { + fromColor = util.overrideOpacity(this.from.options.color.border, values.opacity); + toColor = util.overrideOpacity(this.to.options.color.border, values.opacity); } - else if (this.from.selected === true && this.to.selected === false) { + 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) { + else if ((this.from.selected === false) && (this.to.selected === true)) { fromColor = this.from.options.color.border; } grd.addColorStop(0, fromColor); @@ -279,51 +353,29 @@ class EdgeBase { 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); - } + if (values.inheritsColor === "to") { + return util.overrideOpacity(this.to.options.color.border, values.opacity); + } else { // "from" + return util.overrideOpacity(this.from.options.color.border, values.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; + } else { + return util.overrideOpacity(values.color, values.opacity); } } /** * Draw a line from a node to itself, a circle + * * @param {CanvasRenderingContext2D} ctx - * @param {Number} x - * @param {Number} y - * @param {Number} radius + * @param {Array} values + * @param {number} x + * @param {number} y + * @param {number} radius * @private */ - _circle(ctx, x, y, radius) { + _circle(ctx, values, x, y, radius) { // draw shadow if enabled - this.enableShadow(ctx); + this.enableShadow(ctx, values); // draw a circle ctx.beginPath(); @@ -331,45 +383,53 @@ class EdgeBase { ctx.stroke(); // disable shadows for other elements. - this.disableShadow(ctx); + this.disableShadow(ctx, values); } /** - * Calculate the distance between a point (x3,y3) and a line segment from - * (x1,y1) to (x2,y2). + * Calculate the distance between a point (x3,y3) and a line segment from (x1,y1) to (x2,y2). + * (x3,y3) is the point. + * * 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 + * @param {Node} via + * @param {Array} values + * @returns {number} */ - getDistanceToEdge(x1, y1, x2, y2, x3, y3, via) { // x3,y3 is the point + getDistanceToEdge(x1, y1, x2, y2, x3, y3, via, values) { // eslint-disable-line no-unused-vars 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 [x,y,radius] = this._getCircleData(undefined); 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; - } + return returnValue; } + + /** + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @returns {number} + * @private + */ _getDistanceToLine(x1, y1, x2, y2, x3, y3) { let px = x2 - x1; let py = y2 - y1; @@ -399,12 +459,15 @@ class EdgeBase { /** - * - * @param ctx - * @param position - * @param viaNode + * @param {CanvasRenderingContext2D} ctx + * @param {string} position + * @param {Node} viaNode + * @param {boolean} selected + * @param {boolean} hover + * @param {Array} values + * @returns {{point: *, core: {x: number, y: number}, angle: *, length: number, type: *}} */ - getArrowData(ctx, position, viaNode, selected, hover) { + getArrowData(ctx, position, viaNode, selected, hover, values) { // set lets let angle; let arrowPoint; @@ -413,27 +476,27 @@ class EdgeBase { let guideOffset; let scaleFactor; let type; - let lineWidth = this.getLineWidth(selected, hover); + let lineWidth = values.width; if (position === 'from') { node1 = this.from; node2 = this.to; guideOffset = 0.1; - scaleFactor = this.options.arrows.from.scaleFactor; - type = this.options.arrows.from.type; + scaleFactor = values.fromArrowScale; + type = values.fromArrowType; } else if (position === 'to') { node1 = this.to; node2 = this.from; guideOffset = -0.1; - scaleFactor = this.options.arrows.to.scaleFactor; - type = this.options.arrows.to.type; + scaleFactor = values.toArrowScale; + type = values.toArrowType; } else { node1 = this.to; node2 = this.from; - scaleFactor = this.options.arrows.middle.scaleFactor; - type = this.options.arrows.middle.type; + scaleFactor = values.middleArrowScale; + type = values.middleArrowType; } // if not connected to itself @@ -441,87 +504,88 @@ class EdgeBase { if (position !== 'middle') { // draw arrow head if (this.options.smooth.enabled === true) { - arrowPoint = this.findBorderPosition(node1, ctx, {via: viaNode}); + arrowPoint = this.findBorderPosition(node1, ctx, { via: viaNode }); let guidePos = this.getPoint(Math.max(0.0, Math.min(1.0, arrowPoint.t + guideOffset)), viaNode); angle = Math.atan2((arrowPoint.y - guidePos.y), (arrowPoint.x - guidePos.x)); - } - else { + } else { angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x)); arrowPoint = this.findBorderPosition(node1, ctx); } - } - else { + } else { angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x)); arrowPoint = this.getPoint(0.5, viaNode); // this is 0.6 to account for the size of the arrow. } - } - else { + } else { // draw circle let [x,y,radius] = this._getCircleData(ctx); if (position === 'from') { - arrowPoint = this.findBorderPosition(this.from, ctx, {x, y, low:0.25, high:0.6, direction:-1}); + arrowPoint = this.findBorderPosition(this.from, ctx, { x, y, low: 0.25, high: 0.6, direction: -1 }); angle = arrowPoint.t * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; - } - else if (position === 'to') { - arrowPoint = this.findBorderPosition(this.from, ctx, {x, y, low:0.6, high:1.0, direction:1}); + } else if (position === 'to') { + arrowPoint = this.findBorderPosition(this.from, ctx, { x, y, low: 0.6, high: 1.0, direction: 1 }); angle = arrowPoint.t * -2 * Math.PI + 1.5 * Math.PI - 1.1 * Math.PI; - } - else { + } else { arrowPoint = this._pointOnCircle(x, y, radius, 0.175); angle = 3.9269908169872414; // === 0.175 * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; } } + if (position === 'middle' && scaleFactor < 0) lineWidth *= -1; // reversed middle arrow let length = 15 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. var xi = arrowPoint.x - length * 0.9 * Math.cos(angle); var yi = arrowPoint.y - length * 0.9 * Math.sin(angle); - let arrowCore = {x: xi, y: yi}; + let arrowCore = { x: xi, y: yi }; - return {point: arrowPoint, core: arrowCore, angle: angle, length: length, type: type}; + return { point: arrowPoint, core: arrowCore, angle: angle, length: length, type: type }; } /** * - * @param ctx - * @param selected - * @param hover - * @param arrowData + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @param {boolean} selected + * @param {boolean} hover + * @param {Object} arrowData */ - drawArrowHead(ctx, selected, hover, arrowData) { + drawArrowHead(ctx, values, selected, hover, arrowData) { // set style - ctx.strokeStyle = this.getColor(ctx, selected, hover); + ctx.strokeStyle = this.getColor(ctx, values, selected, hover); ctx.fillStyle = ctx.strokeStyle; - ctx.lineWidth = this.getLineWidth(selected, hover); + ctx.lineWidth = values.width; - if (arrowData.type && arrowData.type.toLowerCase() === 'circle') { - // draw circle at the end of the line - ctx.circleEndpoint(arrowData.point.x, arrowData.point.y, arrowData.angle, arrowData.length); - } else { - // draw arrow at the end of the line - ctx.arrowEndpoint(arrowData.point.x, arrowData.point.y, arrowData.angle, arrowData.length); - } + EndPoints.draw(ctx, arrowData); // draw shadow if enabled - this.enableShadow(ctx); + this.enableShadow(ctx, values); ctx.fill(); // disable shadows for other elements. - this.disableShadow(ctx); + this.disableShadow(ctx, values); } - enableShadow(ctx) { - if (this.options.shadow.enabled === true) { - ctx.shadowColor = this.options.shadow.color; - ctx.shadowBlur = this.options.shadow.size; - ctx.shadowOffsetX = this.options.shadow.x; - ctx.shadowOffsetY = this.options.shadow.y; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + enableShadow(ctx, values) { + if (values.shadow === true) { + ctx.shadowColor = values.shadowColor; + ctx.shadowBlur = values.shadowSize; + ctx.shadowOffsetX = values.shadowX; + ctx.shadowOffsetY = values.shadowY; } } - disableShadow(ctx) { - if (this.options.shadow.enabled === true) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + disableShadow(ctx, values) { + if (values.shadow === true) { ctx.shadowColor = 'rgba(0,0,0,0)'; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; diff --git a/lib/network/modules/components/edges/util/EndPoints.js b/lib/network/modules/components/edges/util/EndPoints.js new file mode 100644 index 000000000..291d4067f --- /dev/null +++ b/lib/network/modules/components/edges/util/EndPoints.js @@ -0,0 +1,231 @@ +/** ============================================================================ + * Location of all the endpoint drawing routines. + * + * Every endpoint has its own drawing routine, which contains an endpoint definition. + * + * The endpoint definitions must have the following properies: + * + * - (0,0) is the connection point to the node it attaches to + * - The endpoints are orientated to the positive x-direction + * - The length of the endpoint is at most 1 + * + * As long as the endpoint classes remain simple and not too numerous, they will + * be contained within this module. + * All classes here except `EndPoints` should be considered as private to this module. + * + * ----------------------------------------------------------------------------- + * ### Further Actions + * + * After adding a new endpoint here, you also need to do the following things: + * + * - Add the new endpoint name to `network/options.js` in array `endPoints`. + * - Add the new endpoint name to the documentation. + * Scan for 'arrows.to.type` and add it to the description. + * - Add the endpoint to the examples. At the very least, add it to example + * `edgeStyles/arrowTypes`. + * ============================================================================= */ + +// NOTE: When a typedef is isolated in a separate comment block, an actual description is generated for it, +// using the rest of the commenting in the code block. Usage of typedef in other comments then +// link to there. TIL. +// +// Also noteworthy, all typedef's set up in this manner are collected in a single, global page 'global.html'. +// In other words, it doesn't matter *where* the typedef's are defined in the code. +// +// +// TODO: add descriptive commenting to given typedef's + +/** + * @typedef {{type:string, point:Point, angle:number, length:number}} ArrowData + * + * Object containing instantiation data for a given endpoint. + */ + +/** + * @typedef {{x:number, y:number}} Point + * + * A point in view-coordinates. + */ + +/** + * Common methods for endpoints + * + * @class + */ +class EndPoint { + + /** + * Apply transformation on points for display. + * + * The following is done: + * - rotate by the specified angle + * - multiply the (normalized) coordinates by the passed length + * - offset by the target coordinates + * + * @param {Array} points + * @param {ArrowData} arrowData + * @static + */ + static transform(points, arrowData) { + if (!(points instanceof Array)) { + points = [points]; + } + + var x = arrowData.point.x; + var y = arrowData.point.y; + var angle = arrowData.angle + var length = arrowData.length; + + for(var i = 0; i < points.length; ++i) { + var p = points[i]; + var xt = p.x * Math.cos(angle) - p.y * Math.sin(angle); + var yt = p.x * Math.sin(angle) + p.y * Math.cos(angle); + + p.x = x + length*xt; + p.y = y + length*yt; + } + } + + + /** + * Draw a closed path using the given real coordinates. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Array.} points + * @static + */ + static drawPath(ctx, points) { + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for(var i = 1; i < points.length; ++i) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.closePath(); + } +} + + + + +/** + * Drawing methods for the arrow endpoint. + * @extends EndPoint + */ +class Arrow extends EndPoint { + + /** + * Draw this shape at the end of a line. + * + * @param {CanvasRenderingContext2D} ctx + * @param {ArrowData} arrowData + * @static + */ + static draw(ctx, arrowData) { + // Normalized points of closed path, in the order that they should be drawn. + // (0, 0) is the attachment point, and the point around which should be rotated + var points = [ + { x: 0 , y: 0 }, + { x:-1 , y: 0.3}, + { x:-0.9, y: 0 }, + { x:-1 , y:-0.3}, + ]; + + EndPoint.transform(points, arrowData); + EndPoint.drawPath(ctx, points); + } +} + + +/** + * Drawing methods for the circle endpoint. + */ +class Circle { + + /** + * Draw this shape at the end of a line. + * + * @param {CanvasRenderingContext2D} ctx + * @param {ArrowData} arrowData + * @static + */ + static draw(ctx, arrowData) { + var point = {x:-0.4, y:0}; + + EndPoint.transform(point, arrowData); + ctx.circle(point.x, point.y, arrowData.length*0.4); + } +} + + +/** + * Drawing methods for the bar endpoint. + */ +class Bar { + + /** + * Draw this shape at the end of a line. + * + * @param {CanvasRenderingContext2D} ctx + * @param {ArrowData} arrowData + * @static + */ + static draw(ctx, arrowData) { +/* + var points = [ + {x:0, y:0.5}, + {x:0, y:-0.5} + ]; + + EndPoint.transform(points, arrowData); + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + ctx.lineTo(points[1].x, points[1].y); + ctx.stroke(); +*/ + + var points = [ + {x:0, y:0.5}, + {x:0, y:-0.5}, + {x:-0.15, y:-0.5}, + {x:-0.15, y:0.5}, + ]; + + EndPoint.transform(points, arrowData); + EndPoint.drawPath(ctx, points); + } +} + + +/** + * Drawing methods for the endpoints. + */ +class EndPoints { + + /** + * Draw an endpoint + * + * @param {CanvasRenderingContext2D} ctx + * @param {ArrowData} arrowData + * @static + */ + static draw(ctx, arrowData) { + var type; + if (arrowData.type) { + type = arrowData.type.toLowerCase(); + } + + switch (type) { + case 'circle': + Circle.draw(ctx, arrowData); + break; + case 'bar': + Bar.draw(ctx, arrowData); + break; + case 'arrow': // fall-through + default: + Arrow.draw(ctx, arrowData); + } + } +} + +export default EndPoints; diff --git a/lib/network/modules/components/nodes/Cluster.js b/lib/network/modules/components/nodes/Cluster.js index 2919c9473..0f2504771 100644 --- a/lib/network/modules/components/nodes/Cluster.js +++ b/lib/network/modules/components/nodes/Cluster.js @@ -1,16 +1,85 @@ -import Node from '../Node' +let util = require("../../../../util"); +let Node = require("../Node").default; /** + * A Cluster is a special Node that allows a group of Nodes positioned closely together + * to be represented by a single Cluster Node. * + * @extends Node */ class Cluster extends Node { - constructor(options, body, imagelist, grouplist, globalOptions) { - super(options, body, imagelist, grouplist, globalOptions); + /** + * @param {Object} options + * @param {Object} body + * @param {Array.}imagelist + * @param {Array} grouplist + * @param {Object} globalOptions + * @param {Object} defaultOptions Global default options for nodes + */ + constructor(options, body, imagelist, grouplist, globalOptions, defaultOptions) { + super(options, body, imagelist, grouplist, globalOptions, defaultOptions); this.isCluster = true; this.containedNodes = {}; this.containedEdges = {}; } + + + /** + * Transfer child cluster data to current and disconnect the child cluster. + * + * Please consult the header comment in 'Clustering.js' for the fields set here. + * + * @param {string|number} childClusterId id of child cluster to open + */ + _openChildCluster(childClusterId) { + let childCluster = this.body.nodes[childClusterId]; + if (this.containedNodes[childClusterId] === undefined) { + throw new Error('node with id: ' + childClusterId + ' not in current cluster'); + } + if (!childCluster.isCluster) { + throw new Error('node with id: ' + childClusterId + ' is not a cluster'); + } + + // Disconnect child cluster from current cluster + delete this.containedNodes[childClusterId]; + util.forEach(childCluster.edges, (edge) => { + delete this.containedEdges[edge.id]; + }); + + // Transfer nodes and edges + util.forEach(childCluster.containedNodes, (node, nodeId) => { + this.containedNodes[nodeId] = node; + }); + childCluster.containedNodes = {}; + + util.forEach(childCluster.containedEdges, (edge, edgeId) => { + this.containedEdges[edgeId] = edge; + }); + childCluster.containedEdges = {}; + + // Transfer edges within cluster edges which are clustered + util.forEach(childCluster.edges, (clusterEdge) => { + util.forEach(this.edges, (parentClusterEdge) => { + // Assumption: a clustered edge can only be present in a single clustering edge + // Not tested here + let index = parentClusterEdge.clusteringEdgeReplacingIds.indexOf(clusterEdge.id); + if (index === -1) return; + + util.forEach(clusterEdge.clusteringEdgeReplacingIds, (srcId) => { + parentClusterEdge.clusteringEdgeReplacingIds.push(srcId); + + // Maintain correct bookkeeping for transferred edge + this.body.edges[srcId].edgeReplacedById = parentClusterEdge.id; + }); + + // Remove cluster edge from parent cluster edge + parentClusterEdge.clusteringEdgeReplacingIds.splice(index, 1); + }); + }); + childCluster.edges = []; + } } + export default Cluster; diff --git a/lib/network/modules/components/nodes/shapes/Box.js b/lib/network/modules/components/nodes/shapes/Box.js index d2d157408..981fc93df 100644 --- a/lib/network/modules/components/nodes/shapes/Box.js +++ b/lib/network/modules/components/nodes/shapes/Box.js @@ -2,74 +2,82 @@ import NodeBase from '../util/NodeBase' +/** + * A Box Node/Cluster shape. + * + * @extends NodeBase + */ class Box extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor (options, body, labelModule) { super(options,body,labelModule); + this._setMargins(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; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected = this.selected, hover = this.hover) { + if (this.needsRefresh(selected, hover)) { + var dimensions = this.getDimensionsFromLabel(ctx, selected, hover); + + this.width = dimensions.width + this.margin.right + this.margin.left; + this.height = dimensions.height + this.margin.top + this.margin.bottom; + this.radius = this.width / 2; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx, selected); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.resize(ctx, selected, hover); 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 = this.options.shapeProperties.borderRadius; // only effective for box - ctx.roundRect(this.left, this.top, this.width, this.height, borderRadius); - - // draw shadow if enabled - this.enableShadow(ctx); - // draw the background - ctx.fill(); - // disable shadows for other elements. - this.disableShadow(ctx); - - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - ctx.restore(); + this.initContextForDraw(ctx, values); + ctx.roundRect(this.left, this.top, this.width, this.height, values.borderRadius); + this.performFill(ctx, values); - this.updateBoundingBox(x,y,ctx,selected); - this.labelModule.draw(ctx, x, y, selected); + this.updateBoundingBox(x, y, ctx, selected, hover); + this.labelModule.draw(ctx, this.left + this.textSize.width / 2 + this.margin.left, + this.top + this.textSize.height / 2 + this.margin.top, selected, hover); } - updateBoundingBox(x,y, ctx, selected) { - this.resize(ctx, selected); - this.left = x - this.width * 0.5; - this.top = y - this.height * 0.5; + /** + * + * @param {number} x width + * @param {number} y height + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + */ + updateBoundingBox(x, y, ctx, selected, hover) { + this._updateBoundingBox(x, y, ctx, selected, hover); let borderRadius = this.options.shapeProperties.borderRadius; // only effective for box - this.boundingBox.left = this.left - borderRadius; - this.boundingBox.top = this.top - borderRadius; - this.boundingBox.bottom = this.top + this.height + borderRadius; - this.boundingBox.right = this.left + this.width + borderRadius; + this._addBoundingBoxMargin(borderRadius); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { this.resize(ctx); let borderWidth = this.options.borderWidth; @@ -80,4 +88,4 @@ class Box extends NodeBase { } } -export default Box; \ No newline at end of file +export default Box; diff --git a/lib/network/modules/components/nodes/shapes/Circle.js b/lib/network/modules/components/nodes/shapes/Circle.js index 221e92df3..07c021746 100644 --- a/lib/network/modules/components/nodes/shapes/Circle.js +++ b/lib/network/modules/components/nodes/shapes/Circle.js @@ -2,51 +2,85 @@ import CircleImageBase from '../util/CircleImageBase' +/** + * A Circle Node/Cluster shape. + * + * @extends CircleImageBase + */ class Circle extends CircleImageBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { - super(options, body, labelModule) + super(options, body, labelModule); + this._setMargins(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; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected = this.selected, hover = this.hover) { + if (this.needsRefresh(selected, hover)) { + var dimensions = this.getDimensionsFromLabel(ctx, selected, hover); + var diameter = Math.max(dimensions.width + this.margin.right + this.margin.left, + dimensions.height + this.margin.top + this.margin.bottom); + + this.options.size = diameter / 2; // NOTE: this size field only set here, not in Ellipse, Database, Box this.width = diameter; this.height = diameter; - this.radius = 0.5*this.width; + this.radius = this.width / 2; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx, selected); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.resize(ctx, selected, hover); 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._drawRawCircle(ctx, x, y, values); this.updateBoundingBox(x,y); - this.labelModule.draw(ctx, x, y, selected); + this.labelModule.draw(ctx, this.left + this.textSize.width / 2 + this.margin.left, + y, selected, hover); } - updateBoundingBox(x,y) { + /** + * + * @param {number} x width + * @param {number} y height + */ + 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) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle - Unused + * @returns {number} + */ + distanceToBorder(ctx, angle) { // eslint-disable-line no-unused-vars this.resize(ctx); return this.width * 0.5; } } -export default Circle; \ No newline at end of file +export default Circle; diff --git a/lib/network/modules/components/nodes/shapes/CircularImage.js b/lib/network/modules/components/nodes/shapes/CircularImage.js index 751637795..f56010bd3 100644 --- a/lib/network/modules/components/nodes/shapes/CircularImage.js +++ b/lib/network/modules/components/nodes/shapes/CircularImage.js @@ -1,75 +1,112 @@ 'use strict'; - import CircleImageBase from '../util/CircleImageBase' +/** + * A CircularImage Node/Cluster shape. + * + * @extends CircleImageBase + */ class CircularImage extends CircleImageBase { - constructor (options, body, labelModule, imageObj) { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + * @param {Image} imageObj + * @param {Image} imageObjAlt + */ + constructor (options, body, labelModule, imageObj, imageObjAlt) { super(options, body, labelModule); - this.imageObj = imageObj || {src: '', width: 0, height: 0}; + this.setImages(imageObj, imageObjAlt); 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; - } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected = this.selected, hover = this.hover) { + var imageAbsent = (this.imageObj.src === undefined) || + (this.imageObj.width === undefined) || + (this.imageObj.height === undefined); + + if (imageAbsent) { + var diameter = this.options.size * 2; + this.width = diameter; + this.height = diameter; + this.radius = 0.5*this.width; + return; } - else { - if (this._swapToImageResizeWhenImageLoaded) { - this.width = undefined; - this.height = undefined; - this._swapToImageResizeWhenImageLoaded = false; - } + + // At this point, an image is present, i.e. this.imageObj is valid. + if (this.needsRefresh(selected, hover)) { this._resizeImage(); } } - draw(ctx, x, y, selected, hover) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.switchImages(selected); 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); - // draw the background circle. IMPORTANT: the stroke in this method is used by the clip method below. - this._drawRawCircle(ctx, x, y, selected, hover, size); + this._drawRawCircle(ctx, x, y, values); // now we draw in the circle, we save so we can revert the clip operation after drawing. ctx.save(); // clip is used to use the stroke in drawRawCircle as an area that we can draw in. ctx.clip(); // draw the image - this._drawImageAtPosition(ctx); + this._drawImageAtPosition(ctx, values); // restore so we can again draw on the full canvas ctx.restore(); - this._drawImageLabel(ctx, x, y, selected); + this._drawImageLabel(ctx, x, y, selected, hover); this.updateBoundingBox(x,y); } + // TODO: compare with Circle.updateBoundingBox(), consolidate? More stuff is happening here + /** + * + * @param {number} x width + * @param {number} y height + */ 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; + + // TODO: compare with Image.updateBoundingBox(), consolidate? 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) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle - Unused + * @returns {number} + */ + distanceToBorder(ctx, angle) { // eslint-disable-line no-unused-vars this.resize(ctx); return this.width * 0.5; } } -export default CircularImage; \ No newline at end of file +export default CircularImage; diff --git a/lib/network/modules/components/nodes/shapes/Database.js b/lib/network/modules/components/nodes/shapes/Database.js index dabc02fff..f9a17dab7 100644 --- a/lib/network/modules/components/nodes/shapes/Database.js +++ b/lib/network/modules/components/nodes/shapes/Database.js @@ -2,75 +2,70 @@ import NodeBase from '../util/NodeBase' +/** + * A Database Node/Cluster shape. + * + * @extends NodeBase + */ class Database extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor (options, body, labelModule) { super(options, body, labelModule); + this._setMargins(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; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + */ + resize(ctx, selected, hover) { + if (this.needsRefresh(selected, hover)) { + var dimensions = this.getDimensionsFromLabel(ctx, selected, hover); + var size = dimensions.width + this.margin.right + this.margin.left; + + this.width = size; this.height = size; - this.radius = 0.5*this.width; + this.radius = this.width / 2; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx, selected); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.resize(ctx, selected, hover); this.left = x - this.width / 2; this.top = y - this.height / 2; - var neutralborderWidth = this.options.borderWidth; - var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; - var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; - ctx.lineWidth = Math.min(this.width, borderWidth); - - ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - - 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 shadow if enabled - this.enableShadow(ctx); - // draw the background - ctx.fill(); - // disable shadows for other elements. - this.disableShadow(ctx); - - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - ctx.restore(); + this.initContextForDraw(ctx, values); + ctx.database(x - this.width / 2, y - this.height / 2, this.width, this.height); + this.performFill(ctx, values); - this.updateBoundingBox(x,y,ctx,selected); - this.labelModule.draw(ctx, x, y, selected); + this.updateBoundingBox(x, y, ctx, selected, hover); + this.labelModule.draw(ctx, this.left + this.textSize.width / 2 + this.margin.left, + this.top + this.textSize.height / 2 + this.margin.top, selected, hover); } - - 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; - } - + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { - return this._distanceToBorder(ctx,angle); + return this._distanceToBorder(ctx, angle); } } -export default Database; \ No newline at end of file +export default Database; diff --git a/lib/network/modules/components/nodes/shapes/Diamond.js b/lib/network/modules/components/nodes/shapes/Diamond.js index 9a72caaa0..8292e3bda 100644 --- a/lib/network/modules/components/nodes/shapes/Diamond.js +++ b/lib/network/modules/components/nodes/shapes/Diamond.js @@ -2,22 +2,43 @@ import ShapeBase from '../util/ShapeBase' +/** + * A Diamond Node/Cluster shape. + * + * @extends ShapeBase + */ class Diamond extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'diamond', 4, x, y, selected, hover, values); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Diamond; \ No newline at end of file +export default Diamond; diff --git a/lib/network/modules/components/nodes/shapes/Dot.js b/lib/network/modules/components/nodes/shapes/Dot.js index 684091393..9c4ac5ac3 100644 --- a/lib/network/modules/components/nodes/shapes/Dot.js +++ b/lib/network/modules/components/nodes/shapes/Dot.js @@ -2,23 +2,44 @@ import ShapeBase from '../util/ShapeBase' +/** + * A Dot Node/Cluster shape. + * + * @extends ShapeBase + */ class Dot extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule) } - resize(ctx) { - this._resizeShape(); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'circle', 2, x, y, selected, hover, values); } - draw(ctx, x, y, selected, hover) { - this._drawShape(ctx, 'circle', 2, x, y, selected, hover); - } - - distanceToBorder(ctx, angle) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ + distanceToBorder(ctx, angle) { // eslint-disable-line no-unused-vars this.resize(ctx); return this.options.size; } } -export default Dot; \ No newline at end of file +export default Dot; diff --git a/lib/network/modules/components/nodes/shapes/Ellipse.js b/lib/network/modules/components/nodes/shapes/Ellipse.js index 69a2d6dbb..b625ff680 100644 --- a/lib/network/modules/components/nodes/shapes/Ellipse.js +++ b/lib/network/modules/components/nodes/shapes/Ellipse.js @@ -2,73 +2,65 @@ import NodeBase from '../util/NodeBase' +/** + * Am Ellipse Node/Cluster shape. + * + * @extends NodeBase + */ class Ellipse extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); } - resize(ctx, selected) { - if (this.width === undefined) { - var textSize = this.labelModule.getTextSize(ctx, selected); - - this.height = textSize.height * 2; - this.width = textSize.width + this.height; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected = this.selected, hover = this.hover) { + if (this.needsRefresh(selected, hover)) { + var dimensions = this.getDimensionsFromLabel(ctx, selected, hover); + + this.height = dimensions.height * 2; + this.width = dimensions.width + dimensions.height; this.radius = 0.5*this.width; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx, selected); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.resize(ctx, selected, hover); this.left = x - this.width * 0.5; this.top = y - this.height * 0.5; - var neutralborderWidth = this.options.borderWidth; - var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; - var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; - ctx.lineWidth = Math.min(this.width, borderWidth); - - ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - - 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 shadow if enabled - this.enableShadow(ctx); - // draw the background - ctx.fill(); - // disable shadows for other elements. - this.disableShadow(ctx); - - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - - ctx.restore(); - - 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.initContextForDraw(ctx, values); + ctx.ellipse_vis(this.left, this.top, this.width, this.height); + this.performFill(ctx, values); - this.boundingBox.left = this.left; - this.boundingBox.top = this.top; - this.boundingBox.bottom = this.top + this.height; - this.boundingBox.right = this.left + this.width; + this.updateBoundingBox(x, y, ctx, selected, hover); + this.labelModule.draw(ctx, x, y, selected, hover); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { this.resize(ctx); var a = this.width * 0.5; diff --git a/lib/network/modules/components/nodes/shapes/Hexagon.js b/lib/network/modules/components/nodes/shapes/Hexagon.js new file mode 100644 index 000000000..6f4618ffd --- /dev/null +++ b/lib/network/modules/components/nodes/shapes/Hexagon.js @@ -0,0 +1,44 @@ +'use strict'; + +import ShapeBase from '../util/ShapeBase' + +/** + * A Hexagon Node/Cluster shape. + * + * @extends ShapeBase + */ +class Hexagon extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ + constructor(options, body, labelModule) { + super(options, body, labelModule) + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'hexagon', 4, x, y, selected, hover, values); + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ + distanceToBorder(ctx, angle) { + return this._distanceToBorder(ctx,angle); + } +} + +export default Hexagon; \ No newline at end of file diff --git a/lib/network/modules/components/nodes/shapes/Icon.js b/lib/network/modules/components/nodes/shapes/Icon.js index c01df0527..e2a170df7 100644 --- a/lib/network/modules/components/nodes/shapes/Icon.js +++ b/lib/network/modules/components/nodes/shapes/Icon.js @@ -2,41 +2,72 @@ import NodeBase from '../util/NodeBase' +/** + * An icon replacement for the default Node shape. + * + * @extends NodeBase + */ class Icon extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); + this._setMargins(labelModule); } - resize(ctx) { - if (this.width === undefined) { - var margin = 5; - var iconSize = { + /** + * + * @param {CanvasRenderingContext2D} ctx - Unused. + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected, hover) { + if (this.needsRefresh(selected, hover)) { + this.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.width = this.iconSize.width + this.margin.right + this.margin.left; + this.height = this.iconSize.height + this.margin.top + this.margin.bottom; this.radius = 0.5*this.width; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.resize(ctx, selected, hover); 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); + this.left = x - this.width / 2; + this.top = y - this.height / 2; + this._icon(ctx, x, y, selected, hover, values); if (this.options.label !== undefined) { var iconTextSpacing = 5; - this.labelModule.draw(ctx, x, y + this.height * 0.5 + iconTextSpacing, selected); + this.labelModule.draw(ctx, this.left + this.iconSize.width / 2 + this.margin.left, + y + this.height / 2 + iconTextSpacing, selected); } - this.updateBoundingBox(x,y) + this.updateBoundingBox(x, y) } - updateBoundingBox(x,y) { + /** + * + * @param {number} x + * @param {number} 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; @@ -50,7 +81,16 @@ class Icon extends NodeBase { } } - _icon(ctx, x, y, selected) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover - Unused + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + _icon(ctx, x, y, selected, hover, values) { let iconSize = Number(this.options.icon.size); if (this.options.icon.code !== undefined) { @@ -62,21 +102,26 @@ class Icon extends NodeBase { ctx.textBaseline = "middle"; // draw shadow if enabled - this.enableShadow(ctx); + this.enableShadow(ctx, values); ctx.fillText(this.options.icon.code, x, y); // disable shadows for other elements. - this.disableShadow(ctx); - } - else { + this.disableShadow(ctx, values); + } 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.') } } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Icon; \ No newline at end of file +export default Icon; diff --git a/lib/network/modules/components/nodes/shapes/Image.js b/lib/network/modules/components/nodes/shapes/Image.js index 34802a12c..7e18ad536 100644 --- a/lib/network/modules/components/nodes/shapes/Image.js +++ b/lib/network/modules/components/nodes/shapes/Image.js @@ -2,17 +2,59 @@ import CircleImageBase from '../util/CircleImageBase' + +/** + * An image-based replacement for the default Node shape. + * + * @extends CircleImageBase + */ class Image extends CircleImageBase { - constructor (options, body, labelModule, imageObj) { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + * @param {Image} imageObj + * @param {Image} imageObjAlt + */ + constructor (options, body, labelModule, imageObj, imageObjAlt) { super(options, body, labelModule); - this.imageObj = imageObj || {src: '', width: 0, height: 0}; + this.setImages(imageObj, imageObjAlt); } - resize() { - this._resizeImage(); + /** + * + * @param {CanvasRenderingContext2D} ctx - Unused. + * @param {boolean} [selected] + * @param {boolean} [hover] + */ + resize(ctx, selected = this.selected, hover = this.hover) { + var imageAbsent = (this.imageObj.src === undefined) || + (this.imageObj.width === undefined) || + (this.imageObj.height === undefined); + + if (imageAbsent) { + var side = this.options.size * 2; + this.width = side; + this.height = side; + return; + } + + if (this.needsRefresh(selected, hover)) { + this._resizeImage(); + } } - draw(ctx, x, y, selected, hover) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this.switchImages(selected); this.resize(); this.left = x - this.width / 2; this.top = y - this.height / 2; @@ -38,37 +80,26 @@ class Image extends CircleImageBase { this.height + ctx.lineWidth); ctx.fill(); - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - ctx.restore(); - + this.performStroke(ctx, values); + ctx.closePath(); } - this._drawImageAtPosition(ctx); + this._drawImageAtPosition(ctx, values); - this._drawImageLabel(ctx, x, y, selected || hover); + this._drawImageLabel(ctx, x, y, selected, hover); this.updateBoundingBox(x,y); } - updateBoundingBox(x,y) { + /** + * + * @param {number} x + * @param {number} 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; + this._updateBoundingBox(x, y); if (this.options.label !== undefined && this.labelModule.size.width > 0) { this.boundingBox.left = Math.min(this.boundingBox.left, this.labelModule.size.left); @@ -77,9 +108,15 @@ class Image extends CircleImageBase { } } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Image; \ No newline at end of file +export default Image; diff --git a/lib/network/modules/components/nodes/shapes/Square.js b/lib/network/modules/components/nodes/shapes/Square.js index e2fc6ebba..c7d52c83f 100644 --- a/lib/network/modules/components/nodes/shapes/Square.js +++ b/lib/network/modules/components/nodes/shapes/Square.js @@ -2,22 +2,43 @@ import ShapeBase from '../util/ShapeBase' +/** + * A Square Node/Cluster shape. + * + * @extends ShapeBase + */ class Square extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'square', 2, x, y, selected, hover, values); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Square; \ No newline at end of file +export default Square; diff --git a/lib/network/modules/components/nodes/shapes/Star.js b/lib/network/modules/components/nodes/shapes/Star.js index 4aae4fae1..007f9330f 100644 --- a/lib/network/modules/components/nodes/shapes/Star.js +++ b/lib/network/modules/components/nodes/shapes/Star.js @@ -2,22 +2,43 @@ import ShapeBase from '../util/ShapeBase' +/** + * A Star Node/Cluster shape. + * + * @extends ShapeBase + */ class Star extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'star', 4, x, y, selected, hover, values); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Star; \ No newline at end of file +export default Star; diff --git a/lib/network/modules/components/nodes/shapes/Text.js b/lib/network/modules/components/nodes/shapes/Text.js index ac22dc5ce..12c0f0593 100644 --- a/lib/network/modules/components/nodes/shapes/Text.js +++ b/lib/network/modules/components/nodes/shapes/Text.js @@ -2,51 +2,71 @@ import NodeBase from '../util/NodeBase' +/** + * A text-based replacement for the default Node shape. + * + * @extends NodeBase + */ class Text extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); + this._setMargins(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; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + */ + resize(ctx, selected, hover) { + if (this.needsRefresh(selected, hover)) { + this.textSize = this.labelModule.getTextSize(ctx, selected, hover); + this.width = this.textSize.width + this.margin.right + this.margin.left; + this.height = this.textSize.height + this.margin.top + this.margin.bottom; this.radius = 0.5*this.width; } } - draw(ctx, x, y, selected, hover) { - this.resize(ctx, selected || hover); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + 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); + this.enableShadow(ctx, values); + this.labelModule.draw(ctx, this.left + this.textSize.width / 2 + this.margin.left, + this.top + this.textSize.height / 2 + this.margin.top, selected, hover); // disable shadows for other elements. - this.disableShadow(ctx); + this.disableShadow(ctx, values); - 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; + this.updateBoundingBox(x, y, ctx, selected, hover); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Text; \ No newline at end of file +export default Text; diff --git a/lib/network/modules/components/nodes/shapes/Triangle.js b/lib/network/modules/components/nodes/shapes/Triangle.js index 6c58a4f12..4079ff5a7 100644 --- a/lib/network/modules/components/nodes/shapes/Triangle.js +++ b/lib/network/modules/components/nodes/shapes/Triangle.js @@ -2,22 +2,43 @@ import ShapeBase from '../util/ShapeBase' +/** + * A Triangle Node/Cluster shape. + * + * @extends ShapeBase + */ class Triangle extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'triangle', 3, x, y, selected, hover, values); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default Triangle; \ No newline at end of file +export default Triangle; diff --git a/lib/network/modules/components/nodes/shapes/TriangleDown.js b/lib/network/modules/components/nodes/shapes/TriangleDown.js index 044fd7c51..0f740d167 100644 --- a/lib/network/modules/components/nodes/shapes/TriangleDown.js +++ b/lib/network/modules/components/nodes/shapes/TriangleDown.js @@ -2,22 +2,43 @@ import ShapeBase from '../util/ShapeBase' +/** + * A downward facing Triangle Node/Cluster shape. + * + * @extends ShapeBase + */ class TriangleDown extends ShapeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ 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); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + draw(ctx, x, y, selected, hover, values) { + this._drawShape(ctx, 'triangleDown', 3, x, y, selected, hover, values); } + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + */ distanceToBorder(ctx, angle) { return this._distanceToBorder(ctx,angle); } } -export default TriangleDown; \ No newline at end of file +export default TriangleDown; diff --git a/lib/network/modules/components/nodes/util/CircleImageBase.js b/lib/network/modules/components/nodes/util/CircleImageBase.js index 020507e84..190828fed 100644 --- a/lib/network/modules/components/nodes/util/CircleImageBase.js +++ b/lib/network/modules/components/nodes/util/CircleImageBase.js @@ -1,150 +1,179 @@ -import NodeBase from '../util/NodeBase' - +import NodeBase from './NodeBase'; + +/** + * NOTE: This is a bad base class + * + * Child classes are: + * + * Image - uses *only* image methods + * Circle - uses *only* _drawRawCircle + * CircleImage - uses all + * + * TODO: Refactor, move _drawRawCircle to different module, derive Circle from NodeBase + * Rename this to ImageBase + * Consolidate common code in Image and CircleImage to base class + * + * @extends NodeBase + */ class CircleImageBase extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule); this.labelOffset = 0; - this.imageLoaded = false; + this.selected = false; } - setOptions(options, imageObj) { + /** + * + * @param {Object} options + * @param {Object} [imageObj] + * @param {Object} [imageObjAlt] + */ + setOptions(options, imageObj, imageObjAlt) { this.options = options; - this.imageObj = imageObj || {src: '', width: 0, height: 0}; + if (!(imageObj === undefined && imageObjAlt === undefined)) { + this.setImages(imageObj, imageObjAlt); + } } + /** - * 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. + * Set the images for this node. * - * @private + * The images can be updated after the initial setting of options; + * therefore, this method needs to be reentrant. + * + * For correct working in error cases, it is necessary to properly set + * field 'nodes.brokenImage' in the options. + * + * @param {Image} imageObj required; main image to show for this node + * @param {Image|undefined} imageObjAlt optional; image to show when node is selected */ - _resizeImage() { - let force = false; - if (!this.imageObj.width || !this.imageObj.height) { // undefined or 0 - this.imageLoaded = false; + setImages(imageObj, imageObjAlt) { + if (imageObjAlt && this.selected) { + this.imageObj = imageObjAlt; + this.imageObjAlt = imageObj; + } else { + this.imageObj = imageObj; + this.imageObjAlt = imageObjAlt; } - else if (this.imageLoaded === false) { - this.imageLoaded = true; - force = true; + } + + /** + * Set selection and switch between the base and the selected image. + * + * Do the switch only if imageObjAlt exists. + * + * @param {boolean} selected value of new selected state for current node + */ + switchImages(selected) { + var selection_changed = ((selected && !this.selected) || (!selected && this.selected)); + this.selected = selected; // Remember new selection + + if (this.imageObjAlt !== undefined && selection_changed) { + let imageTmp = this.imageObj; + this.imageObj = this.imageObjAlt; + this.imageObjAlt = imageTmp; } + } - 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.options.shapeProperties.useImageSize === false) { + /** + * Adjust the node dimensions for a loaded image. + * + * Pre: this.imageObj is valid + */ + _resizeImage() { + var width, height; + + if (this.options.shapeProperties.useImageSize === false) { + // Use the size property + var ratio_width = 1; + var ratio_height = 1; + + // Only calculate the proper ratio if both width and height not zero + if (this.imageObj.width && this.imageObj.height) { 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; + ratio_width = this.imageObj.width / 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; - height = this.options.size * 2 * ratio; + ratio_height = this.imageObj.height / this.imageObj.width; } } - else { - // when not using the size property, we use the image size - width = this.imageObj.width; - height = this.imageObj.height; - } - this.width = width; - this.height = height; - this.radius = 0.5 * this.width; + + width = this.options.size * 2 * ratio_width; + height = this.options.size * 2 * ratio_height; + } + else { + // Use the image size + width = this.imageObj.width; + height = this.imageObj.height; } + this.width = width; + this.height = height; + this.radius = 0.5 * this.width; } - _drawRawCircle(ctx, x, y, selected, hover, size) { - var neutralborderWidth = this.options.borderWidth; - var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; - var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; - ctx.lineWidth = Math.min(this.width, borderWidth); - - ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; - ctx.circle(x, y, size); - - // draw shadow if enabled - this.enableShadow(ctx); - // draw the background - ctx.fill(); - // disable shadows for other elements. - this.disableShadow(ctx); - - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - ctx.restore(); + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @private + */ + _drawRawCircle(ctx, x, y, values) { + this.initContextForDraw(ctx, values); + ctx.circle(x, y, values.size); + this.performFill(ctx, values); } - _drawImageAtPosition(ctx) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @private + */ + _drawImageAtPosition(ctx, values) { if (this.imageObj.width != 0) { // draw the image ctx.globalAlpha = 1.0; // draw shadow if enabled - this.enableShadow(ctx); - - let factor = (this.imageObj.width / this.width) / this.body.view.scale; - if (factor > 2 && this.options.shapeProperties.interpolation === true) { - let w = this.imageObj.width; - let h = this.imageObj.height; - var can2 = document.createElement('canvas'); - can2.width = w; - can2.height = w; - var ctx2 = can2.getContext('2d'); - - factor *= 0.5; - w *= 0.5; - h *= 0.5; - ctx2.drawImage(this.imageObj, 0, 0, w, h); - - let distance = 0; - let iterations = 1; - while (factor > 2 && iterations < 4) { - ctx2.drawImage(can2, distance, 0, w, h, distance+w, 0, w/2, h/2); - distance += w; - factor *= 0.5; - w *= 0.5; - h *= 0.5; - iterations += 1; - } - ctx.drawImage(can2, distance, 0, w, h, this.left, this.top, this.width, this.height); - } - else { - // draw image - ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height); + this.enableShadow(ctx, values); + + let factor = 1; + if (this.options.shapeProperties.interpolation === true) { + factor = (this.imageObj.width / this.width) / this.body.view.scale; } + this.imageObj.drawImageAtPosition(ctx, factor, this.left, this.top, this.width, this.height); // disable shadows for other elements. - this.disableShadow(ctx); + this.disableShadow(ctx, values); } } - _drawImageLabel(ctx, x, y, selected) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x width + * @param {number} y height + * @param {boolean} selected + * @param {boolean} hover + * @private + */ + _drawImageLabel(ctx, x, y, selected, hover) { var yLabel; var offset = 0; if (this.height !== undefined) { offset = this.height * 0.5; - var labelDimensions = this.labelModule.getTextSize(ctx); + var labelDimensions = this.labelModule.getTextSize(ctx, selected, hover); if (labelDimensions.lineCount >= 1) { offset += labelDimensions.height / 2; } @@ -155,8 +184,8 @@ class CircleImageBase extends NodeBase { if (this.options.label) { this.labelOffset = offset; } - this.labelModule.draw(ctx, x, yLabel, selected, 'hanging'); + this.labelModule.draw(ctx, x, yLabel, selected, hover, 'hanging'); } } -export default CircleImageBase; \ No newline at end of file +export default CircleImageBase; diff --git a/lib/network/modules/components/nodes/util/ImageBase.js b/lib/network/modules/components/nodes/util/ImageBase.js index 2ac59d21c..bf101145d 100644 --- a/lib/network/modules/components/nodes/util/ImageBase.js +++ b/lib/network/modules/components/nodes/util/ImageBase.js @@ -18,9 +18,12 @@ class ImageBase extends CircleImageBase { // making method extensible _drawImage(ctx, x, y, selected, hover) { this._resizeImage(); - this.left = x - this.width / 2; - this.top = y - this.height / 2; - + this.left = x - this.width / 2; + if(this.options.showAsBadge) { + this.top = y - this.height / 1.2; + } else { + this.top = y - this.height / 2; + } if (this.options.shapeProperties.useBorderWithImage === true) { var neutralborderWidth = this.options.borderWidth; var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; diff --git a/lib/network/modules/components/nodes/util/NodeBase.js b/lib/network/modules/components/nodes/util/NodeBase.js index 1cf6bf533..e2348517b 100644 --- a/lib/network/modules/components/nodes/util/NodeBase.js +++ b/lib/network/modules/components/nodes/util/NodeBase.js @@ -1,4 +1,12 @@ +/** + * The Base class for all Nodes. + */ class NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { this.body = body; this.labelModule = labelModule; @@ -8,13 +16,49 @@ class NodeBase { this.height = undefined; this.width = undefined; this.radius = undefined; + this.margin = undefined; + this.refreshNeeded = true; this.boundingBox = {top: 0, left: 0, right: 0, bottom: 0}; } + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; } + /** + * + * @param {Label} labelModule + * @private + */ + _setMargins(labelModule) { + this.margin = {}; + if (this.options.margin) { + if (typeof this.options.margin == 'object') { + this.margin.top = this.options.margin.top; + this.margin.right = this.options.margin.right; + this.margin.bottom = this.options.margin.bottom; + this.margin.left = this.options.margin.left; + } else { + this.margin.top = this.options.margin; + this.margin.right = this.options.margin; + this.margin.bottom = this.options.margin; + this.margin.left = this.options.margin; + } + } + labelModule.adjustSizes(this.margin) + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} angle + * @returns {number} + * @private + */ _distanceToBorder(ctx,angle) { var borderWidth = this.options.borderWidth; this.resize(ctx); @@ -23,17 +67,27 @@ class NodeBase { Math.abs(this.height / 2 / Math.sin(angle))) + borderWidth; } - enableShadow(ctx) { - if (this.options.shadow.enabled === true) { - ctx.shadowColor = this.options.shadow.color; - ctx.shadowBlur = this.options.shadow.size; - ctx.shadowOffsetX = this.options.shadow.x; - ctx.shadowOffsetY = this.options.shadow.y; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + enableShadow(ctx, values) { + if (values.shadow) { + ctx.shadowColor = values.shadowColor; + ctx.shadowBlur = values.shadowSize; + ctx.shadowOffsetX = values.shadowX; + ctx.shadowOffsetY = values.shadowY; } } - disableShadow(ctx) { - if (this.options.shadow.enabled === true) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + disableShadow(ctx, values) { + if (values.shadow) { ctx.shadowColor = 'rgba(0,0,0,0)'; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; @@ -41,10 +95,15 @@ class NodeBase { } } - enableBorderDashes(ctx) { - if (this.options.shapeProperties.borderDashes !== false) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + enableBorderDashes(ctx, values) { + if (values.borderDashes !== false) { if (ctx.setLineDash !== undefined) { - let dashes = this.options.shapeProperties.borderDashes; + let dashes = values.borderDashes; if (dashes === true) { dashes = [5,15] } @@ -53,21 +112,184 @@ class NodeBase { else { console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."); this.options.shapeProperties.borderDashes = false; + values.borderDashes = false; } } } - disableBorderDashes(ctx) { - if (this.options.shapeProperties.borderDashes !== false) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + disableBorderDashes(ctx, values) { + if (values.borderDashes !== false) { if (ctx.setLineDash !== undefined) { ctx.setLineDash([0]); } else { console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."); this.options.shapeProperties.borderDashes = false; + values.borderDashes = false; } } } + + /** + * Determine if the shape of a node needs to be recalculated. + * + * @param {boolean} selected + * @param {boolean} hover + * @returns {boolean} + * @protected + */ + needsRefresh(selected, hover) { + if (this.refreshNeeded === true) { + // This is probably not the best location to reset this member. + // However, in the current logic, it is the most convenient one. + this.refreshNeeded = false; + return true; + } + + return (this.width === undefined) || (this.labelModule.differentState(selected, hover)); + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + initContextForDraw(ctx, values) { + var borderWidth = values.borderWidth / this.body.view.scale; + + ctx.lineWidth = Math.min(this.width, borderWidth); + ctx.strokeStyle = values.borderColor; + ctx.fillStyle = values.color; + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + performStroke(ctx, values) { + var borderWidth = values.borderWidth / this.body.view.scale; + + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx, values); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx, values); + } + ctx.restore(); + } + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + */ + performFill(ctx, values) { + // draw shadow if enabled + this.enableShadow(ctx, values); + // draw the background + ctx.fill(); + // disable shadows for other elements. + this.disableShadow(ctx, values); + + this.performStroke(ctx, values); + } + + + /** + * + * @param {number} margin + * @private + */ + _addBoundingBoxMargin(margin) { + this.boundingBox.left -= margin; + this.boundingBox.top -= margin; + this.boundingBox.bottom += margin; + this.boundingBox.right += margin; + } + + + /** + * Actual implementation of this method call. + * + * Doing it like this makes it easier to override + * in the child classes. + * + * @param {number} x width + * @param {number} y height + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + * @private + */ + _updateBoundingBox(x, y, ctx, selected, hover) { + if (ctx !== undefined) { + this.resize(ctx, selected, hover); + } + + this.left = x - this.width / 2; + this.top = y - this.height/ 2; + + this.boundingBox.left = this.left; + this.boundingBox.top = this.top; + this.boundingBox.bottom = this.top + this.height; + this.boundingBox.right = this.left + this.width; + } + + + /** + * Default implementation of this method call. + * This acts as a stub which can be overridden. + * + * @param {number} x width + * @param {number} y height + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + */ + updateBoundingBox(x, y, ctx, selected, hover) { + this._updateBoundingBox(x, y, ctx, selected, hover); + } + + + /** + * Determine the dimensions to use for nodes with an internal label + * + * Currently, these are: Circle, Ellipse, Database, Box + * The other nodes have external labels, and will not call this method + * + * If there is no label, decent default values are supplied. + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + * @returns {{width:number, height:number}} + */ + getDimensionsFromLabel(ctx, selected, hover) { + // NOTE: previously 'textSize' was not put in 'this' for Ellipse + // TODO: examine the consequences. + this.textSize = this.labelModule.getTextSize(ctx, selected, hover); + var width = this.textSize.width; + var height = this.textSize.height; + + const DEFAULT_SIZE = 14; + if (width === 0) { + // This happens when there is no label text set + width = DEFAULT_SIZE; // use a decent default + height = DEFAULT_SIZE; // if width zero, then height also always zero + } + + return {width:width, height:height}; + } } -export default NodeBase; \ No newline at end of file +export default NodeBase; diff --git a/lib/network/modules/components/nodes/util/ShapeBase.js b/lib/network/modules/components/nodes/util/ShapeBase.js index d9e2cce24..087e1bfd2 100644 --- a/lib/network/modules/components/nodes/util/ShapeBase.js +++ b/lib/network/modules/components/nodes/util/ShapeBase.js @@ -1,62 +1,74 @@ import NodeBase from '../util/NodeBase' +/** + * Base class for constructing Node/Cluster Shapes. + * + * @extends NodeBase + */ class ShapeBase extends NodeBase { + /** + * @param {Object} options + * @param {Object} body + * @param {Label} labelModule + */ constructor(options, body, labelModule) { super(options, body, labelModule) } - _resizeShape() { - if (this.width === undefined) { - var size = 2 * this.options.size; + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} [selected] + * @param {boolean} [hover] + * @param {Object} [values={size: this.options.size}] + */ + resize(ctx, selected = this.selected, hover = this.hover, values = { size: this.options.size }) { + if (this.needsRefresh(selected, hover)) { + this.labelModule.getTextSize(ctx, selected, hover); + var size = 2 * values.size; this.width = size; this.height = size; this.radius = 0.5*this.width; } } - _drawShape(ctx, shape, sizeMultiplier, x, y, selected, hover) { - this._resizeShape(); - + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {string} shape + * @param {number} sizeMultiplier - Unused! TODO: Remove next major release + * @param {number} x + * @param {number} y + * @param {boolean} selected + * @param {boolean} hover + * @param {{toArrow: boolean, toArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), toArrowType: *, middleArrow: boolean, middleArrowScale: (number|allOptions.edges.arrows.middle.scaleFactor|{number}|Array), middleArrowType: (allOptions.edges.arrows.middle.type|{string}|string|*), fromArrow: boolean, fromArrowScale: (allOptions.edges.arrows.to.scaleFactor|{number}|allOptions.edges.arrows.middle.scaleFactor|allOptions.edges.arrows.from.scaleFactor|Array|number), fromArrowType: *, arrowStrikethrough: (*|boolean|allOptions.edges.arrowStrikethrough|{boolean}), color: undefined, inheritsColor: (string|string|string|allOptions.edges.color.inherit|{string, boolean}|Array|*), opacity: *, hidden: *, length: *, shadow: *, shadowColor: *, shadowSize: *, shadowX: *, shadowY: *, dashes: (*|boolean|Array|allOptions.edges.dashes|{boolean, array}), width: *}} values + * @private + */ + _drawShape(ctx, shape, sizeMultiplier, x, y, selected, hover, values) { + this.resize(ctx, selected, hover, values); this.left = x - this.width / 2; this.top = y - this.height / 2; - var neutralborderWidth = this.options.borderWidth; - var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; - var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; - ctx.lineWidth = Math.min(this.width, borderWidth); - - ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - 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 shadow if enabled - this.enableShadow(ctx); - // draw the background - ctx.fill(); - // disable shadows for other elements. - this.disableShadow(ctx); - - //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. - ctx.save(); - // if borders are zero width, they will be drawn with width 1 by default. This prevents that - if (borderWidth > 0) { - this.enableBorderDashes(ctx); - //draw the border - ctx.stroke(); - //disable dashed border for other elements - this.disableBorderDashes(ctx); - } - ctx.restore(); + this.initContextForDraw(ctx, values); + ctx[shape](x, y, values.size); + this.performFill(ctx, values); 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'); + // Need to call following here in order to ensure value for `this.labelModule.size.height` + this.labelModule.calculateLabelSize(ctx, selected, hover, x, y, 'hanging') + let yLabel = y + 0.5 * this.height + 0.5 * this.labelModule.size.height; + this.labelModule.draw(ctx, x, yLabel, selected, hover, 'hanging'); } this.updateBoundingBox(x,y); } - updateBoundingBox(x,y) { + /** + * + * @param {number} x + * @param {number} 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; @@ -65,12 +77,9 @@ class ShapeBase extends NodeBase { 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); + this.boundingBox.bottom = Math.max(this.boundingBox.bottom, this.boundingBox.bottom + this.labelModule.size.height); } } - - - } -export default ShapeBase; \ No newline at end of file +export default ShapeBase; diff --git a/lib/network/modules/components/physics/BarnesHutSolver.js b/lib/network/modules/components/physics/BarnesHutSolver.js index 413ee8709..f1508fcd6 100644 --- a/lib/network/modules/components/physics/BarnesHutSolver.js +++ b/lib/network/modules/components/physics/BarnesHutSolver.js @@ -1,5 +1,12 @@ - +/** + * Barnes Hut Solver + */ class BarnesHutSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; @@ -8,15 +15,25 @@ class BarnesHutSolver { this.randomSeed = 5; // debug: show grid - //this.body.emitter.on("afterDrawing", (ctx) => {this._debug(ctx,'#ff0000')}) + // this.body.emitter.on("afterDrawing", (ctx) => {this._debug(ctx,'#ff0000')}) } + /** + * + * @param {Object} options + */ 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 + + // if 1 then min distance = 0.5, if 0.5 then min distance = 0.5 + 0.5*node.shape.radius + this.overlapAvoidanceFactor = 1 - Math.max(0, Math.min(1, this.options.avoidOverlap)); } + /** + * + * @returns {number} random integer + */ seededRandom() { var x = Math.sin(this.randomSeed++) * 10000; return x - Math.floor(x); @@ -47,22 +64,32 @@ class BarnesHutSolver { 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._getForceContributions(barnesHutTree.root, node); } } } } + /** + * @param {Object} parentBranch + * @param {Node} node + * @private + */ + _getForceContributions(parentBranch, node) { + this._getForceContribution(parentBranch.children.NW, node); + this._getForceContribution(parentBranch.children.NE, node); + this._getForceContribution(parentBranch.children.SW, node); + this._getForceContribution(parentBranch.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 + * @param {Object} parentBranch + * @param {Node} node * @private */ _getForceContribution(parentBranch, node) { @@ -84,10 +111,7 @@ class BarnesHutSolver { 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); + this._getForceContributions(parentBranch, 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 @@ -102,11 +126,11 @@ class BarnesHutSolver { /** * Calculate the forces based on the distance. * - * @param distance - * @param dx - * @param dy - * @param node - * @param parentBranch + * @param {number} distance + * @param {number} dx + * @param {number} dy + * @param {Node} node + * @param {Object} parentBranch * @private */ _calculateForces(distance, dx, dy, node, parentBranch) { @@ -133,8 +157,9 @@ class BarnesHutSolver { /** * This function constructs the barnesHut tree recursively. It creates the root, splits it and starts placing the nodes. * - * @param nodes - * @param nodeIndices + * @param {Array.} nodes + * @param {Array.} nodeIndices + * @returns {{root: {centerOfMass: {x: number, y: number}, mass: number, range: {minX: number, maxX: number, minY: number, maxY: number}, size: number, calcSize: number, children: {data: null}, maxWidth: number, level: number, childrenCount: number}}} BarnesHutTree * @private */ _formBarnesHutTree(nodes, nodeIndices) { @@ -148,9 +173,10 @@ class BarnesHutSolver { // 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) { + let node = nodes[nodeIndices[i]]; + let x = node.x; + let y = node.y; + if (node.options.mass > 0) { if (x < minX) { minX = x; } @@ -217,19 +243,20 @@ class BarnesHutSolver { /** * this updates the mass of a branch. this is increased by adding a node. * - * @param parentBranch - * @param node + * @param {Object} parentBranch + * @param {Node} node * @private */ _updateBranchMass(parentBranch, node) { + let centerOfMass = parentBranch.centerOfMass; 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; + centerOfMass.x = centerOfMass.x * parentBranch.mass + node.x * node.options.mass; + centerOfMass.x *= totalMassInv; - parentBranch.centerOfMass.y = parentBranch.centerOfMass.y * parentBranch.mass + node.y * node.options.mass; - parentBranch.centerOfMass.y *= totalMassInv; + centerOfMass.y = centerOfMass.y * parentBranch.mass + node.y * node.options.mass; + centerOfMass.y *= totalMassInv; parentBranch.mass = totalMass; let biggestSize = Math.max(Math.max(node.height, node.radius), node.width); @@ -241,9 +268,9 @@ class BarnesHutSolver { /** * determine in which branch the node will be placed. * - * @param parentBranch - * @param node - * @param skipMassUpdate + * @param {Object} parentBranch + * @param {Node} node + * @param {boolean} skipMassUpdate * @private */ _placeInTree(parentBranch, node, skipMassUpdate) { @@ -252,55 +279,60 @@ class BarnesHutSolver { 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"); + let range = parentBranch.children.NW.range; + let region; + if (range.maxX > node.x) { // in NW or SW + if (range.maxY > node.y) { + region = "NW"; } - else { // in SW - this._placeInRegion(parentBranch, node, "SW"); + else { + region = "SW"; } } else { // in NE or SE - if (parentBranch.children.NW.range.maxY > node.y) { // in NE - this._placeInRegion(parentBranch, node, "NE"); + if (range.maxY > node.y) { + region = "NE"; } - else { // in SE - this._placeInRegion(parentBranch, node, "SE"); + else { + region = "SE"; } } + + this._placeInRegion(parentBranch, node, region); } /** * actually place the node in a region (or branch) * - * @param parentBranch - * @param node - * @param region + * @param {Object} parentBranch + * @param {Node} node + * @param {'NW'| 'NE' | 'SW' | 'SE'} region * @private */ _placeInRegion(parentBranch, node, region) { - switch (parentBranch.children[region].childrenCount) { + let children = parentBranch.children[region]; + + switch (children.childrenCount) { case 0: // place node here - parentBranch.children[region].children.data = node; - parentBranch.children[region].childrenCount = 1; - this._updateBranchMass(parentBranch.children[region], node); + children.children.data = node; + children.childrenCount = 1; + this._updateBranchMass(children, 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 little bit 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) { + if (children.children.data.x === node.x && children.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); + this._splitBranch(children); + this._placeInTree(children, node); } break; case 4: // place in branch - this._placeInTree(parentBranch.children[region], node); + this._placeInTree(children, node); break; } } @@ -310,7 +342,7 @@ class BarnesHutSolver { * 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 + * @param {Object} parentBranch * @private */ _splitBranch(parentBranch) { @@ -340,9 +372,8 @@ class BarnesHutSolver { * Specifically, this inserts a single new segment. * It fills the children section of the parentBranch * - * @param parentBranch - * @param region - * @param parentRange + * @param {Object} parentBranch + * @param {'NW'| 'NE' | 'SW' | 'SE'} region * @private */ _insertRegion(parentBranch, region) { @@ -390,16 +421,14 @@ class BarnesHutSolver { } - - //--------------------------- DEBUGGING BELOW ---------------------------// /** * This function is for debugging purposed, it draws the tree. * - * @param ctx - * @param color + * @param {CanvasRenderingContext2D} ctx + * @param {string} color * @private */ _debug(ctx, color) { @@ -415,9 +444,9 @@ class BarnesHutSolver { /** * This function is for debugging purposes. It draws the branches recursively. * - * @param branch - * @param ctx - * @param color + * @param {Object} branch + * @param {CanvasRenderingContext2D} ctx + * @param {string} color * @private */ _drawBranch(branch, ctx, color) { diff --git a/lib/network/modules/components/physics/CentralGravitySolver.js b/lib/network/modules/components/physics/CentralGravitySolver.js index 8b34b450f..ec914d015 100644 --- a/lib/network/modules/components/physics/CentralGravitySolver.js +++ b/lib/network/modules/components/physics/CentralGravitySolver.js @@ -1,14 +1,29 @@ +/** + * Central Gravity Solver + */ class CentralGravitySolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; this.setOptions(options); } + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; } + /** + * Calculates forces for each node + */ solve() { let dx, dy, distance, node; let nodes = this.body.nodes; @@ -28,6 +43,11 @@ class CentralGravitySolver { /** * Calculate the forces based on the distance. + * @param {number} distance + * @param {number} dx + * @param {number} dy + * @param {Object} forces + * @param {Node} node * @private */ _calculateForces(distance, dx, dy, forces, node) { diff --git a/lib/network/modules/components/physics/FA2BasedCentralGravitySolver.js b/lib/network/modules/components/physics/FA2BasedCentralGravitySolver.js index 5e266e608..7c708669a 100644 --- a/lib/network/modules/components/physics/FA2BasedCentralGravitySolver.js +++ b/lib/network/modules/components/physics/FA2BasedCentralGravitySolver.js @@ -1,6 +1,14 @@ import CentralGravitySolver from "./CentralGravitySolver" +/** + * @extends CentralGravitySolver + */ class ForceAtlas2BasedCentralGravitySolver extends CentralGravitySolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { super(body, physicsBody, options); } @@ -8,6 +16,12 @@ class ForceAtlas2BasedCentralGravitySolver extends CentralGravitySolver { /** * Calculate the forces based on the distance. + * + * @param {number} distance + * @param {number} dx + * @param {number} dy + * @param {Object} forces + * @param {Node} node * @private */ _calculateForces(distance, dx, dy, forces, node) { diff --git a/lib/network/modules/components/physics/FA2BasedRepulsionSolver.js b/lib/network/modules/components/physics/FA2BasedRepulsionSolver.js index 030da003a..001ed638f 100644 --- a/lib/network/modules/components/physics/FA2BasedRepulsionSolver.js +++ b/lib/network/modules/components/physics/FA2BasedRepulsionSolver.js @@ -1,6 +1,14 @@ import BarnesHutSolver from "./BarnesHutSolver" +/** + * @extends BarnesHutSolver + */ class ForceAtlas2BasedRepulsionSolver extends BarnesHutSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { super(body, physicsBody, options); } @@ -8,11 +16,11 @@ class ForceAtlas2BasedRepulsionSolver extends BarnesHutSolver { /** * Calculate the forces based on the distance. * - * @param distance - * @param dx - * @param dy - * @param node - * @param parentBranch + * @param {number} distance + * @param {number} dx + * @param {number} dy + * @param {Node} node + * @param {Object} parentBranch * @private */ _calculateForces(distance, dx, dy, node, parentBranch) { diff --git a/lib/network/modules/components/physics/HierarchicalRepulsionSolver.js b/lib/network/modules/components/physics/HierarchicalRepulsionSolver.js index b2c4e5ee9..c1e130a71 100644 --- a/lib/network/modules/components/physics/HierarchicalRepulsionSolver.js +++ b/lib/network/modules/components/physics/HierarchicalRepulsionSolver.js @@ -1,10 +1,22 @@ +/** + * Hierarchical Repulsion Solver + */ class HierarchicalRepulsionSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; this.setOptions(options); } + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; } diff --git a/lib/network/modules/components/physics/HierarchicalSpringSolver.js b/lib/network/modules/components/physics/HierarchicalSpringSolver.js index e9bde3e3f..ad444c095 100644 --- a/lib/network/modules/components/physics/HierarchicalSpringSolver.js +++ b/lib/network/modules/components/physics/HierarchicalSpringSolver.js @@ -1,10 +1,22 @@ +/** + * Hierarchical Spring Solver + */ class HierarchicalSpringSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; this.setOptions(options); } + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; } @@ -73,7 +85,7 @@ class HierarchicalSpringSolver { } // normalize spring forces - var springForce = 1; + springForce = 1; var springFx, springFy; for (let i = 0; i < nodeIndices.length; i++) { let nodeId = nodeIndices[i]; @@ -101,7 +113,6 @@ class HierarchicalSpringSolver { forces[nodeId].y -= correctionFy; } } - } -export default HierarchicalSpringSolver; \ No newline at end of file +export default HierarchicalSpringSolver; diff --git a/lib/network/modules/components/physics/RepulsionSolver.js b/lib/network/modules/components/physics/RepulsionSolver.js index 0f338ba94..3cb957466 100644 --- a/lib/network/modules/components/physics/RepulsionSolver.js +++ b/lib/network/modules/components/physics/RepulsionSolver.js @@ -1,13 +1,26 @@ +/** + * Repulsion Solver + */ class RepulsionSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; this.setOptions(options); } + /** + * + * @param {Object} 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. diff --git a/lib/network/modules/components/physics/SpringSolver.js b/lib/network/modules/components/physics/SpringSolver.js index 194631f35..eb011e2a3 100644 --- a/lib/network/modules/components/physics/SpringSolver.js +++ b/lib/network/modules/components/physics/SpringSolver.js @@ -1,10 +1,22 @@ +/** + * Spring Solver + */ class SpringSolver { + /** + * @param {Object} body + * @param {{physicsNodeIndices: Array, physicsEdgeIndices: Array, forces: {}, velocities: {}}} physicsBody + * @param {Object} options + */ constructor(body, physicsBody, options) { this.body = body; this.physicsBody = physicsBody; this.setOptions(options); } + /** + * + * @param {Object} options + */ setOptions(options) { this.options = options; } @@ -50,9 +62,9 @@ class SpringSolver { /** * This is the code actually performing the calculation for the function above. * - * @param node1 - * @param node2 - * @param edgeLength + * @param {Node} node1 + * @param {Node} node2 + * @param {number} edgeLength * @private */ _calculateSpringForce(node1, node2, edgeLength) { diff --git a/lib/network/modules/components/shared/ComponentUtil.js b/lib/network/modules/components/shared/ComponentUtil.js new file mode 100644 index 000000000..9555920f1 --- /dev/null +++ b/lib/network/modules/components/shared/ComponentUtil.js @@ -0,0 +1,139 @@ +/** + * Definitions for param's in jsdoc. + * These are more or less global within Network. Putting them here until I can figure out + * where to really put them + * + * @typedef {string|number} Id + * @typedef {Id} NodeId + * @typedef {Id} EdgeId + * @typedef {Id} LabelId + * + * @typedef {{x: number, y: number}} point + * @typedef {{left: number, top: number, width: number, height: number}} rect + * @typedef {{x: number, y:number, angle: number}} rotationPoint + * - point to rotate around and the angle in radians to rotate. angle == 0 means no rotation + * @typedef {{nodeId:NodeId}} nodeClickItem + * @typedef {{nodeId:NodeId, labelId:LabelId}} nodeLabelClickItem + * @typedef {{edgeId:EdgeId}} edgeClickItem + * @typedef {{edgeId:EdgeId, labelId:LabelId}} edgeLabelClickItem + */ + +let util = require("../../../../util"); + +/** + * Helper functions for components + * @class + */ +class ComponentUtil { + /** + * Determine values to use for (sub)options of 'chosen'. + * + * This option is either a boolean or an object whose values should be examined further. + * The relevant structures are: + * + * - chosen: + * - chosen: { subOption: } + * + * Where subOption is 'node', 'edge' or 'label'. + * + * The intention of this method appears to be to set a specific priority to the options; + * Since most properties are either bridged or merged into the local options objects, there + * is not much point in handling them separately. + * TODO: examine if 'most' in previous sentence can be replaced with 'all'. In that case, we + * should be able to get rid of this method. + * + * @param {string} subOption option within object 'chosen' to consider; either 'node', 'edge' or 'label' + * @param {Object} pile array of options objects to consider + * + * @return {boolean|function} value for passed subOption of 'chosen' to use + */ + static choosify(subOption, pile) { + // allowed values for subOption + let allowed = [ 'node', 'edge', 'label']; + let value = true; + + let chosen = util.topMost(pile, 'chosen'); + if (typeof chosen === 'boolean') { + value = chosen; + } else if (typeof chosen === 'object') { + if (allowed.indexOf(subOption) === -1 ) { + throw new Error('choosify: subOption \'' + subOption + '\' should be one of ' + + "'" + allowed.join("', '") + "'"); + } + + let chosenEdge = util.topMost(pile, ['chosen', subOption]); + if ((typeof chosenEdge === 'boolean') || (typeof chosenEdge === 'function')) { + value = chosenEdge; + } + } + + return value; + } + + + /** + * Check if the point falls within the given rectangle. + * + * @param {rect} rect + * @param {point} point + * @param {rotationPoint} [rotationPoint] if specified, the rotation that applies to the rectangle. + * @returns {boolean} true if point within rectangle, false otherwise + * @static + */ + static pointInRect(rect, point, rotationPoint) { + if (rect.width <= 0 || rect.height <= 0) { + return false; // early out + } + + if (rotationPoint !== undefined) { + // Rotate the point the same amount as the rectangle + var tmp = { + x: point.x - rotationPoint.x, + y: point.y - rotationPoint.y + }; + + if (rotationPoint.angle !== 0) { + // In order to get the coordinates the same, you need to + // rotate in the reverse direction + var angle = -rotationPoint.angle; + + var tmp2 = { + x: Math.cos(angle)*tmp.x - Math.sin(angle)*tmp.y, + y: Math.sin(angle)*tmp.x + Math.cos(angle)*tmp.y + }; + point = tmp2; + } else { + point = tmp; + } + + // Note that if a rotation is specified, the rectangle coordinates + // are **not* the full canvas coordinates. They are relative to the + // rotationPoint. Hence, the point coordinates need not be translated + // back in this case. + } + + var right = rect.x + rect.width; + var bottom = rect.y + rect.width; + + return ( + rect.left < point.x && + right > point.x && + rect.top < point.y && + bottom > point.y + ); + } + + + /** + * Check if given value is acceptable as a label text. + * + * @param {*} text value to check; can be anything at this point + * @returns {boolean} true if valid label value, false otherwise + */ + static isValidLabel(text) { + // Note that this is quite strict: types that *might* be converted to string are disallowed + return (typeof text === 'string' && text !== ''); + } +} + +export default ComponentUtil; diff --git a/lib/network/modules/components/shared/Label.js b/lib/network/modules/components/shared/Label.js index 192be8dbc..73014f221 100644 --- a/lib/network/modules/components/shared/Label.js +++ b/lib/network/modules/components/shared/Label.js @@ -1,53 +1,427 @@ let util = require('../../../../util'); - +let ComponentUtil = require('./ComponentUtil').default; +let LabelSplitter = require('./LabelSplitter').default; + +/** + * @typedef {'bold'|'ital'|'boldital'|'mono'|'normal'} MultiFontStyle + * + * The allowed specifiers of multi-fonts. + */ + +/** + * @typedef {{color:string, size:number, face:string, mod:string, vadjust:number}} MultiFontOptions + * + * The full set of options of a given multi-font. + */ + +/** + * @typedef {Array.} Pile + * + * Sequence of option objects, the order is significant. + * The sequence is used to determine the value of a given option. + * + * Usage principles: + * + * - All search is done in the sequence of the pile. + * - As soon as a value is found, the searching stops. + * - prototypes are totally ignored. The idea is to add option objects used as prototypes + * to the pile, in the correct order. + */ + + +/** + * List of special styles for multi-fonts + * @private + */ +const multiFontStyle = ['bold', 'ital', 'boldital', 'mono']; + +/** + * A Label to be used for Nodes or Edges. + */ class Label { - constructor(body,options,edgelabel = false) { - this.body = body; + /** + * @param {Object} body + * @param {Object} options + * @param {boolean} [edgelabel=false] + */ + constructor(body, options, edgelabel = false) { + this.body = body; this.pointToSelf = false; this.baseSize = undefined; - this.fontOptions = {}; + this.fontOptions = {}; // instance variable containing the *instance-local* font options this.setOptions(options); - this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; // could be cached + this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; this.isEdgeLabel = edgelabel; this.listenerAdded = false; } - setOptions(options, allowDeletion = false) { - this.nodeOptions = options; - // We want to keep the font options seperated from the node options. - // The node options have to mirror the globals when they are not overruled. - this.fontOptions = util.deepExtend({},options.font, true); + /** + * @param {Object} options the options of the parent Node-instance + */ + setOptions(options) { + this.elementOptions = options; // Reference to the options of the parent Node-instance + + this.initFontOptions(options.font); - if (options.label !== undefined) { + if (ComponentUtil.isValidLabel(options.label)) { this.labelDirty = true; + } else { + // Bad label! Change the option value to prevent bad stuff happening + options.label = ''; } - if (options.font !== undefined) { - Label.parseOptions(this.fontOptions, options, allowDeletion); + if (options.font !== undefined && options.font !== null) { // font options can be deleted at various levels if (typeof options.font === 'string') { this.baseSize = this.fontOptions.size; } else if (typeof options.font === 'object') { - if (options.font.size !== undefined) { - this.baseSize = options.font.size; + let size = options.font.size; + + if (size !== undefined) { + this.baseSize = 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]; + + /** + * Init the font Options structure. + * + * Member fontOptions serves as an accumulator for the current font options. + * As such, it needs to be completely separated from the node options. + * + * @param {Object} newFontOptions the new font options to process + * @private + */ + initFontOptions(newFontOptions) { + // Prepare the multi-font option objects. + // These will be filled in propagateFonts(), if required + util.forEach(multiFontStyle, (style) => { + this.fontOptions[style] = {}; + }); + + // Handle shorthand option, if present + if (Label.parseFontString(this.fontOptions, newFontOptions)) { + this.fontOptions.vadjust = 0; + return; } - else if (typeof newOptions.font === 'object') { - util.fillIfDefined(parentOptions, newOptions.font, allowDeletion); + + // Copy over the non-multifont options, if specified + util.forEach(newFontOptions, (prop, n) => { + if (prop !== undefined && prop !== null && typeof prop !== 'object') { + this.fontOptions[n] = prop; + } + }); + } + + + /** + * If in-variable is a string, parse it as a font specifier. + * + * Note that following is not done here and have to be done after the call: + * - No number conversion (size) + * - Not all font options are set (vadjust, mod) + * + * @param {Object} outOptions out-parameter, object in which to store the parse results (if any) + * @param {Object} inOptions font options to parse + * @return {boolean} true if font parsed as string, false otherwise + * @static + */ + static parseFontString(outOptions, inOptions) { + if (!inOptions || typeof inOptions !== 'string') return false; + + let newOptionsArray = inOptions.split(" "); + + outOptions.size = newOptionsArray[0].replace("px",''); + outOptions.face = newOptionsArray[1]; + outOptions.color = newOptionsArray[2]; + + return true; + } + + + /** + * Set the width and height constraints based on 'nearest' value + * + * @param {Array} pile array of option objects to consider + * @returns {object} the actual constraint values to use + * @private + */ + constrain(pile) { + // NOTE: constrainWidth and constrainHeight never set! + // NOTE: for edge labels, only 'maxWdt' set + // Node labels can set all the fields + let fontOptions = { + constrainWidth: false, + maxWdt: -1, + minWdt: -1, + constrainHeight: false, + minHgt: -1, + valign: 'middle', + } + + let widthConstraint = util.topMost(pile, 'widthConstraint'); + if (typeof widthConstraint === 'number') { + fontOptions.maxWdt = Number(widthConstraint); + fontOptions.minWdt = Number(widthConstraint); + } else if (typeof widthConstraint === 'object') { + let widthConstraintMaximum = util.topMost(pile, ['widthConstraint', 'maximum']); + if (typeof widthConstraintMaximum === 'number') { + fontOptions.maxWdt = Number(widthConstraintMaximum); + } + let widthConstraintMinimum = util.topMost(pile, ['widthConstraint', 'minimum']) + if (typeof widthConstraintMinimum === 'number') { + fontOptions.minWdt = Number(widthConstraintMinimum); + } + } + + + let heightConstraint = util.topMost(pile, 'heightConstraint'); + if (typeof heightConstraint === 'number') { + fontOptions.minHgt = Number(heightConstraint); + } else if (typeof heightConstraint === 'object') { + let heightConstraintMinimum = util.topMost(pile, ['heightConstraint', 'minimum']); + if (typeof heightConstraintMinimum === 'number') { + fontOptions.minHgt = Number(heightConstraintMinimum); + } + let heightConstraintValign = util.topMost(pile, ['heightConstraint', 'valign']); + if (typeof heightConstraintValign === 'string') { + if ((heightConstraintValign === 'top')|| (heightConstraintValign === 'bottom')) { + fontOptions.valign = heightConstraintValign; + } + } + } + + return fontOptions; + } + + + /** + * Set options and update internal state + * + * @param {Object} options options to set + * @param {Array} pile array of option objects to consider for option 'chosen' + */ + update(options, pile) { + this.setOptions(options, true); + this.propagateFonts(pile); + util.deepExtend(this.fontOptions, this.constrain(pile)); + this.fontOptions.chooser = ComponentUtil.choosify('label', pile); + } + + + /** + * When margins are set in an element, adjust sizes is called to remove them + * from the width/height constraints. This must be done prior to label sizing. + * + * @param {{top: number, right: number, bottom: number, left: number}} margins + */ + adjustSizes(margins) { + let widthBias = (margins) ? (margins.right + margins.left) : 0; + if (this.fontOptions.constrainWidth) { + this.fontOptions.maxWdt -= widthBias; + this.fontOptions.minWdt -= widthBias; + } + let heightBias = (margins) ? (margins.top + margins.bottom) : 0; + if (this.fontOptions.constrainHeight) { + this.fontOptions.minHgt -= heightBias; + } + } + + +///////////////////////////////////////////////////////// +// Methods for handling options piles +// Eventually, these will be moved to a separate class +///////////////////////////////////////////////////////// + + /** + * Add the font members of the passed list of option objects to the pile. + * + * @param {Pile} dstPile pile of option objects add to + * @param {Pile} srcPile pile of option objects to take font options from + * @private + */ + addFontOptionsToPile(dstPile, srcPile) { + for (let i = 0; i < srcPile.length; ++i) { + this.addFontToPile(dstPile, srcPile[i]); + } + } + + + /** + * Add given font option object to the list of objects (the 'pile') to consider for determining + * multi-font option values. + * + * @param {Pile} pile pile of option objects to use + * @param {object} options instance to add to pile + * @private + */ + addFontToPile(pile, options) { + if (options === undefined) return; + if (options.font === undefined || options.font === null) return; + + let item = options.font; + pile.push(item); + } + + + /** + * Collect all own-property values from the font pile that aren't multi-font option objectss. + * + * @param {Pile} pile pile of option objects to use + * @returns {object} object with all current own basic font properties + * @private + */ + getBasicOptions(pile) { + let ret = {}; + + // Scans the whole pile to get all options present + for (let n = 0; n < pile.length; ++n) { + let fontOptions = pile[n]; + + // Convert shorthand if necessary + let tmpShorthand = {}; + if (Label.parseFontString(tmpShorthand, fontOptions)) { + fontOptions = tmpShorthand; + } + + util.forEach(fontOptions, (opt, name) => { + if (opt === undefined) return; // multi-font option need not be present + if (ret.hasOwnProperty(name)) return; // Keep first value we encounter + + if (multiFontStyle.indexOf(name) !== -1) { + // Skip multi-font properties but we do need the structure + ret[name] = {}; + } else { + ret[name] = opt; + } + }); + } + + return ret; + } + + + /** + * Return the value for given option for the given multi-font. + * + * All available option objects are trawled in the set order to construct the option values. + * + * --------------------------------------------------------------------- + * ## Traversal of pile for multi-fonts + * + * The determination of multi-font option values is a special case, because any values not + * present in the multi-font options should by definition be taken from the main font options, + * i.e. from the current 'parent' object of the multi-font option. + * + * ### Search order for multi-fonts + * + * 'bold' used as example: + * + * - search in option group 'bold' in local properties + * - search in main font option group in local properties + * + * --------------------------------------------------------------------- + * + * @param {Pile} pile pile of option objects to use + * @param {MultiFontStyle} multiName sub path for the multi-font + * @param {string} option the option to search for, for the given multi-font + * @returns {string|number} the value for the given option + * @private + */ + getFontOption(pile, multiName, option) { + let multiFont; + + // Search multi font in local properties + for (let n = 0; n < pile.length; ++n) { + let fontOptions = pile[n]; + + if (fontOptions.hasOwnProperty(multiName)) { + multiFont = fontOptions[multiName]; + if (multiFont === undefined || multiFont === null) continue; + + // Convert shorthand if necessary + // TODO: inefficient to do this conversion every time; find a better way. + let tmpShorthand = {}; + if (Label.parseFontString(tmpShorthand, multiFont)) { + multiFont = tmpShorthand; + } + + if (multiFont.hasOwnProperty(option)) { + return multiFont[option]; + } + } + } + + // Option is not mentioned in the multi font options; take it from the parent font options. + // These have already been converted with getBasicOptions(), so use the converted values. + if (this.fontOptions.hasOwnProperty(option)) { + return this.fontOptions[option]; + } + + // A value **must** be found; you should never get here. + throw new Error("Did not find value for multi-font for property: '" + option + "'"); + } + + + /** + * Return all options values for the given multi-font. + * + * All available option objects are trawled in the set order to construct the option values. + * + * @param {Pile} pile pile of option objects to use + * @param {MultiFontStyle} multiName sub path for the mod-font + * @returns {MultiFontOptions} + * @private + */ + getFontOptions(pile, multiName) { + let result = {}; + let optionNames = ['color', 'size', 'face', 'mod', 'vadjust']; // List of allowed options per multi-font + + for (let i = 0; i < optionNames.length; ++i) { + let mod = optionNames[i]; + result[mod] = this.getFontOption(pile, multiName, mod); + } + + return result; + } + +///////////////////////////////////////////////////////// +// End methods for handling options piles +///////////////////////////////////////////////////////// + + + /** + * Collapse the font options for the multi-font to single objects, from + * the chain of option objects passed (the 'pile'). + * + * @param {Pile} pile sequence of option objects to consider. + * First item in list assumed to be the newly set options. + */ + propagateFonts(pile) { + let fontPile = []; // sequence of font objects to consider, order important + + // Note that this.elementOptions is not used here. + this.addFontOptionsToPile(fontPile, pile); + this.fontOptions = this.getBasicOptions(fontPile); + + // We set multifont values even if multi === false, for consistency (things break otherwise) + for (let i = 0; i < multiFontStyle.length; ++i) { + let mod = multiFontStyle[i]; + let modOptions = this.fontOptions[mod]; + let tmpMultiFontOptions = this.getFontOptions(fontPile, mod); + + // Copy over found values + util.forEach(tmpMultiFontOptions, (option, n) => { + modOptions[n] = option; + }); + + modOptions.size = Number(modOptions.size); + modOptions.vadjust = Number(modOptions.vadjust); } - parentOptions.size = Number(parentOptions.size); } addListeners(canvas) { @@ -97,29 +471,34 @@ class Label { /** * Main function. This is called from anything that wants to draw a label. - * @param ctx - * @param x - * @param y - * @param selected - * @param baseline + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {boolean} selected + * @param {boolean} hover + * @param {string} [baseline='middle'] */ - draw(ctx, x, y, selected, baseline = 'middle') { + draw(ctx, x, y, selected, hover, baseline = 'middle') { // if no label, return - if (this.nodeOptions.label === undefined) + if (this.elementOptions.label === undefined) return; // check if we have to render the label let viewFontSize = this.fontOptions.size * this.body.view.scale; - if (this.nodeOptions.label && viewFontSize < this.nodeOptions.scaling.label.drawThreshold - 1) + if (this.elementOptions.label && viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1) return; - // update the size cache if required - this.calculateLabelSize(ctx, selected, x, y, baseline); + // 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.elementOptions.scaling.label.maxVisible) { + viewFontSize = Number(this.elementOptions.scaling.label.maxVisible) / this.body.view.scale; + } - // create the fontfill background + // update the size cache if required + this.calculateLabelSize(ctx, selected, hover, x, y, baseline); this._drawBackground(ctx); // draw text - this._drawText(ctx, selected, x, y, baseline); + this._drawText(ctx, x, this.size.yLine, baseline, viewFontSize); // add listeners: if(!this.listenersAdded) { @@ -128,6 +507,7 @@ class Label { } } + /** * Draws the label background * @param {CanvasRenderingContext2D} ctx @@ -136,93 +516,91 @@ class Label { _drawBackground(ctx) { if (this.fontOptions.background !== undefined && this.fontOptions.background !== "none") { ctx.fillStyle = this.fontOptions.background; - - let lineMargin = 2; - - if (this.isEdgeLabel) { - switch (this.fontOptions.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; - } - } else { - ctx.fillRect(this.size.left, this.size.top - 0.5*lineMargin, this.size.width, this.size.height); - } + let size = this.getSize(); + ctx.fillRect(size.left, size.top, size.width, size.height); } } /** * - * @param ctx - * @param x - * @param baseline + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {string} [baseline='middle'] + * @param {number} viewFontSize * @private */ - _drawText(ctx, selected, x, y, baseline = 'middle') { - let fontSize = this.fontOptions.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.nodeOptions.scaling.label.maxVisible) { - fontSize = Number(this.nodeOptions.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.nodeOptions.labelHighlightBold ? 'bold ' : '') + fontSize + "px " + this.fontOptions.face; - ctx.fillStyle = fontColor; - // When the textAlign property is 'left', make label left-justified - if ((!this.isEdgeLabel) && this.fontOptions.align === 'left') { - ctx.textAlign = this.fontOptions.align; - x = x - 0.5 * this.size.width; // Shift label 1/2-distance to the left - } else { - ctx.textAlign = 'center'; - } - - // set the strokeWidth - if (this.fontOptions.strokeWidth > 0) { - ctx.lineWidth = this.fontOptions.strokeWidth; - ctx.strokeStyle = strokeColor; - ctx.lineJoin = 'round'; + _drawText(ctx, x, y, baseline = 'middle', viewFontSize) { + [x, y] = this._setAlignment(ctx, x, y, baseline); + + ctx.textAlign = 'left'; + x = x - this.size.width / 2; // Shift label 1/2-distance to the left + if ((this.fontOptions.valign) && (this.size.height > this.size.labelHeight)) { + if (this.fontOptions.valign === 'top') { + y -= (this.size.height - this.size.labelHeight) / 2; + } + if (this.fontOptions.valign === 'bottom') { + y += (this.size.height - this.size.labelHeight) / 2; + } } // draw the text for (let i = 0; i < this.lineCount; i++) { - if (this.fontOptions.strokeWidth > 0) { - ctx.strokeText(this.lines[i], x, yLine); + let line = this.lines[i]; + if (line && line.blocks) { + let width = 0; + if (this.isEdgeLabel || this.fontOptions.align === 'center') { + width += (this.size.width - line.width) / 2 + } else if (this.fontOptions.align === 'right') { + width += (this.size.width - line.width) + } + for (let j = 0; j < line.blocks.length; j++) { + let block = line.blocks[j]; + ctx.font = block.font; + let [fontColor, strokeColor] = this._getColor(block.color, viewFontSize, block.strokeColor); + if (block.strokeWidth > 0) { + ctx.lineWidth = block.strokeWidth; + ctx.strokeStyle = strokeColor; + ctx.lineJoin = 'round'; + } + ctx.fillStyle = fontColor; + + if (block.strokeWidth > 0) { + ctx.strokeText(block.text, x + width, y + block.vadjust); + } + ctx.fillText(block.text, x + width, y + block.vadjust); + width += block.width; + } + y += line.height; } - ctx.fillText(this.lines[i], x, yLine); - yLine += fontSize; } } - _setAlignment(ctx, x, yLine, baseline) { + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {string} baseline + * @returns {Array.} + * @private + */ + _setAlignment(ctx, x, y, baseline) { // check for label alignment (for edges) // TODO: make alignment for nodes if (this.isEdgeLabel && this.fontOptions.align !== 'horizontal' && this.pointToSelf === false) { x = 0; - yLine = 0; + y = 0; let lineMargin = 2; if (this.fontOptions.align === 'top') { ctx.textBaseline = 'alphabetic'; - yLine -= 2 * lineMargin; // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers + y -= 2 * lineMargin; // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers } else if (this.fontOptions.align === 'bottom') { ctx.textBaseline = 'hanging'; - yLine += 2 * lineMargin;// distance from edge, required because we use hanging. Hanging has less difference between browsers + y += 2 * lineMargin;// distance from edge, required because we use hanging. Hanging has less difference between browsers } else { ctx.textBaseline = 'middle'; @@ -231,59 +609,98 @@ class Label { else { ctx.textBaseline = baseline; } - - return [x,yLine]; + return [x,y]; } /** * 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 {*[]} + * @param {string} color The font color to use + * @param {number} viewFontSize + * @param {string} initialStrokeColor + * @returns {Array.} An array containing the font color and stroke color * @private */ - _getColor(viewFontSize) { - let fontColor = this.fontOptions.color || '#000000'; - let strokeColor = this.fontOptions.strokeColor || '#ffffff'; - if (viewFontSize <= this.nodeOptions.scaling.label.drawThreshold) { - let opacity = Math.max(0, Math.min(1, 1 - (this.nodeOptions.scaling.label.drawThreshold - viewFontSize))); + _getColor(color, viewFontSize, initialStrokeColor) { + let fontColor = color || '#000000'; + let strokeColor = initialStrokeColor || '#ffffff'; + if (viewFontSize <= this.elementOptions.scaling.label.drawThreshold) { + let opacity = Math.max(0, Math.min(1, 1 - (this.elementOptions.scaling.label.drawThreshold - viewFontSize))); fontColor = util.overrideOpacity(fontColor, opacity); strokeColor = util.overrideOpacity(strokeColor, opacity); } return [fontColor, strokeColor]; } - /** * - * @param ctx - * @param selected + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover * @returns {{width: number, height: number}} */ - getTextSize(ctx, selected = false) { - let size = { - width: this._processLabel(ctx,selected), - height: this.fontOptions.size * this.lineCount, + getTextSize(ctx, selected = false, hover = false) { + this._processLabel(ctx, selected, hover); + return { + width: this.size.width, + height: this.size.height, lineCount: this.lineCount }; - return size; } /** + * Get the current dimensions of the label * - * @param ctx - * @param selected - * @param x - * @param y - * @param baseline + * @return {rect} */ - calculateLabelSize(ctx, selected, x = 0, y = 0, baseline = 'middle') { - if (this.labelDirty === true) { - this.size.width = this._processLabel(ctx,selected); + getSize() { + let lineMargin = 2; + let x = this.size.left; // default values which might be overridden below + let y = this.size.top - 0.5*lineMargin; // idem + + if (this.isEdgeLabel) { + const x2 = -this.size.width * 0.5; + + switch (this.fontOptions.align) { + case 'middle': + x = x2; + y = -this.size.height * 0.5 + break; + case 'top': + x = x2; + y = -(this.size.height + lineMargin); + break; + case 'bottom': + x = x2; + y = lineMargin; + break; + } } - this.size.height = this.fontOptions.size * this.lineCount; + + var ret = { + left : x, + top : y, + width : this.size.width, + height: this.size.height, + }; + + return ret; + } + + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + * @param {number} [x=0] + * @param {number} [y=0] + * @param {'middle'|'hanging'} [baseline='middle'] + */ + calculateLabelSize(ctx, selected, hover, x = 0, y = 0, baseline = 'middle') { + this._processLabel(ctx, selected, hover); 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.fontOptions.size; @@ -292,37 +709,143 @@ class Label { 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; + + /** + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + * @param {string} mod + * @returns {{color, size, face, mod, vadjust, strokeWidth: *, strokeColor: (*|string|allOptions.edges.font.strokeColor|{string}|allOptions.nodes.font.strokeColor|Array)}} + */ + getFormattingValues(ctx, selected, hover, mod) { + let getValue = function(fontOptions, mod, option) { + if (mod === "normal") { + if (option === 'mod' ) return ""; + return fontOptions[option]; + } + + if (fontOptions[mod][option] !== undefined) { // Grumbl leaving out test on undefined equals false for "" + return fontOptions[mod][option]; + } else { + // Take from parent font option + return fontOptions[option]; + } + }; + + let values = { + color : getValue(this.fontOptions, mod, 'color' ), + size : getValue(this.fontOptions, mod, 'size' ), + face : getValue(this.fontOptions, mod, 'face' ), + mod : getValue(this.fontOptions, mod, 'mod' ), + vadjust: getValue(this.fontOptions, mod, 'vadjust'), + strokeWidth: this.fontOptions.strokeWidth, + strokeColor: this.fontOptions.strokeColor + }; + if (selected || hover) { + if (mod === "normal" && (this.fontOptions.chooser === true) && (this.elementOptions.labelHighlightBold)) { + values.mod = 'bold'; + } else { + if (typeof this.fontOptions.chooser === 'function') { + this.fontOptions.chooser(values, this.elementOptions.id, selected, hover); + } + } + } + + let fontString = ""; + if (values.mod !== undefined && values.mod !== "") { // safeguard for undefined - this happened + fontString += values.mod + " "; + } + fontString += values.size + "px " + values.face; + + ctx.font = fontString.replace(/"/g, ""); + values.font = ctx.font; + values.height = values.size; + return values; + } + + + /** + * + * @param {boolean} selected + * @param {boolean} hover + * @returns {boolean} + */ + differentState(selected, hover) { + return ((selected !== this.selectedState) || (hover !== this.hoverState)); + } + + + /** + * This explodes the passed text into lines and determines the width, height and number of lines. + * + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover + * @param {string} inText the text to explode + * @returns {{width, height, lines}|*} + * @private + */ + _processLabelText(ctx, selected, hover, inText) { + let splitter = new LabelSplitter(ctx, this, selected, hover); + return splitter.process(inText); } /** - * This calculates the width as well as explodes the label string and calculates the amount of lines. - * @param ctx - * @param selected - * @returns {number} + * This explodes the label string into lines and sets the width, height and number of lines. + * @param {CanvasRenderingContext2D} ctx + * @param {boolean} selected + * @param {boolean} hover * @private */ - _processLabel(ctx,selected) { - let width = 0; - let lines = ['']; - let lineCount = 0; - if (this.nodeOptions.label !== undefined) { - lines = String(this.nodeOptions.label).split('\n'); - lineCount = lines.length; - ctx.font = (selected && this.nodeOptions.labelHighlightBold ? 'bold ' : '') + this.fontOptions.size + "px " + this.fontOptions.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; - } + _processLabel(ctx, selected, hover) { + + if(this.labelDirty === false && !this.differentState(selected,hover)) + return; + + let state = this._processLabelText(ctx, selected, hover, this.elementOptions.label); + + if ((this.fontOptions.minWdt > 0) && (state.width < this.fontOptions.minWdt)) { + state.width = this.fontOptions.minWdt; + } + + this.size.labelHeight =state.height; + if ((this.fontOptions.minHgt > 0) && (state.height < this.fontOptions.minHgt)) { + state.height = this.fontOptions.minHgt; + } + + this.lines = state.lines; + this.lineCount = state.lines.length; + this.size.width = state.width; + this.size.height = state.height; + this.selectedState = selected; + this.hoverState = hover; + + this.labelDirty = false; + } + + + /** + * Check if this label is visible + * + * @return {boolean} true if this label will be show, false otherwise + */ + visible() { + if ((this.size.width === 0 || this.size.height === 0) + || this.elementOptions.label === undefined) { + return false; // nothing to display + } + + let viewFontSize = this.fontOptions.size * this.body.view.scale; + if (viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1) { + return false; // Too small or too far away to show } - this.lines = lines; - this.lineCount = lineCount; - return width; + return true; } } -export default Label; \ No newline at end of file +export default Label; diff --git a/lib/network/modules/components/shared/LabelAccumulator.js b/lib/network/modules/components/shared/LabelAccumulator.js new file mode 100644 index 000000000..5a05769a4 --- /dev/null +++ b/lib/network/modules/components/shared/LabelAccumulator.js @@ -0,0 +1,238 @@ +/** + * Callback to determine text dimensions, using the parent label settings. + * @callback MeasureText + * @param {text} text + * @param {text} mod + * @return {Object} { width, values} width in pixels and font attributes + */ + + +/** + * Helper class for Label which collects results of splitting labels into lines and blocks. + * + * @private + */ +class LabelAccumulator { + + /** + * @param {MeasureText} measureText + */ + constructor(measureText) { + this.measureText = measureText; + this.current = 0; + this.width = 0; + this.height = 0; + this.lines = []; + } + + + /** + * Append given text to the given line. + * + * @param {number} l index of line to add to + * @param {string} text string to append to line + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + * @private + */ + _add(l, text, mod = 'normal') { + + if (this.lines[l] === undefined) { + this.lines[l] = { + width : 0, + height: 0, + blocks: [] + }; + } + + // We still need to set a block for undefined and empty texts, hence return at this point + // This is necessary because we don't know at this point if we're at the + // start of an empty line or not. + // To compensate, empty blocks are removed in `finalize()`. + // + // Empty strings should still have a height + let tmpText = text; + if (text === undefined || text === "") tmpText = " "; + + // Determine width and get the font properties + let result = this.measureText(tmpText, mod); + let block = Object.assign({}, result.values); + block.text = text; + block.width = result.width; + block.mod = mod; + + if (text === undefined || text === "") { + block.width = 0; + } + + this.lines[l].blocks.push(block); + + // Update the line width. We need this for determining if a string goes over max width + this.lines[l].width += block.width; + } + + + /** + * Returns the width in pixels of the current line. + * + * @returns {number} + */ + curWidth() { + let line = this.lines[this.current]; + if (line === undefined) return 0; + + return line.width; + } + + + /** + * Add text in block to current line + * + * @param {string} text + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + */ + append(text, mod = 'normal') { + this._add(this.current, text, mod); + } + + + /** + * Add text in block to current line and start a new line + * + * @param {string} text + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + */ + newLine(text, mod = 'normal') { + this._add(this.current, text, mod); + this.current++; + } + + + /** + * Determine and set the heights of all the lines currently contained in this instance + * + * Note that width has already been set. + * + * @private + */ + determineLineHeights() { + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + // Looking for max height of blocks in line + let height = 0; + + if (line.blocks !== undefined) { // Can happen if text contains e.g. '\n ' + for (let l = 0; l < line.blocks.length; l++) { + let block = line.blocks[l]; + + if (height < block.height) { + height = block.height; + } + } + } + + line.height = height; + } + } + + + /** + * Determine the full size of the label text, as determined by current lines and blocks + * + * @private + */ + determineLabelSize() { + let width = 0; + let height = 0; + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + if (line.width > width) { + width = line.width; + } + height += line.height; + } + + this.width = width; + this.height = height; + } + + + /** + * Remove all empty blocks and empty lines we don't need + * + * This must be done after the width/height determination, + * so that these are set properly for processing here. + * + * @returns {Array} Lines with empty blocks (and some empty lines) removed + * @private + */ + removeEmptyBlocks() { + let tmpLines = []; + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + // Note: an empty line in between text has width zero but is still relevant to layout. + // So we can't use width for testing empty line here + if (line.blocks.length === 0) continue; + + // Discard final empty line always + if(k === this.lines.length - 1) { + if (line.width === 0) continue; + } + + let tmpLine = {}; + Object.assign(tmpLine, line); + tmpLine.blocks = []; + + let firstEmptyBlock; + let tmpBlocks = [] + for (let l = 0; l < line.blocks.length; l++) { + let block = line.blocks[l]; + if (block.width !== 0) { + tmpBlocks.push(block); + } else { + if (firstEmptyBlock === undefined) { + firstEmptyBlock = block; + } + } + } + + // Ensure that there is *some* text present + if (tmpBlocks.length === 0 && firstEmptyBlock !== undefined) { + tmpBlocks.push(firstEmptyBlock); + } + + tmpLine.blocks = tmpBlocks; + + tmpLines.push(tmpLine); + } + + return tmpLines; + } + + + /** + * Set the sizes for all lines and the whole thing. + * + * @returns {{width: (number|*), height: (number|*), lines: Array}} + */ + finalize() { + //console.log(JSON.stringify(this.lines, null, 2)); + + this.determineLineHeights(); + this.determineLabelSize(); + let tmpLines = this.removeEmptyBlocks(); + + + // Return a simple hash object for further processing. + return { + width : this.width, + height: this.height, + lines : tmpLines + } + } +} + + +export default LabelAccumulator; diff --git a/lib/network/modules/components/shared/LabelSplitter.js b/lib/network/modules/components/shared/LabelSplitter.js new file mode 100644 index 000000000..168d99668 --- /dev/null +++ b/lib/network/modules/components/shared/LabelSplitter.js @@ -0,0 +1,549 @@ +let LabelAccumulator = require('./LabelAccumulator').default; +let ComponentUtil = require('./ComponentUtil').default; + + +/** + * Helper class for Label which explodes the label text into lines and blocks within lines + * + * @private + */ +class LabelSplitter { + + /** + * @param {CanvasRenderingContext2D} ctx Canvas rendering context + * @param {Label} parent reference to the Label instance using current instance + * @param {boolean} selected + * @param {boolean} hover + */ + constructor(ctx, parent, selected, hover) { + this.ctx = ctx; + this.parent = parent; + + + /** + * Callback to determine text width; passed to LabelAccumulator instance + * + * @param {String} text string to determine width of + * @param {String} mod font type to use for this text + * @return {Object} { width, values} width in pixels and font attributes + */ + let textWidth = (text, mod) => { + if (text === undefined) return 0; + + // TODO: This can be done more efficiently with caching + let values = this.parent.getFormattingValues(ctx, selected, hover, mod); + + let width = 0; + if (text !== '') { + // NOTE: The following may actually be *incorrect* for the mod fonts! + // This returns the size with a regular font, bold etc. may + // have different sizes. + let measure = this.ctx.measureText(text); + width = measure.width; + } + + return {width, values: values}; + }; + + + this.lines = new LabelAccumulator(textWidth); + } + + + /** + * Split passed text of a label into lines and blocks. + * + * # NOTE + * + * The handling of spacing is option dependent: + * + * - if `font.multi : false`, all spaces are retained + * - if `font.multi : true`, every sequence of spaces is compressed to a single space + * + * This might not be the best way to do it, but this is as it has been working till now. + * In order not to break existing functionality, for the time being this behaviour will + * be retained in any code changes. + * + * @param {string} text text to split + * @returns {Array} + */ + process(text) { + if (!ComponentUtil.isValidLabel(text)) { + return this.lines.finalize(); + } + + var font = this.parent.fontOptions; + + // Normalize the end-of-line's to a single representation - order important + text = text.replace(/\r\n/g, '\n'); // Dos EOL's + text = text.replace(/\r/g, '\n'); // Mac EOL's + + // Note that at this point, there can be no \r's in the text. + // This is used later on splitStringIntoLines() to split multifont texts. + + let nlLines = String(text).split('\n'); + let lineCount = nlLines.length; + + if (font.multi) { + // Multi-font case: styling tags active + for (let i = 0; i < lineCount; i++) { + let blocks = this.splitBlocks(nlLines[i], font.multi); + // Post: Sequences of tabs and spaces are reduced to single space + + if (blocks === undefined) continue; + + if (blocks.length === 0) { + this.lines.newLine(""); + continue; + } + + if (font.maxWdt > 0) { + // widthConstraint.maximum defined + //console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt); + for (let j = 0; j < blocks.length; j++) { + let mod = blocks[j].mod; + let text = blocks[j].text; + this.splitStringIntoLines(text, mod, true); + } + } else { + // widthConstraint.maximum NOT defined + for (let j = 0; j < blocks.length; j++) { + let mod = blocks[j].mod; + let text = blocks[j].text; + this.lines.append(text, mod); + } + } + + this.lines.newLine(); + } + } else { + // Single-font case + if (font.maxWdt > 0) { + // widthConstraint.maximum defined + // console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt); + for (let i = 0; i < lineCount; i++) { + this.splitStringIntoLines(nlLines[i]); + } + } else { + // widthConstraint.maximum NOT defined + for (let i = 0; i < lineCount; i++) { + this.lines.newLine(nlLines[i]); + } + } + } + + return this.lines.finalize(); + } + + + /** + * normalize the markup system + * + * @param {boolean|'md'|'markdown'|'html'} markupSystem + * @returns {string} + */ + decodeMarkupSystem(markupSystem) { + let system = 'none'; + if (markupSystem === 'markdown' || markupSystem === 'md') { + system = 'markdown'; + } else if (markupSystem === true || markupSystem === 'html') { + system = 'html' + } + return system; + } + + + /** + * + * @param {string} text + * @returns {Array} + */ + splitHtmlBlocks(text) { + let blocks = []; + + // TODO: consolidate following + methods/closures with splitMarkdownBlocks() + // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method + let s = { + bold: false, + ital: false, + mono: false, + spacing: false, + position: 0, + buffer: "", + modStack: [] + }; + + s.mod = function() { + return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; + }; + + s.modName = function() { + if (this.modStack.length === 0) + return 'normal'; + else if (this.modStack[0] === 'mono') + return 'mono'; + else { + if (s.bold && s.ital) { + return 'boldital'; + } else if (s.bold) { + return 'bold'; + } else if (s.ital) { + return 'ital'; + } + } + }; + + s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars + if (this.spacing) { + this.add(" "); + this.spacing = false; + } + if (this.buffer.length > 0) { + blocks.push({ text: this.buffer, mod: this.modName() }); + this.buffer = ""; + } + }; + + s.add = function(text) { + if (text === " ") { + s.spacing = true; + } + if (s.spacing) { + this.buffer += " "; + this.spacing = false; + } + if (text != " ") { + this.buffer += text; + } + }; + + while (s.position < text.length) { + let ch = text.charAt(s.position); + if (/[ \t]/.test(ch)) { + if (!s.mono) { + s.spacing = true; + } else { + s.add(ch); + } + } else if (//.test(text.substr(s.position,3))) { + s.emitBlock(); + s.bold = true; + s.modStack.unshift("bold"); + s.position += 2; + } else if (!s.mono && !s.ital && //.test(text.substr(s.position,3))) { + s.emitBlock(); + s.ital = true; + s.modStack.unshift("ital"); + s.position += 2; + } else if (!s.mono && //.test(text.substr(s.position,6))) { + s.emitBlock(); + s.mono = true; + s.modStack.unshift("mono"); + s.position += 5; + } else if (!s.mono && (s.mod() === 'bold') && /<\/b>/.test(text.substr(s.position,4))) { + s.emitBlock(); + s.bold = false; + s.modStack.shift(); + s.position += 3; + } else if (!s.mono && (s.mod() === 'ital') && /<\/i>/.test(text.substr(s.position,4))) { + s.emitBlock(); + s.ital = false; + s.modStack.shift(); + s.position += 3; + } else if ((s.mod() === 'mono') && /<\/code>/.test(text.substr(s.position,7))) { + s.emitBlock(); + s.mono = false; + s.modStack.shift(); + s.position += 6; + } else { + s.add(ch); + } + } else if (/&/.test(ch)) { + if (/</.test(text.substr(s.position,4))) { + s.add("<"); + s.position += 3; + } else if (/&/.test(text.substr(s.position,5))) { + s.add("&"); + s.position += 4; + } else { + s.add("&"); + } + } else { + s.add(ch); + } + s.position++ + } + s.emitBlock(); + return blocks; + } + + + /** + * + * @param {string} text + * @returns {Array} + */ + splitMarkdownBlocks(text) { + let blocks = []; + + // TODO: consolidate following + methods/closures with splitHtmlBlocks() + // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method + let s = { + bold: false, + ital: false, + mono: false, + beginable: true, + spacing: false, + position: 0, + buffer: "", + modStack: [] + }; + + s.mod = function() { + return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; + }; + + s.modName = function() { + if (this.modStack.length === 0) + return 'normal'; + else if (this.modStack[0] === 'mono') + return 'mono'; + else { + if (s.bold && s.ital) { + return 'boldital'; + } else if (s.bold) { + return 'bold'; + } else if (s.ital) { + return 'ital'; + } + } + }; + + s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars + if (this.spacing) { + this.add(" "); + this.spacing = false; + } + if (this.buffer.length > 0) { + blocks.push({ text: this.buffer, mod: this.modName() }); + this.buffer = ""; + } + }; + + s.add = function(text) { + if (text === " ") { + s.spacing = true; + } + if (s.spacing) { + this.buffer += " "; + this.spacing = false; + } + if (text != " ") { + this.buffer += text; + } + }; + + while (s.position < text.length) { + let ch = text.charAt(s.position); + if (/[ \t]/.test(ch)) { + if (!s.mono) { + s.spacing = true; + } else { + s.add(ch); + } + s.beginable = true + } else if (/\\/.test(ch)) { + if (s.position < text.length+1) { + s.position++; + ch = text.charAt(s.position); + if (/ \t/.test(ch)) { + s.spacing = true; + } else { + s.add(ch); + s.beginable = false; + } + } + } else if (!s.mono && !s.bold && (s.beginable || s.spacing) && /\*/.test(ch)) { + s.emitBlock(); + s.bold = true; + s.modStack.unshift("bold"); + } else if (!s.mono && !s.ital && (s.beginable || s.spacing) && /\_/.test(ch)) { + s.emitBlock(); + s.ital = true; + s.modStack.unshift("ital"); + } else if (!s.mono && (s.beginable || s.spacing) && /`/.test(ch)) { + s.emitBlock(); + s.mono = true; + s.modStack.unshift("mono"); + } else if (!s.mono && (s.mod() === "bold") && /\*/.test(ch)) { + if ((s.position === text.length-1) || /[.,_` \t\n]/.test(text.charAt(s.position+1))) { + s.emitBlock(); + s.bold = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else if (!s.mono && (s.mod() === "ital") && /\_/.test(ch)) { + if ((s.position === text.length-1) || /[.,*` \t\n]/.test(text.charAt(s.position+1))) { + s.emitBlock(); + s.ital = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else if (s.mono && (s.mod() === "mono") && /`/.test(ch)) { + if ((s.position === text.length-1) || (/[.,*_ \t\n]/.test(text.charAt(s.position+1)))) { + s.emitBlock(); + s.mono = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else { + s.add(ch); + s.beginable = false; + } + s.position++ + } + s.emitBlock(); + return blocks; + } + + + /** + * Explodes a piece of text into single-font blocks using a given markup + * + * @param {string} text + * @param {boolean|'md'|'markdown'|'html'} markupSystem + * @returns {Array.<{text: string, mod: string}>} + * @private + */ + splitBlocks(text, markupSystem) { + let system = this.decodeMarkupSystem(markupSystem); + if (system === 'none') { + return [{ + text: text, + mod: 'normal' + }] + } else if (system === 'markdown') { + return this.splitMarkdownBlocks(text); + } else if (system === 'html') { + return this.splitHtmlBlocks(text); + } + } + + + /** + * @param {string} text + * @returns {boolean} true if text length over the current max with + * @private + */ + overMaxWidth(text) { + let width = this.ctx.measureText(text).width; + return (this.lines.curWidth() + width > this.parent.fontOptions.maxWdt); + } + + + /** + * Determine the longest part of the sentence which still fits in the + * current max width. + * + * @param {Array} words Array of strings signifying a text lines + * @return {number} index of first item in string making string go over max + * @private + */ + getLongestFit(words) { + let text = ''; + let w = 0; + + while (w < words.length) { + let pre = (text === '') ? '' : ' '; + let newText = text + pre + words[w]; + + if (this.overMaxWidth(newText)) break; + text = newText; + w++; + } + + return w; + } + + + /** + * Determine the longest part of the string which still fits in the + * current max width. + * + * @param {Array} words Array of strings signifying a text lines + * @return {number} index of first item in string making string go over max + */ + getLongestFitWord(words) { + let w = 0; + + while (w < words.length) { + if (this.overMaxWidth(words.slice(0,w))) break; + w++; + } + + return w; + } + + + /** + * Split the passed text into lines, according to width constraint (if any). + * + * The method assumes that the input string is a single line, i.e. without lines break. + * + * This method retains spaces, if still present (case `font.multi: false`). + * A space which falls on an internal line break, will be replaced by a newline. + * There is no special handling of tabs; these go along with the flow. + * + * @param {string} str + * @param {string} [mod='normal'] + * @param {boolean} [appendLast=false] + * @private + */ + splitStringIntoLines(str, mod = 'normal', appendLast = false) { + // Still-present spaces are relevant, retain them + str = str.replace(/^( +)/g, '$1\r'); + str = str.replace(/([^\r][^ ]*)( +)/g, '$1\r$2\r'); + let words = str.split('\r'); + + while (words.length > 0) { + let w = this.getLongestFit(words); + + if (w === 0) { + // Special case: the first word is already larger than the max width. + let word = words[0]; + + // Break the word to the largest part that fits the line + let x = this.getLongestFitWord(word); + this.lines.newLine(word.slice(0, x), mod); + + // Adjust the word, so that the rest will be done next iteration + words[0] = word.slice(x); + } else { + // skip any space that is replaced by a newline + let newW = w; + if (words[w - 1] === ' ') { + w--; + } else if (words[newW] === ' ') { + newW++; + } + + let text = words.slice(0, w).join(""); + + if (w == words.length && appendLast) { + this.lines.append(text, mod); + } else { + this.lines.newLine(text, mod); + } + + // Adjust the word, so that the rest will be done next iteration + words = words.slice(newW); + } + } + } +} + +export default LabelSplitter; diff --git a/lib/network/options.js b/lib/network/options.js index aaaad4fdb..e0f23b1ca 100644 --- a/lib/network/options.js +++ b/lib/network/options.js @@ -6,39 +6,46 @@ * __type__ is a required field for all objects and contains the allowed types of all objects */ let string = 'string'; -let boolean = 'boolean'; +let bool = 'boolean'; let number = 'number'; let array = 'array'; let object = 'object'; // should only be in a __type__ property let dom = 'dom'; let any = 'any'; +// List of endpoints +let endPoints = ['arrow', 'circle', 'bar']; let allOptions = { configure: { - enabled: { boolean }, - filter: { boolean, string, array, 'function': 'function' }, + enabled: { boolean: bool }, + filter: { boolean: bool, string, array, 'function': 'function' }, container: { dom }, - showButton: { boolean }, - __type__: { object, boolean, string, array, 'function': 'function' } + showButton: { boolean: bool }, + __type__: { object, boolean: bool, string, array, 'function': 'function' } }, edges: { arrows: { - to: { enabled: { boolean }, scaleFactor: { number }, type: { string: ['arrow', 'circle'] }, __type__: { object, boolean } }, - middle: { enabled: { boolean }, scaleFactor: { number }, type: { string: ['arrow', 'circle'] }, __type__: { object, boolean } }, - from: { enabled: { boolean }, scaleFactor: { number }, type: { string: ['arrow', 'circle'] }, __type__: { object, boolean } }, + to: { enabled: { boolean: bool }, scaleFactor: { number }, type: { string: endPoints }, __type__: { object, boolean: bool } }, + middle: { enabled: { boolean: bool }, scaleFactor: { number }, type: { string: endPoints }, __type__: { object, boolean: bool } }, + from: { enabled: { boolean: bool }, scaleFactor: { number }, type: { string: endPoints }, __type__: { object, boolean: bool } }, __type__: { string: ['from', 'to', 'middle'], object } }, - arrowStrikethrough: { boolean }, + arrowStrikethrough: { boolean: bool }, + chosen: { + label: { boolean: bool, 'function': 'function' }, + edge: { boolean: bool, 'function': 'function' }, + __type__: { object, boolean: bool } + }, color: { color: { string }, highlight: { string }, hover: { string }, - inherit: { string: ['from', 'to', 'both'], boolean }, + inherit: { string: ['from', 'to', 'both'], boolean: bool }, opacity: { number }, __type__: { object, string } }, - dashes: { boolean, array }, + dashes: { boolean: bool, array }, font: { color: { string }, size: { number }, // px @@ -47,24 +54,58 @@ let allOptions = { strokeWidth: { number }, // px strokeColor: { string }, align: { string: ['horizontal', 'top', 'middle', 'bottom'] }, + vadjust: { number }, + multi: { boolean: bool, string }, + bold: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + boldital: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + ital: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + mono: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, __type__: { object, string } }, - hidden: { boolean }, + hidden: { boolean: bool }, hoverWidth: { 'function': 'function', number }, label: { string, 'undefined': 'undefined' }, - labelHighlightBold: { boolean }, + labelHighlightBold: { boolean: bool }, length: { number, 'undefined': 'undefined' }, - physics: { boolean }, + physics: { boolean: bool }, scaling: { min: { number }, max: { number }, label: { - enabled: { boolean }, + enabled: { boolean: bool }, min: { number }, max: { number }, maxVisible: { number }, drawThreshold: { number }, - __type__: { object, boolean } + __type__: { object, boolean: bool } }, customScalingFunction: { 'function': 'function' }, __type__: { object } @@ -72,85 +113,96 @@ let allOptions = { selectionWidth: { 'function': 'function', number }, selfReferenceSize: { number }, shadow: { - enabled: { boolean }, + enabled: { boolean: bool }, color: { string }, size: { number }, x: { number }, y: { number }, - __type__: { object, boolean } + __type__: { object, boolean: bool } }, smooth: { - enabled: { boolean }, + enabled: { boolean: bool }, type: { string: ['dynamic', 'continuous', 'discrete', 'diagonalCross', 'straightCross', 'horizontal', 'vertical', 'curvedCW', 'curvedCCW', 'cubicBezier'] }, roundness: { number }, - forceDirection: { string: ['horizontal', 'vertical', 'none'], boolean }, - __type__: { object, boolean } + forceDirection: { string: ['horizontal', 'vertical', 'none'], boolean: bool }, + __type__: { object, boolean: bool } }, title: { string, 'undefined': 'undefined' }, width: { number }, + widthConstraint: { + maximum: { number }, + __type__: { object, boolean: bool, number } + }, value: { number, 'undefined': 'undefined' }, __type__: { object } }, groups: { - useDefaultGroups: { boolean }, + useDefaultGroups: { boolean: bool }, __any__: 'get from nodes, will be overwritten below', __type__: { object } }, interaction: { - dragNodes: { boolean }, - dragView: { boolean }, - hideEdgesOnDrag: { boolean }, - hideNodesOnDrag: { boolean }, - hover: { boolean }, + dragNodes: { boolean: bool }, + dragView: { boolean: bool }, + hideEdgesOnDrag: { boolean: bool }, + hideNodesOnDrag: { boolean: bool }, + hover: { boolean: bool }, keyboard: { - enabled: { boolean }, + enabled: { boolean: bool }, speed: { x: { number }, y: { number }, zoom: { number }, __type__: { object } }, - bindToWindow: { boolean }, - __type__: { object, boolean } - }, - multiselect: { boolean }, - navigationButtons: { boolean }, - selectable: { boolean }, - selectConnectedEdges: { boolean }, - hoverConnectedEdges: { boolean }, + bindToWindow: { boolean: bool }, + __type__: { object, boolean: bool } + }, + multiselect: { boolean: bool }, + navigationButtons: { boolean: bool }, + selectable: { boolean: bool }, + selectConnectedEdges: { boolean: bool }, + hoverConnectedEdges: { boolean: bool }, tooltipDelay: { number }, - zoomView: { boolean }, - zoomFactor: { in: {number}, out: {number}, __type__: {object} }, + zoomView: { boolean: bool }, __type__: { object } }, layout: { randomSeed: { 'undefined': 'undefined', number }, - improvedLayout: { boolean }, + improvedLayout: { boolean: bool }, hierarchical: { - enabled: { boolean }, + enabled: { boolean: bool }, levelSeparation: { number }, nodeSpacing: { number }, treeSpacing: { number }, - blockShifting: { boolean }, - edgeMinimization: { boolean }, - parentCentralization: { boolean }, + blockShifting: { boolean: bool }, + edgeMinimization: { boolean: bool }, + parentCentralization: { boolean: bool }, direction: { string: ['UD', 'DU', 'LR', 'RL'] }, // UD, DU, LR, RL sortMethod: { string: ['hubsize', 'directed'] }, // hubsize, directed - __type__: { object, boolean } + __type__: { object, boolean: bool } }, __type__: { object } }, manipulation: { - enabled: { boolean }, - initiallyActive: { boolean }, - addNode: { boolean, 'function': 'function' }, - addEdge: { boolean, 'function': 'function' }, + enabled: { boolean: bool }, + initiallyActive: { boolean: bool }, + addNode: { boolean: bool, 'function': 'function' }, + addEdge: { boolean: bool, 'function': 'function' }, editNode: { 'function': 'function' }, - editEdge: { boolean, 'function': 'function' }, - deleteNode: { boolean, 'function': 'function' }, - deleteEdge: { boolean, 'function': 'function' }, + editEdge: { + editWithoutDrag: { 'function' : 'function' }, + __type__: {object, boolean: bool, 'function': 'function' } + }, + deleteNode: { boolean: bool, 'function': 'function' }, + deleteEdge: { boolean: bool, 'function': 'function' }, controlNodeStyle: 'get from nodes, will be overwritten below', - __type__: { object, boolean } + __type__: { object, boolean: bool } }, nodes: { borderWidth: { number }, borderWidthSelected: { number, 'undefined': 'undefined' }, brokenImage: { string, 'undefined': 'undefined' }, + chosen: { + label: { boolean: bool, 'function': 'function' }, + node: { boolean: bool, 'function': 'function' }, + __type__: { object, boolean: bool } + }, color: { border: { string }, background: { string }, @@ -167,9 +219,9 @@ let allOptions = { __type__: { object, string } }, fixed: { - x: { boolean }, - y: { boolean }, - __type__: { object, boolean } + x: { boolean: bool }, + y: { boolean: bool }, + __type__: { object, boolean: bool } }, font: { align: { string }, @@ -179,10 +231,49 @@ let allOptions = { background: { string }, strokeWidth: { number }, // px strokeColor: { string }, + vadjust: { number }, + multi: { boolean: bool, string }, + bold: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + boldital: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + ital: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, + mono: { + color: { string }, + size: { number }, // px + face: { string }, + mod: { string }, + vadjust: { number }, + __type__: { object, string } + }, __type__: { object, string } }, group: { string, number, 'undefined': 'undefined' }, - hidden: { boolean }, + heightConstraint: { + minimum: { number }, + valign: { string }, + __type__: { object, boolean: bool, number } + }, + hidden: { boolean: bool }, icon: { face: { string }, code: { string }, //'\uf007', @@ -191,52 +282,68 @@ let allOptions = { __type__: { object } }, id: { string, number }, - image: { string, 'undefined': 'undefined' }, // --> URL + image: { + selected: { string, 'undefined': 'undefined' }, // --> URL + unselected: { string, 'undefined': 'undefined' }, // --> URL + __type__: { object, string } + }, label: { string, 'undefined': 'undefined' }, - labelHighlightBold: { boolean }, + labelHighlightBold: { boolean: bool }, level: { number, 'undefined': 'undefined' }, + margin: { + top: { number }, + right: { number }, + bottom: { number }, + left: { number }, + __type__: { object, number } + }, mass: { number }, - physics: { boolean }, + physics: { boolean: bool }, scaling: { min: { number }, max: { number }, label: { - enabled: { boolean }, + enabled: { boolean: bool }, min: { number }, max: { number }, maxVisible: { number }, drawThreshold: { number }, - __type__: { object, boolean } + __type__: { object, boolean: bool } }, customScalingFunction: { 'function': 'function' }, __type__: { object } }, shadow: { - enabled: { boolean }, + enabled: { boolean: bool }, color: { string }, size: { number }, x: { number }, y: { number }, - __type__: { object, boolean } + __type__: { object, boolean: bool } }, - shape: { string: ['ellipse', 'circle', 'database', 'box', 'text', 'image', 'circularImage', 'diamond', 'dot', 'star', 'triangle', 'triangleDown', 'square', 'icon'] }, + shape: { string: ['ellipse', 'circle', 'database', 'box', 'text', 'image', 'circularImage', 'diamond', 'dot', 'star', 'triangle', 'triangleDown', 'square', 'icon', 'hexagon'] }, shapeProperties: { - borderDashes: { boolean, array }, + borderDashes: { boolean: bool, array }, borderRadius: { number }, - interpolation: { boolean }, - useImageSize: { boolean }, - useBorderWithImage: { boolean }, + interpolation: { boolean: bool }, + useImageSize: { boolean: bool }, + useBorderWithImage: { boolean: bool }, __type__: { object } }, size: { number }, - title: { string, 'undefined': 'undefined' }, + title: { string, dom, 'undefined': 'undefined' }, value: { number, 'undefined': 'undefined' }, + widthConstraint: { + minimum: { number }, + maximum: { number }, + __type__: { object, boolean: bool, number } + }, x: { number }, y: { number }, __type__: { object } }, physics: { - enabled: { boolean }, + enabled: { boolean: bool }, barnesHut: { gravitationalConstant: { number }, centralGravity: { number }, @@ -275,21 +382,21 @@ let allOptions = { minVelocity: { number }, // px/s solver: { string: ['barnesHut', 'repulsion', 'hierarchicalRepulsion', 'forceAtlas2Based'] }, stabilization: { - enabled: { boolean }, + enabled: { boolean: bool }, iterations: { number }, // maximum number of iteration to stabilize updateInterval: { number }, - onlyDynamicEdges: { boolean }, - fit: { boolean }, - __type__: { object, boolean } + onlyDynamicEdges: { boolean: bool }, + fit: { boolean: bool }, + __type__: { object, boolean: bool } }, timestep: { number }, - adaptiveTimestep: { boolean }, - __type__: { object, boolean } + adaptiveTimestep: { boolean: bool }, + __type__: { object, boolean: bool } }, //globals : - autoResize: { boolean }, - clickToUse: { boolean }, + autoResize: { boolean: bool }, + clickToUse: { boolean: bool }, locale: { string }, locales: { __any__: { any }, @@ -361,7 +468,7 @@ let configureOptions = { x: [5, -30, 30, 1], y: [5, -30, 30, 1] }, - shape: ['ellipse', 'box', 'circle', 'database', 'diamond', 'dot', 'square', 'star', 'text', 'triangle', 'triangleDown'], + shape: ['ellipse', 'box', 'circle', 'database', 'diamond', 'dot', 'square', 'star', 'text', 'triangle', 'triangleDown','hexagon'], shapeProperties: { borderDashes: false, borderRadius: [6, 0, 20, 1], diff --git a/lib/network/shapes.js b/lib/network/shapes.js index 9346f4824..cbb5a8da9 100644 --- a/lib/network/shapes.js +++ b/lib/network/shapes.js @@ -5,6 +5,10 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a circle shape + * + * @param {number} x + * @param {number} y + * @param {number} r */ CanvasRenderingContext2D.prototype.circle = function (x, y, r) { this.beginPath(); @@ -14,9 +18,9 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a square shape - * @param {Number} x horizontal center - * @param {Number} y vertical center - * @param {Number} r size, width and height of the square + * @param {number} x horizontal center + * @param {number} y vertical center + * @param {number} r size, width and height of the square */ CanvasRenderingContext2D.prototype.square = function (x, y, r) { this.beginPath(); @@ -26,9 +30,9 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a triangle shape - * @param {Number} x horizontal center - * @param {Number} y vertical center - * @param {Number} r radius, half the length of the sides of the triangle + * @param {number} x horizontal center + * @param {number} y vertical center + * @param {number} r radius, half the length of the sides of the triangle */ CanvasRenderingContext2D.prototype.triangle = function (x, y, r) { // http://en.wikipedia.org/wiki/Equilateral_triangle @@ -55,9 +59,9 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a triangle shape in downward orientation - * @param {Number} x horizontal center - * @param {Number} y vertical center - * @param {Number} r radius + * @param {number} x horizontal center + * @param {number} y vertical center + * @param {number} r radius */ CanvasRenderingContext2D.prototype.triangleDown = function (x, y, r) { // http://en.wikipedia.org/wiki/Equilateral_triangle @@ -81,9 +85,9 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a star shape, a star with 5 points - * @param {Number} x horizontal center - * @param {Number} y vertical center - * @param {Number} r radius, half the length of the sides of the triangle + * @param {number} x horizontal center + * @param {number} y vertical center + * @param {number} r radius, half the length of the sides of the triangle */ CanvasRenderingContext2D.prototype.star = function (x, y, r) { // http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/ @@ -106,9 +110,9 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a Diamond shape - * @param {Number} x horizontal center - * @param {Number} y vertical center - * @param {Number} r radius, half the length of the sides of the triangle + * @param {number} x horizontal center + * @param {number} y vertical center + * @param {number} r radius, half the length of the sides of the triangle */ CanvasRenderingContext2D.prototype.diamond = function (x, y, r) { // http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/ @@ -125,6 +129,12 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * http://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas + * + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + * @param {number} r */ CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { var r2d = Math.PI / 180; @@ -149,8 +159,15 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas + * + * Postfix '_vis' added to discern it from standard method ellipse(). + * + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h */ - CanvasRenderingContext2D.prototype.ellipse = function (x, y, w, h) { + CanvasRenderingContext2D.prototype.ellipse_vis = function (x, y, w, h) { var kappa = .5522848, ox = (w / 2) * kappa, // control point offset horizontal oy = (h / 2) * kappa, // control point offset vertical @@ -171,6 +188,11 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas + * + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h */ CanvasRenderingContext2D.prototype.database = function (x, y, w, h) { var f = 1 / 3; @@ -205,49 +227,17 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { }; - /** - * Draw an arrow at the end of a line with the given angle. - */ - CanvasRenderingContext2D.prototype.arrowEndpoint = function (x, y, angle, length) { - // tail - var xt = x - length * Math.cos(angle); - var yt = y - length * Math.sin(angle); - - // inner tail - var xi = x - length * 0.9 * Math.cos(angle); - var yi = y - length * 0.9 * Math.sin(angle); - - // left - var xl = xt + length / 3 * Math.cos(angle + 0.5 * Math.PI); - var yl = yt + length / 3 * Math.sin(angle + 0.5 * Math.PI); - - // right - var xr = xt + length / 3 * Math.cos(angle - 0.5 * Math.PI); - var yr = yt + length / 3 * Math.sin(angle - 0.5 * Math.PI); - - this.beginPath(); - this.moveTo(x, y); - this.lineTo(xl, yl); - this.lineTo(xi, yi); - this.lineTo(xr, yr); - this.closePath(); - }; - - /** - * Draw an circle an the end of an line with the given angle. - */ - CanvasRenderingContext2D.prototype.circleEndpoint = function (x, y, angle, length) { - var radius = length * 0.4; - var xc = x - radius * Math.cos(angle); - var yc = y - radius * Math.sin(angle); - this.circle(xc, yc, radius); - }; - /** * Sets up the dashedLine functionality for drawing * Original code came from http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas * @author David Jordan * @date 2012-08-08 + * + * @param {number} x + * @param {number} y + * @param {number} x2 + * @param {number} y2 + * @param {string} pattern */ CanvasRenderingContext2D.prototype.dashedLine = function (x, y, x2, y2, pattern) { this.beginPath(); @@ -282,5 +272,20 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { } }; - + /** + * Draw a Hexagon shape with 6 sides + * @param {Number} x horizontal center + * @param {Number} y vertical center + * @param {Number} r radius + */ + CanvasRenderingContext2D.prototype.hexagon = function (x, y, r) { + this.beginPath(); + var sides = 6; + var a = (Math.PI*2)/sides; + this.moveTo(x+r,y); + for (var i = 1; i < sides; i++) { + this.lineTo(x+r*Math.cos(a*i),y+r*Math.sin(a*i)); + } + this.closePath(); + }; } diff --git a/lib/shared/Activator.js b/lib/shared/Activator.js index 253f0bead..5b739cbac 100644 --- a/lib/shared/Activator.js +++ b/lib/shared/Activator.js @@ -11,7 +11,7 @@ var util = require('../util'); * the interactive contents of the element can be used. When clicked outside * the element, the elements mode is changed to inactive. * @param {Element} container - * @constructor + * @constructor Activator */ function Activator(container) { this.active = false; @@ -125,7 +125,7 @@ Activator.prototype.deactivate = function () { /** * Handle a tap event: activate the container - * @param event + * @param {Event} event The event * @private */ Activator.prototype._onTapOverlay = function (event) { diff --git a/lib/shared/ColorPicker.js b/lib/shared/ColorPicker.js index adbfe10be..0d8751749 100644 --- a/lib/shared/ColorPicker.js +++ b/lib/shared/ColorPicker.js @@ -2,7 +2,13 @@ let Hammer = require('../module/hammer'); let hammerUtil = require('../hammerUtil'); let util = require('../util'); +/** + * @param {number} [pixelRatio=1] + */ class ColorPicker { + /** + * @param {number} [pixelRatio=1] + */ constructor(pixelRatio = 1) { this.pixelRatio = pixelRatio; this.generated = false; @@ -25,7 +31,7 @@ class ColorPicker { /** * this inserts the colorPicker into a div from the DOM - * @param container + * @param {Element} container */ insertTo(container) { if (this.hammer !== undefined) { @@ -41,7 +47,7 @@ class ColorPicker { /** * the callback is executed on apply and save. Bind it to the application - * @param callback + * @param {function} callback */ setUpdateCallback(callback) { if (typeof callback === 'function') { @@ -54,7 +60,7 @@ class ColorPicker { /** * the callback is executed on apply and save. Bind it to the application - * @param callback + * @param {function} callback */ setCloseCallback(callback) { if (typeof callback === 'function') { @@ -65,6 +71,12 @@ class ColorPicker { } } + /** + * + * @param {string} color + * @returns {String} + * @private + */ _isColorString(color) { var htmlColors = {black: '#000000',navy: '#000080',darkblue: '#00008B',mediumblue: '#0000CD',blue: '#0000FF',darkgreen: '#006400',green: '#008000',teal: '#008080',darkcyan: '#008B8B',deepskyblue: '#00BFFF',darkturquoise: '#00CED1',mediumspringgreen: '#00FA9A',lime: '#00FF00',springgreen: '#00FF7F',aqua: '#00FFFF',cyan: '#00FFFF',midnightblue: '#191970',dodgerblue: '#1E90FF',lightseagreen: '#20B2AA',forestgreen: '#228B22',seagreen: '#2E8B57',darkslategray: '#2F4F4F',limegreen: '#32CD32',mediumseagreen: '#3CB371',turquoise: '#40E0D0',royalblue: '#4169E1',steelblue: '#4682B4',darkslateblue: '#483D8B',mediumturquoise: '#48D1CC',indigo: '#4B0082',darkolivegreen: '#556B2F',cadetblue: '#5F9EA0',cornflowerblue: '#6495ED',mediumaquamarine: '#66CDAA',dimgray: '#696969',slateblue: '#6A5ACD',olivedrab: '#6B8E23',slategray: '#708090',lightslategray: '#778899',mediumslateblue: '#7B68EE',lawngreen: '#7CFC00',chartreuse: '#7FFF00',aquamarine: '#7FFFD4',maroon: '#800000',purple: '#800080',olive: '#808000',gray: '#808080',skyblue: '#87CEEB',lightskyblue: '#87CEFA',blueviolet: '#8A2BE2',darkred: '#8B0000',darkmagenta: '#8B008B',saddlebrown: '#8B4513',darkseagreen: '#8FBC8F',lightgreen: '#90EE90',mediumpurple: '#9370D8',darkviolet: '#9400D3',palegreen: '#98FB98',darkorchid: '#9932CC',yellowgreen: '#9ACD32',sienna: '#A0522D',brown: '#A52A2A',darkgray: '#A9A9A9',lightblue: '#ADD8E6',greenyellow: '#ADFF2F',paleturquoise: '#AFEEEE',lightsteelblue: '#B0C4DE',powderblue: '#B0E0E6',firebrick: '#B22222',darkgoldenrod: '#B8860B',mediumorchid: '#BA55D3',rosybrown: '#BC8F8F',darkkhaki: '#BDB76B',silver: '#C0C0C0',mediumvioletred: '#C71585',indianred: '#CD5C5C',peru: '#CD853F',chocolate: '#D2691E',tan: '#D2B48C',lightgrey: '#D3D3D3',palevioletred: '#D87093',thistle: '#D8BFD8',orchid: '#DA70D6',goldenrod: '#DAA520',crimson: '#DC143C',gainsboro: '#DCDCDC',plum: '#DDA0DD',burlywood: '#DEB887',lightcyan: '#E0FFFF',lavender: '#E6E6FA',darksalmon: '#E9967A',violet: '#EE82EE',palegoldenrod: '#EEE8AA',lightcoral: '#F08080',khaki: '#F0E68C',aliceblue: '#F0F8FF',honeydew: '#F0FFF0',azure: '#F0FFFF',sandybrown: '#F4A460',wheat: '#F5DEB3',beige: '#F5F5DC',whitesmoke: '#F5F5F5',mintcream: '#F5FFFA',ghostwhite: '#F8F8FF',salmon: '#FA8072',antiquewhite: '#FAEBD7',linen: '#FAF0E6',lightgoldenrodyellow: '#FAFAD2',oldlace: '#FDF5E6',red: '#FF0000',fuchsia: '#FF00FF',magenta: '#FF00FF',deeppink: '#FF1493',orangered: '#FF4500',tomato: '#FF6347',hotpink: '#FF69B4',coral: '#FF7F50',darkorange: '#FF8C00',lightsalmon: '#FFA07A',orange: '#FFA500',lightpink: '#FFB6C1',pink: '#FFC0CB',gold: '#FFD700',peachpuff: '#FFDAB9',navajowhite: '#FFDEAD',moccasin: '#FFE4B5',bisque: '#FFE4C4',mistyrose: '#FFE4E1',blanchedalmond: '#FFEBCD',papayawhip: '#FFEFD5',lavenderblush: '#FFF0F5',seashell: '#FFF5EE',cornsilk: '#FFF8DC',lemonchiffon: '#FFFACD',floralwhite: '#FFFAF0',snow: '#FFFAFA',yellow: '#FFFF00',lightyellow: '#FFFFE0',ivory: '#FFFFF0',white: '#FFFFFF'}; if (typeof color === 'string') { @@ -82,8 +94,8 @@ class ColorPicker { * 'rgba(255,255,255,1.0)' --> rgba string * {r:255,g:255,b:255} --> rgb object * {r:255,g:255,b:255,a:1.0} --> rgba object - * @param color - * @param setInitial + * @param {string|Object} color + * @param {boolean} [setInitial=true] */ setColor(color, setInitial = true) { if (color === 'none') { @@ -152,7 +164,7 @@ class ColorPicker { /** * Hide the picker. Is called by the cancel button. * Optional boolean to store the previous color for easy access later on. - * @param storePrevious + * @param {boolean} [storePrevious=true] * @private */ _hide(storePrevious = true) { @@ -216,8 +228,8 @@ class ColorPicker { /** * set the color, place the picker - * @param rgba - * @param setInitial + * @param {Object} rgba + * @param {boolean} [setInitial=true] * @private */ _setColor(rgba, setInitial = true) { @@ -243,7 +255,7 @@ class ColorPicker { /** * bound to opacity control - * @param value + * @param {number} value * @private */ _setOpacity(value) { @@ -254,7 +266,7 @@ class ColorPicker { /** * bound to brightness control - * @param value + * @param {number} value * @private */ _setBrightness(value) { @@ -269,7 +281,7 @@ class ColorPicker { /** * update the color picker. A black circle overlays the hue circle to mimic the brightness decreasing. - * @param rgba + * @param {Object} rgba * @private */ _updatePicker(rgba = this.color) { @@ -368,7 +380,8 @@ class ColorPicker { this.opacityRange.min = '0'; this.opacityRange.max = '100'; } - catch (err) {} + // TODO: Add some error handling and remove this lint exception + catch (err) {} // eslint-disable-line no-empty this.opacityRange.value = '100'; this.opacityRange.className = 'vis-range'; @@ -378,7 +391,8 @@ class ColorPicker { this.brightnessRange.min = '0'; this.brightnessRange.max = '100'; } - catch (err) {} + // TODO: Add some error handling and remove this lint exception + catch (err) {} // eslint-disable-line no-empty this.brightnessRange.value = '100'; this.brightnessRange.className = 'vis-range'; @@ -513,7 +527,7 @@ class ColorPicker { /** * move the selector. This is called by hammer functions. * - * @param event + * @param {Event} event The event * @private */ _moveSelector(event) { diff --git a/lib/shared/Configurator.js b/lib/shared/Configurator.js index e564bc2d5..7dcb8b091 100644 --- a/lib/shared/Configurator.js +++ b/lib/shared/Configurator.js @@ -1,6 +1,6 @@ var util = require('../util'); -import ColorPicker from './ColorPicker' +var ColorPicker = require('./ColorPicker').default; /** * The way this works is for all properties of this.possible options, you can supply the property name in any form to list the options. @@ -10,13 +10,14 @@ import ColorPicker from './ColorPicker' * Strings with should be written as array: [option1, option2, option3, ..] * * The options are matched with their counterparts in each of the modules and the values used in the configuration are - * - * @param parentModule | the location where parentModule.setOptions() can be called - * @param defaultContainer | the default container of the module - * @param configureOptions | the fully configured and predefined options set found in allOptions.js - * @param pixelRatio | canvas pixel ratio */ class Configurator { + /** + * @param {Object} parentModule | the location where parentModule.setOptions() can be called + * @param {Object} defaultContainer | the default container of the module + * @param {Object} configureOptions | the fully configured and predefined options set found in allOptions.js + * @param {number} pixelRatio | canvas pixel ratio + */ constructor(parentModule, defaultContainer, configureOptions, pixelRatio = 1) { this.parent = parentModule; this.changedOptions = []; @@ -49,7 +50,7 @@ class Configurator { * refresh all options. * Because all modules parse their options by themselves, we just use their options. We copy them here. * - * @param options + * @param {Object} options */ setOptions(options) { if (options !== undefined) { @@ -95,7 +96,10 @@ class Configurator { this._clean(); } - + /** + * + * @param {Object} moduleOptions + */ setModuleOptions(moduleOptions) { this.moduleOptions = moduleOptions; if (this.options.enabled === true) { @@ -225,8 +229,9 @@ class Configurator { /** * all option elements are wrapped in an item - * @param path - * @param domElements + * @param {Array} path | where to look for the actual option + * @param {Array.} domElements + * @returns {number} * @private */ _makeItem(path, ...domElements) { @@ -245,7 +250,7 @@ class Configurator { /** * header for major subjects - * @param name + * @param {string} name * @private */ _makeHeader(name) { @@ -258,9 +263,9 @@ class Configurator { /** * make a label, if it is an object label, it gets different styling. - * @param name - * @param path - * @param objectLabel + * @param {string} name + * @param {array} path | where to look for the actual option + * @param {string} objectLabel * @returns {HTMLElement} * @private */ @@ -279,9 +284,9 @@ class Configurator { /** * make a dropdown list for multiple possible string optoins - * @param arr - * @param value - * @param path + * @param {Array.} arr + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _makeDropdown(arr, value, path) { @@ -314,9 +319,9 @@ class Configurator { /** * make a range object for numeric options - * @param arr - * @param value - * @param path + * @param {Array.} arr + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _makeRange(arr, value, path) { @@ -331,7 +336,8 @@ class Configurator { range.min = min; range.max = max; } - catch (err) {} + // TODO: Add some error handling and remove this lint exception + catch (err) {} // eslint-disable-line no-empty range.step = step; // set up the popup settings in case they are needed. @@ -383,8 +389,8 @@ class Configurator { /** * prepare the popup - * @param string - * @param index + * @param {string} string + * @param {number} index * @private */ _setupPopup(string, index) { @@ -436,9 +442,9 @@ class Configurator { /** * make a checkbox for boolean options. - * @param defaultValue - * @param value - * @param path + * @param {number} defaultValue + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _makeCheckbox(defaultValue, value, path) { @@ -469,9 +475,9 @@ class Configurator { /** * make a text input field for string options. - * @param defaultValue - * @param value - * @param path + * @param {number} defaultValue + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _makeTextInput(defaultValue, value, path) { @@ -493,9 +499,9 @@ class Configurator { /** * make a color field with a color picker for color fields - * @param arr - * @param value - * @param path + * @param {Array.} arr + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _makeColorField(arr, value, path) { @@ -523,10 +529,9 @@ class Configurator { /** * used by the color buttons to call the color picker. - * @param event - * @param value - * @param div - * @param path + * @param {number} value + * @param {HTMLElement} div + * @param {array} path | where to look for the actual option * @private */ _showColorPicker(value, div, path) { @@ -554,8 +559,10 @@ class Configurator { /** * parse an object and draw the correct items - * @param obj - * @param path + * @param {Object} obj + * @param {array} [path=[]] | where to look for the actual option + * @param {boolean} [checkOnly=false] + * @returns {boolean} * @private */ _handleObject(obj, path = [], checkOnly = false) { @@ -635,10 +642,9 @@ class Configurator { /** * handle the array type of option - * @param optionName - * @param arr - * @param value - * @param path + * @param {Array.} arr + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _handleArray(arr, value, path) { @@ -660,8 +666,8 @@ class Configurator { /** * called to update the network with the new settings. - * @param value - * @param path + * @param {number} value + * @param {array} path | where to look for the actual option * @private */ _update(value, path) { @@ -674,6 +680,15 @@ class Configurator { this.parent.setOptions(options); } + + /** + * + * @param {string|Boolean} value + * @param {Array.} path + * @param {{}} optionsObj + * @returns {{}} + * @private + */ _constructOptions(value, path, optionsObj = {}) { let pointer = optionsObj; @@ -697,11 +712,18 @@ class Configurator { return optionsObj; } + /** + * @private + */ _printOptions() { let options = this.getOptions(); this.optionsContainer.innerHTML = '
var options = ' + JSON.stringify(options, null, 2) + '
'; } + /** + * + * @returns {{}} options + */ getOptions() { let options = {}; for (var i = 0; i < this.changedOptions.length; i++) { diff --git a/lib/network/modules/components/Popup.js b/lib/shared/Popup.js similarity index 54% rename from lib/network/modules/components/Popup.js rename to lib/shared/Popup.js index cc7f0722d..41cf25eeb 100644 --- a/lib/network/modules/components/Popup.js +++ b/lib/shared/Popup.js @@ -1,15 +1,14 @@ /** * 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) { + /** + * @param {Element} container The container object. + * @param {string} overflowMethod How the popup should act to overflowing ('flip' or 'cap') + */ + constructor(container, overflowMethod) { this.container = container; + this.overflowMethod = overflowMethod || 'cap'; this.x = 0; this.y = 0; @@ -18,7 +17,7 @@ class Popup { // create the frame this.frame = document.createElement('div'); - this.frame.className = 'vis-network-tooltip'; + this.frame.className = 'vis-tooltip'; this.container.appendChild(this.frame); } @@ -60,20 +59,46 @@ class Popup { 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 = 0, top = 0; - var left = this.x; - if (left + width + this.padding > maxWidth) { - left = maxWidth - width - this.padding; - } - if (left < this.padding) { - left = this.padding; + if (this.overflowMethod == 'flip') { + var isLeft = false, isTop = true; // Where around the position it's located + + if (this.y - height < this.padding) { + isTop = false; + } + + if (this.x + width > maxWidth - this.padding) { + isLeft = true; + } + + if (isLeft) { + left = this.x - width; + } else { + left = this.x; + } + + if (isTop) { + top = this.y - height; + } else { + top = this.y; + } + } else { + top = (this.y - height); + if (top + height + this.padding > maxHeight) { + top = maxHeight - height - this.padding; + } + if (top < this.padding) { + top = this.padding; + } + + 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"; @@ -91,6 +116,8 @@ class Popup { */ hide() { this.hidden = true; + this.frame.style.left = "0"; + this.frame.style.top = "0"; this.frame.style.visibility = "hidden"; } @@ -107,14 +134,17 @@ class Popup { y2: this.y }; - if(pointer.x >= box.x1 && pointer.x <= box.x2 - && pointer.y >= box.y1 && pointer.y <= box.y2) { - return true; - } + return pointer.x >= box.x1 && pointer.x <= box.x2 + && pointer.y >= box.y1 && pointer.y <= box.y2; + - return false; } + /* Remove the popup window + */ + destroy() { + this.frame.parentNode.removeChild(this.frame); // Remove element from DOM + } } export default Popup; diff --git a/lib/shared/Validator.js b/lib/shared/Validator.js index 70412c240..c41066e88 100644 --- a/lib/shared/Validator.js +++ b/lib/shared/Validator.js @@ -7,14 +7,19 @@ let printStyle = 'background: #FFeeee; color: #dd0000'; * Used to validate options. */ class Validator { + /** + * @ignore + */ constructor() { } /** * Main function to be called - * @param options - * @param subObject + * @param {Object} options + * @param {Object} referenceOptions + * @param {Object} subObject * @returns {boolean} + * @static */ static validate(options, referenceOptions, subObject) { errorFound = false; @@ -30,9 +35,10 @@ class Validator { /** * Will traverse an object recursively and check every value - * @param options - * @param referenceOptions - * @param path + * @param {Object} options + * @param {Object} referenceOptions + * @param {array} path | where to look for the actual option + * @static */ static parse(options, referenceOptions, path) { for (let option in options) { @@ -45,61 +51,72 @@ class Validator { /** * Check every value. If the value is an object, call the parse function on that object. - * @param option - * @param options - * @param referenceOptions - * @param path + * @param {string} option + * @param {Object} options + * @param {Object} referenceOptions + * @param {array} path | where to look for the actual option + * @static */ static check(option, options, referenceOptions, path) { if (referenceOptions[option] === undefined && referenceOptions.__any__ === undefined) { Validator.getSuggestion(option, referenceOptions, path); + return; } - else if (referenceOptions[option] === undefined && referenceOptions.__any__ !== undefined) { + + let referenceOption = option; + let is_object = true; + + if (referenceOptions[option] === undefined && referenceOptions.__any__ !== undefined) { + // NOTE: This only triggers if the __any__ is in the top level of the options object. + // THAT'S A REALLY BAD PLACE TO ALLOW IT!!!! + // TODO: Examine if needed, remove if possible + // __any__ is a wildcard. Any value is accepted and will be further analysed by reference. - if (Validator.getType(options[option]) === 'object' && referenceOptions['__any__'].__type__ !== undefined) { - // if the any subgroup is not a predefined object int he configurator we do not look deeper into the object. - Validator.checkFields(option, options, referenceOptions, '__any__', referenceOptions['__any__'].__type__, path); - } - else { - Validator.checkFields(option, options, referenceOptions, '__any__', referenceOptions['__any__'], path); - } + referenceOption = '__any__'; + + // if the any-subgroup is not a predefined object in the configurator, + // we do not look deeper into the object. + is_object = (Validator.getType(options[option]) === 'object'); } else { - // Since all options in the reference are objects, we can check whether they are supposed to be object to look for the __type__ field. - if (referenceOptions[option].__type__ !== undefined) { - // if this should be an object, we check if the correct type has been supplied to account for shorthand options. - Validator.checkFields(option, options, referenceOptions, option, referenceOptions[option].__type__, path); - } - else { - Validator.checkFields(option, options, referenceOptions, option, referenceOptions[option], path); - } + // Since all options in the reference are objects, we can check whether + // they are supposed to be the object to look for the __type__ field. + // if this is an object, we check if the correct type has been supplied to account for shorthand options. } + + let refOptionObj = referenceOptions[referenceOption]; + if (is_object && refOptionObj.__type__ !== undefined) { + refOptionObj = refOptionObj.__type__; + } + + Validator.checkFields(option, options, referenceOptions, referenceOption, refOptionObj, path); } /** * - * @param {String} option | the option property - * @param {Object} options | The supplied options object - * @param {Object} referenceOptions | The reference options containing all options and their allowed formats - * @param {String} referenceOption | Usually this is the same as option, except when handling an __any__ tag. - * @param {String} refOptionType | This is the type object from the reference options - * @param {Array} path | where in the object is the option + * @param {string} option | the option property + * @param {Object} options | The supplied options object + * @param {Object} referenceOptions | The reference options containing all options and their allowed formats + * @param {string} referenceOption | Usually this is the same as option, except when handling an __any__ tag. + * @param {string} refOptionObj | This is the type object from the reference options + * @param {Array} path | where in the object is the option + * @static */ static checkFields(option, options, referenceOptions, referenceOption, refOptionObj, path) { + let log = function(message) { + console.log('%c' + message + Validator.printLocation(path, option), printStyle); + }; + let optionType = Validator.getType(options[option]); let refOptionType = refOptionObj[optionType]; + if (refOptionType !== undefined) { // if the type is correct, we check if it is supposed to be one of a few select values - if (Validator.getType(refOptionType) === 'array') { - if (refOptionType.indexOf(options[option]) === -1) { - console.log('%cInvalid option detected in "' + option + '".' + - ' Allowed values are:' + Validator.print(refOptionType) + ' not "' + options[option] + '". ' + Validator.printLocation(path, option), printStyle); - errorFound = true; - } - else if (optionType === 'object' && referenceOption !== "__any__") { - path = util.copyAndExtendArray(path, option); - Validator.parse(options[option], referenceOptions[referenceOption], path); - } + if (Validator.getType(refOptionType) === 'array' && refOptionType.indexOf(options[option]) === -1) { + log('Invalid option detected in "' + option + '".' + + ' Allowed values are:' + Validator.print(refOptionType) + + ' not "' + options[option] + '". '); + errorFound = true; } else if (optionType === 'object' && referenceOption !== "__any__") { path = util.copyAndExtendArray(path, option); @@ -108,12 +125,19 @@ class Validator { } else if (refOptionObj['any'] === undefined) { // type of the field is incorrect and the field cannot be any - console.log('%cInvalid type received for "' + option + '". Expected: ' + Validator.print(Object.keys(refOptionObj)) + '. Received [' + optionType + '] "' + options[option] + '"' + Validator.printLocation(path, option), printStyle); + log('Invalid type received for "' + option + + '". Expected: ' + Validator.print(Object.keys(refOptionObj)) + + '. Received [' + optionType + '] "' + options[option] + '"'); errorFound = true; } } - + /** + * + * @param {Object|boolean|number|string|Array.|Date|Node|Moment|undefined|null} object + * @returns {string} + * @static + */ static getType(object) { var type = typeof object; @@ -159,6 +183,12 @@ class Validator { return type; } + /** + * @param {string} option + * @param {Object} options + * @param {Array.} path + * @static + */ static getSuggestion(option, options, path) { let localSearch = Validator.findInOptions(option,options,path,false); let globalSearch = Validator.findInOptions(option,allOptions,[],true); @@ -166,29 +196,37 @@ class Validator { let localSearchThreshold = 8; let globalSearchThreshold = 4; + let msg; if (localSearch.indexMatch !== undefined) { - console.log('%cUnknown option detected: "' + option + '" in ' + Validator.printLocation(localSearch.path, option,'') + 'Perhaps it was incomplete? Did you mean: "' + localSearch.indexMatch + '"?\n\n', printStyle); + msg = ' in ' + Validator.printLocation(localSearch.path, option,'') + + 'Perhaps it was incomplete? Did you mean: "' + localSearch.indexMatch + '"?\n\n'; } else if (globalSearch.distance <= globalSearchThreshold && localSearch.distance > globalSearch.distance) { - console.log('%cUnknown option detected: "' + option + '" in ' + Validator.printLocation(localSearch.path, option,'') + 'Perhaps it was misplaced? Matching option found at: ' + Validator.printLocation(globalSearch.path, globalSearch.closestMatch,''), printStyle); + msg = ' in ' + Validator.printLocation(localSearch.path, option,'') + + 'Perhaps it was misplaced? Matching option found at: ' + + Validator.printLocation(globalSearch.path, globalSearch.closestMatch,''); } else if (localSearch.distance <= localSearchThreshold) { - console.log('%cUnknown option detected: "' + option + '". Did you mean "' + localSearch.closestMatch + '"?' + Validator.printLocation(localSearch.path, option), printStyle); + msg = '. Did you mean "' + localSearch.closestMatch + '"?' + + Validator.printLocation(localSearch.path, option); } else { - console.log('%cUnknown option detected: "' + option + '". Did you mean one of these: ' + Validator.print(Object.keys(options)) + Validator.printLocation(path, option), printStyle); + msg = '. Did you mean one of these: ' + Validator.print(Object.keys(options)) + + Validator.printLocation(path, option); } + console.log('%cUnknown option detected: "' + option + '"' + msg, printStyle); errorFound = true; } /** * traverse the options in search for a match. - * @param option - * @param options - * @param path - * @param recursive + * @param {string} option + * @param {Object} options + * @param {Array} path | where to look for the actual option + * @param {boolean} [recursive=false] * @returns {{closestMatch: string, path: Array, distance: number}} + * @static */ static findInOptions(option, options, path, recursive = false) { let min = 1e9; @@ -196,7 +234,7 @@ class Validator { let closestMatchPath = []; let lowerCaseOption = option.toLowerCase(); let indexMatch = undefined; - for (let op in options) { + for (let op in options) { // eslint-disable-line guard-for-in let distance; if (options[op].__type__ !== undefined && recursive === true) { let result = Validator.findInOptions(option, options[op], util.copyAndExtendArray(path,op)); @@ -222,6 +260,13 @@ class Validator { return {closestMatch:closestMatch, path:closestMatchPath, distance:min, indexMatch: indexMatch}; } + /** + * @param {Array.} path + * @param {Object} option + * @param {string} prefix + * @returns {String} + * @static + */ static printLocation(path, option, prefix = 'Problem value found at: \n') { let str = '\n\n' + prefix + 'options = {\n'; for (let i = 0; i < path.length; i++) { @@ -243,21 +288,32 @@ class Validator { return str + '\n\n'; } + /** + * @param {Object} options + * @returns {String} + * @static + */ static print(options) { return JSON.stringify(options).replace(/(\")|(\[)|(\])|(,"__type__")/g, "").replace(/(\,)/g, ', ') } - // Compute the edit distance between the two given strings - // http://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#JavaScript - /* - Copyright (c) 2011 Andrei Mackenzie - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + /** + * Compute the edit distance between the two given strings + * http://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#JavaScript + * + * Copyright (c) 2011 Andrei Mackenzie + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @param {string} a + * @param {string} b + * @returns {Array.>}} + * @static */ static levenshteinDistance(a, b) { if (a.length === 0) return b.length; @@ -292,10 +348,8 @@ class Validator { return matrix[b.length][a.length]; } - -; } export default Validator; -export {printStyle} \ No newline at end of file +export {printStyle} diff --git a/lib/network/css/network-tooltip.css b/lib/shared/tooltip.css similarity index 90% rename from lib/network/css/network-tooltip.css rename to lib/shared/tooltip.css index f8ababcff..1c9694aa5 100644 --- a/lib/network/css/network-tooltip.css +++ b/lib/shared/tooltip.css @@ -1,4 +1,4 @@ -div.vis-network-tooltip { +div.vis-tooltip { position: absolute; visibility: hidden; padding: 5px; @@ -16,4 +16,6 @@ div.vis-network-tooltip { box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.2); pointer-events: none; -} \ No newline at end of file + + z-index: 5; +} diff --git a/lib/timeline/.eslintrc b/lib/timeline/.eslintrc new file mode 100644 index 000000000..5990d5f68 --- /dev/null +++ b/lib/timeline/.eslintrc @@ -0,0 +1,39 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true, + "mocha": true + }, + + "parserOptions": { + "sourceType": "module", + }, + + "extends": "eslint:recommended", + + // For the full list of rules, see: http://eslint.org/docs/rules/ + "rules": { + "complexity": [2, 55], + "max-statements": [2, 115], + "no-unreachable": 1, + "no-useless-escape": 0, + "no-console": 0, + "require-jsdoc": ["error", { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": true + } + }], + "valid-jsdoc": [2, { + "requireReturnDescription": false, + "requireReturn": false, + "requireParamDescription": false, + "requireReturnType": true + }], + } + // To flag presence of console.log without breaking linting: + //"no-console": ["warn", { allow: ["warn", "error"] }], +} diff --git a/lib/timeline/Core.js b/lib/timeline/Core.js index b809a0191..134c1580b 100644 --- a/lib/timeline/Core.js +++ b/lib/timeline/Core.js @@ -2,10 +2,6 @@ var Emitter = require('emitter-component'); var Hammer = require('../module/hammer'); var hammerUtil = require('../hammerUtil'); var util = require('../util'); -var DataSet = require('../DataSet'); -var DataView = require('../DataView'); -var Range = require('./Range'); -var ItemSet = require('./component/ItemSet'); var TimeAxis = require('./component/TimeAxis'); var Activator = require('../shared/Activator'); var DateUtil = require('./DateUtil'); @@ -13,7 +9,7 @@ var CustomTime = require('./component/CustomTime'); /** * Create a timeline visualization - * @constructor + * @constructor Core */ function Core () {} @@ -50,6 +46,7 @@ Core.prototype._create = function (container) { this.dom.shadowBottomLeft = document.createElement('div'); this.dom.shadowTopRight = document.createElement('div'); this.dom.shadowBottomRight = document.createElement('div'); + this.dom.rollingModeBtn = document.createElement('div'); this.dom.root.className = 'vis-timeline'; this.dom.background.className = 'vis-panel vis-background'; @@ -69,6 +66,7 @@ Core.prototype._create = function (container) { this.dom.shadowBottomLeft.className = 'vis-shadow vis-bottom'; this.dom.shadowTopRight.className = 'vis-shadow vis-top'; this.dom.shadowBottomRight.className = 'vis-shadow vis-bottom'; + this.dom.rollingModeBtn.className = 'vis-rolling-mode-btn'; this.dom.root.appendChild(this.dom.background); this.dom.root.appendChild(this.dom.backgroundVertical); @@ -78,6 +76,8 @@ Core.prototype._create = function (container) { this.dom.root.appendChild(this.dom.rightContainer); this.dom.root.appendChild(this.dom.top); this.dom.root.appendChild(this.dom.bottom); + this.dom.root.appendChild(this.dom.bottom); + this.dom.root.appendChild(this.dom.rollingModeBtn); this.dom.centerContainer.appendChild(this.dom.center); this.dom.leftContainer.appendChild(this.dom.left); @@ -111,23 +111,23 @@ Core.prototype._create = function (container) { this._redraw(); } }.bind(this)); + this.on('rangechanged', function () { + if (!this.initialRangeChangeDone) { + this.initialRangeChangeDone = true; + } + }.bind(this)); this.on('touch', this._onTouch.bind(this)); this.on('panmove', this._onDrag.bind(this)); var me = this; + this._origRedraw = this._redraw.bind(this); + this._redraw = util.throttle(this._origRedraw); + this.on('_change', function (properties) { - if (properties && properties.queue == true) { - // redraw once on next tick - if (!me._redrawTimer) { - me._redrawTimer = setTimeout(function () { - me._redrawTimer = null; - me._redraw(); - }, 0) - } - } - else { - // redraw immediately - me._redraw(); + if (me.itemSet && me.itemSet.initialItemSetDrawn && properties && properties.queue == true) { + me._redraw() + } else { + me._origRedraw(); } }); @@ -169,42 +169,71 @@ Core.prototype._create = function (container) { me.emit('release', event); }.bind(this)); + /** + * + * @param {WheelEvent} event + */ function onMouseWheel(event) { if (this.isActive()) { this.emit('mousewheel', event); } - // prevent scrolling when zoomKey defined or activated - if (!this.options.zoomKey || event[this.options.zoomKey]) return - - // prevent scrolling vertically when horizontalScroll is true - if (this.options.horizontalScroll) return - - var 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; - } + // deltaX and deltaY normalization from jquery.mousewheel.js + var deltaX = 0; + var deltaY = 0; - var current = this.props.scrollTop; - var adjusted = current + delta * 120; + // Old school scrollwheel delta + if ( 'detail' in event ) { deltaY = event.detail * -1; } + if ( 'wheelDelta' in event ) { deltaY = event.wheelDelta; } + if ( 'wheelDeltaY' in event ) { deltaY = event.wheelDeltaY; } + if ( 'wheelDeltaX' in event ) { deltaX = event.wheelDeltaX * -1; } - if (this.isActive()) { - this._setScrollTop(adjusted); - if (this.options.verticalScroll) { - this.dom.left.parentNode.scrollTop = -adjusted; - this.dom.right.parentNode.scrollTop = -adjusted; - } - this._redraw(); - this.emit('scroll', event); + // Firefox < 17 horizontal scrolling related to DOMMouseScroll event + if ( 'axis' in event && event.axis === event.HORIZONTAL_AXIS ) { + deltaX = deltaY * -1; + deltaY = 0; + } + + // New school wheel delta (wheel event) + if ( 'deltaY' in event ) { + deltaY = event.deltaY * -1; + } + if ( 'deltaX' in event ) { + deltaX = event.deltaX; } + // prevent scrolling when zoomKey defined or activated + if (!this.options.zoomKey || event[this.options.zoomKey]) return; + // Prevent default actions caused by mouse wheel // (else the page and timeline both scroll) event.preventDefault(); + + if (this.options.verticalScroll && Math.abs(deltaY) >= Math.abs(deltaX)) { + var current = this.props.scrollTop; + var adjusted = current + deltaY; + + if (this.isActive()) { + this._setScrollTop(adjusted); + this._redraw(); + this.emit('scroll', event); + } + } else if (this.options.horizontalScroll) { + var delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY; + + // calculate a single scroll jump relative to the range scale + var diff = (delta / 120) * (this.range.end - this.range.start) / 20; + // calculate new start and end + var newStart = this.range.start + diff; + var newEnd = this.range.end + diff; + + var options = { + animation: false, + byUser: true, + event: event + }; + this.range.setRange(newStart, newEnd, options); + } } if (this.dom.centerContainer.addEventListener) { @@ -217,6 +246,10 @@ Core.prototype._create = function (container) { this.dom.centerContainer.attachEvent("onmousewheel", onMouseWheel.bind(this)); } + /** + * + * @param {scroll} event + */ function onMouseScrollSide(event) { if (!me.options.verticalScroll) return; event.preventDefault(); @@ -233,13 +266,18 @@ Core.prototype._create = function (container) { var itemAddedToTimeline = false; + /** + * + * @param {dragover} event + * @returns {boolean} + */ function handleDragOver(event) { if (event.preventDefault) { event.preventDefault(); // Necessary. Allows us to drop. } // make sure your target is a vis element - if (!event.target.className.includes('vis')) return; + if (!event.target.className.indexOf("vis") > -1) return; // make sure only one item is added every time you're over the timeline if (itemAddedToTimeline) return; @@ -249,22 +287,35 @@ Core.prototype._create = function (container) { return false; } + /** + * + * @param {drop} event + * @returns {boolean} + */ function handleDrop(event) { - // return when dropping non-vis items + // prevent redirect to blank page - Firefox + if(event.preventDefault) { event.preventDefault(); } + if(event.stopPropagation) { event.stopPropagation(); } + // return when dropping non-vis items try { - var itemData = JSON.parse(event.dataTransfer.getData("text/plain")) - if (!itemData.content) return + var itemData = JSON.parse(event.dataTransfer.getData("text")) + if (!itemData || !itemData.content) return } catch (err) { return false; } itemAddedToTimeline = false; event.center = { - x: event.x, - y: event.y - } - me.itemSet._onAddItem(event); + x: event.clientX, + y: event.clientY + }; + if (itemData.target !== 'item') { + me.itemSet._onAddItem(event); + } else { + me.itemSet._onDropObjectOnItem(event); + } + me.emit('drop', me.getEventProperties(event)) return false; } @@ -278,6 +329,7 @@ Core.prototype._create = function (container) { this.redrawCount = 0; this.initialDrawDone = false; + this.initialRangeChangeDone = false; // attach the root panel to the provided container if (!container) throw new Error('No container provided'); @@ -290,23 +342,23 @@ Core.prototype._create = function (container) { * {String} orientation * Vertical orientation for the Timeline, * can be 'bottom' (default) or 'top'. - * {String | Number} width + * {string | number} width * Width for the timeline, a number in pixels or * a css string like '1000px' or '75%'. '100%' by default. - * {String | Number} height + * {string | number} height * Fixed height for the Timeline, a number in pixels or * a css string like '400px' or '75%'. If undefined, * The Timeline will automatically size such that * its contents fit. - * {String | Number} minHeight + * {string | number} minHeight * Minimum height for the Timeline, a number in pixels or * a css string like '400px' or '75%'. - * {String | Number} maxHeight + * {string | number} maxHeight * Maximum height for the Timeline, a number in pixels or * a css string like '400px' or '75%'. - * {Number | Date | String} start + * {number | Date | string} start * Start date for the visible window - * {Number | Date | String} end + * {number | Date | string} end * End date for the visible window */ Core.prototype.setOptions = function (options) { @@ -319,6 +371,8 @@ Core.prototype.setOptions = function (options) { ]; util.selectiveExtend(fields, this.options, options); + this.dom.rollingModeBtn.style.visibility = 'hidden'; + if (this.options.rtl) { this.dom.container.style.direction = "rtl"; this.dom.backgroundVertical.className = 'vis-panel vis-background vis-vertical-rtl'; @@ -332,7 +386,9 @@ Core.prototype.setOptions = function (options) { } } - this.options.orientation = {item:undefined,axis:undefined}; + if (typeof this.options.orientation !== 'object') { + this.options.orientation = {item:undefined,axis:undefined}; + } if ('orientation' in options) { if (typeof options.orientation === 'string') { this.options.orientation = { @@ -424,15 +480,7 @@ Core.prototype.setOptions = function (options) { this.configurator.setModuleOptions({global: appliedOptions}); } - // override redraw with a throttled version - if (!this._origRedraw) { - this._origRedraw = this._redraw.bind(this); - this._redraw = util.throttle(this._origRedraw); - } else { - // Not the initial run: redraw everything - this._redraw(); - } - + this._redraw(); }; /** @@ -522,8 +570,9 @@ Core.prototype.getCustomTime = function(id) { /** * Set a custom title for the custom time bar. - * @param {String} [title] Custom title + * @param {string} [title] Custom title * @param {number} [id=undefined] Id of the custom time bar. + * @returns {*} */ Core.prototype.setCustomTimeTitle = function(title, id) { var customTimes = this.customTimes.filter(function (component) { @@ -550,13 +599,13 @@ Core.prototype.getEventProperties = function (event) { /** * Add custom vertical bar - * @param {Date | String | Number} [time] A Date, unix timestamp, or + * @param {Date | string | number} [time] A Date, unix timestamp, or * ISO date string. Time point where * the new bar should be placed. * If not provided, `new Date()` will * be used. - * @param {Number | String} [id=undefined] Id of the new bar. Optional - * @return {Number | String} Returns the id of the new bar + * @param {number | string} [id=undefined] Id of the new bar. Optional + * @return {number | string} Returns the id of the new bar */ Core.prototype.addCustomTime = function (time, id) { var timestamp = time !== undefined @@ -585,7 +634,7 @@ Core.prototype.addCustomTime = function (time, id) { /** * Remove previously added custom bar * @param {int} id ID of the custom bar to be removed - * @return {boolean} True if the bar exists and is removed, false otherwise + * [at]returns {boolean} True if the bar exists and is removed, false otherwise */ Core.prototype.removeCustomTime = function (id) { var customTimes = this.customTimes.filter(function (bar) { @@ -621,8 +670,9 @@ Core.prototype.getVisibleItems = function() { * provided to specify duration and easing function. * Default duration is 500 ms, and default easing * function is 'easeInOutQuad'. + * @param {function} [callback] a callback funtion to be executed at the end of this function */ -Core.prototype.fit = function(options) { +Core.prototype.fit = function(options, callback) { var range = this.getDataRange(); // skip range set if there is no min and max date @@ -635,12 +685,12 @@ Core.prototype.fit = function(options) { var min = new Date(range.min.valueOf() - interval * 0.01); var max = new Date(range.max.valueOf() + interval * 0.01); var animation = (options && options.animation !== undefined) ? options.animation : true; - this.range.setRange(min, max, animation); + this.range.setRange(min, max, { animation: animation }, callback); }; /** * Calculate the data range of the items start and end dates - * @returns {{min: Date | null, max: Date | null}} + * [at]returns {{min: [Date], max: [Date]}} * @protected */ Core.prototype.getDataRange = function() { @@ -659,8 +709,8 @@ Core.prototype.getDataRange = function() { * Where start and end can be a Date, number, or string, and range is an * object with properties start and end. * - * @param {Date | Number | String | Object} [start] Start date of visible window - * @param {Date | Number | String} [end] End date of visible window + * @param {Date | number | string | Object} [start] Start date of visible window + * @param {Date | number | string} [end] End date of visible window * @param {Object} [options] Available options: * `animation: boolean | {duration: number, easingFunction: string}` * If true (default), the range is animated @@ -668,23 +718,35 @@ Core.prototype.getDataRange = function() { * provided to specify duration and easing function. * Default duration is 500 ms, and default easing * function is 'easeInOutQuad'. + * @param {function} [callback] a callback funtion to be executed at the end of this function */ -Core.prototype.setWindow = function(start, end, options) { +Core.prototype.setWindow = function(start, end, options, callback) { + if (typeof arguments[2] == "function") { + callback = arguments[2]; + options = {}; + } var animation; + var range; if (arguments.length == 1) { - var range = arguments[0]; + range = arguments[0]; animation = (range.animation !== undefined) ? range.animation : true; - this.range.setRange(range.start, range.end, animation); + this.range.setRange(range.start, range.end, { animation: animation }); + } + else if (arguments.length == 2 && typeof arguments[1] == "function") { + range = arguments[0]; + callback = arguments[1]; + animation = (range.animation !== undefined) ? range.animation : true; + this.range.setRange(range.start, range.end, { animation: animation }, callback); } else { animation = (options && options.animation !== undefined) ? options.animation : true; - this.range.setRange(start, end, animation); + this.range.setRange(start, end, { animation: animation }, callback); } }; /** * Move the window such that given time is centered on screen. - * @param {Date | Number | String} time + * @param {Date | number | string} time * @param {Object} [options] Available options: * `animation: boolean | {duration: number, easingFunction: string}` * If true (default), the range is animated @@ -692,8 +754,13 @@ Core.prototype.setWindow = function(start, end, options) { * provided to specify duration and easing function. * Default duration is 500 ms, and default easing * function is 'easeInOutQuad'. + * @param {function} [callback] a callback funtion to be executed at the end of this function */ -Core.prototype.moveTo = function(time, options) { +Core.prototype.moveTo = function(time, options, callback) { + if (typeof arguments[1] == "function") { + callback = arguments[1]; + options = {}; + } var interval = this.range.end - this.range.start; var t = util.convert(time, 'Date').valueOf(); @@ -701,7 +768,7 @@ Core.prototype.moveTo = function(time, options) { var end = t + interval / 2; var animation = (options && options.animation !== undefined) ? options.animation : true; - this.range.setRange(start, end, animation); + this.range.setRange(start, end, { animation: animation }, callback); }; /** @@ -718,10 +785,22 @@ Core.prototype.getWindow = function() { /** * Zoom in the window such that given time is centered on screen. - * @param {Number} percentage - must be between [0..1] + * @param {number} percentage - must be between [0..1] + * @param {Object} [options] Available options: + * `animation: boolean | {duration: number, easingFunction: string}` + * If true (default), the range is animated + * smoothly to the new window. An object can be + * provided to specify duration and easing function. + * Default duration is 500 ms, and default easing + * function is 'easeInOutQuad'. + * @param {function} [callback] a callback funtion to be executed at the end of this function */ -Core.prototype.zoomIn = function(percentage) { - if (!percentage || percentage < 0 || percentage > 1) return +Core.prototype.zoomIn = function(percentage, options, callback) { + if (!percentage || percentage < 0 || percentage > 1) return; + if (typeof arguments[1] == "function") { + callback = arguments[1]; + options = {}; + } var range = this.getWindow(); var start = range.start.valueOf(); var end = range.end.valueOf(); @@ -731,18 +810,27 @@ Core.prototype.zoomIn = function(percentage) { var newStart = start + distance; var newEnd = end - distance; - this.setWindow({ - start : newStart, - end : newEnd - }); + this.setWindow(newStart, newEnd, options, callback); }; /** * Zoom out the window such that given time is centered on screen. - * @param {Number} percentage - must be between [0..1] + * @param {number} percentage - must be between [0..1] + * @param {Object} [options] Available options: + * `animation: boolean | {duration: number, easingFunction: string}` + * If true (default), the range is animated + * smoothly to the new window. An object can be + * provided to specify duration and easing function. + * Default duration is 500 ms, and default easing + * function is 'easeInOutQuad'. + * @param {function} [callback] a callback funtion to be executed at the end of this function */ -Core.prototype.zoomOut = function(percentage) { +Core.prototype.zoomOut = function(percentage, options, callback) { if (!percentage || percentage < 0 || percentage > 1) return + if (typeof arguments[1] == "function") { + callback = arguments[1]; + options = {}; + } var range = this.getWindow(); var start = range.start.valueOf(); var end = range.end.valueOf(); @@ -750,10 +838,7 @@ Core.prototype.zoomOut = function(percentage) { var newStart = start - interval * percentage / 2; var newEnd = end + interval * percentage / 2; - this.setWindow({ - start : newStart, - end : newEnd - }); + this.setWindow(newStart, newEnd, options, callback); }; /** @@ -801,8 +886,8 @@ Core.prototype._redraw = function() { props.border.right = props.border.left; props.border.top = (dom.centerContainer.offsetHeight - dom.centerContainer.clientHeight) / 2; props.border.bottom = props.border.top; - var borderRootHeight= dom.root.offsetHeight - dom.root.clientHeight; - var borderRootWidth = dom.root.offsetWidth - dom.root.clientWidth; + props.borderRootHeight= dom.root.offsetHeight - dom.root.clientHeight; + props.borderRootWidth = dom.root.offsetWidth - dom.root.clientWidth; // workaround for a bug in IE: the clientWidth of an element with // a height:0px and overflow:hidden is not calculated and always has value 0 @@ -811,7 +896,7 @@ Core.prototype._redraw = function() { props.border.right = props.border.left; } if (dom.root.clientHeight === 0) { - borderRootWidth = borderRootHeight; + props.borderRootWidth = props.borderRootHeight; } // calculate the heights. If any of the side panels is empty, we set the height to @@ -828,28 +913,28 @@ Core.prototype._redraw = function() { // TODO: only calculate autoHeight when needed (else we cause an extra reflow/repaint of the DOM) var contentHeight = Math.max(props.left.height, props.center.height, props.right.height); var autoHeight = props.top.height + contentHeight + props.bottom.height + - borderRootHeight + props.border.top + props.border.bottom; + props.borderRootHeight + props.border.top + props.border.bottom; dom.root.style.height = util.option.asSize(options.height, autoHeight + 'px'); // calculate heights of the content panels props.root.height = dom.root.offsetHeight; - props.background.height = props.root.height - borderRootHeight; + props.background.height = props.root.height - props.borderRootHeight; var containerHeight = props.root.height - props.top.height - props.bottom.height - - borderRootHeight; + props.borderRootHeight; props.centerContainer.height = containerHeight; props.leftContainer.height = containerHeight; props.rightContainer.height = props.leftContainer.height; // calculate the widths of the panels props.root.width = dom.root.offsetWidth; - props.background.width = props.root.width - borderRootWidth; + props.background.width = props.root.width - props.borderRootWidth; if (!this.initialDrawDone) { props.scrollbarWidth = util.getScrollBarWidth(); } - if (this.options.verticalScroll) { - if (this.options.rtl) { + if (options.verticalScroll) { + if (options.rtl) { props.left.width = dom.leftContainer.clientWidth || -props.border.left; props.right.width = dom.rightContainer.clientWidth + props.scrollbarWidth || -props.border.right; } else { @@ -861,46 +946,7 @@ Core.prototype._redraw = function() { props.right.width = dom.rightContainer.clientWidth || -props.border.right; } - props.leftContainer.width = props.left.width; - props.rightContainer.width = props.right.width; - var centerWidth = props.root.width - props.left.width - props.right.width - borderRootWidth; - props.center.width = centerWidth; - props.centerContainer.width = centerWidth; - props.top.width = centerWidth; - props.bottom.width = centerWidth; - - // resize the panels - dom.background.style.height = props.background.height + 'px'; - dom.backgroundVertical.style.height = props.background.height + 'px'; - dom.backgroundHorizontal.style.height = props.centerContainer.height + 'px'; - dom.centerContainer.style.height = props.centerContainer.height + 'px'; - dom.leftContainer.style.height = props.leftContainer.height + 'px'; - dom.rightContainer.style.height = props.rightContainer.height + 'px'; - - dom.background.style.width = props.background.width + 'px'; - dom.backgroundVertical.style.width = props.centerContainer.width + 'px'; - dom.backgroundHorizontal.style.width = props.background.width + 'px'; - dom.centerContainer.style.width = props.center.width + 'px'; - dom.top.style.width = props.top.width + 'px'; - dom.bottom.style.width = props.bottom.width + 'px'; - - // reposition the panels - dom.background.style.left = '0'; - dom.background.style.top = '0'; - dom.backgroundVertical.style.left = (props.left.width + props.border.left) + 'px'; - dom.backgroundVertical.style.top = '0'; - dom.backgroundHorizontal.style.left = '0'; - dom.backgroundHorizontal.style.top = props.top.height + 'px'; - dom.centerContainer.style.left = props.left.width + 'px'; - dom.centerContainer.style.top = props.top.height + 'px'; - dom.leftContainer.style.left = '0'; - dom.leftContainer.style.top = props.top.height + 'px'; - dom.rightContainer.style.left = (props.left.width + props.center.width) + 'px'; - dom.rightContainer.style.top = props.top.height + 'px'; - dom.top.style.left = props.left.width + 'px'; - dom.top.style.top = '0'; - dom.bottom.style.left = props.left.width + 'px'; - dom.bottom.style.top = (props.top.height + props.centerContainer.height) + 'px'; + this._setDOM(); // update the scrollTop, feasible range for the offset can be changed // when the height of the Core or of the contents of the center changed @@ -908,17 +954,14 @@ Core.prototype._redraw = function() { // reposition the scrollable contents if (options.orientation.item != 'top') { - offset += Math.max(this.props.centerContainer.height - this.props.center.height - - this.props.border.top - this.props.border.bottom, 0); + offset += Math.max(props.centerContainer.height - props.center.height - + props.border.top - props.border.bottom, 0); } - dom.center.style.left = '0'; dom.center.style.top = offset + 'px'; - dom.left.style.left = '0'; - dom.right.style.left = '0'; // show shadows when vertical scrolling is available - var visibilityTop = this.props.scrollTop == 0 ? 'hidden' : ''; - var visibilityBottom = this.props.scrollTop == this.props.scrollTopMin ? 'hidden' : ''; + var visibilityTop = props.scrollTop == 0 ? 'hidden' : ''; + var visibilityBottom = props.scrollTop == props.scrollTopMin ? 'hidden' : ''; dom.shadowTop.style.visibility = visibilityTop; dom.shadowBottom.style.visibility = visibilityBottom; dom.shadowTopLeft.style.visibility = visibilityTop; @@ -926,18 +969,31 @@ Core.prototype._redraw = function() { dom.shadowTopRight.style.visibility = visibilityTop; dom.shadowBottomRight.style.visibility = visibilityBottom; - if (this.options.verticalScroll) { + if (options.verticalScroll) { + dom.rightContainer.className = 'vis-panel vis-right vis-vertical-scroll'; + dom.leftContainer.className = 'vis-panel vis-left vis-vertical-scroll'; + dom.shadowTopRight.style.visibility = "hidden"; dom.shadowBottomRight.style.visibility = "hidden"; dom.shadowTopLeft.style.visibility = "hidden"; dom.shadowBottomLeft.style.visibility = "hidden"; - } else { + + dom.left.style.top = '0px'; + dom.right.style.top = '0px'; + } + + if (!options.verticalScroll || props.center.height < props.centerContainer.height) { dom.left.style.top = offset + 'px'; dom.right.style.top = offset + 'px'; + dom.rightContainer.className = dom.rightContainer.className.replace(new RegExp('(?:^|\\s)'+ 'vis-vertical-scroll' + '(?:\\s|$)'), ' '); + dom.leftContainer.className = dom.leftContainer.className.replace(new RegExp('(?:^|\\s)'+ 'vis-vertical-scroll' + '(?:\\s|$)'), ' '); + props.left.width = dom.leftContainer.clientWidth || -props.border.left; + props.right.width = dom.rightContainer.clientWidth || -props.border.right; + this._setDOM(); } // enable/disable vertical panning - var contentsOverflow = this.props.center.height > this.props.centerContainer.height; + var contentsOverflow = props.center.height > props.centerContainer.height; this.hammer.get('pan').set({ direction: contentsOverflow ? Hammer.DIRECTION_ALL : Hammer.DIRECTION_HORIZONTAL }); @@ -958,13 +1014,60 @@ Core.prototype._redraw = function() { } else { this.redrawCount = 0; } - - this.initialDrawDone = true; //Emit public 'changed' event for UI updates, see issue #1592 this.body.emitter.emit("changed"); }; +Core.prototype._setDOM = function () { + var props = this.props; + var dom = this.dom; + + props.leftContainer.width = props.left.width; + props.rightContainer.width = props.right.width; + var centerWidth = props.root.width - props.left.width - props.right.width - props.borderRootWidth; + props.center.width = centerWidth; + props.centerContainer.width = centerWidth; + props.top.width = centerWidth; + props.bottom.width = centerWidth; + + // resize the panels + dom.background.style.height = props.background.height + 'px'; + dom.backgroundVertical.style.height = props.background.height + 'px'; + dom.backgroundHorizontal.style.height = props.centerContainer.height + 'px'; + dom.centerContainer.style.height = props.centerContainer.height + 'px'; + dom.leftContainer.style.height = props.leftContainer.height + 'px'; + dom.rightContainer.style.height = props.rightContainer.height + 'px'; + + dom.background.style.width = props.background.width + 'px'; + dom.backgroundVertical.style.width = props.centerContainer.width + 'px'; + dom.backgroundHorizontal.style.width = props.background.width + 'px'; + dom.centerContainer.style.width = props.center.width + 'px'; + dom.top.style.width = props.top.width + 'px'; + dom.bottom.style.width = props.bottom.width + 'px'; + + // reposition the panels + dom.background.style.left = '0'; + dom.background.style.top = '0'; + dom.backgroundVertical.style.left = (props.left.width + props.border.left) + 'px'; + dom.backgroundVertical.style.top = '0'; + dom.backgroundHorizontal.style.left = '0'; + dom.backgroundHorizontal.style.top = props.top.height + 'px'; + dom.centerContainer.style.left = props.left.width + 'px'; + dom.centerContainer.style.top = props.top.height + 'px'; + dom.leftContainer.style.left = '0'; + dom.leftContainer.style.top = props.top.height + 'px'; + dom.rightContainer.style.left = (props.left.width + props.center.width) + 'px'; + dom.rightContainer.style.top = props.top.height + 'px'; + dom.top.style.left = props.left.width + 'px'; + dom.top.style.top = '0'; + dom.bottom.style.left = props.left.width + 'px'; + dom.bottom.style.top = (props.top.height + props.centerContainer.height) + 'px'; + dom.center.style.left = '0'; + dom.left.style.left = '0'; + dom.right.style.left = '0'; +}; + // TODO: deprecated since version 1.1.0, remove some day Core.prototype.repaint = function () { throw new Error('Function repaint is deprecated. Use redraw instead.'); @@ -974,7 +1077,7 @@ Core.prototype.repaint = function () { * Set a current time. This can be used for example to ensure that a client's * time is synchronized with a shared server time. * Only applicable when option `showCurrentTime` is true. - * @param {Date | String | Number} time A Date, unix timestamp, or + * @param {Date | string | number} time A Date, unix timestamp, or * ISO date string. */ Core.prototype.setCurrentTime = function(time) { @@ -1091,6 +1194,7 @@ Core.prototype._startAutoResize = function () { (me.dom.root.offsetHeight != me.props.lastHeight)) { me.props.lastWidth = me.dom.root.offsetWidth; me.props.lastHeight = me.dom.root.offsetHeight; + me.props.scrollbarWidth = util.getScrollBarWidth(); me.body.emitter.emit('_change'); } @@ -1131,7 +1235,7 @@ Core.prototype._stopAutoResize = function () { * @param {Event} event * @private */ -Core.prototype._onTouch = function (event) { +Core.prototype._onTouch = function (event) { // eslint-disable-line no-unused-vars this.touch.allowDragging = true; this.touch.initialScrollTop = this.props.scrollTop; }; @@ -1141,7 +1245,7 @@ Core.prototype._onTouch = function (event) { * @param {Event} event * @private */ -Core.prototype._onPinch = function (event) { +Core.prototype._onPinch = function (event) { // eslint-disable-line no-unused-vars this.touch.allowDragging = false; }; @@ -1173,8 +1277,8 @@ Core.prototype._onDrag = function (event) { /** * Apply a scrollTop - * @param {Number} scrollTop - * @returns {Number} scrollTop Returns the applied scrollTop + * @param {number} scrollTop + * @returns {number} scrollTop Returns the applied scrollTop * @private */ Core.prototype._setScrollTop = function (scrollTop) { @@ -1185,7 +1289,7 @@ Core.prototype._setScrollTop = function (scrollTop) { /** * Update the current scrollTop when the height of the containers has been changed - * @returns {Number} scrollTop Returns the applied scrollTop + * @returns {number} scrollTop Returns the applied scrollTop * @private */ Core.prototype._updateScrollTop = function () { @@ -1204,6 +1308,11 @@ Core.prototype._updateScrollTop = function () { if (this.props.scrollTop > 0) this.props.scrollTop = 0; if (this.props.scrollTop < scrollTopMin) this.props.scrollTop = scrollTopMin; + if (this.options.verticalScroll) { + this.dom.left.parentNode.scrollTop = -this.props.scrollTop; + this.dom.right.parentNode.scrollTop = -this.props.scrollTop; + } + return this.props.scrollTop; }; @@ -1218,7 +1327,7 @@ Core.prototype._getScrollTop = function () { /** * Load a configurator - * @return {Object} + * [at]returns {Object} * @private */ Core.prototype._createConfigurator = function () { diff --git a/lib/timeline/DateUtil.js b/lib/timeline/DateUtil.js index 5ff46f41d..61eb00231 100644 --- a/lib/timeline/DateUtil.js +++ b/lib/timeline/DateUtil.js @@ -5,6 +5,7 @@ * @param {function} moment * @param {Object} body * @param {Array | Object} hiddenDates + * @returns {number} */ exports.convertHiddenOptions = function(moment, body, hiddenDates) { if (hiddenDates && !Array.isArray(hiddenDates)) { @@ -32,9 +33,11 @@ exports.convertHiddenOptions = function(moment, body, hiddenDates) { /** * create new entrees for the repeating hidden dates + * * @param {function} moment * @param {Object} body * @param {Array | Object} hiddenDates + * @returns {null} */ exports.updateHiddenDates = function (moment, body, hiddenDates) { if (hiddenDates && !Array.isArray(hiddenDates)) { @@ -83,7 +86,7 @@ exports.updateHiddenDates = function (moment, body, hiddenDates) { runUntil.add(1, 'weeks'); break; case "weekly": - var dayOffset = endDate.diff(startDate,'days') + var dayOffset = endDate.diff(startDate,'days'); var day = startDate.day(); // set the start date to the range.start @@ -101,7 +104,7 @@ exports.updateHiddenDates = function (moment, body, hiddenDates) { endDate.subtract(1,'weeks'); runUntil.add(1, 'weeks'); - break + break; case "monthly": if (startDate.month() != endDate.month()) { offset = 1; @@ -175,13 +178,14 @@ exports.updateHiddenDates = function (moment, body, hiddenDates) { } } -} +}; /** * remove duplicates from the hidden dates list. Duplicates are evil. They mess everything up. * Scales with N^2 - * @param body + * + * @param {Object} body */ exports.removeDuplicates = function(body) { var hiddenDates = body.hiddenDates; @@ -207,7 +211,7 @@ exports.removeDuplicates = function(body) { } } - for (var i = 0; i < hiddenDates.length; i++) { + for (i = 0; i < hiddenDates.length; i++) { if (hiddenDates[i].remove !== true) { safeDates.push(hiddenDates[i]); } @@ -229,7 +233,7 @@ exports.printDates = function(dates) { * Used in TimeStep to avoid the hidden times. * @param {function} moment * @param {TimeStep} timeStep - * @param previousTime + * @param {Date} previousTime */ exports.stepOverHiddenDates = function(moment, timeStep, previousTime) { var stepInHidden = false; @@ -281,14 +285,16 @@ exports.stepOverHiddenDates = function(moment, timeStep, previousTime) { /** * replaces the Core toScreen methods - * @param Core - * @param time - * @param width + * + * @param {vis.Core} Core + * @param {Date} time + * @param {number} width * @returns {number} */ exports.toScreen = function (Core, time, width) { - if (Core.body.hiddenDates.length == 0) { - var conversion = Core.range.conversion(width); + var conversion; + if (Core.body.hiddenDates.length == 0) { + conversion = Core.range.conversion(width); return (time.valueOf() - conversion.offset) * conversion.scale; } else { var hidden = exports.isHidden(time, Core.body.hiddenDates); @@ -298,7 +304,7 @@ exports.toScreen = function (Core, time, width) { var duration = exports.getHiddenDurationBetween(Core.body.hiddenDates, Core.range.start, Core.range.end); if (time < Core.range.start) { - var conversion = Core.range.conversion(width, duration); + conversion = Core.range.conversion(width, duration); var hiddenBeforeStart = exports.getHiddenDurationBeforeStart(Core.body.hiddenDates, time, conversion.offset); time = Core.options.moment(time).toDate().valueOf(); time = time + hiddenBeforeStart; @@ -307,12 +313,12 @@ exports.toScreen = function (Core, time, width) { } else if (time > Core.range.end) { var rangeAfterEnd = {start: Core.range.start, end: time}; time = exports.correctTimeForHidden(Core.options.moment, Core.body.hiddenDates, rangeAfterEnd, time); - var conversion = Core.range.conversion(width, duration); + conversion = Core.range.conversion(width, duration); return (time.valueOf() - conversion.offset) * conversion.scale; } else { time = exports.correctTimeForHidden(Core.options.moment, Core.body.hiddenDates, Core.range, time); - var conversion = Core.range.conversion(width, duration); + conversion = Core.range.conversion(width, duration); return (time.valueOf() - conversion.offset) * conversion.scale; } } @@ -321,10 +327,10 @@ exports.toScreen = function (Core, time, width) { /** * Replaces the core toTime methods - * @param body - * @param range - * @param x - * @param width + * + * @param {vis.Core} Core + * @param {number} x + * @param {number} width * @returns {Date} */ exports.toTime = function(Core, x, width) { @@ -338,8 +344,7 @@ exports.toTime = function(Core, x, width) { var partialDuration = totalDuration * x / width; var accumulatedHiddenDuration = exports.getAccumulatedHiddenDuration(Core.body.hiddenDates, Core.range, partialDuration); - var newTime = new Date(accumulatedHiddenDuration + partialDuration + Core.range.start); - return newTime; + return new Date(accumulatedHiddenDuration + partialDuration + Core.range.start); } }; @@ -347,8 +352,9 @@ exports.toTime = function(Core, x, width) { /** * Support function * - * @param hiddenDates - * @param range + * @param {Array.<{start: Window.start, end: *}>} hiddenDates + * @param {number} start + * @param {number} end * @returns {number} */ exports.getHiddenDurationBetween = function(hiddenDates, start, end) { @@ -365,13 +371,13 @@ exports.getHiddenDurationBetween = function(hiddenDates, start, end) { }; /** - * Support function - * - * @param hiddenDates - * @param start - * @param end - * @returns {number} - */ + * Support function + * + * @param {Array.<{start: Window.start, end: *}>} hiddenDates + * @param {number} start + * @param {number} end + * @returns {number} + */ exports.getHiddenDurationBeforeStart = function (hiddenDates, start, end) { var duration = 0; for (var i = 0; i < hiddenDates.length; i++) { @@ -388,11 +394,11 @@ exports.getHiddenDurationBeforeStart = function (hiddenDates, start, end) { /** * Support function - * @param moment - * @param hiddenDates - * @param range - * @param time - * @returns {{duration: number, time: *, offset: number}} + * @param {function} moment + * @param {Array.<{start: Window.start, end: *}>} hiddenDates + * @param {{start: number, end: number}} range + * @param {Date} time + * @returns {number} */ exports.correctTimeForHidden = function(moment, hiddenDates, range, time) { time = moment(time).toDate().valueOf(); @@ -415,15 +421,15 @@ exports.getHiddenDurationBefore = function(moment, hiddenDates, range, time) { } } return timeOffset; -} +}; /** * sum the duration from start to finish, including the hidden duration, * until the required amount has been reached, return the accumulated hidden duration - * @param hiddenDates - * @param range - * @param time - * @returns {{duration: number, time: *, offset: number}} + * @param {Array.<{start: Window.start, end: *}>} hiddenDates + * @param {{start: number, end: number}} range + * @param {number} [requiredDuration=0] + * @returns {number} */ exports.getAccumulatedHiddenDuration = function(hiddenDates, range, requiredDuration) { var hiddenDuration = 0; @@ -453,11 +459,11 @@ exports.getAccumulatedHiddenDuration = function(hiddenDates, range, requiredDura /** * used to step over to either side of a hidden block. Correction is disabled on tablets, might be set to true - * @param hiddenDates - * @param time - * @param direction - * @param correctionEnabled - * @returns {*} + * @param {Array.<{start: Window.start, end: *}>} hiddenDates + * @param {Date} time + * @param {number} direction + * @param {boolean} correctionEnabled + * @returns {Date|number} */ exports.snapAwayFromHidden = function(hiddenDates, time, direction, correctionEnabled) { var isHidden = exports.isHidden(time, hiddenDates); @@ -483,14 +489,14 @@ exports.snapAwayFromHidden = function(hiddenDates, time, direction, correctionEn return time; } -} +}; /** * Check if a time is hidden * - * @param time - * @param hiddenDates + * @param {Date} time + * @param {Array.<{start: Window.start, end: *}>} hiddenDates * @returns {{hidden: boolean, startDate: Window.start, endDate: *}} */ exports.isHidden = function(time, hiddenDates) { @@ -500,8 +506,7 @@ exports.isHidden = function(time, hiddenDates) { if (time >= startDate && time < endDate) { // if the start is entering a hidden zone return {hidden: true, startDate: startDate, endDate: endDate}; - break; } } return {hidden: false, startDate: startDate, endDate: endDate}; -} +}; diff --git a/lib/timeline/Graph2d.js b/lib/timeline/Graph2d.js index cd350ec3a..198f86a71 100644 --- a/lib/timeline/Graph2d.js +++ b/lib/timeline/Graph2d.js @@ -1,5 +1,3 @@ -var Emitter = require('emitter-component'); -var Hammer = require('../module/hammer'); var moment = require('../module/moment'); var util = require('../util'); var DataSet = require('../DataSet'); @@ -15,15 +13,16 @@ var printStyle = require('../shared/Validator').printStyle; var allOptions = require('./optionsGraph2d').allOptions; var configureOptions = require('./optionsGraph2d').configureOptions; -import Configurator from '../shared/Configurator'; -import Validator from '../shared/Validator'; +var Configurator = require('../shared/Configurator').default; +var Validator = require('../shared/Validator').default; /** * Create a timeline visualization * @param {HTMLElement} container * @param {vis.DataSet | Array} [items] + * @param {vis.DataSet | Array | vis.DataView | Object} [groups] * @param {Object} [options] See Graph2d.setOptions for the available options. - * @constructor + * @constructor Graph2d * @extends Core */ function Graph2d (container, items, groups, options) { @@ -34,6 +33,12 @@ function Graph2d (container, items, groups, options) { groups = forthArgument; } + // TODO: REMOVE THIS in the next MAJOR release + // see https://github.com/almende/vis/issues/2511 + if (options && options.throttleRedraw) { + console.warn("Graph2d option \"throttleRedraw\" is DEPRICATED and no longer supported. It will be removed in the next MAJOR release."); + } + var me = this; this.defaultOptions = { start: null, @@ -208,9 +213,10 @@ Graph2d.prototype.setGroups = function(groups) { /** * Returns an object containing an SVG element with the icon of the group (size determined by iconWidth and iconHeight), the label of the group (content) and the yAxisOrientation of the group (left or right). - * @param groupId - * @param width - * @param height + * @param {vis.GraphGroup.id} groupId + * @param {number} width + * @param {number} height + * @returns {{icon: SVGElement, label: string, orientation: string}|string} */ Graph2d.prototype.getLegend = function(groupId, width, height) { if (width === undefined) {width = 15;} @@ -225,8 +231,8 @@ Graph2d.prototype.getLegend = function(groupId, width, height) { /** * This checks if the visible option of the supplied group (by ID) is true or false. - * @param groupId - * @returns {*} + * @param {vis.GraphGroup.id} groupId + * @returns {boolean} */ Graph2d.prototype.isGroupVisible = function(groupId) { if (this.linegraph.groups[groupId] !== undefined) { @@ -299,10 +305,10 @@ Graph2d.prototype.getEventProperties = function (event) { var value = []; var yAxisLeft = this.linegraph.yAxisLeft; var yAxisRight = this.linegraph.yAxisRight; - if (!yAxisLeft.hidden) { + if (!yAxisLeft.hidden && this.itemsData.length > 0) { value.push(yAxisLeft.screenToValue(y)); } - if (!yAxisRight.hidden) { + if (!yAxisRight.hidden && this.itemsData.length > 0) { value.push(yAxisRight.screenToValue(y)); } diff --git a/lib/timeline/Range.js b/lib/timeline/Range.js index 74ddb639e..a5a1c3b23 100644 --- a/lib/timeline/Range.js +++ b/lib/timeline/Range.js @@ -1,21 +1,32 @@ var util = require('../util'); -var hammerUtil = require('../hammerUtil'); var moment = require('../module/moment'); var Component = require('./component/Component'); var DateUtil = require('./DateUtil'); /** - * @constructor Range * A Range controls a numeric range with a start and end value. * The Range adjusts the range based on mouse events or programmatic changes, * and triggers events when the range is changing or has been changed. * @param {{dom: Object, domProps: Object, emitter: Emitter}} body * @param {Object} [options] See description at Range.setOptions + * @constructor Range + * @extends Component */ function Range(body, options) { var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0); - this.start = now.clone().add(-3, 'days').valueOf(); // Number - this.end = now.clone().add(4, 'days').valueOf(); // Number + var start = now.clone().add(-3, 'days').valueOf(); + var end = now.clone().add(3, 'days').valueOf(); + this.millisecondsPerPixelCache = undefined; + + if(options === undefined) { + this.start = start; + this.end = end; + } else { + this.start = options.start || start; + this.end = options.end || end + } + + this.rolling = false; this.body = body; this.deltaDifference = 0; @@ -35,7 +46,11 @@ function Range(body, options) { min: null, max: null, zoomMin: 10, // milliseconds - zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000 // milliseconds + zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds + rollingMode: { + follow: false, + offset: 0.5 + } }; this.options = util.extend({}, this.defaultOptions); this.props = { @@ -55,6 +70,9 @@ function Range(body, options) { this.body.emitter.on('touch', this._onTouch.bind(this)); this.body.emitter.on('pinch', this._onPinch.bind(this)); + // on click of rolling mode button + this.body.dom.rollingModeBtn.addEventListener('click', this.startRolling.bind(this)); + this.setOptions(options); } @@ -63,28 +81,31 @@ Range.prototype = new Component(); /** * Set options for the range controller * @param {Object} options Available options: - * {Number | Date | String} start Start date for the range - * {Number | Date | String} end End date for the range - * {Number} min Minimum value for start - * {Number} max Maximum value for end - * {Number} zoomMin Set a minimum value for + * {number | Date | String} start Start date for the range + * {number | Date | String} end End date for the range + * {number} min Minimum value for start + * {number} max Maximum value for end + * {number} zoomMin Set a minimum value for * (end - start). - * {Number} zoomMax Set a maximum value for + * {number} zoomMax Set a maximum value for * (end - start). - * {Boolean} moveable Enable moving of the range + * {boolean} moveable Enable moving of the range * by dragging. True by default - * {Boolean} zoomable Enable zooming of the range + * {boolean} zoomable Enable zooming of the range * by pinching/scrolling. True by default */ Range.prototype.setOptions = function (options) { if (options) { // copy the options that we know var fields = [ - 'direction', 'min', 'max', 'zoomMin', 'zoomMax', 'moveable', 'zoomable', - 'moment', 'activate', 'hiddenDates', 'zoomKey', 'rtl', 'horizontalScroll' + 'animation', 'direction', 'min', 'max', 'zoomMin', 'zoomMax', 'moveable', 'zoomable', + 'moment', 'activate', 'hiddenDates', 'zoomKey', 'rtl', 'showCurrentTime', 'rollingMode', 'horizontalScroll' ]; util.selectiveExtend(fields, this.options, options); + if (options.rollingMode && options.rollingMode.follow) { + this.startRolling(); + } if ('start' in options || 'end' in options) { // apply a new range. both start and end are optional this.setRange(options.start, options.end); @@ -94,7 +115,7 @@ Range.prototype.setOptions = function (options) { /** * Test whether direction has a valid value - * @param {String} direction 'horizontal' or 'vertical' + * @param {string} direction 'horizontal' or 'vertical' */ function validateDirection (direction) { if (direction != 'horizontal' && direction != 'vertical') { @@ -103,33 +124,95 @@ function validateDirection (direction) { } } +/** + * Start auto refreshing the current time bar + */ +Range.prototype.startRolling = function() { + var me = this; + + /** + * Updates the current time. + */ + function update () { + me.stopRolling(); + me.rolling = true; + + + var interval = me.end - me.start; + var t = util.convert(new Date(), 'Date').valueOf(); + + var start = t - interval * (me.options.rollingMode.offset); + var end = t + interval * (1 - me.options.rollingMode.offset); + + var options = { + animation: false + }; + me.setRange(start, end, options); + + // determine interval to refresh + var scale = me.conversion(me.body.domProps.center.width).scale; + interval = 1 / scale / 10; + if (interval < 30) interval = 30; + if (interval > 1000) interval = 1000; + + me.body.dom.rollingModeBtn.style.visibility = "hidden"; + // start a renderTimer to adjust for the new time + me.currentTimeTimer = setTimeout(update, interval); + } + + update(); +}; + +/** + * Stop auto refreshing the current time bar + */ +Range.prototype.stopRolling = function() { + if (this.currentTimeTimer !== undefined) { + clearTimeout(this.currentTimeTimer); + this.rolling = false; + this.body.dom.rollingModeBtn.style.visibility = "visible"; + } +}; + /** * Set a new start and end range - * @param {Date | Number | String} [start] - * @param {Date | Number | String} [end] - * @param {boolean | {duration: number, easingFunction: string}} [animation=false] - * If true (default), the range is animated + * @param {Date | number | string} [start] + * @param {Date | number | string} [end] + * @param {Object} options Available options: + * {boolean | {duration: number, easingFunction: string}} [animation=false] + * If true, the range is animated * smoothly to the new window. An object can be * provided to specify duration and easing function. * Default duration is 500 ms, and default easing * function is 'easeInOutQuad'. - * @param {Boolean} [byUser=false] - * + * {boolean} [byUser=false] + * {Event} event Mouse event + * @param {Function} callback a callback function to be executed at the end of this function + * @param {Function} frameCallback a callback function executed each frame of the range animation. + * The callback will be passed three parameters: + * {number} easeCoefficient an easing coefficent + * {boolean} willDraw If true the caller will redraw after the callback completes + * {boolean} done If true then animation is ending after the current frame */ -Range.prototype.setRange = function(start, end, animation, byUser) { - if (byUser !== true) { - byUser = false; + +Range.prototype.setRange = function(start, end, options, callback, frameCallback) { + if (!options) { + options = {}; } + if (options.byUser !== true) { + options.byUser = false; + } + var me = this; var finalStart = start != undefined ? util.convert(start, 'Date').valueOf() : null; var finalEnd = end != undefined ? util.convert(end, 'Date').valueOf() : null; this._cancelAnimation(); + this.millisecondsPerPixelCache = undefined; - if (animation) { // true or an Object - var me = this; + if (options.animation) { // true or an Object var initStart = this.start; var initEnd = this.end; - var duration = (typeof animation === 'object' && 'duration' in animation) ? animation.duration : 500; - var easingName = (typeof animation === 'object' && 'easingFunction' in animation) ? animation.easingFunction : 'easeInOutQuad'; + var duration = (typeof options.animation === 'object' && 'duration' in options.animation) ? options.animation.duration : 500; + var easingName = (typeof options.animation === 'object' && 'easingFunction' in options.animation) ? options.animation.easingFunction : 'easeInOutQuad'; var easingFunction = util.easingFunctions[easingName]; if (!easingFunction) { throw new Error('Unknown easing function ' + JSON.stringify(easingName) + '. ' + @@ -151,13 +234,24 @@ Range.prototype.setRange = function(start, end, animation, byUser) { changed = me._applyRange(s, e); DateUtil.updateHiddenDates(me.options.moment, me.body, me.options.hiddenDates); anyChanged = anyChanged || changed; - if (changed) { - me.body.emitter.emit('rangechange', {start: new Date(me.start), end: new Date(me.end), byUser:byUser}); + + var params = { + start: new Date(me.start), + end: new Date(me.end), + byUser: options.byUser, + event: options.event + }; + + if (frameCallback) { frameCallback(ease, changed, done); } + + if (changed) { + me.body.emitter.emit('rangechange', params); } if (done) { if (anyChanged) { - me.body.emitter.emit('rangechanged', {start: new Date(me.start), end: new Date(me.end), byUser:byUser}); + me.body.emitter.emit('rangechanged', params); + if (callback) { return callback() } } } else { @@ -174,13 +268,35 @@ Range.prototype.setRange = function(start, end, animation, byUser) { var changed = this._applyRange(finalStart, finalEnd); DateUtil.updateHiddenDates(this.options.moment, this.body, this.options.hiddenDates); if (changed) { - var params = {start: new Date(this.start), end: new Date(this.end), byUser:byUser}; + var params = { + start: new Date(this.start), + end: new Date(this.end), + byUser: options.byUser, + event: options.event + }; + this.body.emitter.emit('rangechange', params); - this.body.emitter.emit('rangechanged', params); + clearTimeout( me.timeoutID ); + me.timeoutID = setTimeout( function () { + me.body.emitter.emit('rangechanged', params); + }, 200 ); + if (callback) { return callback() } } } }; +/** + * Get the number of milliseconds per pixel. + * + * @returns {undefined|number} + */ +Range.prototype.getMillisecondsPerPixel = function() { + if (this.millisecondsPerPixelCache === undefined) { + this.millisecondsPerPixelCache = (this.end - this.start) / this.body.dom.center.clientWidth; + } + return this.millisecondsPerPixelCache; +}; + /** * Stop an animation * @private @@ -196,9 +312,9 @@ Range.prototype._cancelAnimation = function () { * Set a new start and end range. This method is the same as setRange, but * does not trigger a range change and range changed event, and it returns * true when the range is changed - * @param {Number} [start] - * @param {Number} [end] - * @return {Boolean} changed + * @param {number} [start] + * @param {number} [end] + * @return {boolean} changed * @private */ Range.prototype._applyRange = function(start, end) { @@ -216,7 +332,7 @@ Range.prototype._applyRange = function(start, end) { throw new Error('Invalid end "' + end + '"'); } - // prevent start < end + // prevent end < start if (newEnd < newStart) { newEnd = newStart; } @@ -260,7 +376,9 @@ Range.prototype._applyRange = function(start, end) { zoomMin = 0; } if ((newEnd - newStart) < zoomMin) { - if ((this.end - this.start) === zoomMin && newStart > this.start && newEnd < this.end) { + // compensate for a scale of 0.5 ms + var compensation = 0.5; + if ((this.end - this.start) === zoomMin && newStart >= this.start - compensation && newEnd <= this.end) { // ignore this action, we are already zoomed to the minimum newStart = this.start; newEnd = this.end; @@ -323,7 +441,8 @@ Range.prototype.getRange = function() { /** * Calculate the conversion offset and scale for current range, based on * the provided width - * @param {Number} width + * @param {number} width + * @param {number} [totalHidden=0] * @returns {{offset: number, scale: number}} conversion */ Range.prototype.conversion = function (width, totalHidden) { @@ -333,9 +452,10 @@ Range.prototype.conversion = function (width, totalHidden) { /** * Static method to calculate the conversion offset and scale for a range, * based on the provided start, end, and width - * @param {Number} start - * @param {Number} end - * @param {Number} width + * @param {number} start + * @param {number} end + * @param {number} width + * @param {number} [totalHidden=0] * @returns {{offset: number, scale: number}} conversion */ Range.conversion = function (start, end, width, totalHidden) { @@ -375,6 +495,8 @@ Range.prototype._onDragStart = function(event) { // when releasing the fingers in opposite order from the touch screen if (!this.props.touch.allowDragging) return; + this.stopRolling(); + this.props.touch.start = this.start; this.props.touch.end = this.end; this.props.touch.dragging = true; @@ -390,7 +512,7 @@ Range.prototype._onDragStart = function(event) { * @private */ Range.prototype._onDrag = function (event) { - if (!event) return + if (!event) return; if (!this.props.touch.dragging) return; @@ -413,11 +535,11 @@ Range.prototype._onDrag = function (event) { interval -= duration; var width = (direction == 'horizontal') ? this.body.domProps.center.width : this.body.domProps.center.height; - + var diffRange; if (this.options.rtl) { - var diffRange = delta / width * interval; + diffRange = delta / width * interval; } else { - var diffRange = -delta / width * interval; + diffRange = -delta / width * interval; } var newStart = this.props.touch.start + diffRange; @@ -445,7 +567,8 @@ Range.prototype._onDrag = function (event) { this.body.emitter.emit('rangechange', { start: startDate, end: endDate, - byUser: true + byUser: true, + event: event }); // fire a panmove event @@ -477,7 +600,8 @@ Range.prototype._onDragEnd = function (event) { this.body.emitter.emit('rangechanged', { start: new Date(this.start), end: new Date(this.end), - byUser: true + byUser: true, + event: event }); }; @@ -488,7 +612,6 @@ Range.prototype._onDragEnd = function (event) { * @private */ Range.prototype._onMouseWheel = function(event) { - // retrieve delta var delta = 0; if (event.wheelDelta) { /* IE/Opera. */ @@ -502,15 +625,6 @@ Range.prototype._onMouseWheel = function(event) { // don't allow zoom when the according key is pressed and the zoomKey option or not zoomable but movable if ((this.options.zoomKey && !event[this.options.zoomKey] && this.options.zoomable) || (!this.options.zoomable && this.options.moveable)) { - if (this.options.horizontalScroll) { - // calculate a single scroll jump relative to the range scale - var diff = delta * (this.end - this.start) / 20; - // calculate new start and end - var newStart = this.start - diff; - var newEnd = this.end - diff; - - this.setRange(newStart, newEnd); - } return; } @@ -537,11 +651,14 @@ Range.prototype._onMouseWheel = function(event) { } // calculate center, the date to zoom around - var pointer = this.getPointer({x: event.clientX, y: event.clientY}, this.body.dom.center); - var pointerDate = this._pointerToDate(pointer); - - this.zoom(scale, pointerDate, delta); - + var pointerDate; + if (this.rolling) { + pointerDate = this.start + ((this.end - this.start) * this.options.rollingMode.offset); + } else { + var pointer = this.getPointer({x: event.clientX, y: event.clientY}, this.body.dom.center); + pointerDate = this._pointerToDate(pointer); + } + this.zoom(scale, pointerDate, delta, event); if(this.maxRangeReached) return; // Prevent default actions caused by mouse wheel @@ -552,15 +669,18 @@ Range.prototype._onMouseWheel = function(event) { /** * Start of a touch gesture + * @param {Event} event * @private */ -Range.prototype._onTouch = function (event) { +Range.prototype._onTouch = function (event) { // eslint-disable-line no-unused-vars this.props.touch.start = this.start; this.props.touch.end = this.end; this.props.touch.allowDragging = true; this.props.touch.center = null; this.scaleOffset = 0; this.deltaDifference = 0; + // Disable the browser default handling of this event. + util.preventDefault(event); }; /** @@ -572,12 +692,17 @@ Range.prototype._onPinch = function (event) { // only allow zooming when configured as zoomable and moveable if (!(this.options.zoomable && this.options.moveable)) return; + // Disable the browser default handling of this event. + util.preventDefault(event); + this.props.touch.allowDragging = false; if (!this.props.touch.center) { this.props.touch.center = this.getPointer(event.center, this.body.dom.center); } + this.stopRolling(); + var scale = 1 / (event.scale + this.scaleOffset); var centerDate = this._pointerToDate(this.props.touch.center); @@ -603,7 +728,12 @@ Range.prototype._onPinch = function (event) { newEnd = safeEnd; } - this.setRange(newStart, newEnd, false, true); + var options = { + animation: false, + byUser: true, + event: event + }; + this.setRange(newStart, newEnd, options); this.startToFront = false; // revert to default this.endToFront = true; // revert to default @@ -620,10 +750,11 @@ Range.prototype._isInsideRange = function(event) { // calculate the time where the mouse is, check whether inside // and no scroll action should happen. var clientX = event.center ? event.center.x : event.clientX; + var x; if (this.options.rtl) { - var x = clientX - util.getAbsoluteLeft(this.body.dom.centerContainer); + x = clientX - util.getAbsoluteLeft(this.body.dom.centerContainer); } else { - var x = util.getAbsoluteRight(this.body.dom.centerContainer) - clientX; + x = util.getAbsoluteRight(this.body.dom.centerContainer) - clientX; } var time = this.body.util.toTime(x); @@ -632,7 +763,7 @@ Range.prototype._isInsideRange = function(event) { /** * Helper function to calculate the center date for zooming - * @param {{x: Number, y: Number}} pointer + * @param {{x: number, y: number}} pointer * @return {number} date * @private */ @@ -654,9 +785,9 @@ Range.prototype._pointerToDate = function (pointer) { /** * Get the pointer location relative to the location of the dom element - * @param {{x: Number, y: Number}} touch + * @param {{x: number, y: number}} touch * @param {Element} element HTML DOM element - * @return {{x: Number, y: Number}} pointer + * @return {{x: number, y: number}} pointer * @private */ Range.prototype.getPointer = function (touch, element) { @@ -671,19 +802,21 @@ Range.prototype.getPointer = function (touch, element) { y: touch.y - util.getAbsoluteTop(element) }; } -} +}; /** * Zoom the range the given scale in or out. Start and end date will * be adjusted, and the timeline will be redrawn. You can optionally give a * date around which to zoom. * For example, try scale = 0.9 or 1.1 - * @param {Number} scale Scaling factor. Values above 1 will zoom out, + * @param {number} scale Scaling factor. Values above 1 will zoom out, * values below 1 will zoom in. - * @param {Number} [center] Value representing a date around which will + * @param {number} [center] Value representing a date around which will * be zoomed. + * @param {number} delta + * @param {Event} event */ -Range.prototype.zoom = function(scale, center, delta) { +Range.prototype.zoom = function(scale, center, delta, event) { // if centerDate is not provided, take it half between start Date and end Date if (center == null) { center = (this.start + this.end) / 2; @@ -707,7 +840,12 @@ Range.prototype.zoom = function(scale, center, delta) { newEnd = safeEnd; } - this.setRange(newStart, newEnd, false, true); + var options = { + animation: false, + byUser: true, + event: event + }; + this.setRange(newStart, newEnd, options); this.startToFront = false; // revert to default this.endToFront = true; // revert to default @@ -726,7 +864,7 @@ Range.prototype.zoom = function(scale, center, delta) { /** * Move the range with a given delta to the left or right. Start and end * value will be adjusted. For example, try delta = 0.1 or -0.1 - * @param {Number} delta Moving amount. Positive value will move right, + * @param {number} delta Moving amount. Positive value will move right, * negative value will move left */ Range.prototype.move = function(delta) { @@ -745,7 +883,7 @@ Range.prototype.move = function(delta) { /** * Move the range to a new center point - * @param {Number} moveTo New center point of the range + * @param {number} moveTo New center point of the range */ Range.prototype.moveTo = function(moveTo) { var center = (this.start + this.end) / 2; @@ -756,7 +894,12 @@ Range.prototype.moveTo = function(moveTo) { var newStart = this.start - diff; var newEnd = this.end - diff; - this.setRange(newStart, newEnd); + var options = { + animation: false, + byUser: true, + event: null + }; + this.setRange(newStart, newEnd, options); }; module.exports = Range; diff --git a/lib/timeline/Stack.js b/lib/timeline/Stack.js index fa8a47e90..b25179b46 100644 --- a/lib/timeline/Stack.js +++ b/lib/timeline/Stack.js @@ -37,16 +37,15 @@ exports.orderByEnd = function(items) { * items having a top===null will be re-stacked */ exports.stack = function(items, margin, force) { - var i, iMax; if (force) { // reset top position of all items - for (i = 0, iMax = items.length; i < iMax; i++) { + for (var i = 0; i < items.length; i++) { items[i].top = null; } } // calculate new, non-overlapping positions - for (i = 0, iMax = items.length; i < iMax; i++) { + for (var i = 0; i < items.length; i++) { // eslint-disable-line no-redeclare var item = items[i]; if (item.stack && item.top === null) { // initialize top position @@ -73,6 +72,59 @@ exports.stack = function(items, margin, force) { } }; +/** + * Adjust vertical positions of the items within a single subgroup such that they + * don't overlap each other. + * @param {Item[]} items + * All items withina subgroup + * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin + * Margins between items and between items and the axis. + * @param {subgroup} subgroup + * The subgroup that is being stacked + */ +exports.substack = function (items, margin, subgroup) { + for (var i = 0; i < items.length; i++) { + items[i].top = null; + } + + // Set the initial height + var subgroupHeight = subgroup.height; + + // calculate new, non-overlapping positions + for (i = 0; i < items.length; i++) { + var item = items[i]; + + if (item.stack && item.top === null) { + // initialize top position + item.top = item.baseTop;//margin.axis + item.baseTop; + + do { + // TODO: optimize checking for overlap. when there is a gap without items, + // you only need to check for items from the next item on, not from zero + var collidingItem = null; + for (var j = 0, jj = items.length; j < jj; j++) { + var other = items[j]; + if (other.top !== null && other !== item /*&& other.stack*/ && exports.collision(item, other, margin.item, other.options.rtl)) { + collidingItem = other; + break; + } + } + + if (collidingItem != null) { + // There is a collision. Reposition the items above the colliding element + item.top = collidingItem.top + collidingItem.height + margin.item.vertical;// + item.baseTop; + } + + if (item.top + item.height > subgroupHeight) { + subgroupHeight = item.top + item.height; + } + } while (collidingItem); + } + } + + // Set the new height + subgroup.height = subgroupHeight - subgroup.top + 0.5 * margin.item.vertical; +}; /** * Adjust vertical positions of the items without stacking them @@ -80,27 +132,125 @@ exports.stack = function(items, margin, force) { * All visible items * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin * Margins between items and between items and the axis. + * @param {subgroups[]} subgroups + * All subgroups + * @param {boolean} stackSubgroups */ -exports.nostack = function(items, margin, subgroups) { - var i, iMax, newTop; - - // reset top position of all items - for (i = 0, iMax = items.length; i < iMax; i++) { - if (items[i].data.subgroup !== undefined) { - newTop = margin.axis; +exports.nostack = function(items, margin, subgroups, stackSubgroups) { + for (var i = 0; i < items.length; i++) { + if (items[i].data.subgroup == undefined) { + items[i].top = margin.item.vertical; + } else if (items[i].data.subgroup !== undefined && stackSubgroups) { + var newTop = 0; for (var subgroup in subgroups) { if (subgroups.hasOwnProperty(subgroup)) { if (subgroups[subgroup].visible == true && subgroups[subgroup].index < subgroups[items[i].data.subgroup].index) { - newTop += subgroups[subgroup].height + margin.item.vertical; + newTop += subgroups[subgroup].height; + subgroups[items[i].data.subgroup].top = newTop; } } } - items[i].top = newTop; + items[i].top = newTop + 0.5 * margin.item.vertical; + } + } + if (!stackSubgroups) { + exports.stackSubgroups(items, margin, subgroups) + } +}; + +/** + * Adjust vertical positions of the subgroups such that they don't overlap each + * other. + * @param {Array.} items + * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin Margins between items and between items and the axis. + * @param {subgroups[]} subgroups + * All subgroups + */ +exports.stackSubgroups = function(items, margin, subgroups) { + for (var subgroup in subgroups) { + if (subgroups.hasOwnProperty(subgroup)) { + + + subgroups[subgroup].top = 0; + do { + // TODO: optimize checking for overlap. when there is a gap without items, + // you only need to check for items from the next item on, not from zero + var collidingItem = null; + for (var otherSubgroup in subgroups) { + if (subgroups[otherSubgroup].top !== null && otherSubgroup !== subgroup && subgroups[subgroup].index > subgroups[otherSubgroup].index && exports.collisionByTimes(subgroups[subgroup], subgroups[otherSubgroup])) { + collidingItem = subgroups[otherSubgroup]; + break; + } + } + + if (collidingItem != null) { + // There is a collision. Reposition the subgroups above the colliding element + subgroups[subgroup].top = collidingItem.top + collidingItem.height; + } + } while (collidingItem); + } + } + for (var i = 0; i < items.length; i++) { + if (items[i].data.subgroup !== undefined) { + items[i].top = subgroups[items[i].data.subgroup].top + 0.5 * margin.item.vertical; + } + } +}; + +/** + * Adjust vertical positions of the subgroups such that they don't overlap each + * other, then stacks the contents of each subgroup individually. + * @param {Item[]} subgroupItems + * All the items in a subgroup + * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin + * Margins between items and between items and the axis. + * @param {subgroups[]} subgroups + * All subgroups + */ +exports.stackSubgroupsWithInnerStack = function (subgroupItems, margin, subgroups) { + var doSubStack = false; + + // Run subgroups in their order (if any) + var subgroupOrder = []; + + for(var subgroup in subgroups) { + if (subgroups[subgroup].hasOwnProperty("index")) { + subgroupOrder[subgroups[subgroup].index] = subgroup; } else { - items[i].top = margin.axis; + subgroupOrder.push(subgroup); } } + + for(var j = 0; j < subgroupOrder.length; j++) { + subgroup = subgroupOrder[j]; + if (subgroups.hasOwnProperty(subgroup)) { + + doSubStack = doSubStack || subgroups[subgroup].stack; + subgroups[subgroup].top = 0; + + for (var otherSubgroup in subgroups) { + if (subgroups[otherSubgroup].visible && subgroups[subgroup].index > subgroups[otherSubgroup].index) { + subgroups[subgroup].top += subgroups[otherSubgroup].height; + } + } + + var items = subgroupItems[subgroup]; + for(var i = 0; i < items.length; i++) { + if (items[i].data.subgroup !== undefined) { + items[i].top = subgroups[items[i].data.subgroup].top + 0.5 * margin.item.vertical; + + if (subgroups[subgroup].stack) { + items[i].baseTop = items[i].top; + } + } + } + + if (doSubStack && subgroups[subgroup].stack) { + exports.substack(subgroupItems[subgroup], margin, subgroups[subgroup]); + } + } + } }; /** @@ -127,3 +277,17 @@ exports.collision = function(a, b, margin, rtl) { (a.top + a.height + margin.vertical - EPSILON) > b.top); } }; + +/** + * Test if the two provided objects collide + * The objects must have parameters start, end, top, and height. + * @param {Object} a The first Object + * @param {Object} b The second Object + * @return {boolean} true if a and b collide, else false + */ +exports.collisionByTimes = function(a, b) { + return ( + (a.start <= b.start && a.end >= b.start && a.top < (b.top + b.height) && (a.top + a.height) > b.top ) || + (b.start <= a.start && b.end >= a.start && b.top < (a.top + a.height) && (b.top + b.height) > a.top ) + ) +} \ No newline at end of file diff --git a/lib/timeline/TimeStep.js b/lib/timeline/TimeStep.js index dc3991edd..9cb5e07ca 100644 --- a/lib/timeline/TimeStep.js +++ b/lib/timeline/TimeStep.js @@ -3,7 +3,6 @@ var DateUtil = require('./DateUtil'); var util = require('../util'); /** - * @constructor TimeStep * The class TimeStep is an iterator for dates. You provide a start date and an * end date. The class itself determines the best scale (step size) based on the * provided start Date, end Date, and minimumStep. @@ -26,9 +25,12 @@ var util = require('../util'); * @param {Date} [start] The start date, for example new Date(2010, 9, 21) * or new Date(2010, 9, 21, 23, 45, 00) * @param {Date} [end] The end date - * @param {Number} [minimumStep] Optional. Minimum step size in milliseconds + * @param {number} [minimumStep] Optional. Minimum step size in milliseconds + * @param {Date|Array.} [hiddenDates] Optional. + * @param {{showMajorLabels: boolean}} [options] Optional. + * @constructor TimeStep */ -function TimeStep(start, end, minimumStep, hiddenDates) { +function TimeStep(start, end, minimumStep, hiddenDates, options) { this.moment = moment; // variables @@ -58,6 +60,8 @@ function TimeStep(start, end, minimumStep, hiddenDates) { } this.format = TimeStep.FORMAT; // default formatting + this.options = options ? options : {}; + } // Time formatting @@ -69,6 +73,7 @@ TimeStep.FORMAT = { hour: 'HH:mm', weekday: 'ddd D', day: 'D', + week: 'w', month: 'MMM', year: 'YYYY' }, @@ -79,6 +84,7 @@ TimeStep.FORMAT = { hour: 'ddd D MMMM', weekday: 'MMMM YYYY', day: 'MMMM YYYY', + week: 'MMMM YYYY', month: 'YYYY', year: '' } @@ -101,7 +107,7 @@ TimeStep.prototype.setMoment = function (moment) { /** * Set custom formatting for the minor an major labels of the TimeStep. * Both `minorLabels` and `majorLabels` are an Object with properties: - * 'millisecond', 'second', 'minute', 'hour', 'weekday', 'day', 'month', 'year'. + * 'millisecond', 'second', 'minute', 'hour', 'weekday', 'day', 'week', 'month', 'year'. * @param {{minorLabels: Object, majorLabels: Object}} format */ TimeStep.prototype.setFormat = function (format) { @@ -146,18 +152,23 @@ TimeStep.prototype.start = function() { */ TimeStep.prototype.roundToMinor = function() { // round to floor + // to prevent year & month scales rounding down to the first day of week we perform this separately + if (this.scale == 'week') { + this.current.weekday(0); + } // IMPORTANT: we have no breaks in this switch! (this is no bug) // noinspection FallThroughInSwitchStatementJS switch (this.scale) { case 'year': this.current.year(this.step * Math.floor(this.current.year() / this.step)); this.current.month(0); - case 'month': this.current.date(1); - case 'day': // intentional fall through - case 'weekday': this.current.hours(0); - case 'hour': this.current.minutes(0); - case 'minute': this.current.seconds(0); - case 'second': this.current.milliseconds(0); + case 'month': this.current.date(1); // eslint-disable-line no-fallthrough + case 'week': // eslint-disable-line no-fallthrough + case 'day': // eslint-disable-line no-fallthrough + case 'weekday': this.current.hours(0); // eslint-disable-line no-fallthrough + case 'hour': this.current.minutes(0); // eslint-disable-line no-fallthrough + case 'minute': this.current.seconds(0); // eslint-disable-line no-fallthrough + case 'second': this.current.milliseconds(0); // eslint-disable-line no-fallthrough //case 'millisecond': // nothing to do for milliseconds } @@ -170,6 +181,7 @@ TimeStep.prototype.roundToMinor = function() { case 'hour': this.current.subtract(this.current.hours() % this.step, 'hours'); break; case 'weekday': // intentional fall through case 'day': this.current.subtract((this.current.date() - 1) % this.step, 'day'); break; + case 'week': this.current.subtract(this.current.week() % this.step, 'week'); break; case 'month': this.current.subtract(this.current.month() % this.step, 'month'); break; case 'year': this.current.subtract(this.current.year() % this.step, 'year'); break; default: break; @@ -193,47 +205,55 @@ TimeStep.prototype.next = function() { // Two cases, needed to prevent issues with switching daylight savings // (end of March and end of October) - if (this.current.month() < 6) { - switch (this.scale) { - case 'millisecond': this.current.add(this.step, 'millisecond'); break; - case 'second': this.current.add(this.step, 'second'); break; - case 'minute': this.current.add(this.step, 'minute'); break; - case 'hour': - this.current.add(this.step, 'hour'); - // in case of skipping an hour for daylight savings, adjust the hour again (else you get: 0h 5h 9h ... instead of 0h 4h 8h ...) - // TODO: is this still needed now we use the function of moment.js? + switch (this.scale) { + case 'millisecond': this.current.add(this.step, 'millisecond'); break; + case 'second': this.current.add(this.step, 'second'); break; + case 'minute': this.current.add(this.step, 'minute'); break; + case 'hour': + this.current.add(this.step, 'hour'); + + if (this.current.month() < 6) { this.current.subtract(this.current.hours() % this.step, 'hour'); - break; - case 'weekday': // intentional fall through - case 'day': this.current.add(this.step, 'day'); break; - case 'month': this.current.add(this.step, 'month'); break; - case 'year': this.current.add(this.step, 'year'); break; - default: break; - } - } - else { - switch (this.scale) { - case 'millisecond': this.current.add(this.step, 'millisecond'); break; - case 'second': this.current.add(this.step, 'second'); break; - case 'minute': this.current.add(this.step, 'minute'); break; - case 'hour': this.current.add(this.step, 'hour'); break; - case 'weekday': // intentional fall through - case 'day': this.current.add(this.step, 'day'); break; - case 'month': this.current.add(this.step, 'month'); break; - case 'year': this.current.add(this.step, 'year'); break; - default: break; - } + } else { + if (this.current.hours() % this.step !== 0) { + this.current.add(this.step - this.current.hours() % this.step, 'hour'); + } + } + break; + case 'weekday': // intentional fall through + case 'day': this.current.add(this.step, 'day'); break; + case 'week': + if (this.current.weekday() !== 0){ // we had a month break not correlating with a week's start before + this.current.weekday(0); // switch back to week cycles + this.current.add(this.step, 'week'); + } else if(this.options.showMajorLabels === false) { + this.current.add(this.step, 'week'); // the default case + } else { // first day of the week + var nextWeek = this.current.clone(); + nextWeek.add(1, 'week'); + if(nextWeek.isSame(this.current, 'month')){ // is the first day of the next week in the same month? + this.current.add(this.step, 'week'); // the default case + } else { // inject a step at each first day of the month + this.current.add(this.step, 'week'); + this.current.date(1); + } + } + break; + case 'month': this.current.add(this.step, 'month'); break; + case 'year': this.current.add(this.step, 'year'); break; + default: break; } if (this.step != 1) { // round down to the correct major value switch (this.scale) { - case 'millisecond': if(this.current.milliseconds() < this.step) this.current.milliseconds(0); break; - case 'second': if(this.current.seconds() < this.step) this.current.seconds(0); break; - case 'minute': if(this.current.minutes() < this.step) this.current.minutes(0); break; - case 'hour': if(this.current.hours() < this.step) this.current.hours(0); break; + case 'millisecond': if(this.current.milliseconds() > 0 && this.current.milliseconds() < this.step) this.current.milliseconds(0); break; + case 'second': if(this.current.seconds() > 0 && this.current.seconds() < this.step) this.current.seconds(0); break; + case 'minute': if(this.current.minutes() > 0 && this.current.minutes() < this.step) this.current.minutes(0); break; + case 'hour': if(this.current.hours() > 0 && this.current.hours() < this.step) this.current.hours(0); break; case 'weekday': // intentional fall through case 'day': if(this.current.date() < this.step+1) this.current.date(1); break; + case 'week': if(this.current.week() < this.step) this.current.week(1); break; // week numbering starts at 1, not 0 case 'month': if(this.current.month() < this.step) this.current.month(0); break; case 'year': break; // nothing to do for year default: break; @@ -270,7 +290,7 @@ TimeStep.prototype.getCurrent = function() { * @param {{scale: string, step: number}} params * An object containing two properties: * - A string 'scale'. Choose from 'millisecond', 'second', - * 'minute', 'hour', 'weekday', 'day', 'month', 'year'. + * 'minute', 'hour', 'weekday', 'day', 'week', 'month', 'year'. * - A number 'step'. A step size, by default 1. * Choose for example 1, 2, 5, or 10. */ @@ -293,7 +313,7 @@ TimeStep.prototype.setAutoScale = function (enable) { /** * Automatically determine the scale that bests fits the provided minimum step - * @param {Number} [minimumStep] The minimum step size in milliseconds + * @param {number} [minimumStep] The minimum step size in milliseconds */ TimeStep.prototype.setMinimumStep = function(minimumStep) { if (minimumStep == undefined) { @@ -348,7 +368,7 @@ TimeStep.prototype.setMinimumStep = function(minimumStep) { * Static function * @param {Date} date the date to be snapped. * @param {string} scale Current scale, can be 'millisecond', 'second', - * 'minute', 'hour', 'weekday, 'day', 'month', 'year'. + * 'minute', 'hour', 'weekday, 'day', 'week', 'month', 'year'. * @param {number} step Current step (1, 2, 4, 5, ... * @return {Date} snappedDate */ @@ -380,6 +400,20 @@ TimeStep.snap = function(date, scale, step) { clone.seconds(0); clone.milliseconds(0); } + else if (scale == 'week') { + if (clone.weekday() > 2) { // doing it the momentjs locale aware way + clone.weekday(0); + clone.add(1, 'week'); + } + else { + clone.weekday(0); + } + + clone.hours(0); + clone.minutes(0); + clone.seconds(0); + clone.milliseconds(0); + } else if (scale == 'day') { //noinspection FallthroughInSwitchStatementJS switch (step) { @@ -462,6 +496,7 @@ TimeStep.prototype.isMajor = function() { switch (this.scale) { case 'year': case 'month': + case 'week': case 'weekday': case 'day': case 'hour': @@ -475,6 +510,7 @@ TimeStep.prototype.isMajor = function() { } else if (this.switchedMonth == true) { switch (this.scale) { + case 'week': case 'weekday': case 'day': case 'hour': @@ -511,6 +547,8 @@ TimeStep.prototype.isMajor = function() { case 'weekday': // intentional fall through case 'day': return (date.date() == 1); + case 'week': + return (date.date() == 1); case 'month': return (date.month() == 0); case 'year': @@ -525,32 +563,48 @@ TimeStep.prototype.isMajor = function() { * Returns formatted text for the minor axislabel, depending on the current * date and the scale. For example when scale is MINUTE, the current time is * formatted as "hh:mm". - * @param {Date} [date] custom date. if not provided, current date is taken + * @param {Date} [date=this.current] custom date. if not provided, current date is taken + * @returns {String} */ TimeStep.prototype.getLabelMinor = function(date) { if (date == undefined) { date = this.current; } + if (date instanceof Date) { + date = this.moment(date) + } if (typeof(this.format.minorLabels) === "function") { return this.format.minorLabels(date, this.scale, this.step); } var format = this.format.minorLabels[this.scale]; - return (format && format.length > 0) ? this.moment(date).format(format) : ''; + // noinspection FallThroughInSwitchStatementJS + switch (this.scale) { + case 'week': + if(this.isMajor() && date.weekday() !== 0){ + return ""; + } + default: // eslint-disable-line no-fallthrough + return (format && format.length > 0) ? this.moment(date).format(format) : ''; + } }; /** * Returns formatted text for the major axis label, depending on the current * date and the scale. For example when scale is MINUTE, the major scale is * hours, and the hour will be formatted as "hh". - * @param {Date} [date] custom date. if not provided, current date is taken + * @param {Date} [date=this.current] custom date. if not provided, current date is taken + * @returns {String} */ TimeStep.prototype.getLabelMajor = function(date) { if (date == undefined) { date = this.current; } - + if (date instanceof Date) { + date = this.moment(date) + } + if (typeof(this.format.majorLabels) === "function") { return this.format.majorLabels(date, this.scale, this.step); } @@ -564,11 +618,22 @@ TimeStep.prototype.getClassName = function() { var m = this.moment(this.current); var current = m.locale ? m.locale('en') : m.lang('en'); // old versions of moment have .lang() function var step = this.step; + var classNames = []; + /** + * + * @param {number} value + * @returns {String} + */ function even(value) { return (value / step % 2 == 0) ? ' vis-even' : ' vis-odd'; } + /** + * + * @param {Date} date + * @returns {String} + */ function today(date) { if (date.isSame(new Date(), 'day')) { return ' vis-today'; @@ -582,65 +647,83 @@ TimeStep.prototype.getClassName = function() { return ''; } + /** + * + * @param {Date} date + * @returns {String} + */ function currentWeek(date) { return date.isSame(new Date(), 'week') ? ' vis-current-week' : ''; } + /** + * + * @param {Date} date + * @returns {String} + */ function currentMonth(date) { return date.isSame(new Date(), 'month') ? ' vis-current-month' : ''; } + /** + * + * @param {Date} date + * @returns {String} + */ function currentYear(date) { return date.isSame(new Date(), 'year') ? ' vis-current-year' : ''; } switch (this.scale) { case 'millisecond': - return today(current) + - even(current.milliseconds()).trim(); - + classNames.push(today(current)); + classNames.push(even(current.milliseconds())); + break; case 'second': - return today(current) + - even(current.seconds()).trim(); - + classNames.push(today(current)); + classNames.push(even(current.seconds())); + break; case 'minute': - return today(current) + - even(current.minutes()).trim(); - + classNames.push(today(current)); + classNames.push(even(current.minutes())); + break; case 'hour': - return 'vis-h' + current.hours() + (this.step == 4 ? '-h' + (current.hours() + 4) : '') + - today(current) + - even(current.hours()); - + classNames.push('vis-h' + current.hours() + (this.step == 4 ? '-h' + (current.hours() + 4) : '')); + classNames.push(today(current)); + classNames.push(even(current.hours())); + break; case 'weekday': - return 'vis-' + current.format('dddd').toLowerCase() + - today(current) + - currentWeek(current) + - even(current.date()); - + classNames.push('vis-' + current.format('dddd').toLowerCase()); + classNames.push(today(current)); + classNames.push(currentWeek(current)); + classNames.push(even(current.date())); + break; case 'day': - return 'vis-day' + current.date() + - ' vis-' + current.format('MMMM').toLowerCase() + - today(current) + - currentMonth(current) + - this.step <= 2 ? today(current) : '' + - this.step <= 2 ? ' vis-' + current.format('dddd').toLowerCase() : '' + - even(current.date() - 1); - + classNames.push('vis-day' + current.date()); + classNames.push('vis-' + current.format('MMMM').toLowerCase()); + classNames.push(today(current)); + classNames.push(currentMonth(current)); + classNames.push(this.step <= 2 ? today(current) : ''); + classNames.push(this.step <= 2 ? 'vis-' + current.format('dddd').toLowerCase() : ''); + classNames.push(even(current.date() - 1)); + break; + case 'week': + classNames.push('vis-week' + current.format('w')); + classNames.push(currentWeek(current)); + classNames.push(even(current.week())); + break; case 'month': - return 'vis-' + current.format('MMMM').toLowerCase() + - currentMonth(current) + - even(current.month()); - + classNames.push('vis-' + current.format('MMMM').toLowerCase()); + classNames.push(currentMonth(current)); + classNames.push(even(current.month())); + break; case 'year': - var year = current.year(); - return 'vis-year' + year + - currentYear(current) + - even(year); - - default: - return ''; + classNames.push('vis-year' + current.year()); + classNames.push(currentYear(current)); + classNames.push(even(current.year())); + break; } + return classNames.filter(String).join(" "); }; module.exports = TimeStep; diff --git a/lib/timeline/Timeline.js b/lib/timeline/Timeline.js index aeae38c6a..609743931 100644 --- a/lib/timeline/Timeline.js +++ b/lib/timeline/Timeline.js @@ -1,5 +1,3 @@ -var Emitter = require('emitter-component'); -var Hammer = require('../module/hammer'); var moment = require('../module/moment'); var util = require('../util'); var DataSet = require('../DataSet'); @@ -15,8 +13,8 @@ var printStyle = require('../shared/Validator').printStyle; var allOptions = require('./optionsTimeline').allOptions; var configureOptions = require('./optionsTimeline').configureOptions; -import Configurator from '../shared/Configurator'; -import Validator from '../shared/Validator'; +var Configurator = require('../shared/Configurator').default; +var Validator = require('../shared/Validator').default; /** @@ -25,7 +23,7 @@ import Validator from '../shared/Validator'; * @param {vis.DataSet | vis.DataView | Array} [items] * @param {vis.DataSet | vis.DataView | Array} [groups] * @param {Object} [options] See Timeline.setOptions for the available options. - * @constructor + * @constructor Timeline * @extends Core */ function Timeline (container, items, groups, options) { @@ -41,6 +39,12 @@ function Timeline (container, items, groups, options) { groups = forthArgument; } + // TODO: REMOVE THIS in the next MAJOR release + // see https://github.com/almende/vis/issues/2511 + if (options && options.throttleRedraw) { + console.warn("Timeline option \"throttleRedraw\" is DEPRICATED and no longer supported. It will be removed in the next MAJOR release."); + } + var me = this; this.defaultOptions = { start: null, @@ -54,15 +58,14 @@ function Timeline (container, items, groups, options) { width: null, height: null, maxHeight: null, - minHeight: null + minHeight: null, }; this.options = util.deepExtend({}, this.defaultOptions); - // Create the DOM, props, and emitter this._create(container); - if (!options || (options && typeof options.rtl == "undefined")) { + this.dom.root.style.visibility = 'hidden'; var directionFromDom, domNode = this.dom.root; while (!directionFromDom && domNode) { directionFromDom = window.getComputedStyle(domNode, null).direction; @@ -71,7 +74,10 @@ function Timeline (container, items, groups, options) { this.options.rtl = (directionFromDom && (directionFromDom.toLowerCase() == "rtl")); } else { this.options.rtl = options.rtl; - } + } + + this.options.rollingMode = options && options.rollingMode; + this.options.onInitialDrawComplete = options && options.onInitialDrawComplete; // all components listed here will be repainted automatically this.components = []; @@ -121,22 +127,46 @@ function Timeline (container, items, groups, options) { this.itemsData = null; // DataSet this.groupsData = null; // DataSet - this.on('tap', function (event) { + this.dom.root.onclick = function (event) { me.emit('click', me.getEventProperties(event)) - }); - this.on('doubletap', function (event) { + }; + this.dom.root.ondblclick = function (event) { me.emit('doubleClick', me.getEventProperties(event)) - }); + }; this.dom.root.oncontextmenu = function (event) { me.emit('contextmenu', me.getEventProperties(event)) }; + this.dom.root.onmouseover = function (event) { + me.emit('mouseOver', me.getEventProperties(event)) + }; + if(window.PointerEvent) { + this.dom.root.onpointerdown = function (event) { + me.emit('mouseDown', me.getEventProperties(event)) + }; + this.dom.root.onpointermove = function (event) { + me.emit('mouseMove', me.getEventProperties(event)) + }; + this.dom.root.onpointerup = function (event) { + me.emit('mouseUp', me.getEventProperties(event)) + }; + } else { + this.dom.root.onmousemove = function (event) { + me.emit('mouseMove', me.getEventProperties(event)) + }; + this.dom.root.onmousedown = function (event) { + me.emit('mouseDown', me.getEventProperties(event)) + }; + this.dom.root.onmouseup = function (event) { + me.emit('mouseUp', me.getEventProperties(event)) + }; + } //Single time autoscale/fit - this.fitDone = false; + this.initialFitDone = false; this.on('changed', function (){ - if (this.itemsData == null) return; - if (!me.fitDone) { - me.fitDone = true; + if (this.itemsData == null || this.options.rollingMode) return; + if (!me.initialFitDone) { + me.initialFitDone = true; if (me.options.start != undefined || me.options.end != undefined) { if (me.options.start == undefined || me.options.end == undefined) { var range = me.getItemRange(); @@ -144,13 +174,21 @@ function Timeline (container, items, groups, options) { var start = me.options.start != undefined ? me.options.start : range.min; var end = me.options.end != undefined ? me.options.end : range.max; - me.setWindow(start, end, {animation: false}); - } - else { + } else { me.fit({animation: false}); } } + + if (!me.initialDrawDone && me.initialRangeChangeDone) { + me.initialDrawDone = true; + me.dom.root.style.visibility = 'visible'; + if (me.options.onInitialDrawComplete) { + setTimeout(() => { + return me.options.onInitialDrawComplete(); + }, 0) + } + } }); // apply options @@ -259,21 +297,27 @@ Timeline.prototype.setGroups = function(groups) { if (!groups) { newDataSet = null; } - else if (groups instanceof DataSet || groups instanceof DataView) { - newDataSet = groups; - } else { - // turn an array into a dataset - newDataSet = new DataSet(groups); + var filter = function(group) { + return group.visible !== false; + } + if (groups instanceof DataSet || groups instanceof DataView) { + newDataSet = new DataView(groups,{filter: filter}); + } + else { + // turn an array into a dataset + newDataSet = new DataSet(groups.filter(filter)); + } } + this.groupsData = newDataSet; this.itemSet.setGroups(newDataSet); }; /** * Set both items and groups in one go - * @param {{items: Array | vis.DataSet, groups: Array | vis.DataSet}} data + * @param {{items: (Array | vis.DataSet), groups: (Array | vis.DataSet)}} data */ Timeline.prototype.setData = function (data) { if (data && data.groups) { @@ -321,7 +365,7 @@ Timeline.prototype.getSelection = function() { /** * Adjust the visible window such that the selected item (or multiple items) * are centered on screen. - * @param {String | String[]} id An item id or array with item ids + * @param {string | String[]} id An item id or array with item ids * @param {Object} [options] Available options: * `animation: boolean | {duration: number, easingFunction: string}` * If true (default), the range is animated @@ -359,13 +403,74 @@ Timeline.prototype.focus = function(id, options) { } }); + if (start !== null && end !== null) { + var me = this; + // Use the first item for the vertical focus + var item = this.itemSet.items[ids[0]]; + var startPos = this._getScrollTop() * -1; + var initialVerticalScroll = null; + + // Setup a handler for each frame of the vertical scroll + var verticalAnimationFrame = function(ease, willDraw, done) { + var verticalScroll = getItemVerticalScroll(me, item); + + if(!initialVerticalScroll) { + initialVerticalScroll = verticalScroll; + } + + if(initialVerticalScroll.itemTop == verticalScroll.itemTop && !initialVerticalScroll.shouldScroll) { + return; // We don't need to scroll, so do nothing + } + else if(initialVerticalScroll.itemTop != verticalScroll.itemTop && verticalScroll.shouldScroll) { + // The redraw shifted elements, so reset the animation to correct + initialVerticalScroll = verticalScroll; + startPos = me._getScrollTop() * -1; + } + + var from = startPos; + var to = initialVerticalScroll.scrollOffset; + var scrollTop = done ? to : (from + (to - from) * ease); + + me._setScrollTop(-scrollTop); + + if(!willDraw) { + me._redraw(); + } + }; + + // Enforces the final vertical scroll position + var setFinalVerticalPosition = function() { + var finalVerticalScroll = getItemVerticalScroll(me, item); + + if (finalVerticalScroll.shouldScroll && finalVerticalScroll.itemTop != initialVerticalScroll.itemTop) { + me._setScrollTop(-finalVerticalScroll.scrollOffset); + me._redraw(); + } + }; + + // Perform one last check at the end to make sure the final vertical + // position is correct + var finalVerticalCallback = function() { + // Double check we ended at the proper scroll position + setFinalVerticalPosition(); + + // Let the redraw settle and finalize the position. + setTimeout(setFinalVerticalPosition, 100); + }; + // calculate the new middle and interval for the window var middle = (start + end) / 2; - var interval = Math.max((this.range.end - this.range.start), (end - start) * 1.1); + var interval = Math.max(this.range.end - this.range.start, (end - start) * 1.1); + + var animation = options && options.animation !== undefined ? options.animation : true; - var animation = (options && options.animation !== undefined) ? options.animation : true; - this.range.setRange(middle - interval / 2, middle + interval / 2, animation); + if (!animation) { + // We aren't animating so set a default so that the final callback forces the vertical location + initialVerticalScroll = { shouldScroll: false, scrollOffset: -1, itemTop: -1 }; + } + + this.range.setRange(middle - interval / 2, middle + interval / 2, { animation: animation }, finalVerticalCallback, verticalAnimationFrame); } }; @@ -378,8 +483,9 @@ Timeline.prototype.focus = function(id, options) { * provided to specify duration and easing function. * Default duration is 500 ms, and default easing * function is 'easeInOutQuad'. + * @param {function} [callback] */ -Timeline.prototype.fit = function (options) { +Timeline.prototype.fit = function (options, callback) { var animation = (options && options.animation !== undefined) ? options.animation : true; var range; @@ -387,19 +493,83 @@ Timeline.prototype.fit = function (options) { if (dataset.length === 1 && dataset.get()[0].end === undefined) { // a single item -> don't fit, just show a range around the item from -4 to +3 days range = this.getDataRange(); - this.moveTo(range.min.valueOf(), {animation}); + this.moveTo(range.min.valueOf(), {animation}, callback); } else { // exactly fit the items (plus a small margin) range = this.getItemRange(); - this.range.setRange(range.min, range.max, animation); + this.range.setRange(range.min, range.max, { animation: animation }, callback); } }; +/** + * + * @param {vis.Item} item + * @returns {number} + */ +function getStart(item) { + return util.convert(item.data.start, 'Date').valueOf() +} + +/** + * + * @param {vis.Item} item + * @returns {number} + */ +function getEnd(item) { + var end = item.data.end != undefined ? item.data.end : item.data.start; + return util.convert(end, 'Date').valueOf(); +} + +/** + * @param {vis.Timeline} timeline + * @param {vis.Item} item + * @return {{shouldScroll: bool, scrollOffset: number, itemTop: number}} + */ +function getItemVerticalScroll(timeline, item) { + var leftHeight = timeline.props.leftContainer.height; + var contentHeight = timeline.props.left.height; + + var group = item.parent; + var offset = group.top; + var shouldScroll = true; + var orientation = timeline.timeAxis.options.orientation.axis; + + var itemTop = function () { + if (orientation == "bottom") { + return group.height - item.top - item.height; + } + else { + return item.top; + } + }; + + var currentScrollHeight = timeline._getScrollTop() * -1; + var targetOffset = offset + itemTop(); + var height = item.height; + + if (targetOffset < currentScrollHeight) { + if (offset + leftHeight <= offset + itemTop() + height) { + offset += itemTop() - timeline.itemSet.options.margin.item.vertical; + } + } + else if (targetOffset + height > currentScrollHeight + leftHeight) { + offset += itemTop() + height - leftHeight + timeline.itemSet.options.margin.item.vertical; + } + else { + shouldScroll = false; + } + + offset = Math.min(offset, contentHeight - leftHeight); + + return { shouldScroll: shouldScroll, scrollOffset: offset, itemTop: targetOffset }; +} + /** * Determine the range of the items, taking into account their actual width * and a margin of 10 pixels on both sides. - * @return {{min: Date | null, max: Date | null}} + * + * @returns {{min: Date, max: Date}} */ Timeline.prototype.getItemRange = function () { // get a rough approximation for the range based on the items start and end dates @@ -416,32 +586,43 @@ Timeline.prototype.getItemRange = function () { } var factor = interval / this.props.center.width; - function getStart(item) { - return util.convert(item.data.start, 'Date').valueOf() - } + var redrawQueue = {}; + var redrawQueueLength = 0; - function getEnd(item) { - var end = item.data.end != undefined ? item.data.end : item.data.start; - return util.convert(end, 'Date').valueOf(); + // collect redraw functions + util.forEach(this.itemSet.items, function (item, key) { + if (item.groupShowing) { + var returnQueue = true; + redrawQueue[key] = item.redraw(returnQueue); + redrawQueueLength = redrawQueue[key].length; + } + }) + + var needRedraw = redrawQueueLength > 0; + if (needRedraw) { + // redraw all regular items + for (var i = 0; i < redrawQueueLength; i++) { + util.forEach(redrawQueue, function (fns) { + fns[i](); + }); + } } - // calculate the date of the left side and right side of the items given + // calculate the date of the left side and right side of the items given util.forEach(this.itemSet.items, function (item) { - item.show(); - item.repositionX(); - var start = getStart(item); var end = getEnd(item); + var startSide; + var endSide; if (this.options.rtl) { - var startSide = start - (item.getWidthRight() + 10) * factor; - var endSide = end + (item.getWidthLeft() + 10) * factor; + startSide = start - (item.getWidthRight() + 10) * factor; + endSide = end + (item.getWidthLeft() + 10) * factor; } else { - var startSide = start - (item.getWidthLeft() + 10) * factor; - var endSide = end + (item.getWidthRight() + 10) * factor; + startSide = start - (item.getWidthLeft() + 10) * factor; + endSide = end + (item.getWidthRight() + 10) * factor; } - if (startSide < min) { min = startSide; minItem = item; @@ -477,7 +658,7 @@ Timeline.prototype.getItemRange = function () { /** * Calculate the data range of the items start and end dates - * @returns {{min: Date | null, max: Date | null}} + * @returns {{min: Date, max: Date}} */ Timeline.prototype.getDataRange = function() { var min = null; @@ -512,10 +693,11 @@ Timeline.prototype.getDataRange = function() { Timeline.prototype.getEventProperties = function (event) { var clientX = event.center ? event.center.x : event.clientX; var clientY = event.center ? event.center.y : event.clientY; + var x; if (this.options.rtl) { - var x = util.getAbsoluteRight(this.dom.centerContainer) - clientX; + x = util.getAbsoluteRight(this.dom.centerContainer) - clientX; } else { - var x = clientX - util.getAbsoluteLeft(this.dom.centerContainer); + x = clientX - util.getAbsoluteLeft(this.dom.centerContainer); } var y = clientY - util.getAbsoluteTop(this.dom.centerContainer); @@ -553,4 +735,20 @@ Timeline.prototype.getEventProperties = function (event) { } }; +/** + * Toggle Timeline rolling mode + */ + +Timeline.prototype.toggleRollingMode = function () { + if (this.range.rolling) { + this.range.stopRolling(); + } else { + if (this.options.rollingMode == undefined) { + this.setOptions(this.options) + } + this.range.startRolling(); + } + +} + module.exports = Timeline; diff --git a/lib/timeline/component/BackgroundGroup.js b/lib/timeline/component/BackgroundGroup.js index 766d623dc..d9ed11036 100644 --- a/lib/timeline/component/BackgroundGroup.js +++ b/lib/timeline/component/BackgroundGroup.js @@ -1,11 +1,11 @@ -var util = require('../../util'); var Group = require('./Group'); /** * @constructor BackgroundGroup - * @param {Number | String} groupId + * @param {number | string} groupId * @param {Object} data * @param {ItemSet} itemSet + * @extends Group */ function BackgroundGroup (groupId, data, itemSet) { Group.call(this, groupId, data, itemSet); @@ -22,13 +22,13 @@ BackgroundGroup.prototype = Object.create(Group.prototype); * Repaint this group * @param {{start: number, end: number}} range * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin - * @param {boolean} [restack=false] Force restacking of all items + * @param {boolean} [forceRestack=false] Force restacking of all items * @return {boolean} Returns true if the group is resized */ -BackgroundGroup.prototype.redraw = function(range, margin, restack) { +BackgroundGroup.prototype.redraw = function(range, margin, forceRestack) { // eslint-disable-line no-unused-vars var resized = false; - this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range); + this.visibleItems = this._updateItemsInRange(this.orderedItems, this.visibleItems, range); // calculate actual size this.width = this.dom.background.offsetWidth; diff --git a/lib/timeline/component/Component.js b/lib/timeline/component/Component.js index 6e47f9d04..b3112bca9 100644 --- a/lib/timeline/component/Component.js +++ b/lib/timeline/component/Component.js @@ -5,7 +5,7 @@ var util = require('../../util'); * @param {{dom: Object, domProps: Object, emitter: Emitter, range: Range}} [body] * @param {Object} [options] */ -function Component (body, options) { +function Component (body, options) { // eslint-disable-line no-unused-vars this.options = null; this.props = null; } diff --git a/lib/timeline/component/CurrentTime.js b/lib/timeline/component/CurrentTime.js index a697a5827..27ec93bfb 100644 --- a/lib/timeline/component/CurrentTime.js +++ b/lib/timeline/component/CurrentTime.js @@ -124,7 +124,10 @@ CurrentTime.prototype.redraw = function() { CurrentTime.prototype.start = function() { var me = this; - function update () { + /** + * Updates the current time. + */ + function update () { me.stop(); // determine interval to refresh @@ -156,7 +159,7 @@ CurrentTime.prototype.stop = function() { /** * Set a current time. This can be used for example to ensure that a client's * time is synchronized with a shared server time. - * @param {Date | String | Number} time A Date, unix timestamp, or + * @param {Date | string | number} time A Date, unix timestamp, or * ISO date string. */ CurrentTime.prototype.setCurrentTime = function(time) { diff --git a/lib/timeline/component/CustomTime.js b/lib/timeline/component/CustomTime.js index fcdff5de2..e6807f229 100644 --- a/lib/timeline/component/CustomTime.js +++ b/lib/timeline/component/CustomTime.js @@ -14,7 +14,6 @@ var locales = require('../locales'); * @constructor CustomTime * @extends Component */ - function CustomTime (body, options) { this.body = body; @@ -77,8 +76,26 @@ CustomTime.prototype._create = function() { drag.style.left = '-10px'; drag.style.height = '100%'; drag.style.width = '20px'; - bar.appendChild(drag); + /** + * + * @param {WheelEvent} e + */ + function onMouseWheel (e) { + this.body.range._onMouseWheel(e); + } + + if (drag.addEventListener) { + // IE9, Chrome, Safari, Opera + drag.addEventListener("mousewheel", onMouseWheel.bind(this), false); + // Firefox + drag.addEventListener("DOMMouseScroll", onMouseWheel.bind(this), false); + } else { + // IE 6/7/8 + drag.attachEvent("onmousewheel", onMouseWheel.bind(this)); + } + + bar.appendChild(drag); // attach event listeners this.hammer = new Hammer(drag); this.hammer.on('panstart', this._onDragStart.bind(this)); @@ -129,6 +146,8 @@ CustomTime.prototype.redraw = function () { if (title === undefined) { title = locale.time + ': ' + this.options.moment(this.customTime).format('dddd, MMMM Do YYYY, H:mm:ss'); title = title.charAt(0).toUpperCase() + title.substring(1); + } else if (typeof title === "function") { + title = title.call(this.customTime); } this.bar.style.left = x + 'px'; @@ -200,7 +219,8 @@ CustomTime.prototype._onDrag = function (event) { // fire a timechange event this.body.emitter.emit('timechange', { id: this.options.id, - time: new Date(this.customTime.valueOf()) + time: new Date(this.customTime.valueOf()), + event: event }); event.stopPropagation(); @@ -217,7 +237,8 @@ CustomTime.prototype._onDragEnd = function (event) { // fire a timechanged event this.body.emitter.emit('timechanged', { id: this.options.id, - time: new Date(this.customTime.valueOf()) + time: new Date(this.customTime.valueOf()), + event: event }); event.stopPropagation(); diff --git a/lib/timeline/component/DataAxis.js b/lib/timeline/component/DataAxis.js index e2cc569b2..d1dd69f1a 100644 --- a/lib/timeline/component/DataAxis.js +++ b/lib/timeline/component/DataAxis.js @@ -4,11 +4,13 @@ var Component = require('./Component'); var DataScale = require('./DataScale'); /** * A horizontal time axis + * @param {Object} body * @param {Object} [options] See DataAxis.setOptions for the available * options. + * @param {SVGElement} svg + * @param {vis.LineGraph.options} linegraphOptions * @constructor DataAxis * @extends Component - * @param body */ function DataAxis(body, options, svg, linegraphOptions) { this.id = util.randomUUID(); @@ -245,9 +247,8 @@ DataAxis.prototype.hide = function () { /** * Set a range (start and end) - * @param end - * @param start - * @param end + * @param {number} start + * @param {number} end */ DataAxis.prototype.setRange = function (start, end) { this.range.start = start; @@ -342,6 +343,8 @@ DataAxis.prototype.redraw = function () { /** * Repaint major and minor text labels and vertical grid lines + * + * @returns {boolean} * @private */ DataAxis.prototype._redrawLabels = function () { @@ -448,12 +451,13 @@ DataAxis.prototype.screenToValue = function (x) { /** * Create a label for the axis at position x + * + * @param {number} y + * @param {string} text + * @param {'top'|'right'|'bottom'|'left'} orientation + * @param {string} className + * @param {number} characterHeight * @private - * @param y - * @param text - * @param orientation - * @param className - * @param characterHeight */ DataAxis.prototype._redrawLabel = function (y, text, orientation, className, characterHeight) { // reuse redundant label @@ -481,11 +485,11 @@ DataAxis.prototype._redrawLabel = function (y, text, orientation, className, cha /** * Create a minor line for the axis at position y - * @param y - * @param orientation - * @param className - * @param offset - * @param width + * @param {number} y + * @param {'top'|'right'|'bottom'|'left'} orientation + * @param {string} className + * @param {number} offset + * @param {number} width */ DataAxis.prototype._redrawLine = function (y, orientation, className, offset, width) { if (this.master === true) { @@ -508,7 +512,7 @@ DataAxis.prototype._redrawLine = function (y, orientation, className, offset, wi /** * Create a title for the axis * @private - * @param orientation + * @param {'top'|'right'|'bottom'|'left'} orientation */ DataAxis.prototype._redrawTitle = function (orientation) { DOMutil.prepareElements(this.DOMelements.title); diff --git a/lib/timeline/component/DataScale.js b/lib/timeline/component/DataScale.js index a87477fbd..a9cf26a41 100644 --- a/lib/timeline/component/DataScale.js +++ b/lib/timeline/component/DataScale.js @@ -1,7 +1,15 @@ /** - * Created by ludo on 25-1-16. + * + * @param {number} start + * @param {number} end + * @param {boolean} autoScaleStart + * @param {boolean} autoScaleEnd + * @param {number} containerHeight + * @param {number} majorCharHeight + * @param {boolean} zeroAlign + * @param {function} formattingFunction + * @constructor DataScale */ - function DataScale(start, end, autoScaleStart, autoScaleEnd, containerHeight, majorCharHeight, zeroAlign = false, formattingFunction=false) { this.majorSteps = [1, 2, 5, 10]; this.minorSteps = [0.25, 0.5, 1, 2]; @@ -167,7 +175,6 @@ DataScale.prototype.followScale = function (other) { } //Get masters stats: - var lines = other.getLines(); var otherZero = other.convertValue(0); var otherStep = other.getStep() * other.scale; diff --git a/lib/timeline/component/GraphGroup.js b/lib/timeline/component/GraphGroup.js index 20f52e9f0..e3431e9aa 100644 --- a/lib/timeline/component/GraphGroup.js +++ b/lib/timeline/component/GraphGroup.js @@ -1,5 +1,4 @@ var util = require('../../util'); -var DOMutil = require('../../DOMutil'); var Bars = require('./graph2d_types/bar'); var Lines = require('./graph2d_types/line'); var Points = require('./graph2d_types/points'); @@ -12,7 +11,7 @@ var Points = require('./graph2d_types/points'); * @param {array} groupsUsingDefaultStyles | this array has one entree. * It is passed as an array so it is passed by reference. * It enumerates through the default styles - * @constructor + * @constructor GraphGroup */ function GraphGroup(group, groupId, options, groupsUsingDefaultStyles) { this.id = groupId; @@ -49,11 +48,11 @@ GraphGroup.prototype.setItems = function (items) { GraphGroup.prototype.getItems = function () { return this.itemsData; -} +}; /** * this is used for barcharts and shading, this way, we only have to calculate it once. - * @param pos + * @param {number} pos */ GraphGroup.prototype.setZeroPosition = function (pos) { this.zeroPosition = pos; @@ -61,7 +60,7 @@ GraphGroup.prototype.setZeroPosition = function (pos) { /** * set the options of the graph group over the default options. - * @param options + * @param {Object} options */ GraphGroup.prototype.setOptions = function (options) { if (options !== undefined) { @@ -101,7 +100,7 @@ GraphGroup.prototype.setOptions = function (options) { /** * this updates the current group class with the latest group dataset entree, used in _updateGroup in linegraph - * @param group + * @param {vis.Group} group */ GraphGroup.prototype.update = function (group) { this.group = group; @@ -115,9 +114,12 @@ GraphGroup.prototype.update = function (group) { /** * return the legend entree for this group. * - * @param iconWidth - * @param iconHeight - * @returns {{icon: HTMLElement, label: (group.content|*|string), orientation: (.options.yAxisOrientation|*)}} + * @param {number} iconWidth + * @param {number} iconHeight + * @param {{svg: (*|Element), svgElements: Object, options: Object, groups: Array.}} framework + * @param {number} x + * @param {number} y + * @returns {{icon: (*|Element), label: (*|string), orientation: *}} */ GraphGroup.prototype.getLegend = function (iconWidth, iconHeight, framework, x, y) { if (framework == undefined || framework == null) { diff --git a/lib/timeline/component/Group.js b/lib/timeline/component/Group.js index 042b477ad..a05429049 100644 --- a/lib/timeline/component/Group.js +++ b/lib/timeline/component/Group.js @@ -1,20 +1,49 @@ var util = require('../../util'); var stack = require('../Stack'); -var RangeItem = require('./item/RangeItem'); /** - * @constructor Group - * @param {Number | String} groupId + * @param {number | string} groupId * @param {Object} data * @param {ItemSet} itemSet + * @constructor Group */ function Group (groupId, data, itemSet) { this.groupId = groupId; this.subgroups = {}; + this.subgroupStack = {}; + this.subgroupStackAll = false; + this.doInnerStack = false; this.subgroupIndex = 0; this.subgroupOrderer = data && data.subgroupOrder; this.itemSet = itemSet; this.isVisible = null; + this.stackDirty = true; // if true, items will be restacked on next redraw + + if (data && data.nestedGroups) { + this.nestedGroups = data.nestedGroups; + if (data.showNested == false) { + this.showNested = false; + } else { + this.showNested = true; + } + } + + if (data && data.subgroupStack) { + if (typeof data.subgroupStack === "boolean") { + this.doInnerStack = data.subgroupStack; + this.subgroupStackAll = data.subgroupStack; + } + else { + // We might be doing stacking on specific sub groups, but only + // if at least one is set to do stacking + for(var key in data.subgroupStack) { + this.subgroupStack[key] = data.subgroupStack[key]; + this.doInnerStack = this.doInnerStack || data.subgroupStack[key]; + } + } + } + + this.nestedInGroup = null; this.dom = {}; this.props = { @@ -27,6 +56,7 @@ function Group (groupId, data, itemSet) { this.items = {}; // items filtered by groupId of this group this.visibleItems = []; // items currently visible in window + this.itemsInRange = []; // items currently in range this.orderedItems = { byStart: [], byEnd: [] @@ -49,9 +79,9 @@ function Group (groupId, data, itemSet) { Group.prototype._create = function() { var label = document.createElement('div'); if (this.itemSet.options.groupEditable.order) { - label.className = 'vis-label draggable'; + label.className = 'vis-label draggable'; } else { - label.className = 'vis-label'; + label.className = 'vis-label'; } this.dom.label = label; @@ -76,7 +106,8 @@ Group.prototype._create = function() { // display:none is changed to visible. this.dom.marker = document.createElement('div'); this.dom.marker.style.visibility = 'hidden'; - this.dom.marker.innerHTML = '?'; + this.dom.marker.style.position = 'absolute'; + this.dom.marker.innerHTML = ''; this.dom.background.appendChild(this.dom.marker); }; @@ -112,7 +143,6 @@ Group.prototype.setData = function(data) { // update title this.dom.label.title = data && data.title || ''; - if (!this.dom.inner.firstChild) { util.addClassName(this.dom.inner, 'vis-hidden'); } @@ -120,6 +150,45 @@ Group.prototype.setData = function(data) { util.removeClassName(this.dom.inner, 'vis-hidden'); } + if (data && data.nestedGroups) { + if (!this.nestedGroups || this.nestedGroups != data.nestedGroups) { + this.nestedGroups = data.nestedGroups; + } + + if (data.showNested !== undefined || this.showNested === undefined) { + if (data.showNested == false) { + this.showNested = false; + } else { + this.showNested = true; + } + } + + util.addClassName(this.dom.label, 'vis-nesting-group'); + var collapsedDirClassName = this.itemSet.options.rtl ? 'collapsed-rtl' : 'collapsed' + if (this.showNested) { + util.removeClassName(this.dom.label, collapsedDirClassName); + util.addClassName(this.dom.label, 'expanded'); + } else { + util.removeClassName(this.dom.label, 'expanded'); + util.addClassName(this.dom.label, collapsedDirClassName); + } + } else if (this.nestedGroups) { + this.nestedGroups = null; + collapsedDirClassName = this.itemSet.options.rtl ? 'collapsed-rtl' : 'collapsed' + util.removeClassName(this.dom.label, collapsedDirClassName); + util.removeClassName(this.dom.label, 'expanded'); + util.removeClassName(this.dom.label, 'vis-nesting-group'); + } + + if (data && data.nestedInGroup) { + util.addClassName(this.dom.label, 'vis-nested-group'); + if (this.itemSet.options && this.itemSet.options.rtl) { + this.dom.inner.style.paddingRight = '30px'; + } else { + this.dom.inner.style.paddingLeft = '30px'; + } + } + // update className var className = data && data.className || null; if (className != this.className) { @@ -155,118 +224,246 @@ Group.prototype.getLabelWidth = function() { return this.props.label.width; }; - -/** - * Repaint this group - * @param {{start: number, end: number}} range - * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin - * @param {boolean} [restack=false] Force restacking of all items - * @return {boolean} Returns true if the group is resized - */ -Group.prototype.redraw = function(range, margin, restack) { - var resized = false; - - // force recalculation of the height of the items when the marker height changed - // (due to the Timeline being attached to the DOM or changed from display:none to visible) +Group.prototype._didMarkerHeightChange = function() { var markerHeight = this.dom.marker.clientHeight; if (markerHeight != this.lastMarkerHeight) { this.lastMarkerHeight = markerHeight; + var redrawQueue = {}; + var redrawQueueLength = 0; - util.forEach(this.items, function (item) { + util.forEach(this.items, function (item, key) { item.dirty = true; - if (item.displayed) item.redraw(); - }); - - restack = true; + if (item.displayed) { + var returnQueue = true; + redrawQueue[key] = item.redraw(returnQueue); + redrawQueueLength = redrawQueue[key].length; + } + }) + + var needRedraw = redrawQueueLength > 0; + if (needRedraw) { + // redraw all regular items + for (var i = 0; i < redrawQueueLength; i++) { + util.forEach(redrawQueue, function (fns) { + fns[i](); + }); + } + } + return true; } +} - // recalculate the height of the subgroups - this._calculateSubGroupHeights(); - - this.isVisible = this._isGroupVisible(range, margin); +Group.prototype._calculateGroupSizeAndPosition = function() { + var offsetTop = this.dom.foreground.offsetTop + var offsetLeft = this.dom.foreground.offsetLeft + var offsetWidth = this.dom.foreground.offsetWidth + this.top = offsetTop; + this.right = offsetLeft; + this.width = offsetWidth; +} - // calculate actual size and position - var foreground = this.dom.foreground; - this.top = foreground.offsetTop; - this.right = foreground.offsetLeft; - this.width = foreground.offsetWidth; +Group.prototype._redrawItems = function(forceRestack, lastIsVisible, margin, range) { + var restack = forceRestack || this.stackDirty || this.isVisible && !lastIsVisible; - this.isVisible = this._isGroupVisible(range, margin); - // reposition visible items vertically - if (typeof this.itemSet.options.order === 'function') { - // a custom order function + // if restacking, reposition visible items vertically + if (restack) { + var visibleSubgroups = {}; + var subgroup = null; - if (restack) { + if (typeof this.itemSet.options.order === 'function') { + // a custom order function // brute force restack of all items // show all items var me = this; var limitSize = false; - util.forEach(this.items, function (item) { + + var redrawQueue = {}; + var redrawQueueLength = 0; + + util.forEach(this.items, function (item, key) { if (!item.displayed) { - item.redraw(); + var returnQueue = true; + redrawQueue[key] = item.redraw(returnQueue); + redrawQueueLength = redrawQueue[key].length; me.visibleItems.push(item); } + }) + + var needRedraw = redrawQueueLength > 0; + if (needRedraw) { + // redraw all regular items + for (var i = 0; i < redrawQueueLength; i++) { + util.forEach(redrawQueue, function (fns) { + fns[i](); + }); + } + } + + util.forEach(this.items, function (item) { item.repositionX(limitSize); }); - // order all items and force a restacking - var customOrderedItems = this.orderedItems.byStart.slice().sort(function (a, b) { - return me.itemSet.options.order(a.data, b.data); - }); - stack.stack(customOrderedItems, margin, true /* restack=true */); - } + if (this.doInnerStack && this.itemSet.options.stackSubgroups) { + // Order the items within each subgroup + for(subgroup in this.subgroups) { + visibleSubgroups[subgroup] = this.subgroups[subgroup].items.slice().sort(function (a, b) { + return me.itemSet.options.order(a.data, b.data); + }); + } - this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range); - } - else { - // no custom order function, lazy stacking + stack.stackSubgroupsWithInnerStack(visibleSubgroups, margin, this.subgroups); + } + else { + // order all items and force a restacking + var customOrderedItems = this.orderedItems.byStart.slice().sort(function (a, b) { + return me.itemSet.options.order(a.data, b.data); + }); + stack.stack(customOrderedItems, margin, true /* restack=true */); + } - this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range); - if (this.itemSet.options.stack) { // TODO: ugly way to access options... - stack.stack(this.visibleItems, margin, restack); - } - else { // no stacking - stack.nostack(this.visibleItems, margin, this.subgroups); + this.visibleItems = this._updateItemsInRange(this.orderedItems, this.visibleItems, range); + } else { + // no custom order function, lazy stacking + this.visibleItems = this._updateItemsInRange(this.orderedItems, this.visibleItems, range); + + if (this.itemSet.options.stack) { + if (this.doInnerStack && this.itemSet.options.stackSubgroups) { + for(subgroup in this.subgroups) { + visibleSubgroups[subgroup] = this.subgroups[subgroup].items; + } + + stack.stackSubgroupsWithInnerStack(visibleSubgroups, margin, this.subgroups); + } + else { + // TODO: ugly way to access options... + stack.stack(this.visibleItems, margin, true /* restack=true */); + } + } else { + // no stacking + stack.nostack(this.visibleItems, margin, this.subgroups, this.itemSet.options.stackSubgroups); + } } - } - - if (!this.isVisible && this.height) { - return resized = false; - } - // recalculate the height of the group - var height = this._calculateHeight(margin); + this.stackDirty = false; + } +} - // calculate actual size and position - var foreground = this.dom.foreground; - this.top = foreground.offsetTop; - this.right = foreground.offsetLeft; - this.width = foreground.offsetWidth; +Group.prototype._didResize = function(resized, height) { resized = util.updateProperty(this, 'height', height) || resized; // recalculate size of label - resized = util.updateProperty(this.props.label, 'width', this.dom.inner.clientWidth) || resized; - resized = util.updateProperty(this.props.label, 'height', this.dom.inner.clientHeight) || resized; + var labelWidth = this.dom.inner.clientWidth; + var labelHeight = this.dom.inner.clientHeight; + resized = util.updateProperty(this.props.label, 'width', labelWidth) || resized; + resized = util.updateProperty(this.props.label, 'height', labelHeight) || resized; + return resized; +} - // apply new height - this.dom.background.style.height = height + 'px'; - this.dom.foreground.style.height = height + 'px'; +Group.prototype._applyGroupHeight = function(height) { + this.dom.background.style.height = height + 'px'; + this.dom.foreground.style.height = height + 'px'; this.dom.label.style.height = height + 'px'; +} - // update vertical position of items after they are re-stacked and the height of the group is calculated +// update vertical position of items after they are re-stacked and the height of the group is calculated +Group.prototype._updateItemsVerticalPosition = function(margin) { for (var i = 0, ii = this.visibleItems.length; i < ii; i++) { var item = this.visibleItems[i]; item.repositionY(margin); + if (!this.isVisible && this.groupId != "__background__") { + if (item.displayed) item.hide(); + } } +} - return resized; +/** + * Repaint this group + * @param {{start: number, end: number}} range + * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin + * @param {boolean} [forceRestack=false] Force restacking of all items + * @param {boolean} [returnQueue=false] return the queue or if the group resized + * @return {boolean} Returns true if the group is resized or the redraw queue if returnQueue=true + */ +Group.prototype.redraw = function(range, margin, forceRestack, returnQueue) { + var resized = false; + var lastIsVisible = this.isVisible; + var height; + + var queue = [ + // force recalculation of the height of the items when the marker height changed + // (due to the Timeline being attached to the DOM or changed from display:none to visible) + (function () { + forceRestack = this._didMarkerHeightChange.bind(this); + }).bind(this), + + // recalculate the height of the subgroups + this._updateSubGroupHeights.bind(this, margin), + + // calculate actual size and position + this._calculateGroupSizeAndPosition.bind(this), + + // check if group is visible + (function() { + this.isVisible = this._isGroupVisible.bind(this)(range, margin); + }).bind(this), + + // redraw Items if needed + (function() { + this._redrawItems.bind(this)(forceRestack, lastIsVisible, margin, range) + }).bind(this), + + // update subgroups + this._updateSubgroupsSizes.bind(this), + + // recalculate the height of the group + (function() { + height = this._calculateHeight.bind(this)(margin); + }).bind(this), + + // calculate actual size and position again + this._calculateGroupSizeAndPosition.bind(this), + + // check if resized + (function() { + resized = this._didResize.bind(this)(resized, height) + }).bind(this), + + // apply group height + (function() { + this._applyGroupHeight.bind(this)(height) + }).bind(this), + + // update vertical position of items after they are re-stacked and the height of the group is calculated + (function() { + this._updateItemsVerticalPosition.bind(this)(margin) + }).bind(this), + + function() { + if (!this.isVisible && this.height) { + resized = false; + } + return resized + } + ] + + if (returnQueue) { + return queue; + } else { + var result; + queue.forEach(function (fn) { + result = fn(); + }); + return result; + } }; /** * recalculate the height of the subgroups + * + * @param {{item: vis.Item}} margin * @private */ -Group.prototype._calculateSubGroupHeights = function () { +Group.prototype._updateSubGroupHeights = function (margin) { if (Object.keys(this.subgroups).length > 0) { var me = this; @@ -274,7 +471,7 @@ Group.prototype._calculateSubGroupHeights = function () { util.forEach(this.visibleItems, function (item) { if (item.data.subgroup !== undefined) { - me.subgroups[item.data.subgroup].height = Math.max(me.subgroups[item.data.subgroup].height, item.height); + me.subgroups[item.data.subgroup].height = Math.max(me.subgroups[item.data.subgroup].height, item.height + margin.item.vertical); me.subgroups[item.data.subgroup].visible = true; } }); @@ -283,14 +480,16 @@ Group.prototype._calculateSubGroupHeights = function () { /** * check if group is visible + * + * @param {vis.Range} range + * @param {{axis: vis.DataAxis}} margin + * @returns {boolean} is visible * @private - */ + */ Group.prototype._isGroupVisible = function (range, margin) { - var isVisible = - (this.top <= range.body.domProps.centerContainer.height - range.body.domProps.scrollTop + margin.axis) + return (this.top <= range.body.domProps.centerContainer.height - range.body.domProps.scrollTop + margin.axis) && (this.top + this.height + margin.axis >= - range.body.domProps.scrollTop); - return isVisible; -} +}; /** * recalculate the height of the group @@ -301,11 +500,11 @@ Group.prototype._isGroupVisible = function (range, margin) { Group.prototype._calculateHeight = function (margin) { // recalculate the height of the group var height; - var visibleItems = this.visibleItems; - if (visibleItems.length > 0) { - var min = visibleItems[0].top; - var max = visibleItems[0].top + visibleItems[0].height; - util.forEach(visibleItems, function (item) { + var itemsInRange = this.visibleItems; + if (itemsInRange.length > 0) { + var min = itemsInRange[0].top; + var max = itemsInRange[0].top + itemsInRange[0].height; + util.forEach(itemsInRange, function (item) { min = Math.min(min, item.top); max = Math.max(max, (item.top + item.height)); }); @@ -313,7 +512,7 @@ Group.prototype._calculateHeight = function (margin) { // there is an empty gap between the lowest item and the axis var offset = min - margin.axis; max -= offset; - util.forEach(visibleItems, function (item) { + util.forEach(itemsInRange, function (item) { item.top -= offset; }); } @@ -380,16 +579,12 @@ Group.prototype.hide = function() { Group.prototype.add = function(item) { this.items[item.id] = item; item.setParent(this); - + this.stackDirty = true; // add to if (item.data.subgroup !== undefined) { - if (this.subgroups[item.data.subgroup] === undefined) { - this.subgroups[item.data.subgroup] = {height:0, visible: false, index:this.subgroupIndex, items: []}; - this.subgroupIndex++; - } - this.subgroups[item.data.subgroup].items.push(item); + this._addToSubgroup(item); + this.orderSubgroups(); } - this.orderSubgroups(); if (this.visibleItems.indexOf(item) == -1) { var range = this.itemSet.body.range; // TODO: not nice accessing the range like this @@ -397,11 +592,69 @@ Group.prototype.add = function(item) { } }; + +Group.prototype._addToSubgroup = function(item, subgroupId) { + subgroupId = subgroupId || item.data.subgroup; + if (subgroupId != undefined && this.subgroups[subgroupId] === undefined) { + this.subgroups[subgroupId] = { + height:0, + top: 0, + start: item.data.start, + end: item.data.end || item.data.start, + visible: false, + index:this.subgroupIndex, + items: [], + stack: this.subgroupStackAll || this.subgroupStack[subgroupId] || false + }; + this.subgroupIndex++; + } + + + if (new Date(item.data.start) < new Date(this.subgroups[subgroupId].start)) { + this.subgroups[subgroupId].start = item.data.start; + } + + var itemEnd = item.data.end || item.data.start; + if (new Date(itemEnd) > new Date(this.subgroups[subgroupId].end)) { + this.subgroups[subgroupId].end = itemEnd; + } + + this.subgroups[subgroupId].items.push(item); + +}; + +Group.prototype._updateSubgroupsSizes = function () { + var me = this; + if (me.subgroups) { + for (var subgroup in me.subgroups) { + var initialEnd = me.subgroups[subgroup].items[0].data.end || me.subgroups[subgroup].items[0].data.start; + var newStart = me.subgroups[subgroup].items[0].data.start; + var newEnd = initialEnd - 1; + + me.subgroups[subgroup].items.forEach(function(item) { + if (new Date(item.data.start) < new Date(newStart)) { + newStart = item.data.start; + } + + var itemEnd = item.data.end || item.data.start; + if (new Date(itemEnd) > new Date(newEnd)) { + newEnd = itemEnd; + } + }) + + me.subgroups[subgroup].start = newStart; + me.subgroups[subgroup].end = new Date(newEnd - 1) // -1 to compensate for colliding end to start subgroups; + + } + } +} + Group.prototype.orderSubgroups = function() { if (this.subgroupOrderer !== undefined) { var sortArray = []; + var subgroup; if (typeof this.subgroupOrderer == 'string') { - for (var subgroup in this.subgroups) { + for (subgroup in this.subgroups) { sortArray.push({subgroup: subgroup, sortField: this.subgroups[subgroup].items[0].data[this.subgroupOrderer]}) } sortArray.sort(function (a, b) { @@ -409,7 +662,7 @@ Group.prototype.orderSubgroups = function() { }) } else if (typeof this.subgroupOrderer == 'function') { - for (var subgroup in this.subgroups) { + for (subgroup in this.subgroups) { sortArray.push(this.subgroups[subgroup].items[0].data); } sortArray.sort(this.subgroupOrderer); @@ -427,6 +680,7 @@ Group.prototype.resetSubgroups = function() { for (var subgroup in this.subgroups) { if (this.subgroups.hasOwnProperty(subgroup)) { this.subgroups[subgroup].visible = false; + this.subgroups[subgroup].height = 0; } } }; @@ -438,26 +692,37 @@ Group.prototype.resetSubgroups = function() { Group.prototype.remove = function(item) { delete this.items[item.id]; item.setParent(null); + this.stackDirty = true; // remove from visible items var index = this.visibleItems.indexOf(item); if (index != -1) this.visibleItems.splice(index, 1); if(item.data.subgroup !== undefined){ - var subgroup = this.subgroups[item.data.subgroup]; + this._removeFromSubgroup(item); + this.orderSubgroups(); + } +}; + +Group.prototype._removeFromSubgroup = function(item, subgroupId) { + subgroupId = subgroupId || item.data.subgroup; + if (subgroupId != undefined) { + var subgroup = this.subgroups[subgroupId]; if (subgroup){ var itemIndex = subgroup.items.indexOf(item); - subgroup.items.splice(itemIndex,1); - if (!subgroup.items.length){ - delete this.subgroups[item.data.subgroup]; - this.subgroupIndex--; + // Check the item is actually in this subgroup. How should items not in the group be handled? + if (itemIndex >= 0) { + subgroup.items.splice(itemIndex,1); + if (!subgroup.items.length){ + delete this.subgroups[subgroupId]; + } else { + this._updateSubgroupsSizes(); + } } - this.orderSubgroups(); } } }; - /** * Remove an item from the corresponding DataSet * @param {Item} item @@ -494,21 +759,14 @@ Group.prototype.order = function() { /** * Update the visible items * @param {{byStart: Item[], byEnd: Item[]}} orderedItems All items ordered by start date and by end date - * @param {Item[]} visibleItems The previously visible items. + * @param {Item[]} oldVisibleItems The previously visible items. * @param {{start: number, end: number}} range Visible range * @return {Item[]} visibleItems The new visible items. * @private */ -Group.prototype._updateVisibleItems = function(orderedItems, oldVisibleItems, range) { +Group.prototype._updateItemsInRange = function(orderedItems, oldVisibleItems, range) { var visibleItems = []; var visibleItemsLookup = {}; // we keep this to quickly look up if an item already exists in the list without using indexOf on visibleItems - if (!this.isVisible && this.groupId != "__background__") { - for (var i = 0; i < oldVisibleItems.length; i++) { - var item = oldVisibleItems[i]; - if (item.displayed) item.hide(); - } - return visibleItems; - } var interval = (range.end - range.start) / 4; var lowerBound = range.start - interval; @@ -519,7 +777,7 @@ Group.prototype._updateVisibleItems = function(orderedItems, oldVisibleItems, ra if (value < lowerBound) {return -1;} else if (value <= upperBound) {return 0;} else {return 1;} - } + }; // first check if the items that were in view previously are still in view. // IMPORTANT: this handles the case for the items with startdate before the window and enddate after the window! @@ -556,21 +814,39 @@ Group.prototype._updateVisibleItems = function(orderedItems, oldVisibleItems, ra }); } - // finally, we reposition all the visible items. - for (var i = 0; i < visibleItems.length; i++) { + var redrawQueue = {}; + var redrawQueueLength = 0; + + for (i = 0; i < visibleItems.length; i++) { var item = visibleItems[i]; - if (!item.displayed) item.show(); - // reposition item horizontally - item.repositionX(); + if (!item.displayed) { + var returnQueue = true; + redrawQueue[i] = item.redraw(returnQueue); + redrawQueueLength = redrawQueue[i].length; + } + } + + var needRedraw = redrawQueueLength > 0; + if (needRedraw) { + // redraw all regular items + for (var j = 0; j < redrawQueueLength; j++) { + util.forEach(redrawQueue, function (fns) { + fns[j](); + }); + } + } + + for (i = 0; i < visibleItems.length; i++) { + visibleItems[i].repositionX(); } - return visibleItems; }; Group.prototype._traceVisible = function (initialPos, items, visibleItems, visibleItemsLookup, breakCondition) { if (initialPos != -1) { - for (var i = initialPos; i >= 0; i--) { - var item = items[i]; + var i, item; + for (i = initialPos; i >= 0; i--) { + item = items[i]; if (breakCondition(item)) { break; } @@ -582,8 +858,8 @@ Group.prototype._traceVisible = function (initialPos, items, visibleItems, visib } } - for (var i = initialPos + 1; i < items.length; i++) { - var item = items[i]; + for (i = initialPos + 1; i < items.length; i++) { + item = items[i]; if (breakCondition(item)) { break; } @@ -629,7 +905,8 @@ Group.prototype._checkIfVisible = function(item, visibleItems, range) { * this one is for brute forcing and hiding. * * @param {Item} item - * @param {Array} visibleItems + * @param {Array.} visibleItems + * @param {Object} visibleItemsLookup * @param {{start:number, end:number}} range * @private */ @@ -645,6 +922,10 @@ Group.prototype._checkIfVisibleWithReference = function(item, visibleItems, visi } }; - +Group.prototype.changeSubgroup = function(item, oldSubgroup, newSubgroup) { + this._removeFromSubgroup(item, oldSubgroup); + this._addToSubgroup(item, newSubgroup); + this.orderSubgroups(); +}; module.exports = Group; diff --git a/lib/timeline/component/ItemSet.js b/lib/timeline/component/ItemSet.js index 9329263a8..9e683e555 100644 --- a/lib/timeline/component/ItemSet.js +++ b/lib/timeline/component/ItemSet.js @@ -10,6 +10,7 @@ var BoxItem = require('./item/BoxItem'); var PointItem = require('./item/PointItem'); var RangeItem = require('./item/RangeItem'); var BackgroundItem = require('./item/BackgroundItem'); +var Popup = require('../../shared/Popup').default; var UNGROUPED = '__ungrouped__'; // reserved group id for ungrouped items @@ -33,32 +34,41 @@ function ItemSet(body, options) { }, align: 'auto', // alignment of box items stack: true, - groupOrderSwap: function(fromGroup, toGroup, groups) { - var targetOrder = toGroup.order; - toGroup.order = fromGroup.order; - fromGroup.order = targetOrder; + stackSubgroups: true, + groupOrderSwap: function(fromGroup, toGroup, groups) { // eslint-disable-line no-unused-vars + var targetOrder = toGroup.order; + toGroup.order = fromGroup.order; + fromGroup.order = targetOrder; }, groupOrder: 'order', selectable: true, multiselect: false, - itemsAlwaysDraggable: false, + itemsAlwaysDraggable: { + item: false, + range: false, + }, editable: { updateTime: false, updateGroup: false, add: false, - remove: false + remove: false, + overrideItems: false }, groupEditable: { order: false, add: false, remove: false - }, - + }, + snap: TimeStep.snap, + // Only called when `objectData.target === 'item'. + onDropObjectOnItem: function(objectData, item, callback) { + callback(item) + }, onAdd: function (item, callback) { callback(item); }, @@ -92,6 +102,13 @@ function ItemSet(body, options) { axis: 20 }, + showTooltips: true, + + tooltip: { + followMouse: false, + overflowMethod: 'flip' + }, + tooltipOnItemUpdateTime: false }; @@ -118,26 +135,48 @@ function ItemSet(body, options) { // listeners for the DataSet of the items this.itemListeners = { - 'add': function (event, params, senderId) { + 'add': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onAdd(params.items); }, - 'update': function (event, params, senderId) { + 'update': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onUpdate(params.items); }, - 'remove': function (event, params, senderId) { + 'remove': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onRemove(params.items); } }; // listeners for the DataSet of the groups this.groupListeners = { - 'add': function (event, params, senderId) { + 'add': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onAddGroups(params.items); + + if (me.groupsData && me.groupsData.length > 0) { + var groupsData = me.groupsData.getDataSet(); + groupsData.get().forEach(function (groupData) { + if (groupData.nestedGroups) { + if (groupData.showNested != false) { + groupData.showNested = true; + } + var updatedGroups = []; + groupData.nestedGroups.forEach(function(nestedGroupId) { + var updatedNestedGroup = groupsData.get(nestedGroupId); + if (!updatedNestedGroup) { return; } + updatedNestedGroup.nestedInGroup = groupData.id; + if (groupData.showNested == false) { + updatedNestedGroup.visible = false; + } + updatedGroups = updatedGroups.concat(updatedNestedGroup); + }); + groupsData.update(updatedGroups, senderId); + } + }); + } }, - 'update': function (event, params, senderId) { + 'update': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onUpdateGroups(params.items); }, - 'remove': function (event, params, senderId) { + 'remove': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onRemoveGroups(params.items); } }; @@ -147,7 +186,8 @@ function ItemSet(body, options) { this.groupIds = []; this.selection = []; // list with the ids of all selected nodes - this.stackDirty = true; // if true, all items will be restacked on next redraw + + this.popup = null; this.touchParams = {}; // stores properties while dragging this.groupTouchParams = {}; @@ -239,13 +279,19 @@ ItemSet.prototype._create = function(){ this.groupHammer = new Hammer(this.body.dom.leftContainer); } + this.groupHammer.on('tap', this._onGroupClick.bind(this)); this.groupHammer.on('panstart', this._onGroupDragStart.bind(this)); this.groupHammer.on('panmove', this._onGroupDrag.bind(this)); this.groupHammer.on('panend', this._onGroupDragEnd.bind(this)); - this.groupHammer.get('pan').set({threshold:5, direction: Hammer.DIRECTION_HORIZONTAL}); + this.groupHammer.get('pan').set({threshold:5, direction: Hammer.DIRECTION_VERTICAL}); this.body.dom.centerContainer.addEventListener('mouseover', this._onMouseOver.bind(this)); this.body.dom.centerContainer.addEventListener('mouseout', this._onMouseOut.bind(this)); + this.body.dom.centerContainer.addEventListener('mousemove', this._onMouseMove.bind(this)); + // right-click on timeline + this.body.dom.centerContainer.addEventListener('contextmenu', this._onDragEnd.bind(this)); + + this.body.dom.centerContainer.addEventListener('mousewheel', this._onMouseWheel.bind(this)); // attach to the DOM this.show(); @@ -254,51 +300,51 @@ ItemSet.prototype._create = function(){ /** * Set options for the ItemSet. Existing options will be extended/overwritten. * @param {Object} [options] The following options are available: - * {String} type + * {string} type * Default type for the items. Choose from 'box' * (default), 'point', 'range', or 'background'. * The default style can be overwritten by * individual items. - * {String} align + * {string} align * Alignment for the items, only applicable for * BoxItem. Choose 'center' (default), 'left', or * 'right'. - * {String} orientation.item + * {string} orientation.item * Orientation of the item set. Choose 'top' or * 'bottom' (default). * {Function} groupOrder * A sorting function for ordering groups - * {Boolean} stack + * {boolean} stack * If true (default), items will be stacked on * top of each other. - * {Number} margin.axis + * {number} margin.axis * Margin between the axis and the items in pixels. * Default is 20. - * {Number} margin.item.horizontal + * {number} margin.item.horizontal * Horizontal margin between items in pixels. * Default is 10. - * {Number} margin.item.vertical + * {number} margin.item.vertical * Vertical Margin between items in pixels. * Default is 10. - * {Number} margin.item + * {number} margin.item * Margin between items in pixels in both horizontal * and vertical direction. Default is 10. - * {Number} margin + * {number} margin * Set margin for both axis and items in pixels. - * {Boolean} selectable + * {boolean} selectable * If true (default), items can be selected. - * {Boolean} multiselect + * {boolean} multiselect * If true, multiple items can be selected. * False by default. - * {Boolean} editable + * {boolean} editable * Set all editable options to true or false - * {Boolean} editable.updateTime + * {boolean} editable.updateTime * Allow dragging an item to an other moment in time - * {Boolean} editable.updateGroup + * {boolean} editable.updateGroup * Allow dragging an item to an other group - * {Boolean} editable.add + * {boolean} editable.add * Allow creating new items on double tap - * {Boolean} editable.remove + * {boolean} editable.remove * Allow removing items by clicking the delete button * top right of a selected item. * {Function(item: Item, callback: Function)} onAdd @@ -319,12 +365,26 @@ ItemSet.prototype.setOptions = function(options) { if (options) { // copy all options that we know var fields = [ - 'type', 'rtl', 'align', 'order', 'stack', 'selectable', 'multiselect', 'itemsAlwaysDraggable', - 'multiselectPerGroup', 'groupOrder', 'dataAttributes', 'template', 'groupTemplate', 'hide', 'snap', - 'groupOrderSwap', 'tooltipOnItemUpdateTime' + 'type', 'rtl', 'align', 'order', 'stack', 'stackSubgroups', 'selectable', 'multiselect', + 'multiselectPerGroup', 'groupOrder', 'dataAttributes', 'template', 'groupTemplate', 'visibleFrameTemplate', + 'hide', 'snap', 'groupOrderSwap', 'showTooltips', 'tooltip', 'tooltipOnItemUpdateTime' ]; util.selectiveExtend(fields, this.options, options); + if ('itemsAlwaysDraggable' in options) { + if (typeof options.itemsAlwaysDraggable === 'boolean') { + this.options.itemsAlwaysDraggable.item = options.itemsAlwaysDraggable; + this.options.itemsAlwaysDraggable.range = false; + } + else if (typeof options.itemsAlwaysDraggable === 'object') { + util.selectiveExtend(['item', 'range'], this.options.itemsAlwaysDraggable, options.itemsAlwaysDraggable); + // only allow range always draggable when item is always draggable as well + if (! this.options.itemsAlwaysDraggable.item) { + this.options.itemsAlwaysDraggable.range = false; + } + } + } + if ('orientation' in options) { if (typeof options.orientation === 'string') { this.options.orientation.item = options.orientation === 'top' ? 'top' : 'bottom'; @@ -356,16 +416,17 @@ ItemSet.prototype.setOptions = function(options) { if ('editable' in options) { if (typeof options.editable === 'boolean') { - this.options.editable.updateTime = options.editable; - this.options.editable.updateGroup = options.editable; - this.options.editable.add = options.editable; - this.options.editable.remove = options.editable; + this.options.editable.updateTime = options.editable; + this.options.editable.updateGroup = options.editable; + this.options.editable.add = options.editable; + this.options.editable.remove = options.editable; + this.options.editable.overrideItems = false; } else if (typeof options.editable === 'object') { - util.selectiveExtend(['updateTime', 'updateGroup', 'add', 'remove'], this.options.editable, options.editable); + util.selectiveExtend(['updateTime', 'updateGroup', 'add', 'remove', 'overrideItems'], this.options.editable, options.editable); } } - + if ('groupEditable' in options) { if (typeof options.groupEditable === 'boolean') { this.options.groupEditable.order = options.groupEditable; @@ -387,7 +448,7 @@ ItemSet.prototype.setOptions = function(options) { this.options[name] = fn; } }).bind(this); - ['onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving', 'onAddGroup', 'onMoveGroup', 'onRemoveGroup'].forEach(addCallback); + ['onDropObjectOnItem', 'onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving', 'onAddGroup', 'onMoveGroup', 'onRemoveGroup'].forEach(addCallback); // force the itemSet to refresh: options like orientation and margins may be changed this.markDirty(); @@ -401,7 +462,6 @@ ItemSet.prototype.setOptions = function(options) { */ ItemSet.prototype.markDirty = function(options) { this.groupIds = []; - this.stackDirty = true; if (options && options.refreshItems) { util.forEach(this.items, function (item) { @@ -447,7 +507,6 @@ ItemSet.prototype.hide = function() { /** * Show the component in the DOM (when not already visible). - * @return {Boolean} changed */ ItemSet.prototype.show = function() { // show frame containing the items @@ -516,20 +575,21 @@ ItemSet.prototype.getSelection = function() { */ ItemSet.prototype.getVisibleItems = function() { var range = this.body.range.getRange(); + var right, left; if (this.options.rtl) { - var right = this.body.util.toScreen(range.start); - var left = this.body.util.toScreen(range.end); + right = this.body.util.toScreen(range.start); + left = this.body.util.toScreen(range.end); } else { - var left = this.body.util.toScreen(range.start); - var right = this.body.util.toScreen(range.end); + left = this.body.util.toScreen(range.start); + right = this.body.util.toScreen(range.end); } var ids = []; for (var groupId in this.groups) { if (this.groups.hasOwnProperty(groupId)) { var group = this.groups[groupId]; - var rawVisibleItems = group.visibleItems; + var rawVisibleItems = group.isVisible ? group.visibleItems : []; // filter the "raw" set with visibleItems into a set which is really // visible by pixels @@ -554,7 +614,7 @@ ItemSet.prototype.getVisibleItems = function() { /** * Deselect a selected item - * @param {String | Number} id + * @param {string | number} id * @private */ ItemSet.prototype._deselect = function(id) { @@ -599,11 +659,17 @@ ItemSet.prototype.redraw = function() { // TODO: would be nicer to get this as a trigger from Range var visibleInterval = range.end - range.start; var zoomed = (visibleInterval != this.lastVisibleInterval) || (this.props.width != this.props.lastWidth); - if (zoomed) this.stackDirty = true; + var scrolled = range.start != this.lastRangeStart; + var changedStackOption = options.stack != this.lastStack; + var changedStackSubgroupsOption = options.stackSubgroups != this.lastStackSubgroups; + var forceRestack = (zoomed || scrolled || changedStackOption || changedStackSubgroupsOption); this.lastVisibleInterval = visibleInterval; + this.lastRangeStart = range.start; + this.lastStack = options.stack; + this.lastStackSubgroups = options.stackSubgroups; + this.props.lastWidth = this.props.width; - var restack = this.stackDirty; var firstGroup = this._firstGroup(); var firstMargin = { item: margin.item, @@ -617,17 +683,41 @@ ItemSet.prototype.redraw = function() { var minHeight = margin.axis + margin.item.vertical; // redraw the background group - this.groups[BACKGROUND].redraw(range, nonFirstMargin, restack); - - // redraw all regular groups - util.forEach(this.groups, function (group) { - var groupMargin = (group == firstGroup) ? firstMargin : nonFirstMargin; - var groupResized = group.redraw(range, groupMargin, restack); - resized = groupResized || resized; - height += group.height; + this.groups[BACKGROUND].redraw(range, nonFirstMargin, forceRestack); + + var redrawQueue = {}; + var redrawQueueLength = 0; + + // collect redraw functions + util.forEach(this.groups, function (group, key) { + if (key === BACKGROUND) return; + var groupMargin = group == firstGroup ? firstMargin : nonFirstMargin; + var returnQueue = true; + redrawQueue[key] = group.redraw(range, groupMargin, forceRestack, returnQueue); + redrawQueueLength = redrawQueue[key].length; }); + + var needRedraw = redrawQueueLength > 0; + if (needRedraw) { + var redrawResults = {}; + + for (var i = 0; i < redrawQueueLength; i++) { + util.forEach(redrawQueue, function (fns, key) { + redrawResults[key] = fns[i](); + }); + } + + // redraw all regular groups + util.forEach(this.groups, function (group, key) { + if (key === BACKGROUND) return; + var groupResized = redrawResults[key]; + resized = groupResized || resized; + height += group.height; + }); + height = Math.max(height, minHeight); + } + height = Math.max(height, minHeight); - this.stackDirty = false; // update frame height frame.style.height = asSize(height); @@ -646,6 +736,7 @@ ItemSet.prototype.redraw = function() { this.dom.axis.style.left = '0'; } + this.initialItemSetDrawn = true; // check if this component is resized resized = this._isResized() || resized; @@ -672,7 +763,6 @@ ItemSet.prototype._firstGroup = function() { */ ItemSet.prototype._updateUngrouped = function() { var ungrouped = this.groups[UNGROUPED]; - var background = this.groups[BACKGROUND]; var item, itemId; if (this.groupsData) { @@ -809,6 +899,26 @@ ItemSet.prototype.setGroups = function(groups) { } if (this.groupsData) { + // go over all groups nesting + var groupsData = this.groupsData; + if (this.groupsData instanceof DataView) { + groupsData = this.groupsData.getDataSet() + } + + groupsData.get().forEach(function(group){ + if (group.nestedGroups) { + group.nestedGroups.forEach(function(nestedGroupId) { + var updatedNestedGroup = groupsData.get(nestedGroupId); + updatedNestedGroup.nestedInGroup = group.id; + if (group.showNested == false) { + updatedNestedGroup.visible = false; + } + groupsData.update(updatedNestedGroup); + }) + } + }); + + // subscribe to new dataset var id = this.id; util.forEach(this.groupListeners, function (callback, event) { @@ -839,7 +949,7 @@ ItemSet.prototype.getGroups = function() { /** * Remove an item by its id - * @param {String | Number} id + * @param {string | number} id */ ItemSet.prototype.removeItem = function(id) { var item = this.itemsData.get(id), @@ -886,7 +996,7 @@ ItemSet.prototype._getGroupId = function (itemData) { /** * Handle updated items - * @param {Number[]} ids + * @param {number[]} ids * @protected */ ItemSet.prototype._onUpdate = function(ids) { @@ -901,7 +1011,7 @@ ItemSet.prototype._onUpdate = function(ids) { var selected; if (item) { - // update item + // update item if (!constructor || !(item instanceof constructor)) { // item type has changed, delete the item and recreate it selected = item.selected; // preserve selection of this item @@ -918,6 +1028,7 @@ ItemSet.prototype._onUpdate = function(ids) { if (constructor) { item = new constructor(itemData, me.conversion, me.options); item.id = id; // TODO: not so nice setting id afterwards + me._addItem(item); if (selected) { this.selection.push(id); @@ -936,20 +1047,19 @@ ItemSet.prototype._onUpdate = function(ids) { }.bind(this)); this._order(); - this.stackDirty = true; // force re-stacking of all items next redraw this.body.emitter.emit('_change', {queue: true}); }; /** * Handle added items - * @param {Number[]} ids + * @param {number[]} ids * @protected */ ItemSet.prototype._onAdd = ItemSet.prototype._onUpdate; /** * Handle removed items - * @param {Number[]} ids + * @param {number[]} ids * @protected */ ItemSet.prototype._onRemove = function(ids) { @@ -966,7 +1076,6 @@ ItemSet.prototype._onRemove = function(ids) { if (count) { // update order this._order(); - this.stackDirty = true; // force re-stacking of all items next redraw this.body.emitter.emit('_change', {queue: true}); } }; @@ -985,7 +1094,7 @@ ItemSet.prototype._order = function() { /** * Handle updated groups - * @param {Number[]} ids + * @param {number[]} ids * @private */ ItemSet.prototype._onUpdateGroups = function(ids) { @@ -994,7 +1103,7 @@ ItemSet.prototype._onUpdateGroups = function(ids) { /** * Handle changed groups (added or updated) - * @param {Number[]} ids + * @param {number[]} ids * @private */ ItemSet.prototype._onAddGroups = function(ids) { @@ -1042,7 +1151,7 @@ ItemSet.prototype._onAddGroups = function(ids) { /** * Handle removed groups - * @param {Number[]} ids + * @param {number[]} ids * @private */ ItemSet.prototype._onRemoveGroups = function(ids) { @@ -1073,6 +1182,8 @@ ItemSet.prototype._orderGroups = function () { order: this.options.groupOrder }); + groupIds = this._orderNestedGroups(groupIds); + var changed = !util.equalArray(groupIds, this.groupIds); if (changed) { // hide all groups, removes them from the DOM @@ -1096,6 +1207,36 @@ ItemSet.prototype._orderGroups = function () { } }; +/** + * Reorder the nested groups + * + * @param {Array.} groupIds + * @returns {Array.} + * @private + */ +ItemSet.prototype._orderNestedGroups = function(groupIds) { + var newGroupIdsOrder = []; + + groupIds.forEach(function(groupId){ + var groupData = this.groupsData.get(groupId); + if (!groupData.nestedInGroup) { + newGroupIdsOrder.push(groupId) + } + if (groupData.nestedGroups) { + var nestedGroups = this.groupsData.get({ + filter: function(nestedGroup) { + return nestedGroup.nestedInGroup == groupId; + }, + order: this.options.groupOrder + }); + var nestedGroupIds = nestedGroups.map(function(nestedGroup) { return nestedGroup.id }); + newGroupIdsOrder = newGroupIdsOrder.concat(nestedGroupIds); + } + }, this); + return newGroupIdsOrder; +}; + + /** * Add a new item * @param {Item} item @@ -1107,6 +1248,13 @@ ItemSet.prototype._addItem = function(item) { // add to group var groupId = this._getGroupId(item.data); var group = this.groups[groupId]; + + if (!group) { + item.groupShowing = false; + } else if (group && group.data && group.data.showNested) { + item.groupShowing = true; + } + if (group) group.add(item); }; @@ -1117,20 +1265,15 @@ ItemSet.prototype._addItem = function(item) { * @private */ ItemSet.prototype._updateItem = function(item, itemData) { - var oldGroupId = item.data.group; - var oldSubGroupId = item.data.subgroup; - // update the items data (will redraw the item when displayed) item.setData(itemData); - // update group - if (oldGroupId != item.data.group || oldSubGroupId != item.data.subgroup) { - var oldGroup = this.groups[oldGroupId]; - if (oldGroup) oldGroup.remove(item); - - var groupId = this._getGroupId(item.data); - var group = this.groups[groupId]; - if (group) group.add(item); + var groupId = this._getGroupId(item.data); + var group = this.groups[groupId]; + if (!group) { + item.groupShowing = false; + } else if (group && group.data && group.data.showNested) { + item.groupShowing = true; } }; @@ -1157,7 +1300,7 @@ ItemSet.prototype._removeItem = function(item) { /** * Create an array containing all items being a range (having an end date) - * @param array + * @param {Array.} array * @returns {Array} * @private */ @@ -1194,7 +1337,8 @@ ItemSet.prototype._onTouch = function (event) { /** * Given an group id, returns the index it has. * - * @param {Number} groupID + * @param {number} groupId + * @returns {number} index / groupId * @private */ ItemSet.prototype._getGroupIndex = function(groupId) { @@ -1210,26 +1354,29 @@ ItemSet.prototype._getGroupIndex = function(groupId) { * @private */ ItemSet.prototype._onDragStart = function (event) { + if (this.touchParams.itemIsDragging) { return; } var item = this.touchParams.item || null; var me = this; var props; - if (item && (item.selected || this.options.itemsAlwaysDraggable)) { + if (item && (item.selected || this.options.itemsAlwaysDraggable.item)) { - if (!this.options.editable.updateTime && - !this.options.editable.updateGroup && - !item.editable) { + if (this.options.editable.overrideItems && + !this.options.editable.updateTime && + !this.options.editable.updateGroup) { return; } // override options.editable - if (item.editable === false) { + if ((item.editable != null && !item.editable.updateTime && !item.editable.updateGroup) + && !this.options.editable.overrideItems) { return; } var dragLeftItem = this.touchParams.dragLeftItem; var dragRightItem = this.touchParams.dragRightItem; this.touchParams.itemIsDragging = true; + this.touchParams.selectedItem = item; if (dragLeftItem) { props = { @@ -1240,8 +1387,7 @@ ItemSet.prototype._onDragStart = function (event) { }; this.touchParams.itemProps = [props]; - } - else if (dragRightItem) { + } else if (dragRightItem) { props = { item: dragRightItem, initialX: event.center.x, @@ -1250,13 +1396,19 @@ ItemSet.prototype._onDragStart = function (event) { }; this.touchParams.itemProps = [props]; - } - else { - this.touchParams.selectedItem = item; - + } else if (this.options.editable.add && (event.srcEvent.ctrlKey || event.srcEvent.metaKey)) { + // create a new range item when dragging with ctrl key down + this._onDragStartAddItem(event); + } else { + if(this.groupIds.length < 1) { + // Mitigates a race condition if _onDragStart() is + // called after markDirty() without redraw() being called between. + this.redraw(); + } + var baseGroupIndex = this._getGroupIndex(item.data.group); - var itemsToDrag = (this.options.itemsAlwaysDraggable && !item.selected) ? [item.id] : this.getSelection(); + var itemsToDrag = (this.options.itemsAlwaysDraggable.item && !item.selected) ? [item.id] : this.getSelection(); this.touchParams.itemProps = itemsToDrag.map(function (id) { var item = me.items[id]; @@ -1271,8 +1423,7 @@ ItemSet.prototype._onDragStart = function (event) { } event.stopPropagation(); - } - else if (this.options.editable.add && (event.srcEvent.ctrlKey || event.srcEvent.metaKey)) { + } else if (this.options.editable.add && (event.srcEvent.ctrlKey || event.srcEvent.metaKey)) { // create a new range item when dragging with ctrl key down this._onDragStartAddItem(event); } @@ -1284,14 +1435,16 @@ ItemSet.prototype._onDragStart = function (event) { * @private */ ItemSet.prototype._onDragStartAddItem = function (event) { + var xAbs; + var x; var snap = this.options.snap || null; if (this.options.rtl) { - var xAbs = util.getAbsoluteRight(this.dom.frame); - var x = xAbs - event.center.x + 10; // plus 10 to compensate for the drag starting as soon as you've moved 10px + xAbs = util.getAbsoluteRight(this.dom.frame); + x = xAbs - event.center.x + 10; // plus 10 to compensate for the drag starting as soon as you've moved 10px } else { - var xAbs = util.getAbsoluteLeft(this.dom.frame); - var x = event.center.x - xAbs - 10; // minus 10 to compensate for the drag starting as soon as you've moved 10px + xAbs = util.getAbsoluteLeft(this.dom.frame); + x = event.center.x - xAbs - 10; // minus 10 to compensate for the drag starting as soon as you've moved 10px } var time = this.body.util.toTime(x); @@ -1318,7 +1471,8 @@ ItemSet.prototype._onDragStartAddItem = function (event) { newItem.id = id; // TODO: not so nice setting id afterwards newItem.data = this._cloneItemData(itemData); this._addItem(newItem); - + this.touchParams.selectedItem = newItem; + var props = { item: newItem, initialX: event.center.x, @@ -1346,11 +1500,12 @@ ItemSet.prototype._onDrag = function (event) { var me = this; var snap = this.options.snap || null; + var xOffset; if (this.options.rtl) { - var xOffset = this.body.dom.root.offsetLeft + this.body.domProps.right.width; + xOffset = this.body.dom.root.offsetLeft + this.body.domProps.right.width; } else { - var xOffset = this.body.dom.root.offsetLeft + this.body.domProps.left.width; + xOffset = this.body.dom.root.offsetLeft + this.body.domProps.left.width; } var scale = this.body.util.getScale(); @@ -1358,7 +1513,8 @@ ItemSet.prototype._onDrag = function (event) { //only calculate the new group for the item that's actually dragged var selectedItem = this.touchParams.selectedItem; - var updateGroupAllowed = me.options.editable.updateGroup; + var updateGroupAllowed = ((this.options.editable.overrideItems || selectedItem.editable == null) && this.options.editable.updateGroup) || + (!this.options.editable.overrideItems && selectedItem.editable != null && selectedItem.editable.updateGroup); var newGroupBase = null; if (updateGroupAllowed && selectedItem) { if (selectedItem.data.group != undefined) { @@ -1376,34 +1532,42 @@ ItemSet.prototype._onDrag = function (event) { this.touchParams.itemProps.forEach(function (props) { var current = me.body.util.toTime(event.center.x - xOffset); var initial = me.body.util.toTime(props.initialX - xOffset); + var offset; + var initialStart; + var initialEnd; + var start; + var end; if (this.options.rtl) { - var offset = -(current - initial); // ms + offset = -(current - initial); // ms } else { - var offset = (current - initial); // ms + offset = (current - initial); // ms } var itemData = this._cloneItemData(props.item.data); // clone the data - if (props.item.editable === false) { + if (props.item.editable != null + && !props.item.editable.updateTime + && !props.item.editable.updateGroup + && !me.options.editable.overrideItems) { return; } - var updateTimeAllowed = me.options.editable.updateTime || - props.item.editable === true; + var updateTimeAllowed = ((this.options.editable.overrideItems || selectedItem.editable == null) && this.options.editable.updateTime) || + (!this.options.editable.overrideItems && selectedItem.editable != null && selectedItem.editable.updateTime); if (updateTimeAllowed) { if (props.dragLeft) { // drag left side of a range item if (this.options.rtl) { if (itemData.end != undefined) { - var initialEnd = util.convert(props.data.end, 'Date'); - var end = new Date(initialEnd.valueOf() + offset); + initialEnd = util.convert(props.data.end, 'Date'); + end = new Date(initialEnd.valueOf() + offset); // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.end = snap ? snap(end, scale, step) : end; } } else { if (itemData.start != undefined) { - var initialStart = util.convert(props.data.start, 'Date'); - var start = new Date(initialStart.valueOf() + offset); + initialStart = util.convert(props.data.start, 'Date'); + start = new Date(initialStart.valueOf() + offset); // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; } @@ -1413,15 +1577,15 @@ ItemSet.prototype._onDrag = function (event) { // drag right side of a range item if (this.options.rtl) { if (itemData.start != undefined) { - var initialStart = util.convert(props.data.start, 'Date'); - var start = new Date(initialStart.valueOf() + offset); + initialStart = util.convert(props.data.start, 'Date'); + start = new Date(initialStart.valueOf() + offset); // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; } } else { if (itemData.end != undefined) { - var initialEnd = util.convert(props.data.end, 'Date'); - var end = new Date(initialEnd.valueOf() + offset); + initialEnd = util.convert(props.data.end, 'Date'); + end = new Date(initialEnd.valueOf() + offset); // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.end = snap ? snap(end, scale, step) : end; } @@ -1431,11 +1595,11 @@ ItemSet.prototype._onDrag = function (event) { // drag both start and end if (itemData.start != undefined) { - var initialStart = util.convert(props.data.start, 'Date').valueOf(); - var start = new Date(initialStart + offset); + initialStart = util.convert(props.data.start, 'Date').valueOf(); + start = new Date(initialStart + offset); if (itemData.end != undefined) { - var initialEnd = util.convert(props.data.end, 'Date'); + initialEnd = util.convert(props.data.end, 'Date'); var duration = initialEnd.valueOf() - initialStart.valueOf(); // TODO: pass a Moment instead of a Date to snap(). (Breaking change) @@ -1446,15 +1610,10 @@ ItemSet.prototype._onDrag = function (event) { // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; } - - } } } - var updateGroupAllowed = me.options.editable.updateGroup || - props.item.editable === true; - if (updateGroupAllowed && (!props.dragLeft && !props.dragRight) && newGroupBase!=null) { if (itemData.group != undefined) { var newOffset = newGroupBase - props.groupOffset; @@ -1462,7 +1621,6 @@ ItemSet.prototype._onDrag = function (event) { //make sure we stay in bounds newOffset = Math.max(0, newOffset); newOffset = Math.min(me.groupIds.length-1, newOffset); - itemData.group = me.groupIds[newOffset]; } } @@ -1475,8 +1633,7 @@ ItemSet.prototype._onDrag = function (event) { } }.bind(this)); }.bind(this)); - - this.stackDirty = true; // force re-stacking of all items next redraw + this.body.emitter.emit('_change'); } }; @@ -1484,7 +1641,7 @@ ItemSet.prototype._onDrag = function (event) { /** * Move an item to another group * @param {Item} item - * @param {String | Number} groupId + * @param {string | number} groupId * @private */ ItemSet.prototype._moveToGroup = function(item, groupId) { @@ -1493,10 +1650,11 @@ ItemSet.prototype._moveToGroup = function(item, groupId) { var oldGroup = item.parent; oldGroup.remove(item); oldGroup.order(); + + item.data.group = group.groupId; + group.add(item); group.order(); - - item.data.group = group.groupId; } }; @@ -1506,6 +1664,7 @@ ItemSet.prototype._moveToGroup = function(item, groupId) { * @private */ ItemSet.prototype._onDragEnd = function (event) { + this.touchParams.itemIsDragging = false; if (this.touchParams.itemProps) { event.stopPropagation(); @@ -1513,7 +1672,6 @@ ItemSet.prototype._onDragEnd = function (event) { var dataset = this.itemsData.getDataSet(); var itemProps = this.touchParams.itemProps ; this.touchParams.itemProps = null; - this.touchParams.itemIsDragging = false; itemProps.forEach(function (props) { var id = props.item.id; @@ -1528,7 +1686,6 @@ ItemSet.prototype._onDragEnd = function (event) { } // force re-stacking of all items next redraw - me.stackDirty = true; me.body.emitter.emit('_change'); }); } @@ -1545,7 +1702,6 @@ ItemSet.prototype._onDragEnd = function (event) { // restore original values props.item.setData(props.data); - me.stackDirty = true; // force re-stacking of all items next redraw me.body.emitter.emit('_change'); } }); @@ -1554,6 +1710,34 @@ ItemSet.prototype._onDragEnd = function (event) { } }; +ItemSet.prototype._onGroupClick = function (event) { + var group = this.groupFromTarget(event); + + if (!group || !group.nestedGroups) return; + + var groupsData = this.groupsData.getDataSet(); + + var nestingGroup = groupsData.get(group.groupId) + if (nestingGroup.showNested == undefined) { nestingGroup.showNested = true; } + nestingGroup.showNested = !nestingGroup.showNested; + + var nestedGroups = groupsData.get(group.nestedGroups).map(function(nestedGroup) { + nestedGroup.visible = nestingGroup.showNested; + return nestedGroup; + }); + + groupsData.update(nestedGroups.concat(nestingGroup)); + + if (nestingGroup.showNested) { + util.removeClassName(group.dom.label, 'collapsed'); + util.addClassName(group.dom.label, 'expanded'); + } else { + util.removeClassName(group.dom.label, 'expanded'); + var collapsedDirClassName = this.options.rtl ? 'collapsed-rtl' : 'collapsed'; + util.addClassName(group.dom.label, collapsedDirClassName); + } +}; + ItemSet.prototype._onGroupDragStart = function (event) { if (this.options.groupEditable.order) { this.groupTouchParams.group = this.groupFromTarget(event); @@ -1562,16 +1746,20 @@ ItemSet.prototype._onGroupDragStart = function (event) { event.stopPropagation(); this.groupTouchParams.originalOrder = this.groupsData.getIds({ - order: this.options.groupOrder - }); + order: this.options.groupOrder + }); } } -} +}; ItemSet.prototype._onGroupDrag = function (event) { if (this.options.groupEditable.order && this.groupTouchParams.group) { event.stopPropagation(); + var groupsData = this.groupsData; + if (this.groupsData instanceof DataView) { + groupsData = this.groupsData.getDataSet() + } // drag from one group to another var group = this.groupFromTarget(event); @@ -1596,25 +1784,24 @@ ItemSet.prototype._onGroupDrag = function (event) { } if (group && group != this.groupTouchParams.group) { - var groupsData = this.groupsData; var targetGroup = groupsData.get(group.groupId); var draggedGroup = groupsData.get(this.groupTouchParams.group.groupId); // switch groups if (draggedGroup && targetGroup) { - this.options.groupOrderSwap(draggedGroup, targetGroup, this.groupsData); - this.groupsData.update(draggedGroup); - this.groupsData.update(targetGroup); + this.options.groupOrderSwap(draggedGroup, targetGroup, groupsData); + groupsData.update(draggedGroup); + groupsData.update(targetGroup); } // fetch current order of groups - var newOrder = this.groupsData.getIds({ - order: this.options.groupOrder - }); + var newOrder = groupsData.getIds({ + order: this.options.groupOrder + }); + // in case of changes since _onGroupDragStart if (!util.equalArray(newOrder, this.groupTouchParams.originalOrder)) { - var groupsData = this.groupsData; var origOrder = this.groupTouchParams.originalOrder; var draggedId = this.groupTouchParams.group.groupId; var numGroups = Math.min(origOrder.length, newOrder.length); @@ -1638,17 +1825,17 @@ ItemSet.prototype._onGroupDrag = function (event) { // if dragged group was move upwards everything below should have an offset if (newOrder[curPos+newOffset] == draggedId) { newOffset = 1; - continue; + } // if dragged group was move downwards everything above should have an offset else if (origOrder[curPos+orgOffset] == draggedId) { orgOffset = 1; - continue; + } // found a group (apart from dragged group) that has the wrong position -> switch with the // group at the position where other one should be, fix index arrays and continue else { - var slippedPosition = newOrder.indexOf(origOrder[curPos+orgOffset]) + var slippedPosition = newOrder.indexOf(origOrder[curPos+orgOffset]); var switchGroup = groupsData.get(newOrder[curPos+newOffset]); var shouldBeGroup = groupsData.get(origOrder[curPos+orgOffset]); this.options.groupOrderSwap(switchGroup, shouldBeGroup, groupsData); @@ -1666,69 +1853,68 @@ ItemSet.prototype._onGroupDrag = function (event) { } } -} +}; ItemSet.prototype._onGroupDragEnd = function (event) { - if (this.options.groupEditable.order && this.groupTouchParams.group) { - event.stopPropagation(); + if (this.options.groupEditable.order && this.groupTouchParams.group) { + event.stopPropagation(); - // update existing group - var me = this; - var id = me.groupTouchParams.group.groupId; - var dataset = me.groupsData.getDataSet(); - var groupData = util.extend({}, dataset.get(id)); // clone the data - me.options.onMoveGroup(groupData, function (groupData) { - if (groupData) { - // apply changes - groupData[dataset._fieldId] = id; // ensure the group contains its id (can be undefined) - dataset.update(groupData); - } - else { - - // fetch current order of groups - var newOrder = dataset.getIds({ - order: me.options.groupOrder - }); - - // restore original order - if (!util.equalArray(newOrder, me.groupTouchParams.originalOrder)) { - var origOrder = me.groupTouchParams.originalOrder; - var numGroups = Math.min(origOrder.length, newOrder.length); - var curPos = 0; - while (curPos < numGroups) { - // as long as the groups are where they should be step down along the groups order - while (curPos < numGroups && newOrder[curPos] == origOrder[curPos]) { - curPos++; - } - - // all ok - if (curPos >= numGroups) { - break; - } - - // found a group that has the wrong position -> switch with the - // group at the position where other one should be, fix index arrays and continue - var slippedPosition = newOrder.indexOf(origOrder[curPos]) - var switchGroup = dataset.get(newOrder[curPos]); - var shouldBeGroup = dataset.get(origOrder[curPos]); - me.options.groupOrderSwap(switchGroup, shouldBeGroup, dataset); - groupsData.update(switchGroup); - groupsData.update(shouldBeGroup); - - var switchGroupId = newOrder[curPos]; - newOrder[curPos] = origOrder[curPos]; - newOrder[slippedPosition] = switchGroupId; - - curPos++; - } - } + // update existing group + var me = this; + var id = me.groupTouchParams.group.groupId; + var dataset = me.groupsData.getDataSet(); + var groupData = util.extend({}, dataset.get(id)); // clone the data + me.options.onMoveGroup(groupData, function (groupData) { + if (groupData) { + // apply changes + groupData[dataset._fieldId] = id; // ensure the group contains its id (can be undefined) + dataset.update(groupData); + } + else { - } + // fetch current order of groups + var newOrder = dataset.getIds({ + order: me.options.groupOrder }); - - me.body.emitter.emit('groupDragged', { groupId: id }); - } -} + + // restore original order + if (!util.equalArray(newOrder, me.groupTouchParams.originalOrder)) { + var origOrder = me.groupTouchParams.originalOrder; + var numGroups = Math.min(origOrder.length, newOrder.length); + var curPos = 0; + while (curPos < numGroups) { + // as long as the groups are where they should be step down along the groups order + while (curPos < numGroups && newOrder[curPos] == origOrder[curPos]) { + curPos++; + } + + // all ok + if (curPos >= numGroups) { + break; + } + + // found a group that has the wrong position -> switch with the + // group at the position where other one should be, fix index arrays and continue + var slippedPosition = newOrder.indexOf(origOrder[curPos]); + var switchGroup = dataset.get(newOrder[curPos]); + var shouldBeGroup = dataset.get(origOrder[curPos]); + me.options.groupOrderSwap(switchGroup, shouldBeGroup, dataset); + dataset.update(switchGroup); + dataset.update(shouldBeGroup); + + var switchGroupId = newOrder[curPos]; + newOrder[curPos] = origOrder[curPos]; + newOrder[slippedPosition] = switchGroupId; + + curPos++; + } + } + } + }); + + me.body.emitter.emit('groupDragged', { groupId: id }); + } +}; /** * Handle selecting/deselecting an item when tapping it @@ -1771,6 +1957,36 @@ ItemSet.prototype._onSelectItem = function (event) { ItemSet.prototype._onMouseOver = function (event) { var item = this.itemFromTarget(event); if (!item) return; + + // Item we just left + var related = this.itemFromRelatedTarget(event); + if (item === related) { + // We haven't changed item, just element in the item + return; + } + + var title = item.getTitle(); + if (this.options.showTooltips && title) { + if (this.popup == null) { + this.popup = new Popup(this.body.dom.root, + this.options.tooltip.overflowMethod || 'flip'); + } + + this.popup.setText(title); + var container = this.body.dom.centerContainer; + this.popup.setPosition( + event.clientX - util.getAbsoluteLeft(container) + container.offsetLeft, + event.clientY - util.getAbsoluteTop(container) + container.offsetTop + ); + this.popup.show(); + } else { + // Hovering over item without a title, hide popup + // Needed instead of _just_ in _onMouseOut due to #2572 + if (this.popup != null) { + this.popup.hide(); + } + } + this.body.emitter.emit('itemover', { item: item.id, event: event @@ -1779,28 +1995,64 @@ ItemSet.prototype._onMouseOver = function (event) { ItemSet.prototype._onMouseOut = function (event) { var item = this.itemFromTarget(event); if (!item) return; + + // Item we are going to + var related = this.itemFromRelatedTarget(event); + if (item === related) { + // We aren't changing item, just element in the item + return; + } + + if (this.popup != null) { + this.popup.hide(); + } + this.body.emitter.emit('itemout', { item: item.id, event: event }); }; +ItemSet.prototype._onMouseMove = function (event) { + var item = this.itemFromTarget(event); + if (!item) return; + + if (this.options.showTooltips && this.options.tooltip.followMouse) { + if (this.popup) { + if (!this.popup.hidden) { + var container = this.body.dom.centerContainer; + this.popup.setPosition( + event.clientX - util.getAbsoluteLeft(container) + container.offsetLeft, + event.clientY - util.getAbsoluteTop(container) + container.offsetTop + ); + this.popup.show(); // Redraw + } + } + } +}; /** - * Handle creation and updates of an item on double tap - * @param event + * Handle mousewheel + * @param {Event} event The event * @private */ -ItemSet.prototype._onAddItem = function (event) { +ItemSet.prototype._onMouseWheel = function(event) { + if (this.touchParams.itemIsDragging) { + this._onDragEnd(event); + } +}; + +/** + * Handle updates of an item on double tap + * @param {vis.Item} item The item + * @private + */ +ItemSet.prototype._onUpdateItem = function (item) { if (!this.options.selectable) return; if (!this.options.editable.add) return; var me = this; - var snap = this.options.snap || null; - var item = this.itemFromTarget(event); - + if (item) { - // update item - // execute async handler to update the item (or cancel it) var itemData = me.itemsData.get(item.id); // get a clone of the data from the dataset this.options.onUpdate(itemData, function (itemData) { @@ -1809,68 +2061,90 @@ ItemSet.prototype._onAddItem = function (event) { } }); } - else { - // add item - if (this.options.rtl) { - var xAbs = util.getAbsoluteRight(this.dom.frame); - var x = xAbs - event.center.x; - } else { - var xAbs = util.getAbsoluteLeft(this.dom.frame); - var x = event.center.x - xAbs; - } - // var xAbs = util.getAbsoluteLeft(this.dom.frame); - // var x = event.center.x - xAbs; - var start = this.body.util.toTime(x); - var scale = this.body.util.getScale(); - var step = this.body.util.getStep(); +}; - var newItemData = { +/** + * Handle drop event of data on item + * Only called when `objectData.target === 'item'. + * @param {Event} event The event + * @private + */ +ItemSet.prototype._onDropObjectOnItem = function(event) { + var item = this.itemFromTarget(event); + var objectData = JSON.parse(event.dataTransfer.getData("text")); + this.options.onDropObjectOnItem(objectData, item) +} + +/** + * Handle creation of an item on double tap or drop of a drag event + * @param {Event} event The event + * @private + */ +ItemSet.prototype._onAddItem = function (event) { + if (!this.options.selectable) return; + if (!this.options.editable.add) return; + + var me = this; + var snap = this.options.snap || null; + var xAbs; + var x; + // add item + if (this.options.rtl) { + xAbs = util.getAbsoluteRight(this.dom.frame); + x = xAbs - event.center.x; + } else { + xAbs = util.getAbsoluteLeft(this.dom.frame); + x = event.center.x - xAbs; + } + // var xAbs = util.getAbsoluteLeft(this.dom.frame); + // var x = event.center.x - xAbs; + var start = this.body.util.toTime(x); + var scale = this.body.util.getScale(); + var step = this.body.util.getStep(); + var end; + + var newItemData; + if (event.type == 'drop') { + newItemData = JSON.parse(event.dataTransfer.getData("text")); + newItemData.content = newItemData.content ? newItemData.content : 'new item'; + newItemData.start = newItemData.start ? newItemData.start : (snap ? snap(start, scale, step) : start); + newItemData.type = newItemData.type || 'box'; + newItemData[this.itemsData._fieldId] = newItemData.id || util.randomUUID(); + + if (newItemData.type == 'range' && !newItemData.end) { + end = this.body.util.toTime(x + this.props.width / 5); + newItemData.end = snap ? snap(end, scale, step) : end; + } + } else { + newItemData = { start: snap ? snap(start, scale, step) : start, content: 'new item' }; + newItemData[this.itemsData._fieldId] = util.randomUUID(); - if (event.type == 'drop') { - var itemData = JSON.parse(event.dataTransfer.getData("text/plain")) - newItemData.content = itemData.content; // content is required - newItemData.type = itemData.type || 'box'; - newItemData[this.itemsData._fieldId] = itemData.id || util.randomUUID(); - - if (itemData.type == 'range' || (itemData.end && itemData.start)) { - - if (!itemData.end) { - var end = this.body.util.toTime(x + this.props.width / 5); - newItemData.end = snap ? snap(end, scale, step) : end; - } else { - newItemData.end = new Date(newItemData.start._i).getTime() + new Date(itemData.end).getTime() - new Date(itemData.start).getTime(); - } - } - } else { - newItemData[this.itemsData._fieldId] = util.randomUUID(); - - // when default type is a range, add a default end date to the new item - if (this.options.type === 'range') { - var end = this.body.util.toTime(x + this.props.width / 5); - newItemData.end = snap ? snap(end, scale, step) : end; - } + // when default type is a range, add a default end date to the new item + if (this.options.type === 'range') { + end = this.body.util.toTime(x + this.props.width / 5); + newItemData.end = snap ? snap(end, scale, step) : end; } + } - var group = this.groupFromTarget(event); - if (group) { - newItemData.group = group.groupId; - } + var group = this.groupFromTarget(event); + if (group) { + newItemData.group = group.groupId; + } - // execute async handler to customize (or cancel) adding an item - newItemData = this._cloneItemData(newItemData); // convert start and end to the correct type - this.options.onAdd(newItemData, function (item) { - if (item) { - me.itemsData.getDataSet().add(item); - if (event.type == 'drop') { - me.setSelection([item.id]); - } - // TODO: need to trigger a redraw? + // execute async handler to customize (or cancel) adding an item + newItemData = this._cloneItemData(newItemData); // convert start and end to the correct type + this.options.onAdd(newItemData, function (item) { + if (item) { + me.itemsData.getDataSet().add(item); + if (event.type == 'drop') { + me.setSelection([item.id]); } - }); - } + // TODO: need to trigger a redraw? + } + }); }; /** @@ -1895,7 +2169,7 @@ ItemSet.prototype._onMultiSelectItem = function (event) { if (shiftKey && this.options.multiselect) { // select all items between the old selection and the tapped item var itemGroup = this.itemsData.get(item.id).group; - + // when filtering get the group of the last selected item var lastSelectedGroup = undefined; if (this.options.multiselectPerGroup) { @@ -1903,7 +2177,7 @@ ItemSet.prototype._onMultiSelectItem = function (event) { lastSelectedGroup = this.itemsData.get(selection[0]).group; } } - + // determine the selection range if (!this.options.multiselectPerGroup || lastSelectedGroup == undefined || lastSelectedGroup == itemGroup) { selection.push(item.id); @@ -1984,6 +2258,24 @@ ItemSet._getItemRange = function(itemsData) { } }; +/** + * Find an item from an element: + * searches for the attribute 'timeline-item' in the element's tree + * @param {HTMLElement} element + * @return {Item | null} item + */ +ItemSet.prototype.itemFromElement = function(element) { + var cur = element; + while (cur) { + if (cur.hasOwnProperty('timeline-item')) { + return cur['timeline-item']; + } + cur = cur.parentNode; + } + + return null; +}; + /** * Find an item from an event target: * searches for the attribute 'timeline-item' in the event target's element tree @@ -1991,15 +2283,17 @@ ItemSet._getItemRange = function(itemsData) { * @return {Item | null} item */ ItemSet.prototype.itemFromTarget = function(event) { - var target = event.target; - while (target) { - if (target.hasOwnProperty('timeline-item')) { - return target['timeline-item']; - } - target = target.parentNode; - } + return this.itemFromElement(event.target); +}; - return null; +/** + * Find an item from an event's related target: + * searches for the attribute 'timeline-item' in the related target's element tree + * @param {Event} event + * @return {Item | null} item + */ +ItemSet.prototype.itemFromRelatedTarget = function(event) { + return this.itemFromElement(event.relatedTarget); }; /** @@ -2010,8 +2304,16 @@ ItemSet.prototype.itemFromTarget = function(event) { */ ItemSet.prototype.groupFromTarget = function(event) { var clientY = event.center ? event.center.y : event.clientY; - for (var i = 0; i < this.groupIds.length; i++) { - var groupId = this.groupIds[i]; + var groupIds = this.groupIds; + + if (groupIds.length <= 0 && this.groupsData) { + groupIds = this.groupsData.getIds({ + order: this.options.groupOrder + }); + } + + for (var i = 0; i < groupIds.length; i++) { + var groupId = groupIds[i]; var group = this.groups[groupId]; var foreground = group.dom.foreground; var top = util.getAbsoluteTop(foreground); diff --git a/lib/timeline/component/Legend.js b/lib/timeline/component/Legend.js index 90b972595..da99bc30f 100644 --- a/lib/timeline/component/Legend.js +++ b/lib/timeline/component/Legend.js @@ -4,6 +4,13 @@ var Component = require('./Component'); /** * Legend for Graph2d + * + * @param {vis.Graph2d.body} body + * @param {vis.Graph2d.options} options + * @param {number} side + * @param {vis.LineGraph.options} linegraphOptions + * @constructor Legend + * @extends Component */ function Legend(body, options, side, linegraphOptions) { this.body = body; @@ -20,10 +27,10 @@ function Legend(body, options, side, linegraphOptions) { visible: true, position: 'top-right' // top/bottom - left,center,right } - } + }; this.side = side; - this.options = util.extend({},this.defaultOptions); + this.options = util.extend({}, this.defaultOptions); this.linegraphOptions = linegraphOptions; this.svgElements = {}; @@ -99,7 +106,6 @@ Legend.prototype.hide = function() { /** * Show the component in the DOM (when not already visible). - * @return {Boolean} changed */ Legend.prototype.show = function() { // show frame containing the items @@ -173,8 +179,8 @@ Legend.prototype.redraw = function() { } var content = ''; - for (var i = 0; i < groupArray.length; i++) { - var groupId = groupArray[i]; + for (i = 0; i < groupArray.length; i++) { + groupId = groupArray[i]; if (this.groups[groupId].visible == true && (this.linegraphOptions.visibility[groupId] === undefined || this.linegraphOptions.visibility[groupId] == true)) { content += this.groups[groupId].content + '
'; } diff --git a/lib/timeline/component/LineGraph.js b/lib/timeline/component/LineGraph.js index 961a293dc..cddbcfc0a 100644 --- a/lib/timeline/component/LineGraph.js +++ b/lib/timeline/component/LineGraph.js @@ -15,9 +15,10 @@ var UNGROUPED = '__ungrouped__'; // reserved group id for ungrouped items /** * This is the constructor of the LineGraph. It requires a Timeline body and options. * - * @param body - * @param options - * @constructor + * @param {vis.Timeline.body} body + * @param {Object} options + * @constructor LineGraph + * @extends Component */ function LineGraph(body, options) { this.id = util.randomUUID(); @@ -74,26 +75,26 @@ function LineGraph(body, options) { // listeners for the DataSet of the items this.itemListeners = { - 'add': function (event, params, senderId) { + 'add': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onAdd(params.items); }, - 'update': function (event, params, senderId) { + 'update': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onUpdate(params.items); }, - 'remove': function (event, params, senderId) { + 'remove': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onRemove(params.items); } }; // listeners for the DataSet of the groups this.groupListeners = { - 'add': function (event, params, senderId) { + 'add': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onAddGroups(params.items); }, - 'update': function (event, params, senderId) { + 'update': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onUpdateGroups(params.items); }, - 'remove': function (event, params, senderId) { + 'remove': function (event, params, senderId) { // eslint-disable-line no-unused-vars me._onRemoveGroups(params.items); } }; @@ -230,7 +231,6 @@ LineGraph.prototype.hide = function () { /** * Show the component in the DOM (when not already visible). - * @return {Boolean} changed */ LineGraph.prototype.show = function () { // show frame containing the items @@ -332,7 +332,7 @@ LineGraph.prototype.setGroups = function (groups) { }; LineGraph.prototype._onUpdate = function (ids) { - this._updateAllGroupData(); + this._updateAllGroupData(ids); }; LineGraph.prototype._onAdd = function (ids) { this._onUpdate(ids); @@ -341,7 +341,7 @@ LineGraph.prototype._onRemove = function (ids) { this._onUpdate(ids); }; LineGraph.prototype._onUpdateGroups = function (groupIds) { - this._updateAllGroupData(); + this._updateAllGroupData(null, groupIds); }; LineGraph.prototype._onAddGroups = function (groupIds) { this._onUpdateGroups(groupIds); @@ -362,7 +362,7 @@ LineGraph.prototype._onRemoveGroups = function (groupIds) { /** * this cleans the group out off the legends and the dataaxis - * @param groupId + * @param {vis.GraphGroup.id} groupId * @private */ LineGraph.prototype._removeGroup = function (groupId) { @@ -379,13 +379,13 @@ LineGraph.prototype._removeGroup = function (groupId) { } delete this.groups[groupId]; } -} +}; /** * update a group object with the group dataset entree * - * @param group - * @param groupId + * @param {vis.GraphGroup} group + * @param {vis.GraphGroup.id} groupId * @private */ LineGraph.prototype._updateGroup = function (group, groupId) { @@ -425,12 +425,22 @@ LineGraph.prototype._updateGroup = function (group, groupId) { /** * this updates all groups, it is used when there is an update the the itemset. * + * @param {Array} ids + * @param {Array} groupIds * @private */ -LineGraph.prototype._updateAllGroupData = function () { +LineGraph.prototype._updateAllGroupData = function (ids, groupIds) { if (this.itemsData != null) { var groupsContent = {}; var items = this.itemsData.get(); + var fieldId = this.itemsData._fieldId; + var idMap = {}; + if (ids){ + ids.map(function (id) { + idMap[id] = id; + }); + } + //pre-Determine array sizes, for more efficient memory claim var groupCounts = {}; for (var i = 0; i < items.length; i++) { @@ -441,28 +451,55 @@ LineGraph.prototype._updateAllGroupData = function () { } groupCounts.hasOwnProperty(groupId) ? groupCounts[groupId]++ : groupCounts[groupId] = 1; } + + //Pre-load arrays from existing groups if items are not changed (not in ids) + var existingItemsMap = {}; + if (!groupIds && ids) { + for (groupId in this.groups) { + if (this.groups.hasOwnProperty(groupId)) { + group = this.groups[groupId]; + var existing_items = group.getItems(); + + groupsContent[groupId] = existing_items.filter(function (item) { + existingItemsMap[item[fieldId]] = item[fieldId]; + return (item[fieldId] !== idMap[item[fieldId]]); + }); + var newLength = groupCounts[groupId]; + groupCounts[groupId] -= groupsContent[groupId].length; + if (groupsContent[groupId].length < newLength) { + groupsContent[groupId][newLength - 1] = {}; + } + } + } + } + //Now insert data into the arrays. - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var groupId = item.group; + for (i = 0; i < items.length; i++) { + item = items[i]; + groupId = item.group; if (groupId === null || groupId === undefined) { groupId = UNGROUPED; } + if (!groupIds && ids && (item[fieldId] !== idMap[item[fieldId]]) && existingItemsMap.hasOwnProperty(item[fieldId])) { + continue; + } if (!groupsContent.hasOwnProperty(groupId)) { groupsContent[groupId] = new Array(groupCounts[groupId]); } //Copy data (because of unmodifiable DataView input. var extended = util.bridgeObject(item); extended.x = util.convert(item.x, 'Date'); + extended.end = util.convert(item.end, 'Date'); extended.orginalY = item.y; //real Y extended.y = Number(item.y); + extended[fieldId] = item[fieldId]; var index= groupsContent[groupId].length - groupCounts[groupId]--; groupsContent[groupId][index] = extended; } //Make sure all groups are present, to allow removal of old groups - for (var groupId in this.groups){ + for (groupId in this.groups){ if (this.groups.hasOwnProperty(groupId)){ if (!groupsContent.hasOwnProperty(groupId)) { groupsContent[groupId] = new Array(0); @@ -471,7 +508,7 @@ LineGraph.prototype._updateAllGroupData = function () { } //Update legendas, style and axis - for (var groupId in groupsContent) { + for (groupId in groupsContent) { if (groupsContent.hasOwnProperty(groupId)) { if (groupsContent[groupId].length == 0) { if (this.groups.hasOwnProperty(groupId)) { @@ -587,11 +624,13 @@ LineGraph.prototype._getSortedGroupIds = function(){ groupIds[i] = grouplist[i].id; } return groupIds; -} +}; /** * Update and redraw the graph. * + * @returns {boolean} + * @private */ LineGraph.prototype._updateGraph = function () { // reset the svg elements @@ -699,9 +738,9 @@ LineGraph.prototype._updateGraph = function () { paths[groupIds[i]] = Lines.calcPath(groupsData[groupIds[i]], group); } Lines.draw(paths[groupIds[i]], group, this.framework); - //explicit no break; + // eslint-disable-line no-fallthrough case "point": - //explicit no break; + // eslint-disable-line no-fallthrough case "points": if (group.options.style == "point" || group.options.style == "points" || group.options.drawPoints.enabled == true) { Points.draw(groupsData[groupIds[i]], group, this.framework); @@ -709,7 +748,7 @@ LineGraph.prototype._updateGraph = function () { break; case "bar": // bar needs to be drawn enmasse - //explicit no break + // eslint-disable-line no-fallthrough default: //do nothing... } @@ -817,8 +856,8 @@ LineGraph.prototype._getRelevantData = function (groupIds, groupsData, minDate, /** * - * @param groupIds - * @param groupsData + * @param {Array.} groupIds + * @param {vis.DataSet} groupsData * @private */ LineGraph.prototype._applySampling = function (groupIds, groupsData) { @@ -834,6 +873,7 @@ LineGraph.prototype._applySampling = function (groupIds, groupsData) { // the global screen is used because changing the width of the yAxis may affect the increment, resulting in an endless loop // of width changing of the yAxis. + //TODO: This assumes sorted data, but that's not guaranteed! var xDistance = this.body.util.toGlobalScreen(dataContainer[dataContainer.length - 1].x) - this.body.util.toGlobalScreen(dataContainer[0].x); var pointsPerPixel = amountOfPoints / xDistance; increment = Math.min(Math.ceil(0.2 * amountOfPoints), Math.max(1, Math.round(pointsPerPixel))); @@ -853,9 +893,8 @@ LineGraph.prototype._applySampling = function (groupIds, groupsData) { /** * - * - * @param {array} groupIds - * @param {object} groupsData + * @param {Array.} groupIds + * @param {vis.DataSet} groupsData * @param {object} groupRanges | this is being filled here * @private */ @@ -873,10 +912,10 @@ LineGraph.prototype._getYRanges = function (groupIds, groupsData, groupRanges) { // if bar graphs are stacked, their range need to be handled differently and accumulated over all groups. if (options.stack === true && options.style === 'bar') { if (options.yAxisOrientation === 'left') { - combinedDataLeft = combinedDataLeft.concat(group.getItems()); + combinedDataLeft = combinedDataLeft.concat(groupData); } else { - combinedDataRight = combinedDataRight.concat(group.getItems()); + combinedDataRight = combinedDataRight.concat(groupData); } } else { @@ -894,8 +933,9 @@ LineGraph.prototype._getYRanges = function (groupIds, groupsData, groupRanges) { /** * this sets the Y ranges for the Y axis. It also determines which of the axis should be shown or hidden. - * @param {Array} groupIds + * @param {Array.} groupIds * @param {Object} groupRanges + * @returns {boolean} resized * @private */ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { @@ -921,7 +961,7 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { } // if there are items: - for (var i = 0; i < groupIds.length; i++) { + for (i = 0; i < groupIds.length; i++) { if (groupRanges.hasOwnProperty(groupIds[i])) { if (groupRanges[groupIds[i]].ignore !== true) { minVal = groupRanges[groupIds[i]].min; @@ -979,7 +1019,7 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { // clean the accumulated lists var tempGroups = ['__barStackLeft', '__barStackRight', '__lineStackLeft', '__lineStackRight']; - for (var i = 0; i < tempGroups.length; i++) { + for (i = 0; i < tempGroups.length; i++) { if (groupIds.indexOf(tempGroups[i]) != -1) { groupIds.splice(groupIds.indexOf(tempGroups[i]), 1); } @@ -993,9 +1033,9 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { * This shows or hides the Y axis if needed. If there is a change, the changed event is emitted by the updateYAxis function * * @param {boolean} axisUsed + * @param {vis.DataAxis} axis * @returns {boolean} * @private - * @param axis */ LineGraph.prototype._toggleAxisVisiblity = function (axisUsed, axis) { var changed = false; @@ -1020,8 +1060,7 @@ LineGraph.prototype._toggleAxisVisiblity = function (axisUsed, axis) { * util function toScreen to get the x coordinate from the timestamp. It also pre-filters the data and get the minMax ranges for * the yAxis. * - * @param datapoints - * @returns {Array} + * @param {Array.} datapoints * @private */ LineGraph.prototype._convertXcoordinates = function (datapoints) { @@ -1029,6 +1068,12 @@ LineGraph.prototype._convertXcoordinates = function (datapoints) { for (var i = 0; i < datapoints.length; i++) { datapoints[i].screen_x = toScreen(datapoints[i].x) + this.props.width; datapoints[i].screen_y = datapoints[i].y; //starting point for range calculations + if (datapoints[i].end != undefined) { + datapoints[i].screen_end = toScreen(datapoints[i].end) + this.props.width; + } + else { + datapoints[i].screen_end = undefined; + } } }; @@ -1038,9 +1083,8 @@ LineGraph.prototype._convertXcoordinates = function (datapoints) { * util function toScreen to get the x coordinate from the timestamp. It also pre-filters the data and get the minMax ranges for * the yAxis. * - * @param datapoints - * @param group - * @returns {Array} + * @param {Array.} datapoints + * @param {vis.GraphGroup} group * @private */ LineGraph.prototype._convertYcoordinates = function (datapoints, group) { diff --git a/lib/timeline/component/TimeAxis.js b/lib/timeline/component/TimeAxis.js index d80435219..5f02e3e2e 100644 --- a/lib/timeline/component/TimeAxis.js +++ b/lib/timeline/component/TimeAxis.js @@ -201,7 +201,7 @@ TimeAxis.prototype._repaintLabels = function () { var minimumStep = timeLabelsize - DateUtil.getHiddenDurationBefore(this.options.moment, this.body.hiddenDates, this.body.range, timeLabelsize); minimumStep -= this.body.util.toTime(0).valueOf(); - var step = new TimeStep(new Date(start), new Date(end), minimumStep, this.body.hiddenDates); + var step = new TimeStep(new Date(start), new Date(end), minimumStep, this.body.hiddenDates, this.options); step.setMoment(this.options.moment); if (this.options.format) { step.setFormat(this.options.format); @@ -222,11 +222,13 @@ TimeAxis.prototype._repaintLabels = function () { dom.majorTexts = []; dom.minorTexts = []; - var current; + var current; // eslint-disable-line no-unused-vars var next; var x; var xNext; - var isMajor, nextIsMajor; + var isMajor; + var nextIsMajor; // eslint-disable-line no-unused-vars + var showMinorGrid; var width = 0, prevWidth; var line; var labelMinor; @@ -255,7 +257,10 @@ TimeAxis.prototype._repaintLabels = function () { prevWidth = width; width = xNext - x; - var showMinorGrid = (width >= prevWidth * 0.4); // prevent displaying of the 31th of the month on a scale of 5 days + switch (step.scale) { + case 'week': showMinorGrid = true; break; + default: showMinorGrid = (width >= prevWidth * 0.4); break; // prevent displaying of the 31th of the month on a scale of 5 days + } if (this.options.showMinorLabels && showMinorGrid) { var label = this._repaintMinorText(x, labelMinor, orientation, className); @@ -313,10 +318,10 @@ TimeAxis.prototype._repaintLabels = function () { /** * Create a minor label for the axis at position x - * @param {Number} x - * @param {String} text - * @param {String} orientation "top" or "bottom" (default) - * @param {String} className + * @param {number} x + * @param {string} text + * @param {string} orientation "top" or "bottom" (default) + * @param {string} className * @return {Element} Returns the HTML element of the created label * @private */ @@ -332,8 +337,7 @@ TimeAxis.prototype._repaintMinorText = function (x, text, orientation, className this.dom.foreground.appendChild(label); } this.dom.minorTexts.push(label); - - label.childNodes[0].nodeValue = text; + label.innerHTML = text; label.style.top = (orientation == 'top') ? (this.props.majorLabelHeight + 'px') : '0'; @@ -342,7 +346,7 @@ TimeAxis.prototype._repaintMinorText = function (x, text, orientation, className label.style.right = x + 'px'; } else { label.style.left = x + 'px'; - }; + } label.className = 'vis-text vis-minor ' + className; //label.title = title; // TODO: this is a heavy operation @@ -351,10 +355,10 @@ TimeAxis.prototype._repaintMinorText = function (x, text, orientation, className /** * Create a Major label for the axis at position x - * @param {Number} x - * @param {String} text - * @param {String} orientation "top" or "bottom" (default) - * @param {String} className + * @param {number} x + * @param {string} text + * @param {string} orientation "top" or "bottom" (default) + * @param {string} className * @return {Element} Returns the HTML element of the created label * @private */ @@ -364,14 +368,13 @@ TimeAxis.prototype._repaintMajorText = function (x, text, orientation, className if (!label) { // create label - var content = document.createTextNode(text); + var content = document.createElement('div'); label = document.createElement('div'); label.appendChild(content); this.dom.foreground.appendChild(label); } - this.dom.majorTexts.push(label); - label.childNodes[0].nodeValue = text; + label.childNodes[0].innerHTML = text; label.className = 'vis-text vis-major ' + className; //label.title = title; // TODO: this is a heavy operation @@ -381,17 +384,18 @@ TimeAxis.prototype._repaintMajorText = function (x, text, orientation, className label.style.right = x + 'px'; } else { label.style.left = x + 'px'; - }; + } + this.dom.majorTexts.push(label); return label; }; /** * Create a minor line for the axis at position x - * @param {Number} x - * @param {Number} width - * @param {String} orientation "top" or "bottom" (default) - * @param {String} className + * @param {number} x + * @param {number} width + * @param {string} orientation "top" or "bottom" (default) + * @param {string} className * @return {Element} Returns the created line * @private */ @@ -420,7 +424,7 @@ TimeAxis.prototype._repaintMinorLine = function (x, width, orientation, classNam } else { line.style.left = (x - props.minorLineWidth / 2) + 'px'; line.className = 'vis-grid vis-vertical vis-minor ' + className; - }; + } line.style.width = width + 'px'; @@ -430,10 +434,10 @@ TimeAxis.prototype._repaintMinorLine = function (x, width, orientation, classNam /** * Create a Major line for the axis at position x - * @param {Number} x - * @param {Number} width - * @param {String} orientation "top" or "bottom" (default) - * @param {String} className + * @param {number} x + * @param {number} width + * @param {string} orientation "top" or "bottom" (default) + * @param {string} className * @return {Element} Returns the created line * @private */ diff --git a/lib/timeline/component/css/currenttime.css b/lib/timeline/component/css/currenttime.css index 46c7b9c31..658fdfef5 100644 --- a/lib/timeline/component/css/currenttime.css +++ b/lib/timeline/component/css/currenttime.css @@ -2,4 +2,28 @@ background-color: #FF7F6E; width: 2px; z-index: 1; + pointer-events: none; +} + +.vis-rolling-mode-btn { + height: 40px; + width: 40px; + position: absolute; + top: 7px; + right: 20px; + border-radius: 50%; + font-size: 28px; + cursor: pointer; + opacity: 0.8; + color: white; + font-weight: bold; + text-align: center; + background: #3876c2; +} +.vis-rolling-mode-btn:before { + content: "\26F6"; +} + +.vis-rolling-mode-btn:hover { + opacity: 1; } \ No newline at end of file diff --git a/lib/timeline/component/css/item.css b/lib/timeline/component/css/item.css index 37f05533b..659234981 100644 --- a/lib/timeline/component/css/item.css +++ b/lib/timeline/component/css/item.css @@ -6,6 +6,7 @@ border-width: 1px; background-color: #D5DDF6; display: inline-block; + z-index: 1; /*overflow: hidden;*/ } @@ -66,6 +67,10 @@ overflow: hidden; } +.vis-item-visible-frame { + white-space: nowrap; +} + .vis-item.vis-range .vis-item-content { position: relative; display: inline-block; @@ -99,6 +104,10 @@ white-space: nowrap; padding: 5px; border-radius: 1px; + transition: 0.4s; + -o-transition: 0.4s; + -moz-transition: 0.4s; + -webkit-transition: 0.4s; } .vis-item .vis-delete, .vis-item .vis-delete-rtl { diff --git a/lib/timeline/component/css/itemset.css b/lib/timeline/component/css/itemset.css index 45af6a30d..3e9578f97 100644 --- a/lib/timeline/component/css/itemset.css +++ b/lib/timeline/component/css/itemset.css @@ -33,6 +33,26 @@ border-bottom: none; } +.vis-nesting-group { + cursor: pointer; +} + +.vis-nested-group { + background: #f5f5f5; +} + +.vis-label.vis-nesting-group.expanded:before { + content: "\25BC"; +} + +.vis-label.vis-nesting-group.collapsed-rtl:before { + content: "\25C0"; +} + +.vis-label.vis-nesting-group.collapsed:before { + content: "\25B6"; +} + .vis-overlay { position: absolute; top: 0; diff --git a/lib/timeline/component/graph2d_types/bar.js b/lib/timeline/component/graph2d_types/bar.js index 32b3d36e7..13478737d 100644 --- a/lib/timeline/component/graph2d_types/bar.js +++ b/lib/timeline/component/graph2d_types/bar.js @@ -1,13 +1,17 @@ var DOMutil = require('../../../DOMutil'); var Points = require('./points'); -function Bargraph(groupId, options) { +/** + * + * @param {vis.GraphGroup.id} groupId + * @param {Object} options // TODO: Describe options + * @constructor Bargraph + */ +function Bargraph(groupId, options) { // eslint-disable-line no-unused-vars } Bargraph.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { var fillHeight = iconHeight * 0.5; - var path, fillPath; - var outline = DOMutil.getSVGElement("rect", framework.svgElements, framework.svg); outline.setAttributeNS(null, "x", x); outline.setAttributeNS(null, "y", y - fillHeight); @@ -36,14 +40,14 @@ Bargraph.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { DOMutil.drawPoint(x + 0.5 * barWidth + offset, y + fillHeight - bar1Height - 1, groupTemplate, framework.svgElements, framework.svg); DOMutil.drawPoint(x + 1.5 * barWidth + offset + 2, y + fillHeight - bar2Height - 1, groupTemplate, framework.svgElements, framework.svg); } - -} +}; /** * draw a bar graph * - * @param groupIds - * @param processedGroupData + * @param {Array.} groupIds + * @param {Object} processedGroupData + * @param {{svg: Object, svgElements: Array., options: Object, groups: Array.}} framework */ Bargraph.draw = function (groupIds, processedGroupData, framework) { var combinedData = []; @@ -62,8 +66,10 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { for (j = 0; j < processedGroupData[groupIds[i]].length; j++) { combinedData.push({ screen_x: processedGroupData[groupIds[i]][j].screen_x, + screen_end: processedGroupData[groupIds[i]][j].screen_end, screen_y: processedGroupData[groupIds[i]][j].screen_y, x: processedGroupData[groupIds[i]][j].x, + end: processedGroupData[groupIds[i]][j].end, y: processedGroupData[groupIds[i]][j].y, groupId: groupIds[i], label: processedGroupData[groupIds[i]][j].label @@ -106,7 +112,6 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { } else { var nextKey = i + (intersections[key].amount - intersections[key].resolved); - var prevKey = i - (intersections[key].resolved + 1); if (nextKey < combinedData.length) { coreDistance = Math.abs(combinedData[nextKey].screen_x - key); } @@ -128,7 +133,21 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { drawData.offset += (intersections[key].resolved) * drawData.width - (0.5 * drawData.width * (intersections[key].amount + 1)); } } - DOMutil.drawBar(combinedData[i].screen_x + drawData.offset, combinedData[i].screen_y - heightOffset, drawData.width, group.zeroPosition - combinedData[i].screen_y, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); + + let dataWidth = drawData.width; + let start = combinedData[i].screen_x; + + // are we drawing explicit boxes? (we supplied an end value) + if (combinedData[i].screen_end != undefined){ + dataWidth = combinedData[i].screen_end - combinedData[i].screen_x; + start += (dataWidth * 0.5); + } + else { + start += drawData.offset + } + + DOMutil.drawBar(start, combinedData[i].screen_y - heightOffset, dataWidth, group.zeroPosition - combinedData[i].screen_y, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); + // draw points if (group.options.drawPoints.enabled === true) { let pointData = { @@ -148,8 +167,8 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { /** * Fill the intersections object with counters of how many datapoints share the same x coordinates - * @param intersections - * @param combinedData + * @param {Object} intersections + * @param {Array.} combinedData * @private */ Bargraph._getDataIntersections = function (intersections, combinedData) { @@ -180,10 +199,10 @@ Bargraph._getDataIntersections = function (intersections, combinedData) { /** * Get the width and offset for bargraphs based on the coredistance between datapoints * - * @param coreDistance - * @param group - * @param minWidth - * @returns {{width: Number, offset: Number}} + * @param {number} coreDistance + * @param {vis.Group} group + * @param {number} minWidth + * @returns {{width: number, offset: number}} * @private */ Bargraph._getSafeDrawData = function (coreDistance, group, minWidth) { @@ -232,7 +251,7 @@ Bargraph.getStackedYRange = function (combinedData, groupRanges, groupIds, group groupRanges[groupLabel].yAxisOrientation = orientation; groupIds.push(groupLabel); } -} +}; Bargraph._getStackedYRange = function (intersections, combinedData) { var key; diff --git a/lib/timeline/component/graph2d_types/line.js b/lib/timeline/component/graph2d_types/line.js index 18b9877b4..5a661c73f 100644 --- a/lib/timeline/component/graph2d_types/line.js +++ b/lib/timeline/component/graph2d_types/line.js @@ -1,6 +1,12 @@ var DOMutil = require('../../../DOMutil'); -function Line(groupId, options) { +/** + * + * @param {vis.GraphGroup.id} groupId + * @param {Object} options // TODO: Describe options + * @constructor Line + */ +function Line(groupId, options) { // eslint-disable-line no-unused-vars } Line.calcPath = function (dataset, group) { @@ -18,7 +24,7 @@ Line.calcPath = function (dataset, group) { return d; } } -} +}; Line.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { var fillHeight = iconHeight * 0.5; @@ -65,7 +71,7 @@ Line.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { }; DOMutil.drawPoint(x + 0.5 * iconWidth, y, groupTemplate, framework.svgElements, framework.svg); } -} +}; Line.drawShading = function (pathArray, group, subPathArray, framework) { // append shading to the path @@ -106,13 +112,14 @@ Line.drawShading = function (pathArray, group, subPathArray, framework) { } fillPath.setAttributeNS(null, 'd', dFill); } -} +}; /** * draw a line graph * - * @param dataset - * @param group + * @param {Array.} pathArray + * @param {vis.Group} group + * @param {{svg: Object, svgElements: Array., options: Object, groups: Array.}} framework */ Line.draw = function (pathArray, group, framework) { if (pathArray != null && pathArray != undefined) { @@ -137,23 +144,24 @@ Line.serializePath = function(pathArray,type,inverse){ return ""; } var d = type; + var i; if (inverse){ - for (var i = pathArray.length-2; i > 0; i--){ + for (i = pathArray.length-2; i > 0; i--){ d += pathArray[i][0] + "," + pathArray[i][1] + " "; } } else { - for (var i = 1; i < pathArray.length; i++){ + for (i = 1; i < pathArray.length; i++){ d += pathArray[i][0] + "," + pathArray[i][1] + " "; } } return d; -} +}; /** * This uses an uniform parametrization of the interpolation algorithm: * 'On the Parameterization of Catmull-Rom Curves' by Cem Yuksel et al. - * @param data + * @param {Array.} data * @returns {string} * @private */ @@ -203,8 +211,8 @@ Line._catmullRomUniform = function (data) { * These parameterizations are relatively heavy because the distance between 4 points have to be calculated. * * One optimization can be used to reuse distances since this is a sliding window approach. - * @param data - * @param group + * @param {Array.} data + * @param {vis.GraphGroup} group * @returns {string} * @private */ @@ -285,7 +293,7 @@ Line._catmullRom = function (data, group) { /** * this generates the SVG path for a linear drawing between datapoints. - * @param data + * @param {Array.} data * @returns {string} * @private */ diff --git a/lib/timeline/component/graph2d_types/points.js b/lib/timeline/component/graph2d_types/points.js index c6a05a925..a6c7f4214 100644 --- a/lib/timeline/component/graph2d_types/points.js +++ b/lib/timeline/component/graph2d_types/points.js @@ -1,16 +1,22 @@ var DOMutil = require('../../../DOMutil'); -function Points(groupId, options) { +/** + * + * @param {number | string} groupId + * @param {Object} options // TODO: Describe options + * + * @constructor Points + */ +function Points(groupId, options) { // eslint-disable-line no-unused-vars } /** * draw the data points * * @param {Array} dataset - * @param {Object} JSONcontainer - * @param {Object} svg | SVG DOM element * @param {GraphGroup} group - * @param {Number} [offset] + * @param {Object} framework | SVG DOM element + * @param {number} [offset] */ Points.draw = function (dataset, group, framework, offset) { offset = offset || 0; @@ -32,7 +38,6 @@ Points.draw = function (dataset, group, framework, offset) { Points.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { var fillHeight = iconHeight * 0.5; - var path, fillPath; var outline = DOMutil.getSVGElement("rect", framework.svgElements, framework.svg); outline.setAttributeNS(null, "x", x); @@ -45,6 +50,12 @@ Points.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { DOMutil.drawPoint(x + 0.5 * iconWidth, y, getGroupTemplate(group), framework.svgElements, framework.svg); }; +/** + * + * @param {vis.Group} group + * @param {any} callbackResult + * @returns {{style: *, styles: (*|string), size: *, className: *}} + */ function getGroupTemplate(group, callbackResult) { callbackResult = (typeof callbackResult === 'undefined') ? {} : callbackResult; return { @@ -55,6 +66,12 @@ function getGroupTemplate(group, callbackResult) { }; } +/** + * + * @param {Object} framework | SVG DOM element + * @param {vis.Group} group + * @returns {function} + */ function getCallback(framework, group) { var callback = undefined; // check for the graph2d onRender @@ -69,5 +86,4 @@ function getCallback(framework, group) { return callback; } - -module.exports = Points; \ No newline at end of file +module.exports = Points; diff --git a/lib/timeline/component/item/BackgroundItem.js b/lib/timeline/component/item/BackgroundItem.js index 44048527b..4545c586b 100644 --- a/lib/timeline/component/item/BackgroundItem.js +++ b/lib/timeline/component/item/BackgroundItem.js @@ -1,4 +1,3 @@ -var Hammer = require('../../../module/hammer'); var Item = require('./Item'); var BackgroundGroup = require('../BackgroundGroup'); var RangeItem = require('./RangeItem'); @@ -12,8 +11,8 @@ var RangeItem = require('./RangeItem'); * Conversion functions from time to screen and vice versa * @param {Object} [options] Configuration options * // TODO: describe options + * // TODO: implement support for the BackgroundItem just having a start, then being displayed as a sort of an annotation */ -// TODO: implement support for the BackgroundItem just having a start, then being displayed as a sort of an annotation function BackgroundItem (data, conversion, options) { this.props = { content: { @@ -38,11 +37,12 @@ function BackgroundItem (data, conversion, options) { BackgroundItem.prototype = new Item (null, null, null); BackgroundItem.prototype.baseClassName = 'vis-item vis-background'; + BackgroundItem.prototype.stack = false; /** * Check whether this item is visible inside given range - * @returns {{start: Number, end: Number}} range with a timestamp for start and end + * @param {vis.Range} range with a timestamp for start and end * @returns {boolean} True if visible */ BackgroundItem.prototype.isVisible = function(range) { @@ -50,73 +50,125 @@ BackgroundItem.prototype.isVisible = function(range) { return (this.data.start < range.end) && (this.data.end > range.start); }; -/** - * Repaint the item - */ -BackgroundItem.prototype.redraw = function() { - var dom = this.dom; - if (!dom) { +BackgroundItem.prototype._createDomElement = function() { + if (!this.dom) { // create DOM this.dom = {}; - dom = this.dom; // background box - dom.box = document.createElement('div'); + this.dom.box = document.createElement('div'); // className is updated in redraw() // frame box (to prevent the item contents from overflowing - dom.frame = document.createElement('div'); - dom.frame.className = 'vis-item-overflow'; - dom.box.appendChild(dom.frame); + this.dom.frame = document.createElement('div'); + this.dom.frame.className = 'vis-item-overflow'; + this.dom.box.appendChild(this.dom.frame); // contents box - dom.content = document.createElement('div'); - dom.content.className = 'vis-item-content'; - dom.frame.appendChild(dom.content); + this.dom.content = document.createElement('div'); + this.dom.content.className = 'vis-item-content'; + this.dom.frame.appendChild(this.dom.content); // Note: we do NOT attach this item as attribute to the DOM, // such that background items cannot be selected - //dom.box['timeline-item'] = this; + //this.dom.box['timeline-item'] = this; this.dirty = true; } +} - // append DOM to parent DOM +BackgroundItem.prototype._appendDomElement = function() { if (!this.parent) { throw new Error('Cannot redraw item: no parent attached'); } - if (!dom.box.parentNode) { + if (!this.dom.box.parentNode) { var background = this.parent.dom.background; if (!background) { throw new Error('Cannot redraw item: parent has no background container element'); } - background.appendChild(dom.box); + background.appendChild(this.dom.box); } this.displayed = true; +} - // Update DOM when item is marked dirty. An item is marked dirty when: +BackgroundItem.prototype._updateDirtyDomComponents = function() { + // update dirty DOM. An item is marked dirty when: // - the item is not yet rendered // - the item's data is changed // - the item is selected/deselected if (this.dirty) { this._updateContents(this.dom.content); - this._updateTitle(this.dom.content); this._updateDataAttributes(this.dom.content); this._updateStyle(this.dom.box); // update class var className = (this.data.className ? (' ' + this.data.className) : '') + (this.selected ? ' vis-selected' : ''); - dom.box.className = this.baseClassName + className; + this.dom.box.className = this.baseClassName + className; + } +} - // determine from css whether this box has overflow - this.overflow = window.getComputedStyle(dom.content).overflow !== 'hidden'; +BackgroundItem.prototype._getDomComponentsSizes = function() { + // determine from css whether this box has overflow + this.overflow = window.getComputedStyle(this.dom.content).overflow !== 'hidden'; + return { + content: { + width: this.dom.content.offsetWidth + } + } +} + +BackgroundItem.prototype._updateDomComponentsSizes = function(sizes) { + // recalculate size + this.props.content.width = sizes.content.width; + this.height = 0; // set height zero, so this item will be ignored when stacking items - // recalculate size - this.props.content.width = this.dom.content.offsetWidth; - this.height = 0; // set height zero, so this item will be ignored when stacking items + this.dirty = false; +} + +BackgroundItem.prototype._repaintDomAdditionals = function() { +} - this.dirty = false; +/** + * Repaint the item + * @param {boolean} [returnQueue=false] return the queue + * @return {boolean} the redraw result or the redraw queue if returnQueue=true + */ +BackgroundItem.prototype.redraw = function(returnQueue) { + var sizes + var queue = [ + // create item DOM + this._createDomElement.bind(this), + + // append DOM to parent DOM + this._appendDomElement.bind(this), + + this._updateDirtyDomComponents.bind(this), + + (function() { + if (this.dirty) { + sizes = this._getDomComponentsSizes.bind(this)(); + } + }).bind(this), + + (function() { + if (this.dirty) { + this._updateDomComponentsSizes.bind(this)(sizes); + } + }).bind(this), + + // repaint DOM additionals + this._repaintDomAdditionals.bind(this) + ]; + + if (returnQueue) { + return queue; + } else { + var result; + queue.forEach(function (fn) { + result = fn(); + }); + return result; } }; @@ -142,57 +194,23 @@ BackgroundItem.prototype.repositionX = RangeItem.prototype.repositionX; * Reposition the item vertically * @Override */ -BackgroundItem.prototype.repositionY = function(margin) { - var onTop = this.options.orientation.item === 'top'; - this.dom.content.style.top = onTop ? '' : '0'; - this.dom.content.style.bottom = onTop ? '0' : ''; +BackgroundItem.prototype.repositionY = function(margin) { // eslint-disable-line no-unused-vars var height; + var orientation = this.options.orientation.item; // special positioning for subgroups if (this.data.subgroup !== undefined) { // TODO: instead of calculating the top position of the subgroups here for every BackgroundItem, calculate the top of the subgroup once in Itemset - var itemSubgroup = this.data.subgroup; - var subgroups = this.parent.subgroups; - var subgroupIndex = subgroups[itemSubgroup].index; - // if the orientation is top, we need to take the difference in height into account. - if (onTop == true) { - // the first subgroup will have to account for the distance from the top to the first item. - height = this.parent.subgroups[itemSubgroup].height + margin.item.vertical; - height += subgroupIndex == 0 ? margin.axis - 0.5*margin.item.vertical : 0; - var newTop = this.parent.top; - for (var subgroup in subgroups) { - if (subgroups.hasOwnProperty(subgroup)) { - if (subgroups[subgroup].visible == true && subgroups[subgroup].index < subgroupIndex) { - newTop += subgroups[subgroup].height + margin.item.vertical; - } - } - } - // the others will have to be offset downwards with this same distance. - newTop += subgroupIndex != 0 ? margin.axis - 0.5 * margin.item.vertical : 0; - this.dom.box.style.top = newTop + 'px'; - this.dom.box.style.bottom = ''; - } - // and when the orientation is bottom: - else { - var newTop = this.parent.top; - var totalHeight = 0; - for (var subgroup in subgroups) { - if (subgroups.hasOwnProperty(subgroup)) { - if (subgroups[subgroup].visible == true) { - var newHeight = subgroups[subgroup].height + margin.item.vertical; - totalHeight += newHeight; - if (subgroups[subgroup].index > subgroupIndex) { - newTop += newHeight; - } - } - } - } - height = this.parent.subgroups[itemSubgroup].height + margin.item.vertical; - this.dom.box.style.top = (this.parent.height - totalHeight + newTop) + 'px'; - this.dom.box.style.bottom = ''; + this.dom.box.style.height = this.parent.subgroups[itemSubgroup].height + 'px'; + + if (orientation == 'top') { + this.dom.box.style.top = this.parent.top + this.parent.subgroups[itemSubgroup].top + 'px'; + } else { + this.dom.box.style.top = (this.parent.top + this.parent.height - this.parent.subgroups[itemSubgroup].top - this.parent.subgroups[itemSubgroup].height) + 'px'; } + this.dom.box.style.bottom = ''; } // and in the case of no subgroups: else { @@ -202,8 +220,8 @@ BackgroundItem.prototype.repositionY = function(margin) { height = Math.max(this.parent.height, this.parent.itemSet.body.domProps.center.height, this.parent.itemSet.body.domProps.centerContainer.height); - this.dom.box.style.top = onTop ? '0' : ''; - this.dom.box.style.bottom = onTop ? '' : '0'; + this.dom.box.style.bottom = orientation == 'bottom' ? '0' : ''; + this.dom.box.style.top = orientation == 'top' ? '0' : ''; } else { height = this.parent.height; diff --git a/lib/timeline/component/item/BoxItem.js b/lib/timeline/component/item/BoxItem.js index 2a2fc415f..e93b09902 100644 --- a/lib/timeline/component/item/BoxItem.js +++ b/lib/timeline/component/item/BoxItem.js @@ -1,5 +1,4 @@ var Item = require('./Item'); -var util = require('../../../util'); /** * @constructor BoxItem @@ -37,15 +36,14 @@ BoxItem.prototype = new Item (null, null, null); /** * Check whether this item is visible inside given range - * @returns {{start: Number, end: Number}} range with a timestamp for start and end + * @param {{start: number, end: number}} range with a timestamp for start and end * @returns {boolean} True if visible */ BoxItem.prototype.isVisible = function(range) { // determine visibility var isVisible; var align = this.options.align; - var msPerPixel = (range.end - range.start) / range.body.dom.center.clientWidth; - var widthInMs = this.width * msPerPixel; + var widthInMs = this.width * range.getMillisecondsPerPixel(); if (align == 'right') { isVisible = (this.data.start.getTime() > range.start ) && (this.data.start.getTime() - widthInMs < range.end); @@ -60,113 +58,170 @@ BoxItem.prototype.isVisible = function(range) { return isVisible; }; -/** - * Repaint the item - */ -BoxItem.prototype.redraw = function() { - var dom = this.dom; - if (!dom) { +BoxItem.prototype._createDomElement = function() { + if (!this.dom) { // create DOM this.dom = {}; - dom = this.dom; // create main box - dom.box = document.createElement('DIV'); + this.dom.box = document.createElement('DIV'); // contents box (inside the background box). used for making margins - dom.content = document.createElement('DIV'); - dom.content.className = 'vis-item-content'; - dom.box.appendChild(dom.content); + this.dom.content = document.createElement('DIV'); + this.dom.content.className = 'vis-item-content'; + this.dom.box.appendChild(this.dom.content); // line to axis - dom.line = document.createElement('DIV'); - dom.line.className = 'vis-line'; + this.dom.line = document.createElement('DIV'); + this.dom.line.className = 'vis-line'; // dot on axis - dom.dot = document.createElement('DIV'); - dom.dot.className = 'vis-dot'; + this.dom.dot = document.createElement('DIV'); + this.dom.dot.className = 'vis-dot'; // attach this item as attribute - dom.box['timeline-item'] = this; + this.dom.box['timeline-item'] = this; this.dirty = true; } +} - // append DOM to parent DOM +BoxItem.prototype._appendDomElement = function() { if (!this.parent) { throw new Error('Cannot redraw item: no parent attached'); } - if (!dom.box.parentNode) { + if (!this.dom.box.parentNode) { var foreground = this.parent.dom.foreground; if (!foreground) throw new Error('Cannot redraw item: parent has no foreground container element'); - foreground.appendChild(dom.box); + foreground.appendChild(this.dom.box); } - if (!dom.line.parentNode) { + if (!this.dom.line.parentNode) { var background = this.parent.dom.background; if (!background) throw new Error('Cannot redraw item: parent has no background container element'); - background.appendChild(dom.line); + background.appendChild(this.dom.line); } - if (!dom.dot.parentNode) { + if (!this.dom.dot.parentNode) { var axis = this.parent.dom.axis; if (!background) throw new Error('Cannot redraw item: parent has no axis container element'); - axis.appendChild(dom.dot); + axis.appendChild(this.dom.dot); } this.displayed = true; +} - // Update DOM when item is marked dirty. An item is marked dirty when: +BoxItem.prototype._updateDirtyDomComponents = function() { + // An item is marked dirty when: // - the item is not yet rendered // - the item's data is changed // - the item is selected/deselected if (this.dirty) { this._updateContents(this.dom.content); - this._updateTitle(this.dom.box); this._updateDataAttributes(this.dom.box); this._updateStyle(this.dom.box); - var editable = (this.options.editable.updateTime || - this.options.editable.updateGroup || - this.editable === true) && - this.editable !== false; + var editable = (this.editable.updateTime || this.editable.updateGroup); // update class var className = (this.data.className? ' ' + this.data.className : '') + (this.selected ? ' vis-selected' : '') + (editable ? ' vis-editable' : ' vis-readonly'); - dom.box.className = 'vis-item vis-box' + className; - dom.line.className = 'vis-item vis-line' + className; - dom.dot.className = 'vis-item vis-dot' + className; - - // set initial position in the visible range of the grid so that the - // rendered box size can be determinated correctly, even the content - // has a dynamic width (fixes #2032). - var previousRight = dom.box.style.right; - var previousLeft = dom.box.style.left; - if (this.options.rtl) { - dom.box.style.right = "0px"; - } else { - dom.box.style.left = "0px"; - } - - // recalculate size - this.props.dot.height = dom.dot.offsetHeight; - this.props.dot.width = dom.dot.offsetWidth; - this.props.line.width = dom.line.offsetWidth; - this.width = dom.box.offsetWidth; - this.height = dom.box.offsetHeight; + this.dom.box.className = 'vis-item vis-box' + className; + this.dom.line.className = 'vis-item vis-line' + className; + this.dom.dot.className = 'vis-item vis-dot' + className; + } +} - // restore previous position - if (this.options.rtl) { - dom.box.style.right = previousRight; - } else { - dom.box.style.left = previousLeft; +BoxItem.prototype._getDomComponentsSizes = function() { + return { + previous: { + right: this.dom.box.style.right, + left: this.dom.box.style.left + }, + dot: { + height: this.dom.dot.offsetHeight, + width: this.dom.dot.offsetWidth + }, + line: { + width: this.dom.line.offsetWidth + }, + box: { + width: this.dom.box.offsetWidth, + height: this.dom.box.offsetHeight } + } +} + +BoxItem.prototype._updateDomComponentsSizes = function(sizes) { + if (this.options.rtl) { + this.dom.box.style.right = "0px"; + } else { + this.dom.box.style.left = "0px"; + } - this.dirty = false; + // recalculate size + this.props.dot.height = sizes.dot.height; + this.props.dot.width = sizes.dot.width; + this.props.line.width = sizes.line.width; + this.width = sizes.box.width; + this.height = sizes.box.height; + + // restore previous position + if (this.options.rtl) { + this.dom.box.style.right = sizes.previous.right; + } else { + this.dom.box.style.left = sizes.previous.left; } - this._repaintOnItemUpdateTimeTooltip(dom.box); + this.dirty = false; +} + +BoxItem.prototype._repaintDomAdditionals = function() { + this._repaintOnItemUpdateTimeTooltip(this.dom.box); this._repaintDragCenter(); - this._repaintDeleteButton(dom.box); + this._repaintDeleteButton(this.dom.box); +} + +/** + * Repaint the item + * @param {boolean} [returnQueue=false] return the queue + * @return {boolean} the redraw queue if returnQueue=true + */ +BoxItem.prototype.redraw = function(returnQueue) { + var sizes + var queue = [ + // create item DOM + this._createDomElement.bind(this), + + // append DOM to parent DOM + this._appendDomElement.bind(this), + + // update dirty DOM + this._updateDirtyDomComponents.bind(this), + + (function() { + if (this.dirty) { + sizes = this._getDomComponentsSizes(); + } + }).bind(this), + + (function() { + if (this.dirty) { + this._updateDomComponentsSizes.bind(this)(sizes); + } + }).bind(this), + + // repaint DOM additionals + this._repaintDomAdditionals.bind(this) + ]; + + if (returnQueue) { + return queue; + } else { + var result; + queue.forEach(function (fn) { + result = fn(); + }); + return result; + } }; /** diff --git a/lib/timeline/component/item/Item.js b/lib/timeline/component/item/Item.js index 79535a93d..6c5d7d3aa 100644 --- a/lib/timeline/component/item/Item.js +++ b/lib/timeline/component/item/Item.js @@ -18,9 +18,10 @@ function Item (data, conversion, options) { this.data = data; this.dom = null; this.conversion = conversion || {}; - this.options = options || {}; + this.options = options || {}; this.selected = false; this.displayed = false; + this.groupShowing = true; this.dirty = true; this.top = null; @@ -30,11 +31,7 @@ function Item (data, conversion, options) { this.height = null; this.editable = null; - if (this.data && - this.data.hasOwnProperty('editable') && - typeof this.data.editable === 'boolean') { - this.editable = data.editable; - } + this._updateEditStatus(); } Item.prototype.stack = true; @@ -64,22 +61,28 @@ Item.prototype.unselect = function() { */ Item.prototype.setData = function(data) { var groupChanged = data.group != undefined && this.data.group != data.group; - if (groupChanged) { + if (groupChanged && this.parent != null) { this.parent.itemSet._moveToGroup(this, data.group); } - - if (data.hasOwnProperty('editable') && typeof data.editable === 'boolean') { - this.editable = data.editable; + + if (this.parent) { + this.parent.stackDirty = true; + } + + var subGroupChanged = data.subgroup != undefined && this.data.subgroup != data.subgroup; + if (subGroupChanged && this.parent != null) { + this.parent.changeSubgroup(this, this.data.subgroup, data.subgroup); } this.data = data; + this._updateEditStatus(); this.dirty = true; if (this.displayed) this.redraw(); }; /** * Set a parent for the item - * @param {ItemSet | Group} parent + * @param {Group} parent */ Item.prototype.setParent = function(parent) { if (this.displayed) { @@ -96,10 +99,10 @@ Item.prototype.setParent = function(parent) { /** * Check whether this item is visible inside given range - * @returns {{start: Number, end: Number}} range with a timestamp for start and end + * @param {vis.Range} range with a timestamp for start and end * @returns {boolean} True if visible */ -Item.prototype.isVisible = function(range) { +Item.prototype.isVisible = function(range) { // eslint-disable-line no-unused-vars return false; }; @@ -140,15 +143,65 @@ Item.prototype.repositionY = function() { // should be implemented by the item }; +/** + * Repaint a drag area on the center of the item when the item is selected + * @protected + */ +Item.prototype._repaintDragCenter = function () { + if (this.selected && this.options.editable.updateTime && !this.dom.dragCenter) { + var me = this; + // create and show drag area + var dragCenter = document.createElement('div'); + dragCenter.className = 'vis-drag-center'; + dragCenter.dragCenterItem = this; + var hammer = new Hammer(dragCenter); + + hammer.on('tap', function (event) { + me.parent.itemSet.body.emitter.emit('click', { + event: event, + item: me.id + }); + }); + hammer.on('doubletap', function (event) { + event.stopPropagation(); + me.parent.itemSet._onUpdateItem(me); + me.parent.itemSet.body.emitter.emit('doubleClick', { + event: event, + item: me.id + }); + }); + + if (this.dom.box) { + if (this.dom.dragLeft) { + this.dom.box.insertBefore(dragCenter, this.dom.dragLeft); + } + else { + this.dom.box.appendChild(dragCenter); + } + } + else if (this.dom.point) { + this.dom.point.appendChild(dragCenter); + } + + this.dom.dragCenter = dragCenter; + } + else if (!this.selected && this.dom.dragCenter) { + // delete drag area + if (this.dom.dragCenter.parentNode) { + this.dom.dragCenter.parentNode.removeChild(this.dom.dragCenter); + } + this.dom.dragCenter = null; + } +}; + /** * Repaint a delete button on the top right of the item when the item is selected * @param {HTMLElement} anchor * @protected */ Item.prototype._repaintDeleteButton = function (anchor) { - var editable = (this.options.editable.remove || - this.data.editable === true) && - this.data.editable !== false; + var editable = ((this.options.editable.overrideItems || this.editable == null) && this.options.editable.remove) || + (!this.options.editable.overrideItems && this.editable != null && this.editable.remove); if (this.selected && editable && !this.dom.deleteButton) { // create and show button @@ -194,9 +247,6 @@ Item.prototype._repaintOnItemUpdateTimeTooltip = function (anchor) { this.data.editable !== false; if (this.selected && editable && !this.dom.onItemUpdateTimeTooltip) { - // create and show tooltip - var me = this; - var onItemUpdateTimeTooltip = document.createElement('div'); onItemUpdateTimeTooltip.className = 'vis-onUpdateTime-tooltip'; @@ -271,12 +321,50 @@ Item.prototype._repaintOnItemUpdateTimeTooltip = function (anchor) { */ Item.prototype._updateContents = function (element) { var content; + var changed; var templateFunction; + var itemVisibleFrameContent; + var visibleFrameTemplateFunction; + var itemData = this.parent.itemSet.itemsData.get(this.id); // get a clone of the data from the dataset + + var frameElement = this.dom.box || this.dom.point; + var itemVisibleFrameContentElement = frameElement.getElementsByClassName('vis-item-visible-frame')[0] + + if (this.options.visibleFrameTemplate) { + visibleFrameTemplateFunction = this.options.visibleFrameTemplate.bind(this); + itemVisibleFrameContent = visibleFrameTemplateFunction(itemData, frameElement); + } else { + itemVisibleFrameContent = ''; + } + + if (itemVisibleFrameContentElement) { + if ((itemVisibleFrameContent instanceof Object) && !(itemVisibleFrameContent instanceof Element)) { + visibleFrameTemplateFunction(itemData, itemVisibleFrameContentElement) + } else { + changed = this._contentToString(this.itemVisibleFrameContent) !== this._contentToString(itemVisibleFrameContent); + if (changed) { + // only replace the content when changed + if (itemVisibleFrameContent instanceof Element) { + itemVisibleFrameContentElement.innerHTML = ''; + itemVisibleFrameContentElement.appendChild(itemVisibleFrameContent); + } + else if (itemVisibleFrameContent != undefined) { + itemVisibleFrameContentElement.innerHTML = itemVisibleFrameContent; + } + else { + if (!(this.data.type == 'background' && this.data.content === undefined)) { + throw new Error('Property "content" missing in item ' + this.id); + } + } + + this.itemVisibleFrameContent = itemVisibleFrameContent; + } + } + } if (this.options.template) { - var itemData = this.parent.itemSet.itemsData.get(this.id); // get a clone of the data from the dataset templateFunction = this.options.template.bind(this); - content = templateFunction(itemData, element); + content = templateFunction(itemData, element, this.data); } else { content = this.data.content; } @@ -284,7 +372,7 @@ Item.prototype._updateContents = function (element) { if ((content instanceof Object) && !(content instanceof Element)) { templateFunction(itemData, element) } else { - var changed = this._contentToString(this.content) !== this._contentToString(content); + changed = this._contentToString(this.content) !== this._contentToString(content); if (changed) { // only replace the content when changed if (content instanceof Element) { @@ -299,26 +387,11 @@ Item.prototype._updateContents = function (element) { throw new Error('Property "content" missing in item ' + this.id); } } - this.content = content; } } }; -/** - * Set HTML contents for the item - * @param {Element} element HTML element to fill with the contents - * @private - */ -Item.prototype._updateTitle = function (element) { - if (this.data.title != null) { - element.title = this.data.title || ''; - } - else { - element.removeAttribute('vis-title'); - } -}; - /** * Process dataAttributes timeline option and set as data- attributes on dom.content * @param {Element} element HTML element to which the attributes will be attached @@ -354,7 +427,7 @@ Item.prototype._updateTitle = function (element) { /** * Update custom styles of the element - * @param element + * @param {Element} element * @private */ Item.prototype._updateStyle = function(element) { @@ -384,6 +457,41 @@ Item.prototype._contentToString = function (content) { return content; }; +/** + * Update the editability of this item. + */ +Item.prototype._updateEditStatus = function() { + if (this.options) { + if(typeof this.options.editable === 'boolean') { + this.editable = { + updateTime: this.options.editable, + updateGroup: this.options.editable, + remove: this.options.editable + }; + } else if(typeof this.options.editable === 'object') { + this.editable = {}; + util.selectiveExtend(['updateTime', 'updateGroup', 'remove'], this.editable, this.options.editable); + } + } + // Item data overrides, except if options.editable.overrideItems is set. + if (!this.options || !(this.options.editable) || (this.options.editable.overrideItems !== true)) { + if (this.data) { + if (typeof this.data.editable === 'boolean') { + this.editable = { + updateTime: this.data.editable, + updateGroup: this.data.editable, + remove: this.data.editable + } + } else if (typeof this.data.editable === 'object') { + // TODO: in vis.js 5.0, we should change this to not reset options from the timeline configuration. + // Basically just remove the next line... + this.editable = {}; + util.selectiveExtend(['updateTime', 'updateGroup', 'remove'], this.editable, this.data.editable); + } + } + } +}; + /** * Return the width of the item left from its start date * @return {number} @@ -401,32 +509,11 @@ Item.prototype.getWidthRight = function () { }; /** - * Repaint a drag area on the center of the item when the item is selected - * @protected + * Return the title of the item + * @return {string | undefined} */ -Item.prototype._repaintDragCenter = function () { - if (this.selected && this.options.editable.updateTime && !this.dom.dragCenter) { - // create and show drag area - var dragCenter = document.createElement('div'); - dragCenter.className = 'vis-drag-center'; - dragCenter.dragCenterItem = this; - - if (this.dom.box) { - this.dom.box.appendChild(dragCenter); - } - else if (this.dom.point) { - this.dom.point.appendChild(dragCenter); - } - - this.dom.dragCenter = dragCenter; - } - else if (!this.selected && this.dom.dragCenter) { - // delete drag area - if (this.dom.dragCenter.parentNode) { - this.dom.dragCenter.parentNode.removeChild(this.dom.dragCenter); - } - this.dom.dragCenter = null; - } +Item.prototype.getTitle = function () { + return this.data.title; }; module.exports = Item; diff --git a/lib/timeline/component/item/PointItem.js b/lib/timeline/component/item/PointItem.js index aebedaa4f..721a8a829 100644 --- a/lib/timeline/component/item/PointItem.js +++ b/lib/timeline/component/item/PointItem.js @@ -38,112 +38,170 @@ PointItem.prototype = new Item (null, null, null); /** * Check whether this item is visible inside given range - * @returns {{start: Number, end: Number}} range with a timestamp for start and end + * @param {{start: number, end: number}} range with a timestamp for start and end * @returns {boolean} True if visible */ PointItem.prototype.isVisible = function(range) { // determine visibility - var msPerPixel = (range.end - range.start) / range.body.dom.center.clientWidth; - var widthInMs = this.width * msPerPixel; + var widthInMs = this.width * range.getMillisecondsPerPixel(); return (this.data.start.getTime() + widthInMs > range.start ) && (this.data.start < range.end); }; -/** - * Repaint the item - */ -PointItem.prototype.redraw = function() { - var dom = this.dom; - if (!dom) { + +PointItem.prototype._createDomElement = function() { + if (!this.dom) { // create DOM this.dom = {}; - dom = this.dom; // background box - dom.point = document.createElement('div'); + this.dom.point = document.createElement('div'); // className is updated in redraw() // contents box, right from the dot - dom.content = document.createElement('div'); - dom.content.className = 'vis-item-content'; - dom.point.appendChild(dom.content); + this.dom.content = document.createElement('div'); + this.dom.content.className = 'vis-item-content'; + this.dom.point.appendChild(this.dom.content); // dot at start - dom.dot = document.createElement('div'); - dom.point.appendChild(dom.dot); + this.dom.dot = document.createElement('div'); + this.dom.point.appendChild(this.dom.dot); // attach this item as attribute - dom.point['timeline-item'] = this; + this.dom.point['timeline-item'] = this; this.dirty = true; } +} - // append DOM to parent DOM +PointItem.prototype._appendDomElement = function() { if (!this.parent) { throw new Error('Cannot redraw item: no parent attached'); } - if (!dom.point.parentNode) { + if (!this.dom.point.parentNode) { var foreground = this.parent.dom.foreground; if (!foreground) { throw new Error('Cannot redraw item: parent has no foreground container element'); } - foreground.appendChild(dom.point); + foreground.appendChild(this.dom.point); } this.displayed = true; +} - // Update DOM when item is marked dirty. An item is marked dirty when: +PointItem.prototype._updateDirtyDomComponents = function() { + // An item is marked dirty when: // - the item is not yet rendered // - the item's data is changed // - the item is selected/deselected if (this.dirty) { this._updateContents(this.dom.content); - this._updateTitle(this.dom.point); this._updateDataAttributes(this.dom.point); this._updateStyle(this.dom.point); - var editable = (this.options.editable.updateTime || - this.options.editable.updateGroup || - this.editable === true) && - this.editable !== false; - + var editable = (this.editable.updateTime || this.editable.updateGroup); // update class var className = (this.data.className ? ' ' + this.data.className : '') + (this.selected ? ' vis-selected' : '') + (editable ? ' vis-editable' : ' vis-readonly'); - dom.point.className = 'vis-item vis-point' + className; - dom.dot.className = 'vis-item vis-dot' + className; - - // recalculate size of dot and contents - this.props.dot.width = dom.dot.offsetWidth; - this.props.dot.height = dom.dot.offsetHeight; - this.props.content.height = dom.content.offsetHeight; - - // resize contents - if (this.options.rtl) { - dom.content.style.marginRight = 2 * this.props.dot.width + 'px'; - } else { - dom.content.style.marginLeft = 2 * this.props.dot.width + 'px'; - } - //dom.content.style.marginRight = ... + 'px'; // TODO: margin right - - // recalculate size - this.width = dom.point.offsetWidth; - this.height = dom.point.offsetHeight; - - // reposition the dot - dom.dot.style.top = ((this.height - this.props.dot.height) / 2) + 'px'; - if (this.options.rtl) { - dom.dot.style.right = (this.props.dot.width / 2) + 'px'; - } else { - dom.dot.style.left = (this.props.dot.width / 2) + 'px'; + this.dom.point.className = 'vis-item vis-point' + className; + this.dom.dot.className = 'vis-item vis-dot' + className; + } +} + +PointItem.prototype._getDomComponentsSizes = function() { + return { + dot: { + width: this.dom.dot.offsetWidth, + height: this.dom.dot.offsetHeight + }, + content: { + width: this.dom.content.offsetWidth, + height: this.dom.content.offsetHeight + }, + point: { + width: this.dom.point.offsetWidth, + height: this.dom.point.offsetHeight } + } +} - this.dirty = false; +PointItem.prototype._updateDomComponentsSizes = function(sizes) { + // recalculate size of dot and contents + this.props.dot.width = sizes.dot.width; + this.props.dot.height = sizes.dot.height; + this.props.content.height = sizes.content.height; + + // resize contents + if (this.options.rtl) { + this.dom.content.style.marginRight = 2 * this.props.dot.width + 'px'; + } else { + this.dom.content.style.marginLeft = 2 * this.props.dot.width + 'px'; } - - this._repaintOnItemUpdateTimeTooltip(dom.point); + //this.dom.content.style.marginRight = ... + 'px'; // TODO: margin right + + // recalculate size + this.width = sizes.point.width; + this.height = sizes.point.height; + + // reposition the dot + this.dom.dot.style.top = ((this.height - this.props.dot.height) / 2) + 'px'; + if (this.options.rtl) { + this.dom.dot.style.right = (this.props.dot.width / 2) + 'px'; + } else { + this.dom.dot.style.left = (this.props.dot.width / 2) + 'px'; + } + + this.dirty = false; +} + +PointItem.prototype._repaintDomAdditionals = function() { + this._repaintOnItemUpdateTimeTooltip(this.dom.point); this._repaintDragCenter(); - this._repaintDeleteButton(dom.point); + this._repaintDeleteButton(this.dom.point); +} + +/** + * Repaint the item + * @param {boolean} [returnQueue=false] return the queue + * @return {boolean} the redraw queue if returnQueue=true + */ +PointItem.prototype.redraw = function(returnQueue) { + var sizes + var queue = [ + // create item DOM + this._createDomElement.bind(this), + + // append DOM to parent DOM + this._appendDomElement.bind(this), + + // update dirty DOM + this._updateDirtyDomComponents.bind(this), + + (function() { + if (this.dirty) { + sizes = this._getDomComponentsSizes(); + } + }).bind(this), + + (function() { + if (this.dirty) { + this._updateDomComponentsSizes.bind(this)(sizes); + } + }).bind(this), + + // repaint DOM additionals + this._repaintDomAdditionals.bind(this) + ]; + + if (returnQueue) { + return queue; + } else { + var result; + queue.forEach(function (fn) { + result = fn(); + }); + return result; + } }; /** diff --git a/lib/timeline/component/item/RangeItem.js b/lib/timeline/component/item/RangeItem.js index a840b5296..64eb518ba 100644 --- a/lib/timeline/component/item/RangeItem.js +++ b/lib/timeline/component/item/RangeItem.js @@ -1,4 +1,3 @@ -var Hammer = require('../../../module/hammer'); var Item = require('./Item'); /** @@ -38,7 +37,8 @@ RangeItem.prototype.baseClassName = 'vis-item vis-range'; /** * Check whether this item is visible inside given range - * @returns {{start: Number, end: Number}} range with a timestamp for start and end + * + * @param {vis.Range} range with a timestamp for start and end * @returns {boolean} True if visible */ RangeItem.prototype.isVisible = function(range) { @@ -46,89 +46,146 @@ RangeItem.prototype.isVisible = function(range) { return (this.data.start < range.end) && (this.data.end > range.start); }; -/** - * Repaint the item - */ -RangeItem.prototype.redraw = function() { - var dom = this.dom; - if (!dom) { +RangeItem.prototype._createDomElement = function() { + if (!this.dom) { // create DOM this.dom = {}; - dom = this.dom; // background box - dom.box = document.createElement('div'); + this.dom.box = document.createElement('div'); // className is updated in redraw() - // frame box (to prevent the item contents from overflowing - dom.frame = document.createElement('div'); - dom.frame.className = 'vis-item-overflow'; - dom.box.appendChild(dom.frame); + // frame box (to prevent the item contents from overflowing) + this.dom.frame = document.createElement('div'); + this.dom.frame.className = 'vis-item-overflow'; + this.dom.box.appendChild(this.dom.frame); + + // visible frame box (showing the frame that is always visible) + this.dom.visibleFrame = document.createElement('div'); + this.dom.visibleFrame.className = 'vis-item-visible-frame'; + this.dom.box.appendChild(this.dom.visibleFrame); // contents box - dom.content = document.createElement('div'); - dom.content.className = 'vis-item-content'; - dom.frame.appendChild(dom.content); + this.dom.content = document.createElement('div'); + this.dom.content.className = 'vis-item-content'; + this.dom.frame.appendChild(this.dom.content); // attach this item as attribute - dom.box['timeline-item'] = this; + this.dom.box['timeline-item'] = this; this.dirty = true; } - // append DOM to parent DOM +} + +RangeItem.prototype._appendDomElement = function() { if (!this.parent) { throw new Error('Cannot redraw item: no parent attached'); } - if (!dom.box.parentNode) { + if (!this.dom.box.parentNode) { var foreground = this.parent.dom.foreground; if (!foreground) { throw new Error('Cannot redraw item: parent has no foreground container element'); } - foreground.appendChild(dom.box); + foreground.appendChild(this.dom.box); } this.displayed = true; +} - // Update DOM when item is marked dirty. An item is marked dirty when: +RangeItem.prototype._updateDirtyDomComponents = function() { + // update dirty DOM. An item is marked dirty when: // - the item is not yet rendered // - the item's data is changed // - the item is selected/deselected if (this.dirty) { this._updateContents(this.dom.content); - this._updateTitle(this.dom.box); this._updateDataAttributes(this.dom.box); this._updateStyle(this.dom.box); - var editable = (this.options.editable.updateTime || - this.options.editable.updateGroup || - this.editable === true) && - this.editable !== false; + var editable = (this.editable.updateTime || this.editable.updateGroup); // update class var className = (this.data.className ? (' ' + this.data.className) : '') + (this.selected ? ' vis-selected' : '') + (editable ? ' vis-editable' : ' vis-readonly'); - dom.box.className = this.baseClassName + className; + this.dom.box.className = this.baseClassName + className; - // determine from css whether this box has overflow - this.overflow = window.getComputedStyle(dom.frame).overflow !== 'hidden'; - - // recalculate size // turn off max-width to be able to calculate the real width // this causes an extra browser repaint/reflow, but so be it this.dom.content.style.maxWidth = 'none'; - this.props.content.width = this.dom.content.offsetWidth; - this.height = this.dom.box.offsetHeight; - this.dom.content.style.maxWidth = ''; + } +} - this.dirty = false; +RangeItem.prototype._getDomComponentsSizes = function() { + // determine from css whether this box has overflow + this.overflow = window.getComputedStyle(this.dom.frame).overflow !== 'hidden'; + return { + content: { + width: this.dom.content.offsetWidth, + }, + box: { + height: this.dom.box.offsetHeight + } } +} - this._repaintOnItemUpdateTimeTooltip(dom.box); - this._repaintDeleteButton(dom.box); +RangeItem.prototype._updateDomComponentsSizes = function(sizes) { + this.props.content.width = sizes.content.width; + this.height = sizes.box.height; + this.dom.content.style.maxWidth = ''; + this.dirty = false; +} + +RangeItem.prototype._repaintDomAdditionals = function() { + this._repaintOnItemUpdateTimeTooltip(this.dom.box); + this._repaintDeleteButton(this.dom.box); this._repaintDragCenter(); this._repaintDragLeft(); this._repaintDragRight(); +} + +/** + * Repaint the item + * @param {boolean} [returnQueue=false] return the queue + * @return {boolean} the redraw queue if returnQueue=true + */ +RangeItem.prototype.redraw = function(returnQueue) { + var sizes; + var queue = [ + // create item DOM + this._createDomElement.bind(this), + + // append DOM to parent DOM + this._appendDomElement.bind(this), + + // update dirty DOM + this._updateDirtyDomComponents.bind(this), + + (function() { + if (this.dirty) { + sizes = this._getDomComponentsSizes.bind(this)(); + } + }).bind(this), + + (function() { + if (this.dirty) { + this._updateDomComponentsSizes.bind(this)(sizes); + } + }).bind(this), + + // repaint DOM additionals + this._repaintDomAdditionals.bind(this) + ]; + + if (returnQueue) { + return queue; + } else { + var result; + queue.forEach(function (fn) { + result = fn(); + }); + return result; + } }; /** @@ -143,7 +200,6 @@ RangeItem.prototype.show = function() { /** * Hide the item from the DOM (when visible) - * @return {Boolean} changed */ RangeItem.prototype.hide = function() { if (this.displayed) { @@ -170,11 +226,13 @@ RangeItem.prototype.repositionX = function(limitSize) { var parentWidth = this.parent.width; var start = this.conversion.toScreen(this.data.start); var end = this.conversion.toScreen(this.data.end); + var align = this.data.align === undefined ? this.options.align : this.data.align; var contentStartPosition; var contentWidth; // limit the width of the range, as browsers cannot draw very wide divs - if (limitSize === undefined || limitSize === true) { + // unless limitSize: false is explicitly set in item data + if (this.data.limitSize !== false && (limitSize === undefined || limitSize === true)) { if (start < -parentWidth) { start = -parentWidth; } @@ -216,7 +274,7 @@ RangeItem.prototype.repositionX = function(limitSize) { } this.dom.box.style.width = boxWidth + 'px'; - switch (this.options.align) { + switch (align) { case 'left': if (this.options.rtl) { this.dom.content.style.right = '0'; @@ -290,7 +348,7 @@ RangeItem.prototype.repositionY = function() { * @protected */ RangeItem.prototype._repaintDragLeft = function () { - if (this.selected && this.options.editable.updateTime && !this.dom.dragLeft) { + if ((this.selected || this.options.itemsAlwaysDraggable.range) && this.options.editable.updateTime && !this.dom.dragLeft) { // create and show drag area var dragLeft = document.createElement('div'); dragLeft.className = 'vis-drag-left'; @@ -299,7 +357,7 @@ RangeItem.prototype._repaintDragLeft = function () { this.dom.box.appendChild(dragLeft); this.dom.dragLeft = dragLeft; } - else if (!this.selected && this.dom.dragLeft) { + else if (!this.selected && !this.options.itemsAlwaysDraggable.range && this.dom.dragLeft) { // delete drag area if (this.dom.dragLeft.parentNode) { this.dom.dragLeft.parentNode.removeChild(this.dom.dragLeft); @@ -313,7 +371,7 @@ RangeItem.prototype._repaintDragLeft = function () { * @protected */ RangeItem.prototype._repaintDragRight = function () { - if (this.selected && this.options.editable.updateTime && !this.dom.dragRight) { + if ((this.selected || this.options.itemsAlwaysDraggable.range) && this.options.editable.updateTime && !this.dom.dragRight) { // create and show drag area var dragRight = document.createElement('div'); dragRight.className = 'vis-drag-right'; @@ -322,7 +380,7 @@ RangeItem.prototype._repaintDragRight = function () { this.dom.box.appendChild(dragRight); this.dom.dragRight = dragRight; } - else if (!this.selected && this.dom.dragRight) { + else if (!this.selected && !this.options.itemsAlwaysDraggable.range && this.dom.dragRight) { // delete drag area if (this.dom.dragRight.parentNode) { this.dom.dragRight.parentNode.removeChild(this.dom.dragRight); diff --git a/lib/timeline/locales.js b/lib/timeline/locales.js index 781deb4a1..f5a245154 100644 --- a/lib/timeline/locales.js +++ b/lib/timeline/locales.js @@ -28,3 +28,19 @@ exports['de'] = { time: 'Zeit' }; exports['de_DE'] = exports['de']; + +// French +exports['fr'] = { + current: 'actuel', + time: 'heure' +}; +exports['fr_FR'] = exports['fr']; +exports['fr_CA'] = exports['fr']; +exports['fr_BE'] = exports['fr']; + +// Espanol +exports['es'] = { + current: 'corriente', + time: 'hora' +}; +exports['es_ES'] = exports['es']; diff --git a/lib/timeline/optionsGraph2d.js b/lib/timeline/optionsGraph2d.js index 30b27b75d..0caaf2d2d 100644 --- a/lib/timeline/optionsGraph2d.js +++ b/lib/timeline/optionsGraph2d.js @@ -6,7 +6,7 @@ * __type__ is a required field for all objects and contains the allowed types of all objects */ let string = 'string'; -let boolean = 'boolean'; +let bool = 'boolean'; let number = 'number'; let array = 'array'; let date = 'date'; @@ -18,89 +18,90 @@ let any = 'any'; let allOptions = { configure: { - enabled: {boolean}, - filter: {boolean,'function': 'function'}, + enabled: {'boolean': bool}, + filter: {'boolean': bool,'function': 'function'}, container: {dom}, - __type__: {object,boolean,'function': 'function'} + __type__: {object,'boolean': bool,'function': 'function'} }, //globals : yAxisOrientation: {string:['left','right']}, defaultGroup: {string}, - sort: {boolean}, - sampling: {boolean}, - stack:{boolean}, + sort: {'boolean': bool}, + sampling: {'boolean': bool}, + stack:{'boolean': bool}, graphHeight: {string, number}, shaded: { - enabled: {boolean}, + enabled: {'boolean': bool}, orientation: {string:['bottom','top','zero','group']}, // top, bottom, zero, group groupId: {object}, - __type__: {boolean,object} + __type__: {'boolean': bool,object} }, style: {string:['line','bar','points']}, // line, bar barChart: { width: {number}, minWidth: {number}, - sideBySide: {boolean}, + sideBySide: {'boolean': bool}, align: {string:['left','center','right']}, __type__: {object} }, interpolation: { - enabled: {boolean}, + enabled: {'boolean': bool}, parametrization: {string:['centripetal', 'chordal','uniform']}, // uniform (alpha = 0.0), chordal (alpha = 1.0), centripetal (alpha = 0.5) alpha: {number}, - __type__: {object,boolean} + __type__: {object,'boolean': bool} }, drawPoints: { - enabled: {boolean}, + enabled: {'boolean': bool}, onRender: { 'function': 'function' }, size: {number}, style: {string:['square','circle']}, // square, circle - __type__: {object,boolean,'function': 'function'} + __type__: {object,'boolean': bool,'function': 'function'} }, dataAxis: { - showMinorLabels: {boolean}, - showMajorLabels: {boolean}, - icons: {boolean}, + showMinorLabels: {'boolean': bool}, + showMajorLabels: {'boolean': bool}, + icons: {'boolean': bool}, width: {string, number}, - visible: {boolean}, - alignZeros: {boolean}, + visible: {'boolean': bool}, + alignZeros: {'boolean': bool}, left:{ - range: {min:{number},max:{number},__type__: {object}}, + range: {min:{number,'undefined': 'undefined'},max:{number,'undefined': 'undefined'},__type__: {object}}, format: {'function': 'function'}, - title: {text:{string,number},style:{string},__type__: {object}}, + title: {text:{string,number,'undefined': 'undefined'},style:{string,'undefined': 'undefined'},__type__: {object}}, __type__: {object} }, right:{ - range: {min:{number},max:{number},__type__: {object}}, + range: {min:{number,'undefined': 'undefined'},max:{number,'undefined': 'undefined'},__type__: {object}}, format: {'function': 'function'}, - title: {text:{string,number},style:{string},__type__: {object}}, + title: {text:{string,number,'undefined': 'undefined'},style:{string,'undefined': 'undefined'},__type__: {object}}, __type__: {object} }, __type__: {object} }, legend: { - enabled: {boolean}, - icons: {boolean}, + enabled: {'boolean': bool}, + icons: {'boolean': bool}, left: { - visible: {boolean}, + visible: {'boolean': bool}, position: {string:['top-right','bottom-right','top-left','bottom-left']}, __type__: {object} }, right: { - visible: {boolean}, + visible: {'boolean': bool}, position: {string:['top-right','bottom-right','top-left','bottom-left']}, __type__: {object} }, - __type__: {object,boolean} + __type__: {object,'boolean': bool} }, groups: { visibility: {any}, __type__: {object} }, - autoResize: {boolean}, - clickToUse: {boolean}, + autoResize: {'boolean': bool}, + throttleRedraw: {number}, // TODO: DEPRICATED see https://github.com/almende/vis/issues/2511 + clickToUse: {'boolean': bool}, end: {number, date, string, moment}, format: { minorLabels: { @@ -145,12 +146,12 @@ let allOptions = { maxMinorChars: {number}, min: {date, number, string, moment}, minHeight: {number, string}, - moveable: {boolean}, - multiselect: {boolean}, + moveable: {'boolean': bool}, + multiselect: {'boolean': bool}, orientation: {string}, - showCurrentTime: {boolean}, - showMajorLabels: {boolean}, - showMinorLabels: {boolean}, + showCurrentTime: {'boolean': bool}, + showMajorLabels: {'boolean': bool}, + showMinorLabels: {'boolean': bool}, start: {date, number, string, moment}, timeAxis: { scale: {string,'undefined': 'undefined'}, @@ -158,7 +159,7 @@ let allOptions = { __type__: {object} }, width: {string, number}, - zoomable: {boolean}, + zoomable: {'boolean': bool}, zoomKey: {string: ['ctrlKey', 'altKey', 'metaKey', '']}, zoomMax: {number}, zoomMin: {number}, diff --git a/lib/timeline/optionsTimeline.js b/lib/timeline/optionsTimeline.js index 2e94ef2e8..820b6b3ef 100644 --- a/lib/timeline/optionsTimeline.js +++ b/lib/timeline/optionsTimeline.js @@ -6,7 +6,7 @@ * __type__ is a required field for all objects and contains the allowed types of all objects */ let string = 'string'; -let boolean = 'boolean'; +let bool = 'boolean'; let number = 'number'; let array = 'array'; let date = 'date'; @@ -17,26 +17,33 @@ let any = 'any'; let allOptions = { configure: { - enabled: {boolean}, - filter: {boolean,'function': 'function'}, + enabled: { 'boolean': bool}, + filter: { 'boolean': bool,'function': 'function'}, container: {dom}, - __type__: {object,boolean,'function': 'function'} + __type__: {object, 'boolean': bool,'function': 'function'} }, //globals : align: {string}, - rtl: {boolean, 'undefined': 'undefined'}, - verticalScroll: {boolean, 'undefined': 'undefined'}, - horizontalScroll: {boolean, 'undefined': 'undefined'}, - autoResize: {boolean}, - clickToUse: {boolean}, + rtl: { 'boolean': bool, 'undefined': 'undefined'}, + rollingMode: { + follow: { 'boolean': bool }, + offset: {number,'undefined': 'undefined'}, + __type__: {object} + }, + verticalScroll: { 'boolean': bool, 'undefined': 'undefined'}, + horizontalScroll: { 'boolean': bool, 'undefined': 'undefined'}, + autoResize: { 'boolean': bool}, + throttleRedraw: {number}, // TODO: DEPRICATED see https://github.com/almende/vis/issues/2511 + clickToUse: { 'boolean': bool}, dataAttributes: {string, array}, editable: { - add: {boolean, 'undefined': 'undefined'}, - remove: {boolean, 'undefined': 'undefined'}, - updateGroup: {boolean, 'undefined': 'undefined'}, - updateTime: {boolean, 'undefined': 'undefined'}, - __type__: {boolean, object} + add: { 'boolean': bool, 'undefined': 'undefined'}, + remove: { 'boolean': bool, 'undefined': 'undefined'}, + updateGroup: { 'boolean': bool, 'undefined': 'undefined'}, + updateTime: { 'boolean': bool, 'undefined': 'undefined'}, + overrideItems: { 'boolean': bool, 'undefined': 'undefined'}, + __type__: { 'boolean': bool, object} }, end: {number, date, string, moment}, format: { @@ -47,6 +54,7 @@ let allOptions = { hour: {string,'undefined': 'undefined'}, weekday: {string,'undefined': 'undefined'}, day: {string,'undefined': 'undefined'}, + week: {string,'undefined': 'undefined'}, month: {string,'undefined': 'undefined'}, year: {string,'undefined': 'undefined'}, __type__: {object, 'function': 'function'} @@ -58,6 +66,7 @@ let allOptions = { hour: {string,'undefined': 'undefined'}, weekday: {string,'undefined': 'undefined'}, day: {string,'undefined': 'undefined'}, + week: {string,'undefined': 'undefined'}, month: {string,'undefined': 'undefined'}, year: {string,'undefined': 'undefined'}, __type__: {object, 'function': 'function'} @@ -67,10 +76,10 @@ let allOptions = { moment: {'function': 'function'}, groupOrder: {string, 'function': 'function'}, groupEditable: { - add: {boolean, 'undefined': 'undefined'}, - remove: {boolean, 'undefined': 'undefined'}, - order: {boolean, 'undefined': 'undefined'}, - __type__: {boolean, object} + add: { 'boolean': bool, 'undefined': 'undefined'}, + remove: { 'boolean': bool, 'undefined': 'undefined'}, + order: { 'boolean': bool, 'undefined': 'undefined'}, + __type__: { 'boolean': bool, object} }, groupOrderSwap: {'function': 'function'}, height: {string, number}, @@ -80,7 +89,12 @@ let allOptions = { repeat: {string}, __type__: {object, array} }, - itemsAlwaysDraggable: { boolean: boolean }, + itemsAlwaysDraggable: { + item: { 'boolean': bool, 'undefined': 'undefined'}, + range: { 'boolean': bool, 'undefined': 'undefined'}, + __type__: { 'boolean': bool, object} + }, + limitSize: {'boolean': bool}, locale:{string}, locales:{ __any__: {any}, @@ -100,10 +114,11 @@ let allOptions = { maxMinorChars: {number}, min: {date, number, string, moment}, minHeight: {number, string}, - moveable: {boolean}, - multiselect: {boolean}, - multiselectPerGroup: {boolean}, + moveable: { 'boolean': bool}, + multiselect: { 'boolean': bool}, + multiselectPerGroup: { 'boolean': bool}, onAdd: {'function': 'function'}, + onDropObjectOnItem: {'function': 'function'}, onUpdate: {'function': 'function'}, onMove: {'function': 'function'}, onMoving: {'function': 'function'}, @@ -111,24 +126,33 @@ let allOptions = { onAddGroup: {'function': 'function'}, onMoveGroup: {'function': 'function'}, onRemoveGroup: {'function': 'function'}, + onInitialDrawComplete: {'function': 'function'}, order: {'function': 'function'}, orientation: { axis: {string,'undefined': 'undefined'}, item: {string,'undefined': 'undefined'}, __type__: {string, object} }, - selectable: {boolean}, - showCurrentTime: {boolean}, - showMajorLabels: {boolean}, - showMinorLabels: {boolean}, - stack: {boolean}, + selectable: { 'boolean': bool}, + showCurrentTime: { 'boolean': bool}, + showMajorLabels: { 'boolean': bool}, + showMinorLabels: { 'boolean': bool}, + stack: { 'boolean': bool}, + stackSubgroups: { 'boolean': bool}, snap: {'function': 'function', 'null': 'null'}, start: {date, number, string, moment}, template: {'function': 'function'}, groupTemplate: {'function': 'function'}, + visibleFrameTemplate: {string, 'function': 'function'}, + showTooltips: { 'boolean': bool}, + tooltip: { + followMouse: { 'boolean': bool }, + overflowMethod: { 'string': ['cap', 'flip'] }, + __type__: {object} + }, tooltipOnItemUpdateTime: { - template: {'function': 'function'}, - __type__: {boolean, object} + template: {'function': 'function'}, + __type__: { 'boolean': bool, object} }, timeAxis: { scale: {string,'undefined': 'undefined'}, @@ -137,7 +161,7 @@ let allOptions = { }, type: {string}, width: {string, number}, - zoomable: {boolean}, + zoomable: { 'boolean': bool}, zoomKey: {string: ['ctrlKey', 'altKey', 'metaKey', '']}, zoomMax: {number}, zoomMin: {number}, @@ -167,6 +191,7 @@ let configureOptions = { hour: 'HH:mm', weekday: 'ddd D', day: 'D', + week: 'w', month: 'MMM', year: 'YYYY' }, @@ -177,6 +202,7 @@ let configureOptions = { hour: 'ddd D MMMM', weekday: 'MMMM YYYY', day: 'MMMM YYYY', + week: 'MMMM YYYY', month: 'YYYY', year: '' } @@ -217,13 +243,19 @@ let configureOptions = { showMajorLabels: true, showMinorLabels: true, stack: true, + stackSubgroups: true, //snap: {'function': 'function', nada}, start: '', //template: {'function': 'function'}, //timeAxis: { - // scale: ['millisecond', 'second', 'minute', 'hour', 'weekday', 'day', 'month', 'year'], + // scale: ['millisecond', 'second', 'minute', 'hour', 'weekday', 'day', 'week', 'month', 'year'], // step: [1, 1, 10, 1] //}, + showTooltips: true, + tooltip: { + followMouse: false, + overflowMethod: 'flip' + }, tooltipOnItemUpdateTime: false, type: ['box', 'point', 'range', 'background'], width: '100%', diff --git a/lib/util.js b/lib/util.js index 9ced58624..ab23f6af0 100644 --- a/lib/util.js +++ b/lib/util.js @@ -19,7 +19,7 @@ exports.isNumber = function (object) { /** * Remove everything in the DOM object - * @param DOMobject + * @param {Element} DOMobject */ exports.recursiveDOMDelete = function (DOMobject) { if (DOMobject) { @@ -33,10 +33,10 @@ exports.recursiveDOMDelete = function (DOMobject) { /** * this function gives you a range between 0 and 1 based on the min and max values in the set, the total sum of all values and the current value. * - * @param min - * @param max - * @param total - * @param value + * @param {number} min + * @param {number} max + * @param {number} total + * @param {number} value * @returns {number} */ exports.giveRange = function (min, max, total, value) { @@ -47,7 +47,7 @@ exports.giveRange = function (min, max, total, value) { var scale = 1 / (max - min); return Math.max(0, (value - min) * scale); } -} +}; /** * Test whether given object is a string @@ -84,7 +84,7 @@ exports.isDate = function (object) { /** * Create a semi UUID * source: http://stackoverflow.com/a/105074/1262753 - * @return {String} uuid + * @return {string} uuid */ exports.randomUUID = function () { return uuid.v4(); @@ -92,8 +92,8 @@ exports.randomUUID = function () { /** * assign all keys of an object that are not nested objects to a certain value (used for color objects). - * @param obj - * @param value + * @param {object} obj + * @param {number} value */ exports.assignAllKeys = function (obj, value) { for (var prop in obj) { @@ -103,62 +103,70 @@ exports.assignAllKeys = function (obj, value) { } } } +}; + + +/** + * Copy property from b to a if property present in a. + * If property in b explicitly set to null, delete it if `allowDeletion` set. + * + * Internal helper routine, should not be exported. Not added to `exports` for that reason. + * + * @param {object} a target object + * @param {object} b source object + * @param {string} prop name of property to copy to a + * @param {boolean} allowDeletion if true, delete property in a if explicitly set to null in b + * @private + */ +function copyOrDelete(a, b, prop, allowDeletion) { + var doDeletion = false; + if (allowDeletion === true) { + doDeletion = (b[prop] === null && a[prop] !== undefined); + } + + if (doDeletion) { + delete a[prop]; + } else { + a[prop] = b[prop]; // Remember, this is a reference copy! + } } /** - * Fill an object with a possibly partially defined other object. Only copies values if the a object has an object requiring values. + * Fill an object with a possibly partially defined other object. + * + * Only copies values for the properties already present in a. * That means an object is not created on a property if only the b object has it. - * @param obj - * @param value + * + * @param {object} a + * @param {object} b + * @param {boolean} [allowDeletion=false] if true, delete properties in a that are explicitly set to null in b */ exports.fillIfDefined = function (a, b, allowDeletion = false) { + // NOTE: iteration of properties of a + // NOTE: prototype properties iterated over as well for (var prop in a) { if (b[prop] !== undefined) { - if (typeof b[prop] !== 'object') { - if ((b[prop] === undefined || b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } - } - else { + if (b[prop] === null || typeof b[prop] !== 'object') { // Note: typeof null === 'object' + copyOrDelete(a, b, prop, allowDeletion); + } else { if (typeof a[prop] === 'object') { exports.fillIfDefined(a[prop], b[prop], allowDeletion); } } } } -} - - - -/** - * Extend object a with the properties of object b or a series of objects - * Only properties with defined values are copied - * @param {Object} a - * @param {... Object} b - * @return {Object} a - */ -exports.protoExtend = function (a, b) { - for (var i = 1; i < arguments.length; i++) { - var other = arguments[i]; - for (var prop in other) { - a[prop] = other[prop]; - } - } - return a; }; + /** * Extend object a with the properties of object b or a series of objects * Only properties with defined values are copied * @param {Object} a - * @param {... Object} b + * @param {...Object} b * @return {Object} a */ -exports.extend = function (a, b) { +exports.extend = function (a, b) { // eslint-disable-line no-unused-vars for (var i = 1; i < arguments.length; i++) { var other = arguments[i]; for (var prop in other) { @@ -173,12 +181,12 @@ exports.extend = function (a, b) { /** * Extend object a with selected properties of object b or a series of objects * Only properties with defined values are copied - * @param {Array.} props + * @param {Array.} props * @param {Object} a * @param {Object} b * @return {Object} a */ -exports.selectiveExtend = function (props, a, b) { +exports.selectiveExtend = function (props, a, b) { // eslint-disable-line no-unused-vars if (!Array.isArray(props)) { throw new Error('Array with property names expected as first argument'); } @@ -188,7 +196,7 @@ exports.selectiveExtend = function (props, a, b) { for (var p = 0; p < props.length; p++) { var prop = props[p]; - if (other.hasOwnProperty(prop)) { + if (other && other.hasOwnProperty(prop)) { a[prop] = other[prop]; } } @@ -196,116 +204,112 @@ exports.selectiveExtend = function (props, a, b) { return a; }; + /** - * Extend object a with selected properties of object b or a series of objects - * Only properties with defined values are copied - * @param {Array.} props - * @param {Object} a - * @param {Object} b - * @return {Object} a + * Extend object a with selected properties of object b. + * Only properties with defined values are copied. + * + * **Note:** Previous version of this routine implied that multiple source objects + * could be used; however, the implementation was **wrong**. + * Since multiple (>1) sources weren't used anywhere in the `vis.js` code, + * this has been removed + * + * @param {Array.} props names of first-level properties to copy over + * @param {object} a target object + * @param {object} b source object + * @param {boolean} [allowDeletion=false] if true, delete property in a if explicitly set to null in b + * @returns {Object} a */ exports.selectiveDeepExtend = function (props, a, b, allowDeletion = false) { // TODO: add support for Arrays to deepExtend if (Array.isArray(b)) { throw new TypeError('Arrays are not supported by deepExtend'); } - for (var i = 2; i < arguments.length; i++) { - var other = arguments[i]; - for (var p = 0; p < props.length; p++) { - var prop = props[p]; - if (other.hasOwnProperty(prop)) { - if (b[prop] && b[prop].constructor === Object) { - if (a[prop] === undefined) { - a[prop] = {}; - } - if (a[prop].constructor === Object) { - exports.deepExtend(a[prop], b[prop], false, allowDeletion); - } - else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } - } - } else if (Array.isArray(b[prop])) { - throw new TypeError('Arrays are not supported by deepExtend'); - } else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } - } + for (var p = 0; p < props.length; p++) { + var prop = props[p]; + if (b.hasOwnProperty(prop)) { + if (b[prop] && b[prop].constructor === Object) { + if (a[prop] === undefined) { + a[prop] = {}; + } + if (a[prop].constructor === Object) { + exports.deepExtend(a[prop], b[prop], false, allowDeletion); + } + else { + copyOrDelete(a, b, prop, allowDeletion); + } + } else if (Array.isArray(b[prop])) { + throw new TypeError('Arrays are not supported by deepExtend'); + } else { + copyOrDelete(a, b, prop, allowDeletion); } } } return a; }; + /** - * Extend object a with selected properties of object b or a series of objects + * Extend object `a` with properties of object `b`, ignoring properties which are explicitly + * specified to be excluded. + * + * The properties of `b` are considered for copying. + * Properties which are themselves objects are are also extended. * Only properties with defined values are copied - * @param {Array.} props - * @param {Object} a - * @param {Object} b + * + * @param {Array.} propsToExclude names of properties which should *not* be copied + * @param {Object} a object to extend + * @param {Object} b object to take properties from for extension + * @param {boolean} [allowDeletion=false] if true, delete properties in a that are explicitly set to null in b * @return {Object} a */ -exports.selectiveNotDeepExtend = function (props, a, b, allowDeletion = false) { +exports.selectiveNotDeepExtend = function (propsToExclude, a, b, allowDeletion = false) { // TODO: add support for Arrays to deepExtend + // NOTE: array properties have an else-below; apparently, there is a problem here. if (Array.isArray(b)) { throw new TypeError('Arrays are not supported by deepExtend'); } + for (var prop in b) { - if (b.hasOwnProperty(prop)) { - if (props.indexOf(prop) == -1) { - if (b[prop] && b[prop].constructor === Object) { - if (a[prop] === undefined) { - a[prop] = {}; - } - if (a[prop].constructor === Object) { - exports.deepExtend(a[prop], b[prop]); - } - else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } - } - } else if (Array.isArray(b[prop])) { - a[prop] = []; - for (let i = 0; i < b[prop].length; i++) { - a[prop].push(b[prop][i]); - } - } else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } - } + if (!b.hasOwnProperty(prop)) continue; // Handle local properties only + if (propsToExclude.indexOf(prop) !== -1) continue; // In exclusion list, skip + + if (b[prop] && b[prop].constructor === Object) { + if (a[prop] === undefined) { + a[prop] = {}; + } + if (a[prop].constructor === Object) { + exports.deepExtend(a[prop], b[prop]); // NOTE: allowDeletion not propagated! } + else { + copyOrDelete(a, b, prop, allowDeletion); + } + } else if (Array.isArray(b[prop])) { + a[prop] = []; + for (let i = 0; i < b[prop].length; i++) { + a[prop].push(b[prop][i]); + } + } else { + copyOrDelete(a, b, prop, allowDeletion); } } + return a; }; + /** * Deep extend an object a with the properties of object b + * * @param {Object} a * @param {Object} b - * @param [Boolean] protoExtend --> optional parameter. If true, the prototype values will also be extended. - * (ie. the options objects that inherit from others will also get the inherited options) - * @param [Boolean] global --> optional parameter. If true, the values of fields that are null will not deleted + * @param {boolean} [protoExtend=false] If true, the prototype values will also be extended. + * (ie. the options objects that inherit from others will also get the inherited options) + * @param {boolean} [allowDeletion=false] If true, the values of fields that are null will be deleted * @returns {Object} */ -exports.deepExtend = function (a, b, protoExtend, allowDeletion) { +exports.deepExtend = function (a, b, protoExtend=false, allowDeletion=false) { for (var prop in b) { if (b.hasOwnProperty(prop) || protoExtend === true) { if (b[prop] && b[prop].constructor === Object) { @@ -313,15 +317,10 @@ exports.deepExtend = function (a, b, protoExtend, allowDeletion) { a[prop] = {}; } if (a[prop].constructor === Object) { - exports.deepExtend(a[prop], b[prop], protoExtend); + exports.deepExtend(a[prop], b[prop], protoExtend); // NOTE: allowDeletion not propagated! } else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } + copyOrDelete(a, b, prop, allowDeletion); } } else if (Array.isArray(b[prop])) { a[prop] = []; @@ -329,18 +328,14 @@ exports.deepExtend = function (a, b, protoExtend, allowDeletion) { a[prop].push(b[prop][i]); } } else { - if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { - delete a[prop]; - } - else { - a[prop] = b[prop]; - } + copyOrDelete(a, b, prop, allowDeletion); } } } return a; }; + /** * Test whether all elements in two arrays are equal. * @param {Array} a @@ -360,8 +355,8 @@ exports.equalArray = function (a, b) { /** * Convert an object to another type - * @param {Boolean | Number | String | Date | Moment | Null | undefined} object - * @param {String | undefined} type Name of the type. Available types: + * @param {boolean | number | string | Date | Moment | Null | undefined} object + * @param {string | undefined} type Name of the type. Available types: * 'Boolean', 'Number', 'String', * 'Date', 'Moment', ISODate', 'ASPDate'. * @return {*} object @@ -392,7 +387,7 @@ exports.convert = function (object, type) { case 'number': case 'Number': - if (!isNaN(Date.parse(object))) { + if (exports.isString(object) && !isNaN(Date.parse(object))) { return moment(object).valueOf(); } else { return Number(object.valueOf()); @@ -517,7 +512,7 @@ var ASPDateRegex = /^\/?Date\((\-?\d+)/i; /** * Get the type of an object, for example exports.getType([]) returns 'Array' * @param {*} object - * @return {String} type + * @return {string} type */ exports.getType = function (object) { var type = typeof object; @@ -564,8 +559,8 @@ exports.getType = function (object) { /** * Used to extend an array and copy it. This is used to propagate paths recursively. * - * @param arr - * @param newValue + * @param {Array} arr + * @param {*} newValue * @returns {Array} */ exports.copyAndExtendArray = function (arr, newValue) { @@ -575,13 +570,12 @@ exports.copyAndExtendArray = function (arr, newValue) { } newArr.push(newValue); return newArr; -} +}; /** * Used to extend an array and copy it. This is used to propagate paths recursively. * - * @param arr - * @param newValue + * @param {Array} arr * @returns {Array} */ exports.copyArray = function (arr) { @@ -590,7 +584,7 @@ exports.copyArray = function (arr) { newArr.push(arr[i]); } return newArr; -} +}; /** * Retrieve the absolute left value of a DOM element @@ -619,33 +613,34 @@ exports.getAbsoluteTop = function (elem) { /** * add a className to the given elements style * @param {Element} elem - * @param {String} className + * @param {string} classNames */ -exports.addClassName = function (elem, className) { +exports.addClassName = function (elem, classNames) { var classes = elem.className.split(' '); - if (classes.indexOf(className) == -1) { - classes.push(className); // add the class to the array - elem.className = classes.join(' '); - } + var newClasses = classNames.split(' '); + classes = classes.concat(newClasses.filter(function(className) { + return classes.indexOf(className) < 0; + })); + elem.className = classes.join(' '); }; /** * add a className to the given elements style * @param {Element} elem - * @param {String} className + * @param {string} classNames */ -exports.removeClassName = function (elem, className) { +exports.removeClassName = function (elem, classNames) { var classes = elem.className.split(' '); - var index = classes.indexOf(className); - if (index != -1) { - classes.splice(index, 1); // remove the class from the array - elem.className = classes.join(' '); - } + var oldClasses = classNames.split(' '); + classes = classes.filter(function(className) { + return oldClasses.indexOf(className) < 0; + }); + elem.className = classes.join(' '); }; /** * For each method for both arrays and objects. - * In case of an array, the built-in Array.forEach() is applied. + * In case of an array, the built-in Array.forEach() is applied. (**No, it's not!**) * In case of an Object, the method loops over all properties of the object. * @param {Object | Array} object An Object or Array * @param {function} callback Callback method, called for each item in @@ -675,7 +670,7 @@ exports.forEach = function (object, callback) { * Convert an object into an array: all objects properties are put into the * array. The resulting array is unordered. * @param {Object} object - * @param {Array} array + * @returns {Array} array */ exports.toArray = function (object) { var array = []; @@ -690,7 +685,7 @@ exports.toArray = function (object) { /** * Update a property in an object * @param {Object} object - * @param {String} key + * @param {string} key * @param {*} value * @return {Boolean} changed */ @@ -772,6 +767,7 @@ exports.removeEventListener = function (element, action, listener, useCapture) { /** * Cancels the event if it is cancelable, without stopping further propagation of the event. + * @param {Event} event */ exports.preventDefault = function (event) { if (!event) @@ -817,6 +813,7 @@ exports.getTarget = function (event) { * Check if given element contains given parent somewhere in the DOM tree * @param {Element} element * @param {Element} parent + * @returns {boolean} */ exports.hasParent = function (element, parent) { var e = element; @@ -836,7 +833,7 @@ exports.option = {}; /** * Convert a value into a boolean * @param {Boolean | function | undefined} value - * @param {Boolean} [defaultValue] + * @param {boolean} [defaultValue] * @returns {Boolean} bool */ exports.option.asBoolean = function (value, defaultValue) { @@ -854,8 +851,8 @@ exports.option.asBoolean = function (value, defaultValue) { /** * Convert a value into a number * @param {Boolean | function | undefined} value - * @param {Number} [defaultValue] - * @returns {Number} number + * @param {number} [defaultValue] + * @returns {number} number */ exports.option.asNumber = function (value, defaultValue) { if (typeof value == 'function') { @@ -871,8 +868,8 @@ exports.option.asNumber = function (value, defaultValue) { /** * Convert a value into a string - * @param {String | function | undefined} value - * @param {String} [defaultValue] + * @param {string | function | undefined} value + * @param {string} [defaultValue] * @returns {String} str */ exports.option.asString = function (value, defaultValue) { @@ -889,8 +886,8 @@ exports.option.asString = function (value, defaultValue) { /** * Convert a size or location into a string with pixels or a percentage - * @param {String | Number | function | undefined} value - * @param {String} [defaultValue] + * @param {string | number | function | undefined} value + * @param {string} [defaultValue] * @returns {String} size */ exports.option.asSize = function (value, defaultValue) { @@ -926,7 +923,7 @@ exports.option.asElement = function (value, defaultValue) { /** * http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb * - * @param {String} hex + * @param {string} hex * @returns {{r: *, g: *, b: *}} | 255 range */ exports.hexToRGB = function (hex) { @@ -945,20 +942,21 @@ exports.hexToRGB = function (hex) { /** * This function takes color in hex format or rgb() or rgba() format and overrides the opacity. Returns rgba() string. - * @param color - * @param opacity - * @returns {*} + * @param {string} color + * @param {number} opacity + * @returns {String} */ exports.overrideOpacity = function (color, opacity) { + var rgb; if (color.indexOf("rgba") != -1) { return color; } else if (color.indexOf("rgb") != -1) { - var rgb = color.substr(color.indexOf("(") + 1).replace(")", "").split(","); + rgb = color.substr(color.indexOf("(") + 1).replace(")", "").split(","); return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + opacity + ")" } else { - var rgb = exports.hexToRGB(color); + rgb = exports.hexToRGB(color); if (rgb == null) { return color; } @@ -966,14 +964,14 @@ exports.overrideOpacity = function (color, opacity) { return "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + "," + opacity + ")" } } -} +}; /** * - * @param red 0 -- 255 - * @param green 0 -- 255 - * @param blue 0 -- 255 - * @returns {string} + * @param {number} red 0 -- 255 + * @param {number} green 0 -- 255 + * @param {number} blue 0 -- 255 + * @returns {String} * @constructor */ exports.RGBToHex = function (red, green, blue) { @@ -1065,10 +1063,10 @@ exports.parseColor = function (color) { /** * http://www.javascripter.net/faq/rgb2hsv.htm * - * @param red - * @param green - * @param blue - * @returns {*} + * @param {number} red + * @param {number} green + * @param {number} blue + * @returns {{h: number, s: number, v: number}} * @constructor */ exports.RGBToHSV = function (red, green, blue) { @@ -1120,7 +1118,7 @@ var cssUtil = { /** * Append a string with css styles to an element * @param {Element} element - * @param {String} cssText + * @param {string} cssText */ exports.addCssText = function (element, cssText) { var currentStyles = cssUtil.split(element.style.cssText); @@ -1133,7 +1131,7 @@ exports.addCssText = function (element, cssText) { /** * Remove a string with css styles from an element * @param {Element} element - * @param {String} cssText + * @param {string} cssText */ exports.removeCssText = function (element, cssText) { var styles = cssUtil.split(element.style.cssText); @@ -1150,9 +1148,9 @@ exports.removeCssText = function (element, cssText) { /** * https://gist.github.com/mjijackson/5311256 - * @param h - * @param s - * @param v + * @param {number} h + * @param {number} s + * @param {number} v * @returns {{r: number, g: number, b: number}} * @constructor */ @@ -1196,22 +1194,23 @@ exports.isValidRGB = function (rgb) { rgb = rgb.replace(" ", ""); var isOk = /rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)/i.test(rgb); return isOk; -} +}; exports.isValidRGBA = function (rgba) { rgba = rgba.replace(" ", ""); var isOk = /rgba\((\d{1,3}),(\d{1,3}),(\d{1,3}),(.{1,3})\)/i.test(rgba); return isOk; -} +}; /** * This recursively redirects the prototype of JSON objects to the referenceObject * This is used for default options. * - * @param referenceObject + * @param {Array.} fields + * @param {Object} referenceObject * @returns {*} */ exports.selectiveBridgeObject = function (fields, referenceObject) { - if (typeof referenceObject == "object") { + if (referenceObject !== null && typeof referenceObject === "object") { // !!! typeof null === 'object' var objectTo = Object.create(referenceObject); for (var i = 0; i < fields.length; i++) { if (referenceObject.hasOwnProperty(fields[i])) { @@ -1231,16 +1230,22 @@ exports.selectiveBridgeObject = function (fields, referenceObject) { * This recursively redirects the prototype of JSON objects to the referenceObject * This is used for default options. * - * @param referenceObject + * @param {Object} referenceObject * @returns {*} */ exports.bridgeObject = function (referenceObject) { - if (typeof referenceObject == "object") { + if (referenceObject !== null && typeof referenceObject === "object") { // !!! typeof null === 'object' var objectTo = Object.create(referenceObject); - for (var i in referenceObject) { - if (referenceObject.hasOwnProperty(i)) { - if (typeof referenceObject[i] == "object") { - objectTo[i] = exports.bridgeObject(referenceObject[i]); + if (referenceObject instanceof Element) { + // Avoid bridging DOM objects + objectTo = referenceObject; + } else { + objectTo = Object.create(referenceObject); + for (var i in referenceObject) { + if (referenceObject.hasOwnProperty(i)) { + if (typeof referenceObject[i] == "object") { + objectTo[i] = exports.bridgeObject(referenceObject[i]); + } } } } @@ -1254,9 +1259,9 @@ exports.bridgeObject = function (referenceObject) { /** * This method provides a stable sort implementation, very fast for presorted data * - * @param a the array - * @param a order comparator - * @returns {the array} + * @param {Array} a the array + * @param {function} compare an order comparator + * @returns {Array} */ exports.insertSort = function (a,compare) { for (var i = 0; i < a.length; i++) { @@ -1269,35 +1274,128 @@ exports.insertSort = function (a,compare) { return a; } + /** - * this is used to set the options of subobjects in the options object. A requirement of these subobjects - * is that they have an 'enabled' element which is optional for the user but mandatory for the program. + * This is used to set the options of subobjects in the options object. + * + * A requirement of these subobjects is that they have an 'enabled' element + * which is optional for the user but mandatory for the program. + * + * The added value here of the merge is that option 'enabled' is set as required. * - * @param [object] mergeTarget | this is either this.options or the options used for the groups. - * @param [object] options | options - * @param [String] option | this is the option key in the options argument + * + * @param {object} mergeTarget | either this.options or the options used for the groups. + * @param {object} options | options + * @param {string} option | option key in the options argument + * @param {object} globalOptions | global options, passed in to determine value of option 'enabled' */ -exports.mergeOptions = function (mergeTarget, options, option, allowDeletion = false, globalOptions = {}) { - if (options[option] === null) { - mergeTarget[option] = Object.create(globalOptions[option]); +exports.mergeOptions = function (mergeTarget, options, option, globalOptions = {}) { + // Local helpers + var isPresent = function(obj) { + return obj !== null && obj !== undefined; } - else { - if (options[option] !== undefined) { - if (typeof options[option] === 'boolean') { - mergeTarget[option].enabled = options[option]; - } - else { - if (options[option].enabled === undefined) { - mergeTarget[option].enabled = true; - } - for (var prop in options[option]) { - if (options[option].hasOwnProperty(prop)) { - mergeTarget[option][prop] = options[option][prop]; - } - } + + var isObject = function(obj) { + return obj !== null && typeof obj === 'object'; + } + + // https://stackoverflow.com/a/34491287/1223531 + var isEmpty = function(obj) { + for (var x in obj) { if (obj.hasOwnProperty(x)) return false; } + return true; + }; + + // Guards + if (!isObject(mergeTarget)) { + throw new Error('Parameter mergeTarget must be an object'); + } + + if (!isObject(options)) { + throw new Error('Parameter options must be an object'); + } + + if (!isPresent(option)) { + throw new Error('Parameter option must have a value'); + } + + if (!isObject(globalOptions)) { + throw new Error('Parameter globalOptions must be an object'); + } + + + // + // Actual merge routine, separated from main logic + // Only a single level of options is merged. Deeper levels are ref'd. This may actually be an issue. + // + var doMerge = function(target, options, option) { + if (!isObject(target[option])) { + target[option] = {}; + } + + let src = options[option]; + let dst = target[option]; + for (var prop in src) { + if (src.hasOwnProperty(prop)) { + dst[prop] = src[prop]; } } + }; + + + // Local initialization + var srcOption = options[option]; + var globalPassed = isObject(globalOptions) && !isEmpty(globalOptions); + var globalOption = globalPassed? globalOptions[option]: undefined; + var globalEnabled = globalOption? globalOption.enabled: undefined; + + + ///////////////////////////////////////// + // Main routine + ///////////////////////////////////////// + if (srcOption === undefined) { + return; // Nothing to do } + + + if ((typeof srcOption) === 'boolean') { + if (!isObject(mergeTarget[option])) { + mergeTarget[option] = {}; + } + + mergeTarget[option].enabled = srcOption; + return; + } + + if (srcOption === null && !isObject(mergeTarget[option])) { + // If possible, explicit copy from globals + if (isPresent(globalOption)) { + mergeTarget[option] = Object.create(globalOption); + } else { + return; // Nothing to do + } + } + + if (!isObject(srcOption)) { + return; + } + + // + // Ensure that 'enabled' is properly set. It is required internally + // Note that the value from options will always overwrite the existing value + // + let enabled = true; // default value + + if (srcOption.enabled !== undefined) { + enabled = srcOption.enabled; + } else { + // Take from globals, if present + if (globalEnabled !== undefined) { + enabled = globalOption.enabled; + } + } + + doMerge(mergeTarget, options, option); + mergeTarget[option].enabled = enabled; } @@ -1307,8 +1405,8 @@ exports.mergeOptions = function (mergeTarget, options, option, allowDeletion = f * * @param {Item[]} orderedItems | Items ordered by start * @param {function} comparator | -1 is lower, 0 is equal, 1 is higher - * @param {String} field - * @param {String} field2 + * @param {string} field + * @param {string} field2 * @returns {number} * @private */ @@ -1348,8 +1446,8 @@ exports.binarySearchCustom = function (orderedItems, comparator, field, field2) * * @param {Array} orderedItems * @param {{start: number, end: number}} target - * @param {String} field - * @param {String} sidePreference 'before' or 'after' + * @param {string} field + * @param {string} sidePreference 'before' or 'after' * @param {function} comparator an optional comparator, returning -1,0,1 for <,==,>. * @returns {number} * @private @@ -1361,7 +1459,7 @@ exports.binarySearchValue = function (orderedItems, target, field, sidePreferenc var high = orderedItems.length - 1; var prevValue, value, nextValue, middle; - var comparator = comparator != undefined ? comparator : function (a, b) { + comparator = comparator != undefined ? comparator : function (a, b) { return a == b ? 0 : a < b ? -1 : 1 }; @@ -1481,3 +1579,27 @@ exports.getScrollBarWidth = function () { return (w1 - w2); }; + + +exports.topMost = function (pile, accessors) { + let candidate; + if (!Array.isArray(accessors)) { + accessors = [accessors]; + } + for (const member of pile) { + if (member) { + candidate = member[accessors[0]]; + for (let i = 1; i < accessors.length; i++){ + if (candidate) { + candidate = candidate[accessors[i]] + } else { + continue; + } + } + if (typeof candidate != 'undefined') { + break; + } + } + } + return candidate; +}; diff --git a/misc/RELEASE_CHECKLIST_TEMPLATE.md b/misc/RELEASE_CHECKLIST_TEMPLATE.md new file mode 100644 index 000000000..8369c1b6f --- /dev/null +++ b/misc/RELEASE_CHECKLIST_TEMPLATE.md @@ -0,0 +1,86 @@ +# Release Checklist + +## Communication +- [ ] Create a new issue and copy&paste this checklist into it (Yeah! First Step done!) +- [ ] Talk to the team: Who should make the release? +- [ ] Announce a "Code-Freeze". No new Pull-Request until the release is done! +- [ ] Checkout if we have MAJOR or MINOR changes. If not we do a PATCH release. +- [ ] The new version will be: `vX.Y.Z` +- [ ] Identify open BUGS and add them to the next PATCH milestone (optional). +- [ ] Identify MINOR issues and add them to the next MINOR milestone (optional). + +## Update to the newest version +- [ ] Update to the current version: `git checkout develop && git pull`. +- [ ] Create a new release branch. (`git checkout -b vX.Y.Z develop`) + +## Build & Test +- [ ] Update the version number of the library in `package.json` (remove the "SNAPSHOT"). +- [ ] Build the library: `npm prune && rm -rf node_modules && npm install && npm run build && npm run test` +- [ ] Open some of the examples in your browser and visually check if it works as expected! (*We need automated tests for this!*) + +## History +(*THIS IS A LOT OF WORK! WE SHOULD TRY TO automate this in the future!!*) + +- [ ] Get all commits since the last release: ```git log `git describe --tags --abbrev=0`..HEAD --oneline > .commits.tmp``` +- [ ] Open ".commity.tmp". and remove all commit before the last release. +- [ ] Open every commit in GitHub and move every issue/pull-request to the current milestone. +- [ ] Transfer all Commit-Messages/issues to "HISTORY.md" starting at the button. + - Keep the order of the commits. Older commits are lower newers are higher. + - Bug-Fixes start with `FIX #issue:` + - New Features start with `FEAT #issue:` + - Refactors start with `REFA #PR:` + - Additional work start with `Added #PR:` + + +## Commit +- [ ] Commit the new version: `git commit -am "Release vX.Y.Z"` +- [ ] Push the release branch: `git push` +- [ ] Open a Pull-Request for the release-branch to the develop-branch. +- [ ] Wait until somebody of the team looked over your changes and merges the Pull-Request. + +### Update Master +We don't merge the development branch to the master because the master branch is different to the develop-Branch. The master branch has a dist and test folder and does not generate Source-Maps. + +If we would merge the development branch would overwrite this. To solve this we use rebase instead: + +- [ ] Update: `git fetch && git checkout develop && git pull` +- [ ] Rebase the `master` branch on the `develop` branch: `git checkout master && git rebase develop` +- [ ] Generate new dist files: `npm prune && rm -rf node_modules && npm install && npm run build && npm run test && git commit -am "generated dist files for vX.Y.Z" +- [ ] Create a version tag: `git tag "vX.Y.Z"` +- [ ] [Remove the protection](https://github.com/almende/vis/settings/branches/master) from `master`. +- [ ] FORCE-Push the branches to github: `git push --force && git push --tag` +- [ ] [Re-Enable branch protection](https://github.com/almende/vis/settings/branches/master) (enable ALL checkboxes) for `master`. +- [ ] Publish with npm: `npm publish` (check [npmjs.com](https://www.npmjs.com/package/vis)) +- [ ] Create a [new Release](https://github.com/almende/vis/releases/new) with the tang and the name "vX.Y.Z" and copy the data vom [HISTORY.md](../HISTORY.md) into the body. + + +## Test +- [ ] Go to a temp directory (e.g. "vis_vX.Y.Z"): `cd .. && mkdir vis_vX.Y.Z && cd vis_vX.Y.Z` +- [ ] Install the library from npm: `npm init -f && npm install vis` +- [ ] Verify if it installs the just released version, and verify if it works: `cd node_modules/vis/ +- [ ] Install the library via bower: `cd ../.. && bower install vis` +- [ ] Verify if it installs the just released version, and verify if it works: `cd bower_components/vis/` +- [ ] Clone the master from github: `cd ../.. && git clone git@github.com:almende/vis.git`. +- [ ] Verify if it installs the just released version, and verify if it works. `cd vis` + +## Update website +- [ ] update the gh-pages branch: `git checkout gh-pages && git pull && git checkout -b "gh-pages_vX.Y.Z"` +- [ ] Copy the `dist` folder from the `master` branch to the `github-pages` branch in another directory, overwriting existing files: `cp -rf ../vis_vX.Y.Z/vis/dist .` +- [ ] Copy the `docs` folder from the `master` branch to the `github-pages` branch in another directory, overwriting existing files: `cp -rf ../vis_vX.Y.Z/vis/docs .` +- [ ] Copy the `examples` folder from the `master` branch to the `github-pages` branch in another directory, overwriting existing files: `cp -rf ../vis_vX.Y.Z/vis/examples .` +- [ ] Check if there are new or updated examples, and update the gallery screenshots accordingly. +- [ ] Update the library version number in the `index.html` page. +- [ ] Update the CDN links at the download section of index.html AND the CDN link at the top. (search-replace all!!) +- [ ] Commit the changes: `git add -A && git commit -m "updates for vX.Y.Z"` +- [ ] Push the changes `git push --set-upstream origin gh-pages_vX.Y.Z` + +## Prepare next version +- [ ] Switch to the "develop" branch: `git checkout develop`. +- [ ] Change version numbers in "package.json" to a snapshot version `X.X.Z-SNAPSHOT`. +- [ ] Commit and push: `git commit -am "changed version to vX.X.Z-SNAPSHOT"` +- [ ] Create new tag: `git tag vX.X.Z-SNAPSHOT`. +- [ ] [Remove the protection](https://github.com/almende/vis/settings/branches/develop) from `develop`. +- [ ] FORCE-Push the branches to github: `git push --force && git push --tag` +- [ ] [Re-Enable branch protection](https://github.com/almende/vis/settings/branches/develop) (enable ALL checkboxes) for `develop`. + +DONE! \ No newline at end of file diff --git a/misc/how_to_help.md b/misc/how_to_help.md index 63514fa43..37647e050 100644 --- a/misc/how_to_help.md +++ b/misc/how_to_help.md @@ -1,69 +1,5 @@ -# HowTo Help +# How to help! -The company that developed vis.js for the main part, *almende* is [not able to maintain the project at the moment](./we_need_help.md). So help from the community is very needed and welcome! +This project is no longer in active development. See #4259 for details. -## There are many ways to help: - -### Answering questions - -There are new [issues with questions](//github.com/almende/vis/issues?q=is%3Aissue+is%3Aopen+label%3AQuestion+sort%3Acreated-desc) how to use vis.js opened almost every day. Be part of the community and help answer them! - -A better way to ask questions on how to use vis.js is [stackoverflow](https://stackoverflow.com/tags/vis.js). Questions are posed here also and need to be answered by the community. [Please help answering questions](https://stackoverflow.com/tags/vis.js) here also. - -### Closing old issues - -A new issue is often opened fast and then forgotten. Please help go trough [the old issues](//github.com/almende/vis/issues?q=is%3Aissue+is%3Aopen+sort%3Acreated-asc) (especially the [questions](//github.com/almende/vis/issues?q=is%3Aissue+is%3Aopen+sort%3Acreated-asc+label%3AQuestion)) and ask the creator of the issues if the problem still exists before closing the issue. The support team uses the **issue inactive** label to mark these issues. - -### Improve the webpage - -The visjs.org webpage is hosted on the [gh-pages branch](//github.com/almende/vis/tree/gh-pages). If you find a typo or anything else that should be improved feel free to create a pull-request to *gh-pages*. Please make changes in your own fork of gh-pages so the support team can view the changes in your hosted fork. - -### Create new examples - -We have [a collection of examples](//github.com/almende/vis/tree/develop/examples). Please help by creating interesting new ones that show a specific problem or layout. Keep the examples easy to understand for beginners and remove unnecessary clutter. - -### Provide interesting showcases - -If you use vis.js to develop something beautiful feel free to create a pull-request to our show cases page in the gh-pages branch](//github.com/almende/vis/tree/gh-pages/showcase). [These showcases are displayed on our webpage](http://visjs.org/showcase/index.html) and we are always looking for new examples. - -### Confirming and fixing bugs - -Every software has bugs. We also have [quite a nice collection](https://github.com/almende/vis/issues?q=is%3Aissue+is%3Aopen+label%3ABug+sort%3Areactions-%2B1-desc) ;-) -Feel free to fix as many bugs as you want! - -You can not only help by fixing bugs, but also by confirming the bug or even creating a minimal code example to prove this bug exists. - -### Implementing Feature-Requests - -A lot of people have a lot of ideas for improving vis.js. [We label these issues as **Feature-Request**](https://github.com/almende/vis/labels/Feature-Request). Feel free to implement a new feature by creating a new Pull-Request. - -[Some issues are labeled **For everybody!**](//github.com/almende/vis/issues?q=is%3Aissue+is%3Aopen+label%3A%22For+everyone%21%22+sort%3Areactions-%2B1-desc). These are a good starting point. - -### Reviewing Pull-Requests - -We use [GitHub's two-step review](//help.github.com/articles/about-pull-request-reviews/) to make sure pull-requests are clean. You can help by checking out pull-request branches and testing them. You also can comment on lines of code and make sure the pull-request introduces no new bugs or typos. - -## Creating Pull Requests - -There are some rules for pull-request: - -* All pull-request must be to the [develop-branch](//github.com/almende/vis/tree/develop). Pull-request against the [master-branch](//github.com/almende/vis/tree/master) must be closed. (Changes to [gh-pages](//github.com/almende/vis/tree/gh-pages) are also ok.) - -* Only commit changes done in the source files in the folder `lib`, not to the builds - which are located in the folder `dist`. - -* Keep your changes small and clear. Only work on one topic at one time and only change lines of code that you have to change to reach your goal. - -* Test your changes before creating a pull-request. The easiest way is to open the existing examples and playing with them. - -* If you are fixing or implementing an existing issue, please refer to it in the description and in the commit message. - -* If you are introducing a new feature, add some documentation and a new example to make it easy to adapt. - -* If you introduce breaking changes, like changing the signature of a public function, point that out in your description. Breaking changes result in a new major release. - -* Always adapt to the code style of the existing source. Never adapt existing code to your personal taste. :trollface: - -* Pull-requests must be reviewed by at least two member of the support team. The First must approve the pull-request, the second can than merge after also checking it. - -**Happy Helping!!** +Please consider contributing to the [visjs community](https://github.com/visjs)! \ No newline at end of file diff --git a/misc/how_to_publish.md b/misc/how_to_publish.md deleted file mode 100644 index e5adde14a..000000000 --- a/misc/how_to_publish.md +++ /dev/null @@ -1,97 +0,0 @@ -# How to publish vis.js - -This document describes how to publish vis.js. - - -## Build - -- Change the version number of the library in `package.json`. - - npm version major|minor|patch - git commit -m "bumped package.json version to X.XX.X" - -- Open `HISTORY.md`, write down the changes, version number, and release date. - (Changes since last release: `git log \`git describe --tags --abbrev=0\`..HEAD --oneline`) - -- Update external dependencies - - npm install -g npm-check-updates - npm-check-updates -u - git commit -a -m "updated external dependencies" - -- Build the library by running: - - npm prune - npm update - npm run build - -## Test - -- Test the library: - - npm test - -- Open some of the examples in your browser and visually check if it works as expected. - - -## Commit - -- Commit the changes to the `develop` branch. -- Merge the `develop` branch into the `master` branch. -- Push the branches to github -- Create a version tag (with the new version number) and push it to github: - - git tag v3.1.0 - git push --tags - - -## Publish - -- Publish at npm: - - npm publish - -- Test the published library: - - Go to a temp directory - - Install the library from npm: - - npm install vis - - Verify if it installs the just released version, and verify if it works. - - - Install the library via bower: - - bower install vis - - Verify if it installs the just released version, and verify if it works. - - - Verify within a day or so whether vis.js is updated on http://cdnjs.com/ - - -## Update website - -- Copy the `dist` folder from the `master` branch to the `github-pages` branch. -- Copy the `docs` folder from the `master` branch to the `github-pages` branch. -- Copy the `examples` folder from the `master` branch to the `github-pages` branch. -- Create a packaged version of vis.js. Go to the `master` branch and run: - - zip vis.zip dist docs examples README.md HISTORY.md CONTRIBUTING.md LICENSE* NOTICE -r - -- Move the created zip file `vis.zip` to the `download` folder in the - `github-pages` branch. TODO: this should be automated. - -- Check if there are new or updated examples, and update the gallery screenshots - accordingly. - -- Update the library version number in the index.html page. - -- Update the CDN links at the download section of index.html AND the CDN link at the top. (replace all) - -- Commit the changes in the `gh-pages` branch. - - -## Prepare next version - -- Switch to the `develop` branch. -- Change version numbers in `package.json` to a snapshot - version like `0.4.0-SNAPSHOT`. diff --git a/misc/labels.md b/misc/labels.md new file mode 100644 index 000000000..8307c374d --- /dev/null +++ b/misc/labels.md @@ -0,0 +1,106 @@ +# How we use Github labels + +*Because only team members can add and change labels this document is mainly for maintainers, but also for users to understand how we use labels.* + +*It is important to also label old and closed issues uniformly in order to export them later e.g. if the project gets separated into multiple components.* + + +## Issue Types +If an issue was created it MUST always be labeled as one of the following issue types: + +### `Question` +The author has a general or very specific question.
+If it is a general question on how to use vis.js the issues should be closed immediately with a reference to [stackoverflow](https://stackoverflow.com/questions/tagged/vis.js).
+Specific question or hard problems should stay open.
+Questions should be closed within 3 months. + +### `Problem` +This issues points to a potential bug that needs to be confirmed.
+If the problem most likely originates from the user's code it should be labeled as [`Question`](#question) instead.
+The support team should try to reproduce this issue and then close it or mark it as [`Confirmed Bug`](#confirmed-bug). + +### `Confirmed Bug` +This issue was reported as [`Problem`](#problem), but the issue is reproducible and is now a confirmed bug. + +### `Feature-Request` +This issue proposes a new feature or a change of existing functionality. Issues that are unlikely to get implemented should be closed. + +### `wontfix` +This issues is e.g. for discussing a topic or for project management purposes, and is not handled in the usual issue process. + + +## Graph type +All issues MUST have one of the following type labels. These labels are usually mutually exclusive: + +### `DataSet` +Concerns the DataSet implementation. + +### `Graph2D` +Concerns the 2D-Graph implementation. + +### `Graph3D` +Concerns the 3D-Graph implementation. + +### `Network` +Concerns the Network-Graph implementation. + +### `source/non-public API` +This issues is just for discussion or is concerning the build-process, the code-style or something similar. + +### `Timeline` +Concerns the Timeline-Graph implementation. + + +## Additional labels + +### `Docs` +This issue concerns only the documentation.
+If an existing issue is documented wrongly this is a [`Problem`](#problem) in the component and not a [`docs`](#docs) issue.
+This can be used for typos or requests for an improvement of the docs. + +### `Duplicate` +This issues is a duplicate of an existing issue. The duplicate should be closed. In addition, add a reference to the original issue with a comment. + +### `Fixed awaiting release` +This Issue is fixed or implemented in the "develop" branch but is not released yet and therefore should be still open.
+This issues should be closed after the changes are merged into the "master" branch. + +### `For everyone!` +This is a good issue to start working on if you are new to vis.js and want to help.
+This label is also used for labels that may concern a lot of vis.js users. + +### `IE / Edge` +These issues concern a problem with the Microsoft Internet Explorer or Edge browser.
+ +### `invalid` +This is not a valid issue.
+Someone just created an empty issue, picked the wrong project or something similar.
+This can also be used for pull-request to a non-develop branch or something similar.
+This issue or pull request should be closed immediately. + +### `Issue Inactive` +Issues marked as [`Question`](#question) or [`Problem`](#problem) get marked as inactive when the author is not responsive or the topic is old.
+If an issue is marked as inactive for about 2 weeks it can be closed without any hesitation. + +### `PRIORITY` +In general this is used for major bugs. There should only exist a few issues marked as PRIORITY at the same time.
+These issues need to be handled before all others. + +### `Requires breaking change` +A lot of code needs to be changed to implement this. This is maybe something for a major release or something for someone with a lot of time on their hands :-) + +### `waiting for answer/improvement` +This is mostly used for pull requests were a reviewer requested some changes and the owner has not responded yet. + +### `Work In Progress` +Someone is working on this issue or a pull request already exists and needs to be reviewed.
+ +## Example Workflows + +### Bug + +[`Problem`](#Problem) ⟶ [`Confirmed Bug`](#confirmed-bug) ⟶ [`Work In Progress`](#work-in-progress) ⟶ [`Fixed awaiting release`](#fixed-awaiting-release) + +### Feature-Request + +[`Feature-Request`](#feature-request) ⟶ [`Work In Progress`](#work-in-progress) ⟶ [`Fixed awaiting release`](#fixed-awaiting-release) diff --git a/misc/we_need_help.md b/misc/we_need_help.md index b8aa4dd53..9da5f0bbf 100644 --- a/misc/we_need_help.md +++ b/misc/we_need_help.md @@ -1,16 +1,5 @@ # We need help! -## The current status +This project is no longer in active development. See #4259 for details. -Vis.js is looking for people who can help maintain and improve the library. We've put a lot of effort in building these visualizations, fixing bugs, and supporting users as much as we can. For some time now, we’ve been lacking the manpower to maintain the library the way we have in recent years. [@josdejong](//github.com/josdejong) has left the company for a new opportunity, and [@AlexDM0](//github.com/AlexDM0) has moved internally to a daughter company, with severe impact on his time and availability for Vis.js. At the moment [@ludost](//github.com/ludost) is the official maintainer from Almende, but does not have much time to help out. - -Although Almende is looking to replace the expertise required for Vis.js, we don't expect to be able to do comprehensive project management any time soon. At the same time we’d like to spare Vis.js from becoming abandonware, especially given the relative healthy user base. For the longer term future we would be happy if vis.js could stand on its own feet, community supported. - -**If you want to support the project please just start by [helping out](./how_to_help.md).** - -If you have shown some commitment to the project you can ask [@ludost](//github.com/ludost) to become a member of the community support team. This team has write permissions to the repository and is helping maintaining it. Currently this team consists of: - -* [@ludost](//github.com/ludost) (almende maintainer) -* [@mojoaxel](//github.com/mojoaxel) -* [@yotamberk](//github.com/yotamberk) -* [@Tooa](//github.com/Tooa) +Please consider contributing to the [visjs community](https://github.com/visjs)! diff --git a/package.json b/package.json index a3f634f3d..16835a60c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vis", - "version": "4.17.0", + "version": "4.21.0-SNAPSHOT", "description": "A dynamic, browser-based visualization library.", "homepage": "http://visjs.org/", "license": "(Apache-2.0 OR MIT)", @@ -8,6 +8,9 @@ "type": "git", "url": "git://github.com/almende/vis.git" }, + "bugs": { + "url": "https://github.com/almende/vis/issues" + }, "keywords": [ "vis", "visualization", @@ -23,36 +26,51 @@ ], "main": "./dist/vis.js", "scripts": { - "test": "mocha", + "test": "mocha --compilers js:babel-core/register", + "test-cov": "nyc --reporter=lcov mocha --compilers js:babel-core/register", "build": "gulp", + "lint": "gulp lint", "watch": "gulp watch", "watch-dev": "gulp watch --bundle" }, "dependencies": { "emitter-component": "^1.1.1", - "moment": "^2.12.0", + "moment": "^2.18.1", "propagating-hammerjs": "^1.4.6", - "hammerjs": "^2.0.6", + "hammerjs": "^2.0.8", "keycharm": "^0.2.0" }, "devDependencies": { - "async": "^2.0.0-rc.2", - "babel-core": "^6.6.5", - "babel-loader": "^6.2.4", - "babel-preset-es2015": "^6.6.0", - "babelify": "^7.2.0", - "clean-css": "^3.4.10", - "gulp": "^3.9.1", + "gulp": "^4.0.1", "gulp-clean-css": "^2.0.11", - "gulp-concat": "^2.6.0", + "async": "^2.5.0", + "babel-core": "^6.25.0", + "babel-loader": "^7.1.1", + "babel-plugin-transform-es3-member-expression-literals": "^6.22.0", + "babel-plugin-transform-es3-property-literals": "^6.22.0", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-polyfill": "^6.23.0", + "babel-preset-es2015": "^6.24.1", + "babel-runtime": "^6.23.0", + "babelify": "^7.3.0", + "canvas": "^1.6.5", + "clean-css": "^4.1.7", + "eslint": "^4.3.0", + "gulp-concat": "^2.6.1", + "gulp-eslint": "^4.0.0", "gulp-rename": "^1.2.2", - "gulp-util": "^3.0.7", - "merge-stream": "^1.0.0", - "mocha": "^3.1.2", - "rimraf": "^2.5.2", - "uglify-js": "^2.6.2", - "uuid": "^2.0.1", - "webpack": "^1.12.14", - "yargs": "^6.3.0" + "gulp-util": "^3.0.8", + "jsdom": "9.12.0", + "jsdom-global": "^2.1.1", + "merge-stream": "^1.0.1", + "mocha": "^3.4.2", + "mocha-jsdom": "^1.1.0", + "nyc": "^11.2.1", + "rimraf": "^2.6.1", + "test-console": "^1.0.0", + "uglify-js": "^2.8.29", + "uuid": "^3.1.0", + "webpack": "^3.3.0", + "yargs": "^8.0.2" } } diff --git a/test/DataSet.test.js b/test/DataSet.test.js index 23344a02f..cd7e2ed0d 100644 --- a/test/DataSet.test.js +++ b/test/DataSet.test.js @@ -282,7 +282,120 @@ describe('DataSet', function () { {id: 3, content: 'Item 3'}, {id: 4, content: 'Item 4'} ]); + }); + + describe('add', function () { + it('adds nothing for an empty array', function () { + var dataset = new DataSet([]); + var dataItems = []; + assert.equal(dataset.add(dataItems).length, 0) + }); + + it('adds items of an array', function () { + var dataset = new DataSet([]); + var dataItems = [ + {_id: 1, content: 'Item 1', start: new Date(now.valueOf())}, + {_id: 2, content: 'Item 2', start: new Date(now.valueOf())} + ]; + assert.equal(dataset.add(dataItems).length, 2) + }); + + it('adds a single object', function () { + var dataset = new DataSet([]); + var dataItem = {_id: 1, content: 'Item 1', start: new Date(now.valueOf())}; + assert.equal(dataset.add(dataItem).length, 1) + }); + + it('throws an error when passed bad datatypes', function () { + var dataset = new DataSet([]); + assert.throws(function () { dataset.add(null) }, Error, "null type throws error"); + assert.throws(function () { dataset.add(undefined) }, Error, "undefined type throws error"); + }); + }); + + describe('setOptions', function () { + var dataset = new DataSet([ + {_id: 1, content: 'Item 1', start: new Date(now.valueOf())} + ], {queue: true}); + + it('does not update queue when passed an undefined queue', function () { + var dataset = new DataSet([], {queue: true}); + dataset.setOptions({queue: undefined}); + assert.notEqual(dataset._queue, undefined) + }); + it('destroys the queue when queue set to false', function () { + var dataset = new DataSet([]); + dataset.setOptions({queue: false}); + assert.equal(dataset._queue, undefined) + }); + + it('udpates queue options', function () { + var dataset = new DataSet([]); + dataset.setOptions({queue: {max: 5, delay: 3}}); + assert.equal(dataset._queue.max, 5); + assert.equal(dataset._queue.delay, 3); + }); + + it('creates new queue given if none is set', function () { + var dataset = new DataSet([], {queue: true}); + dataset._queue.destroy(); + dataset._queue = null; + dataset.setOptions({queue: {max: 5, delay: 3}}); + assert.equal(dataset._queue.max, 5); + assert.equal(dataset._queue.delay, 3); + }); }); -}); \ No newline at end of file + describe('on / off', function () { + var dataset = new DataSet([ + {_id: 1, content: 'Item 1', start: new Date(now.valueOf())} + ]); + var count = 0; + function inc() {count++;} + + it('fires for put', function () { + var dataset = new DataSet([]); + count = 0; + // on + dataset.on('add', inc); + dataset.add({_id: 1, content: 'Item 1', start: new Date(now.valueOf())}); + assert.equal(count, 1); + // off + dataset.off('add', inc); + dataset.add({_id: 2, content: 'Item 2', start: new Date(now.valueOf())}); + assert.equal(count, 1); + }); + + it('fires for remove', function () { + var dataset = new DataSet([]); + count = 0; + // on + dataset.on('remove', inc); + var id = dataset.add({_id: 1, content: 'Item 1', start: new Date(now.valueOf())}); + dataset.remove(id); + assert.equal(count, 1); + // off + dataset.off('remove', inc); + id = dataset.add({_id: 1, content: 'Item 1', start: new Date(now.valueOf())}); + dataset.remove(id); + assert.equal(count, 1); + + }); + + it('fires for update', function () { + var dataset = new DataSet([]); + count = 0; + // on + dataset.on('update', inc); + var id = dataset.add({_id: 1, content: 'Item 1', start: new Date(now.valueOf())}); + dataset.update({id: id, content: 'beep boop'}); + assert.equal(count, 1); + // off + dataset.off('update', inc); + id = dataset.add({_id: 1, content: 'Item 1', start: new Date(now.valueOf())}); + dataset.update({id: id, content: 'beep boop'}); + assert.equal(count, 1); + }); + }); +}); diff --git a/test/Graph3d.test.js b/test/Graph3d.test.js new file mode 100644 index 000000000..5c5e76ea1 --- /dev/null +++ b/test/Graph3d.test.js @@ -0,0 +1,69 @@ +var assert = require('assert'); +var vis = require('../dist/vis'); +var Graph3d = vis.Graph3d; +var jsdom_global = require('jsdom-global'); +var stdout = require('test-console').stdout; +var Validator = require("./../lib/shared/Validator").default; +//var {printStyle} = require('./../lib/shared/Validator'); +var {allOptions, configureOptions} = require('./../lib/graph3d/options.js'); + +var now = new Date(); + +describe('Graph3d', function () { + before(function() { + //console.log('before!'); + this.jsdom_global = jsdom_global( + "
", + { skipWindowCheck: true} + ); + this.container = document.getElementById('mygraph'); + }); + + + it('should pass validation for the default options', function () { + assert(Graph3d.DEFAULTS !== undefined); + + let errorFound; + let output; + output = stdout.inspectSync(function() { + errorFound = Validator.validate(Graph3d.DEFAULTS, allOptions); + }); + + // Useful during debugging: + //if (errorFound === true) { + // console.log(JSON.stringify(output, null, 2)); + //} + assert(!errorFound, 'DEFAULTS options object does not pass validation'); + }); + + + it('accepts new option values on defined instance', function () { + assert(this.container !== null, 'Container div not found'); + + var BAR_STYLE = 0; // from var STYLE in Settings.js + var DOT_STYLE = 3; // idem + + var data = [ + {x:0, y:0, z: 10}, + {x:0, y:1, z: 20}, + {x:1, y:0, z: 30}, + {x:1, y:1, z: 40}, + ]; + + var options = { + style: 'dot' + }; + + var graph = new vis.Graph3d(this.container, data, options); + assert.equal(graph.style, DOT_STYLE, "Style not set to expected 'dot'"); + + graph.setOptions({ style: 'bar'}); // Call should just work, no exception thrown + assert.equal(graph.style, BAR_STYLE, "Style not set to expected 'bar'"); + }); + + + after(function() { + //console.log('after!'); + this.jsdom_global(); + }); +}); diff --git a/test/Label.test.js b/test/Label.test.js new file mode 100644 index 000000000..e92b43c76 --- /dev/null +++ b/test/Label.test.js @@ -0,0 +1,1624 @@ +/** + * TODO - add tests for: + * ==== + * + * - html unclosed or unopened tags + * - html tag combinations with no font defined (e.g. bold within mono) + * - Unit tests for bad font shorthands. + * Currently, only "size[px] name color" is valid, always 3 items with this exact spacing. + * All other combinations should either be rejected as error or handled gracefully. + */ +var assert = require('assert') +var Label = require('../lib/network/modules/components/shared/Label').default; +var NodesHandler = require('../lib/network/modules/NodesHandler').default; +var util = require('../lib/util'); +var jsdom_global = require('jsdom-global'); +var vis = require('../dist/vis'); +var Network = vis.network; + + +/************************************************************** + * Dummy class definitions for minimal required functionality. + **************************************************************/ + +class DummyContext { + measureText(text) { + return { + width: 12*text.length, + height: 14 + }; + } +} + + +class DummyLayoutEngine { + positionInitially() {} +} + +/************************************************************** + * End Dummy class definitions + **************************************************************/ + + +describe('Network Label', function() { + + /** + * Retrieve options object from a NodesHandler instance + * + * NOTE: these are options at the node-level + */ + function getOptions(options = {}) { + var body = { + functions: {}, + emitter: { + on: function() {} + } + } + + var nodesHandler = new NodesHandler(body, {}, options, new DummyLayoutEngine() ); + //console.log(JSON.stringify(nodesHandler.options, null, 2)); + + return nodesHandler.options; + } + + + /** + * Check if the returned lines and blocks are as expected. + * + * All width/height fields and font info are ignored. + * Within blocks, only the text is compared + */ + function checkBlocks(returned, expected) { + let showBlocks = () => { + return '\nreturned: ' + JSON.stringify(returned, null, 2) + '\n' + + 'expected: ' + JSON.stringify(expected, null, 2); + } + + assert.equal(expected.lines.length, returned.lines.length, 'Number of lines does not match, ' + showBlocks()); + + for (let i = 0; i < returned.lines.length; ++i) { + let retLine = returned.lines[i]; + let expLine = expected.lines[i]; + + assert(retLine.blocks.length === expLine.blocks.length, 'Number of blocks does not match, ' + showBlocks()); + for (let j = 0; j < retLine.blocks.length; ++j) { + let retBlock = retLine.blocks[j]; + let expBlock = expLine.blocks[j]; + + assert(retBlock.text === expBlock.text, 'Text does not match, ' + showBlocks()); + + assert(retBlock.mod !== undefined); + if (retBlock.mod === 'normal' || retBlock.mod === '') { + assert(expBlock.mod === undefined || expBlock.mod === 'normal' || expBlock === '', + 'No mod field expected in returned, ' + showBlocks()); + } else { + assert(retBlock.mod === expBlock.mod, 'Mod fields do not match, line: ' + i + ', block: ' + j + + '; ret: ' + retBlock.mod + ', exp: ' + expBlock.mod + '\n' + showBlocks()); + } + } + } + } + + + function checkProcessedLabels(label, text, expected) { + var ctx = new DummyContext(); + + for (var i in text) { + var ret = label._processLabelText(ctx, false, false, text[i]); + //console.log(JSON.stringify(ret, null, 2)); + checkBlocks(ret, expected[i]); + } + } + + +/************************************************************** + * Test data + **************************************************************/ + + var normal_text = [ + "label text", + "label\nwith\nnewlines", + "OnereallylongwordthatshouldgooverwidthConstraint.maximumifdefined", + "One really long sentence that should go over widthConstraint.maximum if defined", + "Reallyoneenormouslylargelabel withtwobigwordsgoingoverwayovermax" + ] + + var html_text = [ + "label with some multi tags", + "label with some \n multi tags\n and newlines" // NB spaces around \n's + ]; + + var markdown_text = [ + "label *with* `some` _multi *tags*_", + "label *with* `some` \n _multi *tags*_\n and newlines" // NB spaces around \n's + ]; + + +/************************************************************** + * Expected Results + **************************************************************/ + + var normal_expected = [{ + // In first item, width/height kept in for reference + width: 120, + height: 14, + lines: [{ + width: 120, + height: 14, + blocks: [{ + text: "label text", + width: 120, + height: 14, + }] + }] + }, { + lines: [{ + blocks: [{text: "label"}] + }, { + blocks: [{text: "with"}] + }, { + blocks: [{text: "newlines"}] + }] + }, { + // From here onward, changes width max width set + lines: [{ + blocks: [{text: "OnereallylongwordthatshouldgooverwidthConstraint.maximumifdefined"}] + }] + }, { + lines: [{ + blocks: [{text: "One really long sentence that should go over widthConstraint.maximum if defined"}] + }] + }, { + lines: [{ + blocks: [{text: "Reallyoneenormouslylargelabel withtwobigwordsgoingoverwayovermax"}] + }] + }]; + + const indexWidthConstrained = 2; // index of first item that will be different with max width set + + var normal_widthConstraint_expected = normal_expected.slice(0, indexWidthConstrained); + Array.prototype.push.apply(normal_widthConstraint_expected, [{ + lines: [{ + blocks: [{text: "Onereallylongwordthatshoul"}] + }, { + blocks: [{text: "dgooverwidthConstraint.max"}] + }, { + blocks: [{text: "imumifdefined"}] + }] + }, { + lines: [{ + blocks: [{text: "One really long"}] + }, { + blocks: [{text: "sentence that should"}] + }, { + blocks: [{text: "go over"}] + }, { + blocks: [{text: "widthConstraint.maximum"}] + }, { + blocks: [{text: "if defined"}] + }] + }, { + lines: [{ + blocks: [{text: "Reallyoneenormouslylargela"}] + }, { + blocks: [{text: "bel"}] + }, { + blocks: [{text: "withtwobigwordsgoingoverwa"}] + }, { + blocks: [{text: "yovermax"}] + }] + }]); + + + var html_unchanged_expected = [{ + lines: [{ + blocks: [{text: "label with some multi tags"}] + }] + }, { + lines: [{ + blocks: [{text: "label with some "}] + }, { + blocks: [{text: " multi tags"}] + }, { + blocks: [{text: " and newlines"}] + }] + }]; + + var html_widthConstraint_unchanged = [{ + lines: [{ + blocks: [{text: "label with"}] + }, { + blocks: [{text: "some"}] + }, { + blocks: [{text: "multi"}] + }, { + blocks: [{text: "tags"}] + }] + }, { + lines: [{ + blocks: [{text: "label with"}] + }, { + blocks: [{text: "some "}] + }, { + blocks: [{text: " multi"}] + }, { + blocks: [{text: "tags"}] + }, { + blocks: [{text: " and newlines"}] + }] + }]; + + + var markdown_unchanged_expected = [{ + lines: [{ + blocks: [{text: "label *with* `some` _multi *tags*_"}] + }] + }, { + lines: [{ + blocks: [{text: "label *with* `some` "}] + }, { + blocks: [{text: " _multi *tags*_"}] + }, { + blocks: [{text: " and newlines"}] + }] + }]; + + + var markdown_widthConstraint_expected= [{ + lines: [{ + blocks: [{text: "label *with* `some`"}] + }, { + blocks: [{text: "_multi *tags*_"}] + }] + }, { + lines: [{ + blocks: [{text: "label *with* `some` "}] + }, { + blocks: [{text: " _multi *tags*_"}] + }, { + blocks: [{text: " and newlines"}] + }] + }]; + + + var multi_expected = [{ + lines: [{ + blocks: [ + {text: "label "}, + {text: "with" , mod: 'bold'}, + {text: " "}, + {text: "some" , mod: 'mono'}, + {text: " "}, + {text: "multi ", mod: 'ital'}, + {text: "tags" , mod: 'boldital'} + ] + }] + }, { + lines: [{ + blocks: [ + {text: "label "}, + {text: "with" , mod: 'bold'}, + {text: " "}, + {text: "some" , mod: 'mono'}, + {text: " "} + ] + }, { + blocks: [ + {text: " "}, + {text: "multi ", mod: 'ital'}, + {text: "tags" , mod: 'boldital'} + ] + }, { + blocks: [{text: " and newlines"}] + }] + }]; + + + +/************************************************************** + * End Expected Results + **************************************************************/ + + before(function() { + this.jsdom_global = jsdom_global( + "
", + { skipWindowCheck: true} + ); + this.container = document.getElementById('mynetwork'); + }); + + + after(function() { + this.jsdom_global(); + }); + + + it('parses normal text labels', function (done) { + var label = new Label({}, getOptions()); + + checkProcessedLabels(label, normal_text , normal_expected); + checkProcessedLabels(label, html_text , html_unchanged_expected); // html unchanged + checkProcessedLabels(label, markdown_text, markdown_unchanged_expected); // markdown unchanged + + done(); + }); + + + it('parses html labels', function (done) { + var options = getOptions(options); + options.font.multi = true; // TODO: also test 'html', also test illegal value here + + var label = new Label({}, options); + + checkProcessedLabels(label, normal_text , normal_expected); // normal as usual + checkProcessedLabels(label, html_text , multi_expected); + checkProcessedLabels(label, markdown_text, markdown_unchanged_expected); // markdown unchanged + + done(); + }); + + + it('parses markdown labels', function (done) { + var options = getOptions(options); + options.font.multi = 'markdown'; // TODO: also test 'md', also test illegal value here + + var label = new Label({}, options); + + checkProcessedLabels(label, normal_text , normal_expected); // normal as usual + checkProcessedLabels(label, html_text , html_unchanged_expected); // html unchanged + checkProcessedLabels(label, markdown_text, multi_expected); + + done(); + }); + + + it('handles normal text with widthConstraint.maximum', function (done) { + var options = getOptions(options); + + // + // What the user would set: + // + // options.widthConstraint = { minimum: 100, maximum: 200}; + // + // No sense in adding minWdt, not used when splitting labels into lines + // + // This comment also applies to the usage of maxWdt in the test cases below + // + options.font.maxWdt = 300; + + var label = new Label({}, options); + + checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); + checkProcessedLabels(label, html_text , html_widthConstraint_unchanged); // html unchanged + + // Following is an unlucky selection, because the first line broken on the final character (space) + // So we cheat a bit here + options.font.maxWdt = 320; + label = new Label({}, options); + checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected); // markdown unchanged + + done(); + }); + + + it('handles html tags with widthConstraint.maximum', function (done) { + var options = getOptions(options); + options.font.multi = true; + options.font.maxWdt = 300; + + var label = new Label({}, options); + + checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); + checkProcessedLabels(label, html_text , multi_expected); + + // Following is an unlucky selection, because the first line broken on the final character (space) + // So we cheat a bit here + options.font.maxWdt = 320; + label = new Label({}, options); + checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected); + + done(); + }); + + + it('handles markdown tags with widthConstraint.maximum', function (done) { + var options = getOptions(options); + options.font.multi = 'markdown'; + options.font.maxWdt = 300; + + var label = new Label({}, options); + + checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); + checkProcessedLabels(label, html_text , html_widthConstraint_unchanged); + checkProcessedLabels(label, markdown_text, multi_expected); + + done(); + }); + + +describe('Multi-Fonts', function() { + + class HelperNode { + constructor(network) { + this.nodes = network.body.nodes; + } + + fontOption(index) { + return this.nodes[index].labelModule.fontOptions; + }; + + modBold(index) { + return this.fontOption(index).bold; + }; + } + + +describe('Node Labels', function() { + + function createNodeNetwork(newOptions) { + var dataNodes = [ + {id: 0, label: '0'}, + {id: 1, label: '1'}, + {id: 2, label: '2', group: 'group1'}, + {id: 3, label: '3', + font: { + bold: { color: 'green' }, + } + }, + {id: 4, label: '4', group: 'group1', + font: { + bold: { color: 'green' }, + } + }, + ]; + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: new vis.DataSet(dataNodes), + edges: [] + }; + + var options = { + nodes: { + font: { + multi: true + } + }, + groups: { + group1: { + font: { color: 'red' }, + }, + group2: { + font: { color: 'white' }, + }, + }, + }; + + if (newOptions !== undefined) { + util.deepExtend(options, newOptions); + } + + var network = new vis.Network(container, data, options); + return [network, data, options]; + } + + + /** + * Check that setting options for multi-font works as expected + * + * - using multi-font 'bold' for test, the rest should work analogously + * - using multi-font option 'color' for test, the rest should work analogously + */ + it('respects the font option precedence', function (done) { + var [network, data, options] = createNodeNetwork(); + var h = new HelperNode(network); + + assert.equal(h.modBold(0).color, '#343434'); // Default value + assert.equal(h.modBold(1).color, '#343434'); // Default value + assert.equal(h.modBold(2).color, 'red'); // Group value overrides default + assert.equal(h.modBold(3).color, 'green'); // Local value overrides default + assert.equal(h.modBold(4).color, 'green'); // Local value overrides group + + done(); + }); + + + it('handles dynamic data and option updates', function (done) { + var [network, data, options] = createNodeNetwork(); + var h = new HelperNode(network); + + // + // Change some node values dynamically + // + data.nodes.update([ + {id: 1, group: 'group2'}, + {id: 4, font: { bold: { color: 'orange'}}}, + ]); + + assert.equal(h.modBold(0).color, '#343434'); // unchanged + assert.equal(h.modBold(1).color, 'white'); // new group value + assert.equal(h.modBold(3).color, 'green'); // unchanged + assert.equal(h.modBold(4).color, 'orange'); // new local value + + + // + // Change group options dynamically + // + network.setOptions({ + groups: { + group1: { + font: { color: 'brown' }, + }, + }, + }); + + assert.equal(h.modBold(0).color, '#343434'); // unchanged + assert.equal(h.modBold(1).color, 'white'); // Unchanged + assert.equal(h.modBold(2).color, 'brown'); // New group values + assert.equal(h.modBold(3).color, 'green'); // unchanged + assert.equal(h.modBold(4).color, 'orange'); // unchanged + + + network.setOptions({ + nodes: { + font: { + multi: true, + bold: { + color: 'black' + } + } + }, + }); + + assert.equal(h.modBold(0).color, 'black'); // nodes default + assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value + assert.equal(h.modBold(2).color, 'black'); // idem + assert.equal(h.modBold(3).color, 'green'); // unchanged + assert.equal(h.modBold(4).color, 'orange'); // unchanged + + + network.setOptions({ + groups: { + group1: { + font: { bold: {color: 'brown'} }, + }, + }, + }); + + assert.equal(h.modBold(0).color, 'black'); // nodes default + assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value + assert.equal(h.modBold(2).color, 'brown'); // bold group value overrides bold node value + assert.equal(h.modBold(3).color, 'green'); // unchanged + assert.equal(h.modBold(4).color, 'orange'); // unchanged + + done(); + }); + + + it('handles normal font values in default options', function (done) { + var newOptions = { + nodes: { + font: { + color: 'purple' // Override the default value + } + }, + }; + var [network, data, options] = createNodeNetwork(newOptions); + var h = new HelperNode(network); + + assert.equal(h.modBold(0).color, 'purple'); // Nodes value + assert.equal(h.modBold(1).color, 'purple'); // Nodes value + assert.equal(h.modBold(2).color, 'red'); // Group value overrides nodes + assert.equal(h.modBold(3).color, 'green'); // Local value overrides all + assert.equal(h.modBold(4).color, 'green'); // Idem + + done(); + }); + + + it('handles multi-font values in default options/groups', function (done) { + var newOptions = { + nodes: { + font: { + color: 'purple' // This set value should be overridden + } + }, + }; + + newOptions.nodes.font.bold = { color: 'yellow'}; + newOptions.groups = { + group1: { + font: { bold: { color: 'red'}} + } + }; + + var [network, data, options] = createNodeNetwork(newOptions); + var h = new HelperNode(network); + assert(options.nodes.font.multi); + + assert.equal(h.modBold(0).color, 'yellow'); // bold value + assert.equal(h.modBold(1).color, 'yellow'); // bold value + assert.equal(h.modBold(2).color, 'red'); // Group value overrides nodes + assert.equal(h.modBold(3).color, 'green'); // Local value overrides all + assert.equal(h.modBold(4).color, 'green'); // Idem + + done(); + }); + +}); // Node Labels + + +describe('Edge Labels', function() { + + function createEdgeNetwork(newOptions) { + var dataNodes = [ + {id: 1, label: '1'}, + {id: 2, label: '2'}, + {id: 3, label: '3'}, + {id: 4, label: '4'}, + ]; + + var dataEdges = [ + {id: 1, from: 1, to: 2, label: '1'}, + {id: 2, from: 1, to: 4, label: '2', + font: { + bold: { color: 'green' }, + } + }, + {id: 3, from: 2, to: 3, label: '3', + font: { + bold: { color: 'green' }, + } + }, + ]; + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: new vis.DataSet(dataNodes), + edges: new vis.DataSet(dataEdges), + }; + + var options = { + edges: { + font: { + multi: true + } + }, + }; + + if (newOptions !== undefined) { + util.deepExtend(options, newOptions); + } + + var network = new vis.Network(container, data, options); + return [network, data, options]; + } + + + class HelperEdge { + constructor(network) { + this.edges = network.body.edges; + } + + fontOption(index) { + return this.edges[index].labelModule.fontOptions; + }; + + modBold(index) { + return this.fontOption(index).bold; + }; + } + + + /** + * Check that setting options for multi-font works as expected + * + * - using multi-font 'bold' for test, the rest should work analogously + * - using multi-font option 'color' for test, the rest should work analogously + * - edges have no groups + */ + it('respects the font option precedence', function (done) { + var [network, data, options] = createEdgeNetwork(); + var h = new HelperEdge(network); + + assert.equal(h.modBold(1).color, '#343434'); // Default value + assert.equal(h.modBold(2).color, 'green'); // Local value overrides default + assert.equal(h.modBold(3).color, 'green'); // Local value overrides group + + done(); + }); + + + it('handles dynamic data and option updates', function (done) { + var [network, data, options] = createEdgeNetwork(); + var h = new HelperEdge(network); + + data.edges.update([ + {id: 3, font: { bold: { color: 'orange'}}}, + ]); + + assert.equal(h.modBold(1).color, '#343434'); // unchanged + assert.equal(h.modBold(2).color, 'green'); // unchanged + assert.equal(h.modBold(3).color, 'orange'); // new local value + + + network.setOptions({ + edges: { + font: { + multi: true, + bold: { + color: 'black' + } + } + }, + }); + + assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value + assert.equal(h.modBold(2).color, 'green'); // unchanged + assert.equal(h.modBold(3).color, 'orange'); // unchanged + + done(); + }); + + + it('handles font values in default options', function (done) { + var newOptions = { + edges: { + font: { + color: 'purple' // Override the default value + } + }, + }; + var [network, data, options] = createEdgeNetwork(newOptions); + var h = new HelperEdge(network); + + assert.equal(h.modBold(1).color, 'purple'); // Nodes value + assert.equal(h.modBold(2).color, 'green'); // Local value overrides all + assert.equal(h.modBold(3).color, 'green'); // Idem + + done(); + }); + +}); // Edge Labels + + +describe('Shorthand Font Options', function() { + + var testFonts = { + 'default': {color: '#343434', face: 'arial' , size: 14}, + 'monodef': {color: '#343434', face: 'monospace', size: 15}, + 'font1' : {color: '#010101', face: 'Font1' , size: 1}, + 'font2' : {color: '#020202', face: 'Font2' , size: 2}, + 'font3' : {color: '#030303', face: 'Font3' , size: 3}, + 'font4' : {color: '#040404', face: 'Font4' , size: 4}, + 'font5' : {color: '#050505', face: 'Font5' , size: 5}, + 'font6' : {color: '#060606', face: 'Font6' , size: 6}, + 'font7' : {color: '#070707', face: 'Font7' , size: 7}, + }; + + + function checkFont(opt, expectedLabel) { + var expected = testFonts[expectedLabel]; + + util.forEach(expected, (item, key) => { + assert.equal(opt[key], item); + }); + }; + + + function createNetwork() { + var dataNodes = [ + {id: 1, label: '1'}, + {id: 2, label: '2', group: 'group1'}, + {id: 3, label: '3', group: 'group2'}, + {id: 4, label: '4', font: '5px Font5 #050505'}, + ]; + + var dataEdges = []; + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: new vis.DataSet(dataNodes), + edges: new vis.DataSet(dataEdges), + }; + + var options = { + nodes: { + font: { + multi: true, + bold: '1 Font1 #010101', + ital: '2 Font2 #020202', + } + }, + groups: { + group1: { + font: '3 Font3 #030303' + }, + group2: { + font: { + bold: '4 Font4 #040404' + } + } + } + }; + + var network = new vis.Network(container, data, options); + return [network, data]; + } + + + it('handles shorthand options correctly', function (done) { + var [network, data] = createNetwork(); + var h = new HelperNode(network); + + // NOTE: 'mono' has its own global default font and size, which will + // trump any other font values set. + + var opt = h.fontOption(1); + checkFont(opt, 'default'); + checkFont(opt.bold, 'font1'); + checkFont(opt.ital, 'font2'); + checkFont(opt.mono, 'monodef'); // Mono should have defaults + + // Node 2 should be using group1 options + opt = h.fontOption(2); + checkFont(opt, 'font3'); + checkFont(opt.bold, 'font1'); // bold retains nodes default options + checkFont(opt.ital, 'font2'); // ital retains nodes default options + assert.equal(opt.mono.color, '#030303'); // New color + assert.equal(opt.mono.face, 'monospace'); // own global default font + assert.equal(opt.mono.size, 15); // Own global default size + + // Node 3 should be using group2 options + opt = h.fontOption(3); + checkFont(opt, 'default'); + checkFont(opt.bold, 'font4'); + checkFont(opt.ital, 'font2'); + checkFont(opt.mono, 'monodef'); // Mono should have defaults + + // Node 4 has its own base font definition + opt = h.fontOption(4); + checkFont(opt, 'font5'); + checkFont(opt.bold, 'font1'); + checkFont(opt.ital, 'font2'); + assert.equal(opt.mono.color, '#050505'); // New color + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + done(); + }); + + + function dynamicAdd1(network, data) { + // Add new shorthand at every level + data.nodes.update([ + {id: 1, font: '5 Font5 #050505'}, + {id: 4, font: { bold: '6 Font6 #060606'} }, // kills node instance base font + ]); + + network.setOptions({ + nodes: { + font: { + multi: true, + ital: '4 Font4 #040404', + } + }, + groups: { + group1: { + font: { + bold: '7 Font7 #070707' // Kills node instance base font + } + }, + group2: { + font: '6 Font6 #060606' // Note: 'bold' removed by this + } + } + }); + } + + + function dynamicAdd2(network, data) { + network.setOptions({ + nodes: { + font: '7 Font7 #070707' // Note: this kills the font.multi, bold and ital settings! + } + }); + } + + + it('deals with dynamic data and option updates for shorthand', function (done) { + var [network, data] = createNetwork(); + var h = new HelperNode(network); + dynamicAdd1(network, data); + + var opt = h.fontOption(1); + checkFont(opt, 'font5'); // New base font + checkFont(opt.bold, 'font1'); + checkFont(opt.ital, 'font4'); // New global node default + assert.equal(opt.mono.color, '#050505'); // New color + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + opt = h.fontOption(2); + checkFont(opt, 'default'); + checkFont(opt.bold, 'font7'); + checkFont(opt.ital, 'font4'); // New global node default + checkFont(opt.mono, 'monodef'); // Mono should have defaults again + + opt = h.fontOption(3); + checkFont(opt, 'font6'); // New base font + checkFont(opt.bold, 'font1'); // group bold option removed, using global default node + checkFont(opt.ital, 'font4'); // New global node default + assert.equal(opt.mono.color, '#060606'); // New color + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + opt = h.fontOption(4); + checkFont(opt, 'default'); + checkFont(opt.bold, 'font6'); + checkFont(opt.ital, 'font4'); + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + done(); + }); + + + it('deals with dynamic change of global node default', function (done) { + var [network, data] = createNetwork(); + var h = new HelperNode(network); + dynamicAdd1(network, data); // Accumulate data of dynamic add + dynamicAdd2(network, data); + + var opt = h.fontOption(1); + checkFont(opt, 'font5'); // Node instance value + checkFont(opt.bold, 'font5'); // bold def removed from global default node + checkFont(opt.ital, 'font5'); // idem + assert.equal(opt.mono.color, '#050505'); // New color + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + opt = h.fontOption(2); + checkFont(opt, 'font7'); // global node default applies for all settings + checkFont(opt.bold, 'font7'); + checkFont(opt.ital, 'font7'); + assert.equal(opt.mono.color, '#070707'); + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + opt = h.fontOption(3); + checkFont(opt, 'font6'); // Group base font + checkFont(opt.bold, 'font6'); // idem + checkFont(opt.ital, 'font6'); // idem + assert.equal(opt.mono.color, '#060606'); // idem + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + opt = h.fontOption(4); + checkFont(opt, 'font7'); // global node default + checkFont(opt.bold, 'font6'); // node instance bold + checkFont(opt.ital, 'font7'); // global node default + assert.equal(opt.mono.color, '#070707'); // idem + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + + done(); + }); + + + it('deals with dynamic delete of shorthand options', function (done) { + var [network, data] = createNetwork(); + var h = new HelperNode(network); + dynamicAdd1(network, data); // Accumulate data of previous dynamic steps + dynamicAdd2(network, data); // idem + + data.nodes.update([ + {id: 1, font: null}, + {id: 4, font: { bold: null}}, + ]); + + var opt; + +/* + // Interesting: following flagged as error in options parsing, avoiding it for that reason + network.setOptions({ + nodes: { + font: { + multi: true, + ital: null, + } + }, + }); +*/ + + network.setOptions({ + groups: { + group1: { + font: { + bold: null + } + }, + group2: { + font: null + } + } + }); + + // global defaults for all + for (let n = 1; n <= 4; ++ n) { + opt = h.fontOption(n); + checkFont(opt, 'font7'); + checkFont(opt.bold, 'font7'); + checkFont(opt.ital, 'font7'); + assert.equal(opt.mono.color, '#070707'); + assert.equal(opt.mono.face, 'monospace'); + assert.equal(opt.mono.size, 15); + } + +/* + // Not testing following because it is an error in options parsing + network.setOptions({ + nodes: { + font: null + }, + }); +*/ + + done(); + }); + +}); // Shorthand Font Options + + + it('sets and uses font.multi in group options', function (done) { + + /** + * Helper function for easily accessing font options in a node + */ + var fontOption = (index) => { + var nodes = network.body.nodes; + return nodes[index].labelModule.fontOptions; + }; + + + /** + * Helper function for easily accessing bold options in a node + */ + var modBold = (index) => { + return fontOption(index).bold; + }; + + + var dataNodes = [ + {id: 1, label: '1', group: 'group1'}, + { + // From example 1 in #3408 + id: 6, + label: '\uf286 \uf2cd colored glyph icon', + shape: 'icon', + group: 'colored', + icon : { color: 'blue' }, + font: + { + bold : { color : 'blue' }, + ital : { color : 'green' } + } + }, + ]; + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: new vis.DataSet(dataNodes), + edges: [] + }; + + var options = { + groups: { + group1: { + font: { + multi: true, + color: 'red' + }, + }, + colored : + { + // From example 1 in 3408 + icon : + { + face : 'FontAwesome', + code : '\uf2b5', + }, + font: + { + face : 'FontAwesome', + multi: true, + bold : { mod : '' }, + ital : { mod : '' } + } + }, + }, + }; + + var network = new vis.Network(container, data, options); + + assert.equal(modBold(1).color, 'red'); // Group value + assert(fontOption(1).multi); // Group value + assert.equal(modBold(6).color, 'blue'); // node instance value + assert(fontOption(6).multi); // Group value + + + network.setOptions({ + groups: { + group1: { + //font: { color: 'brown' }, // Can not just change one field, entire font object is reset + font: { + multi: true, + color: 'brown' + }, + }, + }, + }); + + assert.equal(modBold(1).color, 'brown'); // New value + assert(fontOption(1).multi); // Group value + assert.equal(modBold(6).color, 'blue'); // unchanged + assert(fontOption(6).multi); // unchanged + + + network.setOptions({ + groups: { + group1: { + font: null, // Remove font from group + }, + }, + }); + + // console.log("==============="); + // console.log(fontOption(1)); + + assert.equal(modBold(1).color, '#343434'); // Reverts to default + assert(!fontOption(1).multi); // idem + assert.equal(modBold(6).color, 'blue'); // unchanged + assert(fontOption(6).multi); // unchanged + + done(); + }); + + + it('compresses spaces for Multi-Font', function (done) { + var options = getOptions(options); + + var text = [ + "Too many spaces here!", + "one two three four five six .", + "This thing:\n - could be\n - a kind\n - of list", // multifont: 2 spaces at start line reduced to 1 + ]; + + + // + // multifont disabled: spaces are preserved + // + var label = new Label({}, options); + + var expected = [{ + lines: [{ + blocks: [{text: "Too many spaces here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected); + + + // + // multifont disabled width maxwidth: spaces are preserved + // + options.font.maxWdt = 300; + var label = new Label({}, options); + + var expected_maxwidth = [{ + lines: [{ + blocks: [{text: "Too many spaces"}], + }, { + blocks: [{text: " here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three "}], + }, { + blocks: [{text: "four five six"}], + }, { + blocks: [{text: " ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_maxwidth); + + + // + // multifont enabled: spaces are compressed + // + options = getOptions(options); + options.font.multi = true; + var label = new Label({}, options); + + var expected_multifont = [{ + lines: [{ + blocks: [{text: "Too many spaces here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_multifont); + + + // + // multifont enabled with max width: spaces are compressed + // + options.font.maxWdt = 300; + var label = new Label({}, options); + + var expected_multifont_maxwidth = [{ + lines: [{ + blocks: [{text: "Too many spaces"}], + }, { + blocks: [{text: "here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four"}], + }, { + blocks: [{text: "five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_multifont_maxwidth); + + done(); + }); +}); // Multi-Fonts + + + it('parses single huge word on line with preceding whitespace when max width set', function (done) { + var options = getOptions(options); + options.font.maxWdt = 300; + assert.equal(options.font.multi, false); + + /** + * Split a string at the given location, return either first or last part + * + * Allows negative indexing, counting from back (ruby style) + */ + let splitAt = (text, pos, getFirst) => { + if (pos < 0) pos = text.length + pos; + + if (getFirst) { + return text.substring(0, pos); + } else { + return text.substring(pos); + } + }; + + var label = new Label({}, options); + var longWord = "asd;lkfja;lfkdj;alkjfd;alskfj"; + + var text = [ + "Mind the space!\n " + longWord, + "Mind the empty line!\n\n" + longWord, + "Mind the dos empty line!\r\n\r\n" + longWord + ]; + + var expected = [{ + lines: [{ + blocks: [{text: "Mind the space!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: splitAt(longWord, -3, true)}] + }, { + blocks: [{text: splitAt(longWord, -3, false)}] + }] + }, { + lines: [{ + blocks: [{text: "Mind the empty"}] + }, { + blocks: [{text: "line!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: splitAt(longWord, -3, true)}] + }, { + blocks: [{text: splitAt(longWord, -3, false)}] + }] + }, { + lines: [{ + blocks: [{text: "Mind the dos empty"}] + }, { + blocks: [{text: "line!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: splitAt(longWord, -3, true)}] + }, { + blocks: [{text: splitAt(longWord, -3, false)}] + }] + }]; + + checkProcessedLabels(label, text, expected); + + + // + // Multi font enabled. For current case, output should be identical to no multi font + // + options.font.multi = true; + var label = new Label({}, options); + checkProcessedLabels(label, text, expected); + + done(); + }); + + + /** + * + * The test network is derived from example `network/nodeStyles/widthHeight.html`, + * where the associated issue (i.e. widthConstraint values not copied) was most poignant. + * + * NOTE: boolean shorthand values for widthConstraint and heightConstraint do nothing. + */ + it('Sets the width/height constraints in the font label options', function (done) { + var nodes = [ + { id: 100, label: 'node 100'}, + { id: 210, group: 'group1', label: 'node 210'}, + { id: 211, widthConstraint: { minimum: 120 }, label: 'node 211'}, + { id: 212, widthConstraint: { minimum: 120, maximum: 140 }, group: 'group1', label: 'node 212'}, // group override + { id: 220, widthConstraint: { maximum: 170 }, label: 'node 220'}, + { id: 200, font: { multi: true }, widthConstraint: 150, label: 'node 200'}, + { id: 201, widthConstraint: 150, label: 'node 201'}, + { id: 202, group: 'group2', label: 'node 202'}, + { id: 203, heightConstraint: { minimum: 75, valign: 'bottom'}, group: 'group2', label: 'node 203'}, // group override + { id: 204, heightConstraint: 80, group: 'group2', label: 'node 204'}, // group override + { id: 300, heightConstraint: { minimum: 70 }, label: 'node 300'}, + { id: 400, heightConstraint: { minimum: 100, valign: 'top' }, label: 'node 400'}, + { id: 401, heightConstraint: { minimum: 100, valign: 'middle' }, label: 'node 401'}, + { id: 402, heightConstraint: { minimum: 100, valign: 'bottom' }, label: 'node 402'} + ]; + + var edges = [ + { id: 1, from: 100, to: 210, label: "edge 1"}, + { id: 2, widthConstraint: 80, from: 210, to: 211, label: "edge 2"}, + { id: 3, heightConstraint: 90, from: 100, to: 220, label: "edge 3"}, + { id: 4, from: 401, to: 402, widthConstraint: { maximum: 150 }, label: "edge 12"}, + ]; + + var container = document.getElementById('mynetwork'); + var data = { + nodes: nodes, + edges: edges + }; + var options = { + edges: { + font: { + size: 12 + }, + widthConstraint: { + maximum: 90 + } + }, + nodes: { + shape: 'box', + margin: 10, + widthConstraint: { + maximum: 200 + } + }, + groups: { + group1: { + shape: 'dot', + widthConstraint: { + maximum: 130 + } + }, + // Following group serves to test all font options + group2: { + shape: 'dot', + widthConstraint: { + minimum: 150, + maximum: 180, + }, + heightConstraint: { + minimum: 210, + valign: 'top', + } + }, + }, + physics: { + enabled: false + } + }; + var network = new vis.Network(container, data, options); + + var nodes_expected = [ + { nodeId: 100, minWdt: -1, maxWdt: 200, minHgt: -1, valign: 'middle'}, + { nodeId: 210, minWdt: -1, maxWdt: 130, minHgt: -1, valign: 'middle'}, + { nodeId: 211, minWdt: 120, maxWdt: 200, minHgt: -1, valign: 'middle'}, + { nodeId: 212, minWdt: 120, maxWdt: 140, minHgt: -1, valign: 'middle'}, + { nodeId: 220, minWdt: -1, maxWdt: 170, minHgt: -1, valign: 'middle'}, + { nodeId: 200, minWdt: 150, maxWdt: 150, minHgt: -1, valign: 'middle'}, + { nodeId: 201, minWdt: 150, maxWdt: 150, minHgt: -1, valign: 'middle'}, + { nodeId: 202, minWdt: 150, maxWdt: 180, minHgt: 210, valign: 'top'}, + { nodeId: 203, minWdt: 150, maxWdt: 180, minHgt: 75, valign: 'bottom'}, + { nodeId: 204, minWdt: 150, maxWdt: 180, minHgt: 80, valign: 'middle'}, + { nodeId: 300, minWdt: -1, maxWdt: 200, minHgt: 70, valign: 'middle'}, + { nodeId: 400, minWdt: -1, maxWdt: 200, minHgt: 100, valign: 'top'}, + { nodeId: 401, minWdt: -1, maxWdt: 200, minHgt: 100, valign: 'middle'}, + { nodeId: 402, minWdt: -1, maxWdt: 200, minHgt: 100, valign: 'bottom'}, + ]; + + + // For edge labels, only maxWdt is set. We check the rest anyway, be it for + // checking incorrect settings or for future code changes. + // + // There is a lot of repetitiveness here. Perhaps using a direct copy of the + // example should be let go. + var edges_expected = [ + { id: 1, minWdt: -1, maxWdt: 90, minHgt: -1, valign: 'middle'}, + { id: 2, minWdt: 80, maxWdt: 80, minHgt: -1, valign: 'middle'}, + { id: 3, minWdt: -1, maxWdt: 90, minHgt: 90, valign: 'middle'}, + { id: 4, minWdt: -1, maxWdt: 150, minHgt: -1, valign: 'middle'}, + ]; + + + let assertConstraints = (expected, fontOptions, label) => { + assert.equal(expected.minWdt, fontOptions.minWdt, 'Incorrect min width' + label); + assert.equal(expected.maxWdt, fontOptions.maxWdt, 'Incorrect max width' + label); + assert.equal(expected.minHgt, fontOptions.minHgt, 'Incorrect min height' + label); + assert.equal(expected.valign, fontOptions.valign, 'Incorrect valign' + label); + } + + + // Check nodes + util.forEach(nodes_expected, function(expected) { + let networkNode = network.body.nodes[expected.nodeId]; + assert(networkNode !== undefined && networkNode !== null, 'node not found for id: ' + expected.nodeId); + let fontOptions = networkNode.labelModule.fontOptions; + + var label = ' for node id: ' + expected.nodeId; + assertConstraints(expected, fontOptions, label); + }); + + + // Check edges + util.forEach(edges_expected, function(expected) { + let networkEdge = network.body.edges[expected.id]; + + var label = ' for edge id: ' + expected.id; + assert(networkEdge !== undefined, 'Edge not found' + label); + + let fontOptions = networkEdge.labelModule.fontOptions; + assertConstraints(expected, fontOptions, label); + }); + + done(); + }); + + + it('deals with null labels and other awkward values', function (done) { + var ctx = new DummyContext(); + var options = getOptions({}); + + var checkHandling = (label, index, text) => { + assert.doesNotThrow(() => {label.getTextSize(ctx, false, false)}, "Unexpected throw for " + text + " " + index); + //label.getTextSize(ctx, false, false); // Use this to determine the error thrown + + // There should not be a label for any of the cases + // + let labelVal = label.elementOptions.label; + let validLabel = (typeof labelVal === 'string' && labelVal !== ''); + assert(!validLabel, "Unexpected label value '" + labelVal+ "' for " + text +" " + index); + }; + + var nodes = [ + {id: 1}, + {id: 2, label: null}, + {id: 3, label: undefined}, + {id: 4, label: {a: 42}}, + {id: 5, label: [ 'an', 'array']}, + {id: 6, label: true}, + {id: 7, label: 3.419}, + ]; + + var edges = [ + {from: 1, to: 2, label: null}, + {from: 1, to: 3, label: undefined}, + {from: 1, to: 4, label: {a: 42}}, + {from: 1, to: 5, label: ['an', 'array']}, + {from: 1, to: 6, label: false}, + {from: 1, to: 7, label: 2.71828}, + ]; + + // Isolate the specific call where a problem with null-label was detected + // Following loops should plain not throw + + + // Node labels + for (let i = 0; i < nodes.length; ++i) { + let label = new Label(null, nodes[i], false); + checkHandling(label, i, 'node'); + } + + + // Edge labels + for (let i = 0; i < edges.length; ++i) { + let label = new Label(null, edges[i], true); + checkHandling(label, i, 'edge'); + } + + + // + // Following extracted from example 'nodeLegend', where the problem was detected. + // + // In the example, only `label:null` was present. The weird thing is that it fails + // in the example, but succeeds in the unit tests. + // Kept in for regression testing. + var container = document.getElementById('mynetwork'); + var data = { + nodes: new vis.DataSet(nodes), + edges: new vis.DataSet(edges) + }; + + var options = {}; + var network = new vis.Network(container, data, options); + + done(); + }); +}); diff --git a/test/Network.test.js b/test/Network.test.js new file mode 100644 index 000000000..4f6163d74 --- /dev/null +++ b/test/Network.test.js @@ -0,0 +1,1313 @@ +/** + * + * Useful during debugging + * ======================= + * + * console.log(JSON.stringify(output, null, 2)); + * + * for (let i in network.body.edges) { + * let edge = network.body.edges[i]; + * console.log("" + i + ": from: " + edge.fromId + ", to: " + edge.toId); + * } + */ +var fs = require('fs'); +var assert = require('assert'); +var vis = require('../dist/vis'); +var Network = vis.network; +var jsdom_global = require('jsdom-global'); +var stdout = require('test-console').stdout; +var Validator = require("./../lib/shared/Validator").default; +var {allOptions, configureOptions} = require('./../lib/network/options.js'); +//var {printStyle} = require('./../lib/shared/Validator'); + + +/** + * Merge all options of object b into object b + * @param {Object} a + * @param {Object} b + * @return {Object} a + * + * Adapted merge() in dotparser.js + */ +function merge (a, b) { + if (!a) { + a = {}; + } + + if (b) { + for (var name in b) { + if (b.hasOwnProperty(name)) { + if (typeof b[name] === 'object') { + a[name] = merge(a[name], b[name]); + } else { + a[name] = b[name]; + } + } + } + } + return a; +} + + +/** + * Load legacy-style (i.e. not module) javascript files into the given context. + */ +function include(list, context) { + if (!(list instanceof Array)) { + list = [list]; + } + + for (var n in list) { + var path = list[n]; + var arr = [fs.readFileSync(path) + '']; + eval.apply(context, arr); + } +} + + +/** + * Defined network consists of two sub-networks: + * + * - 1-2-3-4 + * - 11-12-13-14 + * + * For reference, this is the sample network of issue #1218 + */ +function createSampleNetwork(options) { + var NumInitialNodes = 8; + var NumInitialEdges = 6; + + var nodes = new vis.DataSet([ + {id: 1, label: '1'}, + {id: 2, label: '2'}, + {id: 3, label: '3'}, + {id: 4, label: '4'}, + {id: 11, label: '11'}, + {id: 12, label: '12'}, + {id: 13, label: '13'}, + {id: 14, label: '14'}, + ]); + var edges = new vis.DataSet([ + {from: 1, to: 2}, + {from: 2, to: 3}, + {from: 3, to: 4}, + {from: 11, to: 12}, + {from: 12, to: 13}, + {from: 13, to: 14}, + ]); + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: nodes, + edges: edges + }; + + var defaultOptions = { + layout: { + randomSeed: 8 + }, + edges: { + smooth: { + type: 'continuous' // avoid dynamic here, it adds extra hidden nodes + } + } + }; + + options = merge(defaultOptions, options); + + var network = new vis.Network(container, data, options); + + assertNumNodes(network, NumInitialNodes); + assertNumEdges(network, NumInitialEdges); + + return [network, data, NumInitialNodes, NumInitialEdges]; +}; + + +/** + * Create a cluster for the dynamic data change cases. + * + * Works on the network created by createSampleNetwork(). + * + * This is actually a pathological case; there are two separate sub-networks and + * a cluster is made of two nodes, each from one of the sub-networks. + */ +function createCluster(network) { + var clusterOptionsByData = { + joinCondition: function(node) { + if (node.id == 1 || node.id == 11) return true; + return false; + }, + clusterNodeProperties: {id:"c1", label:'c1'} + } + network.cluster(clusterOptionsByData); +} + + +/** + * Display node/edge state, useful during debugging + */ +function log(network) { + console.log(Object.keys(network.body.nodes)); + console.log(network.body.nodeIndices); + console.log(Object.keys(network.body.edges)); + console.log(network.body.edgeIndices); +}; + + +/** + * Note that only the node and edges counts are asserted. + * This might be done more thoroughly by explicitly checking the id's + */ +function assertNumNodes(network, expectedPresent, expectedVisible) { + if (expectedVisible === undefined) expectedVisible = expectedPresent; + + assert.equal(Object.keys(network.body.nodes).length, expectedPresent, "Total number of nodes does not match"); + assert.equal(network.body.nodeIndices.length, expectedVisible, "Number of visible nodes does not match"); +}; + + +/** + * Comment at assertNumNodes() also applies. + */ +function assertNumEdges(network, expectedPresent, expectedVisible) { + if (expectedVisible === undefined) expectedVisible = expectedPresent; + + assert.equal(Object.keys(network.body.edges).length, expectedPresent, "Total number of edges does not match"); + assert.equal(network.body.edgeIndices.length, expectedVisible, "Number of visible edges does not match"); +}; + + +/** + * Check if the font options haven't changed. + * + * This is to guard against future code changes; a lot of the code deals with particular properties of + * the font options. + * If any assertion fails here, all code in Network handling fonts should be checked. + */ +function checkFontProperties(fontItem, checkStrict = true) { + var knownProperties = [ + 'color', + 'size', + 'face', + 'background', + 'strokeWidth', + 'strokeColor', + 'align', + 'multi', + 'vadjust', + 'bold', + 'boldital', + 'ital', + 'mono', + ]; + + // All properties in fontItem should be known + for (var prop in fontItem) { + if (prop === '__type__') continue; // Skip special field in options definition + if (!fontItem.hasOwnProperty(prop)) continue; + assert(knownProperties.indexOf(prop) !== -1, "Unknown font option '" + prop + "'"); + } + + if (!checkStrict) return; + + // All known properties should be present + var keys = Object.keys(fontItem); + for (var n in knownProperties) { + var prop = knownProperties[n]; + assert(keys.indexOf(prop) !== -1, "Missing known font option '" + prop + "'"); + } +} + + + +describe('Network', function () { + + before(function() { + this.jsdom_global = jsdom_global( + "
", + { skipWindowCheck: true} + ); + this.container = document.getElementById('mynetwork'); + }); + + + after(function() { + try { + this.jsdom_global(); + } catch(e) { + if (e.message() === 'window is undefined') { + console.warning("'" + e.message() + "' happened again"); + } else { + throw e; + } + } + }); + + +///////////////////////////////////////////////////// +// Local helper methods for Edge and Node testing +///////////////////////////////////////////////////// + + /** + * Simplify network creation for local tests + */ + function createNetwork(options) { + var [network, data, numNodes, numEdges] = createSampleNetwork(options); + + return network; + } + + + function firstNode(network) { + for (var id in network.body.nodes) { + return network.body.nodes[id]; + } + + return undefined; + } + + function firstEdge(network) { + for (var id in network.body.edges) { + return network.body.edges[id]; + } + + return undefined; + } + + + function checkChooserValues(item, chooser, labelChooser) { + if (chooser === 'function') { + assert.equal(typeof item.chooser, 'function'); + } else { + assert.equal(item.chooser, chooser); + } + + if (labelChooser === 'function') { + assert.equal(typeof item.labelModule.fontOptions.chooser, 'function'); + } else { + assert.equal(item.labelModule.fontOptions.chooser, labelChooser); + } + } + + +///////////////////////////////////////////////////// +// End Local helper methods for Edge and Node testing +///////////////////////////////////////////////////// + + + /** + * Helper function for clustering + */ + function clusterTo(network, clusterId, nodeList, allowSingle) { + var options = { + joinCondition: function(node) { + return nodeList.indexOf(node.id) !== -1; + }, + clusterNodeProperties: { + id: clusterId, + label: clusterId + } + } + + if (allowSingle === true) { + options.clusterNodeProperties.allowSingleNodeCluster = true + } + + network.cluster(options); + } + + + /** + * At time of writing, this test detected 22 out of 33 'illegal' loops. + * The real deterrent is eslint rule 'guard-for-in`. + */ + it('can deal with added fields in Array.prototype', function (done) { + Array.prototype.foo = 1; // Just add anything to the prototype + Object.prototype.bar = 2; // Let's screw up hashes as well + + // The network should just run without throwing errors + try { + var [network, data, numNodes, numEdges] = createSampleNetwork({}); + + // Do some stuff to trigger more errors + clusterTo(network, 'c1', [1,2,3]); + data.nodes.remove(1); + network.openCluster('c1'); + clusterTo(network, 'c1', [4], true); + clusterTo(network, 'c2', ['c1'], true); + clusterTo(network, 'c3', ['c2'], true); + data.nodes.remove(4); + + } catch(e) { + delete Array.prototype.foo; // Remove it again so as not to confuse other tests. + delete Object.prototype.bar; + assert(false, "Got exception:\n" + e.stack); + } + + delete Array.prototype.foo; // Remove it again so as not to confuse other tests. + delete Object.prototype.bar; + done(); + }); + + +describe('Node', function () { + + it('has known font options', function () { + var network = createNetwork({}); + checkFontProperties(network.nodesHandler.defaultOptions.font); + checkFontProperties(allOptions.nodes.font); + checkFontProperties(configureOptions.nodes.font, false); + }); + + + /** + * NOTE: choosify tests of Node and Edge are parallel + * TODO: consolidate this is necessary + */ + it('properly handles choosify input', function () { + // check defaults + var options = {}; + var network = createNetwork(options); + checkChooserValues(firstNode(network), true, true); + + // There's no point in checking invalid values here; these are detected by the options parser + // and subsequently handled as missing input, thus assigned defaults + + // check various combinations of valid input + + options = {nodes: {chosen: false}}; + network = createNetwork(options); + checkChooserValues(firstNode(network), false, false); + + options = {nodes: {chosen: { node:true, label:false}}}; + network = createNetwork(options); + checkChooserValues(firstNode(network), true, false); + + options = {nodes: {chosen: { + node:true, + label: function(value, id, selected, hovering) {} + }}}; + network = createNetwork(options); + checkChooserValues(firstNode(network), true, 'function'); + + options = {nodes: {chosen: { + node: function(value, id, selected, hovering) {}, + label:false, + }}}; + network = createNetwork(options); + checkChooserValues(firstNode(network), 'function', false); + }); +}); // Node + + +describe('Edge', function () { + + it('has known font options', function () { + var network = createNetwork({}); + checkFontProperties(network.edgesHandler.defaultOptions.font); + checkFontProperties(allOptions.edges.font); + checkFontProperties(configureOptions.edges.font, false); + }); + + + /** + * NOTE: choosify tests of Node and Edge are parallel + * TODO: consolidate this is necessary + */ + it('properly handles choosify input', function () { + // check defaults + var options = {}; + var network = createNetwork(options); + checkChooserValues(firstEdge(network), true, true); + + // There's no point in checking invalid values here; these are detected by the options parser + // and subsequently handled as missing input, thus assigned defaults + + // check various combinations of valid input + + options = {edges: {chosen: false}}; + network = createNetwork(options); + checkChooserValues(firstEdge(network), false, false); + + options = {edges: {chosen: { edge:true, label:false}}}; + network = createNetwork(options); + checkChooserValues(firstEdge(network), true, false); + + options = {edges: {chosen: { + edge:true, + label: function(value, id, selected, hovering) {} + }}}; + network = createNetwork(options); + checkChooserValues(firstEdge(network), true, 'function'); + + options = {edges: {chosen: { + edge: function(value, id, selected, hovering) {}, + label:false, + }}}; + network = createNetwork(options); + checkChooserValues(firstEdge(network), 'function', false); + }); + + + /** + * Support routine for next unit test + */ + function createDataforColorChange() { + var nodes = new vis.DataSet([ + {id: 1, label: 'Node 1' }, // group:'Group1'}, + {id: 2, label: 'Node 2', group:'Group2'}, + {id: 3, label: 'Node 3'}, + ]); + + // create an array with edges + var edges = new vis.DataSet([ + {id: 1, from: 1, to: 2}, + {id: 2, from: 1, to: 3, color: { inherit: 'to'}}, + {id: 3, from: 3, to: 3, color: { color: '#00FF00'}}, + {id: 4, from: 2, to: 3, color: { inherit: 'from'}}, + ]); + + + var data = { + nodes: nodes, + edges: edges + }; + + return data; + } + + + /** + * Unit test for fix of #3350 + * + * The issue is that changing color options is not registered in the nodes. + * We test the updates the color options in the general edges options here. + */ + it('sets inherit color option for edges on call to Network.setOptions()', function () { + var container = document.getElementById('mynetwork'); + var data = createDataforColorChange(); + + var options = { + "edges" : { "color" : { "inherit" : "to" } }, + }; + + // Test passing options on init. + var network = new vis.Network(container, data, options); + var edges = network.body.edges; + assert.equal(edges[1].options.color.inherit, 'to'); // new default + assert.equal(edges[2].options.color.inherit, 'to'); // set in edge + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); // set in edge + + // Sanity check: colors should still be defaults + assert.equal(edges[1].options.color.color, network.edgesHandler.options.color.color); + + // Override the color value - inherit returns to default + network.setOptions({ edges:{color: {}}}); + assert.equal(edges[1].options.color.inherit, 'from'); // default + assert.equal(edges[2].options.color.inherit, 'to'); // set in edge + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); // set in edge + + // Check no options + network = new vis.Network(container, data, {}); + edges = network.body.edges; + assert.equal(edges[1].options.color.inherit, 'from'); // default + assert.equal(edges[2].options.color.inherit, 'to'); // set in edge + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); // set in edge + + // Set new value + network.setOptions(options); + assert.equal(edges[1].options.color.inherit, 'to'); + assert.equal(edges[2].options.color.inherit, 'to'); // set in edge + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); // set in edge + +/* + // Useful for debugging + console.log('==================================='); + console.log(edges[1].options.color); + console.log(edges[1].options.color.__proto__); + console.log(edges[1].options); + console.log(edges[1].options.__proto__); + console.log(edges[1].edgeOptions); +*/ + }); + + + it('sets inherit color option for specific edge', function () { + var container = document.getElementById('mynetwork'); + var data = createDataforColorChange(); + + // Check no options + var network = new vis.Network(container, data, {}); + var edges = network.body.edges; + assert.equal(edges[1].options.color.inherit, 'from'); // default + assert.equal(edges[2].options.color.inherit, 'to'); // set in edge + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); // set in edge + + // Set new value + data.edges.update({id: 1, color: { inherit: 'to'}}); + assert.equal(edges[1].options.color.inherit, 'to'); // Only this changed + assert.equal(edges[2].options.color.inherit, 'to'); + assert.equal(edges[3].options.color.inherit, false); // has explicit color + assert.equal(edges[4].options.color.inherit, 'from'); + }); + + + /** + * Perhaps TODO: add unit test for passing string value for color option + */ + it('sets color value for edges on call to Network.setOptions()', function () { + var container = document.getElementById('mynetwork'); + var data = createDataforColorChange(); + + var defaultColor = '#848484'; // From defaults + var color = '#FF0000'; + + var options = { + "edges" : { "color" : { "color" : color } }, + }; + + // Test passing options on init. + var network = new vis.Network(container, data, options); + var edges = network.body.edges; + assert.equal(edges[1].options.color.color, color); + assert.equal(edges[1].options.color.inherit, false); // Explicit color, so no inherit + assert.equal(edges[2].options.color.color, color); + assert.equal(edges[2].options.color.inherit, 'to'); // Local value overrides! (bug according to docs) + assert.notEqual(edges[3].options.color.color, color); // Has own value + assert.equal(edges[3].options.color.inherit, false); // Explicit color, so no inherit + assert.equal(edges[4].options.color.color, color); + + // Override the color value - all should return to default + network.setOptions({ edges:{color: {}}}); + assert.equal(edges[1].options.color.color, defaultColor); + assert.equal(edges[1].options.color.inherit, 'from'); + assert.equal(edges[2].options.color.color, defaultColor); + assert.notEqual(edges[3].options.color.color, color); // Has own value + assert.equal(edges[4].options.color.color, defaultColor); + + // Check no options + network = new vis.Network(container, data, {}); + edges = network.body.edges; + // At this point, color has not changed yet + assert.equal(edges[1].options.color.color, defaultColor); + assert.equal(edges[1].options.color.highlight, defaultColor); + assert.equal(edges[1].options.color.inherit, 'from'); + assert.notEqual(edges[3].options.color.color, color); // Has own value + + // Set new Value + network.setOptions(options); + assert.equal(edges[1].options.color.color, color); + assert.equal(edges[1].options.color.highlight, defaultColor); // Should not be changed + assert.equal(edges[1].options.color.inherit, false); // Explicit color, so no inherit + assert.equal(edges[2].options.color.color, color); + assert.notEqual(edges[3].options.color.color, color); // Has own value + assert.equal(edges[4].options.color.color, color); + }); + + /** + * Unit test for fix of #3500 + * Checking to make sure edges that become unconnected due to node removal get reconnected + */ + it('has reconnected edges', function () { + var node1 = {id:1, label:"test1"}; + var node2 = {id:2, label:"test2"}; + var nodes = new vis.DataSet([node1, node2]); + + var edge = {id:1, from: 1, to:2}; + var edges = new vis.DataSet([edge]); + + var data = { + nodes: nodes, + edges: edges + }; + + var container = document.getElementById('mynetwork'); + var network = new vis.Network(container, data); + + //remove node causing edge to become disconnected + nodes.remove(node2.id); + + var foundEdge = network.body.edges[edge.id]; + + assert.ok(foundEdge===undefined, "edge is still in state cache"); + + //add node back reconnecting edge + nodes.add(node2); + + foundEdge = network.body.edges[edge.id]; + + assert.ok(foundEdge!==undefined, "edge is missing from state cache"); + }); +}); // Edge + + +describe('Clustering', function () { + + + it('properly handles options allowSingleNodeCluster', function() { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + data.edges.update({from: 1, to: 11,}); + numEdges += 1; + assertNumNodes(network, numNodes); + assertNumEdges(network, numEdges); + + clusterTo(network, 'c1', [3,4]); + numNodes += 1; // A clustering node is now hiding two nodes + numEdges += 1; // One clustering edges now hiding two edges + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 2); + + // Cluster of single node should fail, because by default allowSingleNodeCluster == false + clusterTo(network, 'c2', [14]); + assertNumNodes(network, numNodes, numNodes - 2); // Nothing changed + assertNumEdges(network, numEdges, numEdges - 2); + assert(network.body.nodes['c2'] === undefined); // Cluster not created + + // Redo with allowSingleNodeCluster == true + clusterTo(network, 'c2', [14], true); + numNodes += 1; + numEdges += 1; + assertNumNodes(network, numNodes, numNodes - 3); + assertNumEdges(network, numEdges, numEdges - 3); + assert(network.body.nodes['c2'] !== undefined); // Cluster created + + + // allowSingleNodeCluster: true with two nodes + // removing one clustered node should retain cluster + clusterTo(network, 'c3', [11, 12], true); + numNodes += 1; // Added cluster + numEdges += 2; + assertNumNodes(network, numNodes, 6); + assertNumEdges(network, numEdges, 5); + + data.nodes.remove(12); + assert(network.body.nodes['c3'] !== undefined); // Cluster should still be present + numNodes -= 1; // removed node + numEdges -= 3; // cluster edge C3-13 should be removed + assertNumNodes(network, numNodes, 6); + assertNumEdges(network, numEdges, 4); + }); + + + it('removes nested clusters with allowSingleNodeCluster === true', function() { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + // Create a chain of nested clusters, three deep + clusterTo(network, 'c1', [4], true); + clusterTo(network, 'c2', ['c1'], true); + clusterTo(network, 'c3', ['c2'], true); + numNodes += 3; + numEdges += 3; + assertNumNodes(network, numNodes, numNodes - 3); + assertNumEdges(network, numEdges, numEdges - 3); + assert(network.body.nodes['c1'] !== undefined); + assert(network.body.nodes['c2'] !== undefined); + assert(network.body.nodes['c3'] !== undefined); + + // The whole chain should be removed when the bottom-most node is deleted + data.nodes.remove(4); + numNodes -= 4; + numEdges -= 4; + assertNumNodes(network, numNodes); + assertNumEdges(network, numEdges); + assert(network.body.nodes['c1'] === undefined); + assert(network.body.nodes['c2'] === undefined); + assert(network.body.nodes['c3'] === undefined); + }); + + + /** + * Check on fix for #1218 + */ + it('connects a new edge to a clustering node instead of the clustered node', function () { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + createCluster(network); + numNodes += 1; // A clustering node is now hiding two nodes + numEdges += 2; // Two clustering edges now hide two edges + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 2); + + //console.log("Creating node 21") + data.nodes.update([{id: 21, label: '21'}]); + numNodes += 1; // New unconnected node added + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 2); // edges unchanged + + //console.log("Creating edge 21 pointing to 1"); + // '1' is part of the cluster so should + // connect to cluster instead + data.edges.update([{from: 21, to: 1}]); + numEdges += 2; // A new clustering edge is hiding a new edge + assertNumNodes(network, numNodes, numNodes - 2); // nodes unchanged + assertNumEdges(network, numEdges, numEdges - 3); + }); + + + /** + * Check on fix for #1315 + */ + it('can uncluster a clustered node when a node is removed that has an edge to that cluster', function () { + // NOTE: this block is same as previous test + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + createCluster(network); + numNodes += 1; // A clustering node is now hiding two nodes + numEdges += 2; // Two clustering edges now hide two edges + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 2); + // End block same as previous test + + //console.log("removing 12"); + data.nodes.remove(12); + + // NOTE: + // At this particular point, there are still the two edges for node 12 in the edges DataSet. + // If you want to do the delete correctly, these should also be deleted explictly from + // the edges DataSet. In the Network instance, however, this.body.nodes and this.body.edges + // should be correct, with the edges of 12 all cleared out. + + // 12 was connected to 11, which is clustered + numNodes -= 1; // 12 removed, one less node + numEdges -= 3; // clustering edge c1-12 and 2 edges of 12 gone + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 1); + + //console.log("Unclustering c1"); + network.openCluster("c1"); + numNodes -= 1; // cluster node removed, one less node + numEdges -= 1; // clustering edge gone, regular edge visible + assertNumNodes(network, numNodes, numNodes); // all are visible again + assertNumEdges(network, numEdges, numEdges); // all are visible again + + }); + + + /** + * Check on fix for #1291 + */ + it('can remove a node inside a cluster and then open that cluster', function () { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + var clusterOptionsByData = { + joinCondition: function(node) { + if (node.id == 1 || node.id == 2 || node.id == 3) return true; + return false; + }, + clusterNodeProperties: {id:"c1", label:'c1'} + } + + network.cluster(clusterOptionsByData); + numNodes += 1; // new cluster node + numEdges += 1; // 1 cluster edge expected + assertNumNodes(network, numNodes, numNodes - 3); // 3 clustered nodes + assertNumEdges(network, numEdges, numEdges - 3); // 3 edges hidden + + //console.log("removing node 2, which is inside the cluster"); + data.nodes.remove(2); + numNodes -= 1; // clustered node removed + numEdges -= 2; // edges removed hidden in cluster + assertNumNodes(network, numNodes, numNodes - 2); // view doesn't change + assertNumEdges(network, numEdges, numEdges - 1); // view doesn't change + + //console.log("Unclustering c1"); + network.openCluster("c1") + numNodes -= 1; // cluster node gone + numEdges -= 1; // cluster edge gone + assertNumNodes(network, numNodes, numNodes); // all visible + assertNumEdges(network, numEdges, numEdges); // all visible + + //log(network); + }); + + + /** + * Helper function for setting up a graph for testing clusterByEdgeCount() + */ + function createOutlierGraph() { + // create an array with nodes + var nodes = new vis.DataSet([ + {id: 1, label: '1', group:'Group1'}, + {id: 2, label: '2', group:'Group2'}, + {id: 3, label: '3', group:'Group3'}, + {id: 4, label: '4', group:'Group4'}, + {id: 5, label: '5', group:'Group4'} + ]); + + // create an array with edges + var edges = new vis.DataSet([ + {from: 1, to: 3}, + {from: 1, to: 2}, + {from: 2, to: 4}, + {from: 2, to: 5} + ]); + + // create a network + var container = document.getElementById('mynetwork'); + var data = { + nodes: nodes, + edges: edges + }; + var options = { + "groups" : { + "Group1" : { level:1 }, + "Group2" : { level:2 }, + "Group3" : { level:3 }, + "Group4" : { level:4 } + } + }; + + var network = new vis.Network (container, data, options); + + return network; + } + + + /** + * Check on fix for #3367 + */ + it('correctly handles edge cases of clusterByEdgeCount()', function () { + /** + * Collect clustered id's + * + * All node id's in clustering nodes are collected into an array; + * The results for all clusters are returned as an array. + * + * Ordering of output depends on the order in which they are defined + * within nodes.clustering; strictly, speaking, the array and its items + * are collections, so order should not matter. + */ + var collectClusters = function(network) { + var clusters = []; + for(var n in network.body.nodes) { + var node = network.body.nodes[n]; + if (node.containedNodes === undefined) continue; // clusters only + + // Collect id's of nodes in the cluster + var nodes = []; + for(var m in node.containedNodes) { + nodes.push(m); + } + clusters.push(nodes); + } + + return clusters; + } + + + /** + * Compare cluster data + * + * params are arrays of arrays of id's, e.g: + * + * [[1,3],[2,4]] + * + * Item arrays are the id's of nodes in a given cluster + * + * This comparison depends on the ordering; better + * would be to treat the items and values as collections. + */ + var compareClusterInfo = function(recieved, expected) { + if (recieved.length !== expected.length) return false; + + for (var n = 0; n < recieved.length; ++n) { + var itema = recieved[n]; + var itemb = expected[n]; + if (itema.length !== itemb.length) return false; + + for (var m = 0; m < itema.length; ++m) { + if (itema[m] != itemb[m]) return false; // != because values can be string or number + } + } + + return true; + } + + + var assertJoinCondition = function(joinCondition, expected) { + var network = createOutlierGraph(); + network.clusterOutliers({joinCondition: joinCondition}); + var recieved = collectClusters(network); + //console.log(recieved); + + assert(compareClusterInfo(recieved, expected), + 'recieved:' + JSON.stringify(recieved) + '; ' + + 'expected: ' + JSON.stringify(expected)); + }; + + + // Should cluster 3,4,5: + var joinAll_ = function(n) { return true ; } + + // Should cluster none: + var joinNone_ = function(n) { return false ; } + + // Should cluster 4 & 5: + var joinLevel_ = function(n) { return n.level > 3 ; } + + assertJoinCondition(undefined , [[1,3],[2,4,5]]); + assertJoinCondition(null , [[1,3],[2,4,5]]); + assertJoinCondition(joinNone_ , []); + assertJoinCondition(joinLevel_ , [[2,4,5]]); + }); + + + /////////////////////////////////////////////////////////////// + // Automatic opening of clusters due to dynamic data change + /////////////////////////////////////////////////////////////// + + /** + * Helper function, created nested clusters, three deep + */ + function createNetwork1() { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + clusterTo(network, 'c1', [3,4]); + numNodes += 1; // new cluster node + numEdges += 1; // 1 cluster edge expected + assertNumNodes(network, numNodes, numNodes - 2); // 2 clustered nodes + assertNumEdges(network, numEdges, numEdges - 2); // 2 edges hidden + + clusterTo(network, 'c2', [2,'c1']); + numNodes += 1; // new cluster node + numEdges += 1; // 2 cluster edges expected + assertNumNodes(network, numNodes, numNodes - 4); // 4 clustered nodes, including c1 + assertNumEdges(network, numEdges, numEdges - 4); // 4 edges hidden, including edge for c1 + + clusterTo(network, 'c3', [1,'c2']); + // Attempt at visualization: parentheses belong to the cluster one level above + // c3 + // ( -c2 ) + // ( -c1 ) + // 14-13-12-11 1 -2 (-3-4) + numNodes += 1; // new cluster node + numEdges += 0; // No new cluster edge expected + assertNumNodes(network, numNodes, numNodes - 6); // 6 clustered nodes, including c1 and c2 + assertNumEdges(network, numEdges, numEdges - 5); // 5 edges hidden, including edges for c1 and c2 + + return [network, data, numNodes, numEdges]; + } + + + it('opens clusters automatically when nodes deleted', function () { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + // Simple case: cluster of two nodes, delete one node + clusterTo(network, 'c1', [3,4]); + numNodes += 1; // new cluster node + numEdges += 1; // 1 cluster edge expected + assertNumNodes(network, numNodes, numNodes - 2); // 2 clustered nodes + assertNumEdges(network, numEdges, numEdges - 2); // 2 edges hidden + + data.nodes.remove(4); + numNodes -= 2; // deleting clustered node also removes cluster node + numEdges -= 2; // cluster edge should also be removed + assertNumNodes(network, numNodes, numNodes); + assertNumEdges(network, numEdges, numEdges); + + + // Extended case: nested nodes, three deep + [network, data, numNodes, numEdges] = createNetwork1(); + + data.nodes.remove(4); + // c3 + // ( -c2 ) + // 14-13-12-11 1 (-2 -3) + numNodes -= 2; // node removed, c1 also gone + numEdges -= 2; + assertNumNodes(network, numNodes, numNodes - 4); + assertNumEdges(network, numEdges, numEdges - 3); + + data.nodes.remove(1); + // c2 + // 14-13-12-11 (2 -3) + numNodes -= 2; // node removed, c3 also gone + numEdges -= 2; + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 1); + + data.nodes.remove(2); + // 14-13-12-11 3 + numNodes -= 2; // node removed, c2 also gone + numEdges -= 1; + assertNumNodes(network, numNodes); // All visible again + assertNumEdges(network, numEdges); + + // Same as previous step, but remove all the given nodes in one go + // The result should be the same. + [network, data, numNodes, numEdges] = createNetwork1(); // nested nodes, three deep + data.nodes.remove([1,2,4]); + // 14-13-12-11 3 + assertNumNodes(network, 5); + assertNumEdges(network, 3); + }); + + + /////////////////////////////////////////////////////////////// + // Opening of clusters at various clustering depths + /////////////////////////////////////////////////////////////// + + /** + * Check correct opening of a single cluster. + * This is the 'simple' case. + */ + it('properly opens 1-level clusters', function () { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + + // Pedantic: make a cluster of everything + clusterTo(network, 'c1', [1,2,3,4,11, 12, 13, 14]); + // c1(14-13-12-11 1-2-3-4) + numNodes += 1; + assertNumNodes(network, numNodes, 1); // Just the clustering node visible + assertNumEdges(network, numEdges, 0); // No extra edges! + + network.clustering.openCluster('c1', {}); + numNodes -= 1; + assertNumNodes(network, numNodes, numNodes); // Expecting same as original + assertNumEdges(network, numEdges, numEdges); + + // One external connection + [network, data, numNodes, numEdges] = createSampleNetwork(); + // 14-13-12-11 1-2-3-4 + clusterTo(network, 'c1', [3,4]); + network.clustering.openCluster('c1', {}); + assertNumNodes(network, numNodes, numNodes); // Expecting same as original + assertNumEdges(network, numEdges, numEdges); + + // Two external connections + clusterTo(network, 'c1', [2,3]); + network.clustering.openCluster('c1', {}); + assertNumNodes(network, numNodes, numNodes); // Expecting same as original + assertNumEdges(network, numEdges, numEdges); + + // One external connection to cluster + clusterTo(network, 'c1', [1,2]); + clusterTo(network, 'c2', [3,4]); + // 14-13-12-11 c1(1-2-)-c2(-3-4) + network.clustering.openCluster('c1', {}); + // 14-13-12-11 1-2-c2(-3-4) + numNodes += 1; + numEdges += 1; + assertNumNodes(network, numNodes, numNodes - 2); + assertNumEdges(network, numEdges, numEdges - 2); + + // two external connections to clusters + [network, data, numNodes, numEdges] = createSampleNetwork(); + data.edges.update({ + from: 1, + to: 11, + }); + numEdges += 1; + assertNumNodes(network, numNodes, numNodes); + assertNumEdges(network, numEdges, numEdges); + + clusterTo(network, 'c1', [1,2]); + // 14-13-12-11-c1(-1-2-)-3-4 + numNodes += 1; + numEdges += 2; + clusterTo(network, 'c2', [3,4]); + // 14-13-12-11-c1(-1-2-)-c2(-3-4) + // NOTE: clustering edges are hidden by clustering here! + numNodes += 1; + numEdges += 1; + clusterTo(network, 'c3', [11,12]); + // 14-13-c3(-12-11-)-c1(-1-2-)-c2(-3-4) + numNodes += 1; + numEdges += 2; + assertNumNodes(network, numNodes, numNodes - 6); + assertNumEdges(network, numEdges, numEdges - 8); // 6 regular edges hidden; also 2 clustering!!!!! + + network.clustering.openCluster('c1', {}); + numNodes -= 1; + numEdges -= 2; + // 14-13-c3(-12-11-)-1-2-c2(-3-4) + assertNumNodes(network, numNodes, numNodes - 4); + assertNumEdges(network, numEdges, numEdges - 5); + }); + + + /** + * Check correct opening of nested clusters. + * The test uses clustering three levels deep and opens the middle one. + */ + it('properly opens clustered clusters', function () { + var [network, data, numNodes, numEdges] = createSampleNetwork(); + data.edges.update({from: 1, to: 11,}); + numEdges += 1; + clusterTo(network, 'c1', [3,4]); + clusterTo(network, 'c2', [2,'c1']); + clusterTo(network, 'c3', [1,'c2']); + // Attempt at visualization: parentheses belong to the cluster one level above + // -c3 + // ( -c2 ) + // ( -c1 ) + // 14-13-12-11 -1 -2 (-3-4) + numNodes += 3; + numEdges += 3; + //console.log("numNodes: " + numNodes + "; numEdges: " + numEdges); + assertNumNodes(network, numNodes, numNodes - 6); + assertNumEdges(network, numEdges, numEdges - 6); + + // Open the middle cluster + network.clustering.openCluster('c2', {}); + // -c3 + // ( -c1 ) + // 14-13-12-11 -1 -2 (-3-4) + numNodes -= 1; + numEdges -= 1; + assertNumNodes(network, numNodes, numNodes - 5); + assertNumEdges(network, numEdges, numEdges - 5); + + // + // Same, with one external connection to cluster + // + var [network, data, numNodes, numEdges] = createSampleNetwork(); + data.edges.update({from: 1, to: 11,}); + data.edges.update({from: 2, to: 12,}); + numEdges += 2; + // 14-13-12-11-1-2-3-4 + // |------| + assertNumNodes(network, numNodes); + assertNumEdges(network, numEdges); + + + clusterTo(network, 'c0', [11,12]); + clusterTo(network, 'c1', [3,4]); + clusterTo(network, 'c2', [2,'c1']); + clusterTo(network, 'c3', [1,'c2']); + // +----------------+ + // | c3 | + // | +----------+ | + // | | c2 | | + // +-------+ | | +----+ | | + // | c0 | | | | c1 | | | + // 14-13-|-12-11-|-|-1-|-2-|-3-4| | | + // | | | | | | +----+ | | + // +-------+ | | | | | + // | | +----------+ | + // | | | | + // | +----------------+ + // |------------| + // (I) + numNodes += 4; + numEdges = 15; + assertNumNodes(network, numNodes, 4); + assertNumEdges(network, numEdges, 3); // (I) link 2-12 is combined into cluster edge for 11-1 + + // Open the middle cluster + network.clustering.openCluster('c2', {}); + // +--------------+ + // | c3 | + // | | + // +-------+ | +----+ | + // | c0 | | | c1 | | + // 14-13-|-12-11-|-|-1--2-|-3-4| | + // | | | | | +----+ | + // +-------+ | | | + // | | | | + // | +--------------+ + // |-----------| + // (I) + numNodes -= 1; + numEdges -= 2; + assertNumNodes(network, numNodes, 4); // visibility doesn't change, cluster opened within cluster + assertNumEdges(network, numEdges, 3); // (I) + + // Open the top cluster + network.clustering.openCluster('c3', {}); + // + // +-------+ +----+ + // | c0 | | c1 | + // 14-13-|-12-11-|-1-2-|-3-4| + // | | | | +----+ + // +-------+ | + // | | + // |--------| + // (II) + numNodes -= 1; + numEdges = 12; + assertNumNodes(network, numNodes, 6); // visibility doesn't change, cluster opened within cluster + assertNumEdges(network, numEdges, 6); // (II) link 2-12 visible again + }); +}); // Clustering + + +describe('on node.js', function () { + + it('should be running', function () { + assert(this.container !== null, 'Container div not found'); + + // The following should now just plain succeed + var [network, data] = createSampleNetwork(); + + assert.equal(Object.keys(network.body.nodes).length, 8); + assert.equal(Object.keys(network.body.edges).length, 6); + }); + + +describe('runs example ', function () { + + function loadExample(path, noPhysics) { + include(path, this); + var container = document.getElementById('mynetwork'); + + // create a network + var data = { + nodes: new vis.DataSet(nodes), + edges: new vis.DataSet(edges) + }; + + if (noPhysics) { + // Avoid excessive processor time due to load. + // We're just interested that the load itself is good + options.physics = false; + } + + var network = new vis.Network(container, data, options); + return network; + }; + + + it('basicUsage', function () { + var network = loadExample('./test/network/basicUsage.js'); + //console.log(Object.keys(network.body.edges)); + + // Count in following also contains the helper nodes for dynamic edges + assert.equal(Object.keys(network.body.nodes).length, 10); + assert.equal(Object.keys(network.body.edges).length, 5); + }); + + + it('WorlCup2014', function (done) { + // This is a huge example (which is why it's tested here!), so it takes a long time to load. + this.timeout(15000); + + var network = loadExample('./examples/network/datasources/WorldCup2014.js', true); + + // Count in following also contains the helper nodes for dynamic edges + assert.equal(Object.keys(network.body.nodes).length, 9964); + assert.equal(Object.keys(network.body.edges).length, 9228); + done(); + }); + + + // This actually failed to load, added for this reason + it('disassemblerExample', function () { + var network = loadExample('./examples/network/exampleApplications/disassemblerExample.js'); + // console.log(Object.keys(network.body.nodes)); + // console.log(Object.keys(network.body.edges)); + + // Count in following also contains the helper nodes for dynamic edges + assert.equal(Object.keys(network.body.nodes).length, 9); + assert.equal(Object.keys(network.body.edges).length, 14 - 3); // NB 3 edges in data not displayed + }); + +}); // runs example +}); // on node.js +}); // Network diff --git a/test/PointItem.test.js b/test/PointItem.test.js new file mode 100644 index 000000000..463c3f9a5 --- /dev/null +++ b/test/PointItem.test.js @@ -0,0 +1,283 @@ +var assert = require('assert'); +var vis = require('../dist/vis'); +var jsdom = require('mocha-jsdom'); +var moment = vis.moment; +var timeline = vis.timeline; +var PointItem = require("../lib/timeline/component/item/PointItem"); +var Range = timeline.Range; +var TestSupport = require('./TestSupport'); + +describe('Timeline PointItem', function () { + + jsdom(); + var now = moment(); + + it('should initialize with minimal data', function() { + var pointItem = new PointItem({start: now.toDate()}, null, null); + assert.equal(pointItem.props.content.height, 0); + assert.deepEqual(pointItem.data.start, now.toDate()); + }); + + it('should have a default width of 0', function() { + var pointItem = new PointItem({start: now}, null, null); + assert.equal(pointItem.getWidthRight(), 0); + assert.equal(pointItem.getWidthLeft(), 0); + }); + + it('should error if there is missing data', function () { + assert.throws(function () { new PointItem({}, null, null)}, Error); + }); + + it('should be visible if the range is during', function() { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + range.start = now.clone().add(-1, 'second'); + range.end = range.start.clone().add(1, 'hour'); + var pointItem = new PointItem({start: now.toDate()}, null, null); + assert(pointItem.isVisible(range)); + }); + + it('should not be visible if the range is after', function() { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + range.start = now.clone().add(1, 'second'); + range.end = range.start.clone().add(1, 'hour'); + var pointItem = new PointItem({start: now.toDate()}, null, null); + assert(!pointItem.isVisible(range)); + }); + + it('should not be visible if the range is before', function() { + var now = moment(); + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + range.end = now.clone().add(-1, 'second'); + range.start = range.end.clone().add(-1, 'hour'); + var pointItem = new PointItem({start: now.toDate()}, null, null); + assert(!pointItem.isVisible(range)); + }); + + it('should be visible for a "now" point with a default range', function() { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + var pointItem = new PointItem({start: now.toDate()}, null, null); + assert(pointItem.isVisible(range)); + }); + + +describe('should redraw() and then', function () { + + it('not be dirty', function() { + var pointItem = new PointItem({start: now.toDate()}, null, {editable: false}); + pointItem.setParent(TestSupport.buildMockItemSet()); + assert(pointItem.dirty); + pointItem.redraw(); + assert(!pointItem.dirty); + }); + + + it('have point attached to its parent', function() { + var pointItem = new PointItem({start: now.toDate()}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + assert(!parent.dom.foreground.hasChildNodes()); + pointItem.redraw(); + assert(parent.dom.foreground.hasChildNodes()); + }); + + +describe('have the correct classname for', function() { + + it('a non-editable item', function() { + var pointItem = new PointItem({start: now.toDate(), editable: false}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-readonly"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-readonly"); + }); + + it('an editable item (with object option)', function() { + var pointItem = new PointItem({start: now.toDate()}, null, {editable: {updateTime: true, updateGroup: false}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-editable"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-editable"); + }); + + it('an editable item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate()}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-editable"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-editable"); + }); + + it('an editable:false override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: false}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-readonly"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-readonly"); + }); + + it('an editable:true override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: true}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-editable"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-editable"); + }); + + it('an editable:false override item (with object option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: false}, null, {editable: {updateTime: true, updateGroup: false}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-readonly"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-readonly"); + }); + + it('an editable:false override item (with object option for group change)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: false}, null, {editable: {updateTime: false, updateGroup: true}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-readonly"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-readonly"); + }); + + it('an editable:true override item (with object option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: true}, null, {editable: {updateTime: false, updateGroup: false}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-editable"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-editable"); + }); + + it('an editable:true non-override item (with object option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: true}, null, {editable: {updateTime: false, updateGroup: false, overrideItems: true}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-readonly"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-readonly"); + }); + + it('an editable:false non-override item (with object option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: false}, null, {editable: {updateTime: true, updateGroup: false, overrideItems: true}}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.dom.dot.className, "vis-item vis-dot vis-editable"); + assert.equal(pointItem.dom.point.className, "vis-item vis-point vis-editable"); + }); + + it('an editable: {updateTime} override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true}}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, undefined); + }); + + it('an editable: {updateTime} override item (with boolean option false)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true}}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, undefined); + }); + + it('an editable: {updateGroup} override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateGroup: true}}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, undefined); + assert.equal(pointItem.editable.updateGroup, true); + assert.equal(pointItem.editable.remove, undefined); + }); + +}); // have the correct classname for + + +describe('have the correct property for', function() { + + it('an editable: {updateGroup} override item (with boolean option false)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateGroup: true}}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, undefined); + assert.equal(pointItem.editable.updateGroup, true); + assert.equal(pointItem.editable.remove, undefined); + }); + + it('an editable: {remove} override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {remove: true}}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, undefined); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, true); + }); + + it('an editable: {remove} override item (with boolean option false)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {remove: true}}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, undefined); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, true); + }); + + it('an editable: {updateTime, remove} override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true, remove: true}}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, true); + }); + + it('an editable: {updateTime, remove} override item (with boolean option false)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true, remove: true}}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, undefined); + assert.equal(pointItem.editable.remove, true); + }); + + it('an editable: {updateTime, updateGroup, remove} override item (with boolean option)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true, updateGroup: true, remove: true}}, null, {editable: true}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, true); + assert.equal(pointItem.editable.remove, true); + }); + + it('an editable: {updateTime, updateGroup, remove} override item (with boolean option false)', function() { + var pointItem = new PointItem({start: now.toDate(), editable: {updateTime: true, updateGroup: true, remove: true}}, null, {editable: false}); + var parent = TestSupport.buildMockItemSet(); + pointItem.setParent(parent); + pointItem.redraw(); + assert.equal(pointItem.editable.updateTime, true); + assert.equal(pointItem.editable.updateGroup, true); + assert.equal(pointItem.editable.remove, true); + }); + +}); // have the correct property for +}); // should redraw() and then +}); // Timeline PointItem diff --git a/test/Queue.test.js b/test/Queue.test.js index 0dfa17790..c7b864522 100644 --- a/test/Queue.test.js +++ b/test/Queue.test.js @@ -153,8 +153,59 @@ describe('Queue', function () { assert.equal(obj.count, 2); }); - // TODO: test Queue.setOptions + it('set options in constructor', function () { + var queue = new Queue({delay: 3, max: 5}); + assert.equal(queue.delay, 3); + assert.equal(queue.max, 5); + }); + + it('set options explicitly', function () { + var queue = new Queue(); + queue.setOptions({delay: 3, max: 5}); + assert.equal(queue.delay, 3); + assert.equal(queue.max, 5); + }); + + it('set option delay', function () { + var queue = new Queue(); + queue.setOptions({delay: 3}); + assert.equal(queue.delay, 3); + assert.equal(queue.max, Infinity); + }); + + it('set option max', function () { + var queue = new Queue(); + queue.setOptions({max: 5}); + assert.equal(queue.delay, null); + assert.equal(queue.max, 5); + }); + + it('destroy flushes the queue', function () { + var queue = new Queue({max: 4}); + + var count = 0; + function inc() { + count++; + } + queue.queue(inc); + queue.destroy(); + assert.equal(count, 1) + }); - // TODO: test Queue.destroy + it('destroy removes extensions', function () { + var obj = { + count: 0, + add: function (value) { + this.count += value; + }, + subtract: function (value) { + this.count -= value; + } + }; + + var queue = Queue.extend(obj, {replace: ['add', 'subtract']}); + queue.destroy(); + assert.equal(queue._extended, null); + }); }); diff --git a/test/TestSupport.js b/test/TestSupport.js new file mode 100644 index 000000000..1de998b6c --- /dev/null +++ b/test/TestSupport.js @@ -0,0 +1,42 @@ +var vis = require('../dist/vis'); +var DataSet = vis.DataSet; + +module.exports = { + buildMockItemSet: function() { + var itemset = { + dom: { + foreground: document.createElement('div'), + content: document.createElement('div') + }, + itemSet: { + itemsData: new DataSet() + } + }; + return itemset; + }, + + buildSimpleTimelineRangeBody: function () { + var body = { + dom: { + center: { + clientWidth: 1000 + } + }, + domProps: { + centerContainer: { + width: 900, + height: 600 + } + }, + emitter: { + on: function () {}, + off: function () {}, + emit: function () {} + }, + hiddenDates: [], + util: {} + } + body.dom.rollingModeBtn = document.createElement('div') + return body + } +} diff --git a/test/TimeStep.test.js b/test/TimeStep.test.js new file mode 100644 index 000000000..de5210bb6 --- /dev/null +++ b/test/TimeStep.test.js @@ -0,0 +1,90 @@ +var assert = require('assert'); +var vis = require('../dist/vis'); +var jsdom = require('mocha-jsdom') +var moment = vis.moment; +var timeline = vis.timeline; +var TimeStep = timeline.TimeStep; +var TestSupport = require('./TestSupport'); + +describe('TimeStep', function () { + + jsdom(); + + it('should work with just start and end dates', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + assert.equal(timestep.autoScale, true, "should autoscale if scale not specified"); + assert.equal(timestep.scale, "day", "should default to day scale if scale not specified"); + assert.equal(timestep.step, 1, "should default to 1 day step if scale not specified"); + }); + + it('should work with specified scale (just under 1 second)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5), 999); + assert.equal(timestep.scale, "second", "should have right scale"); + assert.equal(timestep.step, 1, "should have right step size"); + }); + + // TODO: check this - maybe should work for 1000? + it('should work with specified scale (1 second)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5), 1001); + assert.equal(timestep.scale, "second", "should have right scale"); + assert.equal(timestep.step, 5, "should have right step size"); + }); + + it('should work with specified scale (2 seconds)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5), 2000); + assert.equal(timestep.scale, "second", "should have right scale"); + assert.equal(timestep.step, 5, "should have right step size"); + }); + + it('should work with specified scale (5 seconds)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5), 5001); + assert.equal(timestep.scale, "second", "should have right scale"); + assert.equal(timestep.step, 10, "should have right step size"); + }); + + it('should perform the step with a specified scale (1 year)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + timestep.setScale({ scale: 'year', step: 1 }); + timestep.start(); + assert.equal(timestep.getCurrent().unix(), moment("2017-01-01T00:00:00.000").unix(), "should have the right initial value"); + timestep.next(); + assert.equal(timestep.getCurrent().unix(), moment("2018-01-01T00:00:00.000").unix(), "should have the right value after a step"); + }); + + it('should perform the step with a specified scale (1 month)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + timestep.setScale({ scale: 'month', step: 1 }); + timestep.start(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-01T00:00:00.000").unix(), "should have the right initial value"); + timestep.next(); + assert.equal(timestep.getCurrent().unix(), moment("2017-05-01T00:00:00.000").unix(), "should have the right value after a step"); + }); + + it('should perform the step with a specified scale (1 week)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + timestep.setScale({ scale: 'week', step: 1 }); + timestep.start(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-02T00:00:00.000").unix(), "should have the right initial value"); + timestep.next(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-09T00:00:00.000").unix(), "should have the right value after a step"); + }); + + it('should perform the step with a specified scale (1 day)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + timestep.setScale({ scale: 'day', step: 1 }); + timestep.start(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T00:00:00.000").unix(), "should have the right initial value"); + timestep.next(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-04T00:00:00.000").unix(), "should have the right value after a step"); + }); + + it('should perform the step with a specified scale (1 hour)', function () { + var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5)); + timestep.setScale({ scale: 'hour', step: 1 }); + timestep.start(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T00:00:00.000").unix(), "should have the right initial value"); + timestep.next(); + assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T01:00:00.000").unix(), "should have the right value after a step"); + }); + +}); \ No newline at end of file diff --git a/test/TimelineItemSet.test.js b/test/TimelineItemSet.test.js new file mode 100644 index 000000000..8892c49e7 --- /dev/null +++ b/test/TimelineItemSet.test.js @@ -0,0 +1,108 @@ +var assert = require('assert'); + +describe('Timeline ItemSet', function () { + before(function () { + delete require.cache[require.resolve('../dist/vis')] + this.jsdom = require('jsdom-global')(); + this.vis = require('../dist/vis'); + var TestSupport = require('./TestSupport'); + var rangeBody = TestSupport.buildSimpleTimelineRangeBody(); + this.testrange = new this.vis.timeline.Range(rangeBody); + this.testrange.setRange(new Date(2017, 1, 26, 13, 26, 3, 320), new Date(2017, 1, 26, 13, 26, 4, 320), false, false, null); + this.testitems = new this.vis.DataSet({ + type: { + start: 'Date', + end: 'Date' + } + }); + // add single items with different date types + this.testitems.add({id: 1, content: 'Item 1', start: new Date(2017, 1, 26, 13, 26, 3, 600), type: 'point'}); + this.testitems.add({id: 2, content: 'Item 2', start: new Date(2017, 1, 26, 13, 26, 5, 600), type: 'point'}); + }) + + after(function () { + this.jsdom(); + }) + + var getBasicBody = function() { + var body = { + dom: { + container: document.createElement('div'), + leftContainer: document.createElement('div'), + centerContainer: document.createElement('div'), + top: document.createElement('div'), + left: document.createElement('div'), + center: document.createElement('div'), + backgroundVertical: document.createElement('div') + }, + domProps: { + root: {}, + background: {}, + centerContainer: {}, + leftContainer: {}, + rightContainer: {}, + center: {}, + left: {}, + right: {}, + top: {}, + bottom: {}, + border: {}, + scrollTop: 0, + scrollTopMin: 0 + }, + emitter: { + on: function() {return {};}, + emit: function() {} + }, + util: { + } + } + return body; + }; + + it('should initialise with minimal data', function () { + var body = getBasicBody(); + var itemset = new this.vis.timeline.components.ItemSet(body, {}); + assert(itemset); + }); + + it('should redraw() and have the right classNames', function () { + var body = getBasicBody(); + body.range = this.testrange; + var itemset = new this.vis.timeline.components.ItemSet(body, {}); + itemset.redraw(); + assert.equal(itemset.dom.frame.className, 'vis-itemset'); + assert.equal(itemset.dom.background.className, 'vis-background'); + assert.equal(itemset.dom.foreground.className, 'vis-foreground'); + assert.equal(itemset.dom.axis.className, 'vis-axis'); + assert.equal(itemset.dom.labelSet.className, 'vis-labelset'); + }); + + it('should start with no items', function () { + var body = getBasicBody(); + var itemset = new this.vis.timeline.components.ItemSet(body, {}); + assert.equal(itemset.getItems(), null); + }); + + it('should store items correctly', function() { + var body = getBasicBody(); + body.range = this.testrange; + var DateUtil = this.vis.timeline.DateUtil; + body.util.toScreen = function(time) { + return DateUtil.toScreen({ + body: { + hiddenDates: [] + }, + range: { + conversion: function() { + return {offset: 0, scale: 100}; + } + } + }, time, 900) + }; + var itemset = new this.vis.timeline.components.ItemSet(body, {}); + itemset.setItems(this.testitems); + assert.equal(itemset.getItems().length, 2); + assert.deepEqual(itemset.getItems(), this.testitems); + }); +}); diff --git a/test/TimelineRange.test.js b/test/TimelineRange.test.js new file mode 100644 index 000000000..f73d84dfb --- /dev/null +++ b/test/TimelineRange.test.js @@ -0,0 +1,42 @@ +var assert = require('assert'); +var vis = require('../dist/vis'); +var jsdom = require('mocha-jsdom') +var moment = vis.moment; +var timeline = vis.timeline; +var Range = timeline.Range; +var TestSupport = require('./TestSupport'); + +describe('Timeline Range', function () { + + jsdom(); + + it('should have start default before now', function () { + var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0).valueOf(); + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + assert(range.start < now, "Default start is before now"); + }); + + it('should have end default after now', function () { + var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0).valueOf(); + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + assert(range.end > now, "Default end is after now"); + }); + + it('should support custom start and end dates', function () { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + range.setRange(new Date(2017, 0, 26, 13, 26, 3, 320), new Date(2017, 3, 11, 0, 23, 35, 0), false, false, null); + assert.equal(range.start, new Date(2017, 0, 26, 13, 26, 3, 320).valueOf(), "start is as expected"); + assert.equal(range.end, new Date(2017, 3, 11, 0, 23, 35, 0).valueOf(), "end is as expected"); + }); + + it('should calculate milliseconds per pixel', function () { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + assert(range.getMillisecondsPerPixel() > 0, "positive value for milliseconds per pixel"); + }); + + it('should calculate 1 millisecond per pixel for simple range', function () { + var range = new Range(TestSupport.buildSimpleTimelineRangeBody()); + range.setRange(new Date(2017, 0, 26, 13, 26, 3, 320), new Date(2017, 0, 26, 13, 26, 4, 320), false, false, null); + assert.equal(range.getMillisecondsPerPixel(), 1, "one second over 1000 pixels"); + }); +}); \ No newline at end of file diff --git a/test/Validator.test.js b/test/Validator.test.js new file mode 100644 index 000000000..692d9df4d --- /dev/null +++ b/test/Validator.test.js @@ -0,0 +1,265 @@ +/** + * The goal here is to have a minimum-viable test case here, in order to + * check changes in Validator. + * + * Changes in Validator should ideally be checked to see if they trigger here. + * + * test-console reference: https://github.com/jamesshore/test-console + */ +var assert = require('assert'); +var stdout = require('test-console').stdout; +var Validator = require("../lib/shared/Validator").default; + +// Copied from lib/network/options.js +let string = 'string'; +let bool = 'boolean'; +let number = 'number'; +let array = 'array'; +let object = 'object'; // should only be in a __type__ property +let dom = 'dom'; +let any = 'any'; + + +let allOptions = { + // simple options + enabled: { boolean: bool }, + inherit: { string: ['from', 'to', 'both'] }, + size: { number }, + filter: { 'function': 'function' }, + chosen: { + label: { boolean: bool }, + edge: { 'function': 'function' }, + __type__: { object } + }, + chosen2: { + label: { string }, + __type__: { object } + }, + + + // Tests with any. These have been tailored to test all paths in: + // - Validator.check() + // - Validator.checkFields() + __any__: { string }, // Any option name allowed here, but it must be a string + // NOTE: you can have as many new options as you want! IS THIS INTENTIONAL? + groups: { + generic: { any }, + __any__: { any }, + __type__: { object } + }, + + + // TODO: add combined options, e.g. + //inherit: { string: ['from', 'to', 'both'], boolean: bool }, + //filter: { boolean: bool, string, array, 'function': 'function' }, + __type__: { object } +}; + + +describe('Validator', function() { + + function run_validator(options, check_correct, definition = undefined) { + let errorFound; + let output; + + if (definition === undefined) { + definition = allOptions; + } + + output = stdout.inspectSync(function() { + errorFound = Validator.validate(options, definition); + }); + + if (check_correct) { + assert(!errorFound); + assert(output.length === 0, 'No error expected'); + } else { + //console.log(output); //sometimes useful here + assert(errorFound, 'Validation should have failed'); + assert(output.length !== 0, 'must return errors'); + } + + return output; + } + + + function testExpected(output, expectedErrors) { + for (let i = 0; i < expectedErrors.length; ++i) { + assert(expectedErrors[i].test(output[i]), 'Regular expression at index ' + i + ' failed'); + } + assert(output.length === expectedErrors.length, 'Number of expected errors does not match returned errors'); + } + + + it('handles regular options correctly', function(done) { + // Empty options should be accepted as well + run_validator({}, true); + + // test values for all options + var options = { + enabled: true, + inherit: 'from', + size : 123, + filter : function() { return true; }, + chosen : { + label: false, + edge :function() { return true; }, + }, + chosen2: { + label: "I am a string" + }, + + myNameDoesntMatter: "My type does", + groups : { + generic: "any type is good here", + dontCareAboutName: [0,1,2,3] // Type can also be anything + } + }; + + run_validator(options, true); + + done(); + }); + + + it('rejects incorrect options', function(done) { + // All of the options are wrong, all should generate an error + var options = { + iDontExist: 42, // name is 'any' but type must be string + enabled : 'boolean', + inherit : 'abc', + size : 'not a number', + filter : 42, + chosen : 'not an object', + chosen2 : { + label : 123, + + // Following test the working of Validator.getSuggestion() + iDontExist: 'asdf', + generic : "I'm not defined here", + labe : 42, // Incomplete name + labell : 123, + }, + + }; + + var output = run_validator(options, false); + // Sometimes useful: console.log(output); + + // Errors are in the order as the options are defined in the object + let expectedErrors = [ + /Invalid type received for "iDontExist"\. Expected: string\. Received \[number\]/, + /Invalid type received for "enabled"\. Expected: boolean\. Received \[string\]/, + /Invalid option detected in "inherit"\. Allowed values are:from, to, both not/, + /Invalid type received for "size"\. Expected: number\. Received \[string\]/, + /Invalid type received for "filter"\. Expected: function\. Received \[number\]/, + /Invalid type received for "chosen"\. Expected: object\. Received \[string\]/, + /Invalid type received for "label". Expected: string. Received \[number\]/, + + // Expected results of Validator.getSuggestion() + /Unknown option detected: "iDontExist"\. Did you mean one of these:/, + /Unknown option detected: "generic"[\s\S]*Perhaps it was misplaced\? Matching option found at:/gm, + /Unknown option detected: "labe"[\s\S]*Perhaps it was incomplete\? Did you mean:/gm, + /Unknown option detected: "labell"\. Did you mean "label"\?/ + ]; + testExpected(output, expectedErrors); + + done(); + }); + + + /** + * Explicit tests on explicit 'undefined', to be really sure it works as expected. + */ + it('properly handles explicit `undefined`', function(done) { + // Option definitions with 'undefined' + let undefinedOptions = { + width : { number, 'undefined': 'undefined' }, + undefOnly : { 'undefined': 'undefined' }, + colorOptions : { + fill : { string }, + stroke : { string, 'undefined': 'undefined' }, + strokeWidth: { number }, + __type__ : { string, object, 'undefined': 'undefined' } + }, + moreOptions : { + hello : { string }, + world : { string, 'undefined': 'undefined' }, + __type__ : { object } + } + } + + // + // Test good actual option values + // + let correct1 = { + width : 42, + colorOptions: 'I am a string', + moreOptions : { + hello: 'world', + world: '!' + } + } + var output = run_validator(correct1, true, undefinedOptions); + + let correct2 = { + width : undefined, + colorOptions: { + fill : 'I am a string', + stroke: 'I am a string' + }, + moreOptions : { + world: undefined + } + } + var output = run_validator(correct2, true, undefinedOptions); + + let correct3 = { + width : undefined, + undefOnly : undefined, + colorOptions: undefined + } + var output = run_validator(correct3, true, undefinedOptions); + + // + // Test bad actual option values + // + let bad1 = { + width : 'string', + undefOnly : 42, + colorOptions: 42, + moreOptions : undefined + } + var output = run_validator(bad1, false, undefinedOptions); + + let expectedErrors = [ + /Invalid type received for "width"\. Expected: number, undefined\. Received \[string\]/, + /Invalid type received for "undefOnly"\. Expected: undefined\. Received \[number\]/, + /Invalid type received for "colorOptions"\. Expected: string, object, undefined\. Received \[number\]/, + /Invalid type received for "moreOptions"\. Expected: object\. Received \[undefined\]/ + ]; + testExpected(output, expectedErrors); + + let bad2 = { + undefOnly : 'undefined', + colorOptions: { + fill: undefined + } , + moreOptions: { + hello: undefined, + world: 42 + } + } + var output = run_validator(bad2, false, undefinedOptions); + + let expectedErrors2= [ + /Invalid type received for "undefOnly"\. Expected: undefined\. Received \[string\]/, + /Invalid type received for "fill"\. Expected: string\. Received \[undefined\]/, + /Invalid type received for "hello"\. Expected: string\. Received \[undefined\]/, + /Invalid type received for "world"\. Expected: string, undefined\. Received \[number\]/ + ]; + testExpected(output, expectedErrors2); + + done(); + }); +}); diff --git a/test/dotparser.test.js b/test/dotparser.test.js index da951f26b..c9c6412b6 100644 --- a/test/dotparser.test.js +++ b/test/dotparser.test.js @@ -183,4 +183,78 @@ describe('dotparser', function () { }); }); + + /** + * DOT-format examples taken from #3015 + */ + it('properly handles newline escape sequences in strings', function (done) { + var data = 'dinetwork {1 [label="new\\nline"];}'; + + data = String(data); + + var graph = dot.parseDOT(data); + + assert.deepEqual(graph, { + "id": "dinetwork", + "nodes": [ + { + "id": 1, + "attr": { + "label": "new\nline", // And not "new\\nline" + } + } + ] + }); + + + // Note the double backslashes + var data2 = 'digraph {' + "\n" + +' 3 [color="#0d2b7c", label="query:1230:add_q\\n0.005283\\n6.83%\\n(0.0001)\\n(0.13%)\\n17×"];' + "\n" + +' 3 -> 7 [color="#0d2a7b", fontcolor="#0d2a7b", label="0.005128\\n6.63%\\n17×"];' + "\n" + +' 5 [color="#0d1976", label="urlresolvers:537:reverse\\n0.00219\\n2.83%\\n(0.000193)\\n(0.25%)\\n29×"];' + "\n" + +"}" + + data2 = String(data2); + + var graph2 = dot.parseDOT(data2); + //console.log(JSON.stringify(graph, null, 2)); + + assert.deepEqual(graph2, { + "type": "digraph", + "nodes": [ + { + "id": 3, + "attr": { + "color": "#0d2b7c", + "label": "query:1230:add_q\n0.005283\n6.83%\n(0.0001)\n(0.13%)\n17×" + } + }, + { + "id": 7 + }, + { + "id": 5, + "attr": { + "color": "#0d1976", + "label": "urlresolvers:537:reverse\n0.00219\n2.83%\n(0.000193)\n(0.25%)\n29×" + } + } + ], + "edges": [ + { + "from": 3, + "to": 7, + "type": "->", + "attr": { + "color": "#0d2a7b", + "fontcolor": "#0d2a7b", + "label": "0.005128\n6.63%\n17×" + } + } + ] + }); + + done(); + }); + }); diff --git a/test/network/basicUsage.js b/test/network/basicUsage.js new file mode 100644 index 000000000..85808210a --- /dev/null +++ b/test/network/basicUsage.js @@ -0,0 +1,21 @@ +// Network from `basicUsage` example + + // create an array with nodes + var nodes = [ + {id: 1, label: 'Node 1'}, + {id: 2, label: 'Node 2'}, + {id: 3, label: 'Node 3'}, + {id: 4, label: 'Node 4'}, + {id: 5, label: 'Node 5'} + ]; + + // create an array with edges + var edges = [ + {from: 1, to: 3}, + {from: 1, to: 2}, + {from: 2, to: 4}, + {from: 2, to: 5}, + {from: 3, to: 3} + ]; + + var options = {}; diff --git a/test/network/maximumWidthEdgeCase.html b/test/network/maximumWidthEdgeCase.html new file mode 100644 index 000000000..34ff24d4f --- /dev/null +++ b/test/network/maximumWidthEdgeCase.html @@ -0,0 +1,66 @@ + + + + Maximum Width Edge Case Test + + + + + + + + + + +

A word in a label that's wider than the maximum width will be forced onto a line. We can't do better without breaking the word into pieces, and even then the pieces could still be too wide.

+ +

Avoid the problem. Don't set ridiculously small maximum widths.

+ +
+ + + + + diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 000000000..c5d7fc281 --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,471 @@ +var assert = require('assert'); +var util = require('../lib/util'); + + +describe('util', function () { + +/** + * Tests for copy and extend methods. + * + * Goal: to cover all possible paths within the tested method(s) + * + * + * **NOTES** + * + * - All these methods have the inherent flaw that it's possible to define properties + * on an object with value 'undefined'. e.g. in `node`: + * + * > a = { b:undefined } + * > a.hasOwnProperty('b') + * true + * + * The logic for handling this in the code is minimal and accidental. For the time being, + * this flaw is ignored. + */ +describe('extend routines', function () { + + /** + * Check if values have been copied over from b to a as intended + */ + function checkExtended(a, b, checkCopyTarget = false) { + var result = { + color: 'green', + sub: { + enabled: false, + sub2: { + font: 'awesome' + } + } + }; + + assert(a.color !== undefined && a.color === result.color); + assert(a.notInSource === true); + if (checkCopyTarget) { + assert(a.notInTarget === true); + } else { + assert(a.notInTarget === undefined); + } + + var sub = a.sub; + assert(sub !== undefined); + assert(sub.enabled !== undefined && sub.enabled === result.sub.enabled); + assert(sub.notInSource === true); + if (checkCopyTarget) { + assert(sub.notInTarget === true); + } else { + assert(sub.notInTarget === undefined); + } + + sub = a.sub.sub2; + assert(sub !== undefined); + assert(sub !== undefined && sub.font !== undefined && sub.font === result.sub.sub2.font); + assert(sub.notInSource === true); + assert(a.subNotInSource !== undefined); + if (checkCopyTarget) { + assert(a.subNotInTarget.enabled === true); + assert(sub.notInTarget === true); + } else { + assert(a.subNotInTarget === undefined); + assert(sub.notInTarget === undefined); + } + } + + + /** + * Spot check on values of a unchanged as intended + */ + function testAUnchanged(a) { + var sub = a.sub; + assert(sub !== undefined); + assert(sub.enabled !== undefined && sub.enabled === true); + assert(sub.notInSource === true); + assert(sub.notInTarget === undefined); + assert(sub.deleteThis === true); + + sub = a.sub.sub2; + assert(sub !== undefined); + assert(sub !== undefined && sub.font !== undefined && sub.font === 'arial'); + assert(sub.notInSource === true); + assert(sub.notInTarget === undefined); + + assert(a.subNotInSource !== undefined); + assert(a.subNotInTarget === undefined); + } + + + function initA() { + return { + color: 'red', + notInSource: true, + sub: { + enabled: true, + notInSource: true, + sub2: { + font: 'arial', + notInSource: true, + }, + deleteThis: true, + }, + subNotInSource: { + enabled: true, + }, + deleteThis: true, + subDeleteThis: { + enabled: true, + }, + }; + } + + + beforeEach(function() { + this.a = initA(); + + this.b = { + color: 'green', + notInTarget: true, + sub: { + enabled: false, + notInTarget: true, + sub2: { + font: 'awesome', + notInTarget: true, + }, + deleteThis: null, + }, + subNotInTarget: { + enabled: true, + }, + deleteThis: null, + subDeleteThis: null + }; + }); + + + it('performs fillIfDefined() as advertized', function () { + var a = this.a; + var b = this.b; + + util.fillIfDefined(a, b); + checkExtended(a, b); + + // NOTE: if allowDeletion === false, null values are copied over! + // This is due to existing logic; it might not be the intention and hence a bug + assert(a.sub.deleteThis === null); + assert(a.deleteThis === null); + assert(a.subDeleteThis === null); + }); + + + it('performs fillIfDefined() as advertized with deletion', function () { + var a = this.a; + var b = this.b; + + util.fillIfDefined(a, b, true); // thrid param: allowDeletion + checkExtended(a, b); + + // Following should be removed now + assert(a.sub.deleteThis === undefined); + assert(a.deleteThis === undefined); + assert(a.subDeleteThis === undefined); + }); + + + it('performs selectiveDeepExtend() as advertized', function () { + var a = this.a; + var b = this.b; + + // pedantic: copy nothing + util.selectiveDeepExtend([], a, b); + assert(a.color !== undefined && a.color === 'red'); + assert(a.notInSource === true); + assert(a.notInTarget === undefined); + + // pedantic: copy nonexistent property (nothing happens) + assert(b.iDontExist === undefined); + util.selectiveDeepExtend(['iDontExist'], a, b, true); + assert(a.iDontExist === undefined); + + // At this point nothing should have changed yet. + testAUnchanged(a); + + // Copy one property + util.selectiveDeepExtend(['color'], a, b); + assert(a.color !== undefined && a.color === 'green'); + + // Copy property Object + var sub = a.sub; + assert(sub.deleteThis === true); // pre + util.selectiveDeepExtend(['sub'], a, b); + assert(sub !== undefined); + assert(sub.enabled !== undefined && sub.enabled === false); + assert(sub.notInSource === true); + assert(sub.notInTarget === true); + assert(sub.deleteThis === null); + + + // Copy new Objects + assert(a.notInTarget === undefined); // pre + assert(a.subNotInTarget === undefined); // pre + util.selectiveDeepExtend(['notInTarget', 'subNotInTarget'], a, b); + assert(a.notInTarget === true); + assert(a.subNotInTarget.enabled === true); + + // Copy null objects + assert(a.deleteThis !== null); // pre + assert(a.subDeleteThis !== null); // pre + util.selectiveDeepExtend(['deleteThis', 'subDeleteThis'], a, b); + + // NOTE: if allowDeletion === false, null values are copied over! + // This is due to existing logic; it might not be the intention and hence a bug + assert(a.deleteThis === null); + assert(a.subDeleteThis === null); + }); + + + it('performs selectiveDeepExtend() as advertized with deletion', function () { + var a = this.a; + var b = this.b; + + // Only test expected differences here with test allowDeletion === false + + // Copy object property with properties to be deleted + var sub = a.sub; + assert(sub.deleteThis === true); // pre + util.selectiveDeepExtend(['sub'], a, b, true); + assert(sub.deleteThis === undefined); // should be deleted + + // Spot check on rest of properties in `a.sub` - there should have been copied + sub = a.sub; + assert(sub !== undefined); + assert(sub.enabled !== undefined && sub.enabled === false); + assert(sub.notInSource === true); + assert(sub.notInTarget === true); + + // Copy null objects + assert(a.deleteThis === true); // pre + assert(a.subDeleteThis !== undefined); // pre + assert(a.subDeleteThis.enabled === true); // pre + util.selectiveDeepExtend(['deleteThis', 'subDeleteThis'], a, b, true); + assert(a.deleteThis === undefined); // should be deleted + assert(a.subDeleteThis === undefined); // should be deleted + }); + + + it('performs selectiveNotDeepExtend() as advertized', function () { + var a = this.a; + var b = this.b; + + // Exclude all properties, nothing copied + util.selectiveNotDeepExtend(Object.keys(b), a, b); + testAUnchanged(a); + + // Exclude nothing, everything copied + util.selectiveNotDeepExtend([], a, b); + checkExtended(a, b, true); + + // Exclude some + a = initA(); + assert(a.notInTarget === undefined); // pre + assert(a.subNotInTarget === undefined); // pre + util.selectiveNotDeepExtend(['notInTarget', 'subNotInTarget'], a, b); + assert(a.notInTarget === undefined); // not copied + assert(a.subNotInTarget === undefined); // not copied + assert(a.sub.notInTarget === true); // copied! + }); + + + it('performs selectiveNotDeepExtend() as advertized with deletion', function () { + var a = this.a; + var b = this.b; + + // Exclude all properties, nothing copied + util.selectiveNotDeepExtend(Object.keys(b), a, b, true); + testAUnchanged(a); + + // Exclude nothing, everything copied and some deleted + util.selectiveNotDeepExtend([], a, b, true); + checkExtended(a, b, true); + + // Exclude some + a = initA(); + assert(a.notInTarget === undefined); // pre + assert(a.subNotInTarget === undefined); // pre + assert(a.deleteThis === true); // pre + assert(a.subDeleteThis !== undefined); // pre + assert(a.sub.deleteThis === true); // pre + assert(a.subDeleteThis.enabled === true); // pre + util.selectiveNotDeepExtend(['notInTarget', 'subNotInTarget'], a, b, true); + assert(a.deleteThis === undefined); // should be deleted + assert(a.sub.deleteThis !== undefined); // not deleted! Original logic, could be a bug + assert(a.subDeleteThis === undefined); // should be deleted + // Spot check: following should be same as allowDeletion === false + assert(a.notInTarget === undefined); // not copied + assert(a.subNotInTarget === undefined); // not copied + assert(a.sub.notInTarget === true); // copied! + }); + + + /** + * NOTE: parameter `protoExtend` not tested here! + */ + it('performs deepExtend() as advertized', function () { + var a = this.a; + var b = this.b; + + util.deepExtend(a, b); + checkExtended(a, b, true); + }); + + + /** + * NOTE: parameter `protoExtend` not tested here! + */ + it('performs deepExtend() as advertized with delete', function () { + var a = this.a; + var b = this.b; + + // Copy null objects + assert(a.deleteThis === true); // pre + assert(a.subDeleteThis !== undefined); // pre + assert(a.subDeleteThis.enabled === true); // pre + util.deepExtend(a, b, false, true); + checkExtended(a, b, true); // Normal copy should be good + assert(a.deleteThis === undefined); // should be deleted + assert(a.subDeleteThis === undefined); // should be deleted + assert(a.sub.deleteThis !== undefined); // not deleted!!! Original logic, could be a bug + }); +}); // extend routines + + +// +// The important thing with mergeOptions() is that 'enabled' is always set in target option. +// +describe('mergeOptions', function () { + + it('handles good input without global options', function () { + var options = { + someValue: "silly value", + aBoolOption: false, + anObject: { + answer:42 + }, + anotherObject: { + enabled: false, + }, + merge: null + }; + + // Case with empty target + var mergeTarget = {}; + + util.mergeOptions(mergeTarget, options, 'someValue'); + assert(mergeTarget.someValue === undefined, 'Non-object option should not be copied'); + assert(mergeTarget.anObject === undefined); + + util.mergeOptions(mergeTarget, options, 'aBoolOption'); + assert(mergeTarget.aBoolOption !== undefined, 'option aBoolOption should now be an object'); + assert(mergeTarget.aBoolOption.enabled === false, 'enabled value option aBoolOption should have been copied into object'); + + util.mergeOptions(mergeTarget, options, 'anObject'); + assert(mergeTarget.anObject !== undefined, 'Option object is not copied'); + assert(mergeTarget.anObject.answer === 42); + assert(mergeTarget.anObject.enabled === true); + + util.mergeOptions(mergeTarget, options, 'anotherObject'); + assert(mergeTarget.anotherObject.enabled === false, 'enabled value from options must have priority'); + + util.mergeOptions(mergeTarget, options, 'merge'); + assert(mergeTarget.merge === undefined, 'Explicit null option should not be copied, there is no global option for it'); + + // Case with non-empty target + mergeTarget = { + someValue: false, + aBoolOption: true, + anObject: { + answer: 49 + }, + anotherObject: { + enabled: true, + }, + merge: 'hello' + }; + + util.mergeOptions(mergeTarget, options, 'someValue'); + assert(mergeTarget.someValue === false, 'Non-object option should not be copied'); + assert(mergeTarget.anObject.answer === 49, 'Sibling option should not be changed'); + + util.mergeOptions(mergeTarget, options, 'aBoolOption'); + assert(mergeTarget.aBoolOption !== true, 'option enabled should have been overwritten'); + assert(mergeTarget.aBoolOption.enabled === false, 'enabled value option aBoolOption should have been copied into object'); + + util.mergeOptions(mergeTarget, options, 'anObject'); + assert(mergeTarget.anObject.answer === 42); + assert(mergeTarget.anObject.enabled === true); + + util.mergeOptions(mergeTarget, options, 'anotherObject'); + assert(mergeTarget.anotherObject !== undefined, 'Option object is not copied'); + assert(mergeTarget.anotherObject.enabled === false, 'enabled value from options must have priority'); + + util.mergeOptions(mergeTarget, options, 'merge'); + assert(mergeTarget.merge === 'hello', 'Explicit null-option should not be copied, already present in target'); + }); + + + it('gracefully handles bad input', function () { + var mergeTarget = {}; + var options = { + merge: null + }; + + var errMsg = 'Non-object parameters should not be accepted'; + assert.throws(() => util.mergeOptions(null, options, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(undefined, options, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(42, options, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, null, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, undefined, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, 42, 'anything'), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, options, null), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, options, undefined), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, options, 'anything', null), Error, errMsg); + assert.throws(() => util.mergeOptions(mergeTarget, options, 'anything', 'not an object'), Error, errMsg); + + + util.mergeOptions(mergeTarget, options, 'iDontExist'); + assert(mergeTarget.iDontExist === undefined); + }); + + + it('handles good input with global options', function () { + var mergeTarget = { + }; + var options = { + merge: null, + missingEnabled: { + answer: 42 + }, + alsoMissingEnabled: { // has no enabled in globals + answer: 42 + } + }; + + var globalOptions = { + merge: { + enabled: false + }, + missingEnabled: { + enabled: false + } + }; + + util.mergeOptions(mergeTarget, options, 'merge', globalOptions); + assert(mergeTarget.merge.enabled === false, "null-option should create an empty target object"); + + util.mergeOptions(mergeTarget, options, 'missingEnabled', globalOptions); + assert(mergeTarget.missingEnabled.enabled === false); + + util.mergeOptions(mergeTarget, options, 'alsoMissingEnabled', globalOptions); + assert(mergeTarget.alsoMissingEnabled.enabled === true); + }); + +}); // mergeOptions +}); // util