├── .gitignore ├── particles-step-5.gif ├── .babelrc ├── src ├── components │ ├── Header.jsx │ ├── Particles │ │ ├── Particle.jsx │ │ └── index.jsx │ ├── Footer.jsx │ └── index.jsx ├── index.jsx └── reducers │ └── index.js ├── devServer.js ├── index.html ├── webpack.config.dev.js ├── webpack.config.prod.js ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | build/.* 3 | node_modules 4 | -------------------------------------------------------------------------------- /particles-step-5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mobxjs/react-particles-experiment/HEAD/particles-step-5.gif -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"], 3 | "plugins": ["transform-decorators-legacy"], 4 | "env": { 5 | "development": { 6 | "presets": ["react-hmre"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { PropTypes } from 'react'; 3 | 4 | const Header = ({ N }) => ( 5 |
6 |

Click or touch anywhere

7 |
8 | ); 9 | 10 | export default Header; 11 | -------------------------------------------------------------------------------- /src/components/Particles/Particle.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import {observer} from 'mobx-react'; 3 | 4 | const Particle = observer(({ particle: { x, y } }) => ( 5 | 6 | )); 7 | 8 | Particle.propTypes = { 9 | particle: PropTypes.object.isRequired 10 | }; 11 | 12 | export default Particle; 13 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import d3 from 'd3'; 5 | 6 | import {particlesStore as store} from './reducers/'; 7 | import App from './components/'; 8 | 9 | ReactDOM.render( 10 | , 11 | document.querySelectorAll('.main')[0] 12 | ); 13 | 14 | let onResize = function () { 15 | store.resizeScreen(window.innerWidth, window.innerHeight); 16 | } 17 | onResize(); 18 | 19 | d3.select(window).on('resize', onResize); 20 | -------------------------------------------------------------------------------- /src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { PropTypes } from 'react'; 3 | import {observer} from 'mobx-react'; 4 | 5 | const Footer = observer(({ particles }) => ( 6 |
7 | {particles.reduce( 8 | (sum, p) => sum + (p.inUse ? 1: 0), 0 9 | )} particles 10 |
11 | )); 12 | 13 | Footer.propTypes = { 14 | particles: PropTypes.object.isRequired 15 | }; 16 | 17 | export default Footer; 18 | -------------------------------------------------------------------------------- /src/components/Particles/index.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { PropTypes } from 'react'; 3 | import {observer} from 'mobx-react'; 4 | 5 | import Particle from './Particle'; 6 | 7 | const Particles = observer(({ particles }) => ( 8 | {particles.map(particle => 9 | 11 | )} 12 | 13 | )); 14 | 15 | Particles.propTypes = { 16 | particles: React.PropTypes.object.isRequired 17 | }; 18 | 19 | export default Particles; 20 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var express = require('express'); 3 | var webpack = require('webpack'); 4 | var config = require('./webpack.config.dev'); 5 | 6 | var app = express(); 7 | var compiler = webpack(config); 8 | 9 | app.use(require('webpack-dev-middleware')(compiler, { 10 | noInfo: true, 11 | publicPath: config.output.publicPath 12 | })); 13 | 14 | app.use(require('webpack-hot-middleware')(compiler)); 15 | 16 | app.use(express.static('public')); 17 | 18 | app.get('*', function(req, res) { 19 | res.sendFile(path.join(__dirname, 'index.html')); 20 | }); 21 | 22 | app.listen(3000, 'localhost', function(err) { 23 | if (err) { 24 | console.log(err); 25 | return; 26 | } 27 | 28 | console.log('Listening at http://localhost:3000'); 29 | }); 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | A simple particle generator experiment 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'cheap-module-eval-source-map', 6 | entry: [ 7 | 'eventsource-polyfill', // necessary for hot reloading with IE 8 | 'webpack-hot-middleware/client', 9 | './src/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 | extensions: ['', '.js', '.jsx'] 22 | }, 23 | module: { 24 | loaders: [{ 25 | test: /\.js|\.jsx$/, 26 | loaders: ['babel'], 27 | include: path.join(__dirname, 'src') 28 | }, 29 | { 30 | test: /\.less$/, 31 | loader: "style!css!less" 32 | }] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | //devtool: 'source-map', 6 | entry: [ 7 | './src/index' 8 | ], 9 | output: { 10 | path: path.join(__dirname, 'static'), 11 | filename: 'bundle.js', 12 | publicPath: '/static/' 13 | }, 14 | plugins: [ 15 | new webpack.optimize.OccurenceOrderPlugin(), 16 | new webpack.DefinePlugin({ 17 | 'process.env': { 18 | 'NODE_ENV': '"production"' 19 | } 20 | }), 21 | new webpack.optimize.UglifyJsPlugin({ 22 | compressor: { 23 | warnings: true 24 | } 25 | }) 26 | ], 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | }, 30 | module: { 31 | loaders: [{ 32 | test: /\.js|\.jsx$/, 33 | loaders: ['babel'], 34 | include: path.join(__dirname, 'src') 35 | }, 36 | { 37 | test: /\.less$/, 38 | loader: "style!css!less" 39 | }] 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Swizec Teller 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-particles-experiment", 3 | "version": "1.0.0", 4 | "description": "A particle simulation experiment with React and MobX in SVG", 5 | "main": "index.html", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js", 9 | "build": "npm run clean && npm run build:webpack", 10 | "start": "node devServer.js" 11 | }, 12 | "keywords": [ 13 | "react", 14 | "mobx", 15 | "particles", 16 | "svg", 17 | "d3js" 18 | ], 19 | "author": "Swizec Teller", 20 | "license": "MIT", 21 | "dependencies": { 22 | "d3": "^3.5.16", 23 | "mobx": "^2.0.2", 24 | "mobx-react": "^3.0.1", 25 | "react": "^0.14.7", 26 | "react-dom": "^0.14.7" 27 | }, 28 | "devDependencies": { 29 | "babel-core": "^6.5.2", 30 | "babel-loader": "^6.2.3", 31 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 32 | "babel-preset-es2015": "^6.5.0", 33 | "babel-preset-react": "^6.5.0", 34 | "babel-preset-react-hmre": "^1.1.0", 35 | "babel-preset-stage-0": "^6.5.0", 36 | "eventsource-polyfill": "^0.9.6", 37 | "express": "^4.13.4", 38 | "webpack": "^1.12.13", 39 | "webpack-dev-middleware": "^1.5.1", 40 | "webpack-hot-middleware": "^2.7.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import {observable, transaction, extendObservable} from "mobx"; 2 | 3 | import d3 from 'd3'; 4 | 5 | const Gravity = 0.5, 6 | randNormal = d3.random.normal(0.3, 2), 7 | randNormal2 = d3.random.normal(0.5, 1.8); 8 | 9 | class Particle { 10 | id; 11 | @observable x = 0; 12 | @observable y = 0; 13 | @observable vector = [0, 0]; 14 | 15 | constructor(id) { 16 | this.id = id; 17 | } 18 | } 19 | 20 | class ParticleStore { 21 | @observable particles = []; 22 | @observable particlesPerTick = 5; 23 | @observable svgWidth = 800; 24 | @observable svgHeight = 600; 25 | @observable tickerStarted = false; 26 | @observable generateParticles = false; 27 | @observable mousePos = [null, null]; 28 | particleIndex = 0; 29 | 30 | startTicker() { 31 | this.tickerStarted = true; 32 | } 33 | 34 | startParticles() { 35 | this.generateParticles = true; 36 | } 37 | 38 | stopParticles() { 39 | this.generateParticles = false; 40 | } 41 | 42 | createParticles(N, x, y) { 43 | transaction(() => { 44 | for (let i = 0; i < N; i++) { 45 | // Recycle particles. This way we can prevent creating / removing ParticleViews all the time. 46 | particle = new Particle(++this.particlesIndex); 47 | particle.x = x; 48 | particle.y = y; 49 | particle.vector = [particle.id%2 ? -randNormal() : randNormal(), 50 | -randNormal2()*3.3]; 51 | this.particles.push(particle); 52 | } 53 | }); 54 | } 55 | 56 | updateMousePos(x, y) { 57 | this.mousePos = [x, y]; 58 | } 59 | 60 | timeTick() { 61 | transaction(() => { 62 | let {svgWidth, svgHeight, particles} = this; 63 | this.particles = particles.filter(p => 64 | !(p.y > svgHeight || p.x < 0 || p.x > svgWidth) 65 | ); 66 | this.particles.forEach(p => { 67 | let [vx, vy] = p.vector; 68 | p.x += vx; 69 | p.y += vy; 70 | p.vector[1] += Gravity; 71 | }); 72 | }); 73 | } 74 | 75 | resizeScreen(width, height) { 76 | this.svgWidth = width; 77 | this.svgHeight = height; 78 | } 79 | } 80 | 81 | export const particlesStore = new ParticleStore(); -------------------------------------------------------------------------------- /src/components/index.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { PropTypes, Component } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import {observer} from 'mobx-react'; 5 | import d3 from 'd3'; 6 | 7 | import Particles from './Particles'; 8 | import Footer from './Footer'; 9 | import Header from './Header'; 10 | 11 | @observer 12 | class App extends Component { 13 | componentDidMount() { 14 | let svg = d3.select(this.refs.svg); 15 | 16 | svg.on('mousedown', () => { 17 | this.updateMousePos(); 18 | this.props.store.startParticles(); 19 | }); 20 | svg.on('touchstart', () => { 21 | this.updateTouchPos(); 22 | this.props.store.startParticles(); 23 | }); 24 | svg.on('mousemove', () => { 25 | this.updateMousePos(); 26 | }); 27 | svg.on('touchmove', () => { 28 | this.updateTouchPos(); 29 | }); 30 | svg.on('mouseup', () => { 31 | this.props.store.stopParticles(); 32 | }); 33 | svg.on('touchend', () => { 34 | this.props.store.stopParticles(); 35 | }); 36 | svg.on('mouseleave', () => { 37 | this.props.store.stopParticles(); 38 | }); 39 | 40 | } 41 | 42 | updateMousePos() { 43 | let [x, y] = d3.mouse(this.refs.svg); 44 | this.props.store.updateMousePos(x, y); 45 | } 46 | 47 | updateTouchPos() { 48 | let [x, y] = d3.touches(this.refs.svg)[0]; 49 | this.props.store.updateMousePos(x, y); 50 | } 51 | 52 | startTicker() { 53 | const { store } = this.props; 54 | 55 | let ticker = () => { 56 | if (store.tickerStarted) { 57 | this.maybeCreateParticles(); 58 | store.timeTick(); 59 | 60 | window.requestAnimationFrame(ticker); 61 | } 62 | }; 63 | 64 | if (!store.tickerStarted) { 65 | console.log("Starting ticker"); 66 | store.startTicker(); 67 | ticker(); 68 | } 69 | } 70 | 71 | maybeCreateParticles() { 72 | const { store } = this.props; 73 | const [x, y] = store.mousePos; 74 | 75 | if (store.generateParticles) { 76 | store.createParticles(store.particlesPerTick, x, y); 77 | } 78 | } 79 | 80 | render() { 81 | return ( 82 |
83 |
84 | 88 | 89 | 90 |
92 | ); 93 | } 94 | } 95 | 96 | App.propTypes = { 97 | store: PropTypes.object.isRequired 98 | }; 99 | 100 | export default App; 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MobX port of React-Particles-Experiment 2 | 3 | This fork is a port of the React-Particles-Experiment by @swizec to MobX. 4 | It's purpose is mainly to show the difference between different architectural styles. Each style has it's own branch 5 | 6 | 1. The original [Redux](https://github.com/mweststrate/react-particles-experiment/tree/redux-original) powered implementation 7 | 2. An implementation [MobX and Flux](https://github.com/mweststrate/react-particles-experiment/tree/mobx-flux) style actions and dispatchers. This branch uses mainly plain objects 8 | 3. An implementation with [MobX and MVC](https://github.com/mweststrate/react-particles-experiment/tree/master) like store. Uses classes and decorators extensively. 9 | 10 | Diffs: [Redux - MobX Flux](https://github.com/mweststrate/react-particles-experiment/compare/redux-original...mweststrate:mobx-flux) // [Redux - MobX MVC](https://github.com/mweststrate/react-particles-experiment/compare/redux-original...mweststrate:master) // [MobX Flux - MobX MVC](https://github.com/mweststrate/react-particles-experiment/compare/mobx-flux...mweststrate:master) 11 | 12 | ## Performance differences 13 | 14 | This project isn't intended to compare the performance of Redux and Mobx, it's setup is too simple to do that. But a simple test will reveal that MobX is ~20-30% slower. The reason for this is that the usage `@observer` does simplify the code of this project, but beyond that doesn't do anything for performance, because on each ticks all particles will always be re-rendered, since all of them are moving. However, if only a subset of the particles is updated per tick, MobX performs better then Redux. Roughly 30% faster if only 25% of the particles is updated. This is because MobX doesn't need to shallowly copy the `particles` list on each change and will skip the rendering of the `App(Container)` component, which needs to reinstantiate all of the `ParticleView` components in the Redux approach. The MobX Flux and MobX MVC approaches should not show significant performance differences. 15 | 16 | --------- 17 | _(original Readme)_ 18 | 19 | # Animating with React, Redux, and d3 20 | 21 | ![Gif](particles-step-5.gif) 22 | 23 | That's a particle generator. It makes tiny circles fly out of where you click. Hold down your mouse and move around. The particles keep flying out of your cursor. 24 | 25 | On mobile and only have a finger? That works, too. 26 | 27 | I'm a nerd, so this is what I consider fun. Your mileage may vary. Please do click in the embed and look at those circles fly. Ain't it cool? 28 | 29 | ## Here's how it works 30 | 31 | The whole thing is built with React, Redux, and d3. No tricks for animation; just a bit of cleverness. 32 | 33 | Here's the general approach: 34 | 35 | We use **React to render everything**: the page, the SVG element, the particles inside. All of it is built with React components that take some props and return some DOM. This lets us tap into React's algorithms that decide which nodes to update and when to garbage collect old nodes. 36 | 37 | Then we use some **d3 calculations and event detection**. D3 has great random generators, so we take advantage of that. D3's mouse and touch event handlers calculate coordinates relative to our SVG. We need those, and React can't do it. React's click handlers are based on DOM nodes, which don't correspond to `(x, y)` coordinates. D3 looks at real cursor position on screen. 38 | 39 | All **particle coordinates are in a Redux store**. Each particle also has a movement vector. The store holds some useful flags and general parameters, too. This lets us treat animation as data transformations. I'll show you what I mean in a bit. 40 | 41 | We use **actions to communicate user events** like creating particles, starting the animation, changing mouse position, and so on. On each requestAnimationFrame, we **dispatch an "advance animation" action**. 42 | 43 | On each action, the **reducer calculates a new state** for the whole app. This includes **new particle positions** for each step of the animation. 44 | 45 | When the store updates, **React flushes changes** via props and because **coordinates are state, the particles move**. 46 | 47 | The result is smooth animation. 48 | 49 | [Keep reading to learn the details](http://swizec.com/blog/animating-with-react-redux-and-d3/swizec/6775). 50 | 51 | A version of this article will be featured as a chapter in my upcoming [React+d3js ES6 book](http://swizec.com/reactd3js/). 52 | --------------------------------------------------------------------------------