├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.json ├── demo ├── .env ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── _config.yml │ ├── csv-example.csv │ ├── favicon.png │ ├── flat-json-example.json │ ├── hierarchy.json │ ├── index.html │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── components │ ├── MixedNodeElement.js │ ├── MixedNodeInputElement.js │ ├── PureSvgNodeElement.js │ └── Switch │ │ ├── index.js │ │ └── styles.css │ ├── examples │ ├── d3-hierarchy-flare.json │ ├── hugeTree.js │ ├── org-chart.json │ └── reactRepoTree.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── mockData.js │ ├── serviceWorker.js │ └── setupTests.js ├── jest.config.json ├── jest ├── mocks │ ├── cssModule.js │ └── image.js ├── polyfills │ └── raf.js └── setup.ts ├── package-lock.json ├── package.json ├── src ├── Link │ ├── index.tsx │ └── tests │ │ └── index.test.js ├── Node │ ├── DefaultNodeElement.tsx │ ├── index.test.js │ └── index.tsx ├── Tree │ ├── TransitionGroupWrapper.tsx │ ├── index.tsx │ ├── tests │ │ ├── TransitionGroupWrapper.test.js │ │ ├── index.test.js │ │ └── mockData.js │ └── types.ts ├── globalCss.ts ├── index.ts └── types │ └── common.ts ├── tsconfig.esm.json ├── tsconfig.json └── typedoc.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | scripts 3 | playground 4 | coverage 5 | *.ts 6 | *.tsx -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb", 5 | "prettier" 6 | ], 7 | "env": { 8 | "browser": true, 9 | "node": true, 10 | "jest": true, 11 | "es6": true 12 | }, 13 | "plugins": [ 14 | "react", 15 | "jsx-a11y" 16 | ], 17 | "parserOptions": { 18 | "ecmaVersion": 6, 19 | "sourceType": "module", 20 | "ecmaFeatures": { 21 | "jsx": true 22 | } 23 | }, 24 | "rules": { 25 | "arrow-parens": [ 26 | "error", 27 | "as-needed" 28 | ], 29 | "arrow-body-style": [ 30 | 2, 31 | "as-needed" 32 | ], 33 | "comma-dangle": [ 34 | 2, 35 | "always-multiline" 36 | ], 37 | "import/imports-first": 0, 38 | "import/newline-after-import": 0, 39 | "import/no-dynamic-require": 0, 40 | "import/no-extraneous-dependencies": 0, 41 | "import/no-named-as-default": 0, 42 | "import/no-unresolved": 2, 43 | "import/prefer-default-export": 0, 44 | // "indent": [ 45 | // 2, 46 | // 2, 47 | // { 48 | // "SwitchCase": 1 49 | // } 50 | // ], 51 | "jsx-a11y/aria-props": 2, 52 | "jsx-a11y/heading-has-content": 0, 53 | "jsx-a11y/href-no-hash": 0, 54 | "jsx-a11y/img-has-alt": 0, 55 | "jsx-a11y/anchor-is-valid": 0, 56 | "jsx-a11y/label-has-for": 2, 57 | "jsx-a11y/mouse-events-have-key-events": 0, 58 | "jsx-a11y/role-has-required-aria-props": 2, 59 | "jsx-a11y/role-supports-aria-props": 2, 60 | "max-len": 0, 61 | "newline-per-chained-call": 0, 62 | "no-confusing-arrow": 0, 63 | "no-console": 1, 64 | "no-param-reassign": 0, 65 | "no-use-before-define": 0, 66 | "no-underscore-dangle": 0, 67 | "no-unused-expressions": 0, 68 | "no-unused-vars": 1, 69 | "prefer-template": 2, 70 | "class-methods-use-this": 0, 71 | "react/forbid-prop-types": 0, 72 | "react/jsx-first-prop-new-line": [ 73 | 2, 74 | "multiline" 75 | ], 76 | "react/jsx-filename-extension": 0, 77 | "react/jsx-no-target-blank": 0, 78 | "react/jsx-indent": 0, 79 | "react/require-extension": 0, 80 | "react/no-unused-prop-types": 1, 81 | "react/self-closing-comp": 0, 82 | "react/sort-comp": 0, 83 | "require-jsdoc": "warn" 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thank you for taking the time to report an issue with react-d3-tree! 2 | 3 | Feel free to delete any questions that do not apply. 4 | 5 | ## Are you reporting a bug, or opening a feature request? 6 | [Replace with your answer] 7 | 8 | ## What is the actual behavior/output? 9 | [Replace with your answer if relevant] 10 | 11 | ## What is the behavior/output you expect? 12 | [Replace with your answer if relevant] 13 | 14 | ## Can you consistently reproduce the issue/create a reproduction case (e.g. on https://codesandbox.io)? 15 | [Replace with your answer if relevant] 16 | 17 | ## What version of react-d3-tree are you using? 18 | [Replace with your answer] 19 | 20 | ## If react-d3-tree crashed with a traceback, please paste the full traceback below. 21 | [Replace with your traceback] 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run build --if-present 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Remove some common IDE working directories 30 | .idea 31 | .vscode 32 | 33 | # ignore Webpack stats output 34 | stats.json 35 | 36 | # Build outputs 37 | dist 38 | lib 39 | demo/build 40 | demo/public/docs 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "parser": "typescript", 9 | "overrides": [ 10 | { 11 | "files": "*.yaml", 12 | "options": { "parser": "yaml" } 13 | }, 14 | { 15 | "files": "*.json", 16 | "options": { "parser": "json" } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Ben Kremer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

React D3 Tree

2 | 3 |

4 | 5 | build status 6 | 7 | 8 | coverage status 9 | 10 | 11 | npm package 12 | 13 | 14 | npm package: downloads monthly 15 | 16 | 17 | npm package: minzipped size 18 | 19 | 20 | npm package: types 21 | 22 | 23 | code style: prettier 24 | 25 |

26 | 27 |

28 |

👾 Playground

29 |

📖 API Documentation (v3)

30 |

