├── .nvmrc ├── dev ├── .gitignore ├── container.html ├── dummy.static.svg └── index.html ├── .gitignore ├── src ├── index.js ├── util │ ├── getNumberInRange.js │ ├── browsersupport.js │ ├── dpr.js │ ├── configParser.js │ └── pointOnRect.js ├── kinetics.config.json └── lib │ ├── calculateMovement.js │ ├── calculateAttractPosition.js │ ├── calculateBounderies.js │ ├── interactionHook.js │ ├── particles.js │ ├── kinetics.js │ └── particle.js ├── README.md ├── .editorconfig ├── rollup.config.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /dev/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.map -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Kinetics as defaultExport } from './lib/kinetics'; 2 | export default defaultExport; -------------------------------------------------------------------------------- /src/util/getNumberInRange.js: -------------------------------------------------------------------------------- 1 | export default (range, seed) => { 2 | seed = seed || Math.random(); 3 | const {min, max} = range; 4 | return Math.round(seed * (max - min)) + min; 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/browsersupport.js: -------------------------------------------------------------------------------- 1 | // Super simple feature test 2 | // TODO: add more browser compatibilities tests, or use external library 3 | export default () => { 4 | return !!document.querySelector && !!window.requestAnimationFrame; 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Kinetics 2 | ======== 3 | 4 | 5 | 6 | 7 | ``` 8 | 9 | 14 | ``` 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/util/dpr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cross-browser DevicePixelRatio 3 | * @return {Number} DevicePixelRatio 4 | */ 5 | export default () => { 6 | let dpr = 1; 7 | if (typeof screen !== 'undefined' && 'deviceXDPI' in screen) { 8 | dpr = screen.deviceXDPI / screen.logicalXDPI; 9 | } 10 | else if (typeof window !== 'undefined' && 'devicePixelRatio' in window) { 11 | dpr = window.devicePixelRatio; 12 | } 13 | 14 | dpr = Number(dpr.toFixed(3)); 15 | return dpr; 16 | }; 17 | -------------------------------------------------------------------------------- /src/util/configParser.js: -------------------------------------------------------------------------------- 1 | export default obj => { 2 | 3 | // Transform "random(min,max)" to a number 4 | const prefix = "random"; 5 | const regex = new RegExp(`"random\\(([0-9]*[.])?[0-9]+,([0-9]*[.])?[0-9]+\\)"`, "ig"); 6 | const toFloat = value => parseFloat(value.replace(/^\D+/g, '')); // convert contaminated string to float 7 | 8 | let str = JSON.stringify(obj); // stringify and regex, instead of interating the object+array values. both work.. 9 | str = str.replaceAll(regex, s => { 10 | const min = toFloat(s.split(',')[0]); 11 | const max = toFloat(s.split(',')[1]); 12 | return Math.random() * (max - min) + min; 13 | }); 14 | 15 | // 16 | 17 | const result = JSON.parse(str); 18 | // console.log(result); 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /src/kinetics.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": false, 3 | "spring": { 4 | "tension": 8, 5 | "friction": 10, 6 | "randomTension": 50, 7 | "randomFriction": -4, 8 | "extendedRestDelay": 10 9 | }, 10 | "canvas": { 11 | "handlePosition": true 12 | }, 13 | "particles": { 14 | "count": 16, 15 | "sides": {"min": 3, "max": 4}, 16 | "sizes": {"min": 5, "max": 50}, 17 | "rotate": {"speed": 1.5, "direction": null}, 18 | "mode": { 19 | "type": "linear", 20 | "speed": 2, 21 | "boundery": "endless" 22 | }, 23 | "parallex": { 24 | "layers": 3, 25 | "speed": 0.15 26 | }, 27 | "attract": { 28 | "chance": 0.75, 29 | "force": 1, 30 | "grow": "random(1,4)", 31 | "size": null, 32 | "type": "static", 33 | "speed": 1.5, 34 | "direction": -1, 35 | "radius": 1 36 | }, 37 | "fill": { 38 | "colors": ["#FFD166", "#EF476F", "#06D6A0", "#118AB2"], 39 | "toColors": ["#FFD166", "#EF476F", "#06D6A0", "#118AB2"], 40 | "opacity": 1 41 | }, 42 | "stroke": { 43 | "colors": [], 44 | "toColors": [], 45 | "opacity": 0, 46 | "width": [] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/calculateMovement.js: -------------------------------------------------------------------------------- 1 | export default (type, point, speed, flip, seeds) => { 2 | let {x, y} = point; 3 | 4 | switch(type) { 5 | 6 | case "wind-from-right": 7 | x = x - ((seeds.x * speed) + speed) * flip.x; 8 | break; 9 | 10 | case "wind-from-left": 11 | x = x + ((seeds.x * speed) + speed) * flip.x; 12 | break; 13 | 14 | case "linear": 15 | x = x + Math.cos(Math.PI - (Math.PI * seeds.x)) * speed * flip.x; 16 | y = y + Math.cos(Math.PI - (Math.PI * seeds.y)) * speed * flip.y; 17 | break; 18 | 19 | case "rain": 20 | const _v = ((seeds.y * speed) + speed); 21 | x = x - (_v / 2) * flip.x; 22 | y = y + _v * flip.y; 23 | break; 24 | 25 | case "wind": 26 | x = x - ((seeds.x * speed) + speed) * flip.x; 27 | y = y + (seeds.y * speed) * flip.y; 28 | break; 29 | 30 | case "party": 31 | x = x + seeds.x * speed * flip.x; 32 | y = y - seeds.y * speed * flip.y; //Math.floor(Math.random() * 2) - 1; 33 | break; 34 | 35 | case "space": 36 | // x -= speed * Math.floor(Math.random() * 2) - 1; 37 | // y += speed * Math.floor(Math.random() * 2) - 1; 38 | break; 39 | 40 | default: // 41 | throw new Error("invalid type: " + type); 42 | } 43 | 44 | return {x, y}; 45 | }; 46 | -------------------------------------------------------------------------------- /src/lib/calculateAttractPosition.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculate attraction position 3 | * @param {string} type Attraction type 4 | * @param {object} point {x,y} 5 | * @param {object} center {x,y} 6 | * @param {float} angle see: particle.getAngle() 7 | * @param {float} radius Attraction radius 8 | * @return {object} {x,y} point 9 | */ 10 | export default (type, point, center, angle, radius) => { 11 | 12 | let {x, y} = point; 13 | const {x: cx, y: cy} = center; 14 | const rx = (x - cx) * radius; 15 | const ry = (y - cy) * radius; 16 | const cos = Math.cos(angle); 17 | const sin = Math.sin(angle); 18 | 19 | switch (type) { 20 | case "": 21 | case "static": 22 | // (nothing) 23 | break; 24 | 25 | case "drone": 26 | x = cx + cos * Math.abs(rx) - sin * (ry); 27 | y = cy + sin * Math.abs(ry) + cos * (ry); 28 | break; 29 | 30 | case "horz": 31 | x = cos * rx + cx; 32 | break; 33 | 34 | case "vert": 35 | y = sin * ry + cy; 36 | break; 37 | 38 | case "orbit": 39 | x = cx + cos * Math.abs(rx) - sin * Math.abs(ry); 40 | y = cy + sin * Math.abs(rx) + cos * Math.abs(ry); 41 | break; 42 | 43 | case "bee": 44 | x = cx + Math.abs(rx) / 2 * cos * sin; 45 | y = cy + Math.abs(ry) * cos; 46 | break; 47 | 48 | case "swing": 49 | x = cx + sin * rx; 50 | y = cy + sin * ry; 51 | break; 52 | 53 | default: // 54 | throw new Error("invalid type: " + type); 55 | } 56 | 57 | return {x, y}; 58 | }; 59 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import babel from '@rollup/plugin-babel'; 4 | import { terser } from "rollup-plugin-terser"; 5 | import serve from 'rollup-plugin-serve' 6 | import livereload from 'rollup-plugin-livereload' 7 | import json from '@rollup/plugin-json'; 8 | 9 | import pkg from './package.json'; 10 | const production = (process.env.BUILD == 'production'); 11 | const buildDir = production ? 'dist' : 'dev'; 12 | const transformBuildDir = str => str.replace('dist', buildDir); 13 | 14 | export default [ 15 | // browser-friendly UMD build 16 | { 17 | input: 'src/index.js', 18 | output: { 19 | name: 'Kinetics', 20 | file: transformBuildDir(pkg.browser), 21 | // sourcemap: true, 22 | format: 'umd', 23 | // globals: { 24 | // rebound: 'rebound', 25 | // }, 26 | }, 27 | // external: ['rebound'], 28 | 29 | plugins: [ 30 | resolve(), // so Rollup can find `dependencies` 31 | json(), 32 | commonjs({ 33 | // namedExports: { 'rebound': [ 'rebound' ]} 34 | }), // so Rollup can convert `ms` to an ES module 35 | babel({ 36 | // plugins: ['external-helpers'], 37 | exclude: 'node_modules/**', 38 | }), 39 | production && terser(), // minify, but only in production 40 | !production && serve({ 41 | open: false, 42 | openPage: `/${buildDir}/index.html`, 43 | contentBase: ".", 44 | host: "0.0.0.0", //"localhost", 45 | port: 3000 46 | }), 47 | !production && livereload(buildDir) 48 | ] 49 | } 50 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@drum-n-bass/kinetics", 3 | "version": "0.9.0", 4 | "description": "Kinetics, attractive particles system", 5 | "keywords": [ 6 | "particles", 7 | "animation", 8 | "canvas", 9 | "attraction", 10 | "visual" 11 | ], 12 | "main": "index.js", 13 | "browser": "dist/kinetics.js", 14 | "unpkg": "dist/kinetics.js", 15 | "files": [ "dist" ], 16 | "scripts": { 17 | "test": "echo \"*** TODO TESTS ***\"", 18 | "clean": "rm -rf dist", 19 | "build": "rollup -c --environment BUILD:production", 20 | "dev": "rollup -c -w --sourcemap", 21 | "prepublishOnly": "npm run clean && npm run test && npm run build" 22 | }, 23 | "author": "drum-n-bass", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/drum-n-bass/kinetics.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/drum-n-bass/kinetics/issues" 30 | }, 31 | "homepage": "https://kinetics.li", 32 | "contributors": [ 33 | { 34 | "name": "Richard Schumann", 35 | "url": "https://schumanncombo.com" 36 | }, 37 | { 38 | "name": "Oori Shalev | Pluggable", 39 | "url": "https://github.com/oori" 40 | } 41 | ], 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@rollup/plugin-babel": "^5.3.0", 45 | "@rollup/plugin-commonjs": "^19.0.0", 46 | "@rollup/plugin-json": "^4.1.0", 47 | "@rollup/plugin-node-resolve": "^13.0.0", 48 | "rollup": "^2.50.3", 49 | "rollup-plugin-livereload": "^2.0.0", 50 | "rollup-plugin-serve": "^1.1.0", 51 | "rollup-plugin-terser": "^7.0.2" 52 | }, 53 | "dependencies": { 54 | "deepmerge": "^4.2.2", 55 | "onscrolling": "^1.0.0", 56 | "rebound": "^0.1.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/calculateBounderies.js: -------------------------------------------------------------------------------- 1 | export default (mode, point, maxsize, flip, bounderyWidth, bounderyHeight) => { 2 | let {x, y} = point; 3 | let gap = maxsize; 4 | if (mode === "pong") gap /= 2; // on pong, we want it to bounce from the center of the shape, approx divide by two. 5 | // This needs to be dynamic. based on calculated shape (`vertices`) + stroke + grow? 6 | // Can results in ugly "edge disapear/popping" bug, visible on pointy shapes with large stroke width, and "wind" mode. 7 | // Temp solution can be to add manual param `gap = maxsize * bounderyGapFactor` ? 8 | 9 | 10 | 11 | let outside = ""; 12 | if (x > (bounderyWidth + gap)) outside = "right"; 13 | if (x < -gap) outside = "left"; 14 | if (y > (bounderyHeight + gap)) outside = "bottom"; 15 | if (y < -gap) outside = "top"; 16 | 17 | if (outside) switch(mode) { 18 | case "endless": 19 | switch(outside) { 20 | case "left": 21 | x = bounderyWidth + gap; 22 | break; 23 | case "right": 24 | x = -gap; 25 | break; 26 | case "bottom": 27 | y = -gap; 28 | break; 29 | case "top": 30 | y = bounderyHeight + gap; 31 | break; 32 | } 33 | break; 34 | 35 | 36 | case "pong": 37 | switch(outside) { 38 | case "left": 39 | x = -gap; 40 | flip.x *= -1; 41 | break; 42 | case "right": 43 | x = bounderyWidth + gap; 44 | flip.x *= -1; 45 | break; 46 | case "bottom": 47 | y = bounderyHeight + gap; 48 | flip.y *= -1; 49 | break; 50 | case "top": 51 | y = -gap; 52 | flip.y *= -1; 53 | break; 54 | } 55 | break; 56 | 57 | case "emitter": 58 | x = bounderyWidth / 2; 59 | y = bounderyHeight / 2; 60 | break; 61 | 62 | default: // 63 | throw new Error("invalid mode: " + mode); 64 | } 65 | 66 | return {x, y}; 67 | } 68 | -------------------------------------------------------------------------------- /dev/container.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Kinetics Dev 5 | 6 | 7 | 15 | 16 | 17 | 18 |
19 | pause 20 |
21 | 22 | 23 |

















24 |
Lorem...
25 |

















26 |
Lorem...
27 | 28 |
29 |
30 | 31 |

Yo 1!

32 | 33 | 34 |
35 |
36 | 37 |

















38 |
Lorem...
39 |

















40 |
Lorem...
41 |

















42 |
Lorem...
43 |

















44 |
Lorem...
45 |

















46 |
Kinetics is sure paused by now...
47 | 48 | 49 | 50 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/util/pointOnRect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * See: https://stackoverflow.com/a/31254199 3 | * 4 | * Finds the intersection point between the rectangle 5 | * with parallel sides to the x and y axes 6 | * the half-line pointing towards (x,y) 7 | * originating from the middle of the rectangle 8 | * 9 | * Note: the function works given min[XY] <= max[XY], 10 | * even though minY may not be the "top" of the rectangle 11 | * because the coordinate system is flipped. 12 | * Note: if the input is inside the rectangle, 13 | * the line segment wouldn't have an intersection with the rectangle, 14 | * but the projected half-line does. 15 | * Warning: passing in the middle of the rectangle will return the midpoint itself 16 | * there are infinitely many half-lines projected in all directions, 17 | * so let's just shortcut to midpoint (GIGO). 18 | * 19 | * @param x:Number x coordinate of point to build the half-line from 20 | * @param y:Number y coordinate of point to build the half-line from 21 | * @param minX:Number the "left" side of the rectangle 22 | * @param minY:Number the "top" side of the rectangle 23 | * @param maxX:Number the "right" side of the rectangle 24 | * @param maxY:Number the "bottom" side of the rectangle 25 | * @param validate:boolean (optional) whether to treat point inside the rect as error 26 | * @return an object with x and y members for the intersection 27 | * @throws if validate == true and (x,y) is inside the rectangle 28 | * @author TWiStErRob 29 | * @see source 30 | * @see based on 31 | */ 32 | export default (x, y, minX, minY, maxX, maxY, validate) => { 33 | //assert minX <= maxX; 34 | //assert minY <= maxY; 35 | if (validate && (minX < x && x < maxX) && (minY < y && y < maxY)) 36 | throw "Point " + [x,y] + "cannot be inside " 37 | + "the rectangle: " + [minX, minY] + " - " + [maxX, maxY] + "."; 38 | var midX = (minX + maxX) / 2; 39 | var midY = (minY + maxY) / 2; 40 | // if (midX - x == 0) -> m == ±Inf -> minYx/maxYx == x (because value / ±Inf = ±0) 41 | var m = (midY - y) / (midX - x); 42 | 43 | if (x <= midX) { // check "left" side 44 | var minXy = m * (minX - x) + y; 45 | if (minY <= minXy && minXy <= maxY) 46 | return {x: minX, y: minXy}; 47 | } 48 | 49 | if (x >= midX) { // check "right" side 50 | var maxXy = m * (maxX - x) + y; 51 | if (minY <= maxXy && maxXy <= maxY) 52 | return {x: maxX, y: maxXy}; 53 | } 54 | 55 | if (y <= midY) { // check "top" side 56 | var minYx = (minY - y) / m + x; 57 | if (minX <= minYx && minYx <= maxX) 58 | return {x: minYx, y: minY}; 59 | } 60 | 61 | if (y >= midY) { // check "bottom" side 62 | var maxYx = (maxY - y) / m + x; 63 | if (minX <= maxYx && maxYx <= maxX) 64 | return {x: maxYx, y: maxY}; 65 | } 66 | 67 | // edge case when finding midpoint intersection: m = 0/0 = NaN 68 | if (x === midX && y === midY) return {x: x, y: y}; 69 | 70 | 71 | // *** TODO: REMOVE THIS! *** 72 | // it's a bypass for kinetics.container (see v0.7.5), to prevent throwing an error on "negative" scroll. 73 | return {x: midX, y: midY}; 74 | 75 | // Should never happen :) If it does, please tell me! 76 | throw "Cannot find intersection for " + [x,y] 77 | + " inside rectangle " + [minX, minY] + " - " + [maxX, maxY] + "."; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /dev/dummy.static.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/interactionHook.js: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge'; 2 | 3 | export default (kinetics, config = {}, scope = document) => { 4 | // if (typeof scope === 'undefined') scope = document; 5 | 6 | const defaultConfig = { 7 | prefix: "kinetics", 8 | attraction: { 9 | keyword: "attraction" 10 | }, 11 | intersection: { 12 | threshold: 0.2, 13 | keyword: "mode" 14 | } 15 | }; 16 | 17 | // Handle kinetics data attributes instructions, and hook events 18 | // ============================================================= 19 | const { prefix, attraction, intersection } = merge(defaultConfig, config); 20 | 21 | scope.querySelectorAll(`[data-${prefix}-${attraction.keyword}]`).forEach(element => { 22 | const props = getProps(element, prefix, attraction.keyword); 23 | // const touchOptions = isPassiveSupported() ? { passive: true } : false; 24 | 25 | // Hook interaction events 26 | element.addEventListener("mouseenter", evt => { 27 | const area = evt.target.getBoundingClientRect().toJSON(); 28 | if (kinetics.container) { 29 | area.top = evt.target.offsetTop; 30 | area.left = evt.target.offsetLeft; 31 | } 32 | kinetics.attract(area, props) 33 | }, false); 34 | // element.addEventListener("touchstart", evt => kinetics.attract(evt.target.getBoundingClientRect(), props), touchOptions); 35 | 36 | element.addEventListener("mouseleave", evt => kinetics.unattract(), false); 37 | // element.addEventListener("touchend", evt => kinetics.unattract(), touchOptions); 38 | 39 | element.addEventListener("mousemove", evt => kinetics.bump(evt.offsetX, evt.offsetY, evt.movementX, evt.movementY), false); 40 | }); 41 | 42 | 43 | // ** KINETICS MODE ** 44 | if ('IntersectionObserver' in window) { // incompatible browsers are out there 45 | const onIntersect = entries => { 46 | entries.forEach(entry => { 47 | if (entry.isIntersecting) { 48 | const props = getProps(entry.target, prefix, intersection.keyword); 49 | // console.log(props); 50 | kinetics.set({particles: { mode: props }}); 51 | } 52 | }); 53 | } 54 | 55 | // Initialize 56 | const observer = new IntersectionObserver(onIntersect, { threshold: intersection.threshold }); 57 | scope.querySelectorAll(`[data-${prefix}-${intersection.keyword}]`).forEach(element => { 58 | observer.observe(element); 59 | }); 60 | } 61 | } 62 | 63 | 64 | /** 65 | * Parse element's dataset 66 | * @param {DOM Element} element 67 | * @param {String} prefix data property prefix 68 | * @param {String} keyword data property keyword 69 | * @return {Object} Parsed properties 70 | */ 71 | function getProps(element, prefix, keyword){ 72 | // ----------------------------------------- 73 | // Get properties from data attribute 74 | let props = {}; 75 | try { 76 | const key = prefix + keyword[0].toUpperCase() + keyword.substr(1); // camelcase 77 | props = JSON.parse(element.dataset[key]); 78 | } 79 | catch(e) { 80 | Object.keys(element.dataset).forEach(d => { 81 | let _d = d.replace(/[A-Z]/g, m => "-" + m.toLowerCase()); // camelcase back to dash 82 | if (_d.startsWith(`${prefix}-`)) { // is our data-prefix? 83 | _d = _d.substr(prefix.length+1); // trim prefix 84 | if (_d !== keyword) { // exclude top attribute ("data-kinetics-attraction") 85 | let v = element.dataset[d]; 86 | if (!isNaN(parseFloat(v))) v = parseFloat(v); 87 | const k = _d.substr(keyword.length + 1); 88 | props[k] = v; 89 | } 90 | } 91 | }); 92 | } 93 | return props; 94 | } 95 | 96 | 97 | /** 98 | * Passive event listener supported? 99 | * see: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md 100 | * @return {Boolean} 101 | */ 102 | function isPassiveSupported() { 103 | var supportsPassive = false; 104 | try { 105 | var opts = Object.defineProperty({}, 'passive', { 106 | get: function() { 107 | supportsPassive = true; 108 | } 109 | }); 110 | window.addEventListener("testPassive", null, opts); 111 | window.removeEventListener("testPassive", null, opts); 112 | } catch (e) {} 113 | 114 | return supportsPassive; 115 | } 116 | -------------------------------------------------------------------------------- /src/lib/particles.js: -------------------------------------------------------------------------------- 1 | import rebound from 'rebound'; 2 | import Particle from './particle.js'; 3 | import pointOnRect from '../util/pointOnRect.js'; 4 | 5 | export default class Particles { 6 | 7 | constructor(ctx, config, width, height) { 8 | this.ctx = ctx; 9 | this.width = width; 10 | this.height = height; 11 | 12 | const { count, sides } = config.particles; 13 | this.count = count; 14 | this.sides = sides; 15 | 16 | this.config = config; 17 | 18 | this.init(); 19 | } 20 | 21 | /** 22 | * Init system and create particles 23 | */ 24 | init() { 25 | this.particles = []; 26 | 27 | this.springSystem = new rebound.SpringSystem(); 28 | for (let i=0; i < this.count; i++) { 29 | const particle = new Particle(this.ctx, this.config, this.springSystem); 30 | this.particles.push(particle); 31 | } 32 | } 33 | 34 | /** 35 | * Remove system 36 | */ 37 | destory() { 38 | if (this.springSystem) this.springSystem.destory(); 39 | this.particles.forEach(p => p.destory()); 40 | } 41 | 42 | /** 43 | * Update system's config 44 | * @param {object} config 45 | */ 46 | set(config) { 47 | if (!config) return; 48 | // else if (typeof config === "number") { 49 | // //TODO: destory particles (and springsystem?) 50 | // this.count = config; 51 | // this.init(); 52 | // } 53 | else { 54 | this.config = config; 55 | this.particles.forEach(p => p.set(config)); 56 | } 57 | } 58 | 59 | ////////////////////////////////////////////////// 60 | 61 | draw() { 62 | this.resetCanvas(); 63 | this.particles.forEach(p => p.draw()); 64 | } 65 | 66 | update() { 67 | this.particles.forEach(p => p.update()); 68 | 69 | // this.particles = this.particles.filter(p => !p.isDead()); 70 | // if (frameCount % this.generationSpeed === 0) { 71 | // this.init(); 72 | // } 73 | } 74 | 75 | ////////////////////////////////////////////////// 76 | 77 | /** 78 | * Attract particles to area 79 | * @param {object} area Rectangle area 80 | * @param {object} config Configuration 81 | */ 82 | attract(area, config) { 83 | const { chance } = config; 84 | const count = this.particles.length; 85 | const shuffled = [].concat(this.particles) 86 | .sort(() => Math.random()); 87 | shuffled.forEach((p, i) => { 88 | if (i/count < chance) { 89 | const pos = p.position; 90 | const point = pointOnRect(pos.x, pos.y, area.left, area.top, area.right, area.bottom, false); 91 | const center = { 92 | x: area.left + ((area.right - area.left) / 2), 93 | y: area.top + ((area.bottom - area.top) / 2) 94 | }; 95 | 96 | p.attract(point, center, config); 97 | } 98 | }); 99 | } 100 | 101 | 102 | /** 103 | * Unattract particles 104 | */ 105 | unattract() { 106 | this.particles.forEach(p => p.unattract()); 107 | } 108 | 109 | 110 | /** 111 | * Bump particles 112 | * @param {number} x 113 | * @param {number} y 114 | */ 115 | bump(x, y) { 116 | this.particles.forEach(p => { 117 | if (p.isAttracted) { 118 | const factor = 0.1; 119 | p.attractPoint.x += x * factor; 120 | p.attractPoint.y += y * factor; 121 | // seedX 122 | // seedY 123 | // size 124 | } 125 | }); 126 | } 127 | // rotate(angle) { 128 | // this.ctx.rotate(angle * Math.PI / 180); 129 | // } 130 | 131 | /** 132 | * Scroll 133 | * @param {number} diff Vertical difference 134 | */ 135 | scroll(diff) { 136 | this.particles.forEach((p,i) => { 137 | // Attached scrolling fix + attached chrome bug. 138 | // programatically make the y-axis follow scroll diff 139 | if (p.isAttracted) p.attractPoint.y -= diff; 140 | 141 | // Parallex effect 142 | else { 143 | const { layers, speed } = this.config.particles.parallex; 144 | p.position.y -= diff * (i%layers * speed); 145 | } 146 | }); 147 | } 148 | 149 | 150 | /** 151 | * Clear canvas 152 | */ 153 | resetCanvas() { 154 | this.ctx.clearRect(0, 0, this.width, this.height); 155 | } 156 | 157 | /** 158 | * Canvas size changed 159 | * @param {number} width 160 | * @param {number} height 161 | */ 162 | canvasResized(width, height) { 163 | this.width = width; 164 | this.height = height; 165 | 166 | this.particles.forEach(p => p.canvasResized(width, height)); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/lib/kinetics.js: -------------------------------------------------------------------------------- 1 | // import rebound from 'rebound'; 2 | import merge from 'deepmerge'; 3 | import onscrolling from 'onscrolling'; 4 | // import { ResizeObserver as Ponyfill_RO } from '@juggle/resize-observer'; 5 | 6 | import defaultConfig from '../kinetics.config.json'; 7 | import { version } from '../../package.json'; 8 | 9 | import dpr from '../util/dpr'; 10 | import browsersupport from '../util/browsersupport'; 11 | import configParser from '../util/configParser'; 12 | 13 | // import { elementDimentions } from './events.js'; 14 | import Particles from './particles.js'; 15 | import interactionHook from './interactionHook'; 16 | 17 | const Kinetics = (function () { 18 | 'use strict'; 19 | 20 | let _this = null; // TODO: refactor 21 | const mergeOptions = { arrayMerge: (destinationArray, sourceArray, options) => sourceArray }; 22 | 23 | /** 24 | * Kinetics initialisation 25 | * @param {Object} options (optional) Override opt 26 | * @param {DOM Element} container (optional) container element 27 | */ 28 | function Kinetics (options = {}, container) { 29 | if ( !browsersupport() ) return console.warn("KINETICS: FAILED FEATURE TEST"); // ERROR 30 | 31 | // console.log('CONSTRUCTOR', version, options); 32 | _this = this; 33 | 34 | this.destroy(); // just in case 35 | 36 | // Load configuration 37 | this.config = configParser(merge(defaultConfig, options, mergeOptions)); 38 | this.originalConfig = merge({}, this.config); // clone it 39 | 40 | this.container = container; // (optional) container element 41 | 42 | this.construct(); 43 | }; 44 | 45 | Kinetics.prototype.VERSION = version; 46 | 47 | 48 | /** 49 | * constructor 50 | */ 51 | Kinetics.prototype.construct = function() { 52 | // Destroy any existing initializations 53 | this.paths = []; // init paths array 54 | 55 | // // ResizeObserver (with ponyfill, if required) 56 | // const RO = ('ResizeObserver' in window === false) ? Ponyfill_RO : ResizeObserver; 57 | // this.resizeObserver = new RO(onResizeObserved); 58 | 59 | // Intersection observer is optional, only used for "paused" (performance, stop animation when out of view) 60 | if ('IntersectionObserver' in window) this.intersectionObserver = new IntersectionObserver(onIntersectionObserved); 61 | 62 | this.init(); 63 | // this.setupCanvas(0,0); // HACK: kick it once until resizeObserver is called with correct width/height 64 | onResizeObserved(); 65 | // this.onScroll = this.onScroll.bind(this); 66 | } 67 | 68 | 69 | /** 70 | * Destroy the current initialization. 71 | * @public 72 | */ 73 | Kinetics.prototype.destroy = function() { 74 | // If plugin isn't already initialized, stop 75 | if ( !this.config ) return; 76 | if (this.config.debug) console.log("destroy"); 77 | 78 | // TODO: FIX THIS ! 79 | if (this.spring) this.spring.destroy(); 80 | if (this.canvas) this.canvas.remove(); 81 | 82 | // UNHOOK EVENTS 83 | onscrolling.remove(onScroll); 84 | // if (this.resizeObserver) this.resizeObserver.disconnect(); 85 | // window.removeEventListener('resize', this.onResize); 86 | if (this.intersectionObserver) this.intersectionObserver.disconnect(); 87 | if (this.config.click.shuffle) document.removeEventListener('click', onClick, true); 88 | if (this.onRestTimeout) clearTimeout(this.onRestTimeout); 89 | 90 | this.config = null; // Reset variables 91 | } 92 | 93 | 94 | /** 95 | * Init the kinetics system 96 | */ 97 | Kinetics.prototype.init = function() { 98 | if (this.config.debug) console.log("init", this.config); 99 | 100 | // Setup canvas element 101 | this.canvas = document.createElement('canvas'); 102 | if (this.config.canvas.handlePosition) { 103 | this.canvas.style.position = this.container ? "absolute" : "fixed"; 104 | this.canvas.style.top = 0; 105 | this.canvas.style.left = 0; 106 | // this.canvas.style.width = "100%"; 107 | // this.canvas.style.height = "100%"; 108 | this.canvas.style.zIndex = -1; 109 | } 110 | 111 | const elem = this.container || document.body; 112 | 113 | // Add canvas to element 114 | elem.prepend(this.canvas); 115 | this.ctx = this.canvas.getContext('2d', { alpha: true }); // Select canvas context 116 | this.ctx.frameCount = 0; 117 | // initSprings(); // start spring system 118 | 119 | // Create the particles 120 | this.particles = new Particles(this.ctx, this.config); 121 | 122 | // Start the animation loop 123 | this.loop(); 124 | 125 | 126 | // ** HOOK EVENTS ** 127 | window.addEventListener('resize', onResizeObserved); 128 | // this.resizeObserver.observe(elem); // Element resize observer 129 | document.addEventListener('visibilitychange', onVisibilityChanged); 130 | 131 | 132 | // TODO: not this... we do't need anymore. should just test if tab in focus, else pause. mybe not needed? if runs on GPU? 133 | if (this.intersectionObserver) this.intersectionObserver.observe(elem); // Element (viewport) interaction observer 134 | // if (this.config.click.shuffle) document.addEventListener('click', onClick, true); // useCapture = true important !! 135 | onscrolling(onScroll); // Scroll handler 136 | } 137 | 138 | 139 | /** 140 | * Main animation loop 141 | */ 142 | Kinetics.prototype.loop = function() { 143 | requestAnimationFrame(_this.loop); 144 | 145 | if (!_this.paused) { 146 | _this.particles.update(); 147 | _this.particles.draw(); 148 | _this.ctx.frameCount += 1; 149 | } 150 | } 151 | 152 | 153 | 154 | // ======================================================================== 155 | 156 | /**********/ 157 | /* CANVAS */ 158 | /**********/ 159 | 160 | /** 161 | * Setup canvas size 162 | * @param {number} width 163 | * @param {number} height 164 | */ 165 | Kinetics.prototype.setupCanvas = function(width, height) { 166 | if (this.config.debug) console.log("setupCanvas", width, height); 167 | this.width = width; 168 | this.height = height; 169 | 170 | const _dpr = dpr(); 171 | this.canvas.width = width * _dpr; 172 | this.canvas.height = height * _dpr; 173 | this.canvas.style.width = width + "px"; 174 | this.canvas.style.height = height + "px"; 175 | 176 | if (_dpr !== 1) this.ctx.setTransform(_dpr, 0, 0, _dpr, 0, 0); // Reset context 177 | } 178 | 179 | 180 | /** 181 | * Clear the canvas 182 | */ 183 | // Kinetics.prototype.resetCanvas = function() { 184 | // this.ctx.clearRect(0, 0, this.width, this.height); 185 | // } 186 | 187 | 188 | /********** 189 | /* EVENTS * 190 | /********** 191 | 192 | /** 193 | * Scroll event 194 | * @param {object} e event 195 | */ 196 | const onScroll = function (e) { 197 | if (_this.container) return; // We don't need this scroll on "container", as it uses absolute positioning 198 | // if (_this.config.debug) console.log("scroll"); 199 | if (_this.paused) return; 200 | 201 | const diff = e - (_this.prevScroll || 0); 202 | _this.prevScroll = e; 203 | 204 | _this.particles.scroll(diff); 205 | } 206 | 207 | /** 208 | * Element resize handler 209 | */ 210 | const onResizeObserved = function(entries) { 211 | // console.log("onResizeObserved", entries); 212 | // const { width, height } = elementDimentions(entries[0]); 213 | // if (!width || !height) console.warn("KINETICS: UNEXPECTED RESPONSE FROM ResizeObserver"); 214 | 215 | const width = _this.container ? _this.container.offsetWidth : window.innerWidth; 216 | const height = _this.container ? _this.container.offsetHeight : window.innerHeight; 217 | if (_this.config.debug) console.log("Resize observed: Width " + width + "px, Height " + height + "px"); 218 | 219 | _this.setupCanvas(width, height); 220 | _this.particles.canvasResized(width, height); 221 | } 222 | 223 | /** 224 | * Element intersection handler 225 | */ 226 | const onIntersectionObserved = function(entries) { 227 | // console.log("onIntersectionObserved"); 228 | _this.paused = entries[0].intersectionRatio === 0; 229 | if (_this.config.debug) console.log("Paused", _this.paused); 230 | } 231 | 232 | 233 | const onVisibilityChanged = function() { 234 | // console.log("onVisibilityChanged"); 235 | _this.paused = document.visibilityState === 'hidden' 236 | if (_this.config.debug) console.log("Paused", _this.paused); 237 | } 238 | 239 | 240 | 241 | // ======================================================================== 242 | 243 | /******* 244 | API 245 | *******/ 246 | 247 | /** 248 | * Update configuration options on particles system 249 | * @param {Object} options Configuration object (see: kinetics.config.json) 250 | */ 251 | Kinetics.prototype.set = function(options = {}) { 252 | this.config = configParser(merge(this.originalConfig, options, mergeOptions)); // important: we use originalConfig (and not config). so each call to .set() resets the config back to original. 253 | this.particles.set(this.config); 254 | } 255 | 256 | 257 | /** 258 | * Bump the system 259 | * Currently, attached to mousemove, see: interactionHook 260 | */ 261 | Kinetics.prototype.bump = function(x, y, movementX, movementY) { 262 | // if (this.config.debug) console.log("bump", x, y, movementX, movementY); 263 | this.particles.bump(movementX, movementY); 264 | // this.particles.rotate((movementX - movementY) / 10); 265 | // this.ctx.scale(Math.abs(movementX/10), Math.abs(movementY/10)); 266 | // this.particles.attract(area, force, gravity); 267 | } 268 | 269 | 270 | /** 271 | * Attract system to area 272 | * @param {object} area Rectangle area object 273 | * @param {object} props configuration 274 | */ 275 | Kinetics.prototype.attract = function(area, props) { 276 | // if (this.config.debug) console.log("attract", area, force, gravity); 277 | if (this.config.debug) console.log("attract", area, props); 278 | 279 | this.particles.attract(area, configParser(merge(this.config.particles.attract, props))); 280 | } 281 | 282 | 283 | /** 284 | * Unattract kinetics system 285 | */ 286 | Kinetics.prototype.unattract = function() { 287 | if (this.config.debug) console.log("unattract"); 288 | this.particles.unattract(); 289 | } 290 | 291 | /** 292 | * Helper: Initialise interactionHook 293 | * @param {object} config Configuration object 294 | * @param {DOM Element} scope Parent element 295 | */ 296 | Kinetics.prototype.interactionHook = function(config, scope) { 297 | interactionHook(this, config, scope); 298 | } 299 | 300 | return Kinetics; 301 | })(); 302 | 303 | 304 | export { Kinetics }; 305 | -------------------------------------------------------------------------------- /src/lib/particle.js: -------------------------------------------------------------------------------- 1 | import rebound from 'rebound'; 2 | import calculateAttractPosition from './calculateAttractPosition.js'; 3 | import calculateMovement from './calculateMovement.js'; 4 | import calculateBounderies from './calculateBounderies.js'; 5 | import getNumberInRange from '../util/getNumberInRange.js'; 6 | 7 | export default class Particle { 8 | 9 | /** 10 | * Particle constructor 11 | * @param {object} ctx Canvas context 12 | * @param {object} config Configuration 13 | * @param {object} springSystem Spring system 14 | */ 15 | constructor(ctx, config, springSystem) { 16 | this.ctx = ctx; 17 | this.config = config; 18 | const { particles: { sides, fill, stroke } } = config; 19 | 20 | this.sides = getNumberInRange(sides); 21 | this.sidesSeeds = Array.from(Array(this.sides)).map((_,i) => Math.random()); 22 | // this.life = 999; // TODO 23 | 24 | // a random number, index position in arrays (fill, colors, more?) 25 | const randomIndex = arr => Math.floor(Math.random() * arr.length); 26 | this.indexes = { 27 | fill: randomIndex(fill.colors), 28 | fillTo: randomIndex(fill.toColors), 29 | stroke: randomIndex(stroke.colors), 30 | strokeTo: randomIndex(stroke.toColors), 31 | strokeWidth: randomIndex(stroke.width) 32 | }; 33 | 34 | // init values 35 | this.seeds = { x: Math.random(), y: Math.random() }; 36 | this.springPosition = 0; 37 | this.position = { x: 0, y: 0 }; 38 | this.attractPoint = { x: 0, y: 0 }; 39 | this.attractCenter = { x: 0, y: 0 }; 40 | this.attractConfig = {chance: 1, direction: 1, force: 1, grow: 1, radius: 1, size: null, speed: 1, type: "" }; 41 | this.resetFlip(); 42 | // this.canvasWidth = 0; 43 | // this.canvasHeight = 0; 44 | 45 | // initialise spring 46 | const { spring: { tension, friction, randomTension, randomFriction } } = config; 47 | this.spring = springSystem.createSpring( 48 | tension + (this.seeds.x * randomTension), 49 | friction + (this.seeds.y * randomFriction) 50 | ); 51 | 52 | // this.onSpringAtRest = this.onSpringAtRest.bind(this); 53 | this.onSpringUpdate = this.onSpringUpdate.bind(this); 54 | this.spring.addListener({ 55 | onSpringUpdate: this.onSpringUpdate, 56 | // onSpringAtRest: this.onSpringAtRest 57 | }); 58 | } 59 | 60 | /** Remove particle */ 61 | destroy() { 62 | if (this.spring) this.spring.destroy(); 63 | } 64 | 65 | /** 66 | * Update particle configuration 67 | * @param {object} config 68 | */ 69 | set(config) { 70 | this.config = config; 71 | this.resetFlip(); 72 | } 73 | 74 | /** Reset flip state */ 75 | resetFlip() { this.flip = {x: 1, y: 1}; } 76 | 77 | /** 78 | * Spring entered resting poition 79 | */ 80 | // onSpringAtRest(spring) { 81 | // if (this.config.debug) console.log("onSpringAtRest"); 82 | // // Activate after some time 83 | // // if (this.onRestTimeout) clearTimeout(this.onRestTimeout); 84 | // // this.onRestTimeout = setTimeout(onExtendedRest, this.config.spring.extendedRestDelay * 1000); // when would a user normally scroll "again", while it should "feel" the same scroll? 85 | // } 86 | 87 | /** 88 | * Spring is in extended rest (long time) 89 | */ 90 | onExtendedRest() { 91 | if (this.config.debug) console.log("onExtendedRest"); 92 | // if (this.spring.isAtRest()) this.shouldReChaos = true; 93 | } 94 | 95 | /** 96 | * Spring in action 97 | * @param {object} spring 98 | */ 99 | onSpringUpdate(spring) { 100 | this.springPosition = spring.getCurrentValue(); 101 | } 102 | 103 | /** 104 | * Every particle needs to know it's bounderies 105 | * @param {number} width 106 | * @param {number} height 107 | */ 108 | canvasResized(width, height) { 109 | this.canvasWidth = width; 110 | this.canvasHeight = height; 111 | 112 | // re-position: spread particles throughout the canvas 113 | this.position = {x: Math.floor(this.seeds.x * width), y: Math.floor(this.seeds.y * height)}; 114 | } 115 | 116 | 117 | // ======================================================================== 118 | 119 | 120 | /** 121 | * Attract particle to point 122 | * @param {object} point xy object 123 | * @param {object]} center xy object 124 | * @param {object} config 125 | */ 126 | attract(point, center, config) { 127 | this.attractPoint = point; 128 | this.attractCenter = center; 129 | this.attractConfig = config; 130 | this.spring.setEndValue(config.force); 131 | this.isAttracted = true; 132 | } 133 | 134 | /** Unattract particle */ 135 | unattract() { 136 | if (!this.isAttracted) return; 137 | this.spring.setEndValue(0); 138 | this.isAttracted = false; 139 | } 140 | 141 | /** Attract position manipulator */ 142 | attractPosition() { 143 | if (this.isAttracted || !this.spring.isAtRest()) { 144 | const { type, speed, direction, radius } = this.attractConfig; 145 | // TODO: this is only needed on some modes 146 | const angle = this.getAngle(direction, (this.seeds.x * speed) + speed); 147 | return calculateAttractPosition(type, this.attractPoint, this.attractCenter, angle, radius); 148 | } 149 | else return this.attractPoint; 150 | } 151 | 152 | 153 | // ======================================================================== 154 | 155 | 156 | /** 157 | * Modulate the position 158 | * @param {object} pos {x, y} source point 159 | * @param {object} mode Mode configuation 160 | * @return {object} {x, y} modulated point 161 | */ 162 | modulatePosition(pos, mode) { 163 | // Movement 164 | const {type, speed, boundery} = mode; 165 | pos = calculateMovement(type, pos, speed, this.flip, this.seeds); 166 | 167 | // Bounderies 168 | const { sizes: { max }, stroke: { width }} = this.config.particles; 169 | const maxsize = max + this.idxValue(width,'strokeWidth'); 170 | pos = calculateBounderies(boundery, pos, maxsize, this.flip, this.canvasWidth, this.canvasHeight); 171 | 172 | // TODO: (is this meaningful?) performance, less sub-pixel rendering 173 | // pos.x = Math.floor(pos.x); 174 | // pos.y = Math.floor(pos.y); 175 | return pos; 176 | } 177 | 178 | /** 179 | * Generate shape 180 | * @param {number} x center x 181 | * @param {number} y center y 182 | * @return {array} array of vertices 183 | */ 184 | generateVertices(x, y) { 185 | // dynamically resize on attract/spring 186 | const { grow, size } = this.attractConfig; 187 | let attractSizing = 1; 188 | if (!size) attractSizing += this.springPosition * ( grow >= 1 ? grow : grow - 1 ); 189 | 190 | const { sizes, rotate: { speed, direction } } = this.config.particles; 191 | const angle = this.getAngle(direction, speed) 192 | 193 | return Array.from(Array(this.sides)).map((_, i) => { 194 | const slice = 360/this.sides; 195 | const posAngle = ((this.sidesSeeds[i] * slice) + (i * slice)) * Math.PI / 180; 196 | let length = getNumberInRange(sizes, this.sidesSeeds[i]); 197 | 198 | if (size) { // attract to fixed size? 199 | const attractFixedSize = size * this.sidesSeeds[i]; 200 | length = (1 - this.springPosition) * length + this.springPosition * attractFixedSize; // transition between original and fixed size 201 | } 202 | 203 | const vx = length * Math.cos(posAngle) * attractSizing; 204 | const vy = length * Math.sin(posAngle) * attractSizing; 205 | return { 206 | x: x + vx * Math.cos(angle) - vy * Math.sin(angle), 207 | y: y + vx * Math.sin(angle) + vy * Math.cos(angle) 208 | } 209 | }); 210 | } 211 | 212 | /** 213 | * Get Angle in current frame 214 | * @param {int} direction Rotation direction (and quantity) 215 | * @param {float} speed Rotation speed 216 | * @return {float} Angle (in radians) 217 | */ 218 | getAngle(direction, speed) { 219 | const angle = (this.ctx.frameCount * speed)%360 220 | * ( Number.isInteger(direction) ? direction : (this.seeds.x > 0.5 ? 1 : -1) ); // if not set, randomly set rotate direction (positive/negative) 221 | return angle * Math.PI / 180; // in Radians 222 | } 223 | 224 | /** 225 | * Get modulated particle position 226 | * @return {object} {x, y} point 227 | */ 228 | getPosition() { 229 | // Modulate position 230 | if (!this.isAttracted) { 231 | this.position = this.modulatePosition(this.position, this.config.particles.mode); 232 | if (this.spring.isAtRest()) return this.position; // (optional) performance. we don't need to continue calculating... 233 | } 234 | 235 | // Modulate attraction position 236 | let {x, y} = this.attractPosition(); 237 | x = rebound.MathUtil.mapValueInRange(this.springPosition, 0, 1, this.position.x, x); 238 | y = rebound.MathUtil.mapValueInRange(this.springPosition, 0, 1, this.position.y, y); 239 | return {x, y}; 240 | } 241 | 242 | // ======================================================================== 243 | 244 | /** 245 | * Update (on each frame) 246 | */ 247 | update() { 248 | // if (typeof this.canvasWidth === 'undefined') return; // not yet initialised? 249 | const {x, y} = this.getPosition(); 250 | this.vertices = this.generateVertices(x, y); 251 | // this.life--; 252 | } 253 | 254 | /** 255 | * Draw (on each frame) 256 | */ 257 | draw() { 258 | // if (typeof this.canvasWidth === 'undefined') return; // not yet initialised? 259 | 260 | this.ctx.beginPath(); 261 | // this.ctx.moveTo (this.vertices[0][0], this.vertices[0][1]); 262 | this.vertices.forEach(p => this.ctx.lineTo(p.x, p.y)); 263 | this.ctx.closePath(); 264 | 265 | 266 | // ** FILL ** 267 | const { fill, stroke } = this.config.particles; 268 | 269 | // ** FILL ** 270 | if (fill.colors.length) { // any colors in the array? 271 | let fillColor = this.idxValue(fill.colors,'fill'); 272 | if (fill.toColors.length) { 273 | fillColor = rebound.MathUtil.interpolateColor(this.colorPosition(), fillColor, this.idxValue(fill.toColors,'fillTo')); 274 | } 275 | this.ctx.fillStyle = fillColor + this.float2hex(fill.opacity); 276 | this.ctx.fill(); 277 | } 278 | 279 | // ** STROKE ** 280 | if (stroke.colors.length) { // any colors in the array? 281 | const strokeWidth = this.idxValue(stroke.width,'strokeWidth'); 282 | if (strokeWidth > 0) { // valid stroke width? 283 | let strokeColor = this.idxValue(stroke.colors,'stroke'); 284 | if (stroke.toColors.length) 285 | strokeColor = rebound.MathUtil.interpolateColor(this.colorPosition(), strokeColor, this.idxValue(stroke.toColors,'strokeTo')); 286 | 287 | this.ctx.strokeStyle = strokeColor + this.float2hex(stroke.opacity); 288 | this.ctx.lineWidth = strokeWidth; 289 | this.ctx.stroke(); 290 | } 291 | } 292 | } 293 | 294 | 295 | // ======================================================================== 296 | 297 | 298 | /** 299 | * Calculate color position in interpolation 300 | * @return {number} Current position 301 | */ 302 | colorPosition() { 303 | return Math.abs( 304 | Math.sin(this.ctx.frameCount * this.seeds.x * Math.PI / 180) 305 | ); 306 | } 307 | 308 | /** 309 | * float to HEX 310 | * with limiter (0-1 --> 00-ff) 311 | * @param {number} f input float 312 | * @return {string} HEX value 313 | */ 314 | float2hex(f) { 315 | return (Number.isNaN(f) || f < 0 || f > 1) ? '' 316 | : Math.floor(f * 255).toString(16).padStart(2, 0); 317 | } 318 | 319 | /** 320 | * Get indexed value from array 321 | * @param {Array} arr input array 322 | * @param {number} idx Index to fetch 323 | * @return {*|null} Value or null if invalid 324 | */ 325 | idxValue(arr, idx) { 326 | return arr.length ? arr[this.indexes[idx]] : null; 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Kinetics Dev 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 |
rain
19 |
wind-from-right
20 |
wind-from-left
21 |
linear emitter
22 |
linear pong
23 |
linear endless
24 | 25 | 26 | 27 |
28 |
29 | pause 30 |
31 | 32 |
33 | login    34 | Long text with text text 35 |
36 | 37 | 38 | 39 | 44 | 49 | 57 | 58 | 63 | 64 |
65 | 75 | 83 | 91 |
92 | 93 | 94 | 102 | 108 | 114 | 120 | 121 | 126 | 131 | 136 | 141 | 146 | 153 |
154 | 155 | 156 |
157 |
158 | 164 | 170 | 176 | 182 |
183 |
184 | 185 | 186 |
187 |

dummy static

188 |
189 | 197 | 205 | 213 |
214 |
215 | 216 | 217 |
space
218 | 219 |
party 225 | 229 |
230 | 231 |
235 | wind pong 236 | 241 | 245 | 251 | 256 |
257 | 258 | 259 | 260 | 261 | 262 | 263 | 333 | 334 | 335 | 336 | 337 | --------------------------------------------------------------------------------