├── .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 | --------------------------------------------------------------------------------