31 | 32 | React D3 Tree is a [React](http://facebook.github.io/react/) component that lets you represent hierarchical data (e.g. family trees, org charts, file directories) as an interactive tree graph with minimal setup, by leveraging [D3](https://d3js.org/)'s `tree` layout. 33 | 34 | > **Upgrading from v1? Check out the [v2 release notes](https://github.com/bkrem/react-d3-tree/releases/tag/v2.0.0).** 35 | 36 | > **[Legacy v1 docs](https://github.com/bkrem/react-d3-tree/tree/v1)** 37 | 38 | ## Contents 39 | - [Installation](#installation) 40 | - [Usage](#usage) 41 | - [Props](#props) 42 | - [Working with the default Tree](#working-with-the-default-tree) 43 | - [Providing `data`](#providing-data) 44 | - [Styling Nodes](#styling-nodes) 45 | - [Styling Links](#styling-links) 46 | - [Event Handlers](#event-handlers) 47 | - [Customizing the Tree](#customizing-the-tree) 48 | - [`renderCustomNodeElement`](#rendercustomnodeelement) 49 | - [`pathFunc`](#pathfunc) 50 | - [Providing your own `pathFunc`](#providing-your-own-pathfunc) 51 | - [Development](#development) 52 | - [Setup](#setup) 53 | - [Hot reloading](#hot-reloading) 54 | - [Contributors](#contributors) 55 | 56 | ## Installation 57 | ```bash 58 | npm i --save react-d3-tree 59 | ``` 60 | 61 | ## Usage 62 | ```jsx 63 | import React from 'react'; 64 | import Tree from 'react-d3-tree'; 65 | 66 | // This is a simplified example of an org chart with a depth of 2. 67 | // Note how deeper levels are defined recursively via the `children` property. 68 | const orgChart = { 69 | name: 'CEO', 70 | children: [ 71 | { 72 | name: 'Manager', 73 | attributes: { 74 | department: 'Production', 75 | }, 76 | children: [ 77 | { 78 | name: 'Foreman', 79 | attributes: { 80 | department: 'Fabrication', 81 | }, 82 | children: [ 83 | { 84 | name: 'Worker', 85 | }, 86 | ], 87 | }, 88 | { 89 | name: 'Foreman', 90 | attributes: { 91 | department: 'Assembly', 92 | }, 93 | children: [ 94 | { 95 | name: 'Worker', 96 | }, 97 | ], 98 | }, 99 | ], 100 | }, 101 | ], 102 | }; 103 | 104 | export default function OrgChartTree() { 105 | return ( 106 | // `` will fill width/height of its container; in this case `#treeWrapper`. 107 |
108 | 109 |
110 | ); 111 | } 112 | ``` 113 | 114 | ## Props 115 | For details on all props accepted by `Tree`, check out the [TreeProps reference docs](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html). 116 | 117 | The only required prop is [data](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#data), all other props on `Tree` are optional/pre-defined (see "Default value" on each prop definition). 118 | 119 | ## Working with the default Tree 120 | `react-d3-tree` provides default implementations for `Tree`'s nodes & links, which are intended to get you up & running with a working tree quickly. 121 | 122 | This section is focused on explaining **how to provide data, styles and event handlers for the default `Tree` implementation**. 123 | 124 | > Need more fine-grained control over how nodes & links appear/behave? Check out the [Customizing the Tree](#customizing-the-tree) section below. 125 | 126 | ### Providing `data` 127 | By default, `Tree` expects each node object in `data` to implement the [`RawNodeDatum` interface](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_types_common_.rawnodedatum.html): 128 | 129 | ```ts 130 | interface RawNodeDatum { 131 | name: string; 132 | attributes?: Record; 133 | children?: RawNodeDatum[]; 134 | } 135 | ``` 136 | 137 | The `orgChart` example in the [Usage](#usage) section above is an example of this: 138 | 139 | - Every node has at least a `name`. This is rendered as the **node's primary label**. 140 | - Some nodes have `attributes` defined (the `CEO` node does not). **The key-value pairs in `attributes` are rendered as a list of secondary labels**. 141 | - Nodes can have further `RawNodeDatum` objects nested inside them via the `children` key, creating a hierarchy from which the tree graph can be generated. 142 | 143 | ### Styling Nodes 144 | `Tree` provides the following props to style different types of nodes, all of which use an SVG `circle` by default: 145 | 146 | - `rootNodeClassName` - applied to the root node. 147 | - `branchNodeClassName` - applied to any node with 1+ children. 148 | - `leafNodeClassName` - applied to any node without children. 149 | 150 | To visually distinguish these three types of nodes from each other by color, we could provide each with their own class: 151 | 152 | ```css 153 | /* custom-tree.css */ 154 | 155 | .node__root > circle { 156 | fill: red; 157 | } 158 | 159 | .node__branch > circle { 160 | fill: yellow; 161 | } 162 | 163 | .node__leaf > circle { 164 | fill: green; 165 | /* Let's also make the radius of leaf nodes larger */ 166 | r: 40; 167 | } 168 | ``` 169 | 170 | ```jsx 171 | import React from 'react'; 172 | import Tree from 'react-d3-tree'; 173 | import './custom-tree.css'; 174 | 175 | // ... 176 | 177 | export default function StyledNodesTree() { 178 | return ( 179 |
180 | 186 |
187 | ); 188 | } 189 | ``` 190 | 191 | > For more details on the `className` props for nodes, see the [TreeProps reference docs](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html). 192 | 193 | ### Styling Links 194 | `Tree` provides the `pathClassFunc` property to pass additional classNames to every link to be rendered. 195 | 196 | Each link calls `pathClassFunc` with its own `TreeLinkDatum` and the tree's current `orientation`. `Tree` expects `pathClassFunc` to return a `className` string. 197 | 198 | ```jsx 199 | function StyledLinksTree() { 200 | const getDynamicPathClass = ({ source, target }, orientation) => { 201 | if (!target.children) { 202 | // Target node has no children -> this link leads to a leaf node. 203 | return 'link__to-leaf'; 204 | } 205 | 206 | // Style it as a link connecting two branch nodes by default. 207 | return 'link__to-branch'; 208 | }; 209 | 210 | return ( 211 | 'custom-link'} 215 | // Want to apply multiple static classes? `Array.join` is your friend :) 216 | pathClassFunc={() => ['custom-link', 'extra-custom-link'].join(' ')} 217 | // Dynamically determine which `className` to pass based on the link's properties. 218 | pathClassFunc={getDynamicPathClass} 219 | /> 220 | ); 221 | } 222 | ``` 223 | 224 | > For more details, see the `PathClassFunction` [reference docs](https://bkrem.github.io/react-d3-tree/docs/modules/_src_types_common_.html#pathclassfunction). 225 | 226 | ### Event Handlers 227 | `Tree` exposes the following event handler callbacks by default: 228 | 229 | - [onLinkClick](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#onlinkclick) 230 | - [onLinkMouseOut](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#onlinkmouseout) 231 | - [onLinkMouseOver](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src__tree_types_.treeprops.html#onlinkmouseover) 232 | - [onNodeClick](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#onnodeclick) 233 | - [onNodeMouseOut](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#onnodemouseout) 234 | - [onNodeMouseOver](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#onnodemouseover) 235 | 236 | > **Note:** Nodes are expanded/collapsed whenever `onNodeClick` fires. To prevent this, set the [`collapsible` prop](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#collapsible) to `false`. 237 | > `onNodeClick` will still fire, but it will not change the target node's expanded/collapsed state. 238 | 239 | ## Customizing the Tree 240 | 241 | 242 | ### `renderCustomNodeElement` 243 | The [`renderCustomNodeElement` prop](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#rendercustomnodeelement) accepts a **custom render function that will be used for every node in the tree.** 244 | 245 | Cases where you may find rendering your own `Node` element useful include: 246 | 247 | - Using a **different SVG tag for your nodes** (instead of the default ``) - [Example (codesandbox.io)](https://codesandbox.io/s/rd3t-v2-custom-svg-tag-1bq1e?file=/src/App.js) 248 | - Gaining **fine-grained control over event handling** (e.g. to implement events not covered by the default API) - [Example (codesandbox.io)](https://codesandbox.io/s/rd3t-v2-custom-event-handlers-5pwxw?file=/src/App.js) 249 | - Building **richer & more complex nodes/labels** by leveraging the `foreignObject` tag to render HTML inside the SVG namespace - [Example (codesandbox.io)](https://codesandbox.io/s/rd3t-v2-custom-with-foreignobject-0mfj8?file=/src/App.js) 250 | 251 | ### `pathFunc` 252 | The [`pathFunc` prop](https://bkrem.github.io/react-d3-tree/docs/interfaces/_src_tree_types_.treeprops.html#pathfunc) accepts a predefined `PathFunctionOption` enum or a user-defined `PathFunction`. 253 | 254 | By changing or providing your own `pathFunc`, you are able to change how links between nodes of the tree (which are SVG `path` tags under the hood) are drawn. 255 | 256 | The currently [available enums](https://bkrem.github.io/react-d3-tree/docs/modules/_src_types_common_.html#pathfunctionoption) are: 257 | - `diagonal` (default) 258 | - `elbow` 259 | - `straight` 260 | - `step` 261 | 262 | > Want to see how each option looks? [Try them out on the playground](https://bkrem.github.io/react-d3-tree). 263 | 264 | #### Providing your own `pathFunc` 265 | If none of the available path functions suit your needs, you're also able to provide a custom `PathFunction`: 266 | 267 | ```jsx 268 | function CustomPathFuncTree() { 269 | const straightPathFunc = (linkDatum, orientation) => { 270 | const { source, target } = linkDatum; 271 | return orientation === 'horizontal' 272 | ? `M${source.y},${source.x}L${target.y},${target.x}` 273 | : `M${source.x},${source.y}L${target.x},${target.y}`; 274 | }; 275 | 276 | return ( 277 | 282 | ); 283 | } 284 | ``` 285 | 286 | > For more details, see the [`PathFunction` reference docs](https://bkrem.github.io/react-d3-tree/docs/modules/_types_common_.html#pathfunction). 287 | 288 | ## Development 289 | ### Setup 290 | To set up `react-d3-tree` for local development, clone the repo and follow the steps below: 291 | 292 | ```bash 293 | # 1. Set up the library, create a reference to it for symlinking. 294 | cd react-d3-tree 295 | npm i 296 | npm link 297 | 298 | # 2. Set up the demo/playground, symlink to the local copy of `react-d3-tree`. 299 | cd demo 300 | npm i 301 | npm link react-d3-tree 302 | ``` 303 | 304 | > **Tip:** If you'd prefer to use your own app for development instead of the demo, simply run `npm link react-d3-tree` in your app's root folder instead of the demo's :) 305 | 306 | ### Hot reloading 307 | ```bash 308 | npm run build:watch 309 | ``` 310 | 311 | If you're using `react-d3-tree/demo` for development, open up another terminal window in the `demo` directory and call: 312 | ```bash 313 | npm start 314 | ``` 315 | 316 | ## Contributors 317 | A huge thank you to all the [contributors](https://github.com/bkrem/react-d3-tree/graphs/contributors), as well as users who have opened issues with thoughtful suggestions and feedback. 318 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript", 5 | "@babel/preset-react" 6 | ], 7 | "plugins": ["@babel/plugin-proposal-class-properties"] 8 | } 9 | -------------------------------------------------------------------------------- /demo/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /demo/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "parser": "typescript", 9 | "overrides": [ 10 | { 11 | "files": "*.yml", 12 | "options": { "parser": "yaml" } 13 | }, 14 | { 15 | "files": "*.json", 16 | "options": { "parser": "json" } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rd3t-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://bkrem.github.io/react-d3-tree", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "clone": "^2.1.2", 11 | "gh-pages": "^3.1.0", 12 | "react": "^18.2.0", 13 | "react-d3-tree": "^3.6.3", 14 | "react-dom": "^18.2.0", 15 | "react-scripts": "^3.4.4" 16 | }, 17 | "scripts": { 18 | "start": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts start", 19 | "build": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject", 22 | "deploy": "gh-pages -d build" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app" 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo/public/_config.yml: -------------------------------------------------------------------------------- 1 | # We need this to allow GH Pages to include underscore-prefixed Typedoc pages. 2 | include: 3 | - '_*_.html' 4 | - '_*_.*.html' 5 | -------------------------------------------------------------------------------- /demo/public/csv-example.csv: -------------------------------------------------------------------------------- 1 | parent,child,CSV Attribute A,CSV Attribute B 2 | "CSVNode1","CSVNode2",22,someValue 3 | "CSVNode1","CSVNode3",23,someValue 4 | "CSVNode2","CSVNode4",1,someValue 5 | "CSVNode2","CSVNode5",2,someValue 6 | "CSVNode2","CSVNode6",3,someValue 7 | "CSVNode2","CSVNode7",4,someValue 8 | "CSVNode2","CSVNode8",5,someValue 9 | "CSVNode2","CSVNode9",6,someValue 10 | "CSVNode2","CSVNode10",7,someValue 11 | "CSVNode4","CSVNode11",8,someValue 12 | "CSVNode7","CSVNode12",9,someValue 13 | "CSVNode9","CSVNode13",10,someValue 14 | "CSVNode10","CSVNode14",11,someValue 15 | "CSVNode11","CSVNode15",12,someValue 16 | "CSVNode3","CSVNode16",13,someValue 17 | "CSVNode16","CSVNode17",14,someValue 18 | "CSVNode16","CSVNode18",15,someValue 19 | "CSVNode16","CSVNode19",16,someValue 20 | "CSVNode16","CSVNode20",17,someValue 21 | "CSVNode17","CSVNode21",18,someValue 22 | "CSVNode18","CSVNode22",19,someValue 23 | "CSVNode19","CSVNode23",20,someValue 24 | "CSVNode20","CSVNode24",21,someValue 25 | -------------------------------------------------------------------------------- /demo/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkrem/react-d3-tree/94e56314880a8d3f4d5d4c55de1ef035e1149438/demo/public/favicon.png -------------------------------------------------------------------------------- /demo/public/flat-json-example.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "parent": "FlatJSONNode1", 3 | "child": "FlatJSONNode2", 4 | "FlatJSON Attribute A": "22", 5 | "FlatJSON Attribute B": "someValue" 6 | }, { 7 | "parent": "FlatJSONNode1", 8 | "child": "FlatJSONNode3", 9 | "FlatJSON Attribute A": "23", 10 | "FlatJSON Attribute B": "someValue" 11 | }, { 12 | "parent": "FlatJSONNode2", 13 | "child": "FlatJSONNode4", 14 | "FlatJSON Attribute A": "1", 15 | "FlatJSON Attribute B": "someValue" 16 | }, { 17 | "parent": "FlatJSONNode2", 18 | "child": "FlatJSONNode5", 19 | "FlatJSON Attribute A": "2", 20 | "FlatJSON Attribute B": "someValue" 21 | }, { 22 | "parent": "FlatJSONNode2", 23 | "child": "FlatJSONNode6", 24 | "FlatJSON Attribute A": "3", 25 | "FlatJSON Attribute B": "someValue" 26 | }, { 27 | "parent": "FlatJSONNode2", 28 | "child": "FlatJSONNode7", 29 | "FlatJSON Attribute A": "4", 30 | "FlatJSON Attribute B": "someValue" 31 | }, { 32 | "parent": "FlatJSONNode2", 33 | "child": "FlatJSONNode8", 34 | "FlatJSON Attribute A": "5", 35 | "FlatJSON Attribute B": "someValue" 36 | }, { 37 | "parent": "FlatJSONNode2", 38 | "child": "FlatJSONNode9", 39 | "FlatJSON Attribute A": "6", 40 | "FlatJSON Attribute B": "someValue" 41 | }, { 42 | "parent": "FlatJSONNode2", 43 | "child": "FlatJSONNode10", 44 | "FlatJSON Attribute A": "7", 45 | "FlatJSON Attribute B": "someValue" 46 | }, { 47 | "parent": "FlatJSONNode4", 48 | "child": "FlatJSONNode11", 49 | "FlatJSON Attribute A": "8", 50 | "FlatJSON Attribute B": "someValue" 51 | }, { 52 | "parent": "FlatJSONNode7", 53 | "child": "FlatJSONNode12", 54 | "FlatJSON Attribute A": "9", 55 | "FlatJSON Attribute B": "someValue" 56 | }, { 57 | "parent": "FlatJSONNode9", 58 | "child": "FlatJSONNode13", 59 | "FlatJSON Attribute A": "10", 60 | "FlatJSON Attribute B": "someValue" 61 | }, { 62 | "parent": "FlatJSONNode10", 63 | "child": "FlatJSONNode14", 64 | "FlatJSON Attribute A": "11", 65 | "FlatJSON Attribute B": "someValue" 66 | }, { 67 | "parent": "FlatJSONNode11", 68 | "child": "FlatJSONNode15", 69 | "FlatJSON Attribute A": "12", 70 | "FlatJSON Attribute B": "someValue" 71 | }, { 72 | "parent": "FlatJSONNode3", 73 | "child": "FlatJSONNode16", 74 | "FlatJSON Attribute A": "13", 75 | "FlatJSON Attribute B": "someValue" 76 | }, { 77 | "parent": "FlatJSONNode16", 78 | "child": "FlatJSONNode17", 79 | "FlatJSON Attribute A": "14", 80 | "FlatJSON Attribute B": "someValue" 81 | }, { 82 | "parent": "FlatJSONNode16", 83 | "child": "FlatJSONNode18", 84 | "FlatJSON Attribute A": "15", 85 | "FlatJSON Attribute B": "someValue" 86 | }, { 87 | "parent": "FlatJSONNode16", 88 | "child": "FlatJSONNode19", 89 | "FlatJSON Attribute A": "16", 90 | "FlatJSON Attribute B": "someValue" 91 | }, { 92 | "parent": "FlatJSONNode16", 93 | "child": "FlatJSONNode20", 94 | "FlatJSON Attribute A": "17", 95 | "FlatJSON Attribute B": "someValue" 96 | }, { 97 | "parent": "FlatJSONNode17", 98 | "child": "FlatJSONNode21", 99 | "FlatJSON Attribute A": "18", 100 | "FlatJSON Attribute B": "someValue" 101 | }, { 102 | "parent": "FlatJSONNode18", 103 | "child": "FlatJSONNode22", 104 | "FlatJSON Attribute A": "19", 105 | "FlatJSON Attribute B": "someValue" 106 | }, { 107 | "parent": "FlatJSONNode19", 108 | "child": "FlatJSONNode23", 109 | "FlatJSON Attribute A": "20", 110 | "FlatJSON Attribute B": "someValue" 111 | }, { 112 | "parent": "FlatJSONNode20", 113 | "child": "FlatJSONNode24", 114 | "FlatJSON Attribute A": "21", 115 | "FlatJSON Attribute B": "someValue" 116 | }] 117 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | React D3 Tree 19 | 20 | 21 | 22 |
23 | 33 | 44 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | #root, 2 | .App { 3 | min-width:100%; 4 | min-height: 100%; 5 | } 6 | 7 | .title { 8 | text-align: center; 9 | margin-bottom: 20px; 10 | } 11 | 12 | .demo-container { 13 | position: fixed; 14 | display: flex; 15 | flex-direction: row; 16 | height: 100%; 17 | width: 100%; 18 | } 19 | 20 | .column-left { 21 | width: 30%; 22 | display: flex; 23 | flex-direction: column; 24 | background: #f7fafc; 25 | border-right: 1px solid #2d3748; 26 | } 27 | 28 | .controls-container { 29 | display: flex; 30 | flex-direction: column; 31 | flex-basis: 1; 32 | overflow-y: scroll; 33 | } 34 | 35 | .state-container { 36 | display: none; 37 | flex-direction: column; 38 | flex-basis: 30%; 39 | border-top: 1px solid black; 40 | } 41 | .state-container > h4 { 42 | color: black; 43 | } 44 | 45 | .state { 46 | width: 100%; 47 | height: 100%; 48 | } 49 | 50 | .column-right { 51 | height: 100%; 52 | width: 100%; 53 | } 54 | 55 | .tree-container { 56 | height: 100%; 57 | /* background: #ccf6c8; */ 58 | } 59 | 60 | .prop-container { 61 | padding: 2px 5px; 62 | margin: 15px 10px; 63 | } 64 | .prop { 65 | font-weight: bold; 66 | display: block; 67 | } 68 | 69 | .prop-large { 70 | font-size: 16px; 71 | } 72 | 73 | .sub-prop { 74 | padding: 5px; 75 | } 76 | 77 | .btn-controls { 78 | color: #f7fafc; 79 | background-color: #2d3748; 80 | transition: color 0.15s ease, border-color 0.15s ease; 81 | } 82 | 83 | .btn-controls:hover, 84 | .btn-controls:focus, 85 | .btn-controls:active { 86 | color: white; 87 | border-color: #f7fafc; 88 | } 89 | 90 | .tree-stats-container { 91 | text-align: center; 92 | padding: 0.5rem 2rem; 93 | border-bottom: 1px solid #2d3748; 94 | font-weight: bold; 95 | } 96 | 97 | /* Custom node classes */ 98 | .demo-node > circle { 99 | fill: #2d3748; 100 | stroke: #2d3748; 101 | } 102 | 103 | /* Custom path classes */ 104 | .my-path-class { 105 | stroke: royalblue; 106 | stroke-width: 10; 107 | } 108 | 109 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import clone from 'clone'; 3 | import Tree from 'react-d3-tree'; 4 | import { version } from 'react-d3-tree/package.json'; 5 | import Switch from './components/Switch'; 6 | import MixedNodeElement from './components/MixedNodeElement'; 7 | import PureSvgNodeElement from './components/PureSvgNodeElement'; 8 | import MixedNodeInputElement from './components/MixedNodeInputElement'; 9 | import './App.css'; 10 | 11 | // Data examples 12 | import orgChartJson from './examples/org-chart.json'; 13 | import flareJson from './examples/d3-hierarchy-flare.json'; 14 | import reactTree from './examples/reactRepoTree'; 15 | 16 | console.log('Demo React version: ', React.version); 17 | 18 | const customNodeFnMapping = { 19 | svg: { 20 | description: 'Default - Pure SVG node & label (IE11 compatible)', 21 | fn: (rd3tProps, appState) => ( 22 | 27 | ), 28 | }, 29 | mixed: { 30 | description: 'MixedNodeElement - SVG `circle` + `foreignObject` label', 31 | fn: ({ nodeDatum, toggleNode }, appState) => ( 32 | 42 | ), 43 | }, 44 | input: { 45 | description: 'MixedNodeElement - Interactive nodes with inputs', 46 | fn: ({ nodeDatum, toggleNode }, appState) => ( 47 | 57 | ), 58 | }, 59 | }; 60 | 61 | const countNodes = (count = 0, n) => { 62 | // Count the current node 63 | count += 1; 64 | 65 | // Base case: reached a leaf node. 66 | if (!n.children) { 67 | return count; 68 | } 69 | 70 | // Keep traversing children while updating `count` until we reach the base case. 71 | return n.children.reduce((sum, child) => countNodes(sum, child), count); 72 | }; 73 | 74 | class App extends Component { 75 | constructor() { 76 | super(); 77 | 78 | this.addedNodesCount = 0; 79 | 80 | this.state = { 81 | data: orgChartJson, 82 | totalNodeCount: countNodes(0, Array.isArray(orgChartJson) ? orgChartJson[0] : orgChartJson), 83 | orientation: 'horizontal', 84 | dimensions: undefined, 85 | centeringTransitionDuration: 800, 86 | translateX: 200, 87 | translateY: 300, 88 | collapsible: true, 89 | shouldCollapseNeighborNodes: false, 90 | initialDepth: 1, 91 | depthFactor: undefined, 92 | zoomable: true, 93 | draggable: true, 94 | zoom: 1, 95 | scaleExtent: { min: 0.1, max: 1 }, 96 | separation: { siblings: 2, nonSiblings: 2 }, 97 | nodeSize: { x: 200, y: 200 }, 98 | enableLegacyTransitions: false, 99 | transitionDuration: 500, 100 | renderCustomNodeElement: customNodeFnMapping['svg'].fn, 101 | styles: { 102 | nodes: { 103 | node: { 104 | circle: { 105 | fill: '#52e2c5', 106 | }, 107 | attributes: { 108 | stroke: '#000', 109 | }, 110 | }, 111 | leafNode: { 112 | circle: { 113 | fill: 'transparent', 114 | }, 115 | attributes: { 116 | stroke: '#000', 117 | }, 118 | }, 119 | }, 120 | }, 121 | }; 122 | 123 | this.setTreeData = this.setTreeData.bind(this); 124 | this.setLargeTree = this.setLargeTree.bind(this); 125 | this.setOrientation = this.setOrientation.bind(this); 126 | this.setPathFunc = this.setPathFunc.bind(this); 127 | this.handleChange = this.handleChange.bind(this); 128 | this.handleFloatChange = this.handleFloatChange.bind(this); 129 | this.toggleCollapsible = this.toggleCollapsible.bind(this); 130 | this.toggleZoomable = this.toggleZoomable.bind(this); 131 | this.toggleDraggable = this.toggleDraggable.bind(this); 132 | this.toggleCenterNodes = this.toggleCenterNodes.bind(this); 133 | this.setScaleExtent = this.setScaleExtent.bind(this); 134 | this.setSeparation = this.setSeparation.bind(this); 135 | this.setNodeSize = this.setNodeSize.bind(this); 136 | } 137 | 138 | setTreeData(data) { 139 | this.setState({ 140 | data, 141 | totalNodeCount: countNodes(0, Array.isArray(data) ? data[0] : data), 142 | }); 143 | } 144 | 145 | setLargeTree(data) { 146 | this.setState({ 147 | data, 148 | transitionDuration: 0, 149 | }); 150 | } 151 | 152 | setOrientation(orientation) { 153 | this.setState({ orientation }); 154 | } 155 | 156 | setPathFunc(pathFunc) { 157 | this.setState({ pathFunc }); 158 | } 159 | 160 | handleChange(evt) { 161 | const target = evt.target; 162 | const parsedIntValue = parseInt(target.value, 10); 163 | if (target.value === '') { 164 | this.setState({ 165 | [target.name]: undefined, 166 | }); 167 | } else if (!isNaN(parsedIntValue)) { 168 | this.setState({ 169 | [target.name]: parsedIntValue, 170 | }); 171 | } 172 | } 173 | 174 | handleFloatChange(evt) { 175 | const target = evt.target; 176 | const parsedFloatValue = parseFloat(target.value); 177 | if (target.value === '') { 178 | this.setState({ 179 | [target.name]: undefined, 180 | }); 181 | } else if (!isNaN(parsedFloatValue)) { 182 | this.setState({ 183 | [target.name]: parsedFloatValue, 184 | }); 185 | } 186 | } 187 | 188 | handleCustomNodeFnChange = evt => { 189 | const customNodeKey = evt.target.value; 190 | 191 | this.setState({ renderCustomNodeElement: customNodeFnMapping[customNodeKey].fn }); 192 | }; 193 | 194 | toggleCollapsible() { 195 | this.setState(prevState => ({ collapsible: !prevState.collapsible })); 196 | } 197 | 198 | toggleCollapseNeighborNodes = () => { 199 | this.setState(prevState => ({ 200 | shouldCollapseNeighborNodes: !prevState.shouldCollapseNeighborNodes, 201 | })); 202 | }; 203 | 204 | toggleZoomable() { 205 | this.setState(prevState => ({ zoomable: !prevState.zoomable })); 206 | } 207 | 208 | toggleDraggable() { 209 | this.setState(prevState => ({ draggable: !prevState.draggable })); 210 | } 211 | 212 | toggleCenterNodes() { 213 | if (this.state.dimensions !== undefined) { 214 | this.setState({ 215 | dimensions: undefined, 216 | }); 217 | } else { 218 | if (this.treeContainer) { 219 | const { width, height } = this.treeContainer.getBoundingClientRect(); 220 | this.setState({ 221 | dimensions: { 222 | width, 223 | height, 224 | }, 225 | }); 226 | } 227 | } 228 | } 229 | 230 | setScaleExtent(scaleExtent) { 231 | this.setState({ scaleExtent }); 232 | } 233 | 234 | setSeparation(separation) { 235 | if (!isNaN(separation.siblings) && !isNaN(separation.nonSiblings)) { 236 | this.setState({ separation }); 237 | } 238 | } 239 | 240 | setNodeSize(nodeSize) { 241 | if (!isNaN(nodeSize.x) && !isNaN(nodeSize.y)) { 242 | this.setState({ nodeSize }); 243 | } 244 | } 245 | 246 | addChildNode = () => { 247 | const data = clone(this.state.data); 248 | const target = data[0].children ? data[0].children : data[0]._children; 249 | this.addedNodesCount++; 250 | target.push({ 251 | name: `Inserted Node ${this.addedNodesCount}`, 252 | id: `inserted-node-${this.addedNodesCount}`, 253 | }); 254 | this.setState({ 255 | data, 256 | }); 257 | }; 258 | 259 | removeChildNode = () => { 260 | const data = clone(this.state.data); 261 | const target = data[0].children ? data[0].children : data[0]._children; 262 | target.pop(); 263 | this.addedNodesCount--; 264 | this.setState({ 265 | data, 266 | }); 267 | }; 268 | 269 | componentDidMount() { 270 | const dimensions = this.treeContainer.getBoundingClientRect(); 271 | this.setState({ 272 | translateX: dimensions.width / 2.5, 273 | translateY: dimensions.height / 2, 274 | }); 275 | } 276 | 277 | render() { 278 | return ( 279 |
280 |
281 |
282 |
283 |
284 |

React D3 Tree

285 |

v{version}

286 |

287 | 288 | 289 | 📖 290 | {' '} 291 | API Docs (v3) 292 | 293 |

294 |

Examples

295 |
296 | 303 |
304 |
305 | 312 | 319 |
320 |
321 | 322 | {/*
323 |

324 | Dynamically updating data 325 |

326 | 333 | 340 |
*/} 341 | 342 |
343 |

Orientation

344 | 351 | 358 |
359 | 360 |
361 |

Path Function

362 | 369 | 376 | 383 | 390 |
391 | 392 |
393 | 396 | 403 |
404 | 405 |
406 |

Collapsible

407 | 412 |
413 | 414 |
415 |

Zoomable

416 | 421 |
422 | 423 |
424 |

Draggable

425 | 430 |
431 | 432 |
433 |

434 | Center Nodes on Click (via dimensions prop) 435 |

436 | 441 |
442 | 443 |
444 |

Collapse neighbor nodes

445 | 450 |
451 | 452 |
453 |

Enable Legacy Transitions

454 | 458 | this.setState(prevState => ({ 459 | enableLegacyTransitions: !prevState.enableLegacyTransitions, 460 | })) 461 | } 462 | /> 463 |
464 | 465 |
466 |
467 | 470 | 477 |
478 |
479 | 482 | 489 |
490 |
491 | 492 |
493 | 496 | 504 |
505 | 506 |
507 | 510 | 517 |
518 | 519 | {/*
{`Zoomable: ${this.state.zoomable}`}
*/} 520 | 521 |
522 | 525 | 532 |
533 | 534 |
535 | Scale Extent 536 | 539 | 545 | this.setScaleExtent({ 546 | min: parseFloat(evt.target.value), 547 | max: this.state.scaleExtent.max, 548 | }) 549 | } 550 | /> 551 | 554 | 560 | this.setScaleExtent({ 561 | min: this.state.scaleExtent.min, 562 | max: parseFloat(evt.target.value), 563 | }) 564 | } 565 | /> 566 |
567 | 568 |
569 | Node separation 570 | 573 | 579 | this.setSeparation({ 580 | siblings: parseFloat(evt.target.value), 581 | nonSiblings: this.state.separation.nonSiblings, 582 | }) 583 | } 584 | /> 585 | 588 | 594 | this.setSeparation({ 595 | siblings: this.state.separation.siblings, 596 | nonSiblings: parseFloat(evt.target.value), 597 | }) 598 | } 599 | /> 600 |
601 | 602 |
603 | Node size 604 | 607 | 613 | this.setNodeSize({ x: parseFloat(evt.target.value), y: this.state.nodeSize.y }) 614 | } 615 | /> 616 | 619 | 625 | this.setNodeSize({ x: this.state.nodeSize.x, y: parseFloat(evt.target.value) }) 626 | } 627 | /> 628 |
629 | 630 |
631 | 634 | 641 |
642 |
643 | 646 | 653 |
654 |
655 |
656 | 657 |
658 |
659 | Total nodes in tree: {this.state.totalNodeCount} 660 |
661 |
(this.treeContainer = tc)} className="tree-container"> 662 | this.state.renderCustomNodeElement(rd3tProps, this.state) 668 | : undefined 669 | } 670 | rootNodeClassName="demo-node" 671 | branchNodeClassName="demo-node" 672 | orientation={this.state.orientation} 673 | dimensions={this.state.dimensions} 674 | centeringTransitionDuration={this.state.centeringTransitionDuration} 675 | translate={{ x: this.state.translateX, y: this.state.translateY }} 676 | pathFunc={this.state.pathFunc} 677 | collapsible={this.state.collapsible} 678 | initialDepth={this.state.initialDepth} 679 | zoomable={this.state.zoomable} 680 | draggable={this.state.draggable} 681 | zoom={this.state.zoom} 682 | scaleExtent={this.state.scaleExtent} 683 | nodeSize={this.state.nodeSize} 684 | separation={this.state.separation} 685 | enableLegacyTransitions={this.state.enableLegacyTransitions} 686 | transitionDuration={this.state.transitionDuration} 687 | depthFactor={this.state.depthFactor} 688 | styles={this.state.styles} 689 | shouldCollapseNeighborNodes={this.state.shouldCollapseNeighborNodes} 690 | // onUpdate={(...args) => {console.log(args)}} 691 | onNodeClick={(node, evt) => { 692 | console.log('onNodeClick', node, evt); 693 | }} 694 | onNodeMouseOver={(...args) => { 695 | console.log('onNodeMouseOver', args); 696 | }} 697 | onNodeMouseOut={(...args) => { 698 | console.log('onNodeMouseOut', args); 699 | }} 700 | onLinkClick={(...args) => { 701 | console.log('onLinkClick'); 702 | console.log(args); 703 | }} 704 | onLinkMouseOver={(...args) => { 705 | console.log('onLinkMouseOver', args); 706 | }} 707 | onLinkMouseOut={(...args) => { 708 | console.log('onLinkMouseOut', args); 709 | }} 710 | /> 711 |
712 |
713 |
714 |
715 | ); 716 | } 717 | } 718 | 719 | export default App; 720 | -------------------------------------------------------------------------------- /demo/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /demo/src/components/MixedNodeElement.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const MixedNodeElement = ({ nodeData = {}, triggerNodeToggle, foreignObjectProps = {} }) => { 4 | return ( 5 | 6 | 7 | 8 |
18 |

{nodeData.name}

19 |
    20 | {nodeData.attributes && 21 | Object.keys(nodeData.attributes).map((labelKey, i) => ( 22 |
  • 23 | {labelKey}: {nodeData.attributes[labelKey]} 24 |
  • 25 | ))} 26 |
27 | {nodeData.children && ( 28 | 31 | )} 32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | export default MixedNodeElement; 39 | -------------------------------------------------------------------------------- /demo/src/components/MixedNodeInputElement.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const MixedNodeElement = ({ nodeData = {}, triggerNodeToggle, foreignObjectProps = {} }) => { 4 | return ( 5 | 6 | 7 | 8 |
20 |

{nodeData.name}

21 |
    22 | {nodeData.attributes && 23 | Object.keys(nodeData.attributes).map((labelKey, i) => ( 24 |
  • 25 | {labelKey}: {nodeData.attributes[labelKey]} 26 |
  • 27 | ))} 28 |
29 | 34 | {nodeData.children && ( 35 | 38 | )} 39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default MixedNodeElement; 46 | -------------------------------------------------------------------------------- /demo/src/components/PureSvgNodeElement.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const textLayout = { 4 | vertical: { 5 | title: { 6 | textAnchor: 'start', 7 | x: 40, 8 | }, 9 | attributes: {}, 10 | attribute: { 11 | x: 40, 12 | dy: '1.2em', 13 | }, 14 | }, 15 | horizontal: { 16 | title: { 17 | textAnchor: 'start', 18 | y: 40, 19 | }, 20 | attributes: { 21 | x: 0, 22 | y: 40, 23 | }, 24 | attribute: { 25 | x: 0, 26 | dy: '1.2em', 27 | }, 28 | }, 29 | }; 30 | 31 | const PureSvgNodeElement = ({ nodeDatum, orientation, toggleNode, onNodeClick }) => { 32 | return ( 33 | <> 34 | 35 | 36 | 41 | {nodeDatum.name} 42 | 43 | 44 | {nodeDatum.attributes && 45 | Object.entries(nodeDatum.attributes).map(([labelKey, labelValue], i) => ( 46 | 47 | {labelKey}: {labelValue} 48 | 49 | ))} 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default PureSvgNodeElement; 57 | -------------------------------------------------------------------------------- /demo/src/components/Switch/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Component } from 'react'; 3 | import './styles.css'; 4 | 5 | class Input extends Component { 6 | static propTypes = { 7 | name: PropTypes.string.isRequired, 8 | onChange: PropTypes.func.isRequired, 9 | checked: PropTypes.bool.isRequired, 10 | variable: PropTypes.object 11 | }; 12 | 13 | render() { 14 | const { variable, name, onChange, checked } = this.props; 15 | return ( 16 |
17 | 19 | 23 |
24 | ); 25 | } 26 | } 27 | 28 | export default Input; -------------------------------------------------------------------------------- /demo/src/components/Switch/styles.css: -------------------------------------------------------------------------------- 1 | .onoffswitch { 2 | position: relative; 3 | width: 75px; 4 | -webkit-user-select: none; 5 | -moz-user-select: none; 6 | -ms-user-select: none; 7 | user-select: none; 8 | } 9 | 10 | .onoffswitch-checkbox { 11 | display: none; 12 | } 13 | 14 | .onoffswitch-label { 15 | display: block; 16 | overflow: hidden; 17 | cursor: pointer; 18 | border: 1px solid #ccc; 19 | border-radius: 4px; 20 | } 21 | 22 | .onoffswitch-inner { 23 | display: block; 24 | width: 200%; 25 | margin-left: -100%; 26 | transition: margin 0.3s ease-in 0s; 27 | } 28 | 29 | .onoffswitch-inner:before, 30 | .onoffswitch-inner:after { 31 | display: block; 32 | float: left; 33 | width: 50%; 34 | height: 33px; 35 | padding: 0; 36 | line-height: 33px; 37 | font-size: 14px; 38 | color: white; 39 | box-sizing: border-box; 40 | font-weight: normal; 41 | } 42 | 43 | .onoffswitch-inner:before { 44 | content: "YES"; 45 | padding-left: 8px; 46 | background-color: #ffffff; 47 | color: #2d3748; 48 | } 49 | 50 | .onoffswitch-inner:after { 51 | content: "NO"; 52 | padding-right: 8px; 53 | background-color: #ffffff; 54 | text-align: right; 55 | color: #c4c4c4; 56 | } 57 | 58 | .onoffswitch-switch { 59 | display: block; 60 | width: 25px; 61 | margin: 4.5px; 62 | background-color: #c4c4c4; 63 | position: absolute; 64 | top: 0; 65 | bottom: 0; 66 | right: 37px; 67 | border: 2px solid #fff; 68 | border-radius: 4px; 69 | transition: all 0.3s ease-in 0s; 70 | } 71 | 72 | .onoffswitch-checkbox:checked+.onoffswitch-label .onoffswitch-inner { 73 | margin-left: 0; 74 | } 75 | 76 | .onoffswitch-checkbox:checked+.onoffswitch-label .onoffswitch-switch { 77 | right: 0px; 78 | background-color: #2d3748; 79 | } -------------------------------------------------------------------------------- /demo/src/examples/d3-hierarchy-flare.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flare", 3 | "children": [ 4 | { 5 | "name": "analytics", 6 | "children": [ 7 | { 8 | "name": "cluster", 9 | "children": [ 10 | { "name": "AgglomerativeCluster", "value": 3938 }, 11 | { "name": "CommunityStructure", "value": 3812 }, 12 | { "name": "HierarchicalCluster", "value": 6714 }, 13 | { "name": "MergeEdge", "value": 743 } 14 | ] 15 | }, 16 | { 17 | "name": "graph", 18 | "children": [ 19 | { "name": "BetweennessCentrality", "value": 3534 }, 20 | { "name": "LinkDistance", "value": 5731 }, 21 | { "name": "MaxFlowMinCut", "value": 7840 }, 22 | { "name": "ShortestPaths", "value": 5914 }, 23 | { "name": "SpanningTree", "value": 3416 } 24 | ] 25 | }, 26 | { 27 | "name": "optimization", 28 | "children": [{ "name": "AspectRatioBanker", "value": 7074 }] 29 | } 30 | ] 31 | }, 32 | { 33 | "name": "animate", 34 | "children": [ 35 | { "name": "Easing", "value": 17010 }, 36 | { "name": "FunctionSequence", "value": 5842 }, 37 | { 38 | "name": "interpolate", 39 | "children": [ 40 | { "name": "ArrayInterpolator", "value": 1983 }, 41 | { "name": "ColorInterpolator", "value": 2047 }, 42 | { "name": "DateInterpolator", "value": 1375 }, 43 | { "name": "Interpolator", "value": 8746 }, 44 | { "name": "MatrixInterpolator", "value": 2202 }, 45 | { "name": "NumberInterpolator", "value": 1382 }, 46 | { "name": "ObjectInterpolator", "value": 1629 }, 47 | { "name": "PointInterpolator", "value": 1675 }, 48 | { "name": "RectangleInterpolator", "value": 2042 } 49 | ] 50 | }, 51 | { "name": "ISchedulable", "value": 1041 }, 52 | { "name": "Parallel", "value": 5176 }, 53 | { "name": "Pause", "value": 449 }, 54 | { "name": "Scheduler", "value": 5593 }, 55 | { "name": "Sequence", "value": 5534 }, 56 | { "name": "Transition", "value": 9201 }, 57 | { "name": "Transitioner", "value": 19975 }, 58 | { "name": "TransitionEvent", "value": 1116 }, 59 | { "name": "Tween", "value": 6006 } 60 | ] 61 | }, 62 | { 63 | "name": "data", 64 | "children": [ 65 | { 66 | "name": "converters", 67 | "children": [ 68 | { "name": "Converters", "value": 721 }, 69 | { "name": "DelimitedTextConverter", "value": 4294 }, 70 | { "name": "GraphMLConverter", "value": 9800 }, 71 | { "name": "IDataConverter", "value": 1314 }, 72 | { "name": "JSONConverter", "value": 2220 } 73 | ] 74 | }, 75 | { "name": "DataField", "value": 1759 }, 76 | { "name": "DataSchema", "value": 2165 }, 77 | { "name": "DataSet", "value": 586 }, 78 | { "name": "DataSource", "value": 3331 }, 79 | { "name": "DataTable", "value": 772 }, 80 | { "name": "DataUtil", "value": 3322 } 81 | ] 82 | }, 83 | { 84 | "name": "display", 85 | "children": [ 86 | { "name": "DirtySprite", "value": 8833 }, 87 | { "name": "LineSprite", "value": 1732 }, 88 | { "name": "RectSprite", "value": 3623 }, 89 | { "name": "TextSprite", "value": 10066 } 90 | ] 91 | }, 92 | { 93 | "name": "flex", 94 | "children": [{ "name": "FlareVis", "value": 4116 }] 95 | }, 96 | { 97 | "name": "physics", 98 | "children": [ 99 | { "name": "DragForce", "value": 1082 }, 100 | { "name": "GravityForce", "value": 1336 }, 101 | { "name": "IForce", "value": 319 }, 102 | { "name": "NBodyForce", "value": 10498 }, 103 | { "name": "Particle", "value": 2822 }, 104 | { "name": "Simulation", "value": 9983 }, 105 | { "name": "Spring", "value": 2213 }, 106 | { "name": "SpringForce", "value": 1681 } 107 | ] 108 | }, 109 | { 110 | "name": "query", 111 | "children": [ 112 | { "name": "AggregateExpression", "value": 1616 }, 113 | { "name": "And", "value": 1027 }, 114 | { "name": "Arithmetic", "value": 3891 }, 115 | { "name": "Average", "value": 891 }, 116 | { "name": "BinaryExpression", "value": 2893 }, 117 | { "name": "Comparison", "value": 5103 }, 118 | { "name": "CompositeExpression", "value": 3677 }, 119 | { "name": "Count", "value": 781 }, 120 | { "name": "DateUtil", "value": 4141 }, 121 | { "name": "Distinct", "value": 933 }, 122 | { "name": "Expression", "value": 5130 }, 123 | { "name": "ExpressionIterator", "value": 3617 }, 124 | { "name": "Fn", "value": 3240 }, 125 | { "name": "If", "value": 2732 }, 126 | { "name": "IsA", "value": 2039 }, 127 | { "name": "Literal", "value": 1214 }, 128 | { "name": "Match", "value": 3748 }, 129 | { "name": "Maximum", "value": 843 }, 130 | { 131 | "name": "methods", 132 | "children": [ 133 | { "name": "add", "value": 593 }, 134 | { "name": "and", "value": 330 }, 135 | { "name": "average", "value": 287 }, 136 | { "name": "count", "value": 277 }, 137 | { "name": "distinct", "value": 292 }, 138 | { "name": "div", "value": 595 }, 139 | { "name": "eq", "value": 594 }, 140 | { "name": "fn", "value": 460 }, 141 | { "name": "gt", "value": 603 }, 142 | { "name": "gte", "value": 625 }, 143 | { "name": "iff", "value": 748 }, 144 | { "name": "isa", "value": 461 }, 145 | { "name": "lt", "value": 597 }, 146 | { "name": "lte", "value": 619 }, 147 | { "name": "max", "value": 283 }, 148 | { "name": "min", "value": 283 }, 149 | { "name": "mod", "value": 591 }, 150 | { "name": "mul", "value": 603 }, 151 | { "name": "neq", "value": 599 }, 152 | { "name": "not", "value": 386 }, 153 | { "name": "or", "value": 323 }, 154 | { "name": "orderby", "value": 307 }, 155 | { "name": "range", "value": 772 }, 156 | { "name": "select", "value": 296 }, 157 | { "name": "stddev", "value": 363 }, 158 | { "name": "sub", "value": 600 }, 159 | { "name": "sum", "value": 280 }, 160 | { "name": "update", "value": 307 }, 161 | { "name": "variance", "value": 335 }, 162 | { "name": "where", "value": 299 }, 163 | { "name": "xor", "value": 354 }, 164 | { "name": "_", "value": 264 } 165 | ] 166 | }, 167 | { "name": "Minimum", "value": 843 }, 168 | { "name": "Not", "value": 1554 }, 169 | { "name": "Or", "value": 970 }, 170 | { "name": "Query", "value": 13896 }, 171 | { "name": "Range", "value": 1594 }, 172 | { "name": "StringUtil", "value": 4130 }, 173 | { "name": "Sum", "value": 791 }, 174 | { "name": "Variable", "value": 1124 }, 175 | { "name": "Variance", "value": 1876 }, 176 | { "name": "Xor", "value": 1101 } 177 | ] 178 | }, 179 | { 180 | "name": "scale", 181 | "children": [ 182 | { "name": "IScaleMap", "value": 2105 }, 183 | { "name": "LinearScale", "value": 1316 }, 184 | { "name": "LogScale", "value": 3151 }, 185 | { "name": "OrdinalScale", "value": 3770 }, 186 | { "name": "QuantileScale", "value": 2435 }, 187 | { "name": "QuantitativeScale", "value": 4839 }, 188 | { "name": "RootScale", "value": 1756 }, 189 | { "name": "Scale", "value": 4268 }, 190 | { "name": "ScaleType", "value": 1821 }, 191 | { "name": "TimeScale", "value": 5833 } 192 | ] 193 | }, 194 | { 195 | "name": "util", 196 | "children": [ 197 | { "name": "Arrays", "value": 8258 }, 198 | { "name": "Colors", "value": 10001 }, 199 | { "name": "Dates", "value": 8217 }, 200 | { "name": "Displays", "value": 12555 }, 201 | { "name": "Filter", "value": 2324 }, 202 | { "name": "Geometry", "value": 10993 }, 203 | { 204 | "name": "heap", 205 | "children": [ 206 | { "name": "FibonacciHeap", "value": 9354 }, 207 | { "name": "HeapNode", "value": 1233 } 208 | ] 209 | }, 210 | { "name": "IEvaluable", "value": 335 }, 211 | { "name": "IPredicate", "value": 383 }, 212 | { "name": "IValueProxy", "value": 874 }, 213 | { 214 | "name": "math", 215 | "children": [ 216 | { "name": "DenseMatrix", "value": 3165 }, 217 | { "name": "IMatrix", "value": 2815 }, 218 | { "name": "SparseMatrix", "value": 3366 } 219 | ] 220 | }, 221 | { "name": "Maths", "value": 17705 }, 222 | { "name": "Orientation", "value": 1486 }, 223 | { 224 | "name": "palette", 225 | "children": [ 226 | { "name": "ColorPalette", "value": 6367 }, 227 | { "name": "Palette", "value": 1229 }, 228 | { "name": "ShapePalette", "value": 2059 }, 229 | { "name": "SizePalette", "value": 2291 } 230 | ] 231 | }, 232 | { "name": "Property", "value": 5559 }, 233 | { "name": "Shapes", "value": 19118 }, 234 | { "name": "Sort", "value": 6887 }, 235 | { "name": "Stats", "value": 6557 }, 236 | { "name": "Strings", "value": 22026 } 237 | ] 238 | }, 239 | { 240 | "name": "vis", 241 | "children": [ 242 | { 243 | "name": "axis", 244 | "children": [ 245 | { "name": "Axes", "value": 1302 }, 246 | { "name": "Axis", "value": 24593 }, 247 | { "name": "AxisGridLine", "value": 652 }, 248 | { "name": "AxisLabel", "value": 636 }, 249 | { "name": "CartesianAxes", "value": 6703 } 250 | ] 251 | }, 252 | { 253 | "name": "controls", 254 | "children": [ 255 | { "name": "AnchorControl", "value": 2138 }, 256 | { "name": "ClickControl", "value": 3824 }, 257 | { "name": "Control", "value": 1353 }, 258 | { "name": "ControlList", "value": 4665 }, 259 | { "name": "DragControl", "value": 2649 }, 260 | { "name": "ExpandControl", "value": 2832 }, 261 | { "name": "HoverControl", "value": 4896 }, 262 | { "name": "IControl", "value": 763 }, 263 | { "name": "PanZoomControl", "value": 5222 }, 264 | { "name": "SelectionControl", "value": 7862 }, 265 | { "name": "TooltipControl", "value": 8435 } 266 | ] 267 | }, 268 | { 269 | "name": "data", 270 | "children": [ 271 | { "name": "Data", "value": 20544 }, 272 | { "name": "DataList", "value": 19788 }, 273 | { "name": "DataSprite", "value": 10349 }, 274 | { "name": "EdgeSprite", "value": 3301 }, 275 | { "name": "NodeSprite", "value": 19382 }, 276 | { 277 | "name": "render", 278 | "children": [ 279 | { "name": "ArrowType", "value": 698 }, 280 | { "name": "EdgeRenderer", "value": 5569 }, 281 | { "name": "IRenderer", "value": 353 }, 282 | { "name": "ShapeRenderer", "value": 2247 } 283 | ] 284 | }, 285 | { "name": "ScaleBinding", "value": 11275 }, 286 | { "name": "Tree", "value": 7147 }, 287 | { "name": "TreeBuilder", "value": 9930 } 288 | ] 289 | }, 290 | { 291 | "name": "events", 292 | "children": [ 293 | { "name": "DataEvent", "value": 2313 }, 294 | { "name": "SelectionEvent", "value": 1880 }, 295 | { "name": "TooltipEvent", "value": 1701 }, 296 | { "name": "VisualizationEvent", "value": 1117 } 297 | ] 298 | }, 299 | { 300 | "name": "legend", 301 | "children": [ 302 | { "name": "Legend", "value": 20859 }, 303 | { "name": "LegendItem", "value": 4614 }, 304 | { "name": "LegendRange", "value": 10530 } 305 | ] 306 | }, 307 | { 308 | "name": "operator", 309 | "children": [ 310 | { 311 | "name": "distortion", 312 | "children": [ 313 | { "name": "BifocalDistortion", "value": 4461 }, 314 | { "name": "Distortion", "value": 6314 }, 315 | { "name": "FisheyeDistortion", "value": 3444 } 316 | ] 317 | }, 318 | { 319 | "name": "encoder", 320 | "children": [ 321 | { "name": "ColorEncoder", "value": 3179 }, 322 | { "name": "Encoder", "value": 4060 }, 323 | { "name": "PropertyEncoder", "value": 4138 }, 324 | { "name": "ShapeEncoder", "value": 1690 }, 325 | { "name": "SizeEncoder", "value": 1830 } 326 | ] 327 | }, 328 | { 329 | "name": "filter", 330 | "children": [ 331 | { "name": "FisheyeTreeFilter", "value": 5219 }, 332 | { "name": "GraphDistanceFilter", "value": 3165 }, 333 | { "name": "VisibilityFilter", "value": 3509 } 334 | ] 335 | }, 336 | { "name": "IOperator", "value": 1286 }, 337 | { 338 | "name": "label", 339 | "children": [ 340 | { "name": "Labeler", "value": 9956 }, 341 | { "name": "RadialLabeler", "value": 3899 }, 342 | { "name": "StackedAreaLabeler", "value": 3202 } 343 | ] 344 | }, 345 | { 346 | "name": "layout", 347 | "children": [ 348 | { "name": "AxisLayout", "value": 6725 }, 349 | { "name": "BundledEdgeRouter", "value": 3727 }, 350 | { "name": "CircleLayout", "value": 9317 }, 351 | { "name": "CirclePackingLayout", "value": 12003 }, 352 | { "name": "DendrogramLayout", "value": 4853 }, 353 | { "name": "ForceDirectedLayout", "value": 8411 }, 354 | { "name": "IcicleTreeLayout", "value": 4864 }, 355 | { "name": "IndentedTreeLayout", "value": 3174 }, 356 | { "name": "Layout", "value": 7881 }, 357 | { "name": "NodeLinkTreeLayout", "value": 12870 }, 358 | { "name": "PieLayout", "value": 2728 }, 359 | { "name": "RadialTreeLayout", "value": 12348 }, 360 | { "name": "RandomLayout", "value": 870 }, 361 | { "name": "StackedAreaLayout", "value": 9121 }, 362 | { "name": "TreeMapLayout", "value": 9191 } 363 | ] 364 | }, 365 | { "name": "Operator", "value": 2490 }, 366 | { "name": "OperatorList", "value": 5248 }, 367 | { "name": "OperatorSequence", "value": 4190 }, 368 | { "name": "OperatorSwitch", "value": 2581 }, 369 | { "name": "SortOperator", "value": 2023 } 370 | ] 371 | }, 372 | { "name": "Visualization", "value": 16540 } 373 | ] 374 | } 375 | ] 376 | } 377 | -------------------------------------------------------------------------------- /demo/src/examples/org-chart.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CEO", 3 | "children": [ 4 | { 5 | "name": "Manager", 6 | "attributes": { 7 | "Department": "Production", 8 | "isFullTime": true, 9 | "weeklyHours": 80 10 | }, 11 | "children": [ 12 | { 13 | "name": "Foreman", 14 | "attributes": { 15 | "Department": "Fabrication" 16 | }, 17 | "children": [ 18 | { 19 | "name": "Workers" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "Foreman", 25 | "attributes": { 26 | "Department": "Assembly" 27 | }, 28 | "children": [ 29 | { 30 | "name": "Workers" 31 | } 32 | ] 33 | } 34 | ] 35 | }, 36 | { 37 | "name": "Manager", 38 | "attributes": { 39 | "Department": "Marketing", 40 | "isFullTime": true, 41 | "weeklyHours": 80 42 | }, 43 | "children": [ 44 | { 45 | "name": "Sales Officer", 46 | "attributes": { 47 | "Department": "A" 48 | }, 49 | "children": [ 50 | { 51 | "name": "Salespeople" 52 | } 53 | ] 54 | }, 55 | { 56 | "name": "Sales Officer", 57 | "attributes": { 58 | "Department": "B" 59 | }, 60 | "children": [ 61 | { 62 | "name": "Salespeople" 63 | } 64 | ] 65 | } 66 | ] 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | width: 100%; 8 | height: 100%; 9 | margin: 0; 10 | padding: 0; 11 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 12 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 13 | font-weight: 300; 14 | background: #fff; 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | createRoot(document.getElementById('root')).render(); 7 | -------------------------------------------------------------------------------- /demo/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /demo/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /demo/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "jsdom", 4 | "roots": ["/src/"], 5 | "transform": { 6 | "^.+\\.jsx?$": "babel-jest", 7 | "\\.(ts|tsx)$": "ts-jest" 8 | }, 9 | "transformIgnorePatterns": ["node_modules/(?!(d3-.+))"], 10 | "moduleNameMapper": { 11 | ".*\\.(css|less|styl|scss|sass)$": "/jest/mocks/cssModule.js", 12 | "(.*)\\.js$": "$1" 13 | }, 14 | "setupFilesAfterEnv": ["/jest/setup.ts"], 15 | "collectCoverageFrom": [ 16 | "src/**/*.{js,jsx,ts,tsx}", 17 | "!src/index.js", 18 | "!src/**/*.test.{js,jsx,ts,tsx}" 19 | ], 20 | "coverageThreshold": { 21 | "global": { 22 | "statements": 90, 23 | "branches": 84, 24 | "functions": 90, 25 | "lines": 88 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /jest/mocks/cssModule.js: -------------------------------------------------------------------------------- 1 | module.exports = 'CSS_MODULE'; 2 | -------------------------------------------------------------------------------- /jest/mocks/image.js: -------------------------------------------------------------------------------- 1 | module.exports = 'IMAGE_MOCK'; 2 | -------------------------------------------------------------------------------- /jest/polyfills/raf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | const raf = global.requestAnimationFrame = cb => { 3 | setTimeout(cb, 0) 4 | } 5 | 6 | export default raf -------------------------------------------------------------------------------- /jest/setup.ts: -------------------------------------------------------------------------------- 1 | import raf from './polyfills/raf'; 2 | import { configure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | // @ts-ignore 6 | configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-d3-tree", 3 | "version": "3.6.6", 4 | "description": "React component to create interactive D3 tree hierarchies", 5 | "author": "Ben Kremer", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/bkrem/react-d3-tree/issues" 9 | }, 10 | "homepage": "https://github.com/bkrem/react-d3-tree", 11 | "files": [ 12 | "lib" 13 | ], 14 | "type": "module", 15 | "exports": { 16 | ".": { 17 | "import": "./lib/esm/index.js", 18 | "require": "./lib/cjs/index.js", 19 | "types": "./lib/types/index.d.ts" 20 | } 21 | }, 22 | "module": "lib/esm/index.js", 23 | "main": "lib/cjs/index.js", 24 | "types": "lib/types/index.d.ts", 25 | "scripts": { 26 | "build:docs": "rimraf ./docs && typedoc", 27 | "build:cjs": "tsc -p tsconfig.json", 28 | "build:esm": "tsc -p tsconfig.esm.json", 29 | "build": "rimraf lib && npm run build:cjs && npm run build:esm", 30 | "build:watch": "rimraf lib && npm run build:esm -- -w", 31 | "lint": "eslint src/**/*.js", 32 | "test": "jest --config jest.config.json --coverage --verbose", 33 | "test:clean": "rimraf ./coverage", 34 | "test:watch": "jest --watchAll", 35 | "test:cov": "jest --coverage --verbose", 36 | "coveralls": "cat ./coverage/lcov.info | coveralls", 37 | "show:cov": "open coverage/lcov-report/index.html", 38 | "show:docs": "open demo/public/docs/index.html", 39 | "prepublishOnly": "npm run test && npm run build", 40 | "deploy:demo": "npm run build && npm run build:docs && cd demo && npm run build && npm run deploy" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/bkrem/react-d3-tree.git" 45 | }, 46 | "keywords": [ 47 | "react", 48 | "d3", 49 | "tree", 50 | "component", 51 | "graph", 52 | "svg", 53 | "hierarchical-data", 54 | "hierarchy", 55 | "d3-visualization", 56 | "chart" 57 | ], 58 | "husky": { 59 | "hooks": { 60 | "pre-commit": "lint-staged" 61 | } 62 | }, 63 | "lint-staged": { 64 | "src/**/*.js": [ 65 | "eslint", 66 | "prettier --write", 67 | "jest --findRelatedTests", 68 | "git add" 69 | ], 70 | "src/**/*.{ts,tsx}": [ 71 | "prettier --write", 72 | "jest --findRelatedTests", 73 | "git add" 74 | ] 75 | }, 76 | "overrides": { 77 | "cheerio": "1.0.0-rc.12" 78 | }, 79 | "dependencies": { 80 | "@bkrem/react-transition-group": "^1.3.5", 81 | "@types/d3-hierarchy": "^1.1.8", 82 | "clone": "^2.1.1", 83 | "d3-hierarchy": "^1.1.9", 84 | "d3-selection": "^3.0.0", 85 | "d3-shape": "^1.3.7", 86 | "d3-zoom": "^3.0.0", 87 | "dequal": "^2.0.2", 88 | "uuid": "^8.3.1" 89 | }, 90 | "peerDependencies": { 91 | "react": "16.x || 17.x || 18.x || 19.x", 92 | "react-dom": "16.x || 17.x || 18.x || 19.x" 93 | }, 94 | "devDependencies": { 95 | "@babel/core": "^7.8.3", 96 | "@babel/plugin-proposal-class-properties": "^7.8.3", 97 | "@babel/preset-env": "^7.20.2", 98 | "@babel/preset-react": "^7.8.3", 99 | "@babel/preset-typescript": "^7.8.3", 100 | "@types/d3-selection": "^1.4.3", 101 | "@types/d3-shape": "^1.3.5", 102 | "@types/d3-zoom": "^1.8.2", 103 | "@types/react": "^16.9.17", 104 | "babel-eslint": "^10.0.3", 105 | "babel-jest": "^24.9.0", 106 | "coveralls": "^3.0.0", 107 | "enzyme": "^3.4.4", 108 | "enzyme-adapter-react-16": "^1.2.0", 109 | "eslint": "4.16.0", 110 | "eslint-config-airbnb": "^16.1.0", 111 | "eslint-config-prettier": "^3.3.0", 112 | "eslint-plugin-import": "^2.2.0", 113 | "eslint-plugin-jsx-a11y": "^6.0.2", 114 | "eslint-plugin-react": "^7.5.1", 115 | "husky": "^3.0.4", 116 | "jest": "^24.9.0", 117 | "lint-staged": "^9.4.2", 118 | "prettier": "^1.19.1", 119 | "react": "^16.14.0", 120 | "react-dom": "^16.6.3", 121 | "react-test-renderer": "^16.6.3", 122 | "regenerator-runtime": "^0.12.0", 123 | "rimraf": "^3.0.0", 124 | "ts-jest": "^24.0.2", 125 | "ts-node": "^8.3.0", 126 | "tsconfig-paths": "^3.8.0", 127 | "typedoc": "^0.27.6", 128 | "typescript": "^5.7.3" 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Link/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from 'react'; 2 | import { linkHorizontal, linkVertical } from 'd3-shape'; 3 | import { HierarchyPointNode } from 'd3-hierarchy'; 4 | import { select } from 'd3-selection'; 5 | import { 6 | Orientation, 7 | TreeLinkDatum, 8 | PathFunctionOption, 9 | PathFunction, 10 | TreeNodeDatum, 11 | PathClassFunction, 12 | } from '../types/common.js'; 13 | 14 | type LinkEventHandler = ( 15 | source: HierarchyPointNode, 16 | target: HierarchyPointNode, 17 | evt: SyntheticEvent 18 | ) => void; 19 | 20 | interface LinkProps { 21 | linkData: TreeLinkDatum; 22 | orientation: Orientation; 23 | pathFunc: PathFunctionOption | PathFunction; 24 | pathClassFunc?: PathClassFunction; 25 | enableLegacyTransitions: boolean; 26 | transitionDuration: number; 27 | onClick: LinkEventHandler; 28 | onMouseOver: LinkEventHandler; 29 | onMouseOut: LinkEventHandler; 30 | } 31 | 32 | type LinkState = { 33 | initialStyle: { opacity: number }; 34 | }; 35 | 36 | export default class Link extends React.PureComponent { 37 | private linkRef: SVGPathElement = null; 38 | 39 | state = { 40 | initialStyle: { 41 | opacity: 0, 42 | }, 43 | }; 44 | 45 | componentDidMount() { 46 | this.applyOpacity(1, this.props.transitionDuration); 47 | } 48 | 49 | componentWillLeave(done) { 50 | this.applyOpacity(0, this.props.transitionDuration, done); 51 | } 52 | 53 | applyOpacity( 54 | opacity: number, 55 | transitionDuration: LinkProps['transitionDuration'], 56 | done = () => {} 57 | ) { 58 | if (this.props.enableLegacyTransitions) { 59 | select(this.linkRef) 60 | // @ts-ignore 61 | .transition() 62 | .duration(transitionDuration) 63 | .style('opacity', opacity) 64 | .on('end', done); 65 | } else { 66 | select(this.linkRef).style('opacity', opacity); 67 | done(); 68 | } 69 | } 70 | 71 | drawStepPath(linkData: LinkProps['linkData'], orientation: LinkProps['orientation']) { 72 | const { source, target } = linkData; 73 | const deltaY = target.y - source.y; 74 | return orientation === 'horizontal' 75 | ? `M${source.y},${source.x} H${source.y + deltaY / 2} V${target.x} H${target.y}` 76 | : `M${source.x},${source.y} V${source.y + deltaY / 2} H${target.x} V${target.y}`; 77 | } 78 | 79 | drawDiagonalPath(linkData: LinkProps['linkData'], orientation: LinkProps['orientation']) { 80 | const { source, target } = linkData; 81 | return orientation === 'horizontal' 82 | ? linkHorizontal()({ 83 | source: [source.y, source.x], 84 | target: [target.y, target.x], 85 | }) 86 | : linkVertical()({ 87 | source: [source.x, source.y], 88 | target: [target.x, target.y], 89 | }); 90 | } 91 | 92 | drawStraightPath(linkData: LinkProps['linkData'], orientation: LinkProps['orientation']) { 93 | const { source, target } = linkData; 94 | return orientation === 'horizontal' 95 | ? `M${source.y},${source.x}L${target.y},${target.x}` 96 | : `M${source.x},${source.y}L${target.x},${target.y}`; 97 | } 98 | 99 | drawElbowPath(linkData: LinkProps['linkData'], orientation: LinkProps['orientation']) { 100 | return orientation === 'horizontal' 101 | ? `M${linkData.source.y},${linkData.source.x}V${linkData.target.x}H${linkData.target.y}` 102 | : `M${linkData.source.x},${linkData.source.y}V${linkData.target.y}H${linkData.target.x}`; 103 | } 104 | 105 | drawPath() { 106 | const { linkData, orientation, pathFunc } = this.props; 107 | 108 | if (typeof pathFunc === 'function') { 109 | return pathFunc(linkData, orientation); 110 | } 111 | if (pathFunc === 'elbow') { 112 | return this.drawElbowPath(linkData, orientation); 113 | } 114 | if (pathFunc === 'straight') { 115 | return this.drawStraightPath(linkData, orientation); 116 | } 117 | if (pathFunc === 'step') { 118 | return this.drawStepPath(linkData, orientation); 119 | } 120 | return this.drawDiagonalPath(linkData, orientation); 121 | } 122 | 123 | getClassNames() { 124 | const { linkData, orientation, pathClassFunc } = this.props; 125 | const classNames = ['rd3t-link']; 126 | 127 | if (typeof pathClassFunc === 'function') { 128 | classNames.push(pathClassFunc(linkData, orientation)); 129 | } 130 | 131 | return classNames.join(' ').trim(); 132 | } 133 | 134 | handleOnClick = evt => { 135 | this.props.onClick(this.props.linkData.source, this.props.linkData.target, evt); 136 | }; 137 | 138 | handleOnMouseOver = evt => { 139 | this.props.onMouseOver(this.props.linkData.source, this.props.linkData.target, evt); 140 | }; 141 | 142 | handleOnMouseOut = evt => { 143 | this.props.onMouseOut(this.props.linkData.source, this.props.linkData.target, evt); 144 | }; 145 | 146 | render() { 147 | const { linkData } = this.props; 148 | return ( 149 | { 151 | this.linkRef = l; 152 | }} 153 | style={{ ...this.state.initialStyle }} 154 | className={this.getClassNames()} 155 | d={this.drawPath()} 156 | onClick={this.handleOnClick} 157 | onMouseOver={this.handleOnMouseOver} 158 | onMouseOut={this.handleOnMouseOut} 159 | data-source-id={linkData.source.id} 160 | data-target-id={linkData.target.id} 161 | /> 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Link/tests/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import Link from '../index.tsx'; 5 | 6 | describe('', () => { 7 | const linkData = { 8 | source: { 9 | x: 123, 10 | y: 321, 11 | }, 12 | target: { 13 | x: 456, 14 | y: 654, 15 | }, 16 | }; 17 | 18 | const mockProps = { 19 | linkData, 20 | pathFunc: 'diagonal', 21 | orientation: 'horizontal', 22 | transitionDuration: 500, 23 | onClick: () => {}, 24 | onMouseOver: () => {}, 25 | onMouseOut: () => {}, 26 | }; 27 | 28 | const pathFuncs = { 29 | testPathFunc: (d, orientation) => 30 | orientation && `M${d.source.y},${d.source.x}V${d.target.x}H${d.target.y}`, 31 | }; 32 | 33 | jest.spyOn(Link.prototype, 'drawPath'); 34 | jest.spyOn(Link.prototype, 'drawDiagonalPath'); 35 | jest.spyOn(Link.prototype, 'drawElbowPath'); 36 | jest.spyOn(Link.prototype, 'drawStraightPath'); 37 | jest.spyOn(Link.prototype, 'drawStepPath'); 38 | jest.spyOn(Link.prototype, 'applyOpacity'); 39 | jest.spyOn(pathFuncs, 'testPathFunc'); 40 | 41 | // Clear method spies on prototype after each test 42 | afterEach(() => jest.clearAllMocks()); 43 | 44 | it('binds IDs of source & target nodes to data-source-id/data-target-id', () => { 45 | linkData.source.id = 1; 46 | linkData.target.id = 2; 47 | const renderedComponent = shallow(); 48 | expect(renderedComponent.find('path').prop('data-source-id')).toBe(linkData.source.id); 49 | expect(renderedComponent.find('path').prop('data-target-id')).toBe(linkData.target.id); 50 | delete linkData.source.id; 51 | delete linkData.target.id; 52 | }); 53 | 54 | describe('handling classNames', () => { 55 | it('applies the expected internal className', () => { 56 | const renderedComponent = shallow(); 57 | expect(renderedComponent.prop('className')).toBe('rd3t-link'); 58 | }); 59 | 60 | it('applies any additional classNames returned by `pathClassFunc` if defined', () => { 61 | const fixture = 'additionalPathClassName'; 62 | const renderedComponent = shallow( fixture} />); 63 | expect(renderedComponent.prop('className')).toBe(['rd3t-link', fixture].join(' ')); 64 | }); 65 | }); 66 | 67 | describe('drawing paths', () => { 68 | it('calls the appropriate path func based on `props.pathFunc`', () => { 69 | const diagonalComponent = shallow(); 70 | const elbowComponent = shallow(); 71 | const straightComponent = shallow(); 72 | const stepComponent = shallow(); 73 | shallow(); 74 | 75 | expect(diagonalComponent.instance().drawDiagonalPath).toHaveBeenCalled(); 76 | expect(elbowComponent.instance().drawElbowPath).toHaveBeenCalled(); 77 | expect(straightComponent.instance().drawStraightPath).toHaveBeenCalled(); 78 | expect(stepComponent.instance().drawStepPath).toHaveBeenCalled(); 79 | expect(pathFuncs.testPathFunc).toHaveBeenCalled(); 80 | expect(Link.prototype.drawPath).toHaveBeenCalledTimes(5); 81 | }); 82 | 83 | it('returns an appropriate elbowPath according to `props.orientation`', () => { 84 | expect(Link.prototype.drawElbowPath(linkData, 'horizontal')).toBe( 85 | `M${linkData.source.y},${linkData.source.x}V${linkData.target.x}H${linkData.target.y}` 86 | ); 87 | expect(Link.prototype.drawElbowPath(linkData, 'vertical')).toBe( 88 | `M${linkData.source.x},${linkData.source.y}V${linkData.target.y}H${linkData.target.x}` 89 | ); 90 | }); 91 | 92 | it('returns an appropriate diagonal according to `props.orientation`', () => { 93 | const ymean = (linkData.target.y + linkData.source.y) / 2; 94 | expect(Link.prototype.drawDiagonalPath(linkData, 'horizontal')).toBe( 95 | `M${linkData.source.y},${linkData.source.x}` + 96 | `C${ymean},${linkData.source.x},${ymean},${linkData.target.x},` + 97 | `${linkData.target.y},${linkData.target.x}` 98 | ); 99 | expect(Link.prototype.drawDiagonalPath(linkData, 'vertical')).toBe( 100 | `M${linkData.source.x},${linkData.source.y}` + 101 | `C${linkData.source.x},${ymean},${linkData.target.x},${ymean},` + 102 | `${linkData.target.x},${linkData.target.y}` 103 | ); 104 | }); 105 | 106 | it('returns an appropriate straightPath according to `props.orientation`', () => { 107 | expect(Link.prototype.drawStraightPath(linkData, 'horizontal')).toBe( 108 | `M${linkData.source.y},${linkData.source.x}L${linkData.target.y},${linkData.target.x}` 109 | ); 110 | expect(Link.prototype.drawStraightPath(linkData, 'vertical')).toBe( 111 | `M${linkData.source.x},${linkData.source.y}L${linkData.target.x},${linkData.target.y}` 112 | ); 113 | }); 114 | 115 | it('return an appropriate stepPath according to `props.orientation`', () => { 116 | const { source, target } = linkData; 117 | const deltaY = target.y - source.y; 118 | 119 | expect(Link.prototype.drawStepPath(linkData, 'horizontal')).toBe( 120 | `M${source.y},${source.x} H${source.y + deltaY / 2} V${target.x} H${target.y}` 121 | ); 122 | expect(Link.prototype.drawStepPath(linkData, 'vertical')).toBe( 123 | `M${source.x},${source.y} V${source.y + deltaY / 2} H${target.x} V${target.y}` 124 | ); 125 | }); 126 | }); 127 | 128 | it('fades in once it has been mounted', () => { 129 | const fixture = 1; 130 | const renderedComponent = mount(); 131 | 132 | expect(renderedComponent.instance().applyOpacity).toHaveBeenCalledWith( 133 | fixture, 134 | mockProps.transitionDuration 135 | ); 136 | }); 137 | 138 | describe('Events', () => { 139 | it('handles onClick events and passes its nodeId & event object to onClick handler', () => { 140 | const onClickSpy = jest.fn(); 141 | const mockEvt = { mock: 'event' }; 142 | const renderedComponent = shallow(); 143 | 144 | renderedComponent.simulate('click', mockEvt); 145 | expect(onClickSpy).toHaveBeenCalledTimes(1); 146 | expect(onClickSpy).toHaveBeenCalledWith( 147 | linkData.source, 148 | linkData.target, 149 | expect.objectContaining(mockEvt) 150 | ); 151 | }); 152 | 153 | it('handles onMouseOver events and passes its nodeId & event object to onMouseOver handler', () => { 154 | const onMouseOverSpy = jest.fn(); 155 | const mockEvt = { mock: 'event' }; 156 | const renderedComponent = shallow(); 157 | 158 | renderedComponent.simulate('mouseover', mockEvt); 159 | expect(onMouseOverSpy).toHaveBeenCalledTimes(1); 160 | expect(onMouseOverSpy).toHaveBeenCalledWith( 161 | linkData.source, 162 | linkData.target, 163 | expect.objectContaining(mockEvt) 164 | ); 165 | }); 166 | 167 | it('handles onMouseOut events and passes its nodeId & event object to onMouseOut handler', () => { 168 | const onMouseOutSpy = jest.fn(); 169 | const mockEvt = { mock: 'event' }; 170 | const renderedComponent = shallow(); 171 | 172 | renderedComponent.simulate('mouseout', mockEvt); 173 | expect(onMouseOutSpy).toHaveBeenCalledTimes(1); 174 | expect(onMouseOutSpy).toHaveBeenCalledWith( 175 | linkData.source, 176 | linkData.target, 177 | expect.objectContaining(mockEvt) 178 | ); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /src/Node/DefaultNodeElement.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CustomNodeElementProps, SyntheticEventHandler } from '../types/common.js'; 3 | 4 | const DEFAULT_NODE_CIRCLE_RADIUS = 15; 5 | 6 | const textLayout = { 7 | title: { 8 | textAnchor: 'start', 9 | x: 40, 10 | }, 11 | attribute: { 12 | x: 40, 13 | dy: '1.2em', 14 | }, 15 | }; 16 | 17 | export interface DefaultNodeElementProps extends CustomNodeElementProps {} 18 | 19 | const DefaultNodeElement: React.FunctionComponent = ({ 20 | nodeDatum, 21 | toggleNode, 22 | onNodeClick, 23 | onNodeMouseOver, 24 | onNodeMouseOut, 25 | }) => ( 26 | <> 27 | { 30 | toggleNode(); 31 | onNodeClick(evt); 32 | }} 33 | onMouseOver={onNodeMouseOver} 34 | onMouseOut={onNodeMouseOut} 35 | > 36 | 37 | 38 | {nodeDatum.name} 39 | 40 | 41 | {nodeDatum.attributes && 42 | Object.entries(nodeDatum.attributes).map(([labelKey, labelValue], i) => ( 43 | 44 | {labelKey}: {typeof labelValue === 'boolean' ? labelValue.toString() : labelValue} 45 | 46 | ))} 47 | 48 | 49 | 50 | ); 51 | 52 | export default DefaultNodeElement; 53 | -------------------------------------------------------------------------------- /src/Node/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import Node from './index.tsx'; 5 | 6 | describe('', () => { 7 | const data = { 8 | name: 'mockNode', 9 | __rd3t: { 10 | id: 'abc123', 11 | }, 12 | }; 13 | 14 | const hierarchyPointNode = { 15 | data, 16 | depth: 1, 17 | height: 0, 18 | parent: null, 19 | x: -200, 20 | y: 200, 21 | }; 22 | 23 | const mockProps = { 24 | data, 25 | hierarchyPointNode, 26 | nodeSize: { 27 | x: 123, 28 | y: 321, 29 | }, 30 | position: { 31 | x: 111, 32 | y: 222, 33 | }, 34 | depth: 3, 35 | nodeElement: { 36 | tag: 'circle', 37 | baseProps: { 38 | r: 10, 39 | }, 40 | }, 41 | attributes: { 42 | testkeyA: 'testvalA', 43 | testKeyB: 'testvalB', 44 | }, 45 | orientation: 'horizontal', 46 | parent: { 47 | x: 999, 48 | y: 888, 49 | }, 50 | transitionDuration: 500, 51 | onClick: () => {}, 52 | onMouseOver: () => {}, 53 | onMouseOut: () => {}, 54 | centerNode: () => {}, 55 | subscriptions: {}, 56 | allowForeignObjects: false, 57 | }; 58 | 59 | jest.spyOn(Node.prototype, 'applyTransform'); 60 | 61 | // Clear method spies on prototype after each test 62 | afterEach(() => jest.clearAllMocks()); 63 | 64 | it('has the correct `id` attribute value', () => { 65 | const renderedComponent = shallow(); 66 | 67 | expect( 68 | renderedComponent 69 | .find('g') 70 | .first() 71 | .prop('id') 72 | ).toBe(data.__rd3t.id); 73 | }); 74 | 75 | it('applies correct base className if `data.children` is defined and not empty', () => { 76 | const leafNodeComponent = shallow(); 77 | const leafNodeComponentWithEmptyChildren = shallow( 78 | 79 | ); 80 | const nodeComponent = shallow( 81 | 82 | ); 83 | 84 | expect( 85 | leafNodeComponent 86 | .find('g') 87 | .first() 88 | .prop('className') 89 | ).toBe('rd3t-leaf-node'); 90 | expect( 91 | leafNodeComponentWithEmptyChildren 92 | .find('g') 93 | .first() 94 | .prop('className') 95 | ).toBe('rd3t-leaf-node'); 96 | expect( 97 | nodeComponent 98 | .find('g') 99 | .first() 100 | .prop('className') 101 | ).toBe('rd3t-node'); 102 | }); 103 | 104 | it('applies `nodeClassName` if defined', () => { 105 | const fixture = 'additionalNodeClass'; 106 | const leafNodeComponent = shallow(); 107 | 108 | expect( 109 | leafNodeComponent 110 | .find('g') 111 | .first() 112 | .prop('className') 113 | ).toBe(['rd3t-leaf-node', fixture].join(' ')); 114 | }); 115 | 116 | it('applies correct `transform` prop based on its `orientation`', () => { 117 | const horizontalTransform = `translate(${mockProps.parent.y},${mockProps.parent.x})`; 118 | const verticalTransform = `translate(${mockProps.parent.x},${mockProps.parent.y})`; 119 | const horizontalComponent = shallow(); 120 | const verticalComponent = shallow(); 121 | expect( 122 | horizontalComponent 123 | .find('g') 124 | .first() 125 | .prop('transform') 126 | ).toBe(horizontalTransform); 127 | expect( 128 | verticalComponent 129 | .find('g') 130 | .first() 131 | .prop('transform') 132 | ).toBe(verticalTransform); 133 | }); 134 | 135 | describe('Events', () => { 136 | it('handles onNodeToggle and passes its nodeId to handler', () => { 137 | const onNodeToggleSpy = jest.fn(); 138 | const mockEvt = { mock: 'event' }; 139 | const renderedComponent = shallow( 140 | {}} /> 141 | ); 142 | 143 | renderedComponent.find('circle').simulate('click', mockEvt); 144 | expect(onNodeToggleSpy).toHaveBeenCalledTimes(1); 145 | expect(onNodeToggleSpy).toHaveBeenCalledWith(data.__rd3t.id); 146 | }); 147 | 148 | it('handles onNodeClick events and passes its `hierarchyPointNode` representation & event object to handler', () => { 149 | const onClickSpy = jest.fn(); 150 | const mockEvt = { mock: 'event' }; 151 | const renderedComponent = shallow( 152 | {}} onNodeClick={onClickSpy} /> 153 | ); 154 | 155 | renderedComponent.find('circle').simulate('click', mockEvt); 156 | expect(onClickSpy).toHaveBeenCalledTimes(1); 157 | expect(onClickSpy).toHaveBeenCalledWith( 158 | mockProps.hierarchyPointNode, 159 | expect.objectContaining(mockEvt) 160 | ); 161 | }); 162 | 163 | it('handles onNodeMouseOver events and passes its `hierarchyPointNode` representation & event object to handler', () => { 164 | const onMouseOverSpy = jest.fn(); 165 | const mockEvt = { mock: 'event' }; 166 | const renderedComponent = shallow(); 167 | 168 | renderedComponent.find('circle').simulate('mouseover', mockEvt); 169 | expect(onMouseOverSpy).toHaveBeenCalledTimes(1); 170 | expect(onMouseOverSpy).toHaveBeenCalledWith( 171 | mockProps.hierarchyPointNode, 172 | expect.objectContaining(mockEvt) 173 | ); 174 | }); 175 | 176 | it('handles onNodeMouseOut events and passes its `hierarchyPointNode` representation & event object to handler', () => { 177 | const onMouseOutSpy = jest.fn(); 178 | const mockEvt = { mock: 'event' }; 179 | const renderedComponent = shallow(); 180 | 181 | renderedComponent.find('circle').simulate('mouseout', mockEvt); 182 | expect(onMouseOutSpy).toHaveBeenCalledTimes(1); 183 | expect(onMouseOutSpy).toHaveBeenCalledWith( 184 | mockProps.hierarchyPointNode, 185 | expect.objectContaining(mockEvt) 186 | ); 187 | }); 188 | }); 189 | 190 | it('applies its own x/y coords on `transform` once mounted', () => { 191 | const fixture = `translate(${mockProps.position.y},${mockProps.position.x})`; 192 | const renderedComponent = mount(); 193 | 194 | expect(renderedComponent.instance().applyTransform).toHaveBeenCalledWith( 195 | fixture, 196 | mockProps.transitionDuration 197 | ); 198 | }); 199 | 200 | describe('Update Positioning', () => { 201 | it('updates its position if `data.x` or `data.y` changes', () => { 202 | const updatedProps = { 203 | ...mockProps, 204 | x: 1, 205 | y: 2, 206 | data: { 207 | ...mockProps.data, 208 | }, 209 | }; 210 | const initialTransform = `translate(${mockProps.position.y},${mockProps.position.x})`; 211 | const updatedTransform = `translate(${updatedProps.position.y},${updatedProps.position.x})`; 212 | const renderedComponent = mount(); 213 | 214 | expect(renderedComponent.instance().applyTransform).toHaveBeenCalledWith( 215 | initialTransform, 216 | mockProps.transitionDuration 217 | ); 218 | 219 | renderedComponent.setProps(updatedProps); 220 | 221 | expect(renderedComponent.instance().applyTransform).toHaveBeenCalledWith( 222 | updatedTransform, 223 | mockProps.transitionDuration 224 | ); 225 | }); 226 | 227 | it('updates its position if `orientation` changes', () => { 228 | const thisProps = { ...mockProps, shouldTranslateToOrigin: true, orientation: 'horizontal' }; 229 | delete thisProps.parent; 230 | const renderedComponent = mount(); 231 | const nextProps = { ...thisProps, orientation: 'vertical' }; 232 | expect( 233 | renderedComponent.instance().shouldNodeTransform(renderedComponent.props(), nextProps) 234 | ).toBe(true); 235 | }); 236 | 237 | it('updates its position if any subscribed top-level props change', () => { 238 | const subscriptions = { x: 12, y: 10, initialDepth: undefined }; 239 | const renderedComponent = mount(); 240 | const nextProps = { ...mockProps, subscriptions: { ...subscriptions, initialDepth: 1 } }; 241 | 242 | expect( 243 | renderedComponent.instance().shouldNodeTransform(renderedComponent.props(), nextProps) 244 | ).toBe(true); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /src/Node/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from 'react'; 2 | import { HierarchyPointNode } from 'd3-hierarchy'; 3 | import { select } from 'd3-selection'; 4 | import { 5 | Orientation, 6 | Point, 7 | TreeNodeDatum, 8 | RawNodeDatum, 9 | RenderCustomNodeElementFn, 10 | AddChildrenFunction, 11 | } from '../types/common.js'; 12 | import DefaultNodeElement from './DefaultNodeElement.js'; 13 | 14 | type NodeEventHandler = ( 15 | hierarchyPointNode: HierarchyPointNode, 16 | evt: SyntheticEvent 17 | ) => void; 18 | 19 | type NodeProps = { 20 | data: TreeNodeDatum; 21 | position: Point; 22 | hierarchyPointNode: HierarchyPointNode; 23 | parent: HierarchyPointNode | null; 24 | nodeClassName: string; 25 | nodeSize: { 26 | x: number; 27 | y: number; 28 | }; 29 | orientation: Orientation; 30 | enableLegacyTransitions: boolean; 31 | transitionDuration: number; 32 | renderCustomNodeElement: RenderCustomNodeElementFn; 33 | onNodeToggle: (nodeId: string) => void; 34 | onNodeClick: NodeEventHandler; 35 | onNodeMouseOver: NodeEventHandler; 36 | onNodeMouseOut: NodeEventHandler; 37 | subscriptions: object; 38 | centerNode: (hierarchyPointNode: HierarchyPointNode) => void; 39 | handleAddChildrenToNode: (nodeId: string, children: RawNodeDatum[]) => void; 40 | }; 41 | 42 | type NodeState = { 43 | transform: string; 44 | initialStyle: { opacity: number }; 45 | wasClicked: boolean; 46 | }; 47 | 48 | export default class Node extends React.Component { 49 | private nodeRef: SVGGElement = null; 50 | 51 | state = { 52 | transform: this.setTransform( 53 | this.props.position, 54 | this.props.parent, 55 | this.props.orientation, 56 | true 57 | ), 58 | initialStyle: { 59 | opacity: 0, 60 | }, 61 | wasClicked: false, 62 | }; 63 | 64 | componentDidMount() { 65 | this.commitTransform(); 66 | } 67 | 68 | componentDidUpdate() { 69 | if (this.state.wasClicked) { 70 | this.props.centerNode(this.props.hierarchyPointNode); 71 | this.setState({ wasClicked: false }); 72 | } 73 | this.commitTransform(); 74 | } 75 | 76 | shouldComponentUpdate(nextProps: NodeProps, nextState: NodeState) { 77 | return this.shouldNodeTransform(this.props, nextProps, this.state, nextState); 78 | } 79 | 80 | shouldNodeTransform = ( 81 | ownProps: NodeProps, 82 | nextProps: NodeProps, 83 | ownState: NodeState, 84 | nextState: NodeState 85 | ) => 86 | nextProps.subscriptions !== ownProps.subscriptions || 87 | nextProps.position.x !== ownProps.position.x || 88 | nextProps.position.y !== ownProps.position.y || 89 | nextProps.orientation !== ownProps.orientation || 90 | nextState.wasClicked !== ownState.wasClicked; 91 | 92 | setTransform( 93 | position: NodeProps['position'], 94 | parent: NodeProps['parent'], 95 | orientation: NodeProps['orientation'], 96 | shouldTranslateToOrigin = false 97 | ) { 98 | if (shouldTranslateToOrigin) { 99 | const hasParent = parent !== null && parent !== undefined; 100 | const originX = hasParent ? parent.x : 0; 101 | const originY = hasParent ? parent.y : 0; 102 | return orientation === 'horizontal' 103 | ? `translate(${originY},${originX})` 104 | : `translate(${originX},${originY})`; 105 | } 106 | return orientation === 'horizontal' 107 | ? `translate(${position.y},${position.x})` 108 | : `translate(${position.x},${position.y})`; 109 | } 110 | 111 | applyTransform( 112 | transform: string, 113 | transitionDuration: NodeProps['transitionDuration'], 114 | opacity = 1, 115 | done = () => {} 116 | ) { 117 | if (this.props.enableLegacyTransitions) { 118 | select(this.nodeRef) 119 | // @ts-ignore 120 | .transition() 121 | .duration(transitionDuration) 122 | .attr('transform', transform) 123 | .style('opacity', opacity) 124 | .on('end', done); 125 | } else { 126 | select(this.nodeRef) 127 | .attr('transform', transform) 128 | .style('opacity', opacity); 129 | done(); 130 | } 131 | } 132 | 133 | commitTransform() { 134 | const { orientation, transitionDuration, position, parent } = this.props; 135 | const transform = this.setTransform(position, parent, orientation); 136 | this.applyTransform(transform, transitionDuration); 137 | } 138 | 139 | // TODO: needs tests 140 | renderNodeElement = () => { 141 | const { data, hierarchyPointNode, renderCustomNodeElement } = this.props; 142 | const renderNode = 143 | typeof renderCustomNodeElement === 'function' ? renderCustomNodeElement : DefaultNodeElement; 144 | const nodeProps = { 145 | hierarchyPointNode: hierarchyPointNode, 146 | nodeDatum: data, 147 | toggleNode: this.handleNodeToggle, 148 | onNodeClick: this.handleOnClick, 149 | onNodeMouseOver: this.handleOnMouseOver, 150 | onNodeMouseOut: this.handleOnMouseOut, 151 | addChildren: this.handleAddChildren, 152 | }; 153 | 154 | return renderNode(nodeProps); 155 | }; 156 | 157 | handleNodeToggle = () => { 158 | this.setState({ wasClicked: true }); 159 | this.props.onNodeToggle(this.props.data.__rd3t.id); 160 | }; 161 | 162 | handleOnClick = evt => { 163 | this.setState({ wasClicked: true }); 164 | this.props.onNodeClick(this.props.hierarchyPointNode, evt); 165 | }; 166 | 167 | handleOnMouseOver = evt => { 168 | this.props.onNodeMouseOver(this.props.hierarchyPointNode, evt); 169 | }; 170 | 171 | handleOnMouseOut = evt => { 172 | this.props.onNodeMouseOut(this.props.hierarchyPointNode, evt); 173 | }; 174 | 175 | handleAddChildren: AddChildrenFunction = childrenData => { 176 | this.props.handleAddChildrenToNode(this.props.data.__rd3t.id, childrenData); 177 | }; 178 | 179 | componentWillLeave(done) { 180 | const { orientation, transitionDuration, position, parent } = this.props; 181 | const transform = this.setTransform(position, parent, orientation, true); 182 | this.applyTransform(transform, transitionDuration, 0, done); 183 | } 184 | 185 | render() { 186 | const { data, nodeClassName } = this.props; 187 | return ( 188 | { 191 | this.nodeRef = n; 192 | }} 193 | style={this.state.initialStyle} 194 | className={[ 195 | data.children && data.children.length > 0 ? 'rd3t-node' : 'rd3t-leaf-node', 196 | nodeClassName, 197 | ] 198 | .join(' ') 199 | .trim()} 200 | transform={this.state.transform} 201 | > 202 | {this.renderNodeElement()} 203 | 204 | ); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Tree/TransitionGroupWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TransitionGroup } from '@bkrem/react-transition-group'; 3 | 4 | interface TransitionGroupWrapperProps { 5 | enableLegacyTransitions: boolean; 6 | component: string; 7 | className: string; 8 | transform: string; 9 | children: React.ReactNode; 10 | } 11 | 12 | const TransitionGroupWrapper = (props: TransitionGroupWrapperProps) => 13 | props.enableLegacyTransitions ? ( 14 | 19 | {props.children} 20 | 21 | ) : ( 22 | 23 | {props.children} 24 | 25 | ); 26 | 27 | export default TransitionGroupWrapper; 28 | -------------------------------------------------------------------------------- /src/Tree/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { tree as d3tree, hierarchy, HierarchyPointNode } from 'd3-hierarchy'; 3 | import { select } from 'd3-selection'; 4 | import { zoom as d3zoom, zoomIdentity } from 'd3-zoom'; 5 | import { dequal as deepEqual } from 'dequal/lite'; 6 | import clone from 'clone'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | 9 | import TransitionGroupWrapper from './TransitionGroupWrapper.js'; 10 | import Node from '../Node/index.js'; 11 | import Link from '../Link/index.js'; 12 | import { TreeNodeDatum, Point, RawNodeDatum } from '../types/common.js'; 13 | import { TreeLinkEventCallback, TreeNodeEventCallback, TreeProps } from './types.js'; 14 | import globalCss from '../globalCss.js'; 15 | 16 | type TreeState = { 17 | dataRef: TreeProps['data']; 18 | data: TreeNodeDatum[]; 19 | d3: { translate: Point; scale: number }; 20 | isTransitioning: boolean; 21 | isInitialRenderForDataset: boolean; 22 | dataKey: string; 23 | }; 24 | 25 | class Tree extends React.Component { 26 | static defaultProps: Partial = { 27 | onNodeClick: undefined, 28 | onNodeMouseOver: undefined, 29 | onNodeMouseOut: undefined, 30 | onLinkClick: undefined, 31 | onLinkMouseOver: undefined, 32 | onLinkMouseOut: undefined, 33 | onUpdate: undefined, 34 | orientation: 'horizontal', 35 | translate: { x: 0, y: 0 }, 36 | pathFunc: 'diagonal', 37 | pathClassFunc: undefined, 38 | transitionDuration: 500, 39 | depthFactor: undefined, 40 | collapsible: true, 41 | initialDepth: undefined, 42 | zoomable: true, 43 | draggable: true, 44 | zoom: 1, 45 | scaleExtent: { min: 0.1, max: 1 }, 46 | nodeSize: { x: 140, y: 140 }, 47 | separation: { siblings: 1, nonSiblings: 2 }, 48 | shouldCollapseNeighborNodes: false, 49 | svgClassName: '', 50 | rootNodeClassName: '', 51 | branchNodeClassName: '', 52 | leafNodeClassName: '', 53 | renderCustomNodeElement: undefined, 54 | enableLegacyTransitions: false, 55 | hasInteractiveNodes: false, 56 | dimensions: undefined, 57 | centeringTransitionDuration: 800, 58 | dataKey: undefined, 59 | }; 60 | 61 | state: TreeState = { 62 | dataRef: this.props.data, 63 | data: Tree.assignInternalProperties(clone(this.props.data)), 64 | d3: Tree.calculateD3Geometry(this.props), 65 | isTransitioning: false, 66 | isInitialRenderForDataset: true, 67 | dataKey: this.props.dataKey, 68 | }; 69 | 70 | private internalState = { 71 | targetNode: null, 72 | isTransitioning: false, 73 | }; 74 | 75 | svgInstanceRef = `rd3t-svg-${uuidv4()}`; 76 | gInstanceRef = `rd3t-g-${uuidv4()}`; 77 | 78 | static getDerivedStateFromProps(nextProps: TreeProps, prevState: TreeState) { 79 | let derivedState: Partial = null; 80 | // Clone new data & assign internal properties if `data` object reference changed. 81 | // If the dataKey was present but didn't change, then we don't need to re-render the tree 82 | const dataKeyChanged = !nextProps.dataKey || prevState.dataKey !== nextProps.dataKey; 83 | if (nextProps.data !== prevState.dataRef && dataKeyChanged) { 84 | derivedState = { 85 | dataRef: nextProps.data, 86 | data: Tree.assignInternalProperties(clone(nextProps.data)), 87 | isInitialRenderForDataset: true, 88 | dataKey: nextProps.dataKey, 89 | }; 90 | } 91 | const d3 = Tree.calculateD3Geometry(nextProps); 92 | if (!deepEqual(d3, prevState.d3)) { 93 | derivedState = derivedState || {}; 94 | derivedState.d3 = d3; 95 | } 96 | return derivedState; 97 | } 98 | 99 | componentDidMount() { 100 | this.bindZoomListener(this.props); 101 | this.setState({ isInitialRenderForDataset: false }); 102 | } 103 | 104 | componentDidUpdate(prevProps: TreeProps) { 105 | if (this.props.data !== prevProps.data) { 106 | // If last `render` was due to change in dataset -> mark the initial render as done. 107 | this.setState({ isInitialRenderForDataset: false }); 108 | } 109 | 110 | if ( 111 | !deepEqual(this.props.translate, prevProps.translate) || 112 | !deepEqual(this.props.scaleExtent, prevProps.scaleExtent) || 113 | this.props.zoomable !== prevProps.zoomable || 114 | this.props.draggable !== prevProps.draggable || 115 | this.props.zoom !== prevProps.zoom || 116 | this.props.enableLegacyTransitions !== prevProps.enableLegacyTransitions 117 | ) { 118 | // If zoom-specific props change -> rebind listener with new values. 119 | // Or: rebind zoom listeners to new DOM nodes in case legacy transitions were enabled/disabled. 120 | this.bindZoomListener(this.props); 121 | } 122 | 123 | if (typeof this.props.onUpdate === 'function') { 124 | this.props.onUpdate({ 125 | node: this.internalState.targetNode ? clone(this.internalState.targetNode) : null, 126 | zoom: this.state.d3.scale, 127 | translate: this.state.d3.translate, 128 | }); 129 | } 130 | // Reset the last target node after we've flushed it to `onUpdate`. 131 | this.internalState.targetNode = null; 132 | } 133 | 134 | /** 135 | * Collapses all tree nodes with a `depth` larger than `initialDepth`. 136 | * 137 | * @param {array} nodeSet Array of nodes generated by `generateTree` 138 | * @param {number} initialDepth Maximum initial depth the tree should render 139 | */ 140 | setInitialTreeDepth(nodeSet: HierarchyPointNode[], initialDepth: number) { 141 | nodeSet.forEach(n => { 142 | n.data.__rd3t.collapsed = n.depth >= initialDepth; 143 | }); 144 | } 145 | 146 | /** 147 | * bindZoomListener - If `props.zoomable`, binds a listener for 148 | * "zoom" events to the SVG and sets scaleExtent to min/max 149 | * specified in `props.scaleExtent`. 150 | */ 151 | bindZoomListener(props: TreeProps) { 152 | const { zoomable, scaleExtent, translate, zoom, onUpdate, hasInteractiveNodes } = props; 153 | const svg = select(`.${this.svgInstanceRef}`); 154 | const g = select(`.${this.gInstanceRef}`); 155 | 156 | // Sets initial offset, so that first pan and zoom does not jump back to default [0,0] coords. 157 | // @ts-ignore 158 | svg.call(d3zoom().transform, zoomIdentity.translate(translate.x, translate.y).scale(zoom)); 159 | svg.call( 160 | d3zoom() 161 | .scaleExtent(zoomable ? [scaleExtent.min, scaleExtent.max] : [zoom, zoom]) 162 | // TODO: break this out into a separate zoom handler fn, rather than inlining it. 163 | .filter((event: any) => { 164 | if (hasInteractiveNodes) { 165 | return ( 166 | event.target.classList.contains(this.svgInstanceRef) || 167 | event.target.classList.contains(this.gInstanceRef) || 168 | event.shiftKey 169 | ); 170 | } 171 | return true; 172 | }) 173 | .on('zoom', (event: any) => { 174 | if ( 175 | !this.props.draggable && 176 | ['mousemove', 'touchmove', 'dblclick'].includes(event.sourceEvent.type) 177 | ) { 178 | return; 179 | } 180 | 181 | g.attr('transform', event.transform); 182 | if (typeof onUpdate === 'function') { 183 | // This callback is magically called not only on "zoom", but on "drag", as well, 184 | // even though event.type == "zoom". 185 | // Taking advantage of this and not writing a "drag" handler. 186 | onUpdate({ 187 | node: null, 188 | zoom: event.transform.k, 189 | translate: { x: event.transform.x, y: event.transform.y }, 190 | }); 191 | // TODO: remove this? Shouldn't be mutating state keys directly. 192 | this.state.d3.scale = event.transform.k; 193 | this.state.d3.translate = { 194 | x: event.transform.x, 195 | y: event.transform.y, 196 | }; 197 | } 198 | }) 199 | ); 200 | } 201 | 202 | /** 203 | * Assigns internal properties that are required for tree 204 | * manipulation to each node in the `data` set and returns a new `data` array. 205 | * 206 | * @static 207 | */ 208 | static assignInternalProperties(data: RawNodeDatum[], currentDepth: number = 0): TreeNodeDatum[] { 209 | // Wrap the root node into an array for recursive transformations if it wasn't in one already. 210 | const d = Array.isArray(data) ? data : [data]; 211 | return d.map(n => { 212 | const nodeDatum = n as TreeNodeDatum; 213 | nodeDatum.__rd3t = { id: null, depth: null, collapsed: false }; 214 | nodeDatum.__rd3t.id = uuidv4(); 215 | // D3@v5 compat: manually assign `depth` to node.data so we don't have 216 | // to hold full node+link sets in state. 217 | // TODO: avoid this extra step by checking D3's node.depth directly. 218 | nodeDatum.__rd3t.depth = currentDepth; 219 | // If there are children, recursively assign properties to them too. 220 | if (nodeDatum.children && nodeDatum.children.length > 0) { 221 | nodeDatum.children = Tree.assignInternalProperties(nodeDatum.children, currentDepth + 1); 222 | } 223 | return nodeDatum; 224 | }); 225 | } 226 | 227 | /** 228 | * Recursively walks the nested `nodeSet` until a node matching `nodeId` is found. 229 | */ 230 | findNodesById(nodeId: string, nodeSet: TreeNodeDatum[], hits: TreeNodeDatum[]) { 231 | if (hits.length > 0) { 232 | return hits; 233 | } 234 | hits = hits.concat(nodeSet.filter(node => node.__rd3t.id === nodeId)); 235 | nodeSet.forEach(node => { 236 | if (node.children && node.children.length > 0) { 237 | hits = this.findNodesById(nodeId, node.children, hits); 238 | } 239 | }); 240 | return hits; 241 | } 242 | 243 | /** 244 | * Recursively walks the nested `nodeSet` until all nodes at `depth` have been found. 245 | * 246 | * @param {number} depth Target depth for which nodes should be returned 247 | * @param {array} nodeSet Array of nested `node` objects 248 | * @param {array} accumulator Accumulator for matches, passed between recursive calls 249 | */ 250 | findNodesAtDepth(depth: number, nodeSet: TreeNodeDatum[], accumulator: TreeNodeDatum[]) { 251 | accumulator = accumulator.concat(nodeSet.filter(node => node.__rd3t.depth === depth)); 252 | nodeSet.forEach(node => { 253 | if (node.children && node.children.length > 0) { 254 | accumulator = this.findNodesAtDepth(depth, node.children, accumulator); 255 | } 256 | }); 257 | return accumulator; 258 | } 259 | 260 | /** 261 | * Recursively sets the internal `collapsed` property of 262 | * the passed `TreeNodeDatum` and its children to `true`. 263 | * 264 | * @static 265 | */ 266 | static collapseNode(nodeDatum: TreeNodeDatum) { 267 | nodeDatum.__rd3t.collapsed = true; 268 | if (nodeDatum.children && nodeDatum.children.length > 0) { 269 | nodeDatum.children.forEach(child => { 270 | Tree.collapseNode(child); 271 | }); 272 | } 273 | } 274 | 275 | /** 276 | * Sets the internal `collapsed` property of 277 | * the passed `TreeNodeDatum` object to `false`. 278 | * 279 | * @static 280 | */ 281 | static expandNode(nodeDatum: TreeNodeDatum) { 282 | nodeDatum.__rd3t.collapsed = false; 283 | } 284 | 285 | /** 286 | * Collapses all nodes in `nodeSet` that are neighbors (same depth) of `targetNode`. 287 | */ 288 | collapseNeighborNodes(targetNode: TreeNodeDatum, nodeSet: TreeNodeDatum[]) { 289 | const neighbors = this.findNodesAtDepth(targetNode.__rd3t.depth, nodeSet, []).filter( 290 | node => node.__rd3t.id !== targetNode.__rd3t.id 291 | ); 292 | neighbors.forEach(neighbor => Tree.collapseNode(neighbor)); 293 | } 294 | 295 | /** 296 | * Finds the node matching `nodeId` and 297 | * expands/collapses it, depending on the current state of 298 | * its internal `collapsed` property. 299 | * `setState` callback receives targetNode and handles 300 | * `props.onClick` if defined. 301 | */ 302 | handleNodeToggle = (nodeId: string) => { 303 | const data = clone(this.state.data); 304 | const matches = this.findNodesById(nodeId, data, []); 305 | const targetNodeDatum = matches[0]; 306 | 307 | if (this.props.collapsible && !this.state.isTransitioning) { 308 | if (targetNodeDatum.__rd3t.collapsed) { 309 | Tree.expandNode(targetNodeDatum); 310 | this.props.shouldCollapseNeighborNodes && this.collapseNeighborNodes(targetNodeDatum, data); 311 | } else { 312 | Tree.collapseNode(targetNodeDatum); 313 | } 314 | 315 | if (this.props.enableLegacyTransitions) { 316 | // Lock node toggling while transition takes place. 317 | this.setState({ data, isTransitioning: true }); 318 | // Await transitionDuration + 10 ms before unlocking node toggling again. 319 | setTimeout( 320 | () => this.setState({ isTransitioning: false }), 321 | this.props.transitionDuration + 10 322 | ); 323 | } else { 324 | this.setState({ data }); 325 | } 326 | 327 | this.internalState.targetNode = targetNodeDatum; 328 | } 329 | }; 330 | 331 | handleAddChildrenToNode = (nodeId: string, childrenData: RawNodeDatum[]) => { 332 | const data = clone(this.state.data); 333 | const matches = this.findNodesById(nodeId, data, []); 334 | 335 | if (matches.length > 0) { 336 | const targetNodeDatum = matches[0]; 337 | 338 | const depth = targetNodeDatum.__rd3t.depth; 339 | const formattedChildren = clone(childrenData).map((node: RawNodeDatum) => 340 | Tree.assignInternalProperties([node], depth + 1) 341 | ); 342 | targetNodeDatum.children.push(...formattedChildren.flat()); 343 | 344 | this.setState({ data }); 345 | } 346 | }; 347 | 348 | /** 349 | * Handles the user-defined `onNodeClick` function. 350 | */ 351 | handleOnNodeClickCb: TreeNodeEventCallback = (hierarchyPointNode, evt) => { 352 | const { onNodeClick } = this.props; 353 | if (onNodeClick && typeof onNodeClick === 'function') { 354 | // Persist the SyntheticEvent for downstream handling by users. 355 | evt.persist(); 356 | onNodeClick(clone(hierarchyPointNode), evt); 357 | } 358 | }; 359 | 360 | /** 361 | * Handles the user-defined `onLinkClick` function. 362 | */ 363 | handleOnLinkClickCb: TreeLinkEventCallback = (linkSource, linkTarget, evt) => { 364 | const { onLinkClick } = this.props; 365 | if (onLinkClick && typeof onLinkClick === 'function') { 366 | // Persist the SyntheticEvent for downstream handling by users. 367 | evt.persist(); 368 | onLinkClick(clone(linkSource), clone(linkTarget), evt); 369 | } 370 | }; 371 | 372 | /** 373 | * Handles the user-defined `onNodeMouseOver` function. 374 | */ 375 | handleOnNodeMouseOverCb: TreeNodeEventCallback = (hierarchyPointNode, evt) => { 376 | const { onNodeMouseOver } = this.props; 377 | if (onNodeMouseOver && typeof onNodeMouseOver === 'function') { 378 | // Persist the SyntheticEvent for downstream handling by users. 379 | evt.persist(); 380 | onNodeMouseOver(clone(hierarchyPointNode), evt); 381 | } 382 | }; 383 | 384 | /** 385 | * Handles the user-defined `onLinkMouseOver` function. 386 | */ 387 | handleOnLinkMouseOverCb: TreeLinkEventCallback = (linkSource, linkTarget, evt) => { 388 | const { onLinkMouseOver } = this.props; 389 | if (onLinkMouseOver && typeof onLinkMouseOver === 'function') { 390 | // Persist the SyntheticEvent for downstream handling by users. 391 | evt.persist(); 392 | onLinkMouseOver(clone(linkSource), clone(linkTarget), evt); 393 | } 394 | }; 395 | 396 | /** 397 | * Handles the user-defined `onNodeMouseOut` function. 398 | */ 399 | handleOnNodeMouseOutCb: TreeNodeEventCallback = (hierarchyPointNode, evt) => { 400 | const { onNodeMouseOut } = this.props; 401 | if (onNodeMouseOut && typeof onNodeMouseOut === 'function') { 402 | // Persist the SyntheticEvent for downstream handling by users. 403 | evt.persist(); 404 | onNodeMouseOut(clone(hierarchyPointNode), evt); 405 | } 406 | }; 407 | 408 | /** 409 | * Handles the user-defined `onLinkMouseOut` function. 410 | */ 411 | handleOnLinkMouseOutCb: TreeLinkEventCallback = (linkSource, linkTarget, evt) => { 412 | const { onLinkMouseOut } = this.props; 413 | if (onLinkMouseOut && typeof onLinkMouseOut === 'function') { 414 | // Persist the SyntheticEvent for downstream handling by users. 415 | evt.persist(); 416 | onLinkMouseOut(clone(linkSource), clone(linkTarget), evt); 417 | } 418 | }; 419 | 420 | /** 421 | * Takes a hierarchy point node and centers the node on the screen 422 | * if the dimensions parameter is passed to `Tree`. 423 | * 424 | * This code is adapted from Rob Schmuecker's centerNode method. 425 | * Link: http://bl.ocks.org/robschmuecker/7880033 426 | */ 427 | centerNode = (hierarchyPointNode: HierarchyPointNode) => { 428 | const { dimensions, orientation, zoom, centeringTransitionDuration } = this.props; 429 | if (dimensions) { 430 | const g = select(`.${this.gInstanceRef}`); 431 | const svg = select(`.${this.svgInstanceRef}`); 432 | const scale = this.state.d3.scale; 433 | 434 | let x: number; 435 | let y: number; 436 | // if the orientation is horizontal, calculate the variables inverted (x->y, y->x) 437 | if (orientation === 'horizontal') { 438 | y = -hierarchyPointNode.x * scale + dimensions.height / 2; 439 | x = -hierarchyPointNode.y * scale + dimensions.width / 2; 440 | } else { 441 | // else, calculate the variables normally (x->x, y->y) 442 | x = -hierarchyPointNode.x * scale + dimensions.width / 2; 443 | y = -hierarchyPointNode.y * scale + dimensions.height / 2; 444 | } 445 | //@ts-ignore 446 | g.transition() 447 | .duration(centeringTransitionDuration) 448 | .attr('transform', 'translate(' + x + ',' + y + ')scale(' + scale + ')'); 449 | // Sets the viewport to the new center so that it does not jump back to original 450 | // coordinates when dragged/zoomed 451 | //@ts-ignore 452 | svg.call(d3zoom().transform, zoomIdentity.translate(x, y).scale(zoom)); 453 | } 454 | }; 455 | 456 | /** 457 | * Generates tree elements (`nodes` and `links`) by 458 | * grabbing the rootNode from `this.state.data[0]`. 459 | * Restricts tree depth to `props.initialDepth` if defined and if this is 460 | * the initial render of the tree. 461 | */ 462 | generateTree() { 463 | const { initialDepth, depthFactor, separation, nodeSize, orientation } = this.props; 464 | const { isInitialRenderForDataset } = this.state; 465 | const tree = d3tree() 466 | .nodeSize(orientation === 'horizontal' ? [nodeSize.y, nodeSize.x] : [nodeSize.x, nodeSize.y]) 467 | .separation((a, b) => 468 | a.parent.data.__rd3t.id === b.parent.data.__rd3t.id 469 | ? separation.siblings 470 | : separation.nonSiblings 471 | ); 472 | 473 | const rootNode = tree( 474 | hierarchy(this.state.data[0], d => (d.__rd3t.collapsed ? null : d.children)) 475 | ); 476 | let nodes = rootNode.descendants(); 477 | const links = rootNode.links(); 478 | 479 | // Configure nodes' `collapsed` property on first render if `initialDepth` is defined. 480 | if (initialDepth !== undefined && isInitialRenderForDataset) { 481 | this.setInitialTreeDepth(nodes, initialDepth); 482 | } 483 | 484 | if (depthFactor) { 485 | nodes.forEach(node => { 486 | node.y = node.depth * depthFactor; 487 | }); 488 | } 489 | 490 | return { nodes, links }; 491 | } 492 | 493 | /** 494 | * Set initial zoom and position. 495 | * Also limit zoom level according to `scaleExtent` on initial display. This is necessary, 496 | * because the first time we are setting it as an SVG property, instead of going 497 | * through D3's scaling mechanism, which would have picked up both properties. 498 | * 499 | * @static 500 | */ 501 | static calculateD3Geometry(nextProps: TreeProps) { 502 | let scale; 503 | if (nextProps.zoom > nextProps.scaleExtent.max) { 504 | scale = nextProps.scaleExtent.max; 505 | } else if (nextProps.zoom < nextProps.scaleExtent.min) { 506 | scale = nextProps.scaleExtent.min; 507 | } else { 508 | scale = nextProps.zoom; 509 | } 510 | return { 511 | translate: nextProps.translate, 512 | scale, 513 | }; 514 | } 515 | 516 | /** 517 | * Determines which additional `className` prop should be passed to the node & returns it. 518 | */ 519 | getNodeClassName = (parent: HierarchyPointNode, nodeDatum: TreeNodeDatum) => { 520 | const { rootNodeClassName, branchNodeClassName, leafNodeClassName } = this.props; 521 | const hasParent = parent !== null && parent !== undefined; 522 | if (hasParent) { 523 | return nodeDatum.children ? branchNodeClassName : leafNodeClassName; 524 | } else { 525 | return rootNodeClassName; 526 | } 527 | }; 528 | 529 | render() { 530 | const { nodes, links } = this.generateTree(); 531 | const { 532 | renderCustomNodeElement, 533 | orientation, 534 | pathFunc, 535 | transitionDuration, 536 | nodeSize, 537 | depthFactor, 538 | initialDepth, 539 | separation, 540 | enableLegacyTransitions, 541 | svgClassName, 542 | pathClassFunc, 543 | } = this.props; 544 | const { translate, scale } = this.state.d3; 545 | const subscriptions = { 546 | ...nodeSize, 547 | ...separation, 548 | depthFactor, 549 | initialDepth, 550 | }; 551 | 552 | return ( 553 |
554 | 555 | 560 | 566 | {links.map((linkData, i) => { 567 | return ( 568 | 580 | ); 581 | })} 582 | 583 | {nodes.map((hierarchyPointNode, i) => { 584 | const { data, x, y, parent } = hierarchyPointNode; 585 | return ( 586 | 606 | ); 607 | })} 608 | 609 | 610 |
611 | ); 612 | } 613 | } 614 | 615 | export default Tree; 616 | -------------------------------------------------------------------------------- /src/Tree/tests/TransitionGroupWrapper.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { TransitionGroup } from '@bkrem/react-transition-group'; 4 | 5 | import TransitionGroupWrapper from '../TransitionGroupWrapper.tsx'; 6 | 7 | describe('', () => { 8 | it('renders a when transitions are disabled', () => { 9 | const fixture = { 10 | component: 'g', 11 | transform: 't', 12 | className: 'cls', 13 | enableLegacyTransitions: false, 14 | transitionDuration: 500, 15 | }; 16 | 17 | const renderedComponent = shallow( 18 | {[]} 19 | ); 20 | expect(renderedComponent.find('g').prop('transform')).toContain(fixture.transform); 21 | expect(renderedComponent.find('g').prop('className')).toContain(fixture.className); 22 | }); 23 | 24 | it('renders a when transitions are enabled', () => { 25 | const fixture = { 26 | component: 'g', 27 | transform: 't', 28 | className: 'cls', 29 | enableLegacyTransitions: true, 30 | transitionDuration: 500, 31 | }; 32 | 33 | const renderedComponent = shallow( 34 | {[]} 35 | ); 36 | expect(renderedComponent.find(TransitionGroup).prop('transform')).toContain(fixture.transform); 37 | expect(renderedComponent.find(TransitionGroup).prop('className')).toContain(fixture.className); 38 | expect(renderedComponent.find(TransitionGroup).prop('component')).toContain(fixture.component); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/Tree/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import React from 'react'; 3 | import { shallow, mount } from 'enzyme'; 4 | import { render } from 'react-dom'; 5 | 6 | import TransitionGroupWrapper from '../TransitionGroupWrapper.tsx'; 7 | import Node from '../../Node/index.tsx'; 8 | import Link from '../../Link/index.tsx'; 9 | import Tree from '../index.tsx'; 10 | import { mockData, mockData2, mockData4, mockTree_D1N2_D2N2 } from './mockData'; 11 | 12 | describe('', () => { 13 | jest.spyOn(Tree.prototype, 'generateTree'); 14 | jest.spyOn(Tree, 'assignInternalProperties'); 15 | jest.spyOn(Tree, 'collapseNode'); 16 | jest.spyOn(Tree, 'expandNode'); 17 | jest.spyOn(Tree.prototype, 'setInitialTreeDepth'); 18 | jest.spyOn(Tree.prototype, 'bindZoomListener'); 19 | jest.spyOn(Tree.prototype, 'collapseNeighborNodes'); 20 | 21 | // Clear method spies on prototype after each test 22 | afterEach(() => jest.clearAllMocks()); 23 | 24 | it('builds a tree on each render', () => { 25 | const renderedComponent = shallow(); 26 | expect(renderedComponent.instance().generateTree).toHaveBeenCalled(); 27 | }); 28 | 29 | it('maps every node onto a ', () => { 30 | const nodeCount = 5; // root + 2 nodes + 2 child nodes 31 | const renderedComponent = shallow(); 32 | 33 | expect(renderedComponent.find(Node).length).toBe(nodeCount); 34 | }); 35 | 36 | it('maps every parent-child relation onto a ', () => { 37 | const linkCount = 4; 38 | const renderedComponent = shallow(); 39 | 40 | expect(renderedComponent.find(Link).length).toBe(linkCount); 41 | }); 42 | 43 | it('maps every parent-child relation onto a with expected siblings', () => { 44 | const linkCount = 5; // 1 top level node + 2 child nodes (1 child, 2 children) in mockData 45 | const renderedComponent = shallow(); 46 | 47 | expect(renderedComponent.find(Link).length).toBe(linkCount); 48 | }); 49 | 50 | // Ensures D3's pan & zoom listeners can bind to multiple trees in a single view (https://github.com/bkrem/react-d3-tree/issues/100). 51 | it("assigns unique ref classNames to each Tree instance's `svg` and primary `g` tag", () => { 52 | const tree1 = shallow(); 53 | const tree2 = shallow(); 54 | expect(tree1.find('.rd3t-svg').prop('className')).not.toBe( 55 | tree2.find('.rd3t-svg').prop('className') 56 | ); 57 | expect(tree1.find('.rd3t-g').prop('className')).not.toBe( 58 | tree2.find('.rd3t-g').prop('className') 59 | ); 60 | }); 61 | 62 | it('reassigns internal props if `props.data` changes', () => { 63 | // `assignInternalProperties` recurses by depth: 1 level -> 1 call 64 | const mockDataDepth = 3; 65 | const mockData2Depth = 2; 66 | const nextProps = { 67 | data: mockData2, 68 | }; 69 | const renderedComponent = mount(); 70 | expect(Tree.assignInternalProperties).toHaveBeenCalledTimes(mockDataDepth); 71 | renderedComponent.setProps(nextProps); 72 | expect(Tree.assignInternalProperties).toHaveBeenCalledTimes(mockDataDepth + mockData2Depth); 73 | }); 74 | 75 | it("reassigns internal props if `props.data`'s array reference changes", () => { 76 | // `assignInternalProperties` recurses by depth: 1 level -> 1 call 77 | const mockDataDepth = 3; 78 | const nextDataDepth = 3; 79 | const nextData = [...mockData]; 80 | nextData[0].children.push({ name: `${nextData[0].children.length}` }); 81 | const renderedComponent = mount(); 82 | expect(Tree.assignInternalProperties).toHaveBeenCalledTimes(mockDataDepth); 83 | renderedComponent.setProps({ data: nextData }); 84 | expect(Tree.assignInternalProperties).toHaveBeenCalledTimes(mockDataDepth + nextDataDepth); 85 | }); 86 | 87 | describe('translate', () => { 88 | it('applies the `translate` prop when specified', () => { 89 | const fixture = { x: 123, y: 321 }; 90 | const expected = `translate(${fixture.x},${fixture.y})`; 91 | const renderedComponent = shallow(); 92 | expect(renderedComponent.find(TransitionGroupWrapper).prop('transform')).toContain(expected); 93 | }); 94 | }); 95 | 96 | describe('depthFactor', () => { 97 | it("mutates each node's `y` prop according to `depthFactor` when specified", () => { 98 | const depthFactor = 100; 99 | const renderedComponent = shallow( 100 | 101 | ); 102 | 103 | const { nodes } = renderedComponent.instance().generateTree(mockData); 104 | nodes.forEach(node => { 105 | expect(node.y).toBe(node.depth * depthFactor); 106 | }); 107 | }); 108 | }); 109 | 110 | describe('orientation', () => { 111 | it('passes `props.orientation` to its and children', () => { 112 | const fixture = 'vertical'; 113 | const renderedComponent = shallow(); 114 | 115 | expect(renderedComponent.find(Node).everyWhere(n => n.prop('orientation') === fixture)).toBe( 116 | true 117 | ); 118 | expect(renderedComponent.find(Link).everyWhere(n => n.prop('orientation') === fixture)).toBe( 119 | true 120 | ); 121 | }); 122 | }); 123 | 124 | describe('collapsible', () => { 125 | it("collapses a node's children when it is clicked in an expanded state", () => { 126 | const renderedComponent = mount(); 127 | const nodeCount = renderedComponent.find(Node).length; 128 | renderedComponent 129 | .find(Node) 130 | .first() 131 | .find('circle') 132 | .simulate('click'); // collapse 133 | 134 | expect(Tree.collapseNode).toHaveBeenCalledTimes(nodeCount); 135 | }); 136 | 137 | it("expands a node's children when it is clicked in a collapsed state", () => { 138 | const renderedComponent = mount(); 139 | const nodeCount = renderedComponent.find(Node).length; 140 | renderedComponent 141 | .find(Node) 142 | .first() 143 | .find('circle') 144 | .simulate('click'); // collapse 145 | 146 | renderedComponent 147 | .find(Node) 148 | .first() 149 | .find('circle') 150 | .simulate('click'); // re-expand 151 | 152 | expect(Tree.collapseNode).toHaveBeenCalledTimes(nodeCount); 153 | expect(Tree.expandNode).toHaveBeenCalledTimes(1); 154 | }); 155 | 156 | it('does not collapse a node if `props.collapsible` is false', () => { 157 | const renderedComponent = mount(); 158 | renderedComponent 159 | .find(Node) 160 | .first() 161 | .find('circle') 162 | .simulate('click'); 163 | 164 | expect(Tree.collapseNode).toHaveBeenCalledTimes(0); 165 | }); 166 | 167 | describe('with `props.enableLegacyTransitions`', () => { 168 | it('does not toggle any nodes again until `transitionDuration` has completed', () => { 169 | const renderedComponent = mount(); 170 | const nodeCount = renderedComponent.find(Node).length; 171 | renderedComponent 172 | .find(Node) 173 | .first() 174 | .find('circle') 175 | .simulate('click'); 176 | 177 | renderedComponent 178 | .find(Node) 179 | .first() 180 | .find('circle') 181 | .simulate('click'); 182 | 183 | expect(Tree.collapseNode).toHaveBeenCalledTimes(nodeCount); 184 | expect(Tree.expandNode).not.toHaveBeenCalled(); 185 | }); 186 | 187 | it('allows toggling nodes again after `transitionDuration` + 10ms has expired', () => { 188 | jest.useFakeTimers(); 189 | const renderedComponent = mount(); 190 | const nodeCount = renderedComponent.find(Node).length; 191 | renderedComponent 192 | .find(Node) 193 | .first() 194 | .find('circle') 195 | .simulate('click'); 196 | 197 | jest.runAllTimers(); 198 | 199 | renderedComponent 200 | .find(Node) 201 | .first() 202 | .find('circle') 203 | .simulate('click'); 204 | 205 | expect(Tree.collapseNode).toHaveBeenCalledTimes(nodeCount); 206 | expect(Tree.expandNode).toHaveBeenCalledTimes(1); 207 | }); 208 | }); 209 | }); 210 | 211 | describe('shouldCollapseNeighborNodes', () => { 212 | it('is inactive by default', () => { 213 | const renderedComponent = mount(); 214 | renderedComponent 215 | .find(Node) 216 | .first() 217 | .simulate('click'); // collapse 218 | 219 | renderedComponent 220 | .find(Node) 221 | .first() 222 | .simulate('click'); // re-expand 223 | 224 | expect(Tree.prototype.collapseNeighborNodes).toHaveBeenCalledTimes(0); 225 | }); 226 | 227 | it('collapses all neighbor nodes of the targetNode if it is about to be expanded', () => { 228 | const renderedComponent = mount(); 229 | renderedComponent 230 | .find(Node) 231 | .first() 232 | .find('circle') 233 | .simulate('click'); // collapse 234 | 235 | renderedComponent 236 | .find(Node) 237 | .first() 238 | .find('circle') 239 | .simulate('click'); // re-expand 240 | 241 | expect(Tree.prototype.collapseNeighborNodes).toHaveBeenCalledTimes(1); 242 | }); 243 | }); 244 | 245 | describe('initialDepth', () => { 246 | it('expands tree to full depth by default', () => { 247 | const renderedComponent = shallow(); 248 | expect(renderedComponent.find(Node).length).toBe(5); 249 | }); 250 | 251 | it('expands tree to `props.initialDepth` if specified', () => { 252 | const renderedComponent = shallow(); 253 | expect(renderedComponent.find(Node).length).toBe(3); 254 | }); 255 | 256 | it('renders only the root node if `initialDepth === 0`', () => { 257 | const renderedComponent = shallow(); 258 | expect(renderedComponent.find(Node).length).toBe(1); 259 | }); 260 | 261 | it('increases tree depth by no more than 1 level when a node is expanded after initialising to `initialDepth`', () => { 262 | const renderedComponent = mount(); 263 | expect(renderedComponent.find(Node).length).toBe(1); 264 | renderedComponent 265 | .find(Node) 266 | .first() 267 | .find('circle') 268 | .simulate('click'); 269 | expect(renderedComponent.find(Node).length).toBe(3); 270 | }); 271 | }); 272 | 273 | describe('zoom', () => { 274 | it('applies the `zoom` prop when specified', () => { 275 | const zoomLevel = 0.3; 276 | const expected = `scale(${zoomLevel})`; 277 | const renderedComponent = shallow(); 278 | expect(renderedComponent.find(TransitionGroupWrapper).prop('transform')).toContain(expected); 279 | }); 280 | 281 | it('applies default zoom level when `zoom` is not specified', () => { 282 | const renderedComponent = shallow(); 283 | expect(renderedComponent.find(TransitionGroupWrapper).prop('transform')).toContain( 284 | `scale(1)` 285 | ); 286 | }); 287 | 288 | it('respects `scaleExtent` constraints on initial display', () => { 289 | const scaleExtent = { min: 0.2, max: 0.8 }; 290 | 291 | let renderedComponent = shallow( 292 | 293 | ); 294 | expect(renderedComponent.find(TransitionGroupWrapper).prop('transform')).toContain( 295 | `scale(${scaleExtent.max})` 296 | ); 297 | 298 | renderedComponent = shallow(); 299 | expect(renderedComponent.find(TransitionGroupWrapper).prop('transform')).toContain( 300 | `scale(${scaleExtent.min})` 301 | ); 302 | }); 303 | 304 | it('rebinds zoom handler on zoom-related props update', () => { 305 | const zoomProps = [ 306 | { translate: { x: 1, y: 1 } }, 307 | { scaleExtent: { min: 0.3, max: 0.4 } }, 308 | { zoom: 3.1415 }, 309 | ]; 310 | const renderedComponent = mount(); 311 | 312 | expect(renderedComponent.instance().bindZoomListener).toHaveBeenCalledTimes(1); 313 | 314 | zoomProps.forEach(nextProps => renderedComponent.setProps(nextProps)); 315 | expect(renderedComponent.instance().bindZoomListener).toHaveBeenCalledTimes(4); 316 | }); 317 | 318 | it('rebinds on `props.enableLegacyTransitions` change to handle switched DOM nodes from TransitionGroupWrapper', () => { 319 | const renderedComponent = mount(); 320 | expect(renderedComponent.instance().bindZoomListener).toHaveBeenCalledTimes(1); 321 | renderedComponent.setProps({ enableLegacyTransitions: true }); 322 | expect(renderedComponent.instance().bindZoomListener).toHaveBeenCalledTimes(2); 323 | }); 324 | }); 325 | 326 | describe('Event handlers', () => { 327 | describe('onNodeClick', () => { 328 | it('calls the onNodeClick callback when a node is toggled', () => { 329 | const onClickSpy = jest.fn(); 330 | const renderedComponent = mount(); 331 | 332 | renderedComponent 333 | .find(Node) 334 | .first() 335 | .find('circle') 336 | .simulate('click'); 337 | 338 | expect(onClickSpy).toHaveBeenCalledTimes(1); 339 | }); 340 | 341 | it('calls the onNodeClick callback even when `props.collapsible` is false', () => { 342 | const onClickSpy = jest.fn(); 343 | const renderedComponent = mount( 344 | 345 | ); 346 | 347 | renderedComponent 348 | .find(Node) 349 | .first() 350 | .find('circle') 351 | .simulate('click'); 352 | 353 | expect(onClickSpy).toHaveBeenCalledTimes(1); 354 | }); 355 | 356 | it('clones the `hierarchyPointNode` representation & passes it to the onNodeClick callback if defined', () => { 357 | const onClickSpy = jest.fn(); 358 | const mockEvt = { mock: 'event' }; 359 | 360 | const renderedComponent = mount( 361 | // Disable `collapsible` here to avoid side-effects on the underlying tree structure, 362 | // i.e. node's expanding/collapsing onClick. 363 | 364 | ); 365 | 366 | renderedComponent 367 | .find(Node) 368 | .first() 369 | .find('circle') 370 | .simulate('click', mockEvt); 371 | 372 | expect(onClickSpy).toHaveBeenCalledWith( 373 | renderedComponent 374 | .find(Node) 375 | .first() 376 | .prop('hierarchyPointNode'), 377 | expect.objectContaining(mockEvt) 378 | ); 379 | }); 380 | 381 | it('persists the SynthethicEvent for downstream processing', () => { 382 | const persistSpy = jest.fn(); 383 | const mockEvt = { mock: 'event', persist: persistSpy }; 384 | const renderedComponent = mount( {}} />); 385 | 386 | renderedComponent 387 | .find(Node) 388 | .first() 389 | .find('circle') 390 | .simulate('click', mockEvt); 391 | 392 | expect(persistSpy).toHaveBeenCalledTimes(1); 393 | }); 394 | }); 395 | 396 | describe('onNodeMouseOver', () => { 397 | it('calls the onNodeMouseOver callback when a node is hovered over', () => { 398 | const onMouseOverSpy = jest.fn(); 399 | const renderedComponent = mount(); 400 | 401 | renderedComponent 402 | .find(Node) 403 | .first() 404 | .find('circle') 405 | .simulate('mouseover'); 406 | 407 | expect(onMouseOverSpy).toHaveBeenCalledTimes(1); 408 | }); 409 | 410 | it('does not call the onNodeMouseOver callback if it is not a function', () => { 411 | const onMouseOverSpy = jest.fn(); 412 | const renderedComponent = mount(); 413 | 414 | renderedComponent 415 | .find(Node) 416 | .first() 417 | .find('circle') 418 | .simulate('mouseover'); 419 | 420 | expect(onMouseOverSpy).toHaveBeenCalledTimes(0); 421 | }); 422 | 423 | it('clones the `hierarchyPointNode` representation & passes it to the onNodeMouseOver callback if defined', () => { 424 | const onMouseOverSpy = jest.fn(); 425 | const mockEvt = { mock: 'event' }; 426 | const renderedComponent = mount(); 427 | 428 | renderedComponent 429 | .find(Node) 430 | .first() 431 | .find('circle') 432 | .simulate('mouseover', mockEvt); 433 | 434 | expect(onMouseOverSpy).toHaveBeenCalledWith( 435 | renderedComponent 436 | .find(Node) 437 | .first() 438 | .prop('hierarchyPointNode'), 439 | expect.objectContaining(mockEvt) 440 | ); 441 | }); 442 | 443 | it('persists the SynthethicEvent for downstream processing if handler is defined', () => { 444 | const persistSpy = jest.fn(); 445 | const mockEvt = { mock: 'event', persist: persistSpy }; 446 | const renderedComponent = mount( {}} />); 447 | 448 | renderedComponent 449 | .find(Node) 450 | .first() 451 | .find('circle') 452 | .simulate('mouseover', mockEvt); 453 | 454 | expect(persistSpy).toHaveBeenCalledTimes(1); 455 | }); 456 | }); 457 | 458 | describe('onNodeMouseOut', () => { 459 | it('calls the onNodeMouseOut callback when a node is hovered over', () => { 460 | const onMouseOutSpy = jest.fn(); 461 | const renderedComponent = mount(); 462 | 463 | renderedComponent 464 | .find(Node) 465 | .first() 466 | .find('circle') 467 | .simulate('mouseout'); 468 | 469 | expect(onMouseOutSpy).toHaveBeenCalledTimes(1); 470 | }); 471 | 472 | it('does not call the onNodeMouseOut callback if it is not a function', () => { 473 | const onMouseOutSpy = jest.fn(); 474 | const renderedComponent = mount(); 475 | 476 | renderedComponent 477 | .find(Node) 478 | .first() 479 | .find('circle') 480 | .simulate('mouseout'); 481 | 482 | expect(onMouseOutSpy).toHaveBeenCalledTimes(0); 483 | }); 484 | 485 | it('clones the `hierarchyPointNode` representation & passes it to the onNodeMouseOut callback if defined', () => { 486 | const onMouseOutSpy = jest.fn(); 487 | const mockEvt = { mock: 'event' }; 488 | const renderedComponent = mount(); 489 | 490 | renderedComponent 491 | .find(Node) 492 | .first() 493 | .find('circle') 494 | .simulate('mouseout', mockEvt); 495 | 496 | expect(onMouseOutSpy).toHaveBeenCalledWith( 497 | renderedComponent 498 | .find(Node) 499 | .first() 500 | .prop('hierarchyPointNode'), 501 | expect.objectContaining(mockEvt) 502 | ); 503 | }); 504 | 505 | it('persists the SynthethicEvent for downstream processing if handler is defined', () => { 506 | const persistSpy = jest.fn(); 507 | const mockEvt = { mock: 'event', persist: persistSpy }; 508 | const renderedComponent = mount( {}} />); 509 | 510 | renderedComponent 511 | .find(Node) 512 | .first() 513 | .find('circle') 514 | .simulate('mouseout', mockEvt); 515 | 516 | expect(persistSpy).toHaveBeenCalledTimes(1); 517 | }); 518 | }); 519 | 520 | describe('onLinkClick', () => { 521 | it('calls the onLinkClick callback when a node is toggled', () => { 522 | const onLinkClickSpy = jest.fn(); 523 | const renderedComponent = mount(); 524 | 525 | renderedComponent 526 | .find(Link) 527 | .first() 528 | .simulate('click'); 529 | 530 | expect(onLinkClickSpy).toHaveBeenCalledTimes(1); 531 | }); 532 | 533 | it('does not call the onLinkClick callback if it is not a function', () => { 534 | const onClickSpy = jest.fn(); 535 | const renderedComponent = mount(); 536 | 537 | renderedComponent 538 | .find(Link) 539 | .first() 540 | .simulate('click'); 541 | 542 | expect(onClickSpy).toHaveBeenCalledTimes(0); 543 | }); 544 | 545 | it('calls the onLinkClick callback even when `props.collapsible` is false', () => { 546 | const onLinkClickSpy = jest.fn(); 547 | const renderedComponent = mount( 548 | 549 | ); 550 | 551 | renderedComponent 552 | .find(Link) 553 | .first() 554 | .simulate('click'); 555 | 556 | expect(onLinkClickSpy).toHaveBeenCalledTimes(1); 557 | }); 558 | 559 | it("clones the clicked link's data & passes it to the onLinkClick callback if defined", () => { 560 | const onLinkClickSpy = jest.fn(); 561 | const mockEvt = { mock: 'event' }; 562 | const renderedComponent = mount(); 563 | 564 | renderedComponent 565 | .find(Link) 566 | .first() 567 | .simulate('click', mockEvt); 568 | 569 | expect(onLinkClickSpy).toHaveBeenCalledWith( 570 | renderedComponent 571 | .find(Link) 572 | .first() 573 | .prop('linkData').source, 574 | renderedComponent 575 | .find(Link) 576 | .first() 577 | .prop('linkData').target, 578 | expect.objectContaining(mockEvt) 579 | ); 580 | }); 581 | 582 | it('persists the SyntheticEvent for downstream processing', () => { 583 | const persistSpy = jest.fn(); 584 | const mockEvt = { mock: 'event', persist: persistSpy }; 585 | const renderedComponent = mount( {}} />); 586 | 587 | renderedComponent 588 | .find(Link) 589 | .first() 590 | .simulate('click', mockEvt); 591 | 592 | expect(persistSpy).toHaveBeenCalledTimes(1); 593 | }); 594 | }); 595 | 596 | describe('onLinkMouseOver', () => { 597 | it('calls the onLinkMouseOver callback when a node is hovered over', () => { 598 | const onLinkMouseOverOverSpy = jest.fn(); 599 | const renderedComponent = mount( 600 | 601 | ); 602 | 603 | renderedComponent 604 | .find(Link) 605 | .first() 606 | .simulate('mouseover'); 607 | 608 | expect(onLinkMouseOverOverSpy).toHaveBeenCalledTimes(1); 609 | }); 610 | 611 | it('does not call the onLinkMouseOver callback if it is not a function', () => { 612 | const onLinkMouseOverSpy = jest.fn(); 613 | const renderedComponent = mount(); 614 | 615 | renderedComponent 616 | .find(Link) 617 | .first() 618 | .simulate('mouseover'); 619 | 620 | expect(onLinkMouseOverSpy).toHaveBeenCalledTimes(0); 621 | }); 622 | 623 | it("clones the hovered node's data & passes it to the onLinkMouseOver callback if defined", () => { 624 | const onLinkMouseOverOverSpy = jest.fn(); 625 | const mockEvt = { mock: 'event' }; 626 | const renderedComponent = mount( 627 | 628 | ); 629 | 630 | renderedComponent 631 | .find(Link) 632 | .first() 633 | .simulate('mouseover', mockEvt); 634 | 635 | expect(onLinkMouseOverOverSpy).toHaveBeenCalledWith( 636 | renderedComponent 637 | .find(Link) 638 | .first() 639 | .prop('linkData').source, 640 | renderedComponent 641 | .find(Link) 642 | .first() 643 | .prop('linkData').target, 644 | expect.objectContaining(mockEvt) 645 | ); 646 | }); 647 | 648 | it('persists the SynthethicEvent for downstream processing if handler is defined', () => { 649 | const persistSpy = jest.fn(); 650 | const mockEvt = { mock: 'event', persist: persistSpy }; 651 | const renderedComponent = mount( {}} />); 652 | 653 | renderedComponent 654 | .find(Link) 655 | .first() 656 | .simulate('mouseover', mockEvt); 657 | 658 | expect(persistSpy).toHaveBeenCalledTimes(1); 659 | }); 660 | }); 661 | 662 | describe('onLinkMouseOut', () => { 663 | it('calls the onLinkMouseOut callback when a node is hovered over', () => { 664 | const onLinkMouseOutSpy = jest.fn(); 665 | const renderedComponent = mount( 666 | 667 | ); 668 | 669 | renderedComponent 670 | .find(Link) 671 | .first() 672 | .simulate('mouseout'); 673 | 674 | expect(onLinkMouseOutSpy).toHaveBeenCalledTimes(1); 675 | }); 676 | 677 | it('does not call the onLinkMouseOut callback if it is not a function', () => { 678 | const onLinkMouseOutSpy = jest.fn(); 679 | const renderedComponent = mount(); 680 | 681 | renderedComponent 682 | .find(Link) 683 | .first() 684 | .simulate('mouseout'); 685 | 686 | expect(onLinkMouseOutSpy).toHaveBeenCalledTimes(0); 687 | }); 688 | 689 | it("clones the hovered node's data & passes it to the onNodeMouseOut callback if defined", () => { 690 | const onLinkMouseOutSpy = jest.fn(); 691 | const mockEvt = { mock: 'event' }; 692 | const renderedComponent = mount( 693 | 694 | ); 695 | 696 | renderedComponent 697 | .find(Link) 698 | .first() 699 | .simulate('mouseout', mockEvt); 700 | 701 | expect(onLinkMouseOutSpy).toHaveBeenCalledWith( 702 | renderedComponent 703 | .find(Link) 704 | .first() 705 | .prop('linkData').source, 706 | renderedComponent 707 | .find(Link) 708 | .first() 709 | .prop('linkData').target, 710 | expect.objectContaining(mockEvt) 711 | ); 712 | }); 713 | 714 | it('persists the SynthethicEvent for downstream processing if handler is defined', () => { 715 | const persistSpy = jest.fn(); 716 | const mockEvt = { mock: 'event', persist: persistSpy }; 717 | const renderedComponent = mount( {}} />); 718 | 719 | renderedComponent 720 | .find(Link) 721 | .first() 722 | .simulate('mouseout', mockEvt); 723 | 724 | expect(persistSpy).toHaveBeenCalledTimes(1); 725 | }); 726 | }); 727 | 728 | describe('onUpdate', () => { 729 | it('calls `onUpdate` on node toggle', () => { 730 | const onUpdateSpy = jest.fn(); 731 | 732 | const renderedComponent = mount(); 733 | renderedComponent 734 | .find(Node) 735 | .first() 736 | .simulate('click'); // collapse 737 | 738 | expect(onUpdateSpy).toHaveBeenCalledWith({ 739 | node: expect.any(Object), 740 | zoom: 1, 741 | translate: { x: 0, y: 0 }, 742 | }); 743 | }); 744 | 745 | // FIXME: cannot read property 'baseVal' of undefined. 746 | it.skip('calls `onUpdate` on zoom', () => { 747 | const onUpdateSpy = jest.fn(); 748 | 749 | document.body.innerHTML += '
'; 750 | render( 751 | , 757 | document.querySelector('#reactContainer') 758 | ); 759 | const scrollableComponent = document.querySelector('.rd3t-tree-container > svg'); 760 | scrollableComponent.dispatchEvent(new Event('wheel')); 761 | expect(onUpdateSpy).toHaveBeenCalledTimes(1); 762 | expect(onUpdateSpy).toHaveBeenCalledWith({ 763 | node: null, 764 | translate: { x: expect.any(Number), y: expect.any(Number) }, 765 | zoom: expect.any(Number), 766 | }); 767 | }); 768 | 769 | it.skip('does not call `onUpdate` if not a function', () => { 770 | const onUpdateSpy = jest.fn(); 771 | 772 | document.body.innerHTML += '
'; 773 | render( 774 | , 775 | document.querySelector('#reactContainer') 776 | ); 777 | const scrollableComponent = document.querySelector('.rd3t-tree-container > svg'); 778 | scrollableComponent.dispatchEvent(new Event('wheel')); 779 | expect(onUpdateSpy).toHaveBeenCalledTimes(0); 780 | }); 781 | 782 | it('passes the specified (not default) `zoom` and `translate` when a node is clicked for the 1st time', () => { 783 | const onUpdateSpy = jest.fn(); 784 | const zoom = 0.7; 785 | const translate = { x: 10, y: 5 }; 786 | 787 | const renderedComponent = mount( 788 | 789 | ); 790 | renderedComponent 791 | .find(Node) 792 | .first() 793 | .simulate('click'); 794 | 795 | expect(onUpdateSpy).toHaveBeenCalledWith({ 796 | node: expect.any(Object), 797 | translate, 798 | zoom, 799 | }); 800 | }); 801 | }); 802 | }); 803 | }); 804 | -------------------------------------------------------------------------------- /src/Tree/tests/mockData.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | // The naming of the mock trees describes their shape. 4 | // E.g. `mockTree_D1N2_D2N2` -> _Depth1with2Nodes_Depth2with2Nodes_... 5 | 6 | export const mockTree_D1N2_D2N2 = [ 7 | { 8 | name: 'Top Level', 9 | attributes: { 10 | keyA: 'val A', 11 | keyB: 'val B', 12 | keyC: 'val C', 13 | }, 14 | children: [ 15 | { 16 | name: 'Level 2: A', 17 | attributes: { 18 | keyA: 'val A', 19 | keyB: 'val B', 20 | keyC: 'val C', 21 | }, 22 | children: [ 23 | { 24 | name: '3: Son of A', 25 | attributes: { 26 | keyA: 'val A', 27 | keyB: 'val B', 28 | keyC: 'val C', 29 | }, 30 | }, 31 | { 32 | name: '3: Daughter of A', 33 | attributes: { 34 | keyA: 'val A', 35 | keyB: 'val B', 36 | keyC: 'val C', 37 | }, 38 | }, 39 | ], 40 | }, 41 | { 42 | name: 'Level 2: B', 43 | }, 44 | ], 45 | }, 46 | ]; 47 | 48 | const mockData = [ 49 | { 50 | name: 'Top Level', 51 | attributes: { 52 | keyA: 'val A', 53 | keyB: 'val B', 54 | keyC: 'val C', 55 | }, 56 | children: [ 57 | { 58 | name: '2: A', 59 | attributes: { 60 | keyA: 'val A', 61 | keyB: 'val B', 62 | keyC: 'val C', 63 | }, 64 | children: [ 65 | { 66 | name: '3: Son of A', 67 | attributes: { 68 | keyA: 'val A', 69 | keyB: 'val B', 70 | keyC: 'val C', 71 | }, 72 | }, 73 | { 74 | name: '3: Daughter of A', 75 | attributes: { 76 | keyA: 'val A', 77 | keyB: 'val B', 78 | keyC: 'val C', 79 | }, 80 | }, 81 | ], 82 | }, 83 | { 84 | name: '2: B', 85 | }, 86 | ], 87 | }, 88 | ]; 89 | 90 | const mockData2 = [ 91 | { 92 | name: 'Top Level', 93 | parent: 'null', 94 | attributes: { 95 | keyA: 'val A', 96 | keyB: 'val B', 97 | keyC: 'val C', 98 | }, 99 | children: [ 100 | { 101 | name: 'Level 2: A', 102 | parent: 'Top Level', 103 | attributes: { 104 | keyA: 'val A', 105 | keyB: 'val B', 106 | keyC: 'val C', 107 | }, 108 | }, 109 | ], 110 | }, 111 | ]; 112 | 113 | const mockData4 = [ 114 | { 115 | name: 'Top Level', 116 | parent: 'null', 117 | attributes: { 118 | keyA: 'val A', 119 | keyB: 'val B', 120 | keyC: 'val C', 121 | }, 122 | children: [ 123 | { 124 | name: 'Level 2: A', 125 | parent: 'Top Level', 126 | attributes: { 127 | keyA: 'val A', 128 | keyB: 'val B', 129 | keyC: 'val C', 130 | }, 131 | children: [ 132 | { 133 | name: 'Level 3: A', 134 | parent: 'Level 2: B', 135 | }, 136 | { 137 | name: 'Level 3: B', 138 | parent: 'Level 2: A', 139 | }, 140 | ], 141 | }, 142 | { 143 | name: 'Level 2: B', 144 | parent: 'Level 2: A', 145 | children: [ 146 | { 147 | name: 'Level 3: B', 148 | parent: 'Level 2: B', 149 | }, 150 | ], 151 | }, 152 | ], 153 | }, 154 | ]; 155 | 156 | export { mockData, mockData2, mockData4 }; 157 | -------------------------------------------------------------------------------- /src/Tree/types.ts: -------------------------------------------------------------------------------- 1 | import { HierarchyPointNode } from 'd3-hierarchy'; 2 | import { SyntheticEvent } from 'react'; 3 | import { 4 | Orientation, 5 | PathClassFunction, 6 | PathFunction, 7 | PathFunctionOption, 8 | Point, 9 | RawNodeDatum, 10 | RenderCustomNodeElementFn, 11 | TreeNodeDatum, 12 | } from '../types/common.js'; 13 | 14 | export type TreeNodeEventCallback = ( 15 | node: HierarchyPointNode, 16 | event: SyntheticEvent 17 | ) => any; 18 | 19 | export type TreeLinkEventCallback = ( 20 | sourceNode: HierarchyPointNode, 21 | targetNode: HierarchyPointNode, 22 | event: SyntheticEvent 23 | ) => any; 24 | 25 | /** 26 | * Props accepted by the `Tree` component. 27 | * 28 | * {@link Tree.defaultProps | Default Props} 29 | */ 30 | export interface TreeProps { 31 | /** 32 | * The root node object, in which child nodes (also of type `RawNodeDatum`) 33 | * are recursively defined in the `children` key. 34 | * 35 | * `react-d3-tree` will automatically attach a unique `id` attribute to each node in the DOM, 36 | * as well as `data-source-id` & `data-target-id` attributes to each link connecting two nodes. 37 | */ 38 | data: RawNodeDatum[] | RawNodeDatum; 39 | 40 | /** 41 | * Custom render function that will be used for every node in the tree. 42 | * 43 | * The function is passed `CustomNodeElementProps` as its first argument. 44 | * `react-d3-tree` expects the function to return a `ReactElement`. 45 | * 46 | * See the `RenderCustomNodeElementFn` type for more details. 47 | * 48 | * {@link Tree.defaultProps.renderCustomNodeElement | Default value} 49 | */ 50 | renderCustomNodeElement?: RenderCustomNodeElementFn; 51 | 52 | /** 53 | * Called when a node is clicked. 54 | * 55 | * {@link Tree.defaultProps.onNodeClick | Default value} 56 | */ 57 | onNodeClick?: TreeNodeEventCallback; 58 | 59 | /** 60 | * Called when mouse enters the space belonging to a node. 61 | * 62 | * {@link Tree.defaultProps.onNodeMouseOver | Default value} 63 | */ 64 | onNodeMouseOver?: TreeNodeEventCallback; 65 | 66 | /** 67 | * Called when mouse leaves the space belonging to a node. 68 | * 69 | * {@link Tree.defaultProps.onNodeMouseOut | Default value} 70 | */ 71 | onNodeMouseOut?: TreeNodeEventCallback; 72 | 73 | /** 74 | * Called when a link is clicked. 75 | * 76 | * {@link Tree.defaultProps.onLinkClick | Default value} 77 | */ 78 | onLinkClick?: TreeLinkEventCallback; 79 | 80 | /** 81 | * Called when mouse enters the space belonging to a link. 82 | * 83 | * {@link Tree.defaultProps.onLinkMouseOver | Default value} 84 | */ 85 | onLinkMouseOver?: TreeLinkEventCallback; 86 | 87 | /** 88 | * Called when mouse leaves the space belonging to a link. 89 | * 90 | * {@link Tree.defaultProps.onLinkMouseOut | Default value} 91 | */ 92 | onLinkMouseOut?: TreeLinkEventCallback; 93 | 94 | /** 95 | * Called when the inner D3 component updates. That is - on every zoom or translate event, 96 | * or when tree branches are toggled. 97 | * 98 | * {@link Tree.defaultProps.onUpdate | Default value} 99 | */ 100 | onUpdate?: (target: { node: TreeNodeDatum | null; zoom: number; translate: Point }) => any; 101 | 102 | /** 103 | * Determines along which axis the tree is oriented. 104 | * 105 | * `horizontal` - Tree expands along x-axis (left-to-right). 106 | * 107 | * `vertical` - Tree expands along y-axis (top-to-bottom). 108 | * 109 | * Additionally, passing a negative value to {@link TreeProps.depthFactor | depthFactor} will 110 | * invert the tree's direction (i.e. right-to-left, bottom-to-top). 111 | * 112 | * {@link Tree.defaultProps.orientation | Default value} 113 | */ 114 | orientation?: Orientation; 115 | 116 | /** 117 | * Translates the graph along the x/y axis by the specified amount of pixels. 118 | * 119 | * By default, the graph will render in the top-left corner of the SVG canvas. 120 | * 121 | * {@link Tree.defaultProps.translate | Default value} 122 | */ 123 | translate?: Point; 124 | 125 | /** 126 | * Enables the centering of nodes on click by providing the dimensions of the tree container, 127 | * e.g. via {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect | `getBoundingClientRect()`}. 128 | * 129 | * If dimensions are given: node will center on click. If not, node will not center on click. 130 | */ 131 | dimensions?: { 132 | width: number; 133 | height: number; 134 | }; 135 | 136 | /** 137 | * Sets the time (in milliseconds) for the transition to center a node once clicked. 138 | * 139 | * {@link Tree.defaultProps.centeringTransitionDuration | Default value} 140 | */ 141 | centeringTransitionDuration?: number; 142 | 143 | /** 144 | * The draw function (or `d`) used to render `path`/`link` elements. Accepts a predefined 145 | * `PathFunctionOption` or a user-defined `PathFunction`. 146 | * 147 | * See the `PathFunction` type for more information. 148 | * 149 | * For details on draw functions, see: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d 150 | * 151 | * {@link Tree.defaultProps.pathFunc | Default value} 152 | */ 153 | pathFunc?: PathFunctionOption | PathFunction; 154 | 155 | /** 156 | * Allows for additional className(s) to be passed to links. 157 | * 158 | * Each link calls `pathClassFunc` with its own `TreeLinkDatum` and the tree's current `orientation`. 159 | * Expects a `className` string to be returned. 160 | * 161 | * See the `PathClassFunction` type for more information. 162 | * 163 | * {@link Tree.defaultProps.pathClassFunc | Default value} 164 | */ 165 | pathClassFunc?: PathClassFunction; 166 | 167 | /** 168 | * Determines the spacing between parent & child nodes. 169 | * 170 | * **Tip: Negative values invert the tree's direction.** 171 | * 172 | * `node.y = node.depth * depthFactor` 173 | * 174 | * Example: `depthFactor: 0` renders all nodes on the same height (since node.y === 0 for all). 175 | * 176 | * {@link Tree.defaultProps.depthFactor | Default value} 177 | */ 178 | depthFactor?: number; 179 | 180 | /** 181 | * Determines whether the tree's nodes can collapse/expand. 182 | * 183 | * {@link Tree.defaultProps.collapsible | Default value} 184 | */ 185 | collapsible?: boolean; 186 | 187 | /** 188 | * Sets the maximum node depth to which the tree is expanded on its initial render. 189 | * 190 | * By default, the tree renders to full depth. 191 | * 192 | * {@link Tree.defaultProps.initialDepth | Default value} 193 | */ 194 | initialDepth?: number; 195 | 196 | /** 197 | * Toggles ability to zoom in/out on the Tree by scaling it according to `scaleExtent`. 198 | * 199 | * {@link Tree.defaultProps.zoomable | Default value} 200 | */ 201 | zoomable?: boolean; 202 | 203 | /** 204 | * Toggles ability to drag the Tree. 205 | * 206 | * {@link Tree.defaultProps.draggable | Default value} 207 | */ 208 | draggable?: boolean; 209 | 210 | /** 211 | * A floating point number to set the initial zoom level. It is constrained by `scaleExtent`. 212 | * 213 | * {@link Tree.defaultProps.zoom | Default value} 214 | */ 215 | zoom?: number; 216 | 217 | /** 218 | * Sets the minimum/maximum extent to which the tree can be scaled if `zoomable` is true. 219 | * 220 | * {@link Tree.defaultProps.scaleExtent | Default value} 221 | */ 222 | scaleExtent?: { 223 | min?: number; 224 | max?: number; 225 | }; 226 | 227 | /** 228 | * The amount of space each node element occupies. 229 | * 230 | * {@link Tree.defaultProps.nodeSize | Default value} 231 | */ 232 | nodeSize?: { 233 | x: number; 234 | y: number; 235 | }; 236 | 237 | /** 238 | * Sets separation between neighboring nodes, differentiating between siblings (same parent node) 239 | * and non-siblings. 240 | * 241 | * {@link Tree.defaultProps.separation | Default value} 242 | */ 243 | separation?: { 244 | siblings?: number; 245 | nonSiblings?: number; 246 | }; 247 | 248 | /** 249 | * If a node is currently being expanded, all other nodes at the same depth will be collapsed. 250 | * 251 | * {@link Tree.defaultProps.shouldCollapseNeighborNodes | Default value} 252 | */ 253 | shouldCollapseNeighborNodes?: boolean; 254 | 255 | /** 256 | * Allows for additional className(s) to be passed to the `svg` element wrapping the tree. 257 | * 258 | * {@link Tree.defaultProps.svgClassName | Default value} 259 | */ 260 | svgClassName?: string; 261 | 262 | /** 263 | * Allows for additional className(s) to be passed to the root node. 264 | * 265 | * {@link Tree.defaultProps.rootNodeClassName | Default value} 266 | */ 267 | rootNodeClassName?: string; 268 | 269 | /** 270 | * Allows for additional className(s) to be passed to all branch nodes (nodes with children). 271 | * 272 | * {@link Tree.defaultProps.branchNodeClassName | Default value} 273 | */ 274 | branchNodeClassName?: string; 275 | 276 | /** 277 | * Allows for additional className(s) to be passed to all leaf nodes (nodes without children). 278 | * 279 | * {@link Tree.defaultProps.leafNodeClassName | Default value} 280 | */ 281 | leafNodeClassName?: string; 282 | 283 | /** 284 | * Enables/disables legacy transitions using `react-transition-group`. 285 | * 286 | * **Note:** This flag is considered legacy and **usage is discouraged for large trees**, 287 | * as responsiveness may suffer. 288 | * 289 | * `enableLegacyTransitions` will be deprecated once a suitable 290 | * replacement for transitions has been found. 291 | * 292 | * {@link Tree.defaultProps.enableLegacyTransitions | Default value} 293 | */ 294 | enableLegacyTransitions?: boolean; 295 | 296 | /** 297 | * Sets the animation duration (in milliseconds) of each expansion/collapse of a tree node. 298 | * Requires `enableLegacyTransition` to be `true`. 299 | * 300 | * {@link Tree.defaultProps.transitionDuration | Default value} 301 | */ 302 | transitionDuration?: number; 303 | 304 | /** 305 | * Disables drag/pan/zoom D3 events when hovering over a node. 306 | * Useful for cases where D3 events interfere when interacting with inputs or other interactive elements on a node. 307 | * 308 | * **Tip:** Holding the `Shift` key while hovering over a node re-enables the D3 events. 309 | * 310 | * {@link Tree.defaultProps.hasInteractiveNodes | Default value} 311 | */ 312 | hasInteractiveNodes?: boolean; 313 | 314 | /** 315 | * Indicates the tree being represented by the data. If the dataKey changes, then we should re-render the tree. 316 | * If the data changes but the dataKey keeps being the same, then it's a change (like adding children to a node) for the same tree, 317 | * so we shouldn't re-render the tree. 318 | * 319 | * {@link Tree.defaultProps.dataKey | Default value} 320 | */ 321 | dataKey?: string; 322 | } 323 | -------------------------------------------------------------------------------- /src/globalCss.ts: -------------------------------------------------------------------------------- 1 | // Importing CSS files globally (e.g. `import "./styles.css"`) can cause resolution issues with certain 2 | // libraries/frameworks. 3 | // Example: Next.js (https://github.com/vercel/next.js/blob/master/errors/css-npm.md) 4 | // 5 | // Since rd3t's CSS is bare bones to begin with, we provide all required styles as a template string, 6 | // which can be imported like any other TS/JS module and inlined into a `` tag. 7 | 8 | export default ` 9 | /* Tree */ 10 | .rd3t-tree-container { 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | .rd3t-grabbable { 16 | cursor: move; /* fallback if grab cursor is unsupported */ 17 | cursor: grab; 18 | cursor: -moz-grab; 19 | cursor: -webkit-grab; 20 | } 21 | .rd3t-grabbable:active { 22 | cursor: grabbing; 23 | cursor: -moz-grabbing; 24 | cursor: -webkit-grabbing; 25 | } 26 | 27 | /* Node */ 28 | .rd3t-node { 29 | cursor: pointer; 30 | fill: #777; 31 | stroke: #000; 32 | stroke-width: 2; 33 | } 34 | 35 | .rd3t-leaf-node { 36 | cursor: pointer; 37 | fill: transparent; 38 | stroke: #000; 39 | stroke-width: 1; 40 | } 41 | 42 | .rd3t-label__title { 43 | fill: #000; 44 | stroke: none; 45 | font-weight: bolder; 46 | } 47 | 48 | .rd3t-label__attributes { 49 | fill: #777; 50 | stroke: none; 51 | font-weight: bolder; 52 | font-size: smaller; 53 | } 54 | 55 | /* Link */ 56 | .rd3t-link { 57 | fill: none; 58 | stroke: #000; 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Tree from './Tree/index.js'; 2 | 3 | export * from './Tree/types.js'; 4 | export * from './types/common.js'; 5 | 6 | export { Tree }; 7 | export default Tree; 8 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent } from 'react'; 2 | import { HierarchyPointNode } from 'd3-hierarchy'; 3 | 4 | export type Orientation = 'horizontal' | 'vertical'; 5 | 6 | export interface Point { 7 | x: number; 8 | y: number; 9 | } 10 | 11 | export interface RawNodeDatum { 12 | name: string; 13 | attributes?: Record; 14 | children?: RawNodeDatum[]; 15 | } 16 | 17 | export interface TreeNodeDatum extends RawNodeDatum { 18 | children?: TreeNodeDatum[]; 19 | __rd3t: { 20 | id: string; 21 | depth: number; 22 | collapsed: boolean; 23 | }; 24 | } 25 | 26 | export interface TreeLinkDatum { 27 | source: HierarchyPointNode; 28 | target: HierarchyPointNode; 29 | } 30 | 31 | export type PathFunctionOption = 'diagonal' | 'elbow' | 'straight' | 'step'; 32 | export type PathFunction = (link: TreeLinkDatum, orientation: Orientation) => string; 33 | export type PathClassFunction = PathFunction; 34 | 35 | export type SyntheticEventHandler = (evt: SyntheticEvent) => void; 36 | export type AddChildrenFunction = (children: RawNodeDatum[]) => void; 37 | 38 | /** 39 | * The properties that are passed to the user-defined `renderCustomNodeElement` render function. 40 | */ 41 | export interface CustomNodeElementProps { 42 | /** 43 | * The full datum of the node that is being rendered. 44 | */ 45 | nodeDatum: TreeNodeDatum; 46 | /** 47 | * The D3 `HierarchyPointNode` representation of the node, which wraps `nodeDatum` 48 | * with additional properties. 49 | */ 50 | hierarchyPointNode: HierarchyPointNode; 51 | /** 52 | * Toggles the expanded/collapsed state of the node. 53 | * 54 | * Provided for customized control flow; e.g. if we want to toggle the node when its 55 | * label is clicked instead of the node itself. 56 | */ 57 | toggleNode: () => void; 58 | /** 59 | * The `onNodeClick` handler defined for `Tree` (if any). 60 | */ 61 | onNodeClick: SyntheticEventHandler; 62 | /** 63 | * The `onNodeMouseOver` handler defined for `Tree` (if any). 64 | */ 65 | onNodeMouseOver: SyntheticEventHandler; 66 | /** 67 | * The `onNodeMouseOut` handler defined for `Tree` (if any). 68 | */ 69 | onNodeMouseOut: SyntheticEventHandler; 70 | /** 71 | * The `Node` class's internal `addChildren` handler. 72 | */ 73 | addChildren: AddChildrenFunction; 74 | } 75 | 76 | export type RenderCustomNodeElementFn = (rd3tNodeProps: CustomNodeElementProps) => JSX.Element; 77 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "Node", 5 | "target": "es6", 6 | "allowJs": true, 7 | "jsx": "react", 8 | "outDir": "./lib/esm", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "*.js": ["*"] 14 | } 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "**/tests/*", "**/*.spec.ts", "**/*.test.ts", "**/*.test.js"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "allowJs": true, 5 | "target": "es5", 6 | "jsx": "react", 7 | "outDir": "./lib/cjs", 8 | "declaration": true, 9 | "declarationDir": "./lib/types", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "*.js": ["*"] 15 | } 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "**/tests/*", "**/*.spec.ts", "**/*.test.ts", "**/*.test.js"] 19 | } 20 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src"], 3 | "exclude": ["**/tests/*", "**/*.test.js"], 4 | "out": "demo/public/docs", 5 | "includeVersion": true 6 | } 7 | --------------------------------------------------------------------------------