├── .gitignore ├── .babelrc ├── src ├── reducer.js ├── shared │ ├── tip-selection.js │ ├── generateData.js │ └── algorithms.js ├── index.js ├── containers │ ├── radio-button.css │ ├── SliderContainer.js │ └── TangleContainer.js └── components │ ├── Tangle.css │ └── Tangle.js ├── .eslintrc.js ├── README.md ├── public ├── css │ └── style.css └── index.html ├── webpack.config.js ├── package.json └── __test__ └── shared ├── tip-selection.test.js └── algorithms.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/bundle.js -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | 3 | const tangleReducer = (state = {}, action) => state; 4 | 5 | const appReducer = combineReducers({ 6 | tangle: tangleReducer, 7 | }); 8 | 9 | export default appReducer; 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "google", 4 | "plugin:react/recommended", 5 | ], 6 | "parser": "babel-eslint", 7 | "rules": { 8 | "arrow-parens": 0, 9 | "require-jsdoc": 0, 10 | "max-len": 0, 11 | "jsx-quotes": ["error", "prefer-single"] 12 | }, 13 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Iota Visual Simulation 2 | This is a visualization aid for those interested in the Iota data structure, the _tangle_. It is written using React and D3.js. 3 | 4 | ## Setting up 5 | After cloning the repo, run: 6 | ``` 7 | npm install 8 | ``` 9 | to download dependencies. 10 | 11 | To run locally during development, use: 12 | ``` 13 | npm run dev-server 14 | ``` 15 | 16 | and go to `localhost:9000` in your browser. 17 | 18 | ## Testing & Linting 19 | Before submitting PRs, make sure the code passes tests and lints by running the following: 20 | 21 | ``` 22 | npm test 23 | npm run lint 24 | ``` 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | svg { 2 | border: 1px solid black; 3 | } 4 | 5 | * { 6 | font-family: 'Play'; 7 | } 8 | 9 | div.title-bar-container { 10 | width: 100%; 11 | margin: auto; 12 | margin-bottom: 20px; 13 | } 14 | 15 | .left-title { 16 | width: 250px; 17 | float: left; 18 | font-size: 14px; 19 | } 20 | 21 | .left-title h2 { 22 | padding: 0; 23 | margin: 0; 24 | margin-top: 10px; 25 | } 26 | 27 | .right-title { 28 | margin-left: 250px; 29 | padding-top: 15px; 30 | font-size: 11px; 31 | } 32 | 33 | .right-title p { 34 | margin: 0; 35 | margin-bottom: 8px; 36 | } 37 | 38 | @media only screen and (max-width: 768px) { 39 | /* For mobile phones: */ 40 | .left-title, .right-title { 41 | width: 100%; 42 | float: none; 43 | margin-left: 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/shared/tip-selection.js: -------------------------------------------------------------------------------- 1 | import {choose, isTip, randomWalk, weightedRandomWalk, calculateWeights} from './algorithms'; 2 | 3 | export const uniformRandom = ({nodes, links}) => { 4 | const candidates = nodes.filter(node => isTip({links, node})); 5 | 6 | return candidates.length === 0 ? [] : [choose(candidates), choose(candidates)]; 7 | }; 8 | 9 | export const unWeightedMCMC = ({nodes, links}) => { 10 | if (nodes.length === 0) { 11 | return []; 12 | } 13 | 14 | const start = nodes[0]; // Start in genesis 15 | 16 | return [ 17 | randomWalk({links, start}), 18 | randomWalk({links, start}), 19 | ]; 20 | }; 21 | 22 | export const weightedMCMC = ({nodes, links, alpha}) => { 23 | if (nodes.length === 0) { 24 | return []; 25 | } 26 | 27 | const start = nodes[0]; // Start in genesis 28 | 29 | calculateWeights({nodes, links}); 30 | 31 | return [ 32 | weightedRandomWalk({links, start, alpha}), 33 | weightedRandomWalk({links, start, alpha}), 34 | ]; 35 | }; 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from 'react-dom'; 3 | import reducer from './reducer'; 4 | import {Provider} from 'react-redux'; 5 | import {createStore} from 'redux'; 6 | import TangleContainer from './containers/TangleContainer'; 7 | 8 | const store = createStore(reducer); 9 | 10 | render( 11 | 12 |
13 |
14 |
15 |

Iota Tangle Visualization

16 |
17 |
18 |

19 | This demo shows the tangle structure behind Iota, as described in 20 | the white paper. 21 |

22 |

23 | The source code can be found on github. 24 |

