├── .babelrc
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .travis.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── examples
├── react-tree
│ ├── components
│ │ ├── App.js
│ │ └── TreeChart.js
│ ├── index.html
│ ├── index.js
│ ├── package.json
│ ├── server.js
│ └── webpack.config.js
└── tree
│ ├── index.html
│ ├── index.js
│ ├── package.json
│ ├── server.js
│ └── webpack.config.js
├── package.json
├── src
├── charts
│ ├── index.js
│ └── tree
│ │ ├── sortAndSerialize.js
│ │ ├── tree.js
│ │ └── utils.js
└── index.js
├── webpack.config.base.js
├── webpack.config.development.js
└── webpack.config.production.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015-loose", "stage-0", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib
2 | **/node_modules
3 | **/webpack.config.js
4 | examples/**/server.js
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-airbnb",
3 | "env": {
4 | "browser": true,
5 | "node": true
6 | },
7 | "rules": {
8 | "react/jsx-uses-react": 2,
9 | "react/jsx-uses-vars": 2,
10 | "react/react-in-jsx-scope": 2,
11 | "no-unused-vars": 1,
12 | "no-nested-ternary": 1,
13 | "semi": 0,
14 | "block-scoped-var": 0,
15 | "padded-blocks": 0
16 | },
17 | "plugins": [
18 | "react"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | .DS_Store
4 | dist
5 | lib
6 | coverage
7 | .idea
8 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 | test
4 | examples
5 | coverage
6 | .idea
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "iojs"
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change log
2 |
3 | All notable changes to this project will be documented in this file.
4 | This project adheres to [Semantic Versioning](http://semver.org/).
5 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4 |
5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
6 |
7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8 |
9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10 |
11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12 |
13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
14 |
15 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Romain Séguy
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | d3-state-visualizer
2 | ===================
3 |
4 | **This package was merged into the [`redux-devtools`](https://github.com/reduxjs/redux-devtools) monorepo. Please refer to that repository for the latest updates, issues and pull requests.**
5 |
6 | Enables real-time visualization of your application state.
7 |
8 | [Demo](http://reduxjs.github.io/d3-state-visualizer)
9 |
10 | ## Installation
11 |
12 | `npm install d3-state-visualizer`
13 |
14 | ## Usage
15 |
16 | ```javascript
17 | import { tree } from 'd3-state-visualizer';
18 |
19 | const appState = {
20 | todoStore: {
21 | todos: [
22 | { title: 'd3'},
23 | { title: 'state' },
24 | { title: 'visualizer' },
25 | { title: 'tree' }
26 | ],
27 | completedCount: 1
28 | }
29 | };
30 |
31 | const render = tree(document.getElementById('root'), {
32 | state: appState,
33 | id: 'treeExample',
34 | size: 1000,
35 | aspectRatio: 0.5,
36 | isSorted: false,
37 | widthBetweenNodesCoeff: 1.5,
38 | heightBetweenNodesCoeff: 2,
39 | style: {border: '1px solid black'},
40 | tooltipOptions: {offset: {left: 30, top: 10}, indentationSize: 2}
41 | });
42 |
43 | render();
44 | ```
45 | ## Charts API
46 |
47 | The APIs are minimal and consists of a single function you provide with:
48 | - a DOM element
49 | - a plain old JS object for options.
50 |
51 | #### Tree
52 |
53 | This chart is a bit special as it accepts either one of the two following options, but **not both**:
54 |
55 | - `tree`: a properly formed tree structure such as one generated by [map2tree](https://github.com/romseguy/map2tree) or [react2tree](https://github.com/romseguy/react2tree)
56 | - `state`: a plain javascript object mapping arbitrarily nested keys to values – which will be transformed into a tree structure, again using [map2tree](https://github.com/romseguy/map2tree).
57 |
58 | Other options are listed below and have reasonable default values if you want to omit them:
59 |
60 | Option | Type | Default | Description
61 | --------------------------|----------|-------------|-------------------------------------------------------------------------
62 | `id` | String | `'d3svg'` | Sets the identifier of the SVG element —i.e your chart— that will be added to the DOM element you passed as first argument
63 | `style` | Object | `{}` | Sets the CSS style of the chart
64 | `size` | Number | `500` | Sets size of the chart in pixels
65 | `aspectRatio` | Float | `1.0` | Sets the chart height to `size * aspectRatio` and [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) in order to preserve the aspect ratio of the chart. [Great video](https://www.youtube.com/watch?v=FCOeMy7HrBc) if you want to learn more about how SVG works
66 | `widthBetweenNodesCoeff` | Float | `1.0` | Alters the horizontal space between each node
67 | `heightBetweenNodesCoeff` | Float | `1.0` | Alters the vertical space between each node
68 | `isSorted` | Boolean | `false` | Sorts the chart in alphabetical order
69 | `transitionDuration` | Number | `750` | Sets the duration of all the transitions used by the chart
70 | `tooltipOptions` | Object | [here](https://github.com/romseguy/d3tooltip) | Sets the options for the [tooltip](https://github.com/romseguy/d3tooltip) that is showing up when you're hovering the nodes
71 | `rootKeyName` | String | `'state'` | Sets the first node's name of the resulting tree structure. **Warning**: only works if you provide a `state` option
72 | `pushMethod` | String | `'push'` | Sets the method that shall be used to add array children to the tree. **Warning**: only works if you provide a `state` option
73 |
74 | More to come...
75 |
76 | ## Bindings
77 |
78 | ### Redux Dev tools
79 |
80 | See this [repository](https://github.com/romseguy/redux-devtools-chart-monitor).
81 |
82 | ### React
83 |
84 | [example](https://github.com/romseguy/d3-state-visualizer/tree/master/examples/react-tree) implementation.
85 |
86 | ## Roadmap
87 |
88 | * Threshold for large arrays so only a single node is displayed instead of all the children. That single node would be exclude from searching until selected.
89 |
--------------------------------------------------------------------------------
/examples/react-tree/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class Box1 extends React.Component {
4 | render() {
5 | return box1;
6 | }
7 | }
8 | class Box2 extends React.Component {
9 | render() {
10 | return box2;
11 | }
12 | }
13 | class Parent extends React.Component {
14 | render() {
15 | return (
16 |
17 |
18 |
19 |
20 | );
21 | }
22 | }
23 | class App extends React.Component {
24 | render() {
25 | return ;
26 | }
27 | }
28 |
29 | export default App;
30 |
--------------------------------------------------------------------------------
/examples/react-tree/components/TreeChart.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { findDOMNode } from 'react-dom';
3 | import { tree } from 'd3-state-visualizer/charts';
4 |
5 | class TreeChart extends Component {
6 | static propTypes = {
7 | state: PropTypes.object,
8 | rootKeyName: PropTypes.string,
9 | pushMethod: PropTypes.string,
10 | tree: PropTypes.shape({
11 | name: PropTypes.string,
12 | children: PropTypes.array
13 | }),
14 | id: PropTypes.string,
15 | style: PropTypes.shape({
16 | node: PropTypes.shape({
17 | colors: PropTypes.shape({
18 | 'default': PropTypes.string,
19 | parent: PropTypes.string,
20 | collapsed: PropTypes.string
21 | }),
22 | radius: PropTypes.number
23 | }),
24 | text: PropTypes.shape({
25 | colors: PropTypes.shape({
26 | 'default': PropTypes.string,
27 | hover: PropTypes.string
28 | })
29 | }),
30 | link: PropTypes.object
31 | }),
32 | size: PropTypes.number,
33 | aspectRatio: PropTypes.number,
34 | margin: PropTypes.shape({
35 | top: PropTypes.number,
36 | right: PropTypes.number,
37 | bottom: PropTypes.number,
38 | left: PropTypes.number
39 | }),
40 | isSorted: PropTypes.bool,
41 | heightBetweenNodesCoeff: PropTypes.number,
42 | widthBetweenNodesCoeff: PropTypes.number,
43 | transitionDuration: PropTypes.number,
44 | tooltipOptions: PropTypes.shape({
45 | disabled: PropTypes.bool,
46 | left: PropTypes.number,
47 | top: PropTypes.number,
48 | offset: PropTypes.shape({
49 | left: PropTypes.number,
50 | top: PropTypes.number
51 | }),
52 | indentationSize: PropTypes.number,
53 | style: PropTypes.object
54 | })
55 | };
56 |
57 | constructor(props, context) {
58 | super(props, context);
59 | }
60 |
61 | componentDidMount() {
62 | this.renderChart = tree(findDOMNode(this), this.props);
63 | this.renderChart();
64 | }
65 |
66 | componentWillReceiveProps(nextProps) {
67 | this.renderChart(nextProps.tree || nextProps.state);
68 | }
69 |
70 | render() {
71 | return ;
72 | }
73 | }
74 |
75 | export default TreeChart;
76 |
--------------------------------------------------------------------------------
/examples/react-tree/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | State graph with d3-state-visualizer and React
4 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/examples/react-tree/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import react2tree from 'react2tree';
4 | import App from './components/App';
5 | import TreeChart from './components/TreeChart';
6 |
7 | const hierarchy = react2tree(render(, document.createElement('hierarchy')));
8 |
9 | render(
10 |
11 |
22 |
23 | , document.getElementById('root')
24 | );
25 |
--------------------------------------------------------------------------------
/examples/react-tree/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "d3-state-visualizer-react-tree-example",
3 | "version": "0.0.0",
4 | "description": "Visualize your React component hierarchy as a tree",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "node server.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/romseguy/d3-state-visualizer.git"
12 | },
13 | "keywords": [
14 | "d3",
15 | "state",
16 | "store",
17 | "visualization"
18 | ],
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/romseguy/d3-state-visualizer/issues"
22 | },
23 | "homepage": "https://github.com/romseguy/d3-state-visualizer",
24 | "dependencies": {
25 | "d3-state-visualizer": "^1.0.1",
26 | "react": "^0.14.0",
27 | "react-dom": "^0.14.0",
28 | "react2tree": "^1.2.0"
29 | },
30 | "devDependencies": {
31 | "babel-core": "^6.1.20",
32 | "babel-loader": "^6.2.0",
33 | "node-libs-browser": "^0.5.2",
34 | "react-hot-loader": "^1.2.7",
35 | "webpack": "^1.9.11",
36 | "webpack-dev-server": "^1.9.0"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/examples/react-tree/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebpackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.config');
4 |
5 | new WebpackDevServer(webpack(config), {
6 | publicPath: config.output.publicPath,
7 | hot: true,
8 | historyApiFallback: true,
9 | stats: {
10 | colors: true
11 | }
12 | }).listen(3000, 'localhost', function (err) {
13 | if (err) {
14 | console.log(err);
15 | }
16 |
17 | console.log('Listening at localhost:3000');
18 | });
19 |
--------------------------------------------------------------------------------
/examples/react-tree/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var webpack = require('webpack');
3 |
4 | module.exports = {
5 | devtool: 'eval',
6 | entry: [
7 | 'webpack-dev-server/client?http://localhost:3000',
8 | 'webpack/hot/only-dev-server',
9 | './index'
10 | ],
11 | output: {
12 | path: path.join(__dirname, 'dist'),
13 | filename: 'bundle.js',
14 | publicPath: '/static/'
15 | },
16 | plugins: [
17 | new webpack.HotModuleReplacementPlugin(),
18 | new webpack.NoErrorsPlugin()
19 | ],
20 | resolve: {
21 | alias: {
22 | 'd3-state-visualizer': path.join(__dirname, '..', '..', 'src')
23 | },
24 | extensions: ['', '.js']
25 | },
26 | module: {
27 | loaders: [{
28 | test: /\.js$/,
29 | loaders: ['react-hot', 'babel'],
30 | exclude: /node_modules/,
31 | include: __dirname
32 | }, {
33 | test: /\.js$/,
34 | loaders: ['babel'],
35 | include: [
36 | path.join(__dirname, '..', '..', 'src')
37 | ]
38 | }]
39 | }
40 | };
41 |
--------------------------------------------------------------------------------
/examples/tree/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | State tree with d3-state-visualizer
4 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/examples/tree/index.js:
--------------------------------------------------------------------------------
1 | import { tree } from 'd3-state-visualizer';
2 |
3 | const appState = {
4 | todoStore: {
5 | todos: [
6 | { title: 'd3'},
7 | { title: 'state' },
8 | { title: 'visualizer' },
9 | { title: 'tree' }
10 | ],
11 | completedCount: 1,
12 | alphabeticalOrder: true
13 | },
14 | someStore: {
15 | someProperty: 0,
16 | someObject: {
17 | anotherProperty: 'value',
18 | someArray: [0, 1, 2]
19 | }
20 | }
21 | };
22 |
23 | const render = tree(document.getElementById('root'), {
24 | state: appState,
25 | id: 'treeExample',
26 | size: 1000,
27 | aspectRatio: 0.5,
28 | isSorted: false,
29 | widthBetweenNodesCoeff: 1.5,
30 | heightBetweenNodesCoeff: 2,
31 | style: {border: '1px solid black'},
32 | tooltipOptions: {offset: {left: 30, top: 10}, indentationSize: 2}
33 | });
34 |
35 | render();
36 |
--------------------------------------------------------------------------------
/examples/tree/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "d3-state-visualizer-tree-example",
3 | "version": "0.0.0",
4 | "description": "Visualize your app state as a tree",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "node server.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/romseguy/d3-state-visualizer.git"
12 | },
13 | "keywords": [
14 | "d3",
15 | "state",
16 | "store",
17 | "visualization"
18 | ],
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/romseguy/d3-state-visualizer/issues"
22 | },
23 | "homepage": "https://github.com/romseguy/d3-state-visualizer",
24 | "dependencies": {
25 | "d3-state-visualizer": "^1.0.1",
26 | "map2tree": "^1.3.0"
27 | },
28 | "devDependencies": {
29 | "babel-core": "^6.1.20",
30 | "babel-loader": "^6.2.0",
31 | "node-libs-browser": "^0.5.2",
32 | "webpack": "^1.9.11",
33 | "webpack-dev-server": "^1.9.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/examples/tree/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebpackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.config');
4 |
5 | new WebpackDevServer(webpack(config), {
6 | publicPath: config.output.publicPath,
7 | hot: true,
8 | historyApiFallback: true,
9 | stats: {
10 | colors: true
11 | }
12 | }).listen(3000, 'localhost', function (err) {
13 | if (err) {
14 | console.log(err);
15 | }
16 |
17 | console.log('Listening at localhost:3000');
18 | });
19 |
--------------------------------------------------------------------------------
/examples/tree/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var webpack = require('webpack');
3 |
4 | module.exports = {
5 | devtool: 'eval',
6 | entry: [
7 | 'webpack-dev-server/client?http://localhost:3000',
8 | 'webpack/hot/only-dev-server',
9 | './index'
10 | ],
11 | output: {
12 | path: path.join(__dirname, 'dist'),
13 | filename: 'bundle.js',
14 | publicPath: '/static/'
15 | },
16 | plugins: [
17 | new webpack.HotModuleReplacementPlugin(),
18 | new webpack.NoErrorsPlugin()
19 | ],
20 | resolve: {
21 | alias: {
22 | 'd3-state-visualizer': path.join(__dirname, '..', '..', 'src')
23 | },
24 | extensions: ['', '.js']
25 | },
26 | module: {
27 | loaders: [{
28 | test: /\.js$/,
29 | loaders: ['babel'],
30 | exclude: /node_modules/,
31 | include: __dirname
32 | }, {
33 | test: /\.js$/,
34 | loaders: ['babel'],
35 | include: [
36 | path.join(__dirname, '..', '..', 'src')
37 | ]
38 | }]
39 | }
40 | };
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "d3-state-visualizer",
3 | "version": "1.3.2",
4 | "description": "Visualize your app state with a range of reusable charts",
5 | "main": "lib/index.js",
6 | "files": [
7 | "dist",
8 | "lib",
9 | "src"
10 | ],
11 | "scripts": {
12 | "clean": "rimraf lib dist",
13 | "lint": "eslint src examples",
14 | "build": "npm run build:lib && npm run build:umd && npm run build:umd:min",
15 | "build:lib": "babel src --out-dir lib",
16 | "build:umd": "webpack src/index.js dist/d3-state-visualizer.js --config webpack.config.development.js",
17 | "build:umd:min": "webpack src/index.js dist/d3-state-visualizer.min.js --config webpack.config.production.js",
18 | "version": "npm run build",
19 | "postversion": "git push && git push --tags && npm run clean",
20 | "prepublish": "npm run clean && npm run build"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "https://github.com/romseguy/d3-state-visualizer.git"
25 | },
26 | "keywords": [
27 | "d3",
28 | "state",
29 | "store",
30 | "tree",
31 | "visualization"
32 | ],
33 | "author": "romseguy",
34 | "license": "MIT",
35 | "bugs": {
36 | "url": "https://github.com/romseguy/d3-state-visualizer/issues"
37 | },
38 | "homepage": "https://github.com/romseguy/d3-state-visualizer",
39 | "devDependencies": {
40 | "babel-cli": "^6.3.15",
41 | "babel-core": "^6.1.20",
42 | "babel-eslint": "^5.0.0-beta4",
43 | "babel-loader": "^6.2.0",
44 | "babel-preset-es2015-loose": "^6.1.3",
45 | "babel-preset-react": "^6.3.13",
46 | "babel-preset-stage-0": "^6.3.13",
47 | "eslint": "^0.23",
48 | "eslint-config-airbnb": "0.0.6",
49 | "eslint-plugin-react": "^3.6.3",
50 | "rimraf": "^2.3.4",
51 | "webpack": "^1.9.6"
52 | },
53 | "peerDependencies": {},
54 | "dependencies": {
55 | "d3": "^3.5.6",
56 | "d3tooltip": "^1.2.2",
57 | "deepmerge": "^0.2.10",
58 | "is-plain-object": "2.0.1",
59 | "map2tree": "^1.4.0",
60 | "ramda": "^0.17.1"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/charts/index.js:
--------------------------------------------------------------------------------
1 | export tree from './tree/tree';
2 |
--------------------------------------------------------------------------------
/src/charts/tree/sortAndSerialize.js:
--------------------------------------------------------------------------------
1 | import { is } from 'ramda'
2 |
3 | function sortObject(obj, strict) {
4 | if (obj instanceof Array) {
5 | let ary
6 | if (strict) {
7 | ary = obj.sort()
8 | } else {
9 | ary = obj
10 | }
11 | return ary
12 | }
13 |
14 | if (obj && typeof obj === 'object') {
15 | const tObj = {}
16 | Object.keys(obj).sort().forEach(key => tObj[key] = sortObject(obj[key]))
17 | return tObj
18 | }
19 |
20 | return obj
21 | }
22 |
23 | export default function sortAndSerialize(obj) {
24 | return JSON.stringify(sortObject(obj, true), undefined, 2)
25 | }
26 |
--------------------------------------------------------------------------------
/src/charts/tree/tree.js:
--------------------------------------------------------------------------------
1 | import d3 from 'd3'
2 | import { isEmpty } from 'ramda'
3 | import map2tree from 'map2tree'
4 | import deepmerge from 'deepmerge'
5 | import { getTooltipString, toggleChildren, visit, getNodeGroupByDepthCount } from './utils'
6 | import d3tooltip from 'd3tooltip'
7 |
8 | const defaultOptions = {
9 | state: undefined,
10 | rootKeyName: 'state',
11 | pushMethod: 'push',
12 | tree: undefined,
13 | id: 'd3svg',
14 | style: {
15 | node: {
16 | colors: {
17 | 'default': '#ccc',
18 | collapsed: 'lightsteelblue',
19 | parent: 'white'
20 | },
21 | radius: 7
22 | },
23 | text: {
24 | colors: {
25 | 'default': 'black',
26 | hover: 'skyblue'
27 | }
28 | },
29 | link: {
30 | stroke: '#000',
31 | fill: 'none'
32 | }
33 | },
34 | size: 500,
35 | aspectRatio: 1.0,
36 | initialZoom: 1,
37 | margin: {
38 | top: 10,
39 | right: 10,
40 | bottom: 10,
41 | left: 50
42 | },
43 | isSorted: false,
44 | heightBetweenNodesCoeff: 2,
45 | widthBetweenNodesCoeff: 1,
46 | transitionDuration: 750,
47 | blinkDuration: 100,
48 | onClickText: () => {},
49 | tooltipOptions: {
50 | disabled: false,
51 | left: undefined,
52 | right: undefined,
53 | offset: {
54 | left: 0,
55 | top: 0
56 | },
57 | style: undefined
58 | }
59 | }
60 |
61 | export default function(DOMNode, options = {}) {
62 | const {
63 | id,
64 | style,
65 | size,
66 | aspectRatio,
67 | initialZoom,
68 | margin,
69 | isSorted,
70 | widthBetweenNodesCoeff,
71 | heightBetweenNodesCoeff,
72 | transitionDuration,
73 | blinkDuration,
74 | state,
75 | rootKeyName,
76 | pushMethod,
77 | tree,
78 | tooltipOptions,
79 | onClickText
80 | } = deepmerge(defaultOptions, options)
81 |
82 | const width = size - margin.left - margin.right
83 | const height = size * aspectRatio - margin.top - margin.bottom
84 | const fullWidth = size
85 | const fullHeight = size * aspectRatio
86 |
87 | const attr = {
88 | id,
89 | preserveAspectRatio: 'xMinYMin slice'
90 | }
91 |
92 | if (!style.width) {
93 | attr.width = fullWidth
94 | }
95 |
96 | if (!style.width || !style.height) {
97 | attr.viewBox = `0 0 ${fullWidth} ${fullHeight}`
98 | }
99 |
100 | const root = d3.select(DOMNode)
101 | const zoom = d3.behavior.zoom()
102 | .scaleExtent([0.1, 3])
103 | .scale(initialZoom)
104 | const vis = root
105 | .append('svg')
106 | .attr(attr)
107 | .style({cursor: '-webkit-grab', ...style})
108 | .call(zoom.on('zoom', () => {
109 | const { translate, scale } = d3.event
110 | vis.attr('transform', `translate(${translate})scale(${scale})`)
111 | }))
112 | .append('g')
113 | .attr({
114 | transform: `translate(${margin.left + style.node.radius}, ${margin.top}) scale(${initialZoom})`
115 | })
116 |
117 | let layout = d3.layout.tree().size([width, height])
118 | let data
119 |
120 | if (isSorted) {
121 | layout.sort((a, b) => b.name.toLowerCase() < a.name.toLowerCase() ? 1 : -1)
122 | }
123 |
124 | // previousNodePositionsById stores node x and y
125 | // as well as hierarchy (id / parentId);
126 | // helps animating transitions
127 | let previousNodePositionsById = {
128 | root: {
129 | id: 'root',
130 | parentId: null,
131 | x: height / 2,
132 | y: 0
133 | }
134 | }
135 |
136 | // traverses a map with node positions by going through the chain
137 | // of parent ids; once a parent that matches the given filter is found,
138 | // the parent position gets returned
139 | function findParentNodePosition(nodePositionsById, nodeId, filter) {
140 | let currentPosition = nodePositionsById[nodeId]
141 | while (currentPosition) {
142 | currentPosition = nodePositionsById[currentPosition.parentId]
143 | if (!currentPosition) {
144 | return null
145 | }
146 | if (!filter || filter(currentPosition)) {
147 | return currentPosition
148 | }
149 | }
150 | }
151 |
152 | return function renderChart(nextState = tree || state) {
153 | data = !tree ? map2tree(nextState, {key: rootKeyName, pushMethod}) : nextState
154 |
155 | if (isEmpty(data) || !data.name) {
156 | data = { name: 'error', message: 'Please provide a state map or a tree structure'}
157 | }
158 |
159 | let nodeIndex = 0
160 | let maxLabelLength = 0
161 |
162 | // nodes are assigned with string ids, which reflect their location
163 | // within the hierarcy; e.g. "root|branch|subBranch|subBranch[0]|property"
164 | // top-level elemnt always has id "root"
165 | visit(data,
166 | node => {
167 | maxLabelLength = Math.max(node.name.length, maxLabelLength)
168 | node.id = node.id || 'root'
169 | },
170 | node => node.children && node.children.length > 0 ? node.children.map((c) => {
171 | c.id = `${node.id || ''}|${c.name}`
172 | return c
173 | }) : null
174 | )
175 |
176 | /*eslint-disable*/
177 | update()
178 | /*eslint-enable*/
179 |
180 | function update() {
181 | // path generator for links
182 | const diagonal = d3.svg.diagonal().projection(d => [d.y, d.x])
183 | // set tree dimensions and spacing between branches and nodes
184 | const maxNodeCountByLevel = Math.max(...getNodeGroupByDepthCount(data))
185 |
186 | layout = layout.size([maxNodeCountByLevel * 25 * heightBetweenNodesCoeff, width])
187 |
188 | let nodes = layout.nodes(data)
189 | let links = layout.links(nodes)
190 |
191 | nodes.forEach(node => node.y = node.depth * (maxLabelLength * 7 * widthBetweenNodesCoeff))
192 |
193 | const nodePositions = nodes.map(n => ({
194 | parentId: n.parent && n.parent.id,
195 | id: n.id,
196 | x: n.x,
197 | y: n.y
198 | }))
199 | const nodePositionsById = {}
200 | nodePositions.forEach(node => nodePositionsById[node.id] = node)
201 |
202 | // process the node selection
203 | let node = vis.selectAll('g.node')
204 | .property('__oldData__', d => d)
205 | .data(nodes, d => d.id || (d.id = ++nodeIndex))
206 | let nodeEnter = node.enter().append('g')
207 | .attr({
208 | 'class': 'node',
209 | transform: d => {
210 | const position = findParentNodePosition(nodePositionsById, d.id, (n) => previousNodePositionsById[n.id])
211 | const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
212 | return `translate(${previousPosition.y},${previousPosition.x})`
213 | }
214 | })
215 | .style({
216 | fill: style.text.colors.default,
217 | cursor: 'pointer'
218 | })
219 | .on({
220 | mouseover: function mouseover() {
221 | d3.select(this).style({
222 | fill: style.text.colors.hover
223 | })
224 | },
225 | mouseout: function mouseout() {
226 | d3.select(this).style({
227 | fill: style.text.colors.default
228 | })
229 | }
230 | })
231 |
232 | if (!tooltipOptions.disabled) {
233 | nodeEnter.call(d3tooltip(d3, 'tooltip', {...tooltipOptions, root})
234 | .text((d, i) => getTooltipString(d, i, tooltipOptions))
235 | .style(tooltipOptions.style)
236 | )
237 | }
238 |
239 | // g inside node contains circle and text
240 | // this extra wrapper helps run d3 transitions in parallel
241 | const nodeEnterInnerGroup = nodeEnter.append('g')
242 | nodeEnterInnerGroup.append('circle')
243 | .attr({
244 | 'class': 'nodeCircle',
245 | r: 0
246 | })
247 | .on({
248 | click: clickedNode => {
249 | if (d3.event.defaultPrevented) return
250 | toggleChildren(clickedNode)
251 | update()
252 | }
253 | })
254 |
255 | nodeEnterInnerGroup.append('text')
256 | .attr({
257 | 'class': 'nodeText',
258 | 'text-anchor': 'middle',
259 | 'transform': `translate(0,0)`,
260 | dy: '.35em'
261 | })
262 | .style({
263 | 'fill-opacity': 0
264 | })
265 | .text(d => d.name)
266 | .on({
267 | click: onClickText
268 | })
269 |
270 | // update the text to reflect whether node has children or not
271 | node.select('text')
272 | .text(d => d.name)
273 |
274 | // change the circle fill depending on whether it has children and is collapsed
275 | node.select('circle')
276 | .style({
277 | stroke: 'black',
278 | 'stroke-width': '1.5px',
279 | fill: d => d._children ? style.node.colors.collapsed : (d.children ? style.node.colors.parent : style.node.colors.default)
280 | })
281 |
282 | // transition nodes to their new position
283 | let nodeUpdate = node.transition()
284 | .duration(transitionDuration)
285 | .attr({
286 | transform: d => `translate(${d.y},${d.x})`
287 | })
288 |
289 | // ensure circle radius is correct
290 | nodeUpdate.select('circle')
291 | .attr('r', style.node.radius)
292 |
293 | // fade the text in and align it
294 | nodeUpdate.select('text')
295 | .style('fill-opacity', 1)
296 | .attr({
297 | transform: function transform(d) {
298 | const x = (d.children || d._children ? -1 : 1) * (this.getBBox().width / 2 + style.node.radius + 5)
299 | return `translate(${x},0)`
300 | }
301 | })
302 |
303 | // blink updated nodes
304 | node.filter(function flick(d) {
305 | // test whether the relevant properties of d match
306 | // the equivalent property of the oldData
307 | // also test whether the old data exists,
308 | // to catch the entering elements!
309 | return (this.__oldData__ && d.value !== this.__oldData__.value)
310 | })
311 | .select('g')
312 | .style('opacity', '0.3').transition()
313 | .duration(blinkDuration).style('opacity', '1')
314 |
315 | // transition exiting nodes to the parent's new position
316 | let nodeExit = node.exit().transition()
317 | .duration(transitionDuration)
318 | .attr({
319 | transform: d => {
320 | const position = findParentNodePosition(previousNodePositionsById, d.id, (n) => nodePositionsById[n.id])
321 | const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
322 | return `translate(${futurePosition.y},${futurePosition.x})`
323 | }
324 | })
325 | .remove()
326 |
327 | nodeExit.select('circle')
328 | .attr('r', 0)
329 |
330 | nodeExit.select('text')
331 | .style('fill-opacity', 0)
332 |
333 | // update the links
334 | let link = vis.selectAll('path.link')
335 | .data(links, d => d.target.id)
336 |
337 | // enter any new links at the parent's previous position
338 | link.enter().insert('path', 'g')
339 | .attr({
340 | 'class': 'link',
341 | d: d => {
342 | const position = findParentNodePosition(nodePositionsById, d.target.id, (n) => previousNodePositionsById[n.id])
343 | const previousPosition = position && previousNodePositionsById[position.id] || previousNodePositionsById.root
344 | return diagonal({
345 | source: previousPosition,
346 | target: previousPosition
347 | })
348 | }
349 | })
350 | .style(style.link)
351 |
352 | // transition links to their new position
353 | link.transition()
354 | .duration(transitionDuration)
355 | .attr({
356 | d: diagonal
357 | })
358 |
359 | // transition exiting nodes to the parent's new position
360 | link.exit()
361 | .transition()
362 | .duration(transitionDuration)
363 | .attr({
364 | d: d => {
365 | const position = findParentNodePosition(previousNodePositionsById, d.target.id, (n) => nodePositionsById[n.id])
366 | const futurePosition = position && nodePositionsById[position.id] || nodePositionsById.root
367 | return diagonal({
368 | source: futurePosition,
369 | target: futurePosition
370 | })
371 | }
372 | })
373 | .remove()
374 |
375 | // delete the old data once it's no longer needed
376 | node.property('__oldData__', null)
377 |
378 | // stash the old positions for transition
379 | previousNodePositionsById = nodePositionsById
380 | }
381 | }
382 | }
383 |
--------------------------------------------------------------------------------
/src/charts/tree/utils.js:
--------------------------------------------------------------------------------
1 | import { is, join, pipe, replace } from 'ramda';
2 | import sortAndSerialize from './sortAndSerialize';
3 |
4 | export function collapseChildren(node) {
5 | if (node.children) {
6 | node._children = node.children;
7 | node._children.forEach(collapseChildren);
8 | node.children = null;
9 | }
10 | }
11 |
12 | export function expandChildren(node) {
13 | if (node._children) {
14 | node.children = node._children;
15 | node.children.forEach(expandChildren);
16 | node._children = null;
17 | }
18 | }
19 |
20 | export function toggleChildren(node) {
21 | if (node.children) {
22 | node._children = node.children;
23 | node.children = null;
24 | } else if (node._children) {
25 | node.children = node._children;
26 | node._children = null;
27 | }
28 | return node;
29 | }
30 |
31 | export function visit(parent, visitFn, childrenFn) {
32 | if (!parent) {
33 | return;
34 | }
35 |
36 | visitFn(parent);
37 |
38 | let children = childrenFn(parent);
39 | if (children) {
40 | let count = children.length;
41 |
42 | for (let i = 0; i < count; i++) {
43 | visit(children[i], visitFn, childrenFn);
44 | }
45 | }
46 | }
47 |
48 | export function getNodeGroupByDepthCount(rootNode) {
49 | let nodeGroupByDepthCount = [1];
50 |
51 | const traverseFrom = function traverseFrom(node, depth = 0) {
52 | if (!node.children || node.children.length === 0) {
53 | return 0;
54 | }
55 |
56 | if (nodeGroupByDepthCount.length <= depth + 1) {
57 | nodeGroupByDepthCount.push(0);
58 | }
59 |
60 | nodeGroupByDepthCount[depth + 1] += node.children.length;
61 |
62 | node.children.forEach(childNode => {
63 | traverseFrom(childNode, depth + 1);
64 | });
65 | };
66 |
67 | traverseFrom(rootNode);
68 | return nodeGroupByDepthCount;
69 | }
70 |
71 | export function getTooltipString(node, i, { indentationSize = 4 }) {
72 | if (!is(Object, node)) return '';
73 |
74 | const spacer = join(' ');
75 | const cr2br = replace(/\n/g, '
');
76 | const spaces2nbsp = replace(/\s{2}/g, spacer(new Array(indentationSize)));
77 | const json2html = pipe(sortAndSerialize, cr2br, spaces2nbsp);
78 |
79 | const children = node.children || node._children;
80 |
81 | if (typeof node.value !== 'undefined') return json2html(node.value);
82 | if (typeof node.object !== 'undefined') return json2html(node.object);
83 | if (children && children.length) return 'childrenCount: ' + children.length;
84 | return 'empty';
85 | }
86 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import * as charts from './charts';
2 |
3 | export { tree } from './charts';
4 |
5 | export default charts;
6 |
--------------------------------------------------------------------------------
/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | module: {
5 | loaders: [
6 | { test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ }
7 | ]
8 | },
9 | output: {
10 | library: 'd3-state-visualizer',
11 | libraryTarget: 'umd'
12 | },
13 | resolve: {
14 | extensions: ['', '.js']
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/webpack.config.development.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var webpack = require('webpack');
4 | var baseConfig = require('./webpack.config.base');
5 |
6 | var config = Object.create(baseConfig);
7 | config.plugins = [
8 | new webpack.optimize.OccurenceOrderPlugin(),
9 | new webpack.DefinePlugin({
10 | 'process.env.NODE_ENV': JSON.stringify('development')
11 | })
12 | ];
13 |
14 | module.exports = config;
15 |
--------------------------------------------------------------------------------
/webpack.config.production.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var webpack = require('webpack');
4 | var baseConfig = require('./webpack.config.base');
5 |
6 | var config = Object.create(baseConfig);
7 | config.plugins = [
8 | new webpack.optimize.OccurenceOrderPlugin(),
9 | new webpack.DefinePlugin({
10 | 'process.env.NODE_ENV': JSON.stringify('production')
11 | }),
12 | new webpack.optimize.UglifyJsPlugin({
13 | compressor: {
14 | screw_ie8: true,
15 | warnings: false
16 | }
17 | })
18 | ];
19 |
20 | module.exports = config;
21 |
--------------------------------------------------------------------------------