├── .babelrc
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── .nvmrc
├── .storybook
├── addons.js
├── config.js
└── webpack.config.js
├── .travis.yml
├── README.md
├── package-lock.json
├── package.json
├── public
├── react-logo.png
└── sample.png
├── rollup.config.js
├── src
├── ParticleImage
│ ├── ParticleImage.stories.tsx
│ ├── ParticleImage.tsx
│ ├── createImageUniverse.ts
│ ├── index.ts
│ └── useTransientParticleForce.ts
├── index.ts
├── setupTests.ts
├── types.ts
├── universe
│ ├── Array2D.ts
│ ├── CanvasRenderer.ts
│ ├── Particle.ts
│ ├── ParticleForce.ts
│ ├── PixelManager.ts
│ ├── Renderer.ts
│ ├── Simulator.ts
│ ├── Subverse.ts
│ ├── Universe.ts
│ ├── Vector.ts
│ ├── forces.ts
│ ├── index.ts
│ └── timing.ts
└── utils.ts
├── tsconfig.declaration.json
├── tsconfig.json
├── tsconfig.test.json
├── typings
└── index.d.ts
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["react-app", { "flow": false, "typescript": true }]]
3 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser
3 | extends: [
4 | "plugin:@typescript-eslint/recommended" // Uses the recommended rules from the @typescript-eslint/eslint-plugin
5 | ],
6 | parserOptions: {
7 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
8 | sourceType: "module" // Allows for the use of imports
9 | },
10 | rules: {
11 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
12 | // e.g. "@typescript-eslint/explicit-function-return-type": "off",
13 | semi: ["error", "always"]
14 | }
15 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # See https://help.github.com/ignore-files/ for more about ignoring files.
3 |
4 | # dependencies
5 | node_modules
6 |
7 | # builds
8 | build
9 | dist
10 | declaration
11 | docs
12 | .rpt2_cache
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v10.13.0
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-knobs/register';
2 | import '@storybook/addon-actions/register';
3 | import '@storybook/addon-links/register';
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | // automatically import all files ending in *.stories.js
4 | configure(require.context('../src', true, /\.stories\.tsx?$/), module)
5 |
6 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ config }) => {
2 | config.module.rules.push({
3 | test: /\.(ts|tsx)$/,
4 | use: [
5 | {
6 | loader: require.resolve('awesome-typescript-loader'),
7 | },
8 | ],
9 | });
10 | config.resolve.extensions.push('.ts', '.tsx');
11 | return config;
12 | };
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 9
4 | - 8
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-particle-image
2 |
3 | > Render images as interactive particles
4 |
5 | [](https://www.npmjs.com/package/react-particle-image) [](https://standardjs.com)
6 |
7 | 
8 |
9 | ## Install
10 |
11 | ```bash
12 | npm install --save react-particle-image
13 | ```
14 |
15 | ## Links
16 |
17 | - [Demo](https://malerba118.github.io/react-particle-image-demo/) ([source](https://github.com/malerba118/react-particle-image-demo/blob/master/src/App.tsx))
18 | - [Docs](https://malerba118.github.io/react-particle-image/interfaces/_particleimage_particleimage_.particleimageprops.html)
19 |
20 |
21 | ## Simple Usage
22 | [codesandbox](https://codesandbox.io/s/react-particle-image-simple-ei97k)
23 | ```tsx
24 | import * as React from "react";
25 | import ParticleImage, { ParticleOptions } from "react-particle-image";
26 |
27 | const particleOptions: ParticleOptions = {
28 | filter: ({ x, y, image }) => {
29 | // Get pixel
30 | const pixel = image.get(x, y);
31 | // Make a particle for this pixel if blue > 50 (range 0-255)
32 | return pixel.b > 50;
33 | },
34 | color: ({ x, y, image }) => "#61dafb"
35 | };
36 |
37 | export default function App() {
38 | return (
39 |
46 | );
47 | }
48 | ```
49 |
50 | ## Complex Usage
51 | [codesandbox](https://codesandbox.io/s/react-particle-image-complex-pbzo9)
52 | ```tsx
53 | import * as React from "react";
54 | import useWindowSize from "@rooks/use-window-size";
55 | import ParticleImage, {
56 | ParticleOptions,
57 | Vector,
58 | forces,
59 | ParticleForce
60 | } from "react-particle-image";
61 | import "./styles.css";
62 |
63 | const particleOptions: ParticleOptions = {
64 | filter: ({ x, y, image }) => {
65 | // Get pixel
66 | const pixel = image.get(x, y);
67 | // Make a particle for this pixel if blue > 50 (range 0-255)
68 | return pixel.b > 50;
69 | },
70 | color: ({ x, y, image }) => "#61dafb",
71 | radius: () => Math.random() * 1.5 + 0.5,
72 | mass: () => 40,
73 | friction: () => 0.15,
74 | initialPosition: ({ canvasDimensions }) => {
75 | return new Vector(canvasDimensions.width / 2, canvasDimensions.height / 2);
76 | }
77 | };
78 |
79 | const motionForce = (x: number, y: number): ParticleForce => {
80 | return forces.disturbance(x, y, 5);
81 | };
82 |
83 | export default function App() {
84 | const { innerWidth, innerHeight } = useWindowSize();
85 |
86 | return (
87 |
99 | );
100 | }
101 | ```
102 |
103 | ## Performance Tips
104 | `ParticleImage` has a target frame rate of 30fps, but with thousands of particles updating positions and repainting 30 times per second, performance can be a problem.
105 |
106 | If animations are choppy try:
107 | - Reducing the number of distinct particle colors (particles of the same color will be batched while painting)
108 | - Reducing the number of particles (less than 6000 is ideal)
109 | - Reducing the resolution of the src image.
110 |
111 | Here's a [codesandbox of a good boy](https://codesandbox.io/s/react-particle-image-multicolor-dp8up) to show what I mean. Note the `round` function to reduce the number of colors painted on the canvas.
112 |
113 |
114 | MIT © [malerba118](https://github.com/malerba118)
115 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-particle-image",
3 | "version": "1.0.2",
4 | "description": "Turn images into interactive particles",
5 | "author": "malerba118",
6 | "license": "MIT",
7 | "repository": "malerba118/react-particle-image",
8 | "keywords": [
9 | "particle",
10 | "image",
11 | "react"
12 | ],
13 | "main": "dist/index.js",
14 | "module": "dist/index.es.js",
15 | "jsnext:main": "dist/index.es.js",
16 | "types": "declaration/index.d.ts",
17 | "engines": {
18 | "node": ">=8",
19 | "npm": ">=5"
20 | },
21 | "scripts": {
22 | "test": "cross-env CI=1 react-scripts-ts test --env=jsdom",
23 | "test:watch": "react-scripts-ts test --env=jsdom",
24 | "lint": "tsc --noEmit && eslint 'src/**/*.{js,ts,tsx}' --quiet --fix",
25 | "build": "rollup -c && npm run declaration",
26 | "declaration": "rm -rf declaration && tsc -p tsconfig.declaration.json",
27 | "generate-docs": "typedoc",
28 | "start": "rollup -c -w",
29 | "prepare": "yarn run build",
30 | "predeploy": "npm run generate-docs",
31 | "deploy": "gh-pages --dotfiles -d docs",
32 | "storybook": "start-storybook -s ./public -p 6006",
33 | "build-storybook": "build-storybook"
34 | },
35 | "dependencies": {
36 | "lodash.flatmap": "^4.5.0",
37 | "lodash.isequal": "^4.5.0",
38 | "lodash.throttle": "^4.1.1"
39 | },
40 | "peerDependencies": {
41 | "react": ">=16.8.0",
42 | "react-dom": ">=16.8.0"
43 | },
44 | "devDependencies": {
45 | "@babel/preset-react": "^7.7.4",
46 | "@storybook/addon-actions": "^5.2.8",
47 | "@storybook/addon-knobs": "^5.3.9",
48 | "@storybook/addon-links": "^5.2.8",
49 | "@storybook/addons": "^5.2.8",
50 | "@storybook/react": "^5.2.8",
51 | "@svgr/rollup": "^2.4.1",
52 | "@types/enzyme": "^3.10.4",
53 | "@types/enzyme-adapter-react-16": "^1.0.5",
54 | "@types/jest": "^23.1.5",
55 | "@types/lodash.flatmap": "^4.5.6",
56 | "@types/lodash.isequal": "^4.5.5",
57 | "@types/lodash.throttle": "^4.1.6",
58 | "@types/react": "^16.3.13",
59 | "@types/react-dom": "^16.0.5",
60 | "@typescript-eslint/eslint-plugin": "^2.10.0",
61 | "@typescript-eslint/parser": "^2.10.0",
62 | "awesome-typescript-loader": "^5.2.1",
63 | "babel-core": "^6.26.3",
64 | "babel-loader": "^8.0.6",
65 | "babel-preset-react-app": "^9.1.0",
66 | "babel-runtime": "^6.26.0",
67 | "cross-env": "^5.1.4",
68 | "enzyme": "^3.10.0",
69 | "enzyme-adapter-react-16": "^1.15.1",
70 | "eslint": "^6.7.2",
71 | "gh-pages": "^1.2.0",
72 | "react": "^16.8.0",
73 | "react-dom": "^16.8.0",
74 | "react-fps-stats": "^0.1.2",
75 | "react-scripts-ts": "^2.16.0",
76 | "react-use": "^13.22.2",
77 | "rollup": "^0.62.0",
78 | "rollup-plugin-babel": "^3.0.7",
79 | "rollup-plugin-commonjs": "^9.1.3",
80 | "rollup-plugin-node-resolve": "^3.3.0",
81 | "rollup-plugin-peer-deps-external": "^2.2.0",
82 | "rollup-plugin-postcss": "^1.6.2",
83 | "rollup-plugin-typescript2": "^0.17.0",
84 | "rollup-plugin-url": "^1.4.0",
85 | "typedoc": "^0.16.9",
86 | "typedoc-plugin-no-inherit": "^1.1.10",
87 | "typedoc-plugin-nojekyll": "^1.0.1",
88 | "typescript": "^3.7.3"
89 | },
90 | "files": [
91 | "dist",
92 | "declaration"
93 | ]
94 | }
95 |
--------------------------------------------------------------------------------
/public/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/malerba118/react-particle-image/ee69f73bcf73bffc365879e8774733cbcd356f2a/public/react-logo.png
--------------------------------------------------------------------------------
/public/sample.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/malerba118/react-particle-image/ee69f73bcf73bffc365879e8774733cbcd356f2a/public/sample.png
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2'
2 | import commonjs from 'rollup-plugin-commonjs'
3 | import external from 'rollup-plugin-peer-deps-external'
4 | // import postcss from 'rollup-plugin-postcss-modules'
5 | import postcss from 'rollup-plugin-postcss'
6 | import resolve from 'rollup-plugin-node-resolve'
7 | import url from 'rollup-plugin-url'
8 | import svgr from '@svgr/rollup'
9 |
10 | import pkg from './package.json'
11 |
12 | export default {
13 | input: 'src/index.ts',
14 | output: [
15 | {
16 | file: pkg.main,
17 | format: 'cjs',
18 | exports: 'named',
19 | sourcemap: true
20 | },
21 | {
22 | file: pkg.module,
23 | format: 'es',
24 | exports: 'named',
25 | sourcemap: true
26 | }
27 | ],
28 | plugins: [
29 | external(),
30 | postcss({
31 | modules: true
32 | }),
33 | url(),
34 | svgr(),
35 | resolve(),
36 | typescript({
37 | rollupCommonJSResolveHack: true,
38 | clean: true
39 | }),
40 | commonjs()
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/src/ParticleImage/ParticleImage.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import ParticleImage from './ParticleImage';
3 | import FPSStats from "react-fps-stats";
4 | import { withKnobs, number, select } from "@storybook/addon-knobs";
5 | import { action } from "@storybook/addon-actions";
6 |
7 | import { forces } from '../universe';
8 | import { ImageState } from '../types';
9 |
10 | export default {
11 | title: 'ParticleImage',
12 | decorators: [withKnobs]
13 | };
14 |
15 | const widthOptions = {
16 | range: true,
17 | min: 400,
18 | max: 1000,
19 | step: 1,
20 | };
21 |
22 | const heightOptions = {
23 | range: true,
24 | min: 400,
25 | max: 1000,
26 | step: 1,
27 | };
28 |
29 | const scaleOptions = {
30 | range: true,
31 | min: 0,
32 | max: 3,
33 | step: .1,
34 | };
35 |
36 | const entropyOptions = {
37 | range: true,
38 | min: 0,
39 | max: 100,
40 | step: 1,
41 | };
42 |
43 | const REACT_LOGO_URL = '/react-logo.png'
44 | const PORTRAIT_URL = '/sample.png'
45 |
46 | const particleOptionsMap = {
47 | [REACT_LOGO_URL]: {
48 | radius: () => 1,
49 | mass: () => 50,
50 | filter: ({x, y, image}) => {
51 | const pixel = image.get(x, y)
52 | return pixel.b > 50
53 | },
54 | color: () => '#61D9FB',
55 | friction: () => .15
56 | },
57 | [PORTRAIT_URL]: {
58 | radius: ({x, y, image}) => {
59 | const pixel = image.get(x, y)
60 | let magnitude = (pixel.r + pixel.g + pixel.b) / 3 / 255 * pixel.a/255
61 | return magnitude * 3
62 | },
63 | mass: () => 40,
64 | filter: ({x, y, image}) => {
65 | const pixel = image.get(x, y)
66 | let magnitude = (pixel.r + pixel.g + pixel.b) / 3 / 255 * pixel.a/255
67 | return magnitude > .22
68 | },
69 | color: ({x, y, image}) => 'white',
70 | friction: () => .15
71 | }
72 | }
73 |
74 | export const Playground = () => {
75 |
76 | const width = number('Width', 800, widthOptions)
77 | const height = number('Height', 400, heightOptions)
78 | const scale = number('Scale', 1, scaleOptions)
79 | const entropy = number('Entropy', 10, entropyOptions)
80 | const src = select('Source', [REACT_LOGO_URL, PORTRAIT_URL], REACT_LOGO_URL)
81 |
82 | return (
83 | <>
84 |
85 | forces.disturbance(x, y, 6)} onImageStateChange={console.log} onUniverseStateChange={console.log}/>
86 | >
87 | );
88 | };
89 |
90 | export const Defaults = () => {
91 | return (
92 |
93 | );
94 | };
95 |
96 | export const DefaultsWithFilter = () => {
97 | const particleOptions = {
98 | filter: ({x, y, image}) => {
99 | const pixel = image.get(x, y)
100 | return pixel.b > 50
101 | },
102 | }
103 |
104 | return (
105 |
106 | );
107 | };
108 |
109 | export const DefaultsWithFilterAndInteraction = () => {
110 | const particleOptions = {
111 | filter: ({x, y, image}) => {
112 | const pixel = image.get(x, y)
113 | return pixel.b > 50
114 | },
115 | }
116 |
117 | return (
118 | forces.disturbance(x, y, 4)}
122 | />
123 | );
124 | };
125 |
126 |
127 | export const MouseDownForceDuration = () => {
128 | const particleOptions = {
129 | filter: ({x, y, image}) => {
130 | const pixel = image.get(x, y)
131 | return pixel.b > 50
132 | },
133 | }
134 |
135 | return (
136 | forces.disturbance(x, y, 50)}
140 | mouseDownForceDuration={2000}
141 | />
142 | );
143 | };
144 |
145 | export const InvalidUrl = () => {
146 | const [error, setError] = useState(false)
147 |
148 | if (error) {
149 | return Error
150 | }
151 |
152 | return (
153 | {
156 | if (state === ImageState.Error) {
157 | setError(true)
158 | }
159 | }}
160 | />
161 | );
162 | };
163 |
164 |
165 |
--------------------------------------------------------------------------------
/src/ParticleImage/ParticleImage.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useEffect, useRef, useCallback, useState, HTMLProps } from 'react';
2 | import throttle from 'lodash.throttle'
3 | import { Universe, UniverseState, CanvasRenderer, Simulator, ParticleForce, Vector, forces, PixelManager, Array2D, timing } from '../universe'
4 | import { getMousePosition, getTouchPosition, RGBA } from '../utils'
5 | import createImageUniverse, { ImageUniverseSetupResult } from './createImageUniverse'
6 | import useTransientParticleForce from './useTransientParticleForce';
7 | import { Dimensions, ImageState } from '../types'
8 |
9 | export type PixelOptions = {
10 | x: number;
11 | y: number;
12 | image: Array2D
13 | }
14 |
15 | /**
16 | * Options to be applied to particles during their creation.
17 | */
18 | export interface ParticleOptions {
19 |
20 | /**
21 | * Given a pixel in the img, the filter determines whether or not a particle should be created for this pixel.
22 | * This is run for all pixels in the image in random order until maxParticles limit is reached. If the filter
23 | * returns true, a particle will be created for this pixel.
24 | */
25 | filter?: (options: PixelOptions) => boolean;
26 |
27 | /**
28 | * Given a pixel in the img, calculates the radius of the corresponding particle.
29 | * This function is only executed on pixels whose filters return true.
30 | */
31 | radius?: (options: PixelOptions) => number;
32 |
33 | /**
34 | * Given a pixel in the img, calculates the mass of the corresponding particle.
35 | * This function is only executed on pixels whose filters return true.
36 | */
37 | mass?: (options: PixelOptions) => number;
38 |
39 | /**
40 | * Given a pixel in the img, calculates the color of the corresponding particle.
41 | * Fewer colors will result in better performance (higher framerates).
42 | * This function is only executed on pixels whose filters return true.
43 | */
44 | color?: (options: PixelOptions) => string;
45 |
46 | /**
47 | * Given a pixel in the img, calculates the coefficient of kinetic friction of the corresponding particle.
48 | * This should have a value between 0 and 1.
49 | * This function is only executed on pixels whose filters return true.
50 | */
51 | friction?: (options: PixelOptions) => number;
52 |
53 | /**
54 | * Given a pixel in the img, calculates the initial position vector of the corresponding particle.
55 | * This function is only executed on pixels whose filters return true.
56 | */
57 | initialPosition?: (options: PixelOptions & {finalPosition: Vector, canvasDimensions: Dimensions}) => Vector;
58 |
59 | /**
60 | * Given a pixel in the img, calculates the initial velocity vector of the corresponding particle.
61 | * This function is only executed on pixels whose filters return true.
62 | */
63 | initialVelocity?: (options: PixelOptions) => Vector;
64 | }
65 |
66 | /**
67 | * Available props for ParticleImage.
68 | * @noInheritDoc
69 | */
70 | export interface ParticleImageProps extends HTMLProps {
71 | /**
72 | * Img src url to load image
73 | */
74 | src: string
75 |
76 | /**
77 | * Height of the canvas.
78 | */
79 | height?: number;
80 |
81 | /**
82 | * Width of the canvas.
83 | */
84 | width?: number;
85 |
86 | /**
87 | * Scales the image provided via src.
88 | */
89 | scale?: number;
90 |
91 | /**
92 | * The maximum number of particles that will be created in the canvas.
93 | */
94 | maxParticles?: number;
95 |
96 | /**
97 | * The amount of entropy to act on the particles.
98 | */
99 | entropy?: number;
100 |
101 | /**
102 | * The background color of the canvas.
103 | */
104 | backgroundColor?: string;
105 |
106 | /**
107 | * Options to be applied to particles during their creation.
108 | */
109 | particleOptions?: ParticleOptions;
110 |
111 | /**
112 | * An interactive force to be applied to the particles during mousemove events.
113 | */
114 | mouseMoveForce?: (x: number, y: number) => ParticleForce;
115 |
116 | /**
117 | * Time in milliseconds that force resulting from mousemove event should last in universe.
118 | */
119 | mouseMoveForceDuration?: number;
120 |
121 | /**
122 | * An interactive force to be applied to the particles during touchmove events.
123 | */
124 | touchMoveForce?: (x: number, y: number) => ParticleForce;
125 |
126 | /**
127 | * Time in milliseconds that force resulting from mousemove event should last in universe.
128 | */
129 | touchMoveForceDuration?: number;
130 |
131 | /**
132 | * An interactive force to be applied to the particles during mousedown events.
133 | */
134 | mouseDownForce?: (x: number, y: number) => ParticleForce;
135 |
136 | /**
137 | * Time in milliseconds that force resulting from mousemove event should last in universe.
138 | */
139 | mouseDownForceDuration?: number;
140 |
141 | /**
142 | * The duration in milliseconds that it should take for the universe to reach full health.
143 | */
144 | creationDuration?: number;
145 |
146 | /**
147 | * The duration in milliseconds that it should take for the universe to die.
148 | */
149 | deathDuration?: number;
150 |
151 | /**
152 | * A timing function to dictate how the particles in the universe grow from radius zero to their full radius.
153 | * This function receives a progress argument between 0 and 1 and should return a number between 0 and 1.
154 | */
155 | creationTimingFn?: timing.TimingFunction;
156 |
157 | /**
158 | * A timing function to dictate how the particles in the universe shrink from their full radius to radius zero.
159 | * This function receives a progress argument between 0 and 1 and should return a number between 0 and 1.
160 | */
161 | deathTimingFn?: timing.TimingFunction;
162 |
163 | /**
164 | * Callback invoked on universe state changes.
165 | */
166 | onUniverseStateChange?: (state: UniverseState, universe: Universe) => void;
167 |
168 | /**
169 | * Callback invoked on image loading state changes.
170 | */
171 | onImageStateChange?: (state: ImageState) => void;
172 | }
173 |
174 | /**
175 | * Default particle options
176 | * @internal
177 | */
178 | const defaultParticleOptions: Required = {
179 | filter: () => true,
180 | radius: () => 1,
181 | mass: () => 50,
182 | color: () => 'white',
183 | friction: () => .15,
184 | initialPosition: ({finalPosition}) => finalPosition,
185 | initialVelocity: () => new Vector(0, 0)
186 | }
187 |
188 | const ParticleImage: FC = ({
189 | src,
190 | height = 400,
191 | width = 400,
192 | scale = 1,
193 | maxParticles = 5000,
194 | entropy = 20,
195 | backgroundColor = '#222',
196 | particleOptions = {},
197 | mouseMoveForce,
198 | touchMoveForce,
199 | mouseDownForce,
200 | mouseMoveForceDuration = 100,
201 | touchMoveForceDuration = 100,
202 | mouseDownForceDuration = 100,
203 | creationTimingFn,
204 | creationDuration,
205 | deathTimingFn,
206 | deathDuration,
207 | onUniverseStateChange,
208 | onImageStateChange,
209 | style={},
210 | ...otherProps
211 | }) => {
212 |
213 | const [canvas, setCanvas] = useState()
214 | const [universe, setUniverse] = useState()
215 | const simulatorRef = useRef()
216 | const entropyForceRef = useRef()
217 | const [pixelManagers, setPixelManagers] = useState([])
218 |
219 | const mergedParticleOptions: Required = {
220 | ...defaultParticleOptions,
221 | ...particleOptions
222 | }
223 |
224 | useEffect(() => {
225 | if (canvas) {
226 | const renderer = new CanvasRenderer(canvas)
227 | const simulator = new Simulator(renderer)
228 | simulatorRef.current = simulator
229 | simulator.start()
230 | return () => simulator.stop()
231 | }
232 | }, [canvas])
233 |
234 | useEffect(() => {
235 | if (canvas) {
236 | const canvasDimensions = {
237 | width: canvas.width,
238 | height: canvas.height
239 | }
240 | const death = universe?.die()
241 | const setUp = createImageUniverse({
242 | url: src,
243 | maxParticles,
244 | particleOptions: mergedParticleOptions,
245 | scale,
246 | canvasDimensions,
247 | creationTimingFn,
248 | creationDuration,
249 | deathTimingFn,
250 | deathDuration,
251 | onUniverseStateChange
252 | })
253 | onImageStateChange?.(ImageState.Loading)
254 | setUp
255 | .then(() => {
256 | onImageStateChange?.(ImageState.Loaded)
257 | })
258 | .catch(() => {
259 | onImageStateChange?.(ImageState.Error)
260 | })
261 | Promise.all([setUp, death])
262 | .then(([{universe, pixelManagers}]) => {
263 | setPixelManagers(pixelManagers)
264 | universe.addParticleForce(forces.friction)
265 | simulatorRef.current?.setUniverse(universe)
266 | setUniverse(universe)
267 | })
268 | .catch(() => {
269 | // Eat it here, let the consumer handle it via onImageStateChange
270 | })
271 | }
272 | }, [canvas, src])
273 |
274 | useEffect(() => {
275 | universe?.setOnStateChange(onUniverseStateChange)
276 | }, [universe, onUniverseStateChange])
277 |
278 | const updateScale = useCallback(throttle((scale: number) => {
279 | pixelManagers.forEach((pixelManager) => {
280 | pixelManager.setScale(scale)
281 | })
282 | }, 50), [pixelManagers])
283 |
284 | const updateWidth = useCallback(throttle((width: number) => {
285 | pixelManagers.forEach((pixelManager) => {
286 | pixelManager.setCanvasWidth(width)
287 | })
288 | }, 50), [pixelManagers])
289 |
290 | const updateHeight = useCallback(throttle((height: number) => {
291 | pixelManagers.forEach((pixelManager) => {
292 | pixelManager.setCanvasHeight(height)
293 | })
294 | }, 50), [pixelManagers])
295 |
296 | useEffect(() => {
297 | updateScale(scale)
298 | }, [scale, updateScale])
299 |
300 | useEffect(() => {
301 | updateWidth(width)
302 | }, [width, updateWidth])
303 |
304 | useEffect(() => {
305 | updateHeight(height)
306 | }, [height, updateHeight])
307 |
308 | useEffect(() => {
309 | const entropyForce = forces.entropy(entropy)
310 | universe?.addParticleForce(entropyForce)
311 | entropyForceRef.current = entropyForce
312 | return () => {
313 | universe?.removeParticleForce(entropyForce)
314 | }
315 | }, [entropy, canvas, universe])
316 |
317 | const [mouseMoveParticleForce, setMouseMoveParticleForce] = useTransientParticleForce({universe, duration: mouseMoveForceDuration})
318 | const [touchMoveParticleForce, setTouchMoveParticleForce] = useTransientParticleForce({universe, duration: touchMoveForceDuration})
319 | const [mouseDownParticleForce, setMouseDownParticleForce] = useTransientParticleForce({universe, duration: mouseDownForceDuration})
320 |
321 | const handleMouseMove = (e) => {
322 | if (mouseMoveForce) {
323 | const position = getMousePosition(e)
324 | setMouseMoveParticleForce(() => mouseMoveForce(position.x, position.y))
325 | }
326 | otherProps.onMouseMove?.(e)
327 | }
328 |
329 | const handleTouchMove = (e) => {
330 | if (touchMoveForce) {
331 | const position = getTouchPosition(e)
332 | setTouchMoveParticleForce(() => touchMoveForce(position.x, position.y))
333 | }
334 | otherProps.onTouchMove?.(e)
335 | }
336 |
337 | const handleMouseDown = (e) => {
338 | if (mouseDownForce) {
339 | const position = getMousePosition(e)
340 | setMouseDownParticleForce(() => mouseDownForce(position.x, position.y))
341 | }
342 | otherProps.onMouseDown?.(e)
343 | }
344 |
345 | return (
346 |