25 |
26 |
27 | 28 |
29 |
, 30 | document.getElementById('container') 31 | ); 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: path.resolve(__dirname, 'src/index.js'), 5 | output: { 6 | path: path.resolve(__dirname, 'public'), 7 | filename: 'bundle.js', 8 | }, 9 | devServer: { 10 | contentBase: path.resolve(__dirname, 'public'), 11 | port: 9000, 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | loader: 'babel-loader', 17 | 18 | // Skip any files outside of your project's `src` directory 19 | include: [ 20 | path.resolve(__dirname, 'src'), 21 | ], 22 | exclude: [ 23 | path.resolve(__dirname, 'node_modules'), 24 | ], 25 | 26 | // Only run `.js` and `.jsx` files through Babel 27 | test: /\.jsx?$/, 28 | 29 | // Options to configure babel with 30 | query: { 31 | presets: ['es2015', 'react'], 32 | plugins: ['transform-object-rest-spread'], 33 | }, 34 | }, 35 | { 36 | test: /\.css$/, 37 | loader: 'style-loader!css-loader', 38 | }, 39 | ], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/shared/generateData.js: -------------------------------------------------------------------------------- 1 | const jStat = require('jStat').jStat; 2 | 3 | export const generateTangle = ({nodeCount, lambda = 1.5, h=1, alpha=0.5, tipSelectionAlgorithm}) => { 4 | jStat.exponential.sample(lambda); 5 | const genesis = { 6 | name: '0', 7 | time: 0, 8 | }; 9 | 10 | let nodes = [genesis]; 11 | let time = h; 12 | while (nodes.length < nodeCount) { 13 | const delay = jStat.exponential.sample(lambda); 14 | time += delay; 15 | nodes.push({ 16 | name: `${nodes.length}`, 17 | time, 18 | x: 300, 19 | y: 200, 20 | }); 21 | } 22 | 23 | const links = []; 24 | for (let node of nodes) { 25 | const candidates = nodes 26 | .filter(candidate => candidate.time < node.time - h); 27 | 28 | const candidateLinks = links 29 | .filter(link => link.source.time < node.time - h); 30 | 31 | const tips = tipSelectionAlgorithm({ 32 | nodes: candidates, 33 | links: candidateLinks, 34 | alpha, 35 | }); 36 | 37 | if (tips.length > 0) { 38 | links.push({source: node, target: tips[0]}); 39 | if (tips.length > 1 && tips[0].name !== tips[1].name) { 40 | links.push({source: node, target: tips[1]}); 41 | } 42 | } 43 | }; 44 | 45 | return { 46 | nodes, 47 | links, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsdemo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev-server": "webpack-dev-server", 8 | "test": "jest", 9 | "lint": "eslint src" 10 | }, 11 | "keywords": [], 12 | "author": "Alon Gal ", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "babel-core": "^6.26.0", 16 | "babel-eslint": "^8.2.1", 17 | "babel-jest": "^22.1.0", 18 | "babel-loader": "^7.1.2", 19 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 20 | "babel-polyfill": "^6.26.0", 21 | "babel-preset-env": "^1.6.1", 22 | "babel-preset-es2015": "^6.24.1", 23 | "babel-preset-react": "^6.24.1", 24 | "css-loader": "^0.28.9", 25 | "d3-force": "^1.1.0", 26 | "d3-scale": "^1.0.7", 27 | "eslint": "^4.15.0", 28 | "eslint-config-google": "^0.9.1", 29 | "jStat": "^1.7.1", 30 | "jest": "^22.1.4", 31 | "jstat": "^1.7.1", 32 | "prop-types": "^15.6.0", 33 | "rc-slider": "^8.6.0", 34 | "rc-tooltip": "^3.7.0", 35 | "react": "^16.2.0", 36 | "react-dom": "^16.2.0", 37 | "react-redux": "^5.0.6", 38 | "redux": "^3.7.2", 39 | "regenerator-runtime": "^0.11.1", 40 | "style-loader": "^0.19.1", 41 | "webpack": "^3.10.0", 42 | "webpack-dev-server": "^2.11.0" 43 | }, 44 | "dependencies": {} 45 | } 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | IOTA visualization 13 | 14 | 15 | 16 | 17 | 18 | 31 | 32 | 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /src/containers/radio-button.css: -------------------------------------------------------------------------------- 1 | /* The container */ 2 | .container { 3 | display: block; 4 | position: relative; 5 | padding-left: 35px; 6 | margin-bottom: 12px; 7 | cursor: pointer; 8 | font-size: 22px; 9 | -webkit-user-select: none; 10 | -moz-user-select: none; 11 | -ms-user-select: none; 12 | user-select: none; 13 | } 14 | 15 | /* Hide the browser's default radio button */ 16 | .container input { 17 | position: absolute; 18 | opacity: 0; 19 | cursor: pointer; 20 | } 21 | 22 | /* Create a custom radio button */ 23 | .checkmark { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | height: 25px; 28 | width: 25px; 29 | background-color: #eee; 30 | border-radius: 50%; 31 | } 32 | 33 | /* On mouse-over, add a grey background color */ 34 | .container:hover input ~ .checkmark { 35 | background-color: #ccc; 36 | } 37 | 38 | /* When the radio button is checked, add a blue background */ 39 | .container input:checked ~ .checkmark { 40 | background-color: #2196F3; 41 | } 42 | 43 | /* Create the indicator (the dot/circle - hidden when not checked) */ 44 | .checkmark:after { 45 | content: ""; 46 | position: absolute; 47 | display: none; 48 | } 49 | 50 | /* Show the indicator (dot/circle) when checked */ 51 | .container input:checked ~ .checkmark:after { 52 | display: block; 53 | } 54 | 55 | /* Style the indicator (dot/circle) */ 56 | .container .checkmark:after { 57 | top: 9px; 58 | left: 9px; 59 | width: 8px; 60 | height: 8px; 61 | border-radius: 50%; 62 | background: white; 63 | } 64 | -------------------------------------------------------------------------------- /src/containers/SliderContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Slider from 'rc-slider'; 4 | 5 | 6 | const SliderContainer = props => 7 |
8 |
9 | {props.min} 10 |
11 |
{ 17 | e.preventDefault(); // Cancels the event 18 | const valueToAdd = e.deltaY > 0 ? props.step : -props.step; 19 | const valueToEmit = props.value + valueToAdd; 20 | if (valueToEmit < props.max && valueToEmit > props.min) { 21 | props.onChange(valueToEmit); 22 | } 23 | }} 24 | > 25 | 49 |
50 |
51 | {props.max} 52 |
53 |
; 54 | 55 | SliderContainer.propTypes = { 56 | min: PropTypes.number.isRequired, 57 | max: PropTypes.number.isRequired, 58 | defaultValue: PropTypes.number.isRequired, 59 | step: PropTypes.number, 60 | handle: PropTypes.any, 61 | disabled: PropTypes.bool, 62 | onChange: PropTypes.any, 63 | }; 64 | 65 | export default SliderContainer; -------------------------------------------------------------------------------- /src/components/Tangle.css: -------------------------------------------------------------------------------- 1 | .unselectable { 2 | -webkit-user-select:none; 3 | -khtml-user-select:none; 4 | -moz-user-select:none; 5 | -ms-user-select:none; 6 | -o-user-select:none; 7 | user-select:none; 8 | } 9 | 10 | .approved rect { 11 | fill: red; 12 | } 13 | 14 | .approved text { 15 | fill: white; 16 | } 17 | 18 | .approving rect { 19 | fill: blue; 20 | } 21 | 22 | .approving text { 23 | fill: white; 24 | } 25 | 26 | path.links { 27 | stroke: black; 28 | stroke-width: 1px; 29 | stroke-dasharray: 4,2; 30 | } 31 | 32 | path.links.approved { 33 | stroke-dasharray: none; 34 | stroke: red; 35 | } 36 | 37 | path.links.approving { 38 | stroke-dasharray: none; 39 | stroke: blue; 40 | } 41 | 42 | .tip rect { 43 | fill: lightgray; 44 | } 45 | 46 | .tip text { 47 | fill: black; 48 | } 49 | 50 | .top-bar-container { 51 | position: relative; 52 | margin: 0; 53 | padding: 0; 54 | padding-bottom: 5px; 55 | border-right: 1px solid black; 56 | border-left: 1px solid black; 57 | border-top: 1px solid black; 58 | } 59 | 60 | .top-bar-row { 61 | position: relative; 62 | width: 100%; 63 | display: table; 64 | } 65 | 66 | .left-cell, .slider-title { 67 | width: 160px; 68 | } 69 | 70 | .left-cell { 71 | position: absolute; 72 | height: 100%; 73 | padding-left: 10px; 74 | border-right: 1px solid black; 75 | } 76 | 77 | .slider-title { 78 | display: table-cell; 79 | text-align: right; 80 | font-size: 11px; 81 | } 82 | 83 | .slider-container { 84 | display: table-cell; 85 | padding-left: 20px; 86 | padding-right: 20px; 87 | padding-top: 0; 88 | padding-bottom: 0; 89 | } 90 | 91 | .left-slider-value, .right-slider-value { 92 | font-size: 10px; 93 | display: table-cell; 94 | width: 35px; 95 | } 96 | 97 | .left-slider-value { 98 | text-align: right; 99 | padding-right: 10px; 100 | } 101 | 102 | .right-slider-value { 103 | padding-left: 10px; 104 | } 105 | 106 | .right-cell, .tip-algo-label { 107 | width: 170px; 108 | } 109 | 110 | .right-cell { 111 | position: absolute; 112 | right: 0; 113 | height: 100%; 114 | padding-right: 10px; 115 | border-left: 1px solid black; 116 | } 117 | 118 | .tip-algo-label { 119 | display: table-cell; 120 | position: relative; 121 | top: 3px; 122 | } 123 | 124 | .axis text { 125 | fill: black; 126 | stroke: none; 127 | font-size: 15; 128 | text-anchor: middle; 129 | } 130 | 131 | /* Override radio-button.css */ 132 | .container { 133 | padding-left: 15px; 134 | margin-bottom: 12px; 135 | cursor: pointer; 136 | font-size: 22px; 137 | -webkit-user-select: none; 138 | -moz-user-select: none; 139 | -ms-user-select: none; 140 | user-select: none; 141 | } 142 | 143 | .container > div { 144 | float: left; 145 | } 146 | 147 | .checkmark { 148 | width: 10px; 149 | height: 10px; 150 | } 151 | -------------------------------------------------------------------------------- /__test__/shared/tip-selection.test.js: -------------------------------------------------------------------------------- 1 | import * as tipSelections from '../../src/shared/tip-selection'; 2 | import {uniformRandom, unWeightedMCMC, weightedMCMC} from '../../src/shared/tip-selection'; 3 | 4 | const initNodes = n => [...Array(n).keys()].map(i => ({name: i})); 5 | 6 | // convert links from names to pointers 7 | const graphify = ({nodes, links}) => { 8 | for (let link of links) { 9 | link.source = nodes.find(n => n.name === link.source); 10 | link.target = nodes.find(n => n.name === link.target); 11 | } 12 | }; 13 | 14 | describe('Tip selection algorithms', () => { 15 | describe('Global tests for all algorithms', () => { 16 | let tipSelectionAlgorithm; 17 | 18 | Object.keys(tipSelections).map(name => { 19 | describe(name, () => { 20 | beforeAll(() => { 21 | tipSelectionAlgorithm = tipSelections[name]; 22 | }); 23 | it('returns empty array in 0 node graph', () => { 24 | const nodes = []; 25 | const links = []; 26 | 27 | const tips = tipSelectionAlgorithm({nodes, links}); 28 | 29 | expect(tips).toHaveLength(0); 30 | }); 31 | it('returns empty array in a 2-clique', () => { 32 | const nodes = initNodes(2); 33 | const links = [ 34 | {source: 0, target: 1}, 35 | {source: 1, target: 0}, 36 | ]; 37 | graphify({nodes, links}); 38 | 39 | const tips = uniformRandom({nodes, links}); 40 | 41 | expect(tips).toHaveLength(0); 42 | }); 43 | it('returns only option (twice) in a chain of length 2', () => { 44 | const nodes = initNodes(2); 45 | 46 | const links = [ 47 | {source: 1, target: 0}, 48 | ]; 49 | graphify({nodes, links}); 50 | 51 | const tips = uniformRandom({nodes, links}); 52 | 53 | expect(tips).toHaveLength(2); 54 | expect(tips[0]).toEqual(nodes[1]); 55 | expect(tips[1]).toEqual(nodes[1]); 56 | }); 57 | }); 58 | }); 59 | }); 60 | describe('MCMC', () => { 61 | describe('Common tests', () => { 62 | [{ 63 | name: 'unWeightedMCMC', 64 | algo: unWeightedMCMC, 65 | }, 66 | { 67 | name: 'weightedMCMC', 68 | algo: weightedMCMC, 69 | }].map(algo => { 70 | describe(algo.name, () => { 71 | it('doesn\'t reach disconnected component', () => { 72 | const nodes = initNodes(2); 73 | const links = []; 74 | graphify({nodes, links}); 75 | 76 | const tips = algo.algo({nodes, links, start: nodes[0]}); 77 | 78 | expect(tips).toHaveLength(2); 79 | expect(tips[0]).toEqual(nodes[0]); 80 | expect(tips[1]).toEqual(nodes[0]); 81 | }); 82 | it('Stays on genesis branch when given two disconnected components', () => { 83 | const nodes = initNodes(5); 84 | const links = [ 85 | {source: 1, target: 0}, 86 | {source: 2, target: 1}, 87 | {source: 4, target: 3}, 88 | ]; 89 | graphify({nodes, links}); 90 | 91 | const tips = algo.algo({nodes, links}); 92 | 93 | expect(tips).toHaveLength(2); 94 | expect(tips[0]).toEqual(nodes[2]); 95 | expect(tips[1]).toEqual(nodes[2]); 96 | }); 97 | }); 98 | }); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/shared/algorithms.js: -------------------------------------------------------------------------------- 1 | export const isTip = ({links, node}) => { 2 | return !links.some(link => node === link.target); 3 | }; 4 | 5 | export const choose = arr => arr[Math.floor(Math.random() * arr.length)]; 6 | 7 | export const getDescendants = ({nodes, links, root}) => { 8 | const stack = [root]; 9 | const visitedNodes = new Set(); 10 | const visitedLinks = new Set(); 11 | 12 | while (stack.length > 0) { 13 | const current = stack.pop(); 14 | 15 | const outgoingEdges = links.filter(l => l.source === current); 16 | for (let link of outgoingEdges) { 17 | visitedLinks.add(link); 18 | if (!visitedNodes.has(link.target)) { 19 | stack.push(link.target); 20 | visitedNodes.add(link.target); 21 | } 22 | } 23 | } 24 | 25 | return {nodes: visitedNodes, links: visitedLinks}; 26 | }; 27 | 28 | export const getAncestors = ({nodes, links, root}) => { 29 | const stack = [root]; 30 | const visitedNodes = new Set(); 31 | const visitedLinks = new Set(); 32 | 33 | while (stack.length > 0) { 34 | const current = stack.pop(); 35 | 36 | const incomingEdges = links.filter(l => l.target === current); 37 | for (let link of incomingEdges) { 38 | visitedLinks.add(link); 39 | if (!visitedNodes.has(link.source)) { 40 | stack.push(link.source); 41 | visitedNodes.add(link.source); 42 | } 43 | } 44 | } 45 | 46 | return {nodes: visitedNodes, links: visitedLinks}; 47 | }; 48 | 49 | export const getTips = ({nodes, links}) => { 50 | const tips = nodes.filter(node => 51 | !links.some(link => link.target === node)); 52 | 53 | return new Set(tips); 54 | }; 55 | 56 | export const getApprovers = ({links, node}) => { 57 | return links 58 | .filter(link => link.target === node) 59 | .map(link => link.source); 60 | }; 61 | 62 | export const randomWalk = ({links, start}) => { 63 | let particle = start; 64 | 65 | while (!isTip({links, node: particle})) { 66 | const approvers = getApprovers({links, node: particle}); 67 | 68 | particle = choose(approvers); 69 | } 70 | 71 | return particle; 72 | }; 73 | 74 | const weightedChoose = (arr, weights) => { 75 | const sum = weights.reduce((sum, w) => sum + w, 0); 76 | const rand = Math.random() * sum; 77 | 78 | let cumSum = weights[0]; 79 | for (let i=1; i < arr.length; i++) { 80 | if (rand < cumSum) { 81 | return arr[i-1]; 82 | } 83 | cumSum += weights[i]; 84 | } 85 | 86 | return arr[arr.length-1]; 87 | }; 88 | 89 | export const weightedRandomWalk = ({nodes, links, start, alpha}) => { 90 | let particle = start; 91 | 92 | while (!isTip({links, node: particle})) { 93 | const approvers = getApprovers({links, node: particle}); 94 | 95 | const cumWeights = approvers.map(node => node.cumWeight); 96 | 97 | // normalize so maximum cumWeight is 0 98 | const maxWeight = Math.max(...cumWeights); 99 | const normalizedWeights = cumWeights.map(w => w - maxWeight); 100 | 101 | const weights = normalizedWeights.map(w => Math.exp(alpha * w)); 102 | 103 | particle = weightedChoose(approvers, weights); 104 | } 105 | 106 | return particle; 107 | }; 108 | 109 | const getChildrenLists = ({nodes, links}) => { 110 | // Initialize an empty list for each node 111 | const childrenLists = nodes.reduce((lists, node) => 112 | Object.assign(lists, {[node.name]: []}), {}); 113 | 114 | for (let link of links) { 115 | childrenLists[link.source.name].push(link.target); 116 | } 117 | 118 | return childrenLists; 119 | }; 120 | 121 | // DFS-based topological sort 122 | export const topologicalSort = ({nodes, links}) => { 123 | const childrenLists = getChildrenLists({nodes, links}); 124 | const unvisited = new Set(nodes); 125 | const result = []; 126 | 127 | const visit = node => { 128 | if (!unvisited.has(node)) { 129 | return; 130 | } 131 | 132 | for (let child of childrenLists[node.name]) { 133 | visit(child); 134 | } 135 | 136 | unvisited.delete(node); 137 | result.push(node); 138 | }; 139 | 140 | while (unvisited.size > 0) { 141 | const node = unvisited.values().next().value; 142 | 143 | visit(node); 144 | } 145 | 146 | result.reverse(); 147 | return result; 148 | }; 149 | 150 | export const calculateWeights = ({nodes, links}) => { 151 | const sorted = topologicalSort({nodes, links}); 152 | 153 | // Initialize an empty set for each node 154 | const ancestorSets = nodes.reduce((lists, node) => 155 | Object.assign(lists, {[node.name]: new Set()}), {}); 156 | 157 | const childrenLists = getChildrenLists({nodes, links}); 158 | for (let node of sorted) { 159 | for (let child of childrenLists[node.name]) { 160 | ancestorSets[child.name] = new Set([...ancestorSets[child.name], ...ancestorSets[node.name], node]); 161 | } 162 | 163 | node.cumWeight = ancestorSets[node.name].size + 1; 164 | } 165 | }; 166 | -------------------------------------------------------------------------------- /src/components/Tangle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as d3Scale from 'd3-scale'; 4 | 5 | const Axis = ({x, endX, y, startVal, endVal, ticks}) => { 6 | const tickSize = 5; 7 | 8 | const xScale = d3Scale.scaleLinear().domain([startVal, endVal]); 9 | xScale.range([x, endX]); 10 | const tickValues = xScale.ticks(ticks); 11 | 12 | return ( 13 | 14 | 17 | Time 18 | 19 | 26 | {tickValues.map(value => 27 | 35 | )} 36 | {tickValues.map(value => 37 | 42 | {value} 43 | 44 | )}} 45 | 46 | ); 47 | }; 48 | 49 | Axis.propTypes = { 50 | x: PropTypes.number.isRequired, 51 | endX: PropTypes.number.isRequired, 52 | y: PropTypes.number.isRequired, 53 | startVal: PropTypes.number.isRequired, 54 | endVal: PropTypes.number.isRequired, 55 | ticks: PropTypes.number.isRequired, 56 | }; 57 | 58 | const Marker = ({color, id, nodeRadius}) => 59 | 69 | 70 | ; 71 | 72 | Marker.propTypes = { 73 | color: PropTypes.string.isRequired, 74 | id: PropTypes.string.isRequired, 75 | nodeRadius: PropTypes.number.isRequired, 76 | }; 77 | 78 | const Node = ({nodeRadius, mouseEntersNodeHandler, mouseLeavesNodeHandler, name}) => 79 | 90 | ; 91 | 92 | Node.propTypes = { 93 | nodeRadius: PropTypes.number.isRequired, 94 | mouseEntersNodeHandler: PropTypes.any, 95 | mouseLeavesNodeHandler: PropTypes.any, 96 | name: PropTypes.string, 97 | }; 98 | 99 | const generateLinkPath = ({link, nodeRadius}) => { 100 | const arrowheadSpace = nodeRadius; 101 | 102 | const pathVector = { 103 | x: link.target.x - link.source.x, 104 | y: link.target.y - link.source.y, 105 | }; 106 | const radius = Math.sqrt(Math.pow(pathVector.x, 2) + Math.pow(pathVector.y, 2)); 107 | 108 | const scalingFactor = (radius - arrowheadSpace) / radius; 109 | const arrowX = link.source.x + scalingFactor * pathVector.x; 110 | const arrowY = link.source.y + scalingFactor * pathVector.y; 111 | 112 | return `M ${link.source.x} ${link.source.y} ` + 113 | `L ${arrowX} ${arrowY}`; 114 | }; 115 | 116 | const Tangle = props => 117 |
118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | {props.links.map(link => 126 | ${link.target.name}`} 129 | d={generateLinkPath({link, nodeRadius: props.nodeRadius})} 130 | markerEnd={props.approvedLinks.has(link) ? 'url(#arrowhead-approved)' : 131 | props.approvingLinks.has(link) ? 'url(#arrowhead-approving)' : 132 | 'url(#arrowhead)'} 133 | /> )} 134 | 135 | 136 | {props.nodes.map(node => 137 | 142 | {props.hoveredNode === node && 143 | 144 | 145 | 146 | } 147 | 152 | {props.showLabels && 157 | {node.name} 158 | } 159 | )} 160 | 161 | 162 | n.time))} 169 | /> 170 | 171 | 172 |
; 173 | 174 | Tangle.propTypes = { 175 | links: PropTypes.array.isRequired, 176 | nodes: PropTypes.array.isRequired, 177 | width: PropTypes.number.isRequired, 178 | height: PropTypes.number.isRequired, 179 | leftMargin: PropTypes.number.isRequired, 180 | rightMargin: PropTypes.number.isRequired, 181 | nodeRadius: PropTypes.number.isRequired, 182 | mouseEntersNodeHandler: PropTypes.func, 183 | mouseLeavesNodeHandler: PropTypes.func, 184 | approvedNodes: PropTypes.any, 185 | approvedLinks: PropTypes.any, 186 | approvingNodes: PropTypes.any, 187 | approvingLinks: PropTypes.any, 188 | hoveredNode: PropTypes.any, 189 | }; 190 | 191 | export default Tangle; 192 | -------------------------------------------------------------------------------- /__test__/shared/algorithms.test.js: -------------------------------------------------------------------------------- 1 | import {getDescendants, getTips, getApprovers, randomWalk, topologicalSort, calculateWeights, weightedRandomWalk, getAncestors} from '../../src/shared/algorithms'; 2 | 3 | // convert links from names to pointers 4 | const graphify = ({nodes, links}) => { 5 | for (let link of links) { 6 | link.source = nodes.find(n => n.name === link.source); 7 | link.target = nodes.find(n => n.name === link.target); 8 | } 9 | }; 10 | 11 | const initNodes = n => [...Array(n).keys()].map(i => ({name: i})); 12 | 13 | describe('Algorithms', () => { 14 | describe('getDescendants', () => { 15 | it('Returns empty set when node has no parents', () => { 16 | const nodes = initNodes(1); 17 | const links = []; 18 | 19 | const descendants = getDescendants({nodes, links, root: nodes[0]}); 20 | 21 | expect(descendants.nodes.size).toEqual(0); 22 | expect(descendants.links.size).toEqual(0); 23 | }); 24 | it('Returns only child in two node graph', () => { 25 | const nodes = initNodes(2); 26 | const links = [{source: 0, target: 1}]; 27 | graphify({nodes, links}); 28 | 29 | const descendants = getDescendants({nodes, links, root: nodes[0]}); 30 | 31 | expect(descendants.nodes.size).toEqual(1); 32 | expect(descendants.nodes.has(nodes[1])).toBeTruthy(); 33 | expect(descendants.links.size).toEqual(1); 34 | expect(descendants.links.has(links[0])).toBeTruthy(); 35 | }); 36 | it('Returns both children in a three node graph', () => { 37 | const nodes = initNodes(3); 38 | const links = [{source: 0, target: 1}, {source: 0, target: 2}]; 39 | graphify({nodes, links}); 40 | 41 | const descendants = getDescendants({nodes, links, root: nodes[0]}); 42 | 43 | expect(descendants.nodes.size).toEqual(2); 44 | expect(descendants.nodes.has(nodes[1])).toBeTruthy(); 45 | expect(descendants.nodes.has(nodes[2])).toBeTruthy(); 46 | expect(descendants.links.size).toEqual(2); 47 | expect(descendants.links.has(links[0])).toBeTruthy(); 48 | expect(descendants.links.has(links[1])).toBeTruthy(); 49 | }); 50 | it('Returns only descendants in a chain', () => { 51 | const nodes = initNodes(5); 52 | const links = [ 53 | {source: 0, target: 1}, 54 | {source: 1, target: 2}, 55 | {source: 2, target: 3}, 56 | {source: 3, target: 4}, 57 | ]; 58 | graphify({nodes, links}); 59 | 60 | const descendants = getDescendants({nodes, links, root: nodes[2]}); 61 | expect(descendants.nodes.size).toEqual(2); 62 | expect(descendants.nodes.has(nodes[3])).toBeTruthy(); 63 | expect(descendants.nodes.has(nodes[4])).toBeTruthy(); 64 | expect(descendants.links.size).toEqual(2); 65 | expect(descendants.links.has(links[2])).toBeTruthy(); 66 | expect(descendants.links.has(links[3])).toBeTruthy(); 67 | }); 68 | }); 69 | describe('getAncestors', () => { 70 | it('Returns empty set when node has no parents', () => { 71 | const nodes = initNodes(1); 72 | const links = []; 73 | 74 | const ancestors = getAncestors({nodes, links, root: nodes[0]}); 75 | 76 | expect(ancestors.nodes.size).toEqual(0); 77 | expect(ancestors.links.size).toEqual(0); 78 | }); 79 | it('Returns only parent in two node graph', () => { 80 | const nodes = initNodes(2); 81 | const links = [{source: 0, target: 1}]; 82 | graphify({nodes, links}); 83 | 84 | const descendants = getAncestors({nodes, links, root: nodes[1]}); 85 | 86 | expect(descendants.nodes.size).toEqual(1); 87 | expect(descendants.nodes.has(nodes[0])).toBeTruthy(); 88 | expect(descendants.links.size).toEqual(1); 89 | expect(descendants.links.has(links[0])).toBeTruthy(); 90 | }); 91 | it('Returns both parents in a three node graph', () => { 92 | const nodes = initNodes(3); 93 | const links = [{source: 1, target: 0}, {source: 2, target: 0}]; 94 | graphify({nodes, links}); 95 | 96 | const ancestors = getAncestors({nodes, links, root: nodes[0]}); 97 | 98 | expect(ancestors.nodes.size).toEqual(2); 99 | expect(ancestors.nodes.has(nodes[1])).toBeTruthy(); 100 | expect(ancestors.nodes.has(nodes[2])).toBeTruthy(); 101 | expect(ancestors.links.size).toEqual(2); 102 | expect(ancestors.links.has(links[0])).toBeTruthy(); 103 | expect(ancestors.links.has(links[1])).toBeTruthy(); 104 | }); 105 | it('Returns only ancestors in a chain', () => { 106 | const nodes = initNodes(5); 107 | const links = [ 108 | {source: 0, target: 1}, 109 | {source: 1, target: 2}, 110 | {source: 2, target: 3}, 111 | {source: 3, target: 4}, 112 | ]; 113 | graphify({nodes, links}); 114 | 115 | const ancestors = getAncestors({nodes, links, root: nodes[2]}); 116 | expect(ancestors.nodes.size).toEqual(2); 117 | expect(ancestors.nodes.has(nodes[0])).toBeTruthy(); 118 | expect(ancestors.nodes.has(nodes[1])).toBeTruthy(); 119 | expect(ancestors.links.size).toEqual(2); 120 | expect(ancestors.links.has(links[0])).toBeTruthy(); 121 | expect(ancestors.links.has(links[1])).toBeTruthy(); 122 | }); 123 | }); 124 | describe('getTips', () => { 125 | it('returns nothing for empty graph', () => { 126 | const nodes = []; 127 | const links = []; 128 | 129 | const tips = getTips({nodes, links}); 130 | 131 | expect(tips.size).toEqual(0); 132 | }); 133 | it('returns tip for singleton', () => { 134 | const nodes = initNodes(1); 135 | const links = []; 136 | 137 | const tips = getTips({nodes, links}); 138 | 139 | expect(tips.size).toEqual(1); 140 | }); 141 | it('returns nothing for 2-clique', () => { 142 | const nodes = initNodes(2); 143 | const links = [ 144 | {source: 0, target: 1}, 145 | {source: 1, target: 0}, 146 | ]; 147 | graphify({nodes, links}); 148 | 149 | const tips = getTips({nodes, links}); 150 | 151 | expect(tips.size).toEqual(0); 152 | }); 153 | it('returns 2 tips for 3 node graph', () => { 154 | const nodes = initNodes(3); 155 | const links = [ 156 | {source: 1, target: 0}, 157 | {source: 2, target: 0}, 158 | ]; 159 | graphify({nodes, links}); 160 | 161 | const tips = getTips({nodes, links}); 162 | 163 | expect(tips.size).toEqual(2); 164 | expect(tips.has(nodes[1])).toBeTruthy(); 165 | expect(tips.has(nodes[2])).toBeTruthy(); 166 | }); 167 | }); 168 | describe('getApprovers', () => { 169 | it('returns no approvers for 1 node graph', () => { 170 | const nodes = initNodes(1); 171 | const links = []; 172 | graphify({nodes, links}); 173 | 174 | const approvers = getApprovers({links, node: nodes[0]}); 175 | 176 | expect(approvers).toHaveLength(0); 177 | }); 178 | it('returns correct approvers in 3 node graph', () => { 179 | const nodes = initNodes(3); 180 | const links = [ 181 | {source: 1, target: 0}, 182 | {source: 2, target: 0}, 183 | ]; 184 | graphify({nodes, links}); 185 | 186 | const approvers = getApprovers({links, node: nodes[0]}); 187 | 188 | expect(approvers).toHaveLength(2); 189 | expect(approvers).toContain(nodes[1]); 190 | expect(approvers).toContain(nodes[2]); 191 | }); 192 | it('returns correct approvers in 3 node chain', () => { 193 | const nodes = initNodes(3); 194 | const links = [ 195 | {source: 1, target: 0}, 196 | {source: 2, target: 1}, 197 | ]; 198 | graphify({nodes, links}); 199 | 200 | const approvers = getApprovers({links, node: nodes[0]}); 201 | 202 | expect(approvers).toHaveLength(1); 203 | expect(approvers).toContain(nodes[1]); 204 | }); 205 | }); 206 | describe('randomWalk', () => { 207 | it('stays on branch, single possible outcome', () => { 208 | const nodes = initNodes(4); 209 | const links = [ 210 | {source: 1, target: 0}, 211 | {source: 2, target: 1}, 212 | {source: 3, target: 0}, 213 | ]; 214 | graphify({nodes, links}); 215 | 216 | const tip = randomWalk({links, start: nodes[1]}); 217 | 218 | expect(tip).toEqual(nodes[2]); 219 | }); 220 | it('stays on branch, two possible outcomes', () => { 221 | const nodes = initNodes(5); 222 | const links = [ 223 | {source: 1, target: 0}, 224 | {source: 2, target: 1}, 225 | {source: 3, target: 0}, 226 | {source: 4, target: 1}, 227 | ]; 228 | graphify({nodes, links}); 229 | 230 | const tip = randomWalk({links, start: nodes[1]}); 231 | 232 | expect([nodes[2], nodes[4]].find(x => x === tip)).toBeTruthy(); 233 | }); 234 | }); 235 | describe('topologicalSort ', () => { 236 | it('returns all vertices in disconnected graph', () => { 237 | const nodes = initNodes(10); 238 | const links = []; 239 | 240 | const ordered = topologicalSort({nodes, links}); 241 | 242 | expect(ordered).toHaveLength(10); 243 | }); 244 | it('returns all vertices in chain', () => { 245 | const nodes = initNodes(10); 246 | const links = [...Array(9).keys()].map(i => ({source: i, target: i+1})); 247 | graphify({nodes, links}); 248 | 249 | const ordered = topologicalSort({nodes, links}); 250 | 251 | expect(ordered).toHaveLength(10); 252 | ordered.forEach((node, i) => expect(ordered[i]).toEqual(nodes[i])); 253 | }); 254 | it('returns genesis last in 3 node graph', () => { 255 | const nodes = initNodes(3); 256 | const links = [ 257 | {source: 1, target: 0}, 258 | {source: 2, target: 0}, 259 | ]; 260 | graphify({nodes, links}); 261 | 262 | const ordered = topologicalSort({nodes, links}); 263 | 264 | expect(ordered).toHaveLength(3); 265 | expect(ordered[2]).toEqual(nodes[0]); 266 | }); 267 | it('returns correct first and last in 4 node diamond graph', () => { 268 | const nodes = initNodes(4); 269 | const links = [ 270 | {source: 1, target: 0}, 271 | {source: 2, target: 0}, 272 | {source: 3, target: 2}, 273 | {source: 3, target: 1}, 274 | ]; 275 | graphify({nodes, links}); 276 | 277 | const ordered = topologicalSort({nodes, links}); 278 | 279 | expect(ordered).toHaveLength(4); 280 | expect(ordered[0]).toEqual(nodes[3]); 281 | expect(ordered[3]).toEqual(nodes[0]); 282 | }); 283 | }); 284 | describe('calculateWeights', () => { 285 | it('calculates correct weights in 4 node diamond graph', () => { 286 | const nodes = initNodes(4); 287 | const links = [ 288 | {source: 1, target: 0}, 289 | {source: 2, target: 0}, 290 | {source: 3, target: 2}, 291 | {source: 3, target: 1}, 292 | ]; 293 | graphify({nodes, links}); 294 | 295 | calculateWeights({nodes, links}); 296 | 297 | expect(nodes[0].cumWeight).toEqual(4); 298 | expect(nodes[1].cumWeight).toEqual(2); 299 | expect(nodes[2].cumWeight).toEqual(2); 300 | expect(nodes[3].cumWeight).toEqual(1); 301 | }); 302 | it('calculates correct weights in a chain', () => { 303 | const nodes = initNodes(10); 304 | const links = [...Array(9).keys()].map(i => ({source: i, target: i+1})); 305 | graphify({nodes, links}); 306 | 307 | calculateWeights({nodes, links}); 308 | 309 | nodes.forEach((node, i) => expect(node.cumWeight).toEqual(i+1)); 310 | }); 311 | }); 312 | describe('weightedRandomWalk', () => { 313 | it('always goes to an extremely heavy node', () => { 314 | const nodes = initNodes(10); 315 | nodes.forEach(node => node.cumWeight = 1); 316 | nodes[6].cumWeight = 100; 317 | 318 | // All link to genesis 319 | const links = [...Array(9).keys()].map(i => ({ 320 | source: i+1, 321 | target: 0, 322 | })); 323 | graphify({nodes, links}); 324 | 325 | const tip = weightedRandomWalk({nodes, links, start: nodes[0], alpha: 1}); 326 | 327 | expect(tip).toEqual(nodes[6]); 328 | }); 329 | it('stays on branch, single possible outcome', () => { 330 | const nodes = initNodes(4); 331 | nodes.forEach(node => node.cumWeight = 1); 332 | const links = [ 333 | {source: 1, target: 0}, 334 | {source: 2, target: 1}, 335 | {source: 3, target: 0}, 336 | ]; 337 | graphify({nodes, links}); 338 | 339 | const tip = weightedRandomWalk({links, start: nodes[1]}); 340 | 341 | expect(tip).toEqual(nodes[2]); 342 | }); 343 | it('stays on branch, two possible outcomes', () => { 344 | const nodes = initNodes(5); 345 | nodes.forEach(node => node.cumWeight = 1); 346 | const links = [ 347 | {source: 1, target: 0}, 348 | {source: 2, target: 1}, 349 | {source: 3, target: 0}, 350 | {source: 4, target: 1}, 351 | ]; 352 | graphify({nodes, links}); 353 | 354 | const tip = weightedRandomWalk({links, start: nodes[1]}); 355 | 356 | expect([nodes[2], nodes[4]].find(x => x === tip)).toBeTruthy(); 357 | }); 358 | }); 359 | }); 360 | -------------------------------------------------------------------------------- /src/containers/TangleContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Tangle from '../components/Tangle'; 4 | import {connect} from 'react-redux'; 5 | import * as d3Force from 'd3-force'; 6 | import {scaleLinear} from 'd3-scale'; 7 | import {generateTangle} from '../shared/generateData'; 8 | import Slider from 'rc-slider'; 9 | import Tooltip from 'rc-tooltip'; 10 | import 'rc-slider/assets/index.css'; 11 | import 'rc-tooltip/assets/bootstrap.css'; 12 | import {getAncestors, getDescendants, getTips} from '../shared/algorithms'; 13 | import './radio-button.css'; 14 | import {uniformRandom, unWeightedMCMC, weightedMCMC} from '../shared/tip-selection'; 15 | import '../components/Tangle.css'; 16 | import SliderContainer from './SliderContainer'; 17 | 18 | const mapStateToProps = (state, ownProps) => ({}); 19 | const mapDispatchToProps = (dispatch, ownProps) => ({}); 20 | 21 | const nodeRadiusMax = 25; 22 | const nodeRadiusMin = 13; 23 | const showLabelsMinimumRadius = 21; 24 | const getNodeRadius = nodeCount => { 25 | const smallNodeCount = 20; 26 | const largeNodeCount = 100; 27 | 28 | if (nodeCount < smallNodeCount) { 29 | return nodeRadiusMax; 30 | } 31 | if (nodeCount > largeNodeCount) { 32 | return nodeRadiusMin; 33 | } 34 | const scale = scaleLinear().domain([smallNodeCount, largeNodeCount]); 35 | scale.range([nodeRadiusMax, nodeRadiusMin]); 36 | 37 | return scale(nodeCount); 38 | }; 39 | 40 | const tipSelectionDictionary = { 41 | 'UR': { 42 | algo: uniformRandom, 43 | label: 'Uniform Random', 44 | }, 45 | 'UWRW': { 46 | algo: unWeightedMCMC, 47 | label: 'Unweighted Random Walk', 48 | }, 49 | 'WRW': { 50 | algo: weightedMCMC, 51 | label: 'Weighted Random Walk', 52 | }, 53 | }; 54 | 55 | const leftMargin = 10; 56 | const rightMargin = 10; 57 | const bottomMargin = 190; 58 | 59 | const nodeCountMin = 1; 60 | const nodeCountMax = 500; 61 | const nodeCountDefault = 20; 62 | const lambdaMin = 0.1; 63 | const lambdaMax = 50; 64 | const lambdaDefault = 1.5; 65 | const alphaMin = 0; 66 | const alphaMax = 5; 67 | const alphaDefault = 0.5; 68 | 69 | const Handle = Slider.Handle; 70 | const sliderHandle = props => { 71 | const {value, dragging, index, ...restProps} = props; 72 | return ( 73 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | sliderHandle.propTypes = { 86 | value: PropTypes.number.isRequired, 87 | dragging: PropTypes.bool.isRequired, 88 | index: PropTypes.number.isRequired, 89 | }; 90 | 91 | const TipAlgorithmLabel = ({selectedAlgorithm, onChange, algoKey}) => 92 | ; 102 | 103 | TipAlgorithmLabel.propTypes = { 104 | selectedAlgorithm: PropTypes.string.isRequired, 105 | onChange: PropTypes.any, 106 | algoKey: PropTypes.string.isRequired, 107 | }; 108 | 109 | 110 | class TangleContainer extends React.Component { 111 | constructor(props) { 112 | super(); 113 | 114 | this.state = { 115 | nodes: [], 116 | links: [], 117 | nodeCount: nodeCountDefault, 118 | lambda: lambdaDefault, 119 | alpha: alphaDefault, 120 | width: 300, // default values 121 | height: 300, 122 | nodeRadius: getNodeRadius(nodeCountDefault), 123 | tipSelectionAlgorithm: 'UR', 124 | }; 125 | this.updateWindowDimensions = this.updateWindowDimensions.bind(this); 126 | 127 | this.force = d3Force.forceSimulation(); 128 | this.force.alphaDecay(0.1); 129 | 130 | this.force.on('tick', () => { 131 | this.force.nodes(this.state.nodes); 132 | 133 | // restrict nodes to window area 134 | for (let node of this.state.nodes) { 135 | node.y = Math.max(this.state.nodeRadius, Math.min(this.state.height - this.state.nodeRadius, node.y)); 136 | } 137 | 138 | this.setState({ 139 | links: this.state.links, 140 | nodes: this.state.nodes, 141 | }); 142 | }); 143 | } 144 | componentWillUnmount() { 145 | this.force.stop(); 146 | window.removeEventListener('resize', this.updateWindowDimensions); 147 | } 148 | componentDidMount() { 149 | this.startNewTangle(); 150 | this.updateWindowDimensions(); 151 | window.addEventListener('resize', this.updateWindowDimensions); 152 | } 153 | updateWindowDimensions() { 154 | this.setState({ 155 | width: window.innerWidth - leftMargin - rightMargin, 156 | height: window.innerWidth < 768 ? window.innerHeight : window.innerHeight - bottomMargin, 157 | }, () => { 158 | this.recalculateFixedPositions(); 159 | this.force 160 | .force('no_collision', d3Force.forceCollide().radius(this.state.nodeRadius * 2).strength(0.01).iterations(15)) 161 | .force('pin_y_to_center', d3Force.forceY().y(d => this.state.height / 2).strength(0.1)) 162 | .force('pin_x_to_time', d3Force.forceX().x(d => this.xFromTime(d.time)).strength(1)) 163 | .force('link', d3Force.forceLink().links(this.state.links).strength(0.5).distance(this.state.nodeRadius*3)); // strength in [0,1] 164 | 165 | this.force.restart().alpha(1); 166 | }); 167 | } 168 | startNewTangle() { 169 | const nodeRadius = getNodeRadius(this.state.nodeCount); 170 | const tangle = generateTangle({ 171 | nodeCount: this.state.nodeCount, 172 | lambda: this.state.lambda, 173 | alpha: this.state.alpha, 174 | nodeRadius, 175 | tipSelectionAlgorithm: tipSelectionDictionary[this.state.tipSelectionAlgorithm].algo, 176 | }); 177 | 178 | const {width, height} = this.state; 179 | 180 | for (let node of tangle.nodes) { 181 | node.y = height/4 + Math.random()*(height/2), 182 | node.x = width/2; // required to avoid annoying errors 183 | } 184 | 185 | this.force.stop(); 186 | 187 | this.setState({ 188 | nodes: tangle.nodes, 189 | links: tangle.links, 190 | nodeRadius, 191 | }, () => { 192 | // Set all nodes' x by time value after state has been set 193 | this.recalculateFixedPositions(); 194 | }); 195 | 196 | this.force.restart().alpha(1); 197 | } 198 | recalculateFixedPositions() { 199 | // Set genesis's y to center 200 | const genesisNode = this.state.nodes[0]; 201 | genesisNode.fx = this.setState.height / 2; 202 | 203 | for (let node of this.state.nodes) { 204 | node.fx = this.xFromTime(node.time); 205 | } 206 | } 207 | xFromTime(time) { 208 | const padding = this.state.nodeRadius; 209 | // Avoid edge cases with 0 or 1 nodes 210 | if (this.state.nodes.length < 2) { 211 | return padding; 212 | } 213 | 214 | const maxTime = this.state.nodes[this.state.nodes.length-1].time; 215 | 216 | // Rescale nodes' x to cover [margin, width-margin] 217 | const scale = scaleLinear().domain([0, maxTime]); 218 | scale.range([padding, this.state.width - padding]); 219 | 220 | return scale(time); 221 | } 222 | mouseEntersNodeHandler(e) { 223 | const name = e.target.getAttribute('name'); 224 | this.setState({ 225 | hoveredNode: this.state.nodes.find(node => node.name === name), 226 | }); 227 | } 228 | mouseLeavesNodeHandler(e) { 229 | this.setState({ 230 | hoveredNode: undefined, 231 | }); 232 | } 233 | getApprovedNodes(root) { 234 | if (!root) { 235 | return {nodes: new Set(), links: new Set()}; 236 | } 237 | 238 | return getDescendants({ 239 | nodes: this.state.nodes, 240 | links: this.state.links, 241 | root, 242 | }); 243 | } 244 | getApprovingNodes(root) { 245 | if (!root) { 246 | return {nodes: new Set(), links: new Set()}; 247 | } 248 | 249 | return getAncestors({ 250 | nodes: this.state.nodes, 251 | links: this.state.links, 252 | root, 253 | }); 254 | } 255 | handleTipSelectionRadio(event) { 256 | this.setState({ 257 | tipSelectionAlgorithm: event.target.value, 258 | }, () => { 259 | this.startNewTangle(); 260 | }); 261 | } 262 | render() { 263 | const {nodeCount,lambda,alpha, width, height} = this.state; 264 | const approved = this.getApprovedNodes(this.state.hoveredNode); 265 | const approving = this.getApprovingNodes(this.state.hoveredNode); 266 | 267 | return ( 268 |
269 |
270 |
271 |
272 |
273 |
Number of transactions
274 |
275 | { 284 | this.setState(Object.assign(this.state, {nodeCount})); 285 | this.startNewTangle(); 286 | }} /> 287 |
288 |
289 | 293 |
294 |
295 |
296 |
Transaction rate (λ)
297 |
298 | { 307 | this.setState(Object.assign(this.state, {lambda})); 308 | this.startNewTangle(); 309 | }} /> 310 |
311 |
312 | 316 |
317 |
318 |
319 |
alpha
320 |
321 | { 331 | this.setState(Object.assign(this.state, {alpha})); 332 | this.startNewTangle(); 333 | }} /> 334 |
335 |
336 | 340 |
341 |
342 |
343 | showLabelsMinimumRadius ? true : false} 362 | /> 363 |
364 | ); 365 | } 366 | } 367 | 368 | const TangleContainerConnected = connect( 369 | mapStateToProps, 370 | mapDispatchToProps 371 | )(TangleContainer); 372 | 373 | export default TangleContainerConnected; 374 | --------------------------------------------------------------------------------