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