├── .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 | [![NPM](https://img.shields.io/npm/v/react-particle-image.svg)](https://www.npmjs.com/package/react-particle-image) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | ![react-particles-demo-3](https://user-images.githubusercontent.com/5760059/74112617-d6741a00-4b63-11ea-9757-81c55fe8e9b5.gif) 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 | { 355 | if (c?.getContext('2d')) { 356 | setCanvas(c) 357 | } 358 | }} 359 | /> 360 | ) 361 | } 362 | 363 | export default ParticleImage -------------------------------------------------------------------------------- /src/ParticleImage/createImageUniverse.ts: -------------------------------------------------------------------------------- 1 | import { ParticleOptions } from './ParticleImage' 2 | import { TimingFunction } from '../universe/timing' 3 | import { getImageData, shuffle, range } from '../utils' 4 | import { Universe, UniverseState, PixelManager, Vector, Particle } from '../universe' 5 | import { Dimensions } from '../types' 6 | 7 | export interface ImageUniverseSetupResult { 8 | universe: Universe; 9 | pixelManagers: PixelManager[]; 10 | } 11 | 12 | export interface SetupOptions { 13 | url: string; 14 | maxParticles: number; 15 | particleOptions: Required; 16 | scale: number; 17 | canvasDimensions: Dimensions; 18 | creationDuration?: number; 19 | deathDuration?: number; 20 | creationTimingFn?: TimingFunction; 21 | deathTimingFn?: TimingFunction; 22 | onUniverseStateChange?: (state: UniverseState, universe: Universe) => void 23 | } 24 | 25 | const createImageUniverse = async ({url, maxParticles, particleOptions, scale, canvasDimensions, creationTimingFn, deathTimingFn, creationDuration, deathDuration, onUniverseStateChange}: SetupOptions): Promise => { 26 | 27 | const image = await getImageData(url) 28 | const imageHeight = image.getHeight() 29 | const imageWidth = image.getWidth() 30 | let numPixels = imageHeight * imageWidth 31 | let indexArray = shuffle(range(numPixels)) 32 | let selectedPixels = 0 33 | const universe = new Universe({ 34 | creationTimingFn, 35 | deathTimingFn, 36 | creationDuration, 37 | deathDuration, 38 | onStateChange: onUniverseStateChange 39 | }) 40 | let pixelManagers: PixelManager[] = [] 41 | maxParticles = Math.min(numPixels, maxParticles) 42 | 43 | while (selectedPixels < maxParticles && indexArray.length) { 44 | const nextIndex = indexArray.pop() || 0 45 | const x = nextIndex % imageWidth 46 | const y = Math.floor(nextIndex / imageWidth) 47 | 48 | let shouldCreateParticle: boolean = particleOptions.filter({x, y, image}) 49 | 50 | if (shouldCreateParticle) { 51 | const subverse = universe.createSubverse() 52 | 53 | const pixelManager = new PixelManager({pixelX: x, pixelY: y, scale, imageHeight: image.getHeight(), imageWidth: image.getWidth(), canvasWidth: canvasDimensions.width, canvasHeight: canvasDimensions.height}) 54 | pixelManagers.push(pixelManager) 55 | subverse.addParticleForce(pixelManager.getParticleForce()) 56 | 57 | let color: string = particleOptions.color({x, y, image}) 58 | 59 | let radius: number = particleOptions.radius({x, y, image}) 60 | 61 | let friction: number = particleOptions.friction({x, y, image}) 62 | 63 | let mass: number = particleOptions.mass({x, y, image}) 64 | 65 | let position: Vector = particleOptions.initialPosition({x, y, image, finalPosition: pixelManager.getPixelPosition(), canvasDimensions}) 66 | 67 | let velocity: Vector = particleOptions.initialVelocity({x, y, image}) 68 | 69 | subverse.addParticle(new Particle({radius, mass, color, friction, position, velocity})) 70 | selectedPixels += 1 71 | } 72 | 73 | } 74 | 75 | return { universe, pixelManagers } 76 | } 77 | 78 | export default createImageUniverse -------------------------------------------------------------------------------- /src/ParticleImage/index.ts: -------------------------------------------------------------------------------- 1 | import ParticleImage, { ParticleImageProps, PixelOptions, ParticleOptions } from './ParticleImage'; 2 | import useTransientParticleForce from './useTransientParticleForce' 3 | 4 | export { ParticleImageProps, PixelOptions, ParticleOptions, useTransientParticleForce } 5 | export default ParticleImage; -------------------------------------------------------------------------------- /src/ParticleImage/useTransientParticleForce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Universe, ParticleForce } from '../universe' 3 | import { Optional, Nullable } from '../types' 4 | 5 | interface UseTransientParticleForceParams { 6 | universe: Optional; 7 | duration?: number; 8 | } 9 | 10 | type UseTransientParticleForceReturn = [ 11 | Nullable, 12 | React.Dispatch>> 13 | ] 14 | 15 | const useTransientParticleForce = ({universe, duration = 100}: UseTransientParticleForceParams): UseTransientParticleForceReturn => { 16 | const [particleForce, setParticleForce] = useState>(null) 17 | 18 | useEffect(() => { 19 | // Reset before universe change 20 | return () => { 21 | setParticleForce(null) 22 | } 23 | }, [universe]) 24 | 25 | useEffect(() => { 26 | if (universe && particleForce) { 27 | universe.addParticleForce(particleForce) 28 | const timeoutId = window.setTimeout(() => { 29 | universe.removeParticleForce(particleForce) 30 | setParticleForce(null) 31 | }, duration) 32 | return () => { 33 | window.clearTimeout(timeoutId) 34 | universe.removeParticleForce(particleForce) 35 | } 36 | } 37 | }, [universe, particleForce, duration]) 38 | 39 | return [particleForce, setParticleForce] 40 | } 41 | 42 | export default useTransientParticleForce -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ParticleImage, { ParticleImageProps, PixelOptions, ParticleOptions, useTransientParticleForce } from './ParticleImage'; 2 | export { BrowserAnimator, RGBA, getMousePosition, getTouchPosition } from './utils' 3 | export * from './universe'; 4 | export { ParticleImageProps, PixelOptions, ParticleOptions, useTransientParticleForce } 5 | export default ParticleImage; 6 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null; 2 | export type Optional = T | undefined; 3 | 4 | export type Bounds = { 5 | top: number 6 | right: number 7 | bottom: number 8 | left: number 9 | }; 10 | 11 | export type Dimensions = { 12 | width: number, 13 | height: number 14 | } 15 | 16 | export enum ImageState { 17 | Loading = 'Loading', 18 | Loaded = 'Loaded', 19 | Error = 'Error' 20 | } -------------------------------------------------------------------------------- /src/universe/Array2D.ts: -------------------------------------------------------------------------------- 1 | 2 | type SliceRange = [number, number] 3 | 4 | class Array2D { 5 | 6 | private array: Array> 7 | private width: number; 8 | private height: number; 9 | 10 | constructor(array: Array>) { 11 | this.array = array 12 | this.updateWidth() 13 | this.updateHeight() 14 | } 15 | 16 | private updateWidth(): void { 17 | this.width = Math.min(...this.array.map(row => row.length)) 18 | } 19 | 20 | private updateHeight(): void { 21 | this.height = this.array.length 22 | } 23 | 24 | getHeight(): number { 25 | return this.height 26 | } 27 | 28 | getWidth(): number { 29 | return this.width 30 | } 31 | 32 | get(x: number, y: number): T { 33 | return this.array[y][x] 34 | } 35 | 36 | set(x: number, y: number, value: T): void { 37 | if (!this.array[y]) { 38 | this.array[y] = [] 39 | } 40 | this.array[y][x] = value 41 | this.updateWidth() 42 | this.updateHeight() 43 | } 44 | 45 | slice([xMin, xMax]: SliceRange, [yMin, yMax]: SliceRange): Array2D { 46 | return new Array2D(this.array.slice(yMin, yMax).map((row) => { 47 | return row.slice(xMin, xMax) 48 | })) 49 | } 50 | 51 | 52 | forEach(callback: (item: T, x: number, y: number) => void): void { 53 | this.array.forEach((row: T[], y: number) => { 54 | row.forEach((item: T, x: number) => { 55 | callback(item, x, y) 56 | }) 57 | }) 58 | } 59 | } 60 | 61 | export default Array2D -------------------------------------------------------------------------------- /src/universe/CanvasRenderer.ts: -------------------------------------------------------------------------------- 1 | 2 | import Universe from './Universe' 3 | import Particle from './Particle' 4 | import Renderer from './Renderer' 5 | import { Nullable } from '../types' 6 | import { groupBy, TwoPI } from '../utils' 7 | 8 | class CanvasRenderer extends Renderer { 9 | 10 | canvas: HTMLCanvasElement 11 | 12 | constructor(canvas: HTMLCanvasElement) { 13 | super() 14 | this.canvas = canvas; 15 | } 16 | 17 | private context(): Nullable { 18 | return this.canvas.getContext('2d') 19 | } 20 | 21 | private height() { 22 | return this.canvas.height; 23 | } 24 | 25 | private width() { 26 | return this.canvas.width; 27 | } 28 | 29 | private clear() { 30 | this.context()?.clearRect(0, 0, this.width(), this.height()); 31 | } 32 | 33 | private drawParticles(particles: Particle[], color: string) { 34 | const context = this.context() 35 | if (context) { 36 | context.fillStyle = color; 37 | context.beginPath(); 38 | particles.forEach((particle) => { 39 | context.moveTo( particle.position.x + particle.radius, particle.position.y ); 40 | context.arc(particle.position.x, particle.position.y, particle.perceivedRadius, 0, TwoPI ); 41 | }) 42 | context.fill(); 43 | } 44 | } 45 | 46 | drawFrame(universe: Universe) { 47 | this.clear() 48 | const particles = universe.getParticles() 49 | const particlesByColor = groupBy(particles, (particle) => particle.color) 50 | Object.keys(particlesByColor).forEach((color) => { 51 | this.drawParticles(particlesByColor[color], color) 52 | }) 53 | } 54 | } 55 | 56 | export default CanvasRenderer; -------------------------------------------------------------------------------- /src/universe/Particle.ts: -------------------------------------------------------------------------------- 1 | import Vector from './Vector' 2 | 3 | class Particle { 4 | radius: number; 5 | perceivedRadius: number = 0; 6 | friction: number; 7 | mass: number; 8 | position: Vector; 9 | velocity: Vector; 10 | color: string; 11 | growthRate: number; 12 | decayRate: number; 13 | 14 | constructor({ 15 | radius = 1, 16 | friction = 10, 17 | mass = 100, 18 | position = new Vector(0, 0), 19 | velocity = new Vector(0, 0), 20 | color = 'black', 21 | growthRate = .05, 22 | decayRate = .05 23 | } = {}) { 24 | this.radius = radius 25 | this.friction = friction 26 | this.mass = mass 27 | this.position = position 28 | this.velocity = velocity 29 | this.color = color 30 | this.growthRate = growthRate 31 | this.decayRate = decayRate 32 | } 33 | } 34 | 35 | 36 | export default Particle -------------------------------------------------------------------------------- /src/universe/ParticleForce.ts: -------------------------------------------------------------------------------- 1 | import Vector from './Vector' 2 | import Particle from './Particle' 3 | 4 | type ParticleForce = (particle: Particle) => Vector 5 | 6 | export default ParticleForce -------------------------------------------------------------------------------- /src/universe/PixelManager.ts: -------------------------------------------------------------------------------- 1 | import * as forces from "./forces"; 2 | import ParticleForce from "./ParticleForce"; 3 | import Particle from "./Particle"; 4 | import Vector from "./Vector"; 5 | 6 | interface PixelManagerOptions { 7 | pixelX: number; 8 | pixelY: number; 9 | scale: number; 10 | imageWidth: number; 11 | imageHeight: number; 12 | canvasWidth: number; 13 | canvasHeight: number; 14 | } 15 | 16 | class PixelManager { 17 | 18 | pixelX: number; 19 | pixelY: number; 20 | scale: number; 21 | imageWidth: number; 22 | imageHeight: number; 23 | canvasWidth: number; 24 | canvasHeight: number; 25 | 26 | constructor(options: PixelManagerOptions) { 27 | this.pixelX = options.pixelX 28 | this.pixelY = options.pixelY 29 | this.scale = options.scale 30 | this.imageWidth = options.imageWidth 31 | this.imageHeight = options.imageHeight 32 | this.canvasWidth = options.canvasWidth 33 | this.canvasHeight = options.canvasHeight 34 | } 35 | 36 | setScale = (scale: number) => { 37 | this.scale = scale 38 | } 39 | 40 | setCanvasWidth = (width: number) => { 41 | this.canvasWidth = width 42 | } 43 | 44 | setCanvasHeight = (height: number) => { 45 | this.canvasHeight = height 46 | } 47 | 48 | getParticleForce = (): ParticleForce => (particle: Particle) => { 49 | const pixelPosition = this.getPixelPosition() 50 | return forces.blackHole(pixelPosition.x, pixelPosition.y)(particle) 51 | } 52 | 53 | getPixelPosition = (): Vector => { 54 | const x = this.pixelX * this.scale + (this.canvasWidth / 2) - (this.imageWidth * this.scale / 2); 55 | const y = this.pixelY * this.scale + (this.canvasHeight / 2) - (this.imageHeight * this.scale / 2); 56 | return new Vector(x, y) 57 | } 58 | } 59 | 60 | export default PixelManager -------------------------------------------------------------------------------- /src/universe/Renderer.ts: -------------------------------------------------------------------------------- 1 | 2 | import Universe from './Universe' 3 | 4 | abstract class Renderer { 5 | abstract drawFrame(universe: Universe): void 6 | } 7 | 8 | export default Renderer -------------------------------------------------------------------------------- /src/universe/Simulator.ts: -------------------------------------------------------------------------------- 1 | import Universe from './Universe' 2 | import Renderer from './Renderer' 3 | import { BrowserAnimator } from '../utils' 4 | import { Nullable } from '../types' 5 | 6 | export interface SimulatorOptions { 7 | frameRate?: number; 8 | } 9 | 10 | class Simulator { 11 | 12 | universe: Nullable; 13 | renderer: Renderer; 14 | animator: BrowserAnimator; 15 | id: Nullable = null; 16 | 17 | constructor(renderer: Renderer, universe: Nullable = null, { frameRate = 30 }: SimulatorOptions = {}) { 18 | this.universe = universe 19 | this.renderer = renderer 20 | this.animator = new BrowserAnimator(this.loop, frameRate) 21 | } 22 | 23 | setUniverse(universe: Nullable) { 24 | this.universe = universe 25 | } 26 | 27 | start = () => { 28 | this.animator.start() 29 | } 30 | 31 | stop = () => { 32 | this.animator.stop() 33 | } 34 | 35 | private loop = () => { 36 | if (this.universe) { 37 | this.renderer.drawFrame(this.universe) 38 | this.universe.tick() 39 | } 40 | } 41 | 42 | } 43 | 44 | export default Simulator -------------------------------------------------------------------------------- /src/universe/Subverse.ts: -------------------------------------------------------------------------------- 1 | import Particle from './Particle' 2 | import ParticleForce from './ParticleForce' 3 | import Vector from './Vector' 4 | import { Bounds, Nullable } from '../types' 5 | import flatMap from 'lodash.flatmap' 6 | 7 | export interface SubverseOptions { 8 | bounds?: Bounds 9 | } 10 | 11 | class Subverse { 12 | private particles: Particle[] = [] 13 | private particleForces: ParticleForce[] = [] 14 | private parent: Nullable 15 | private options: SubverseOptions 16 | private subverses: Subverse[] = [] 17 | 18 | constructor(parent: Nullable, options: SubverseOptions = {}) { 19 | this.parent = parent 20 | this.options = options 21 | } 22 | 23 | createSubverse(): Subverse { 24 | let subverse = new Subverse(this, this.options) 25 | this.subverses.push(subverse) 26 | return subverse 27 | } 28 | 29 | removeSubverse(subverse: Subverse) { 30 | this.subverses = this.subverses.filter(s => s !== subverse) 31 | } 32 | 33 | addParticle(particle: Particle) { 34 | this.particles.push(particle) 35 | } 36 | 37 | removeParticle(particle: Particle) { 38 | this.particles = this.particles.filter(p => p !== particle) 39 | } 40 | 41 | getParticles(): Particle[] { 42 | return this.particles.concat( 43 | flatMap( 44 | this.subverses, 45 | subverse => subverse.getParticles() 46 | ) 47 | ) 48 | } 49 | 50 | addParticleForce(particleForce: ParticleForce) { 51 | this.particleForces.push(particleForce) 52 | } 53 | 54 | removeParticleForce(particleForce: ParticleForce) { 55 | this.particleForces = this.particleForces.filter(pf => pf !== particleForce) 56 | } 57 | 58 | getParticleForces(): ParticleForce[] { 59 | if (!this.parent) { 60 | return this.particleForces 61 | } 62 | return this.parent.getParticleForces().concat(this.particleForces) 63 | } 64 | 65 | private enforceBounds(particle: Particle, bounds: Bounds) { 66 | if (particle.position.x > bounds.right) { 67 | particle.position.x = bounds.right; 68 | particle.velocity.x *= -1; 69 | } else if (particle.position.x < bounds.left) { 70 | particle.position.x = bounds.left; 71 | particle.velocity.x *= -1; 72 | } 73 | if (particle.position.y > bounds.bottom) { 74 | particle.position.y = bounds.bottom; 75 | particle.velocity.y *= -1; 76 | } else if (particle.position.y < bounds.top) { 77 | particle.position.y = bounds.top; 78 | particle.velocity.y *= -1; 79 | } 80 | } 81 | 82 | private applyForces(particle: Particle, particleForces: ParticleForce[]) { 83 | const forces: Vector[] = particleForces.map(particleForce => particleForce(particle)) 84 | const netForce: Vector = Vector.sum(forces) 85 | const acceleration: Vector = netForce.divideScalar(particle.mass) 86 | particle.position.add(particle.velocity) 87 | particle.velocity.add(acceleration) 88 | } 89 | 90 | tick() { 91 | const particleForces = this.getParticleForces() 92 | this.particles.forEach((particle) => { 93 | this.applyForces(particle, particleForces) 94 | if (this.options.bounds) { 95 | this.enforceBounds(particle, this.options.bounds) 96 | } 97 | }) 98 | this.subverses.forEach(subverse => subverse.tick()) 99 | } 100 | } 101 | 102 | export default Subverse -------------------------------------------------------------------------------- /src/universe/Universe.ts: -------------------------------------------------------------------------------- 1 | import Subverse from './Subverse' 2 | import Particle from './Particle' 3 | import { Bounds, Optional } from '../types' 4 | import timing, { TimingFunction } from './timing' 5 | 6 | export interface UniverseOptions { 7 | bounds?: Bounds; 8 | frameRate?: number; 9 | creationDuration?: number; 10 | deathDuration?: number; 11 | creationTimingFn?: TimingFunction; 12 | deathTimingFn?: TimingFunction; 13 | onStateChange?: (state: UniverseState, universe: Universe) => void 14 | } 15 | 16 | export enum UniverseState { 17 | Creating = 'Creating', 18 | Created = 'Created', 19 | Dying = 'Dying', 20 | Dead = 'Dead' 21 | } 22 | 23 | class Universe extends Subverse { 24 | 25 | private state: UniverseState 26 | private resolveDeath: (value?: any) => void 27 | private health: number = 0 28 | private frameRate: number 29 | private creationDuration: number 30 | private creationRate: number = 1 31 | private deathDuration: number 32 | private deathRate: number = 1 33 | private creationTimingFn: TimingFunction 34 | private deathTimingFn: TimingFunction 35 | private onStateChange?: (state: UniverseState, universe: Universe) => void 36 | 37 | constructor({ bounds, frameRate = 30, creationDuration = 500, deathDuration = 500, creationTimingFn = timing.easeInQuad, deathTimingFn = timing.easeInQuad, onStateChange }: UniverseOptions = {}) { 38 | super(null, { bounds }) 39 | this.setFrameRate(frameRate) 40 | this.setCreationDuration(creationDuration) 41 | this.setDeathDuration(deathDuration) 42 | this.creationTimingFn = creationTimingFn 43 | this.deathTimingFn = deathTimingFn 44 | this.onStateChange = onStateChange 45 | this.setState(UniverseState.Creating) 46 | } 47 | 48 | private setState(val: UniverseState) { 49 | this.state = val 50 | this.onStateChange?.(val, this) 51 | } 52 | 53 | private applyGrowth(particle: Particle) { 54 | particle.perceivedRadius = particle.radius * this.creationTimingFn(this.health) 55 | } 56 | 57 | private applyDecay(particle: Particle) { 58 | particle.perceivedRadius = particle.radius * this.deathTimingFn(this.health) 59 | } 60 | 61 | setCreationDuration(creationDuration: number) { 62 | this.creationDuration = creationDuration 63 | this.creationRate = 1000 / (this.creationDuration * this.frameRate) 64 | } 65 | 66 | setDeathDuration(deathDuration: number) { 67 | this.deathDuration = deathDuration 68 | this.deathRate = 1000 / (this.deathDuration * this.frameRate) 69 | } 70 | 71 | setFrameRate(frameRate: number) { 72 | this.frameRate = frameRate 73 | this.creationRate = 1000 / (this.creationDuration * this.frameRate) 74 | this.deathRate = 1000 / (this.deathDuration * this.frameRate) 75 | } 76 | 77 | setOnStateChange(onStateChange: Optional<(state: UniverseState, universe: Universe) => void>) { 78 | this.onStateChange = onStateChange 79 | } 80 | 81 | die(): Promise { 82 | this.setState(UniverseState.Dying) 83 | return new Promise((resolve) => { 84 | this.resolveDeath = resolve 85 | }) 86 | } 87 | 88 | tick() { 89 | if (this.state === UniverseState.Creating) { 90 | this.health = Math.min(this.health + this.creationRate, 1) 91 | this.getParticles().forEach((particle) => { 92 | this.applyGrowth(particle) 93 | }) 94 | if (this.health === 1) { 95 | this.setState(UniverseState.Created) 96 | } 97 | } 98 | else if (this.state === UniverseState.Dying) { 99 | this.health = Math.max(this.health - this.deathRate, 0) 100 | this.getParticles().forEach((particle) => { 101 | this.applyDecay(particle) 102 | }) 103 | if (this.health === 0) { 104 | this.setState(UniverseState.Dead) 105 | this.resolveDeath() 106 | } 107 | } 108 | super.tick() 109 | } 110 | } 111 | 112 | export default Universe -------------------------------------------------------------------------------- /src/universe/Vector.ts: -------------------------------------------------------------------------------- 1 | 2 | class Vector { 3 | 4 | x: number; 5 | y: number; 6 | 7 | constructor(x = 0, y = 0) { 8 | this.x = x; 9 | this.y = y; 10 | } 11 | 12 | add(vector: Vector): Vector { 13 | this.x += vector.x; 14 | this.y += vector.y; 15 | return this; 16 | } 17 | 18 | subtract(vector: Vector): Vector { 19 | this.x -= vector.x; 20 | this.y -= vector.y; 21 | return this; 22 | } 23 | 24 | addScalar(scalar: number): Vector { 25 | this.x += scalar; 26 | this.y += scalar; 27 | return this; 28 | } 29 | 30 | divideScalar(scalar: number): Vector { 31 | this.x = this.x / scalar; 32 | this.y = this.y / scalar; 33 | return this; 34 | } 35 | 36 | multiplyScalar(scalar: number): Vector { 37 | this.x = this.x * scalar; 38 | this.y = this.y * scalar; 39 | return this; 40 | } 41 | 42 | getMagnitude(): number { 43 | return Math.sqrt(this.x * this.x + this.y * this.y); 44 | } 45 | 46 | getAngle(): number { 47 | return Math.atan2(this.y, this.x); 48 | } 49 | 50 | clone(): Vector { 51 | return new Vector(this.x, this.y) 52 | } 53 | 54 | toUnit(): Vector { 55 | const magnitude = this.getMagnitude() 56 | if (magnitude) { 57 | return this.clone().divideScalar(magnitude) 58 | } 59 | return this.clone() 60 | } 61 | 62 | static from(angle: number, magnitude: number): Vector { 63 | return new Vector(magnitude * Math.cos(angle), magnitude * Math.sin(angle)); 64 | } 65 | 66 | static sum(vectors: Vector[]): Vector { 67 | let v = new Vector(0, 0); 68 | vectors.forEach(vector => { 69 | v.add(vector); 70 | }); 71 | return v; 72 | } 73 | } 74 | 75 | export default Vector; -------------------------------------------------------------------------------- /src/universe/forces.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Particle, ParticleForce, Vector } from '../universe' 3 | 4 | function blackHole(blackHoleX: number, blackHoleY: number, strength: number = 1): ParticleForce { 5 | return (particle: Particle) => { 6 | let blackHolePosition = new Vector(blackHoleX, blackHoleY); 7 | return blackHolePosition.subtract(particle.position).multiplyScalar(strength); 8 | }; 9 | } 10 | 11 | function disturbance(x: number, y: number, strength: number = 1): ParticleForce { 12 | return (particle: Particle) => { 13 | let holePosition = new Vector(x, y); 14 | holePosition.subtract(particle.position).multiplyScalar(-1) 15 | holePosition.divideScalar((holePosition.getMagnitude()^12)/(strength + .001) + .01) 16 | return holePosition 17 | }; 18 | } 19 | 20 | function entropy(n: number): ParticleForce { 21 | return () => { 22 | let randomForce = new Vector(Math.random() - 0.5, Math.random() - 0.5); 23 | return randomForce.multiplyScalar(n); 24 | }; 25 | } 26 | 27 | const friction: ParticleForce = (particle: Particle) => { 28 | const friction = Math.min(Math.max(particle.friction, 0), 1) 29 | if (particle.velocity.getMagnitude() === 0) { 30 | return new Vector(0, 0) 31 | } 32 | return particle.velocity.clone().multiplyScalar(-(friction * particle.mass)) 33 | }; 34 | 35 | export { 36 | blackHole, 37 | disturbance, 38 | entropy, 39 | friction 40 | } -------------------------------------------------------------------------------- /src/universe/index.ts: -------------------------------------------------------------------------------- 1 | import Universe, { UniverseState, UniverseOptions } from './Universe' 2 | import Subverse, { SubverseOptions } from './Subverse' 3 | import Particle from './Particle' 4 | import Vector from './Vector' 5 | import ParticleForce from './ParticleForce' 6 | import Renderer from './Renderer' 7 | import CanvasRenderer from './CanvasRenderer' 8 | import Simulator from './Simulator' 9 | import PixelManager from './PixelManager' 10 | import Array2D from './Array2D' 11 | import * as forces from './forces' 12 | import * as timing from './timing' 13 | 14 | export { 15 | Universe, 16 | UniverseState, 17 | UniverseOptions, 18 | Subverse, 19 | SubverseOptions, 20 | Particle, 21 | Vector, 22 | ParticleForce, 23 | Renderer, 24 | CanvasRenderer, 25 | Simulator, 26 | PixelManager, 27 | Array2D, 28 | forces, 29 | timing 30 | } 31 | -------------------------------------------------------------------------------- /src/universe/timing.ts: -------------------------------------------------------------------------------- 1 | export type TimingFunction = (t: number) => number 2 | 3 | type TimingFunctions = { 4 | [key: string]: TimingFunction 5 | } 6 | 7 | const timing: TimingFunctions = { 8 | linear: (t) => t, 9 | easeInQuad: (t) => t*t, 10 | easeOutQuad: t => t*(2-t), 11 | easeInOutQuad: t => t<.5 ? 2*t*t : -1+(4-2*t)*t 12 | } 13 | 14 | export default timing -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Array2D } from './universe' 2 | import React from 'react' 3 | 4 | export type RGBA = { 5 | r: number 6 | g: number 7 | b: number 8 | a: number 9 | } 10 | 11 | export function getImageData(src: string) { 12 | var image = new Image(); 13 | image.crossOrigin = "Anonymous"; 14 | let p = new Promise>((resolve, reject) => { 15 | image.onload = function() { 16 | let canvas = document.createElement("canvas"); 17 | canvas.width = image.width 18 | canvas.height = image.height 19 | 20 | let context = canvas.getContext("2d"); 21 | if (!context) { 22 | return reject(new Error('Could not get canvas context')) 23 | } 24 | context.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); 25 | 26 | let imageData = context.getImageData(0, 0, canvas.width, canvas.height) 27 | .data; 28 | 29 | context.clearRect(0, 0, canvas.width, canvas.height); 30 | 31 | let pixels: RGBA[][] = []; 32 | let i = 0; 33 | while (i < imageData.length - 1) { 34 | let x = (i / 4) % canvas.width; 35 | let y = Math.floor(i / 4 / canvas.width); 36 | if (!pixels[y]) { 37 | pixels[y] = [] 38 | } 39 | pixels[y][x] = { 40 | r: imageData[i], 41 | g: imageData[i + 1], 42 | b: imageData[i + 2], 43 | a: imageData[i + 3] 44 | } 45 | i += 4; 46 | } 47 | resolve(new Array2D(pixels)); 48 | }; 49 | image.onerror = reject; 50 | }); 51 | image.src = src; 52 | return p; 53 | } 54 | 55 | export const range = (n: number) => [...Array(n).keys()] 56 | 57 | export const shuffle = (array: Array) => { 58 | let currentIndex = array.length, temporaryValue, randomIndex; 59 | 60 | while (0 !== currentIndex) { 61 | 62 | randomIndex = Math.floor(Math.random() * currentIndex); 63 | currentIndex -= 1; 64 | 65 | temporaryValue = array[currentIndex]; 66 | array[currentIndex] = array[randomIndex]; 67 | array[randomIndex] = temporaryValue; 68 | } 69 | 70 | return array; 71 | } 72 | 73 | export const groupBy = (array: Array, grouper: (item: T) => string): {[key: string]: T[]} => { 74 | return array.reduce((groups, item) => { 75 | const key = grouper(item) 76 | if (!groups[key]) { 77 | groups[key] = [] 78 | } 79 | groups[key].push(item) 80 | return groups 81 | }, {}) 82 | } 83 | 84 | export const TwoPI = Math.PI * 2; 85 | 86 | export const getMousePosition = (event: React.MouseEvent) => { 87 | const canvas = event.target as HTMLCanvasElement; 88 | var rect = canvas.getBoundingClientRect(); 89 | return { 90 | x: (event.clientX - rect.left) / (rect.right - rect.left) * canvas.width, 91 | y: (event.clientY - rect.top) / (rect.bottom - rect.top) * canvas.height 92 | }; 93 | } 94 | 95 | export const getTouchPosition = (event: React.TouchEvent) => { 96 | const canvas = event.target as HTMLCanvasElement; 97 | var rect = canvas.getBoundingClientRect(); 98 | return { 99 | x: (event.touches[0].clientX - rect.left) / (rect.right - rect.left) * canvas.width, 100 | y: (event.touches[0].clientY - rect.top) / (rect.bottom - rect.top) * canvas.height 101 | }; 102 | } 103 | 104 | 105 | export class BrowserAnimator { 106 | 107 | callback: Function 108 | delay: number 109 | frame: number 110 | time: number | null 111 | rafId: number | null 112 | 113 | constructor(callback: Function, fps: number = 30) { 114 | this.delay = 1000 / fps 115 | this.time = null 116 | this.frame = -1 117 | this.callback = callback 118 | } 119 | 120 | setFps = (fps: number) => { 121 | this.delay = 1000 / fps 122 | this.time = null 123 | this.frame = -1 124 | } 125 | 126 | start = () => { 127 | if (!this.rafId) { 128 | this.rafId = requestAnimationFrame(this.loop); 129 | } 130 | } 131 | 132 | stop = () => { 133 | if (this.rafId) { 134 | cancelAnimationFrame(this.rafId); 135 | this.rafId = null 136 | this.time = null; 137 | this.frame = -1; 138 | } 139 | } 140 | 141 | private loop = (timestamp) => { 142 | if (this.time === null) { 143 | this.time = timestamp; 144 | } 145 | var seg = Math.floor((timestamp - (this.time as number)) / this.delay); // calc frame no. 146 | if (seg > this.frame) { // moved to next frame? 147 | this.frame = seg; // update 148 | this.callback({ // callback function 149 | time: timestamp, 150 | frame: this.frame 151 | }) 152 | } 153 | this.rafId = requestAnimationFrame(this.loop) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "removeComments": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "declarationDir": "declaration" 8 | } 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017", "esnext"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": false, 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": false, 14 | "noImplicitThis": true, 15 | "noImplicitAny": false, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "allowSyntheticDefaultImports": true, 21 | "skipLibCheck": true, 22 | "downlevelIteration": true, 23 | "typeRoots": ["node_modules/@types", "typings/index.d.ts"] 24 | }, 25 | "include": ["src"], 26 | "exclude": [ 27 | "node_modules", 28 | "typings", 29 | "build", 30 | "dist", 31 | "**/*.test.ts", 32 | "**/*.test.tsx", 33 | "**/*.stories.ts", 34 | "**/*.stories.tsx" 35 | ], 36 | "typedocOptions": { 37 | "out": "docs", 38 | "includes": "src", 39 | "stripInternal": true, 40 | "exclude": [ 41 | "**/*.stories.tsx", 42 | "src/ParticleImage/index.ts", 43 | "src/ParticleImage/createImageUniverse.ts", 44 | "src/ParticleImage/useTransientParticleForce.ts", 45 | "src/universe/index.ts", 46 | "src/setupTests.ts", 47 | "src/utils.ts" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-fps-stats'; --------------------------------------------------------------------------------