├── .csslintrc ├── .gitignore ├── .idea ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── findbugs-idea.xml ├── jsLibraryMappings.xml ├── libraries │ └── Generated_files.xml ├── misc.xml ├── modules.xml ├── vcs.xml ├── watcherTasks.xml └── workspace.xml ├── .jscsrc ├── .jshintrc ├── .travis.yml ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE.md ├── README.md ├── bower.json ├── d3.relationshipgraph.min.js ├── dest ├── d3.relationshipgraph.js ├── d3.relationshipgraph.min.css └── d3.relationshipgraph.min.js ├── examples └── index.html ├── package.json ├── relationshipgraph.iml ├── src ├── RelationshipGraph.css ├── RelationshipGraph.scss ├── d3-tip.js └── index.js └── test ├── RelationshipGraph.js └── index.html /.csslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "adjoining-classes": false, 3 | "box-sizing": false, 4 | "box-model": false, 5 | "compatible-vendor-prefixes": false, 6 | "floats": false, 7 | "font-sizes": false, 8 | "gradients": false, 9 | "important": false, 10 | "known-properties": false, 11 | "outline-none": false, 12 | "qualified-headings": false, 13 | "regex-selectors": false, 14 | "shorthand": false, 15 | "text-indent": false, 16 | "unique-headings": false, 17 | "universal-selector": false, 18 | "unqualified-attributes": false 19 | } 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | node_modules/* 3 | /.idea/ 4 | /src/.sass-cache/* 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/findbugs-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 32 | 203 | 216 | 225 | 226 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/libraries/Generated_files.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "maxErrors": 1000000, 3 | "disallowMixedSpacesAndTabs": true, 4 | "disallowMultipleLineBreaks": true, 5 | "disallowQuotedKeysInObjects": true, 6 | "disallowTabs": true, 7 | "disallowSpaceBeforeComma": true, 8 | "disallowSpaceBeforeSemicolon": true, 9 | "disallowUnusedParams": true, 10 | "disallowSpaceAfterObjectKeys": true, 11 | "disallowSpaceAfterPrefixUnaryOperators": true, 12 | "disallowSpaceBeforePostfixUnaryOperators": true, 13 | "disallowSpaceBeforeBinaryOperators": [ 14 | "," 15 | ], 16 | "disallowTrailingWhitespace": true, 17 | "disallowTrailingComma": true, 18 | "disallowYodaConditions": true, 19 | "disallowKeywordsOnNewLine": [ 20 | "else" 21 | ], 22 | "disallowKeywords": [ 23 | "with" 24 | ], 25 | "requireSpaceBeforeBlockStatements": true, 26 | "requireParenthesesAroundIIFE": true, 27 | "requireSpacesInConditionalExpression": true, 28 | "requireBlocksOnNewline": 1, 29 | "requireCommaBeforeLineBreak": true, 30 | "requireSpaceBeforeBinaryOperators": true, 31 | "requireSpaceAfterBinaryOperators": true, 32 | "requireCamelCaseOrUpperCaseIdentifiers": true, 33 | "requireLineFeedAtFileEnd": true, 34 | "requireCapitalizedConstructors": true, 35 | "requireDotNotation": true, 36 | "requirePaddingNewLinesAfterUseStrict": true, 37 | "requireSpaceAfterComma": true, 38 | "requireSpaceAfterLineComment": true, 39 | "requireSpacesInFunctionDeclaration": { 40 | "beforeOpeningRoundBrace": true 41 | }, 42 | "requireCurlyBraces": [ 43 | "do", 44 | "catch", 45 | "else", 46 | "finally", 47 | "for", 48 | "if", 49 | "try", 50 | "while" 51 | ], 52 | "requireSpaceAfterKeywords": [ 53 | "if", 54 | "else", 55 | "for", 56 | "while", 57 | "do", 58 | "switch", 59 | "case", 60 | "return", 61 | "try", 62 | "catch", 63 | "typeof" 64 | ], 65 | /*"safeContextKeyword": "_this",*/ 66 | "requireSemicolons": true, 67 | "validateLineBreaks": "LF", 68 | "validateQuoteMarks": "'", 69 | "requireAnonymousFunctions": true, 70 | "validateIndentation": 4, 71 | "jsDoc": { 72 | "checkAnnotations": true, 73 | "checkParamExistence": true, 74 | "checkParamNames": true, 75 | "checkRedundantReturns": true, 76 | "checkReturnTypes": true, 77 | "checkTypes": true, 78 | "enforceExistence": true, 79 | "requireDescriptionCompleteSentence": true, 80 | "requireNewlineAfterDescription": true, 81 | "requireParamDescription": true, 82 | "requireReturnDescription": true, 83 | "requireReturnTypes": true 84 | }, 85 | "requireCapitalizedConstructorsNew": true 86 | } 87 | 88 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "maxerr": 50, 3 | "esversion": 6, 4 | "bitwise": true, 5 | "camelcase": true, 6 | "curly": true, 7 | "eqeqeq": false, 8 | "forin": true, 9 | "freeze": true, 10 | "immed": true, 11 | "latedef": false, 12 | "newcap": true, 13 | "noarg": true, 14 | "noempty": true, 15 | "nonbsp": true, 16 | "nonew": true, 17 | "plusplus": false, 18 | "quotmark": "single", 19 | "undef": true, 20 | "unused": true, 21 | "strict": true, 22 | "indent": 2, 23 | "asi": false, 24 | "boss": false, 25 | "debug": false, 26 | "eqnull": true, 27 | "moz": false, 28 | "evil": false, 29 | "expr": true, 30 | "funcscope": false, 31 | "globalstrict": false, 32 | "iterator": false, 33 | "lastsemic": false, 34 | "laxbreak": true, 35 | "laxcomma": false, 36 | "loopfunc": true, 37 | "multistr": false, 38 | "noyield": false, 39 | "notypeof": false, 40 | "proto": false, 41 | "scripturl": false, 42 | "shadow": false, 43 | "sub": true, 44 | "supernew": false, 45 | "validthis": false, 46 | "browser": true, 47 | "browserify": false, 48 | "couch": false, 49 | "devel": false, 50 | "dojo": false, 51 | "jasmine": false, 52 | "jquery": false, 53 | "mocha": true, 54 | "mootools": false, 55 | "node": false, 56 | "nonstandard": false, 57 | "phantom": false, 58 | "prototypejs": false, 59 | "qunit": false, 60 | "rhino": false, 61 | "shelljs": false, 62 | "typed": false, 63 | "worker": false, 64 | "wsh": false, 65 | "yui": false, 66 | "maxlen": 120, 67 | "predef": [ 68 | "d3", 69 | "d3.tip" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "6" 5 | before_install: npm install -g grunt-cli 6 | install: npm install 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.2.2 (5/16/2016) 2 | * Removed the Object/Array prototypes to prevent them from conflicting with other frameworks. 3 | * Fixed a spacing issue that was caused by having the wrong font size. 4 | * Added a check to prevent the SVG from being created multiple times. 5 | * Minor optimizations, fixed a resizing bug, moved the default color array into the constructor to prevent it from being created every time 'data' was called, finished adding in the thresholds, and added transitions. 6 | * Determine the direction of the tooltip based on the location and updated the CSS. 7 | * Added bower.json and a README. 8 | 9 | # 1.3.0 (5/17/2016 - 5/19/2016) 10 | * Pulled d3-tip into the source code to make modifications to add a way to determine the direction of the tip so that the tip doesn't go off the screen. 11 | * Minified the CSS stylesheet. 12 | * Added a way to configure the truncate cap in the configuration object. 13 | * Removed d3-tip from the dependencies. 14 | * Added AMD and CommonJS support. 15 | * Fixed a bug that was causing the resize of the SVG to calculate the width and height incorrectly. 16 | * Made *data* return the RelationshipGraph object to keep d3's chaining functionality working. 17 | * Fixed a bug where if the value was a number, the threshold wouldn't work. 18 | * Used JSCS to enforce styling. 19 | * Added grunt tasks. 20 | * Added a TravisCI yaml file for tests. 21 | * Added CSSLint 22 | * Began adding in unit tests. 23 | * Updated algorithm for determining if the tooltip should be relocated. 24 | * Added sorting to the thresholds if it is made up of numbers. 25 | 26 | # 1.4.1 (5/19/2016 - 5/21/2016) 27 | * Added additional tests and fixed the bugs that came with that. 28 | * Updated d3 to 3.5.17 29 | * Fixed a bug that made the sorting different each time. 30 | * Finished the test suite. 31 | 32 | # 1.4.2 (5/21/2016 - 5/26/2016) 33 | * Updated dev dependencies. 34 | * Reduced the complexity of the *data* method by splitting it up into separate private methods. 35 | * Moved the all private methods into one area. 36 | * Added the ability to not pass in thresholds and all the blocks be the same color. 37 | * Right aligned the parent labels. 38 | 39 | # 1.5.0 (5/26/2016 - 6/12/2016) 40 | * Created a non-minified js file in dest using grunt-contrib-concat. 41 | * Added a test to check the colors of the blocks to make sure they're correct. 42 | * Made the comparison between the value and the threshold case insensitive, added type checking for the threshold comparisons, and made sure that the key appears in title case when the tooltip comes up. 43 | * Fixed a bug where clicking `Random` twice (or more) on example page causes the demo to keep cycling. 44 | * Moved the child nodes five pixels away from the parent labels to make the space larger. 45 | * Optimized the code by using local variables instead of accessing object properties multiple times and made static functions instead of recreating them in loops. 46 | * Fixed a bug where the number thresholds had to be exact instead of between two thresholds. 47 | * Fixed a bug where only the first word in the tooltip key was capitalized instead of the key being in title case. 48 | * Fixed the regex for numeric comparisons so that it would take negative numbers into account. 49 | * Added additional tests. 50 | * Fixed the way that the width of the parent labels was determined and added a cache. 51 | * Optimized parent labels by storing the keys instead of generating it each time. 52 | * Added a way to add a custom sort function. 53 | * Added a way to set a custom string for the `value` key instead of having it always say 'value' on the tooltip.` 54 | * Added support for private data by using the `_private_` key in the JSON data. 55 | 56 | # 2.0.0 (6/12/2016-7/08/2016) 57 | * Added a way to set the onclick function for a parent label. 58 | * Cleaned up some of the code. 59 | * Fixed an SVG width issue where if no data was supplied, the width and height were set to -15, which threw an exception. 60 | * Fixed a bug where if the tooltip width and height got too big, the arrow wasn't pointing at the child node. 61 | * Fixed a bug where the width of the SVG was being determined incorrectly. 62 | * Added a way to not show the value on the tooltip by setting the `valueKeyName` to an empty string. 63 | * Added a cursor pointer if there is an onClick function. 64 | * Rewrote the source in ES6 and d3 v4.1.0 65 | * Added in some missing ES6 conversions. 66 | 67 | # 2.1.0 (7/08/2016-10/02/2016) 68 | * Added interaction methods with the child nodes to allow users to change the color of the node and the stroke color. 69 | * Added a way to query objects based on sub objects. 70 | * Lazily loaded the node interaction methods. 71 | * Made graph backwards compatible with d3 v3. 72 | * Changed the CSS to SCSS. 73 | * Fixed the spacing between the child nodes. 74 | * Updated uglify to version 2.0.0 75 | * Fixed a bug where the IDs given to the child nodes were the same. This caused the node in row 1, index 22, to have the same ID as the node in row 12, index 2 (for example). 76 | 77 | # 2.1.2 (10/02/2016-) 78 | * Fixed a bug where the colors were not being set correctly for the children nodes because the color was being returned as `000000` instead of `#000000`. 79 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* global module, grunt, initConfig */ 2 | module.exports = function(grunt) { 3 | 'use strict'; 4 | 5 | grunt.initConfig({ 6 | concat: { 7 | options: { 8 | separator: ' ' 9 | }, 10 | dist: { 11 | src: ['src/d3-tip.js', 'src/index.js'], 12 | dest: 'dest/d3.relationshipgraph.js' 13 | } 14 | }, 15 | babel: { 16 | options: { 17 | presets: ['es2015'] 18 | }, 19 | dist: { 20 | files: { 21 | 'dest/d3.relationshipgraph.js': 'dest/d3.relationshipgraph.js' 22 | } 23 | } 24 | }, 25 | uglify: { 26 | options: { 27 | mangle: false 28 | }, 29 | target: { 30 | files: { 31 | 'dest/d3.relationshipgraph.min.js': 'dest/d3.relationshipgraph.js' 32 | } 33 | } 34 | }, 35 | cssmin: { 36 | target: { 37 | files: { 38 | 'dest/d3.relationshipgraph.min.css': 'src/RelationshipGraph.css' 39 | } 40 | } 41 | }, 42 | jshint: { 43 | all: { 44 | 'src': 'src/index.js', 45 | options: { 46 | jshintrc: '.jshintrc' 47 | } 48 | } 49 | }, 50 | jscs: { 51 | src: 'src/index.js', 52 | options: { 53 | config: '.jscsrc' 54 | } 55 | }, 56 | csslint: { 57 | options: { 58 | csslintrc: '.csslintrc' 59 | }, 60 | src: ['src/RelationshipGraph.css'] 61 | }, 62 | mocha: { 63 | test: { 64 | src: ['test/**/*.html'] 65 | }, 66 | options: { 67 | log: true, 68 | logErrors: true 69 | } 70 | } 71 | }); 72 | 73 | grunt.loadNpmTasks('grunt-babel'); 74 | grunt.loadNpmTasks('grunt-contrib-concat'); 75 | grunt.loadNpmTasks('grunt-contrib-uglify'); 76 | grunt.loadNpmTasks('grunt-contrib-jshint'); 77 | grunt.loadNpmTasks('grunt-jscs'); 78 | grunt.loadNpmTasks('grunt-contrib-cssmin'); 79 | grunt.loadNpmTasks('grunt-contrib-csslint'); 80 | grunt.loadNpmTasks('grunt-mocha'); 81 | 82 | grunt.registerTask('default', ['jshint', 'jscs', 'concat', 'babel', 'uglify', 'csslint', 'cssmin']); 83 | grunt.registerTask('test', ['jshint', 'jscs', 'concat', 'babel', 'uglify', 'csslint', 'cssmin', 'mocha']); 84 | }; 85 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Harrison Kelly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-relationshipgraph [![Build Status](https://travis-ci.org/hkelly93/d3-relationshipgraph.svg?branch=master)](https://travis-ci.org/hkelly93/d3-relationshipgraph) [![Dependencies Status](https://david-dm.org/hkelly93/d3-relationshipgraph.svg)](https://david-dm.org/hkelly93/d3-relationshipgraph.svg) [![devDependency Status](https://david-dm.org/hkelly93/d3-relationshipgraph/dev-status.svg)](https://david-dm.org/hkelly93/d3-relationshipgraph#info=devDependencies) 2 | A framework for creating parent-child relationships with [D3.js](http://www.d3js.org). 3 | 4 | ## Examples 5 | View a working example [here](https://cdn.rawgit.com/hkelly93/d3-relationshipGraph/master/examples/index.html). 6 | 7 | If you have used d3-relationshipgraph, feel free to edit this readme and put a link and image for your example. 8 | 9 | ## Installation 10 | You can install d3-relationshipgraph via [bower](http://bower.io) 11 | 12 | ``` 13 | $ bower install d3-relationshipgraph 14 | ``` 15 | You can also use [npm](http://npmjs.org) 16 | 17 | ``` 18 | $ npm install d3-relationshipgraph 19 | ``` 20 | Or by downloading the repository and running 21 | ``` 22 | $ npm install 23 | ``` 24 | in the directory. 25 | 26 | ## Usage 27 | ### Setup 28 | Since d3.relationshipgraph extends the D3.js framework, it can be easily added to an existing project by adding the following 29 | 30 | ```html 31 | 32 | 33 | 34 | ```` 35 | 36 | Once the framework is added to the HTML file, graphs can be created using familiar D3 selections 37 | 38 | ```javascript 39 | var json = [ 40 | { 41 | movietitle: 'Avatar', 42 | parent: '20th Century Fox', 43 | value: '$2,787,965,087', 44 | year: '2009' 45 | }, 46 | { 47 | movietitle: 'Titanic', 48 | parent: '20th Century Fox', 49 | value: '$2,186,772,302', 50 | year: '1997' 51 | } 52 | ]; 53 | 54 | var graph = d3.select('#graph').relationshipGraph({ 55 | showTooltips: true, 56 | maxChildCount: 10, 57 | showKeys: false, 58 | thresholds: [1000000000, 2000000000, 3000000000] 59 | }).data(json); 60 | ``` 61 | 62 | This simple code will produce the example at the beginning of the readme. 63 | 64 | ### Thresholds 65 | Thresholds can be `strings` or `numbers`. If you use a `string`, only values that match exactly will be in that threshold. If you use a number, a numeric value will be in the smallest threshold that is greater than the value. 66 | If the values are `strings` (such as in the example above), the number is extracted from the string and used. This allows you to use string values such as: 67 | 68 | ```javascript 69 | var json = [ 70 | {parent: 'a', value: '$100'}, 71 | {parent: 'b', value: '$100.15'}, 72 | {parent: 'c', value: '100%'}, 73 | {parent: 'd', value: '100.15%'} 74 | ]; 75 | ```` 76 | 77 | and thresholds such as: 78 | 79 | ```javascript 80 | var thresholds = [25, 50, 75, 100]; 81 | ```` 82 | 83 | ### Private Data 84 | Private data can be added to the JSON data by using the `_private_` key. This allows you to pass private data into the onClick function that isn't shown in the tooltip. 85 | 86 | To use private data, structure your JSON data so that it looks similar to 87 | 88 | ```javascript 89 | var myData = { 90 | parent: 'parentA', 91 | name: 'child1', 92 | _private_: { 93 | private1: 'Hidden from the tooltip.', 94 | private2: 'Also hidden from the tooltip.' 95 | } 96 | } 97 | ``` 98 | 99 | ### Configuration 100 | d3.relationshipgraph is configured by passing in a JavaScript object into the constructor. The object can have the following properties 101 | 102 | ```Javascript 103 | config = { 104 | showTooltips: true, // Whether or not to show tooltips when the child block is moused over. 105 | maxChildCount: 10, // The maximum amount of children to show per row before wrapping. 106 | onClick: function(obj) {}, // The callback function to call when a child block is clicked on. This gets passed the JSON for the object. 107 | showKeys: true, // Whether or not to show the JSON keys in the tooltip 108 | thresholds: [100, 200, 300], // The thresholds for the color changes. If the values are strings, the colors are determined by the value of the child being equal to the threshold. If the thresholds are numbers, the color is determined by the value being less than the threshold. 109 | colors: ['red', 'green', 'blue'], // The custom color set to use for the child blocks. These can be color names, HEX values, or RGBA values. 110 | transitionTime: 1000, // The time in milliseconds for the transitions. Set to 0 to disable. 111 | truncate: 25, // The maximum length for the parent labels before they get truncated. Set to 0 to disable. 112 | sortFunction: sortJson, // A custom sort function. The parent value must be sorted first. 113 | valueKeyName: 'Worldwide Gross' // Set a custom key value in the tooltip instead of showing 'value'. 114 | } 115 | ``` 116 | 117 | None of the configurations are required and they all have default values 118 | 119 | ```Javascript 120 | config = { 121 | showTooltips: true, 122 | maxChildCount: 0, // When the value is 0, the max count is determined by the width of the parent element. 123 | onClick: function () { }, // no-op 124 | showKeys: true, 125 | thresholds: [], // All chiild blocks will be the same color. 126 | transitionTime: 1500, 127 | truncate: 0, 128 | sortFunction: sortJson, 129 | valueKeyName: 'value' 130 | } 131 | ``` 132 | 133 | If a custom sorting is used, the `parent` value MUST be sorted first. 134 | 135 | ### Updating with New Data 136 | To update the relationship graph with new data, store the RelationshipGraph object and call the `data` function with the updated JSON 137 | 138 | ```Javascript 139 | var json = [ 140 | { 141 | movietitle: 'Avatar', 142 | parent: '20th Century Fox', 143 | value: '$2,787,965,087', 144 | year: '2009' 145 | }, 146 | { 147 | movietitle: 'Titanic', 148 | parent: '20th Century Fox', 149 | value: '$2,186,772,302', 150 | year: '1997' 151 | } 152 | ]; 153 | 154 | var graph = d3.select('#graph').relationshipGraph({ 155 | showTooltips: true, 156 | maxChildCount: 10, 157 | showKeys: false, 158 | thresholds: [1000000000, 2000000000, 3000000000] 159 | }); 160 | 161 | graph.data(json); // Add the first set of data. 162 | 163 | json = [ 164 | { 165 | movietitle: 'Avatar', 166 | parent: '20th Century Fox', 167 | value: '$2,787,965,087', 168 | year: '2009' 169 | }, 170 | { 171 | movietitle: 'Titanic', 172 | parent: '20th Century Fox', 173 | value: '$2,186,772,302', 174 | year: '1997' 175 | }, 176 | { 177 | movietitle: 'Star Wars: The Force Awakens', 178 | parent: 'Walt Disney Studios', 179 | value: '$2,066,247,462', 180 | year: '2015' 181 | } 182 | ]; 183 | 184 | graph.data(json); // Update the graph with new data. 185 | ```` 186 | 187 | ## Child Node Interaction 188 | To interact with the child nodes once they are in the graph, you can query for them based on subobjects. Thiswill return the objects that match the query. Once these objects have been returned, 189 | you can change the color of the nodes by using the `setNodeColor` method on the object, or change the stroke color of the node by using the `setNodeStrokeColor` method. 190 | 191 | An example of a query is if I was lookingfor all nodes in the following example that are from the year 2009 192 | 193 | ```javascript 194 | var json = [ 195 | { 196 | movietitle: 'Avatar', 197 | parent: '20th Century Fox', 198 | value: '$2,787,965,087', 199 | year: '2009' 200 | }, 201 | { 202 | movietitle: 'Titanic', 203 | parent: '20th Century Fox', 204 | value: '$2,186,772,302', 205 | year: '1997' 206 | } 207 | ]; 208 | 209 | var graph = d3.select('#graph').relationshipGraph(); 210 | 211 | graph.query({year: '2009'}); 212 | ``` 213 | That would return the Javascript object(s) that match the query. 214 | 215 | ## License 216 | This project is licensed under the MIT license -- see the [LICENSE.md](LICENSE.md) file for details. 217 | 218 | ## Contributing 219 | If you would like to contribute please ensure that the following passes 220 | 221 | ``` 222 | $ grunt test -v 223 | ``` 224 | before putting up a pull request. 225 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-relationshipgraph", 3 | "description": "A D3 graph to show parent child relationships.", 4 | "main": "src/d3-tip.js, src/index.js", 5 | "dependencies": { 6 | "d3": "^4.0.1" 7 | }, 8 | "devDependencies": { 9 | "babel-preset-es2015": "^6.9.0", 10 | "chai": "~3.5.0", 11 | "mocha": "~2.5.3", 12 | "grunt": "^1.0.1", 13 | "grunt-babel": "^6.0.0", 14 | "grunt-contrib-concat": "^1.0.1", 15 | "grunt-contrib-csslint": "^1.0.0", 16 | "grunt-contrib-cssmin": "^1.0.1", 17 | "grunt-contrib-jshint": "^1.0.0", 18 | "grunt-contrib-uglify": "^1.0.1", 19 | "grunt-jscs": "^3.0.0", 20 | "grunt-mocha": "^1.0.2" 21 | }, 22 | "version": "2.0.0", 23 | "keywords": [ 24 | "d3", 25 | "relationship", 26 | "graph", 27 | "parent", 28 | "child", 29 | "relationshipgraph", 30 | "d3.relationshipgraph" 31 | ], 32 | "authors": "Harrison Kelly ", 33 | "license": "MIT" 34 | } 35 | -------------------------------------------------------------------------------- /d3.relationshipgraph.min.js: -------------------------------------------------------------------------------- 1 | (function(root,factory){"use strict";if(typeof define==="function"&&define.amd){define(["d3"],factory)}else if(typeof module==="object"&&module.exports){module.exports=function(d3){d3.tip=factory(d3);return d3.tip}}else{root.d3.tip=factory(root.d3)}})(this,function(d3){"use strict";return function(){var direction=d3_tip_direction,offset=d3_tip_offset,html=d3_tip_html,node=initNode(),svg=null,point=null,target=null;var getPageTopLeft=function(el){var rect=el.getBoundingClientRect();var docEl=document.documentElement;return{top:rect.top+(window.pageYOffset||docEl.scrollTop||0),right:rect.right+(window.pageXOffset||0),bottom:rect.bottom+(window.pageYOffset||0),left:rect.left+(window.pageXOffset||docEl.scrollLeft||0)}};function tip(vis){svg=getSVGNode(vis);point=svg.createSVGPoint();document.body.appendChild(node)}tip.show=function(){var args=Array.prototype.slice.call(arguments);if(args[args.length-1]instanceof SVGElement){target=args.pop()}var content=html.apply(this,args),poffset=offset.apply(this,args),dir=direction.apply(this,args),nodel=getNodeEl(),i=directions.length,coords,scrollTop=document.documentElement.scrollTop||document.body.scrollTop,scrollLeft=document.documentElement.scrollLeft||document.body.scrollLeft;nodel.html(content).style({opacity:1,"pointer-events":"all"});var node=nodel[0][0],nodeWidth=node.clientWidth,nodeHeight=node.clientHeight,windowWidth=window.innerWidth,windowHeight=window.innerHeight,elementCoords=getPageTopLeft(this),breaksTop=elementCoords.top-nodeHeight<0,breaksLeft=elementCoords.left-nodeWidth<0,breaksRight=elementCoords.right+nodeHeight>windowWidth,breaksBottom=elementCoords.bottom+nodeHeight>windowHeight;if(breaksTop&&!breaksRight&&!breaksBottom&&breaksLeft){dir="e"}else if(breaksTop&&!breaksRight&&!breaksBottom&&!breaksLeft){dir="s"}else if(breaksTop&&breaksRight&&!breaksBottom&&!breaksLeft){dir="w"}else if(!breaksTop&&!breaksRight&&!breaksBottom&&breaksLeft){dir="e"}else if(!breaksTop&&!breaksRight&&breaksBottom&&breaksLeft){dir="e"}else if(!breaksTop&&!breaksRight&&breaksBottom&&!breaksLeft){dir="e"}else if(!breaksTop&&breaksRight&&breaksBottom&&!breaksLeft){dir="n"}else if(!breaksTop&&breaksRight&&!breaksBottom&&!breaksLeft){dir="w"}direction(dir);while(i--){nodel.classed(directions[i],false)}coords=direction_callbacks.get(dir).apply(this);nodel.classed(dir,true).style({top:coords.top+poffset[0]+scrollTop+"px",left:coords.left+poffset[1]+scrollLeft+"px"});return tip};tip.hide=function(){var nodel=getNodeEl();nodel.style({opacity:0,"pointer-events":"none"});return tip};tip.attr=function(n){if(arguments.length<2&&typeof n==="string"){return getNodeEl().attr(n)}else{var args=Array.prototype.slice.call(arguments);d3.selection.prototype.attr.apply(getNodeEl(),args)}return tip};tip.style=function(n,v){if(arguments.length<2&&typeof n==="string"){return getNodeEl().style(n)}else{var args=Array.prototype.slice.call(arguments);d3.selection.prototype.style.apply(getNodeEl(),args)}return tip};tip.direction=function(v){if(!arguments.length){return direction}direction=v==null?v:d3.functor(v);return tip};tip.offset=function(v){if(!arguments.length){return offset}offset=v==null?v:d3.functor(v);return tip};tip.html=function(v){if(!arguments.length){return html}html=v==null?v:d3.functor(v);return tip};tip.destroy=function(){if(node){getNodeEl().remove();node=null}return tip};function d3_tip_direction(){return"n"}function d3_tip_offset(){return[0,0]}function d3_tip_html(){return" "}var direction_callbacks=d3.map({n:direction_n,s:direction_s,e:direction_e,w:direction_w,nw:direction_nw,ne:direction_ne,sw:direction_sw,se:direction_se}),directions=direction_callbacks.keys();function direction_n(){var bbox=getScreenBBox();return{top:bbox.n.y-node.offsetHeight,left:bbox.n.x-node.offsetWidth/2}}function direction_s(){var bbox=getScreenBBox();return{top:bbox.s.y,left:bbox.s.x-node.offsetWidth/2}}function direction_e(){var bbox=getScreenBBox();return{top:bbox.e.y-node.offsetHeight/2,left:bbox.e.x}}function direction_w(){var bbox=getScreenBBox();return{top:bbox.w.y-node.offsetHeight/2,left:bbox.w.x-node.offsetWidth}}function direction_nw(){var bbox=getScreenBBox();return{top:bbox.nw.y-node.offsetHeight,left:bbox.nw.x-node.offsetWidth}}function direction_ne(){var bbox=getScreenBBox();return{top:bbox.ne.y-node.offsetHeight,left:bbox.ne.x}}function direction_sw(){var bbox=getScreenBBox();return{top:bbox.sw.y,left:bbox.sw.x-node.offsetWidth}}function direction_se(){var bbox=getScreenBBox();return{top:bbox.se.y,left:bbox.e.x}}function initNode(){var node=d3.select(document.createElement("div"));node.style({position:"absolute",top:0,opacity:0,"pointer-events":"none","box-sizing":"border-box"});return node.node()}function getSVGNode(el){el=el.node();if(el.tagName.toLowerCase()==="svg"){return el}return el.ownerSVGElement}function getNodeEl(){if(node===null){node=initNode();document.body.appendChild(node)}return d3.select(node)}function getScreenBBox(){var targetel=target||d3.event.target;while("undefined"===typeof targetel.getScreenCTM&&"undefined"===targetel.parentNode){targetel=targetel.parentNode}var bbox={},matrix=targetel.getScreenCTM(),tbbox=targetel.getBBox(),width=tbbox.width,height=tbbox.height,x=tbbox.x,y=tbbox.y;point.x=x;point.y=y;bbox.nw=point.matrixTransform(matrix);point.x+=width;bbox.ne=point.matrixTransform(matrix);point.y+=height;bbox.se=point.matrixTransform(matrix);point.x-=width;bbox.sw=point.matrixTransform(matrix);point.y-=height/2;bbox.w=point.matrixTransform(matrix);point.x+=width;bbox.e=point.matrixTransform(matrix);point.x-=width/2;point.y-=height/2;bbox.n=point.matrixTransform(matrix);point.y+=height;bbox.s=point.matrixTransform(matrix);return bbox}return tip}});(function(root,factory){"use strict";if(typeof define==="function"&&define.amd){define("d3.relationshipGraph",["d3"],factory)}else if(typeof exports==="object"&&typeof module==="object"){module.exports=factory(require("d3"))}else if(typeof exports==="object"){exports.d3.relationshipGraph=factory(require("d3"))}else{root.d3.relationshipGraph=factory(root.d3)}})(this,function(d3){"use strict";var containsKey=function(obj,key){return Object.keys(obj).indexOf(key)>-1};var contains=function(arr,key){return arr.indexOf(key)>-1};var truncate=function(str,cap){if(cap===0){return str}return str.length>=cap?str.substring(0,cap)+"...":str};var noop=function(){};d3.relationshipGraph=function(){return RelationshipGraph.extend.apply(RelationshipGraph,arguments)};d3.selection.prototype.relationshipGraph=function(userConfig){return new RelationshipGraph(this,userConfig)};d3.selection.enter.prototype.relationshipGraph=function(){return this.graph};var RelationshipGraph=function(selection,userConfig){if(userConfig.thresholds===undefined||typeof userConfig.thresholds!=="object"){throw"Thresholds must be an Object."}this.config={blockSize:24,selection:selection,showTooltips:userConfig.showTooltips||true,maxChildCount:userConfig.maxChildCount||0,onClick:userConfig.onClick||noop,showKeys:userConfig.showKeys||true,thresholds:userConfig.thresholds,colors:userConfig.colors||["#c4f1be","#a2c3a4","#869d96","#525b76","#201e50","#485447","#5b7f77","#6474ad","#b9c6cb","#c0d6c1","#754668","#587d71","#4daa57","#b5dda4","#f9eccc","#0e7c7b","#17bebb","#d4f4dd","#d62246","#4b1d3f","#cf4799","#c42583","#731451","#f3d1bf","#c77745"],transitionTime:userConfig.transitionTime||1500,truncate:userConfig.truncate||25};if(this.config.thresholds.length>0&&typeof this.config.thresholds[0]=="number"){this.config.thresholds.sort()}this.ctx=document.createElement("canvas").getContext("2d");this.ctx.font="10pt Helvetica";var createTooltip=function(self){var hiddenKeys=["ROW","INDEX","COLOR","PARENTCOLOR","PARENT"],showKeys=self.config.showKeys;return d3.tip().attr("class","relationshipGraph-tip").offset([-8,-10]).html(function(obj){var keys=Object.keys(obj),table=document.createElement("table"),count=keys.length,rows=[];while(count--){var element=keys[count],upperCaseKey=element.toUpperCase();if(!contains(hiddenKeys,upperCaseKey)){var row=document.createElement("tr"),key=showKeys?document.createElement("td"):null,value=document.createElement("td");if(showKeys){key.innerHTML=element.charAt(0).toUpperCase()+element.substring(1);row.appendChild(key)}value.innerHTML=obj[element];value.style.fontWeight="normal";row.appendChild(value);rows.push(row)}}var rowCount=rows.length;while(rowCount--){table.appendChild(rows[rowCount])}self.tip.direction("n");return table.outerHTML})};if(this.config.showTooltips){this.tip=createTooltip(this)}else{this.tip=null}this.svg=this.config.selection.select("svg").select("g");if(this.svg.empty()){this.svg=this.config.selection.append("svg").attr("width","500").attr("height","500").attr("style","display: block").append("g").attr("transform","translate(10, 0)")}this.graph=this};RelationshipGraph.prototype.verifyJson=function(json){if(json===undefined||typeof JSON!=="object"||json.length===0){throw"JSON has to be a JavaScript object that is not empty."}var length=json.length;while(length--){var element=json[length],keys=Object.keys(element),keyLength=keys.length;if(element.parent===undefined){throw"Child does not have a parent."}else if(element.parentColor!==undefined&&(element.parentColor>4||element.parentColor<0)){throw"Parent color is unsupported."}while(keyLength--){if(keys[keyLength].toUpperCase()=="VALUE"){if(keys[keyLength]!="value"){json[length].value=json[length][keys[keyLength]];delete json[length][keys[keyLength]]}break}}}return true};RelationshipGraph.prototype.data=function(json){if(this.verifyJson(json)){var row=1,index=1,previousParent=null,parents=[],parentSizes={},previousParentSizes=0,_this=this,parent,i,maxWidth,maxHeight;json.sort(function(child1,child2){if(child1.parentchild2.parent){return 1}else{return 0}});for(i=0;ilongest.length){longest=current}}var longestWidth=this.ctx.measureText(longest).width,parentDiv=this.config.selection[0][0],calculatedMaxChildren=this.config.maxChildCount===0?Math.floor((parentDiv.parentElement.clientWidth-15-longestWidth)/this.config.blockSize):this.config.maxChildCount;for(i=0;i-1){previousParentSize+=Math.ceil(parentSizes[Object.keys(parentSizes)[i]]/calculatedMaxChildren)*calculatedMaxChildren;i--}return Math.ceil(previousParentSize/calculatedMaxChildren)*_this.config.blockSize}).style("fill",function(obj){return obj.parentColor!==undefined?_this.config.colors[obj.parentColor]:"#000000"});parentNodes.exit().remove();var childrenNodes=this.svg.selectAll(".relationshipGraph-block").data(json);childrenNodes.enter().append("rect").attr("x",function(obj){return longestWidth+(obj.index-1)*_this.config.blockSize}).attr("y",function(obj){return(obj.row-1)*_this.config.blockSize}).attr("rx",4).attr("ry",4).attr("class","relationshipGraph-block").attr("width",_this.config.blockSize).attr("height",_this.config.blockSize).style("fill",function(obj){return _this.config.colors[obj.color%_this.config.colors.length]||_this.config.colors[0]}).on("mouseover",_this.tip?_this.tip.show:noop).on("mouseout",_this.tip?_this.tip.hide:noop).on("click",function(obj){_this.tip.hide();_this.config.onClick(obj)});childrenNodes.transition(_this.config.transitionTime).attr("x",function(obj){return longestWidth+(obj.index-1)*_this.config.blockSize}).attr("y",function(obj){return(obj.row-1)*_this.config.blockSize}).style("fill",function(obj){return _this.config.colors[obj.color%_this.config.colors.length]||_this.config.colors[0]});childrenNodes.exit().transition(_this.config.transitionTime).remove();if(this.config.showTooltips){d3.select(".d3-tip").remove();this.svg.call(this.tip)}this.config.selection.select("svg").attr("width",maxWidth+15).attr("height",maxHeight+15)}return this};return RelationshipGraph}); -------------------------------------------------------------------------------- /dest/d3.relationshipgraph.min.css: -------------------------------------------------------------------------------- 1 | .relationshipGraph-block{stroke:#F7F7F7;stroke-width:1px;pointer-events:all}.relationshipGraph-block:hover{cursor:pointer}.relationshipGraph-Text{font-family:Helvetica,sans-serif;font-size:10pt;fill:#323232}.relationshipGraph-measurement{font-family:Helvetica,sans-serif;font-size:13px;position:absolute;width:auto;height:auto;left:-100%;top:-100%}.relationshipGraph-tip{font-weight:700;font-family:Helvetica,sans-serif;font-size:9pt;line-height:1;padding:12px;background:#323232;color:#e7e7e7;border-radius:6px;z-index:50;max-width:350px;max-height:300px}.relationshipGraph-tip:after{display:inline-block;font-size:15px;width:100%;height:5px;color:#323232;content:"\25B6";position:absolute}.relationshipGraph-tip.n:after{content:"\25BC";margin:-1px 0 0;top:100%;left:12px;text-align:center}.relationshipGraph-tip.e{margin-left:15px}.relationshipGraph-tip.e:after{content:"\25C0";margin:-2px 0 0;top:50%;left:-11px}.relationshipGraph-tip.s{margin-top:15px}.relationshipGraph-tip.s:after{content:"\25B2";margin:0 0 1px;top:-12px;left:12px;text-align:center}.relationshipGraph-tip.w:after{content:"\25B6";margin:-4px 0 0 -1px;top:50%;left:100%} -------------------------------------------------------------------------------- /dest/d3.relationshipgraph.min.js: -------------------------------------------------------------------------------- 1 | "use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}var _slicedToArray=function(){function sliceIterator(arr,i){var _arr=[],_n=!0,_d=!1,_e=void 0;try{for(var _s,_i=arr[Symbol.iterator]();!(_n=(_s=_i.next()).done)&&(_arr.push(_s.value),!i||_arr.length!==i);_n=!0);}catch(err){_d=!0,_e=err}finally{try{!_n&&_i["return"]&&_i["return"]()}finally{if(_d)throw _e}}return _arr}return function(arr,i){if(Array.isArray(arr))return arr;if(Symbol.iterator in Object(arr))return sliceIterator(arr,i);throw new TypeError("Invalid attempt to destructure non-iterable instance")}}(),_createClass=function(){function defineProperties(target,props){for(var i=0;iwindowWidth,breaksBottom=elementCoords.bottom+nodeHeight>windowHeight;for(breaksTop&&!breaksRight&&!breaksBottom&&breaksLeft?dir="e":!breaksTop||breaksRight||breaksBottom||breaksLeft?breaksTop&&breaksRight&&!breaksBottom&&!breaksLeft?dir="w":breaksTop||breaksRight||breaksBottom||!breaksLeft?!breaksTop&&!breaksRight&&breaksBottom&&breaksLeft?dir="e":breaksTop||breaksRight||!breaksBottom||breaksLeft?!breaksTop&&breaksRight&&breaksBottom&&!breaksLeft?dir="n":breaksTop||!breaksRight||breaksBottom||breaksLeft||(dir="w"):dir="e":dir="e":dir="s",direction(dir);i--;)nodel.classed(directions[i],!1);return coords=direction_callbacks.get(dir).apply(this),nodel.classed(dir,!0).style("top",coords.top+poffset[0]+scrollTop+"px").style("left",coords.left+poffset[1]+scrollLeft+"px"),tip},tip.hide=function(){var nodel=getNodeEl();return nodel.style("opacity",0).style("pointer-events","none"),tip},tip.attr=function(n){if(arguments.length<2&&"string"==typeof n)return getNodeEl().attr(n);var args=Array.prototype.slice.call(arguments);return d3.selection.prototype.attr.apply(getNodeEl(),args),tip},tip.style=function(n){if(arguments.length<2&&"string"==typeof n)return getNodeEl().style(n);var args=Array.prototype.slice.call(arguments);if(1===args.length)for(var styles=args[0],keys=Object.keys(styles),key=0;keyi;i++){var current=parents[i]+" ( "+parentSizes[parentNames[i]]+") ";current.length>longest.length&&(longest=current)}var longestWidth=this.getPixelLength(longest),parentDiv=this._d3V4?selection._groups[0][0]:selection[0][0],calculatedMaxChildren=0===configuration.maxChildCount?Math.floor((parentDiv.parentElement.clientWidth-blockSize-longestWidth)/blockSize):configuration.maxChildCount,jsonLength=json.length,thresholds=configuration.thresholds;for(i=0;jsonLength>i;i++){var element=json[i],parent=element.parent;if(null!==previousParent&&previousParent!==parent?(element.__row=row+1,element.__index=1,index=2,row++):(index===calculatedMaxChildren+1&&(index=1,row++),element.__row=row,element.__index=index,index++),previousParent=parent,0===thresholds.length)element.__color=0;else{var value=void 0,compare=void 0;if("string"==typeof thresholds[0])value=element.value,compare=RelationshipGraph.stringCompare;else{var elementValue=element.value;value="number"==typeof elementValue?elementValue:parseFloat(elementValue.replace(/[^0-9-.]+/g,"")),compare=RelationshipGraph.numericCompare}var thresholdIndex=compare(value,thresholds);element.__color=-1===thresholdIndex?0:thresholdIndex,element.__colorValue=this.configuration.colors[element.__color%this.configuration.colors.length]}element.setNodeColor=function(color){this.__node||(this.__node=document.getElementById(this.__id)),this.__node&&(this.__node.style.fill=color)},element.setNodeStrokeColor=function(color){this.__node||(this.__node=document.getElementById(this.__id)),this.__node&&(this.__node.style.strokeWidth=color?"1px":0,this.__node.style.stroke=color?color:"")},element.__id=this.getId()+"-child-node"+element.__row+"-"+element.__index}return[longestWidth,calculatedMaxChildren,row]}},{key:"createParents",value:function(parentNodes,parentSizes,longestWidth,calculatedMaxChildren){var parentSizesKeys=Object.keys(parentSizes),_this=this;parentNodes.enter().append("text").text(function(obj,index){return obj+" ("+parentSizes[parentSizesKeys[index]]+")"}).attr("x",function(obj,index){var width=_this.getPixelLength(obj+" ("+parentSizes[parentSizesKeys[index]]+")");return longestWidth-width}).attr("y",function(obj,index){if(0===index)return 0;for(var previousParentSize=0,i=index-1;i>-1;)previousParentSize+=Math.ceil(parentSizes[parentSizesKeys[i]]/calculatedMaxChildren)*calculatedMaxChildren,i--;return Math.ceil(previousParentSize/calculatedMaxChildren)*_this.configuration.blockSize+_this._spacing*index}).style("text-anchor","start").style("fill",function(obj){return void 0!==obj.parentColor?_this.configuration.colors[obj.parentColor]:"#000000"}).style("cursor",this.parentPointer?"pointer":"default").attr("class","relationshipGraph-Text").attr("transform","translate(-6, "+_this.configuration.blockSize/1.5+")").on("click",function(obj){_this.configuration.onClick.parent(obj)})}},{key:"updateParents",value:function(parentNodes,parentSizes,longestWidth,calculatedMaxChildren){var parentSizesKeys=Object.keys(parentSizes),_this=this;parentNodes.text(function(obj,index){return obj+" ("+parentSizes[parentSizesKeys[index]]+")"}).attr("x",function(obj,index){var width=_this.getPixelLength(obj+" ("+parentSizes[parentSizesKeys[index]]+")");return longestWidth-width}).attr("y",function(obj,index){if(0===index)return 0;for(var previousParentSize=0,i=index-1;i>-1;)previousParentSize+=Math.ceil(parentSizes[parentSizesKeys[i]]/calculatedMaxChildren)*calculatedMaxChildren,i--;return Math.ceil(previousParentSize/calculatedMaxChildren)*_this.configuration.blockSize+_this._spacing*index}).style("fill",function(obj){return void 0!==obj.parentColor?_this.configuration.colors[obj.parentColor]:"#000000"}).style("cursor",_this.parentPointer?"pointer":"default")}},{key:"createChildren",value:function(childrenNodes,longestWidth){var _this=this;childrenNodes.enter().append("rect").attr("id",function(obj){return obj.__id}).attr("x",function(obj){return longestWidth+(obj.__index-1)*_this.configuration.blockSize+5+(_this._spacing*obj.__index-1)}).attr("y",function(obj){return(obj.__row-1)*_this.configuration.blockSize+(_this._spacing*obj.__row-1)}).attr("rx",4).attr("ry",4).attr("class","relationshipGraph-block").attr("width",_this.configuration.blockSize).attr("height",_this.configuration.blockSize).style("fill",function(obj){return obj.__colorValue}).style("cursor",_this.childPointer?"pointer":"default").on("mouseover",_this.tooltip?_this.tooltip.show:RelationshipGraph.noop).on("mouseout",_this.tooltip?_this.tooltip.hide:RelationshipGraph.noop).on("click",function(obj){_this.tooltip.hide(),_this.configuration.onClick.child(obj)})}},{key:"updateChildren",value:function(childrenNodes,longestWidth){var blockSize=this.configuration.blockSize,_this=this;childrenNodes.transition(this.configuration.transitionTime).attr("id",function(obj){return obj.__id}).attr("x",function(obj){return longestWidth+(obj.__index-1)*blockSize+5+(_this._spacing*obj.__index-1)}).attr("y",function(obj){return(obj.__row-1)*blockSize+(_this._spacing*obj.__row-1)}).style("fill",function(obj){return obj.__colorValue})}},{key:"removeNodes",value:function(nodes){nodes.exit().transition(this.configuration.transitionTime).remove()}},{key:"data",value:function(json){if(RelationshipGraph.verifyJson(json)){var parents=[],parentSizes={},configuration=this.configuration,row=0,parent=void 0,i=void 0,maxWidth=void 0,maxHeight=void 0,calculatedMaxChildren=0,longestWidth=0;configuration.sortFunction(json),this.representation=json;var jsonLength=json.length;for(i=0;jsonLength>i;i++)parent=json[i].parent,RelationshipGraph.containsKey(parentSizes,parent)?parentSizes[parent]++:(parentSizes[parent]=1,parents.push(RelationshipGraph.truncate(parent,configuration.truncate)));var _assignIndexAndRow=this.assignIndexAndRow(json,parentSizes,parents),_assignIndexAndRow2=_slicedToArray(_assignIndexAndRow,3);for(longestWidth=_assignIndexAndRow2[0],calculatedMaxChildren=_assignIndexAndRow2[1],row=_assignIndexAndRow2[2],maxHeight=row*configuration.blockSize,maxWidth=longestWidth+calculatedMaxChildren*configuration.blockSize,maxWidth+=this._spacing*calculatedMaxChildren,i=0;row>i;i++)maxHeight+=this._spacing*i;var parentNodes=this.svg.selectAll(".relationshipGraph-Text").data(parents);this.createParents(parentNodes,parentSizes,longestWidth,calculatedMaxChildren),this.updateParents(parentNodes,parentSizes,longestWidth,calculatedMaxChildren),this.removeNodes(parentNodes);var childrenNodes=this.svg.selectAll(".relationshipGraph-block").data(json);this.createChildren(childrenNodes,longestWidth),this.updateChildren(childrenNodes,longestWidth),this.removeNodes(childrenNodes),this.configuration.showTooltips&&(d3.select(".d3-tip").remove(),this.svg.call(this.tooltip)),this.configuration.selection.select("svg").attr("width",Math.abs(maxWidth+15)).attr("height",Math.abs(maxHeight+15))}return this}},{key:"search",value:function(query){var results=[],queryKeys=Object.keys(query),queryKeysLength=queryKeys.length;if(this.representation&&query)for(var length=this.representation.length,i=0;length>i;i++){for(var currentObject=this.representation[i],isMatch=!1,j=0;queryKeysLength>j;j++){var queryVal=query[queryKeys[j]];if(!(isMatch=currentObject[queryKeys[j]]==queryVal))break}isMatch&&results.push(currentObject)}return results}}],[{key:"getColors",value:function(){return["#c4f1be","#a2c3a4","#869d96","#525b76","#201e50","#485447","#5b7f77","#6474ad","#b9c6cb","#c0d6c1","#754668","#587d71","#4daa57","#b5dda4","#f9eccc","#0e7c7b","#17bebb","#d4f4dd","#d62246","#4b1d3f","#cf4799","#c42583","#731451","#f3d1bf","#c77745"]}},{key:"containsKey",value:function(obj,key){return Object.keys(obj).indexOf(key)>-1}},{key:"contains",value:function(arr,key){return arr.indexOf(key)>-1}},{key:"truncate",value:function(str,cap){return cap&&str&&str.length>cap?str.substring(0,cap)+"...":str}},{key:"isArray",value:function(arr){return"[object Array]"==Object.prototype.toString.call(arr)}},{key:"noop",value:function(){}},{key:"sortJson",value:function(json){json.sort(function(child1,child2){var parent1=child1.parent.toLowerCase(),parent2=child2.parent.toLowerCase();return parent1>parent2?1:parent2>parent1?-1:0})}},{key:"stringCompare",value:function(value,thresholds){if("string"!=typeof value)throw"Cannot make value comparison between a string and a "+("undefined"==typeof value?"undefined":_typeof(value))+".";if(!thresholds||!thresholds.length)throw"Cannot find correct threshold because there are no thresholds.";for(var thresholdsLength=thresholds.length,i=0;thresholdsLength>i;i++)if(value==thresholds[i])return i;return-1}},{key:"numericCompare",value:function(value,thresholds){if("number"!=typeof value)throw"Cannot make value comparison between a number and a "+("undefined"==typeof value?"undefined":_typeof(value))+".";if(!thresholds||!thresholds.length)throw"Cannot find correct threshold because there are no thresholds.";for(var length=thresholds.length,i=0;length>i;i++)if(value<=thresholds[i])return i;return-1}},{key:"verifyJson",value:function(json){if(!RelationshipGraph.isArray(json)||json.length<0||"object"!==_typeof(json[0]))throw"JSON has to be an Array of JavaScript objects that is not empty.";for(var length=json.length;length--;){var element=json[length],keys=Object.keys(element),keyLength=keys.length,parentColor=element.parentColor;if(void 0===element.parent)throw"Child does not have a parent.";if(void 0!==parentColor&&(parentColor>4||0>parentColor))throw"Parent color is unsupported.";for(;keyLength--;)if("VALUE"==keys[keyLength].toUpperCase()){"value"!=keys[keyLength]&&(json[length].value=json[length][keys[keyLength]],delete json[length][keys[keyLength]]);break}}return!0}}]),RelationshipGraph}();d3.relationshipGraph=function(){return RelationshipGraph.extend.apply(RelationshipGraph,arguments)},d3.selection.prototype.relationshipGraph=function(userConfig){return new RelationshipGraph(this,userConfig)}; -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | d3.relationshipgraph Example 6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | Highest Grossing Films by Studio 14 |

15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 |
24 | Source: https://en.wikipedia.org/wiki/List_of_highest-grossing_films 25 |
26 | 639 | 640 | 641 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-relationshipgraph", 3 | "description": "A D3 graph to show parent child relationships.", 4 | "homepage": "http://github.com/hkelly93/d3-relationshipgraph", 5 | "author": "Harrison Kelly 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 31 | 202 | 215 | 216 | -------------------------------------------------------------------------------- /src/RelationshipGraph.css: -------------------------------------------------------------------------------- 1 | .relationshipGraph-block { 2 | stroke: #F7F7F7; 3 | stroke-width: 1px; 4 | pointer-events: all; } 5 | 6 | .relationshipGraph-block:hover { 7 | cursor: pointer; } 8 | 9 | .relationshipGraph-Text { 10 | font-family: Helvetica, sans-serif; 11 | font-size: 10pt; 12 | fill: #323232; } 13 | 14 | .relationshipGraph-measurement { 15 | font-family: Helvetica, sans-serif; 16 | font-size: 13px; 17 | position: absolute; 18 | width: auto; 19 | height: auto; 20 | left: -100%; 21 | top: -100%; } 22 | 23 | .relationshipGraph-tip { 24 | font-weight: bold; 25 | font-family: Helvetica, sans-serif; 26 | font-size: 9pt; 27 | line-height: 1; 28 | padding: 12px; 29 | background: #323232; 30 | color: #e7e7e7; 31 | border-radius: 6px; 32 | z-index: 50; 33 | max-width: 350px; 34 | max-height: 300px; } 35 | 36 | .relationshipGraph-tip:after { 37 | display: inline-block; 38 | font-size: 15px; 39 | width: 100%; 40 | height: 5px; 41 | color: #323232; 42 | content: "\25B6"; 43 | position: absolute; } 44 | 45 | .relationshipGraph-tip.n:after { 46 | content: "\25BC"; 47 | margin: -1px 0 0 0; 48 | top: 100%; 49 | left: 12px; 50 | text-align: center; } 51 | 52 | .relationshipGraph-tip.e { 53 | margin-left: 15px; } 54 | 55 | .relationshipGraph-tip.e:after { 56 | content: "\25C0"; 57 | margin: -2px 0 0 0; 58 | top: 50%; 59 | left: -11px; } 60 | 61 | .relationshipGraph-tip.s { 62 | margin-top: 15px; } 63 | 64 | .relationshipGraph-tip.s:after { 65 | content: "\25B2"; 66 | margin: 0 0 1px 0; 67 | top: -12px; 68 | left: 12px; 69 | text-align: center; } 70 | 71 | .relationshipGraph-tip.w:after { 72 | content: "\25B6"; 73 | margin: -4px 0 0 -1px; 74 | top: 50%; 75 | left: 100%; } 76 | 77 | /*# sourceMappingURL=RelationshipGraph.css.map */ 78 | -------------------------------------------------------------------------------- /src/RelationshipGraph.scss: -------------------------------------------------------------------------------- 1 | $font-stack: Helvetica, sans-serif; 2 | $tooltip-gray: #323232; 3 | 4 | .relationshipGraph-block { 5 | stroke: #F7F7F7; 6 | stroke-width: 1px; 7 | pointer-events: all; 8 | } 9 | 10 | .relationshipGraph-block:hover { 11 | cursor: pointer; 12 | } 13 | 14 | .relationshipGraph-Text { 15 | font-family: $font-stack; 16 | font-size: 10pt; 17 | fill: $tooltip-gray; 18 | } 19 | 20 | .relationshipGraph-measurement { 21 | font-family: $font-stack; 22 | font-size: 13px; 23 | position: absolute; 24 | width: auto; 25 | height: auto; 26 | left: -100%; 27 | top: -100%; 28 | } 29 | 30 | .relationshipGraph-tip { 31 | font-weight: bold; 32 | font-family: $font-stack; 33 | font-size: 9pt; 34 | line-height: 1; 35 | padding: 12px; 36 | background: $tooltip-gray; 37 | color: #e7e7e7; 38 | border-radius: 6px; 39 | z-index: 50; 40 | max-width: 350px; 41 | max-height: 300px; 42 | } 43 | 44 | .relationshipGraph-tip:after { 45 | display: inline-block; 46 | font-size: 15px; 47 | width: 100%; 48 | height: 5px; 49 | color: $tooltip-gray; 50 | content: "\25B6"; 51 | position: absolute; 52 | } 53 | 54 | .relationshipGraph-tip.n:after { 55 | content: "\25BC"; 56 | margin: -1px 0 0 0; 57 | top: 100%; 58 | left: 12px; 59 | text-align: center; 60 | } 61 | 62 | .relationshipGraph-tip.e { 63 | margin-left: 15px; 64 | } 65 | 66 | .relationshipGraph-tip.e:after { 67 | content: "\25C0"; 68 | margin: -2px 0 0 0; 69 | top: 50%; 70 | left: -11px; 71 | } 72 | 73 | .relationshipGraph-tip.s { 74 | margin-top: 15px; 75 | } 76 | 77 | .relationshipGraph-tip.s:after { 78 | content: "\25B2"; 79 | margin: 0 0 1px 0; 80 | top: -12px; 81 | left: 12px; 82 | text-align: center; 83 | } 84 | 85 | .relationshipGraph-tip.w:after { 86 | content: "\25B6"; 87 | margin: -4px 0 0 -1px; 88 | top: 50%; 89 | left: 100%; 90 | } 91 | -------------------------------------------------------------------------------- /src/d3-tip.js: -------------------------------------------------------------------------------- 1 | // d3.tip 2 | // Copyright (c) 2013 Justin Palmer 3 | // 4 | // Tooltips for d3.js SVG visualizations 5 | // 6 | // Updated by Harrison kelly. 7 | 8 | /* global define, module, SVGElement */ 9 | (function (root, factory) { 10 | 'use strict'; 11 | 12 | if (typeof define === 'function' && define.amd) { 13 | // AMD. Register as an anonymous module with d3 as a dependency. 14 | define(['d3'], factory); 15 | } else if (typeof module === 'object' && module.exports) { 16 | // CommonJS 17 | module.exports = function (d3) { 18 | d3.tip = factory(d3); 19 | return d3.tip; 20 | }; 21 | } else { 22 | // Browser global. 23 | window.d3.tip = factory(d3); 24 | } 25 | }(this, function (d3) { 26 | 'use strict'; 27 | 28 | /** 29 | * Constructs a new tooltip 30 | * 31 | * @return a tip. 32 | * @public 33 | */ 34 | return () => { 35 | const d3TipDirection = () => { 36 | return 'n'; 37 | }; 38 | 39 | const d3TipOffset = () => { 40 | return [0, 0]; 41 | }; 42 | 43 | const d3TipHtml = () => { 44 | return ' '; 45 | }; 46 | 47 | const initNode = () => { 48 | const node = d3.select(document.createElement('div')); 49 | node 50 | .style('position', 'absolute') 51 | .style('top', 0) 52 | .style('opacity', 0) 53 | .style('pointer-events', 'none') 54 | .style('box-sizing', 'border-box'); 55 | 56 | return node.node(); 57 | }; 58 | 59 | const getNodeEl = () => { 60 | if (node === null) { 61 | node = initNode(); 62 | // re-add node to DOM 63 | document.body.appendChild(node); 64 | } 65 | 66 | return d3.select(node); 67 | }; 68 | 69 | /** 70 | * Given a shape on the screen, will return an SVGPoint for the directions: 71 | * north, south, east, west, northeast, southeast, northwest, southwest 72 | * 73 | * +-+-+ 74 | * | | 75 | * + | 76 | * | | 77 | * +-+-+ 78 | * 79 | * @returns {{}} an Object {n, s, e, w, nw, sw, ne, se} 80 | * @private 81 | */ 82 | const getScreenBBox = () => { 83 | let targetel = target || d3.event.target; 84 | 85 | while ('undefined' === typeof targetel.getScreenCTM && 'undefined' === targetel.parentNode) { 86 | targetel = targetel.parentNode; 87 | } 88 | 89 | const bbox = {}, 90 | matrix = targetel.getScreenCTM(), 91 | tbbox = targetel.getBBox(), 92 | width = tbbox.width, 93 | height = tbbox.height, 94 | x = tbbox.x, 95 | y = tbbox.y; 96 | 97 | point.x = x; 98 | point.y = y; 99 | bbox.nw = point.matrixTransform(matrix); 100 | point.x += width; 101 | bbox.ne = point.matrixTransform(matrix); 102 | point.y += height; 103 | bbox.se = point.matrixTransform(matrix); 104 | point.x -= width; 105 | bbox.sw = point.matrixTransform(matrix); 106 | point.y -= height / 2; 107 | bbox.w = point.matrixTransform(matrix); 108 | point.x += width; 109 | bbox.e = point.matrixTransform(matrix); 110 | point.x -= width / 2; 111 | point.y -= height / 2; 112 | bbox.n = point.matrixTransform(matrix); 113 | point.y += height; 114 | bbox.s = point.matrixTransform(matrix); 115 | 116 | return bbox; 117 | }; 118 | 119 | var direction = d3TipDirection, 120 | offset = d3TipOffset, 121 | html = d3TipHtml, 122 | node = initNode(), 123 | svg = null, 124 | point = null, 125 | target = null; 126 | 127 | /** 128 | * http://stackoverflow.com/a/7611054 129 | * @param el 130 | * @returns {{left: number, top: number}} 131 | */ 132 | const getPageTopLeft = (el) => { 133 | const rect = el.getBoundingClientRect(), 134 | docEl = document.documentElement; 135 | 136 | return { 137 | top: rect.top + (window.pageYOffset || docEl.scrollTop || 0), 138 | right: rect.right + (window.pageXOffset || 0), 139 | bottom: rect.bottom + (window.pageYOffset || 0), 140 | left: rect.left + (window.pageXOffset || docEl.scrollLeft || 0) 141 | }; 142 | }; 143 | 144 | const functor = (val) => { 145 | return typeof val === 'function' ? val : () => { 146 | return val; 147 | }; 148 | }; 149 | 150 | const directionN = () => { 151 | const bbox = getScreenBBox(); 152 | return { 153 | top: bbox.n.y - node.offsetHeight, 154 | left: bbox.n.x - node.offsetWidth / 2 155 | }; 156 | }; 157 | 158 | const directionS = () => { 159 | const bbox = getScreenBBox(); 160 | return { 161 | top: bbox.s.y, 162 | left: bbox.s.x - node.offsetWidth / 2 163 | }; 164 | }; 165 | 166 | const directionE = () => { 167 | const bbox = getScreenBBox(); 168 | return { 169 | top: bbox.e.y - node.offsetHeight / 2, 170 | left: bbox.e.x 171 | }; 172 | }; 173 | 174 | const directionW = () => { 175 | const bbox = getScreenBBox(); 176 | return { 177 | top: bbox.w.y - node.offsetHeight / 2, 178 | left: bbox.w.x - node.offsetWidth 179 | }; 180 | }; 181 | 182 | const directionNW = () => { 183 | const bbox = getScreenBBox(); 184 | return { 185 | top: bbox.nw.y - node.offsetHeight, 186 | left: bbox.nw.x - node.offsetWidth 187 | }; 188 | }; 189 | 190 | const directionNE = () => { 191 | const bbox = getScreenBBox(); 192 | return { 193 | top: bbox.ne.y - node.offsetHeight, 194 | left: bbox.ne.x 195 | }; 196 | }; 197 | 198 | const directionSW = () => { 199 | const bbox = getScreenBBox(); 200 | return { 201 | top: bbox.sw.y, 202 | left: bbox.sw.x - node.offsetWidth 203 | }; 204 | }; 205 | 206 | const directionSE = () => { 207 | const bbox = getScreenBBox(); 208 | return { 209 | top: bbox.se.y, 210 | left: bbox.e.x 211 | }; 212 | }; 213 | 214 | const direction_callbacks = d3.map({ 215 | n: directionN, 216 | s: directionS, 217 | e: directionE, 218 | w: directionW, 219 | nw: directionNW, 220 | ne: directionNE, 221 | sw: directionSW, 222 | se: directionSE 223 | }), 224 | 225 | directions = direction_callbacks.keys(); 226 | 227 | const getSVGNode = (el) => { 228 | el = el.node(); 229 | 230 | if (el.tagName.toLowerCase() === 'svg') { 231 | return el; 232 | } 233 | 234 | return el.ownerSVGElement; 235 | }; 236 | 237 | const tip = (vis) => { 238 | svg = getSVGNode(vis); 239 | point = svg.createSVGPoint(); 240 | document.body.appendChild(node); 241 | }; 242 | 243 | /** 244 | * Show the tooltip on the screen. 245 | * 246 | * @returns {function()} a tip 247 | * @public 248 | */ 249 | tip.show = function () { 250 | const args = Array.prototype.slice.call(arguments); 251 | if (args[args.length - 1] instanceof SVGElement) { 252 | target = args.pop(); 253 | } 254 | 255 | const content = html.apply(this, args), 256 | poffset = offset.apply(this, args), 257 | nodel = getNodeEl(), 258 | scrollTop = document.documentElement.scrollTop || document.body.scrollTop, 259 | scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; 260 | 261 | let coords, 262 | dir = direction.apply(this, args), 263 | i = directions.length; 264 | 265 | nodel.html(content) 266 | .style('position', 'absolute') 267 | .style('opacity', 1) 268 | .style('pointer-events', 'all'); 269 | 270 | // Figure out the correct direction. 271 | const node = nodel._groups ? nodel._groups[0][0] : nodel[0][0], 272 | nodeWidth = node.clientWidth, 273 | nodeHeight = node.clientHeight, 274 | windowWidth = window.innerWidth, 275 | windowHeight = window.innerHeight, 276 | elementCoords = getPageTopLeft(this), 277 | breaksTop = (elementCoords.top - nodeHeight < 0), 278 | breaksLeft = (elementCoords.left - nodeWidth < 0), 279 | breaksRight = (elementCoords.right + nodeHeight > windowWidth), 280 | breaksBottom = (elementCoords.bottom + nodeHeight > windowHeight); 281 | 282 | if (breaksTop && !breaksRight && !breaksBottom && breaksLeft) { // Case 1: NW 283 | dir = 'e'; 284 | } else if (breaksTop && !breaksRight && !breaksBottom && !breaksLeft) { // Case 2: N 285 | dir = 's'; 286 | } else if (breaksTop && breaksRight && !breaksBottom && !breaksLeft) { // Case 3: NE 287 | dir = 'w'; 288 | } else if (!breaksTop && !breaksRight && !breaksBottom && breaksLeft) { // Case 4: W 289 | dir = 'e'; 290 | } else if (!breaksTop && !breaksRight && breaksBottom && breaksLeft) { // Case 5: SW 291 | dir = 'e'; 292 | } else if (!breaksTop && !breaksRight && breaksBottom && !breaksLeft) { // Case 6: S 293 | dir = 'e'; 294 | } else if (!breaksTop && breaksRight && breaksBottom && !breaksLeft) { // Case 7: SE 295 | dir = 'n'; 296 | } else if (!breaksTop && breaksRight && !breaksBottom && !breaksLeft) { // Case 8: E 297 | dir = 'w'; 298 | } 299 | 300 | direction(dir); 301 | 302 | while (i--) { 303 | nodel.classed(directions[i], false); 304 | } 305 | 306 | coords = direction_callbacks.get(dir).apply(this); 307 | nodel.classed(dir, true) 308 | .style('top', (coords.top + poffset[0]) + scrollTop + 'px') 309 | .style('left', (coords.left + poffset[1]) + scrollLeft + 'px'); 310 | 311 | return tip; 312 | }; 313 | 314 | /** 315 | * Hide the tooltip 316 | * 317 | * @returns {function()} a tup 318 | * @public 319 | */ 320 | tip.hide = function () { 321 | const nodel = getNodeEl(); 322 | 323 | nodel 324 | .style('opacity', 0) 325 | .style('pointer-events', 'none'); 326 | 327 | return tip; 328 | }; 329 | 330 | /** 331 | * Proxy attr calls to the d3 tip container. Sets or gets attribute value. 332 | * 333 | * @param n {String} The name of the attribute 334 | * @returns {*} The tip or attribute value 335 | * @public 336 | */ 337 | tip.attr = function (n) { 338 | if (arguments.length < 2 && typeof n === 'string') { 339 | return getNodeEl().attr(n); 340 | } else { 341 | const args = Array.prototype.slice.call(arguments); 342 | d3.selection.prototype.attr.apply(getNodeEl(), args); 343 | } 344 | 345 | return tip; 346 | }; 347 | 348 | /** 349 | * Proxy style calls to the d3 tip container. Sets or gets a style value. 350 | * 351 | * @param n {String} name of the property. 352 | * @returns {*} The tip or style property value 353 | * @public 354 | */ 355 | tip.style = function (n) { 356 | if (arguments.length < 2 && typeof n === 'string') { 357 | return getNodeEl().style(n); 358 | } else { 359 | const args = Array.prototype.slice.call(arguments); 360 | 361 | if (args.length === 1) { 362 | const styles = args[0], 363 | keys = Object.keys(styles); 364 | 365 | for (let key = 0; key < keys.length; key++) { 366 | d3.selection.prototype.style.apply(getNodeEl(), styles[key]); 367 | } 368 | } 369 | } 370 | 371 | return tip; 372 | }; 373 | 374 | /** 375 | * Set or get the direction of the tooltip 376 | * 377 | * @param v {String} One of: n, s, e, w, nw, sw, ne, se. 378 | * @returns {function()} The tip or the direction 379 | * @public 380 | */ 381 | tip.direction = function (v) { 382 | if (!arguments.length) { 383 | return direction; 384 | } 385 | 386 | direction = v == null ? v : functor(v); 387 | 388 | return tip; 389 | }; 390 | 391 | /** 392 | * Sets or gets the offset of the tip 393 | * 394 | * @param v {Array} Array of [x, y,] offset 395 | * @returns {function()} The offset or the tip. 396 | * @public 397 | */ 398 | tip.offset = function (v) { 399 | if (!arguments.length) { 400 | return offset; 401 | } 402 | 403 | offset = v == null ? v : functor(v); 404 | 405 | return tip; 406 | }; 407 | 408 | /** 409 | * Sets or gets the html value of the tooltip. 410 | * 411 | * @param v {String} Thes tring value of the tip 412 | * @returns {function()} The html value or the tip. 413 | * @public 414 | */ 415 | tip.html = function (v) { 416 | if (!arguments.length) { 417 | return html; 418 | } 419 | 420 | html = v == null ? v : functor(v); 421 | 422 | return tip; 423 | }; 424 | 425 | /** 426 | * Destroys the tooltip and removes it from the DOM. 427 | * 428 | * @returns {function()} A tip. 429 | * @public 430 | */ 431 | tip.destroy = function () { 432 | if (node) { 433 | getNodeEl().remove(); 434 | node = null; 435 | } 436 | 437 | return tip; 438 | }; 439 | 440 | return tip; 441 | }; 442 | 443 | })); 444 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MIT License (MIT). 3 | * 4 | * Copyright (c) 2016 Harrison Kelly. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | * 24 | * D3-relationshipgraph - 2.0.0 25 | */ 26 | 27 | class RelationshipGraph { 28 | 29 | /** 30 | * 31 | * @param {d3.selection} selection The ID of the element containing the graph. 32 | * @param {Object} userConfig Configuration for graph. 33 | * @constructor 34 | */ 35 | constructor(selection, userConfig = {showTooltips: true, maxChildCount: 0, thresholds: []}) { 36 | // Verify that the user config contains the thresholds. 37 | if (!userConfig.thresholds) { 38 | userConfig.thresholds = []; 39 | } else if (typeof userConfig.thresholds !== 'object') { 40 | throw 'Thresholds must be an Object.'; 41 | } 42 | 43 | if (userConfig.onClick !== undefined) { 44 | this.parentPointer = userConfig.onClick.parent !== undefined; 45 | this.childPointer = userConfig.onClick.child !== undefined; 46 | } else { 47 | this.parentPointer = false; 48 | this.childPointer = false; 49 | } 50 | 51 | const defaultOnClick = {parent: RelationshipGraph.noop, child: RelationshipGraph.noop}; 52 | 53 | /** 54 | * Contains the configuration for the graph. 55 | * 56 | * @type {{blockSize: number, selection: d3.selection, showTooltips: boolean, maxChildCount: number, 57 | * onClick: (RelationshipGraph.noop|*), showKeys: (*|boolean), thresholds: Array, 58 | * colors: (*|Array|boolean|string[]), transitionTime: (*|number), 59 | * truncate: (RelationshipGraph.truncate|*|number)}} 60 | */ 61 | this.configuration = { 62 | blockSize: 24, // The block size for each child. 63 | selection, // The ID for the graph. 64 | showTooltips: userConfig.showTooltips, // Whether or not to show the tooltips on hover. 65 | maxChildCount: userConfig.maxChildCount || 0, // The maximum amount of children to show per row. 66 | onClick: userConfig.onClick || defaultOnClick, // The callback function to call. 67 | showKeys: userConfig.showKeys, // Whether or not to show the keys in the tooltip. 68 | thresholds: userConfig.thresholds, // Thresholds to determine the colors of the child blocks with. 69 | colors: userConfig.colors || RelationshipGraph.getColors(), // Colors to use for blocks. 70 | transitionTime: userConfig.transitionTime || 1500, // Time for a transition to start and complete. 71 | truncate: userConfig.truncate || 0, // Maximum length of a parent label. Use 0 to turn off truncation. 72 | sortFunction: userConfig.sortFunction || RelationshipGraph.sortJson, // A custom sort function 73 | valueKeyName: userConfig.valueKeyName // Set a custom key in the tooltip. 74 | }; 75 | 76 | // Make sure that the colors are in the correct format. 77 | for (let i = 0; i < this.configuration.colors.length; i++) { 78 | let color = this.configuration.colors[i]; 79 | 80 | if (color.indexOf('#') < 0 && typeof color === 'string' && color.length === 6 && 81 | !isNaN(parseInt(color, 16))) { 82 | color = '#' + color; 83 | this.configuration.colors[i] = color; 84 | } 85 | } 86 | 87 | // TODO: Find a better way to handles these. 88 | if (this.configuration.showTooltips === undefined) { 89 | this.configuration.showTooltips = true; 90 | } 91 | 92 | if (this.configuration.showKeys === undefined) { 93 | this.configuration.showKeys = true; 94 | } 95 | 96 | if (this.configuration.keyValueName === undefined) { 97 | this.configuration.keyValueName = 'value'; 98 | } 99 | 100 | // If the threshold array is made up of numbers, make sure that it is sorted. 101 | if (this.configuration.thresholds.length && (typeof this.configuration.thresholds[0]) == 'number') { 102 | this.configuration.thresholds.sort(function(a, b) 103 | { 104 | return a - b; 105 | }); 106 | } 107 | 108 | /** 109 | * Used for measuring text widths. 110 | * @type {Element} 111 | */ 112 | this.measurementDiv = document.createElement('div'); 113 | this.measurementDiv.className = 'relationshipGraph-measurement'; 114 | document.body.appendChild(this.measurementDiv); 115 | 116 | /** 117 | * Used for caching measurements. 118 | * @type {{}} 119 | */ 120 | this.measuredCache = {}; 121 | 122 | /** 123 | * Represents the current data that is shown by the graph. 124 | * @type {Array} 125 | */ 126 | this.representation = []; 127 | 128 | /** 129 | * The _spacing (in pixels) between child nodes. 130 | * @type {number} 131 | */ 132 | this._spacing = 1; 133 | 134 | /** 135 | * Used to determine whether or not d3 V3 or V4 is being used. 136 | * 137 | * @type {boolean} 138 | * @private 139 | */ 140 | this._d3V4 = !!this.configuration.selection._groups; 141 | 142 | /** 143 | * Function to create the tooltip. 144 | * 145 | * @param {RelationshipGraph} self The RelationshipGraph instance. 146 | * @returns {function} the tip object. 147 | */ 148 | const createTooltip = self => { 149 | let hiddenKeys = ['_PRIVATE_', 'PARENT', 'PARENTCOLOR', 'SETNODECOLOR', 'SETNODESTROKECOLOR'], 150 | showKeys = self.configuration.showKeys; 151 | 152 | return d3.tip().attr('class', 'relationshipGraph-tip') 153 | .offset([-8, -10]) 154 | .html(function(obj) { 155 | let keys = Object.keys(obj), 156 | table = document.createElement('table'), 157 | count = keys.length, 158 | rows = []; 159 | 160 | // Loop through the keys in the object and only show values self are not in the hiddenKeys array. 161 | while (count--) { 162 | let element = keys[count], 163 | upperCaseKey = element.toUpperCase(); 164 | 165 | if (!RelationshipGraph.contains(hiddenKeys, upperCaseKey) && !upperCaseKey.startsWith('__')) { 166 | let row = document.createElement('tr'), 167 | key = showKeys ? document.createElement('td') : null, 168 | value = document.createElement('td'); 169 | 170 | if (showKeys) { 171 | key.innerHTML = element.charAt(0).toUpperCase() + element.substring(1); 172 | row.appendChild(key); 173 | } 174 | 175 | if (upperCaseKey == 'VALUE' && !self.configuration.valueKeyName) { 176 | continue; 177 | } 178 | 179 | value.innerHTML = obj[element]; 180 | value.style.fontWeight = 'normal'; 181 | 182 | row.appendChild(value); 183 | rows.push(row); 184 | } 185 | 186 | } 187 | 188 | let rowCount = rows.length; 189 | 190 | while (rowCount--) { 191 | table.appendChild(rows[rowCount]); 192 | } 193 | 194 | return table.outerHTML; 195 | }); 196 | }; 197 | 198 | if (this.configuration.showTooltips) { 199 | this.tooltip = createTooltip(this); 200 | this.tooltip.direction('n'); 201 | } else { 202 | this.tooltip = null; 203 | } 204 | 205 | // Check if this selection already has a graph. 206 | this.svg = this.configuration.selection.select('svg').select('g'); 207 | 208 | if (this.svg.empty()) { 209 | // Create the svg element that will contain the graph. 210 | this.svg = this.configuration.selection 211 | .append('svg') 212 | .attr('width', '500') 213 | .attr('height', '500') 214 | .attr('style', 'display: block') 215 | .append('g') 216 | .attr('transform', 'translate(10, 0)'); 217 | } 218 | 219 | this.graph = this; 220 | } 221 | 222 | /** 223 | * Generate the basic set of colors. 224 | * 225 | * @returns {string[]} List of HEX colors. 226 | * @private 227 | */ 228 | static getColors() { 229 | return ['#c4f1be', '#a2c3a4', '#869d96', '#525b76', '#201e50', '#485447', '#5b7f77', '#6474ad', '#b9c6cb', 230 | '#c0d6c1', '#754668', '#587d71', '#4daa57', '#b5dda4', '#f9eccc', '#0e7c7b', '#17bebb', '#d4f4dd', 231 | '#d62246', '#4b1d3f', '#cf4799', '#c42583', '#731451', '#f3d1bf', '#c77745' 232 | ]; 233 | } 234 | 235 | /** 236 | * Checks if the object contains the key. 237 | * 238 | * @param {object} obj The object to check in. 239 | * @param {string} key They key to check for. 240 | * @returns {boolean} Whether or not the object contains the key. 241 | * @private 242 | */ 243 | static containsKey(obj, key) { 244 | return Object.keys(obj).indexOf(key) > -1; 245 | } 246 | 247 | /** 248 | * Checks whether or not the key is in the array. 249 | * 250 | * @param {*[]} arr The array to check in. 251 | * @param {string} key The key to check for. 252 | * @returns {boolean} Whether or not the key exists in the array. 253 | * @private 254 | */ 255 | static contains(arr, key) { 256 | return arr.indexOf(key) > -1; 257 | } 258 | 259 | /** 260 | * Truncate a string to 25 characters plus an ellipses. 261 | * 262 | * @param {string} str The string to truncate. 263 | * @param {number} cap The number to cap the string at before it gets truncated. 264 | * @returns {string} The string truncated (if necessary). 265 | * @private 266 | */ 267 | static truncate(str, cap) { 268 | if (!cap || !str) { 269 | return str; 270 | } 271 | 272 | return (str.length > cap) ? str.substring(0, cap) + '...' : str; 273 | } 274 | 275 | /** 276 | * Determines if the array passed in is an Array object. 277 | * 278 | * @param arr {Array} The array object to check. 279 | * @returns {boolean} Whether or not the array is actually an array object. 280 | */ 281 | static isArray(arr) { 282 | return Object.prototype.toString.call(arr) == '[object Array]'; 283 | } 284 | 285 | /** 286 | * Noop function. 287 | * 288 | * @private 289 | */ 290 | static noop() { } 291 | 292 | /** 293 | * Sorts the array of JSON by parent name. This method is case insensitive. 294 | * 295 | * @param json {Array} The Array to be sorted. 296 | */ 297 | static sortJson(json) { 298 | json.sort(function(child1, child2) { 299 | const parent1 = child1.parent.toLowerCase(), 300 | parent2 = child2.parent.toLowerCase(); 301 | 302 | return (parent1 > parent2) ? 1 : (parent1 < parent2) ? -1 : 0; 303 | }); 304 | } 305 | 306 | /** 307 | * Go through all of the thresholds and find the one that is equal to the value. 308 | * 309 | * @param {String} value The value from the JSON. 310 | * @param {Array} thresholds The thresholds from the JSON. 311 | * @returns {number} The index of the threshold that is equal to the value or -1 if the value doesn't equal any 312 | * thresholds. 313 | * @private 314 | */ 315 | static stringCompare(value, thresholds) { 316 | if (typeof value !== 'string') { 317 | throw 'Cannot make value comparison between a string and a ' + (typeof value) + '.'; 318 | } 319 | 320 | if (!thresholds || !thresholds.length) { 321 | throw 'Cannot find correct threshold because there are no thresholds.'; 322 | } 323 | 324 | const thresholdsLength = thresholds.length; 325 | 326 | for (let i = 0; i < thresholdsLength; i++) { 327 | if (value == thresholds[i]) { 328 | return i; 329 | } 330 | } 331 | 332 | return -1; 333 | } 334 | 335 | /** 336 | * Go through all of the thresholds and find the smallest number that is greater than the value. 337 | * 338 | * @param {number} value The value from the JSON. 339 | * @param {Array} thresholds The thresholds from the JSON. 340 | * @returns {number} The index of the threshold that is the smallest number that is greater than the value or -1 if 341 | * the value isn't between any thresholds. 342 | * @private 343 | */ 344 | static numericCompare(value, thresholds) { 345 | if (typeof value !== 'number') { 346 | throw 'Cannot make value comparison between a number and a ' + (typeof value) + '.'; 347 | } 348 | 349 | if (!thresholds || !thresholds.length) { 350 | throw 'Cannot find correct threshold because there are no thresholds.'; 351 | } 352 | 353 | const length = thresholds.length; 354 | 355 | for (let i = 0; i < length; i++) { 356 | if (value <= thresholds[i]) { 357 | return i; 358 | } 359 | } 360 | 361 | return -1; 362 | } 363 | 364 | /** 365 | * Return the ID of the selection. 366 | * 367 | * @returns {string} The ID of the selection. 368 | * @private 369 | */ 370 | getId() { 371 | const selection = this.configuration.selection, 372 | parent = this._d3V4 ? selection._groups[0][0] : selection[0][0]; 373 | 374 | return parent.id; 375 | } 376 | 377 | /** 378 | * Returns the pixel length of the string based on the font size. 379 | * 380 | * @param {string} str The string to get the length of. 381 | * @returns {Number} The pixel length of the string. 382 | * @public 383 | */ 384 | getPixelLength(str) { 385 | if (RelationshipGraph.containsKey(this.measuredCache, str)) { 386 | return this.measuredCache[str]; 387 | } 388 | 389 | const text = document.createTextNode(str); 390 | this.measurementDiv.appendChild(text); 391 | 392 | const width = this.measurementDiv.offsetWidth; 393 | this.measurementDiv.removeChild(text); 394 | 395 | this.measuredCache[str] = width; 396 | 397 | return width; 398 | } 399 | 400 | /** 401 | * Assign the index and row to each of the children in the Array of Objects. 402 | * 403 | * @param json {Array} The array of Objects to loop through. 404 | * @param parentSizes {Object} The parent sizes determined. 405 | * @param parents {Array} The parent label names. 406 | * @returns {Array} Object containing the longest width, the calculated max children per row, and the maximum amount 407 | * of rows. 408 | */ 409 | assignIndexAndRow(json, parentSizes, parents) { 410 | // Determine the longest parent name to calculate how far from the left the child blocks should start. 411 | let longest = '', 412 | parentNames = Object.keys(parentSizes), 413 | i, 414 | index = 0, 415 | row = 0, 416 | previousParent = '', 417 | parentLength = parents.length, 418 | {configuration} = this, 419 | {blockSize} = configuration, 420 | {selection} = configuration; 421 | 422 | for (i = 0; i < parentLength; i++) { 423 | let current = parents[i] + ' ( ' + parentSizes[parentNames[i]] + ') '; 424 | 425 | if (current.length > longest.length) { 426 | longest = current; 427 | } 428 | } 429 | 430 | // Calculate the row and column for each child block. 431 | let longestWidth = this.getPixelLength(longest), 432 | parentDiv = this._d3V4 ? selection._groups[0][0] : selection[0][0], 433 | calculatedMaxChildren = (configuration.maxChildCount === 0) ? 434 | Math.floor((parentDiv.parentElement.clientWidth - blockSize - longestWidth) / blockSize) : 435 | configuration.maxChildCount, 436 | jsonLength = json.length, 437 | {thresholds} = configuration; 438 | 439 | for (i = 0; i < jsonLength; i++) { 440 | let element = json[i], 441 | {parent} = element; 442 | 443 | if (previousParent !== null && previousParent !== parent) { 444 | element.__row = row + 1; 445 | element.__index = 1; 446 | 447 | index = 2; 448 | row++; 449 | } else { 450 | if (index === calculatedMaxChildren + 1) { 451 | index = 1; 452 | row++; 453 | } 454 | 455 | element.__row = row; 456 | element.__index = index; 457 | 458 | index++; 459 | } 460 | 461 | previousParent = parent; 462 | 463 | if (thresholds.length === 0) { 464 | element.__color = 0; 465 | } else { 466 | // Figure out the color based on the threshold. 467 | let value, 468 | compare; 469 | 470 | if (typeof thresholds[0] === 'string') { 471 | value = element.value; 472 | compare = RelationshipGraph.stringCompare; 473 | } else { 474 | const elementValue = element.value; 475 | 476 | value = (typeof elementValue == 'number') ? 477 | elementValue : parseFloat(elementValue.replace(/[^0-9-.]+/g, '')); 478 | 479 | compare = RelationshipGraph.numericCompare; 480 | } 481 | 482 | const thresholdIndex = compare(value, thresholds); 483 | 484 | element.__color = (thresholdIndex === -1) ? 0 : thresholdIndex; 485 | element.__colorValue = this.configuration.colors[element.__color % this.configuration.colors.length]; 486 | } 487 | 488 | // Add the interaction methods 489 | /** 490 | * Set the color of the node. This method gets the object lazily and is only gets the object from the DOM 491 | * once. 492 | * 493 | * @param {String} color The new color of the node to set. 494 | */ 495 | element.setNodeColor = function(color) { 496 | if (!this.__node) { 497 | this.__node = document.getElementById(this.__id); 498 | } 499 | 500 | if (this.__node) { 501 | this.__node.style.fill = color; 502 | } 503 | }; 504 | 505 | /** 506 | * Set the color of the node's stroke. This method gets the object lazily and is only gets the object from 507 | * the DOM once. 508 | * 509 | * @param {String} color The color to set the stroke to. Set this to a falsy value to remove the stroke. 510 | */ 511 | element.setNodeStrokeColor = function(color) { 512 | if (!this.__node) { 513 | this.__node = document.getElementById(this.__id); 514 | } 515 | 516 | if (this.__node) { 517 | this.__node.style.strokeWidth = color ? '1px' : 0; 518 | this.__node.style.stroke = color ? color : ''; 519 | } 520 | }; 521 | 522 | element.__id = this.getId() + '-child-node' + element.__row + '-' + element.__index; 523 | } 524 | 525 | return [ 526 | longestWidth, 527 | calculatedMaxChildren, 528 | row 529 | ]; 530 | } 531 | 532 | /** 533 | * Verify that the JSON passed in is correct. 534 | * 535 | * @param json {Array} The array of JSON objects to verify. 536 | */ 537 | static verifyJson(json) { 538 | if (!(RelationshipGraph.isArray(json)) || (json.length < 0) || (typeof json[0] !== 'object')) { 539 | throw 'JSON has to be an Array of JavaScript objects that is not empty.'; 540 | } 541 | 542 | let length = json.length; 543 | 544 | while (length--) { 545 | let element = json[length], 546 | keys = Object.keys(element), 547 | keyLength = keys.length, 548 | {parentColor} = element; 549 | 550 | if (element.parent === undefined) { 551 | throw 'Child does not have a parent.'; 552 | } else if (parentColor !== undefined && (parentColor > 4 || parentColor < 0)) { 553 | throw 'Parent color is unsupported.'; 554 | } 555 | 556 | while (keyLength--) { 557 | if (keys[keyLength].toUpperCase() == 'VALUE') { 558 | if (keys[keyLength] != 'value') { 559 | json[length].value = json[length][keys[keyLength]]; 560 | delete json[length][keys[keyLength]]; 561 | } 562 | break; 563 | } 564 | } 565 | } 566 | 567 | return true; 568 | } 569 | 570 | /** 571 | * Creates the parent labels. 572 | * 573 | * @param {d3.selection} parentNodes The parentNodes. 574 | * @param {Object} parentSizes The child count for each parent. 575 | * @param {number} longestWidth The longest width of a parent node. 576 | * @param {number} calculatedMaxChildren The maximum amount of children nodes per row. 577 | * @private 578 | */ 579 | createParents(parentNodes, parentSizes, longestWidth, calculatedMaxChildren) { 580 | const parentSizesKeys = Object.keys(parentSizes), 581 | _this = this; 582 | 583 | parentNodes.enter().append('text') 584 | .text(function(obj, index) { 585 | return obj + ' (' + parentSizes[parentSizesKeys[index]] + ')'; 586 | }) 587 | .attr('x', function(obj, index) { 588 | const width = _this.getPixelLength(obj + ' (' + parentSizes[parentSizesKeys[index]] + ')'); 589 | return longestWidth - width; 590 | }) 591 | .attr('y', function(obj, index) { 592 | if (index === 0) { 593 | return 0; 594 | } 595 | 596 | /** 597 | * Determine the Y coordinate by determining the Y coordinate of all of the parents before. This 598 | * has to be calculated completely because it is an update and can occur anywhere. 599 | */ 600 | let previousParentSize = 0, 601 | i = index - 1; 602 | 603 | while (i > -1) { 604 | previousParentSize += Math.ceil(parentSizes[parentSizesKeys[i]] / calculatedMaxChildren) * 605 | calculatedMaxChildren; 606 | i--; 607 | } 608 | 609 | return Math.ceil(previousParentSize / calculatedMaxChildren) * _this.configuration.blockSize + 610 | (_this._spacing * index); 611 | }) 612 | .style('text-anchor', 'start') 613 | .style('fill', function(obj) { 614 | return (obj.parentColor !== undefined) ? _this.configuration.colors[obj.parentColor] : '#000000'; 615 | }) 616 | .style('cursor', this.parentPointer ? 'pointer' : 'default') 617 | .attr('class', 'relationshipGraph-Text') 618 | .attr('transform', 'translate(-6, ' + _this.configuration.blockSize / 1.5 + ')') 619 | .on('click', function(obj) { 620 | _this.configuration.onClick.parent(obj); 621 | }); 622 | } 623 | 624 | /** 625 | * Updates the existing parent nodes with new data. 626 | * 627 | * @param {d3.selection} parentNodes The parentNodes. 628 | * @param {Object} parentSizes The child count for each parent. 629 | * @param {number} longestWidth The longest width of a parent node. 630 | * @param {number} calculatedMaxChildren The maxiumum amount of children nodes per row. 631 | * @private 632 | */ 633 | updateParents(parentNodes, parentSizes, longestWidth, calculatedMaxChildren) { 634 | const parentSizesKeys = Object.keys(parentSizes), 635 | _this = this; 636 | 637 | // noinspection JSUnresolvedFunction 638 | parentNodes 639 | .text(function(obj, index) { 640 | return obj + ' (' + parentSizes[parentSizesKeys[index]] + ')'; 641 | }) 642 | .attr('x', function(obj, index) { 643 | const width = _this.getPixelLength(obj + ' (' + parentSizes[parentSizesKeys[index]] + ')'); 644 | return longestWidth - width; 645 | }) 646 | .attr('y', function(obj, index) { 647 | if (index === 0) { 648 | return 0; 649 | } 650 | 651 | /** 652 | * Determine the Y coordinate by determining the Y coordinate of all of the parents before. This 653 | * has to be calculated completely because it is an update and can occur anywhere. 654 | */ 655 | let previousParentSize = 0, 656 | i = index - 1; 657 | 658 | while (i > -1) { 659 | previousParentSize += Math.ceil(parentSizes[parentSizesKeys[i]] / calculatedMaxChildren) * 660 | calculatedMaxChildren; 661 | i--; 662 | } 663 | 664 | return Math.ceil(previousParentSize / calculatedMaxChildren) * _this.configuration.blockSize + 665 | (_this._spacing * index); 666 | }) 667 | .style('fill', function(obj) { 668 | return (obj.parentColor !== undefined) ? _this.configuration.colors[obj.parentColor] : '#000000'; 669 | }) 670 | .style('cursor', _this.parentPointer ? 'pointer' : 'default'); 671 | } 672 | 673 | /** 674 | * Creates new children nodes. 675 | * 676 | * @param {d3.selection} childrenNodes The children nodes. 677 | * @param {number} longestWidth The longest width of a parent node. 678 | * @private 679 | */ 680 | createChildren(childrenNodes, longestWidth) { 681 | const _this = this; 682 | 683 | childrenNodes.enter() 684 | .append('rect') 685 | .attr('id', function(obj) { 686 | return obj.__id; 687 | }) 688 | .attr('x', function(obj) { 689 | return longestWidth + ((obj.__index - 1) * _this.configuration.blockSize) + 5 + 690 | (_this._spacing * obj.__index - 1); 691 | }) 692 | .attr('y', function(obj) { 693 | return (obj.__row - 1) * _this.configuration.blockSize + (_this._spacing * obj.__row - 1); 694 | }) 695 | .attr('rx', 4) 696 | .attr('ry', 4) 697 | .attr('class', 'relationshipGraph-block') 698 | .attr('width', _this.configuration.blockSize) 699 | .attr('height', _this.configuration.blockSize) 700 | .style('fill', function(obj) { 701 | return obj.__colorValue; 702 | }) 703 | .style('cursor', _this.childPointer ? 'pointer' : 'default') 704 | .on('mouseover', _this.tooltip ? _this.tooltip.show : RelationshipGraph.noop) 705 | .on('mouseout', _this.tooltip ? _this.tooltip.hide : RelationshipGraph.noop) 706 | .on('click', function(obj) { 707 | _this.tooltip.hide(); 708 | _this.configuration.onClick.child(obj); 709 | }); 710 | } 711 | 712 | /** 713 | * Updates the existing children nodes with new data. 714 | * 715 | * @param {d3.selection} childrenNodes The children nodes. 716 | * @param {number} longestWidth The longest width of a parent node. 717 | * @private 718 | */ 719 | updateChildren(childrenNodes, longestWidth) { 720 | const {blockSize} = this.configuration, 721 | _this = this; 722 | 723 | // noinspection JSUnresolvedFunction 724 | childrenNodes.transition(this.configuration.transitionTime) 725 | .attr('id', function(obj) { 726 | return obj.__id; 727 | }) 728 | .attr('x', function(obj) { 729 | return longestWidth + ((obj.__index - 1) * blockSize) + 5 + (_this._spacing * obj.__index - 1); 730 | }) 731 | .attr('y', function(obj) { 732 | return (obj.__row - 1) * blockSize + (_this._spacing * obj.__row - 1); 733 | }) 734 | .style('fill', function(obj) { 735 | return obj.__colorValue; 736 | }); 737 | } 738 | 739 | /** 740 | * Removes nodes that no longer exist. 741 | * 742 | * @param {d3.selection} nodes The nodes. 743 | * @private 744 | */ 745 | removeNodes(nodes) { 746 | // noinspection JSUnresolvedFunction 747 | nodes.exit().transition(this.configuration.transitionTime).remove(); 748 | } 749 | 750 | /** 751 | * Generate the graph. 752 | * 753 | * @param {Array} json The array of JSON to feed to the graph. 754 | * @return {RelationshipGraph} The RelationshipGraph object to keep d3's chaining functionality. 755 | * @public 756 | */ 757 | data(json) { 758 | if (RelationshipGraph.verifyJson(json)) { 759 | const parents = [], 760 | parentSizes = {}, 761 | {configuration} = this; 762 | 763 | let row = 0, 764 | parent, 765 | i, 766 | maxWidth, 767 | maxHeight, 768 | calculatedMaxChildren = 0, 769 | longestWidth = 0; 770 | 771 | // Ensure that the JSON is sorted by parent. 772 | configuration.sortFunction(json); 773 | 774 | this.representation = json; 775 | 776 | /** 777 | * Loop through all of the childrenNodes in the JSON array and determine the amount of childrenNodes per 778 | * parent. This will also calculate the row and index for each block and truncate the parent names to 25 779 | * characters. 780 | */ 781 | const jsonLength = json.length; 782 | 783 | for (i = 0; i < jsonLength; i++) { 784 | parent = json[i].parent; 785 | 786 | if (RelationshipGraph.containsKey(parentSizes, parent)) { 787 | parentSizes[parent]++; 788 | } else { 789 | parentSizes[parent] = 1; 790 | parents.push(RelationshipGraph.truncate(parent, configuration.truncate)); 791 | } 792 | } 793 | 794 | /** 795 | * Assign the indexes and rows to each child. This method also calculates the maximum amount of children 796 | * per row, the longest row width, and how many rows there are. 797 | */ 798 | [longestWidth, calculatedMaxChildren, row] = this.assignIndexAndRow(json, parentSizes, parents); 799 | 800 | // Set the max width and height. 801 | maxHeight = row * configuration.blockSize; 802 | maxWidth = longestWidth + calculatedMaxChildren * configuration.blockSize; 803 | 804 | // Account for the added _spacing. 805 | maxWidth += this._spacing * calculatedMaxChildren; 806 | 807 | for (i = 0; i < row; i++) { 808 | maxHeight += this._spacing * i; 809 | } 810 | 811 | // Select all of the parent nodes. 812 | const parentNodes = this.svg.selectAll('.relationshipGraph-Text') 813 | .data(parents); 814 | 815 | // Add new parent nodes. 816 | this.createParents(parentNodes, parentSizes, longestWidth, calculatedMaxChildren); 817 | 818 | // Update existing parent nodes. 819 | this.updateParents(parentNodes, parentSizes, longestWidth, calculatedMaxChildren); 820 | 821 | // Remove deleted parent nodes. 822 | this.removeNodes(parentNodes); 823 | 824 | // Select all of the children nodes. 825 | const childrenNodes = this.svg.selectAll('.relationshipGraph-block') 826 | .data(json); 827 | 828 | // Add new child nodes. 829 | this.createChildren(childrenNodes, longestWidth); 830 | 831 | // Update existing child nodes. 832 | this.updateChildren(childrenNodes, longestWidth); 833 | 834 | // Delete removed child nodes. 835 | this.removeNodes(childrenNodes); 836 | 837 | if (this.configuration.showTooltips) { 838 | d3.select('.d3-tip').remove(); 839 | this.svg.call(this.tooltip); 840 | } 841 | 842 | this.configuration.selection.select('svg') 843 | .attr('width', Math.abs(maxWidth + 15)) 844 | .attr('height', Math.abs(maxHeight + 15)); 845 | } 846 | 847 | return this; 848 | } 849 | 850 | /** 851 | * Searches through the representation and returns the child nodes that match the search query. 852 | * 853 | * @param {object} query The partial object match to search for. 854 | * @returns {Array} An array with the objects that matched the partial query or an empty array if none are found. 855 | */ 856 | search(query) { 857 | const results = [], 858 | queryKeys = Object.keys(query), 859 | queryKeysLength = queryKeys.length; 860 | 861 | if (this.representation && query) { 862 | const length = this.representation.length; 863 | 864 | for (let i = 0; i < length; i++) { 865 | const currentObject = this.representation[i]; 866 | 867 | let isMatch = false; 868 | 869 | for (let j = 0; j < queryKeysLength; j++) { 870 | const queryVal = query[queryKeys[j]]; 871 | 872 | if (!(isMatch = currentObject[queryKeys[j]] == queryVal)) { 873 | break; 874 | } 875 | } 876 | 877 | if (isMatch) { 878 | results.push(currentObject); 879 | } 880 | } 881 | } 882 | 883 | return results; 884 | } 885 | } 886 | 887 | /** 888 | * Add a relationshipGraph function to d3 that returns a RelationshipGraph object. 889 | */ 890 | d3.relationshipGraph = function() { 891 | 'use strict'; 892 | 893 | return RelationshipGraph.extend.apply(RelationshipGraph, arguments); 894 | }; 895 | 896 | /** 897 | * Add relationshipGraph to selection. 898 | * 899 | * @param {Object} userConfig Configuration for graph. 900 | * @return {Object} Returns a new RelationshipGraph object. 901 | */ 902 | d3.selection.prototype.relationshipGraph = function(userConfig) { 903 | 'use strict'; 904 | 905 | return new RelationshipGraph(this, userConfig); 906 | }; 907 | -------------------------------------------------------------------------------- /test/RelationshipGraph.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, d3, chai, RelationshipGraph */ 2 | describe('RelationshipGraph', function() { 3 | 'use strict'; 4 | 5 | describe('#ValidateAddedToD3()', function () { 6 | it('Should be added to d3.', function () { 7 | chai.expect(typeof d3.relationshipGraph).to.equal('function'); 8 | chai.expect(d3.selection.prototype.relationshipGraph).to.not.equal(undefined); 9 | }); 10 | }); 11 | 12 | describe('#ValidateRelationshipGraph()', function() { 13 | it('Should return a RelationshipGraph object.', function() { 14 | var graph = d3.select('#test').relationshipGraph({ 15 | thresholds: [200] 16 | }); 17 | 18 | chai.expect(typeof graph).to.equal('object'); 19 | chai.expect(typeof graph.data).to.equal('function'); 20 | }); 21 | }); 22 | 23 | describe('#ValidateDefaultConfig()', function() { 24 | it('Should return the default config.', function() { 25 | var graph = d3.select('#test').relationshipGraph(); 26 | 27 | var config = graph.configuration; 28 | 29 | chai.expect(config.showTooltips).to.equal(true); 30 | chai.expect(config.maxChildCount).to.equal(0); 31 | chai.expect(typeof config.thresholds).to.equal('object'); 32 | chai.expect(config.thresholds.length).to.equal(0); 33 | }); 34 | }); 35 | 36 | describe('#ValidateThresholdSorting()', function() { 37 | it('Should be sorted.', function() { 38 | var thresholds = [500, 400, 300, 200, 100], 39 | expected = [100, 200, 300, 400, 500]; 40 | 41 | var graph = d3.select('#test').relationshipGraph({ 42 | thresholds: thresholds 43 | }); 44 | 45 | for (var i = 0; i < expected.length; i++) { 46 | chai.expect(graph.configuration.thresholds[i]).to.equal(expected[i]); 47 | } 48 | }); 49 | 50 | it('Should be the same', function() { 51 | var thresholds = ['test1', 'test2', 'test']; 52 | 53 | var graph = d3.select('#test').relationshipGraph({ 54 | thresholds: thresholds 55 | }); 56 | 57 | for (var i = 0; i < thresholds.length; i++) { 58 | chai.expect(graph.configuration.thresholds[i]).to.equal(thresholds[i]); 59 | } 60 | }); 61 | }); 62 | 63 | //describe('#ValidateIncorrectThresholds()', function() { 64 | // it('Should throw an exception', function() { 65 | // // Test string. 66 | // chai.expect(d3.select('#graph').relationshipGraph.bind( 67 | // d3.select('#graph').relationshipGraph, {'thresholds': '15'})).to.throw('Thresholds must be an Object.'); 68 | // // Test number. 69 | // chai.expect(d3.select('#graph').relationshipGraph.bind( 70 | // d3.select('#graph').relationshipGraph, {'thresholds': 15})).to.throw('Thresholds must be an Object.'); 71 | // }); 72 | //}); 73 | 74 | describe('#ValidateShowTooltips()', function() { 75 | it('Should be false', function() { 76 | var graph = d3.select('#test').relationshipGraph({ 77 | thresholds: [200], 78 | showTooltips: false 79 | }); 80 | 81 | chai.expect(graph.configuration.showTooltips).to.equal(false); 82 | chai.expect(graph.tooltip).to.equal(null); 83 | }); 84 | it('Should be true', function () { 85 | var graph = d3.select('#test').relationshipGraph({ 86 | thresholds: [200], 87 | showTooltips: true 88 | }); 89 | 90 | chai.expect(graph.configuration.showTooltips).to.equal(true); 91 | chai.expect(typeof graph.tooltip).to.equal('function'); 92 | }); 93 | it('Should be true', function () { 94 | var graph = d3.select('#test3').relationshipGraph({ 95 | thresholds: [200] 96 | }); 97 | 98 | chai.expect(graph.configuration.showTooltips).to.equal(true); 99 | chai.expect(typeof graph.tooltip).to.equal('function'); 100 | }); 101 | }); 102 | 103 | describe('#ValidateShowKeys()', function() { 104 | it('Should be false', function() { 105 | var graph = d3.select('#test').relationshipGraph({ 106 | thresholds: [200], 107 | showKeys: false 108 | }); 109 | 110 | chai.expect(graph.configuration.showKeys).to.equal(false); 111 | }); 112 | it('Should be true', function () { 113 | var graph = d3.select('#test').relationshipGraph({ 114 | thresholds: [200], 115 | showKeys: true 116 | }); 117 | 118 | chai.expect(graph.configuration.showKeys).to.equal(true); 119 | chai.expect(typeof graph.tooltip).to.equal('function'); 120 | }); 121 | it('Should be true', function () { 122 | var graph = d3.select('#test').relationshipGraph({ 123 | thresholds: [200] 124 | }); 125 | 126 | chai.expect(graph.configuration.showKeys).to.equal(true); 127 | chai.expect(typeof graph.tooltip).to.equal('function'); 128 | }); 129 | }); 130 | 131 | describe('#ValidateVerifyJSON()', function() { 132 | var graph = d3.select('#test').relationshipGraph({ 133 | thresholds: [200] 134 | }); 135 | 136 | it('Should be an empty object', function() { 137 | // Test undefined 138 | chai.expect(RelationshipGraph.verifyJson.bind(RelationshipGraph.verifyJson, undefined)).to.throw('JSON has to be an Array of JavaScript objects that is not empty.'); 139 | // Test number 140 | chai.expect(RelationshipGraph.verifyJson.bind(RelationshipGraph.verifyJson, 5)).to.throw('JSON has to be an Array of JavaScript objects that is not empty.'); 141 | // Test string 142 | chai.expect(RelationshipGraph.verifyJson.bind(RelationshipGraph.verifyJson, '5')).to.throw('JSON has to be an Array of JavaScript objects that is not empty.'); 143 | // Test null 144 | chai.expect(RelationshipGraph.verifyJson.bind(RelationshipGraph.verifyJson, null)).to.throw('JSON has to be an Array of JavaScript objects that is not empty.'); 145 | }); 146 | 147 | it('Should not have a parent', function () { 148 | // Test no parent 149 | var json = [{ 150 | test: 15 151 | }]; 152 | 153 | chai.expect(RelationshipGraph.verifyJson.bind(RelationshipGraph.verifyJson, json)).to.throw('Child does not have a parent.'); 154 | }); 155 | }); 156 | 157 | describe('#ValidateConfigurations()', function() { 158 | var noop = function() {}; 159 | 160 | it('Should be the same.', function() { 161 | var config = { 162 | showTooltips: true, 163 | maxChildCount: 10, 164 | onClick: { 165 | parent: noop, 166 | child: noop 167 | }, 168 | showKeys: true, 169 | thresholds: [100, 200, 300], 170 | colors: ['red', 'green', 'blue'], 171 | transitionTime: 1000, 172 | truncate: 25 173 | }; 174 | 175 | var graph = d3.select('#test2').relationshipGraph(config); 176 | 177 | chai.expect(graph.configuration.showTooltips).to.equal(config.showTooltips); 178 | chai.expect(graph.configuration.maxChildCount).to.equal(config.maxChildCount); 179 | chai.expect(graph.configuration.onClick).to.equal(config.onClick); 180 | chai.expect(graph.configuration.showKeys).to.equal(config.showKeys); 181 | 182 | for (var i = 0; i < config.thresholds.length; i++) { 183 | chai.expect(graph.configuration.thresholds[i]).to.equal(config.thresholds[i]); 184 | } 185 | 186 | for (i = 0; i < config.colors.length; i++) { 187 | chai.expect(graph.configuration.colors[i]).to.equal(config.colors[i]); 188 | } 189 | 190 | chai.expect(graph.configuration.transitionTime).to.equal(config.transitionTime); 191 | chai.expect(graph.configuration.truncate).to.equal(config.truncate); 192 | }); 193 | }); 194 | 195 | describe('#ValidateContainsKey()', function() { 196 | it('Should contain the key', function() { 197 | chai.expect(RelationshipGraph.containsKey({'test': 15}, 'test')).to.equal(true); 198 | }); 199 | 200 | it('Should not contain the key', function() { 201 | chai.expect(RelationshipGraph.containsKey({'test': 15}, 'test2')).to.equal(false); 202 | }); 203 | 204 | }); 205 | 206 | describe('#ValidateContains()', function() { 207 | it('Should contain the key', function() { 208 | chai.expect(RelationshipGraph.contains([15, 22], 22)).to.equal(true); 209 | }); 210 | 211 | it('Should not contain the key', function() { 212 | chai.expect(RelationshipGraph.contains([15, 22], 30)).to.equal(false); 213 | }); 214 | }); 215 | 216 | describe('#ValidateTruncate()', function() { 217 | it('Should be truncated', function() { 218 | chai.expect(RelationshipGraph.truncate('teststring', 5).length).to.equal(8); // This includes the ellipses 219 | }); 220 | 221 | it('Should not be truncated', function() { 222 | chai.expect(RelationshipGraph.truncate('teststring', 10).length).to.equal(10); 223 | chai.expect(RelationshipGraph.truncate('teststring', 0).length).to.equal(10); 224 | }); 225 | }); 226 | 227 | describe('#ValidateIsArray()', function() { 228 | it('Should be false', function() { 229 | chai.expect(RelationshipGraph.isArray(7)).to.equal(false); 230 | }); 231 | 232 | it('Should be true', function() { 233 | chai.expect(RelationshipGraph.isArray([1, 2, 3])).to.equal(true); 234 | chai.expect(RelationshipGraph.isArray([ 235 | {'test': 22}, 236 | {'test': 45} 237 | ])).to.equal(true); 238 | }); 239 | }); 240 | 241 | describe('#ValidateCreation()', function() { 242 | var graph = d3.select('#graph').relationshipGraph({ 243 | showTooltips: true, 244 | maxChildCount: 10, 245 | showKeys: false, 246 | thresholds: [1000000000, 2000000000, 3000000000] 247 | }); 248 | 249 | var json = [ 250 | { 251 | movietitle: 'Avatar', 252 | parent: '20th Century Fox', 253 | value: '$2,787,965,087', 254 | year: '2009' 255 | }, 256 | { 257 | movietitle: 'Titanic', 258 | parent: '20th Century Fox', 259 | value: '$2,186,772,302', 260 | year: '1997' 261 | }, 262 | { 263 | movietitle: 'Star Wars: The Force Awakens', 264 | parent: 'Walt Disney Studios', 265 | value: '$2,066,247,462', 266 | year: '2015' 267 | }, 268 | { 269 | movietitle: 'Jurassic World', 270 | parent: 'Universal Pictures', 271 | value: '$1,670,400,637', 272 | year: '2015' 273 | }, 274 | { 275 | movietitle: 'The Avengers', 276 | parent: 'Walt Disney Studios', 277 | value: '$1,519,557,910', 278 | year: '2012' 279 | }, 280 | { 281 | movietitle: 'Furious 7', 282 | parent: 'Universal Pictures', 283 | value: '$1,516,045,911', 284 | year: '2015' 285 | }, 286 | { 287 | movietitle: 'Avengers: Age of Ultron', 288 | parent: 'Walt Disney Studios', 289 | value: '$1,405,413,868', 290 | year: '2015' 291 | }, 292 | { 293 | movietitle: 'Harry Potter and the Deathly Hallows -- Part 2', 294 | parent: 'Warner Bros. Pictures', 295 | value: '$1,341,511,219', 296 | year: '2011' 297 | }, 298 | { 299 | movietitle: 'Frozen', 300 | parent: 'Walt Disney Studios', 301 | value: '$1,287,000,000', 302 | year: '2013' 303 | }, 304 | { 305 | movietitle: 'Iron Man 3', 306 | parent: 'Walt Disney Studios', 307 | value: '$1,215,439,994', 308 | year: '2013' 309 | }, 310 | { 311 | movietitle: 'Minions', 312 | parent: 'Universal Pictures', 313 | value: '$1,159,398,397', 314 | year: '2015' 315 | }, 316 | { 317 | movietitle: 'Transformers: Dark of the Moon', 318 | parent: 'Paramount Pictures', 319 | value: '$1,123,794,079', 320 | year: '2011' 321 | }, 322 | { 323 | movietitle: 'The Lord of the Rings: The Return of the King', 324 | parent: 'New Line Cinema', 325 | value: '$1,119,929,521', 326 | year: '2003' 327 | }, 328 | { 329 | movietitle: 'Skyfall', 330 | parent: 'Columbia Pictures', 331 | value: '$1,108,561,013', 332 | year: '2012' 333 | }, 334 | { 335 | movietitle: 'Transformers: Age of Extinction', 336 | parent: 'Universal Pictures', 337 | value: '$1,104,054,072', 338 | year: '2014' 339 | }, 340 | { 341 | movietitle: 'The Dark Knight Rises', 342 | parent: 'Warner Bros. Pictures', 343 | value: '$1,084,939,099', 344 | year: '2012' 345 | }, 346 | { 347 | movietitle: 'Pirates of the Caribbean: Dead Man\'s Chest', 348 | parent: 'Walt Disney Studios', 349 | value: '$1,066,179,725', 350 | year: '2006' 351 | }, 352 | { 353 | movietitle: 'Toy Story 3', 354 | parent: 'Walt Disney Studios', 355 | value: '$1,063,171,911', 356 | year: '2010' 357 | }, 358 | { 359 | movietitle: 'Pirates of the Caribbean: On Stranger Ties', 360 | parent: 'Walt Disney Studios', 361 | value: '$1,045,713,802', 362 | year: '2011' 363 | }, 364 | { 365 | movietitle: 'Jurassic Park', 366 | parent: 'Universal Pictures', 367 | value: '$1,029,939,903', 368 | year: '1993' 369 | }, 370 | { 371 | movietitle: 'Star Wars: Episode I -- The Phantom Menace', 372 | parent: '20th Century Fox', 373 | value: '$1,027,044,677', 374 | year: '1999' 375 | }, 376 | { 377 | movietitle: 'Alice in Wonderland', 378 | parent: 'Walt Disney Studios', 379 | value: '$1,025,467,110', 380 | year: '2010' 381 | }, 382 | { 383 | movietitle: 'The Hobbit: An Unexpected Journey', 384 | parent: 'Warner Bros. Pictures', 385 | value: '$1,021,103,568', 386 | year: '2012' 387 | }, 388 | { 389 | movietitle: 'The Dark Knight', 390 | parent: 'Warner Bros. Pictures', 391 | value: '$1,004,558,444', 392 | year: '2008' 393 | }, 394 | { 395 | movietitle: 'The Lion King', 396 | parent: 'Walt Disney Studios', 397 | value: '$987,483,777', 398 | year: '1994' 399 | }, 400 | { 401 | movietitle: 'Harry Potter and the Philosopher\'s Stone', 402 | parent: 'Warner Bros. Pictures', 403 | value: '$974,755,371', 404 | year: '2001' 405 | }, 406 | { 407 | movietitle: 'Despicable Me 2', 408 | parent: 'Universal Pictures', 409 | value: '$970,761,885', 410 | year: '2013' 411 | }, 412 | { 413 | movietitle: 'Zootopia', 414 | parent: 'Walt Disney Studios', 415 | value: '$969,831,439', 416 | year: '2016' 417 | }, 418 | { 419 | movietitle: 'Pirates of the Caribbean: At World\'s End', 420 | parent: 'Walt Disney Studios', 421 | value: '$963,420,425', 422 | year: '2007' 423 | }, 424 | { 425 | movietitle: 'Harry Potter and the Deathly Hallows -- Part 1', 426 | parent: 'Warner Bros. Pictures', 427 | value: '$960,283,305', 428 | year: '2010' 429 | }, 430 | { 431 | movietitle: 'The Hobbit: The Desolation of Smaug', 432 | parent: 'Warner Bros. Pictures', 433 | value: '$958,366,855', 434 | year: '2013' 435 | }, 436 | { 437 | movietitle: 'The Hobbit: The Battle of the Five Armies', 438 | parent: 'Warner Bros. Pictures', 439 | value: '$956,892,078', 440 | year: '2014' 441 | }, 442 | { 443 | movietitle: 'Captain America: Civil War', 444 | parent: 'Walt Disney Studios', 445 | value: '$940,892,078', 446 | year: '2016' 447 | }, 448 | { 449 | movietitle: 'Harry Potter and the Order of the Phoenix', 450 | parent: 'Warner Bros. Pictures', 451 | value: '$939,885,929', 452 | year: '2007' 453 | }, 454 | { 455 | movietitle: 'Finding Nemo', 456 | parent: 'Walt Disney Studios', 457 | value: '$936,743,261', 458 | year: '2003' 459 | }, 460 | { 461 | movietitle: 'Harry Potter and the Half-Blood Prince', 462 | parent: 'Warner Bros. Pictures', 463 | value: '$934,416,487', 464 | year: '2009' 465 | }, 466 | { 467 | movietitle: 'The Lord of the Rings: The Two Towers', 468 | parent: 'New Line Cinema', 469 | value: '$926,047,111', 470 | year: '2002' 471 | }, 472 | { 473 | movietitle: 'Shrek 2', 474 | parent: 'Walt Disney Studios', 475 | value: '$919,838,758', 476 | year: '2004' 477 | }, 478 | { 479 | movietitle: 'Harry Potter and the Goblet of Fire', 480 | parent: 'Warner Bros. Pictures', 481 | value: '$896,911,078', 482 | year: '2005' 483 | }, 484 | { 485 | movietitle: 'Spider-Man 3', 486 | parent: 'Columbia Pictures', 487 | value: '$890,871,626', 488 | year: '2007' 489 | }, 490 | { 491 | movietitle: 'Ice Age: dawn of the Dinosaurs', 492 | parent: '20th Century Fox', 493 | value: '$886,686,817', 494 | year: '2009' 495 | }, 496 | { 497 | movietitle: 'Spectre', 498 | parent: 'Columbia Pictures', 499 | value: '$880,674,609', 500 | year: '2015' 501 | }, 502 | { 503 | movietitle: 'Harry Potter and the Chamber of Secrets', 504 | parent: 'Warner Bros. Pictures', 505 | value: '$878,979,634', 506 | year: '2002' 507 | }, 508 | { 509 | movietitle: 'Ice Age: Continental Drift', 510 | parent: '20th Century Fox', 511 | value: '$877,244,782', 512 | year: '2012' 513 | }, 514 | { 515 | movietitle: 'The Lord of the Rings: The Fellowship of the Rings', 516 | parent: 'New Line Cinema', 517 | value: '$871,530,324', 518 | year: '2001' 519 | }, 520 | { 521 | movietitle: 'Batman v Superman: Dawn of Justice', 522 | parent: 'Warner Bros. Pictures', 523 | value: '$868,814,243', 524 | year: '2016' 525 | }, 526 | { 527 | movietitle: 'The Hunger Games: Catching Fire', 528 | parent: 'Lionsgate Films', 529 | value: '$865,011,746', 530 | year: '2013' 531 | }, 532 | { 533 | movietitle: 'Inside Out', 534 | parent: 'Walt Disney Studios', 535 | value: '$857,427,711', 536 | year: '2015' 537 | }, 538 | { 539 | movietitle: 'Star Wars: Episode III -- Revenge of the Sith', 540 | parent: '20th Century Fox', 541 | value: '$848,754,768', 542 | year: '2005' 543 | }, 544 | { 545 | movietitle: 'Transformers: Revenge of the Fallen', 546 | parent: 'Universal Pictures', 547 | value: '$836,303,693', 548 | year: '2009' 549 | } 550 | ]; 551 | 552 | // Ensure that the JSON is sorted by parent. 553 | json.sort(function(child1, child2) { 554 | var parent1 = child1.parent.toLowerCase(), 555 | parent2 = child2.parent.toLowerCase(), 556 | r = ((parent1 > parent2) ? 1 : (parent1 < parent2) ? -1 : 0); 557 | 558 | if (r === 0) { 559 | var keys = Object.keys(child1); 560 | 561 | for (var i = 0; i < keys.length; i++) { 562 | if (keys[i] == parent) { 563 | continue; 564 | } 565 | 566 | var val1 = child1[keys[i]], 567 | val2 = child2[keys[i]]; 568 | 569 | r = ((val1 > val2) ? 1 : (val1 < val2) ? -1 : 0); 570 | 571 | if (r !== 0) { 572 | return r; 573 | } 574 | } 575 | } 576 | 577 | return r; 578 | }); 579 | 580 | it('Should exist', function() { 581 | var graphElement = document.getElementById('graph'), 582 | svg = graphElement.children[0]; 583 | 584 | chai.expect(graphElement.tagName.toUpperCase()).to.equal('DIV'); 585 | chai.expect(svg.tagName.toUpperCase()).to.equal('SVG'); 586 | }); 587 | 588 | it('Should be created correctly', function() { 589 | graph.data(json); 590 | 591 | var spacing = graph._spacing; 592 | 593 | var text = document.getElementsByClassName('relationshipGraph-Text'); 594 | 595 | var expectedText = ['20th Century Fox (6)', 'Columbia Pictures (3)', 'Lionsgate Films (1)', 'New Line Cinema (3)', 596 | 'Paramount Pictures (1)', 'Universal Pictures (7)', 'Walt Disney Studios (16)', 'Warner Bros. Pictures (13)'], 597 | expectedY = [0, 24, 48, 72, 96, 120, 144, 192]; 598 | 599 | chai.expect(text.length).to.equal(expectedText.length); 600 | 601 | for (var i = 0; i < text.length; i++) { 602 | var element = text[i], 603 | elementText = element.firstChild.textContent; 604 | 605 | chai.expect(element).to.not.equal(undefined); 606 | chai.expect(parseInt(element.getAttribute('y'))).to.equal(expectedY[i] + (expectedY[i] === 0 ? 0 : spacing * i)); 607 | chai.expect(elementText).to.equal(expectedText[i]); 608 | } 609 | 610 | var rects = document.getElementsByClassName('relationshipGraph-block'), 611 | expectedX = [162, 187, 212, 237, 262, 287, 162, 187, 212, 162, 162, 187, 212, 162, 162, 187, 212, 237, 262, 612 | 287, 312, 162, 187, 212, 237, 262, 287, 312, 337, 362, 387, 162, 187, 212, 237, 262, 287, 162, 187, 613 | 212, 237, 262, 287, 312, 337, 362, 387, 162, 187, 212], 614 | expectedColors = ['#869d96', '#c4f1be', '#c4f1be', '#a2c3a4', '#c4f1be', '#869d96', '#a2c3a4', '#c4f1be', 615 | '#c4f1be', '#c4f1be', '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', '#a2c3a4', 616 | '#a2c3a4', '#a2c3a4', '#a2c3a4', '#c4f1be', '#a2c3a4', '#a2c3a4', '#c4f1be', '#c4f1be', '#a2c3a4', 617 | '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', '#a2c3a4', '#c4f1be', '#869d96', '#a2c3a4', '#c4f1be', 618 | '#a2c3a4', '#c4f1be', '#c4f1be', '#c4f1be', '#c4f1be', '#a2c3a4', '#c4f1be', '#c4f1be', '#c4f1be', 619 | '#c4f1be', '#a2c3a4', '#a2c3a4', '#a2c3a4', '#c4f1be', '#c4f1be', '#869d96', '#c4f1be', '#c4f1be', 620 | '#a2c3a4', '#c4f1be', '#869d96', '#a2c3a4', '#c4f1be', '#c4f1be', '#c4f1be', '#c4f1be', '#a2c3a4', 621 | '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', '#a2c3a4', '#a2c3a4', '#a2c3a4', '#a2c3a4', '#c4f1be', 622 | '#a2c3a4', '#a2c3a4', '#c4f1be', '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', '#c4f1be', '#a2c3a4', 623 | '#a2c3a4', '#c4f1be', '#869d96', '#a2c3a4', '#c4f1be', '#a2c3a4', '#c4f1be', '#c4f1be', '#c4f1be', 624 | '#c4f1be', '#a2c3a4', '#c4f1be', '#c4f1be', '#c4f1be', '#c4f1be', '#a2c3a4', '#a2c3a4', '#a2c3a4', 625 | '#c4f1be', '#c4f1be' 626 | ]; 627 | 628 | expectedY = [0, 0, 0, 0, 0, 0, 25, 25, 25, 50, 75, 75, 75, 100, 125, 125, 125, 125, 125, 125, 125, 150, 150, 629 | 150, 150, 150, 150, 150, 150, 150, 150, 175, 175, 175, 175, 175, 175, 200, 200, 200, 200, 200, 200, 200, 630 | 200, 200, 200, 225, 225, 225]; 631 | 632 | chai.expect(rects.length).to.equal(expectedX.length); 633 | 634 | var addition = (rects[0].getAttribute('x') != expectedX[0]) ? 17 : 0; 635 | 636 | for (var j = 0; j < rects.length; j++) { 637 | var block = rects[j]; 638 | 639 | chai.expect(parseInt(block.getAttribute('x'))).to.equal(expectedX[j] + addition); 640 | chai.expect(parseInt(block.getAttribute('y'))).to.equal(expectedY[j]); 641 | chai.expect(parseInt(block.getAttribute('rx'))).to.equal(4); 642 | chai.expect(parseInt(block.getAttribute('ry'))).to.equal(4); 643 | chai.expect(parseInt(block.getAttribute('width'))).to.equal(graph.configuration.blockSize); 644 | chai.expect(parseInt(block.getAttribute('height'))).to.equal(graph.configuration.blockSize); 645 | chai.expect(block.style.fill).to.equal(expectedColors[j]); 646 | } 647 | 648 | }); 649 | }); 650 | 651 | describe('#VerifySortJson', function() { 652 | it('Should be sorted.', function() { 653 | var json = [ 654 | { 655 | movietitle: 'Avatar', 656 | parent: '20th Century Fox', 657 | value: '$2,787,965,087', 658 | year: '2009' 659 | }, 660 | { 661 | movietitle: 'Star Wars: The Force Awakens', 662 | parent: 'Walt Disney Studios', 663 | value: '$2,066,247,462', 664 | year: '2015' 665 | }, 666 | { 667 | movietitle: 'Titanic', 668 | parent: '20th Century Fox', 669 | value: '$2,186,772,302', 670 | year: '1997' 671 | } 672 | ], expected = [ 673 | { 674 | movietitle: 'Avatar', 675 | parent: '20th Century Fox', 676 | value: '$2,787,965,087', 677 | year: '2009' 678 | }, 679 | { 680 | movietitle: 'Titanic', 681 | parent: '20th Century Fox', 682 | value: '$2,186,772,302', 683 | year: '1997' 684 | }, 685 | { 686 | movietitle: 'Star Wars: The Force Awakens', 687 | parent: 'Walt Disney Studios', 688 | value: '$2,066,247,462', 689 | year: '2015' 690 | } 691 | ]; 692 | 693 | json.sort(function(child1, child2) { 694 | var parent1 = child1.parent.toLowerCase(), 695 | parent2 = child2.parent.toLowerCase(); 696 | 697 | return (parent1 > parent2) ? 1 : (parent1 < parent2) ? -1 : 0; 698 | }); 699 | 700 | for (var i = 0; i < json.length; i++) { 701 | chai.expect(json[i].movietitle).to.equal(expected[i].movietitle); 702 | chai.expect(json[i].parent).to.equal(expected[i].parent); 703 | chai.expect(json[i].value).to.equal(expected[i].value); 704 | chai.expect(json[i].year).to.equal(expected[i].year); 705 | } 706 | 707 | document.getElementById('graph').innerHTML = ''; 708 | }); 709 | }); 710 | 711 | describe('#VerifyCustomColors', function() { 712 | it('Should have the custom color set.', function() { 713 | var custom = ['red', 'green', 'blue']; 714 | 715 | var graph = d3.select('#test').relationshipGraph({ 716 | colors: custom 717 | }); 718 | 719 | for (var i = 0; i < custom.length; i++) { 720 | chai.expect(graph.configuration.colors[i]).to.equal(custom[i]); 721 | } 722 | }); 723 | }); 724 | 725 | describe('#VerifyNumericValues', function() { 726 | it('Should be 1000.15.', function() { 727 | var values = ['1000.15', '$1000.15', '$1,000.15', '1000.15%', '1,000.15%']; 728 | 729 | for (var i = 0; i < values.length; i++) { 730 | var converted = parseFloat(values[i].replace(/[^0-9-\.]+/g, '')); 731 | 732 | chai.expect(converted).to.equal(1000.15); 733 | } 734 | }); 735 | }); 736 | 737 | describe('#VerifyCompare', function() { 738 | var strings = ['apples', 'oranges', 'pears'], 739 | numbers = [100, 200, 300]; 740 | 741 | var stringCompare = function (value, thresholds) { 742 | if (typeof value !== 'string') { 743 | throw 'Cannot make value comparison between a string and a ' + (typeof value) + '.'; 744 | } 745 | 746 | var thresholdsLength = thresholds.length; 747 | 748 | for (var i = 0; i < thresholdsLength; i++) { 749 | if (value == thresholds[i]) { 750 | return i; 751 | } 752 | } 753 | 754 | return -1; 755 | }; 756 | 757 | var numericCompare = function (value, thresholds) { 758 | if (typeof value !== 'number') { 759 | throw 'Cannot make value comparison between a number and a ' + (typeof value) + '.'; 760 | } 761 | 762 | var length = thresholds.length; 763 | 764 | for (var i = 0; i < length; i++) { 765 | if (value < thresholds[i]) { 766 | return i; 767 | } 768 | } 769 | 770 | return -1; 771 | }; 772 | 773 | it('Should be apples (0).', function() { 774 | var result = stringCompare('apples', strings); 775 | 776 | chai.expect(result).to.equal(strings.indexOf('apples')); 777 | }); 778 | 779 | it('Should be -1.', function() { 780 | var result = stringCompare('bananas', strings); 781 | 782 | chai.expect(result).to.equal(strings.indexOf('bananas')); 783 | }); 784 | 785 | it('Should be 200 (1).', function() { 786 | var result = numericCompare(115, numbers); 787 | 788 | chai.expect(result).to.equal(numbers.indexOf(200)); 789 | }); 790 | 791 | it('Should be -1.', function() { 792 | var result = numericCompare(400, numbers); 793 | 794 | chai.expect(result).to.equal(numbers.indexOf(400)); 795 | }); 796 | }); 797 | 798 | //describe('#VerifyGetPixelLength', function() { 799 | // var strings = ['Test', 'LongerTest', 'Test123', 'Test ', ' Test'], 800 | // expected = [25, 66, 47, 25, 25], 801 | // graph = d3.select('#test').relationshipGraph(), 802 | // addition = (graph.getPixelLength(strings[0]) !== expected[0]) ? 1 : 0; 803 | // 804 | // it('Should be the same.', function() { 805 | // for (var i = 0; i < strings.length; i++) { 806 | // var length = graph.getPixelLength(strings[i]); 807 | // 808 | // chai.expect(length).to.equal(expected[i] + addition); 809 | // } 810 | // }); 811 | //}); 812 | 813 | describe('#VerifyToTitleCase', function() { 814 | var toTitleCase = function(str) { 815 | return str.toLowerCase().split(' ').map(function(part) { 816 | return part.charAt(0).toUpperCase() + part.substring(1).toLowerCase(); 817 | }).join(' '); 818 | }; 819 | 820 | var strings = ['this is a test', 'another test', 'What about This?', 'MAYBE ALL CAPS??!!', '123', '$$$'], 821 | expected = ['This Is A Test', 'Another Test', 'What About This?', 'Maybe All Caps??!!', '123', '$$$']; 822 | 823 | it('Should be the same.', function() { 824 | for (var i = 0; i < strings.length; i++) { 825 | chai.expect(toTitleCase(strings[i])).to.equal(expected[i]); 826 | } 827 | }); 828 | }); 829 | 830 | describe('#VerifyValueKeyName', function() { 831 | it('Should be "cool value".', function() { 832 | var graph = d3.select('#test').relationshipGraph({ 833 | valueKeyName: 'cool value' 834 | }); 835 | 836 | chai.expect(graph.configuration.valueKeyName).to.equal('cool value'); 837 | }); 838 | }); 839 | 840 | describe('#VerifyGetId', function() { 841 | it('Should be returned correctly.', function() { 842 | var graph = d3.select('#graph').relationshipGraph(); 843 | 844 | chai.expect(graph.getId()).to.equals('graph'); 845 | }); 846 | }); 847 | }); 848 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | d3-relationshipgraph tests 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | --------------------------------------------------------------------------------