Repository: Swizec/react-particles-experiment Branch: master Commit: 37ad5b2e9d8b Files: 14 Total size: 16.3 KB Directory structure: gitextract_s_pr5jvy/ ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── public/ │ ├── index.html │ └── manifest.json └── src/ ├── actions/ │ └── index.js ├── components/ │ ├── Footer.jsx │ ├── Header.jsx │ ├── Particles.jsx │ └── index.jsx ├── containers/ │ └── AppContainer.jsx ├── index.js └── reducers/ └── index.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ bower_components build/.* node_modules # See https://help.github.com/ignore-files/ for more about ignoring files. # dependencies /node_modules # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Swizec Teller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Animating with React, Redux, and d3 ![Gif](https://raw.githubusercontent.com/Swizec/react-particles-experiment/master/particles-step-5.gif) 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. On mobile and only have a finger? That works, too. 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? ## Here's how it works The whole thing is built with React, Redux, and d3. No tricks for animation; just a bit of cleverness. Here's the general approach: 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. 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. 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. 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**. On each action, the **reducer calculates a new state** for the whole app. This includes **new particle positions** for each step of the animation. When the store updates, **React flushes changes** via props and because **coordinates are state, the particles move**. The result is smooth animation. [Keep reading to learn the details](http://swizec.com/blog/animating-with-react-redux-and-d3/swizec/6775). A version of this article will be featured as a chapter in my upcoming [React+d3js ES6 book](http://swizec.com/reactd3js/). ================================================ FILE: package.json ================================================ { "name": "react-particles-experiment", "version": "0.1.0", "private": true, "dependencies": { "d3": "^5.7.0", "konva": "^2.4.2", "prop-types": "^15.6.2", "react": "^16.5.2", "react-dom": "^16.5.2", "react-konva": "^16.5.2", "react-redux": "^5.0.7", "redux": "^4.0.1" }, "devDependencies": { "react-scripts": "1.0.17" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" } } ================================================ FILE: public/index.html ================================================ React App
================================================ FILE: public/manifest.json ================================================ { "short_name": "React App", "name": "Create React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": "./index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: src/actions/index.js ================================================ export const TIME_TICK = "TIME_TICK"; export const TICKER_STARTED = "TICKER_STARTED"; export const CREATE_PARTICLES = "CREATE_PARTICLES"; export const START_PARTICLES = "START_PARTICLES"; export const STOP_PARTICLES = "STOP_PARTICLES"; export const UPDATE_MOUSE_POS = "UPDATE_MOUSE_POS"; export const RESIZE_SCREEN = "RESIZE_SCREEN"; export function tickTime() { return { type: TIME_TICK }; } export function tickerStarted() { return { type: TICKER_STARTED }; } export function startParticles() { return { type: START_PARTICLES }; } export function stopParticles() { return { type: STOP_PARTICLES }; } export function updateMousePos(x, y) { return { type: UPDATE_MOUSE_POS, x: x, y: y }; } export function resizeScreen(width, height) { return { type: RESIZE_SCREEN, width: width, height: height }; } ================================================ FILE: src/components/Footer.jsx ================================================ import React from 'react'; const Footer = ({ N, fps }) => (
{N} particles
); export default Footer; ================================================ FILE: src/components/Header.jsx ================================================ import React from 'react'; const Header = ({ N }) => (

Click or touch anywhere

); export default Header; ================================================ FILE: src/components/Particles.jsx ================================================ import React, { Component } from "react"; import { FastLayer } from "react-konva"; class Particles extends Component { _particles = {}; layerRef = React.createRef(); componentDidMount() { this.canvas = this.layerRef.current.canvas._canvas; this.canvasContext = this.canvas.getContext("2d"); this.sprite = new Image(); this.sprite.src = "http://i.imgur.com/m5l6lhr.png"; } drawParticle(particle) { let { x, y } = particle; this.canvasContext.drawImage(this.sprite, 0, 0, 128, 128, x, y, 35, 35); } componentDidUpdate() { let particles = this.props.particles; // console.time("drawing"); this.canvasContext.clearRect( 0, 0, this.canvas.width, this.canvas.height ); this.canvasContext.lineWidth = 1; this.canvasContext.strokeStyle = "black"; for (let i = 0; i < particles.length; i++) { this.drawParticle(particles[i]); } // console.timeEnd("drawing"); } render() { return ; } } export default Particles; ================================================ FILE: src/components/index.jsx ================================================ import React, { Component } from "react"; import { select as d3Select, mouse as d3Mouse, touches as d3Touches } from "d3"; import { Stage } from "react-konva"; import Particles from "./Particles"; import Footer from "./Footer"; import Header from "./Header"; class App extends Component { svgWrap = React.createRef(); componentDidMount() { let svg = d3Select(this.svgWrap.current); svg.on("mousedown", () => { this.updateMousePos(); this.props.startParticles(); }); svg.on("touchstart", () => { this.updateTouchPos(); this.props.startParticles(); }); svg.on("mousemove", () => { this.updateMousePos(); }); svg.on("touchmove", () => { this.updateTouchPos(); }); svg.on("mouseup", () => { this.props.stopParticles(); }); svg.on("touchend", () => { this.props.stopParticles(); }); svg.on("mouseleave", () => { this.props.stopParticles(); }); } updateMousePos() { let [x, y] = d3Mouse(this.svgWrap.current); this.props.updateMousePos(x, y); } updateTouchPos() { let [x, y] = d3Touches(this.svgWrap.current)[0]; this.props.updateMousePos(x, y); } render() { return (
this.props.startTicker()} style={{ overflow: "hidden" }} >
); } } export default App; ================================================ FILE: src/containers/AppContainer.jsx ================================================ import { connect } from "react-redux"; import React, { Component } from "react"; import * as d3 from "d3"; import App from "../components"; import { tickTime, tickerStarted, startParticles, stopParticles, updateMousePos } from "../actions"; class AppContainer extends Component { startTicker = () => { const { isTickerStarted } = this.props; if (!isTickerStarted) { console.log("Starting ticker"); this.props.tickerStarted(); d3.timer(this.props.tickTime); } }; render() { const { svgWidth, svgHeight, particles } = this.props; return ( ); } } const mapStateToProps = ({ generateParticles, mousePos, particlesPerTick, isTickerStarted, svgWidth, svgHeight, particles }) => ({ generateParticles, mousePos, particlesPerTick, isTickerStarted, svgWidth, svgHeight, particles }); const mapDispatchToProps = { tickTime, tickerStarted, startParticles, stopParticles, updateMousePos }; export default connect( mapStateToProps, mapDispatchToProps )(AppContainer); ================================================ FILE: src/index.js ================================================ import React from "react"; import ReactDOM from "react-dom"; import { Provider } from "react-redux"; import { createStore } from "redux"; import { select as d3Select } from "d3"; import particlesApp from "./reducers"; import AppContainer from "./containers/AppContainer"; import { resizeScreen } from "./actions"; let store = createStore(particlesApp); ReactDOM.render( , document.querySelectorAll("#root")[0] ); let onResize = function() { store.dispatch(resizeScreen(window.innerWidth, window.innerHeight)); }; onResize(); d3Select(window).on("resize", onResize); ================================================ FILE: src/reducers/index.js ================================================ import { randomNormal } from "d3"; const Gravity = 0.5, randNormal = randomNormal(0.3, 2), randNormal2 = randomNormal(0.5, 1.8); const initialState = { particles: [], particleIndex: 0, particlesPerTick: 3000, svgWidth: 800, svgHeight: 600, isTickerStarted: false, generateParticles: false, mousePos: [null, null], lastFrameTime: null }; function appReducer(state, action) { switch (action.type) { case "TICKER_STARTED": return Object.assign({}, state, { isTickerStarted: true, lastFrameTime: new Date() }); case "START_PARTICLES": return Object.assign({}, state, { generateParticles: true }); case "STOP_PARTICLES": return Object.assign({}, state, { generateParticles: false }); case "UPDATE_MOUSE_POS": return Object.assign({}, state, { mousePos: [action.x, action.y] }); case "RESIZE_SCREEN": return Object.assign({}, state, { svgWidth: action.width, svgHeight: action.height }); default: return state; } } function particlesReducer(state, action) { switch (action.type) { case "TIME_TICK": let { svgWidth, svgHeight, lastFrameTime, generateParticles, particlesPerTick, particleIndex, mousePos } = state, newFrameTime = new Date(), multiplier = (newFrameTime - lastFrameTime) / (1000 / 60), newParticles = state.particles.slice(0); if (generateParticles) { for (let i = 0; i < particlesPerTick; i++) { let particle = { id: state.particleIndex + i, x: mousePos[0], y: mousePos[1] }; particle.vector = [ particle.id % 2 ? -randNormal() : randNormal(), -randNormal2() * 3.3 ]; newParticles.unshift(particle); } particleIndex = particleIndex + particlesPerTick + 1; } let movedParticles = newParticles .filter(p => { return !(p.y > svgHeight || p.x < 0 || p.x > svgWidth); }) .map(p => { let [vx, vy] = p.vector; p.x += vx * multiplier; p.y += vy * multiplier; p.vector[1] += Gravity * multiplier; return p; }); return { particles: movedParticles, lastFrameTime: new Date(), particleIndex }; default: return { particles: state.particles, lastFrameTime: state.lastFrameTime, particleIndex: state.particleIndex }; } } // Manually combineReducers export default function(state = initialState, action) { return { ...appReducer(state, action), ...particlesReducer(state, action) }; }