├── .babelrc ├── .gitignore ├── .npmignore ├── package-lock.json ├── package.json ├── readme.md ├── src └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | .babelrc 3 | webpack.config.js -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-image-particles", 3 | "version": "1.0.7", 4 | "description": "Create interactive particle effects from an image.", 5 | "main": "./build/index.js", 6 | "scripts": { 7 | "build": "webpack" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/samzi123/react-image-particles/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/samzi123/react-image-particles.git" 15 | }, 16 | "keywords": [ 17 | "Particles", 18 | "Image", 19 | "Interactive", 20 | "React" 21 | ], 22 | "author": "Samuel Henderson", 23 | "license": "ISC", 24 | "dependencies": { 25 | }, 26 | "devDependencies": { 27 | "webpack-cli": "^5.1.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # react-image-particles 2 | A React component that converts any image into interactive particles. 3 | 4 | ![Example](https://instagram-caption-tool.s3.amazonaws.com/demo.gif) 5 | 6 | ## Installation 7 | Using npm: 8 | `npm install react-image-particles` 9 | 10 | Using yarn: 11 | `yarn add react-image-particles` 12 | 13 | ## Usage 14 | ```javascript 15 | import React from 'react'; 16 | import ImageToParticle from 'react-image-particles'; 17 | 18 | const App = () => { 19 | return ( 20 | 26 | ); 27 | }; 28 | 29 | export default App; 30 | ``` 31 | 32 | ## Props 33 | The `` component accepts the following props: 34 | - `path` (string) *required*: Image to apply the effect to. 35 | - `width` (number) *optional*: Width of the image canvas in pixels. 36 | - `height` (number) *optional*: Height of the image canvas in pixels. 37 | - `particleSize` (number) *optional*: Size of each particle in pixels. 38 | - `numParticles` (number) *optional*: Number of particles to use. Defaults to the number of pixels in the image. 39 | 40 | ## Author 41 | Samuel Henderson 42 | 43 | Contributions are welcome! 44 | Repo: https://github.com/samzi123/react-image-particles 45 | 46 | ## License 47 | MIT -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | 3 | export default function ImageToParticle({ path, width=200, height=200, particleSize=2, numParticles=null }) { 4 | const canvasAsRef = useRef(null); 5 | // set the radius based on the image size because the image may be resized and we want the effect to scale accordingly 6 | const mouseRadius = (width + height) / 12; 7 | // used to determine if a particle is close enough to the mouse to be affected by it 8 | const maxDistanceSquared = mouseRadius * mouseRadius; 9 | const particleSizeSquared = particleSize * particleSize; 10 | var hasLoaded = false; 11 | 12 | // use spacial partitioning grid to speed up lookup of particles close to the mouse 13 | const positionGrid = []; 14 | const positionGridRows = Math.ceil(height / mouseRadius); 15 | const positionGridCols = Math.ceil(width / mouseRadius); 16 | 17 | useEffect(() => { 18 | class GridCell { 19 | constructor() { 20 | this.particles = new Set(); 21 | } 22 | 23 | addParticle(particle) { 24 | this.particles.add(particle); 25 | } 26 | 27 | removeParticle(particle) { 28 | this.particles.delete(particle); 29 | } 30 | } 31 | 32 | for (let i = 0; i < positionGridRows; i++) { 33 | positionGrid[i] = []; 34 | for (let j = 0; j < positionGridCols; j++) { 35 | // todo: use a linked list instead of a set 36 | positionGrid[i][j] = new GridCell(); 37 | } 38 | } 39 | 40 | const canvas = canvasAsRef.current; 41 | const ctx = canvas.getContext("2d"); 42 | 43 | // whether to use the number of particles specified in NUM_PARTICLES or the number of pixels in the image 44 | const IS_NUM_PARTICLES_SET = numParticles !== null; 45 | var NUM_PARTICLES = numParticles !== null ? numParticles : 1000; 46 | 47 | // if we don't scale back the image back slightly, the particles disappear at the edges of the canvas 48 | const imageOffsetX = 0.2; 49 | const imageOffsetY = 0.2; 50 | 51 | canvas.width = width; 52 | canvas.height = height; 53 | let particleArr = []; 54 | let mouseMoved = false; 55 | 56 | let mouse = { 57 | x: null, 58 | y: null, 59 | radius: mouseRadius, 60 | } 61 | 62 | window.addEventListener('mousemove', function(event){ 63 | const rect = canvas.getBoundingClientRect(); 64 | 65 | mouse.x = event.clientX - rect.left 66 | mouse.y = event.clientY - rect.top 67 | mouseMoved = true; 68 | }); 69 | 70 | function drawImage(data) { 71 | class Particle { 72 | constructor(x, y, color, size) { 73 | this.x = x; 74 | this.y = y; 75 | this.color = color; 76 | this.size = size; 77 | this.baseX = this.x; 78 | this.baseY = this.y; 79 | this.density = (Math.random() * 30) + 1; 80 | this.positionGridRow = Math.floor(this.y / mouseRadius); 81 | this.positionGridCol = Math.floor(this.x / mouseRadius); 82 | } 83 | 84 | draw() { 85 | ctx.fillStyle = this.color; 86 | ctx.beginPath(); 87 | ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 88 | ctx.closePath(); 89 | ctx.fill(); 90 | } 91 | 92 | calculateGridPosition() { 93 | const row = Math.floor(this.y / mouseRadius); 94 | const col = Math.floor(this.x / mouseRadius); 95 | 96 | // recalculate the grid position if the particle has moved to a new cell 97 | if ((row !== this.positionGridRow || col !== this.positionGridCol)){ 98 | if (row >= 0 && row < positionGridRows && col >= 0 && col < positionGridCols) { 99 | if (this.positionGridCol !== -1 && this.positionGridRow !== -1) { 100 | positionGrid[this.positionGridRow][this.positionGridCol].removeParticle(this); 101 | } 102 | 103 | positionGrid[row][col].addParticle(this); 104 | this.positionGridRow = row; 105 | this.positionGridCol = col; 106 | } else { 107 | this.positionGridRow = -1; 108 | this.positionGridCol = -1; 109 | } 110 | } 111 | } 112 | 113 | update() { 114 | // collision detection with mouse 115 | const dx = mouse.x - this.x; 116 | const dy = mouse.y - this.y; 117 | const distanceSquared = Math.abs(dx * dx + dy * dy); 118 | 119 | // add force to particle if it is close to the mouse 120 | if (mouseMoved && distanceSquared < maxDistanceSquared + particleSizeSquared) { 121 | const forceDirectionX = dx / mouseRadius; 122 | const forceDirectionY = dy / mouseRadius; 123 | const force = 1 - (distanceSquared / maxDistanceSquared); 124 | 125 | const directionX = forceDirectionX * force * this.density; 126 | const directionY = forceDirectionY * force * this.density; 127 | 128 | this.x -= directionX; 129 | this.y -= directionY; 130 | 131 | this.calculateGridPosition(); 132 | } 133 | } 134 | 135 | // apply force to move particle back to original position 136 | applyForceBackToOriginalPosition() { 137 | this.x -= (this.x - this.baseX) / 15; 138 | this.y -= (this.y - this.baseY) / 15; 139 | this.calculateGridPosition(); 140 | } 141 | } 142 | 143 | function init() { 144 | particleArr = []; 145 | const numPixelsWithPositiveAlpha = data.data.filter((_, i) => i % 4 === 3 && data.data[i] > 128).length; 146 | NUM_PARTICLES = Math.min(NUM_PARTICLES, numPixelsWithPositiveAlpha); 147 | 148 | for (let y = 0, y2 = data.height; y < y2; y++) { 149 | for (let x = 0, x2 = data.width; x < x2; x++) { 150 | if (data.data[(y * 4 * data.width) + (x * 4) + 3] > 128) { 151 | // calculate if we wanna show this particle or not to reach the desired number of particles 152 | if (IS_NUM_PARTICLES_SET && NUM_PARTICLES < numPixelsWithPositiveAlpha && Math.random() > NUM_PARTICLES / numPixelsWithPositiveAlpha) { 153 | continue; 154 | } 155 | 156 | const positionX = (canvas.width * (imageOffsetX / 2)) + Math.floor((x / data.width) * canvas.width) * (1 - imageOffsetX); 157 | const positionY = (canvas.height * (imageOffsetY / 2)) + Math.floor((y / data.height) * canvas.height) * (1 - imageOffsetY); 158 | const index = (y * 4 * data.width) + (x * 4); 159 | 160 | const color = "rgb(" + data.data[index] + "," + data.data[index + 1] + "," + data.data[index + 2] + ")"; 161 | particleArr.push(new Particle(positionX, positionY, color, particleSize)); 162 | 163 | // add particle to spatial optimization grid 164 | const row = Math.floor(positionY / mouseRadius); 165 | const col = Math.floor(positionX / mouseRadius); 166 | 167 | positionGrid[row][col].particles.add(particleArr[particleArr.length - 1]); 168 | } 169 | } 170 | } 171 | } 172 | 173 | function animate() { 174 | requestAnimationFrame(animate); 175 | ctx.clearRect(0, 0, canvas.width, canvas.height); 176 | 177 | // only draw particles that are close to the mouse 178 | const mouseRow = Math.floor(mouse.y / mouseRadius); 179 | const mouseCol = Math.floor(mouse.x / mouseRadius); 180 | const rowColOffsets = [[0,1], [0,-1], [1,1], [1,-1], [1,0], [-1,0], [0,0], [-1,-1], [-1,1]]; 181 | 182 | // loop through all cells around the mouse and update the particles in those cells 183 | if (mouseMoved) { 184 | for (let i = 0; i < rowColOffsets.length; ++i) { 185 | const particleRow = mouseRow + rowColOffsets[i][0]; 186 | const particleCol = mouseCol + rowColOffsets[i][1]; 187 | 188 | if (particleRow < 0 || particleRow >= positionGridRows || particleCol < 0 || particleCol >= positionGridCols) 189 | continue; 190 | 191 | for (const particle of positionGrid[particleRow][particleCol].particles) { 192 | particle.update(); 193 | } 194 | } 195 | } 196 | 197 | //draw all particles 198 | for (let i = 0; i < particleArr.length; i++) { 199 | // if the particle has moved away from its original position, move it back 200 | if (particleArr[i].x !== particleArr[i].baseX || particleArr[i].y !== particleArr[i].baseY) { 201 | particleArr[i].applyForceBackToOriginalPosition(); 202 | } 203 | 204 | particleArr[i].draw(); 205 | } 206 | 207 | mouseMoved = false; 208 | } 209 | 210 | init(); 211 | animate(); 212 | } 213 | 214 | /** @param {ImageBitmap} bitmap */ 215 | function readImageData (bitmap) { 216 | const { width: w, height: h } = bitmap 217 | const _canvas = new OffscreenCanvas(w, h) 218 | const _ctx = _canvas.getContext('2d') 219 | 220 | _ctx.drawImage(bitmap, 0, 0) 221 | const imageData = _ctx.getImageData(0, 0, w, h) 222 | 223 | return imageData; 224 | } 225 | 226 | window.addEventListener('load', function() { 227 | if (hasLoaded) { 228 | return; 229 | } 230 | 231 | fetch(path) 232 | .then(r => r.blob()) 233 | .then(createImageBitmap) 234 | .then(readImageData) 235 | .then(pixels => { 236 | drawImage(pixels); 237 | }); 238 | 239 | hasLoaded = true; 240 | }); 241 | }, []); 242 | 243 | return ( 244 | 245 | ); 246 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | 3 | module.exports = { 4 | mode: "production", 5 | entry: "./src/index.js", 6 | output: { 7 | path: path.resolve("build"), 8 | filename: "index.js", 9 | libraryTarget: "commonjs2" 10 | }, 11 | module: { 12 | rules: [ 13 | { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }, 14 | { 15 | test: /\.css$/, 16 | use: ['style-loader', 'css-loader'], 17 | } 18 | ] 19 | }, 20 | externals: { 21 | react: "react" 22 | } 23 | }; --------------------------------------------------------------------------------