├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
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 |
36 |
37 |
40 |
43 |
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 |
294 |
Examples
295 |
296 | this.setTreeData(orgChartJson)}
300 | >
301 | Org chart (small)
302 |
303 |
304 |
305 | this.setTreeData(flareJson)}
309 | >
310 | d3-hierarchy - flare.json (medium)
311 |
312 | this.setTreeData(reactTree)}
316 | >
317 | React repository (large)
318 |
319 |
320 |
321 |
322 | {/*
323 |
324 | Dynamically updating data
325 |
326 | this.addChildNode()}
330 | >
331 | Insert Node
332 |
333 | this.removeChildNode()}
337 | >
338 | Remove Node
339 |
340 | */}
341 |
342 |
343 |
Orientation
344 | this.setOrientation('horizontal')}
348 | >
349 | {'Horizontal'}
350 |
351 | this.setOrientation('vertical')}
355 | >
356 | {'Vertical'}
357 |
358 |
359 |
360 |
361 |
Path Function
362 | this.setPathFunc('diagonal')}
366 | >
367 | {'Diagonal'}
368 |
369 | this.setPathFunc('elbow')}
373 | >
374 | {'Elbow'}
375 |
376 | this.setPathFunc('straight')}
380 | >
381 | {'Straight'}
382 |
383 | this.setPathFunc('step')}
387 | >
388 | {'Step'}
389 |
390 |
391 |
392 |
393 |
394 | Custom Node Element
395 |
396 |
397 | {Object.entries(customNodeFnMapping).map(([key, { description }]) => (
398 |
399 | {description}
400 |
401 | ))}
402 |
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 |
491 |
492 |
493 |
494 | Initial Depth
495 |
496 |
504 |
505 |
506 |
507 |
508 | Depth Factor
509 |
510 |
517 |
518 |
519 | {/*
{`Zoomable: ${this.state.zoomable}`}
*/}
520 |
521 |
522 |
523 | Zoom
524 |
525 |
532 |
533 |
534 |
535 | Scale Extent
536 |
537 | Min
538 |
539 |
545 | this.setScaleExtent({
546 | min: parseFloat(evt.target.value),
547 | max: this.state.scaleExtent.max,
548 | })
549 | }
550 | />
551 |
552 | Max
553 |
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 |
571 | Siblings
572 |
573 |
579 | this.setSeparation({
580 | siblings: parseFloat(evt.target.value),
581 | nonSiblings: this.state.separation.nonSiblings,
582 | })
583 | }
584 | />
585 |
586 | Non-Siblings
587 |
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 |
605 | X
606 |
607 |
613 | this.setNodeSize({ x: parseFloat(evt.target.value), y: this.state.nodeSize.y })
614 | }
615 | />
616 |
617 | Y
618 |
619 |
625 | this.setNodeSize({ x: this.state.nodeSize.x, y: parseFloat(evt.target.value) })
626 | }
627 | />
628 |
629 |
630 |
631 |
632 | Transition Duration
633 |
634 |
641 |
642 |
643 |
644 | Centering Transition Duration
645 |
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 |
29 | {nodeData.__rd3t.collapsed ? '⬅️ ➡️ Expand' : '➡️ ⬅️ Collapse'}
30 |
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 |
30 | Option: 1
31 | Option: 2
32 | Option: 3
33 |
34 | {nodeData.children && (
35 |
36 | {nodeData.__rd3t.collapsed ? '⬅️ ➡️ Expand' : '➡️ ⬅️ Collapse'}
37 |
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 |
20 |
21 |
22 |
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 |
--------------------------------------------------------------------------------