|
// this might look cluttery on codepen, you can check the repo (below) |
|
// GITHUB LINK --> https://github.com/devloop01/canvas-image-interaction |
|
|
|
// I added comments just in case you are exploring through the code. |
|
|
|
// ALL THE CARD OPTIONS LISTED BELOW --> |
|
// 1. jumpToRandomPosition: If `true` the particles on every frame will jump to random position. Else the particles will move randomly without jumping. Defaults to `false` |
|
// 2. growAndShrink: If `true` the particles will grow & shrink, it will grow .8 times larger than the radius. Defaults to `false` |
|
// 3. fill: If `true` the particles are filled with the current pixel color that they are on, else they will be stroked for that same color. Defaults to `true` |
|
// 4. bounceFromEdges: If `true` the particles will bounce back when they hit the (specified) edges, or else thay will continue their path from the opposite edges/walls. Defaults to `true`. |
|
// 5. shape: You can specify what shape of the particles. Currently you can specify any one from the following, i.e. "circle", "square", "hexagon". If not specified then it defaults to "circle" |
|
// 6. radius: You can specity the radius of the particles, defaults to "5" if not specified. |
|
// 7. randomRadius: If `true` then the particles will have random radius, else defaults to `false` |
|
// 8. maxRadius: You can specify the minimum radius of the particles, else defaults to "2" |
|
// 9. minRadius: You can specify the maximum radius of the particles, else defaults to "5" |
|
// 10. maxVelocity: You can specify the maximum velocity of the particles, else defaults to "8" |
|
|
|
// Okay that's it, that's all the options I could add. Play around and see what fits for you. |
|
// Also please STAR this project if you think it's interesting, you can even fork it and make/add something new. |
|
|
|
console.clear(); |
|
|
|
const cards = Array.from(document.querySelectorAll(".card")); |
|
const cardOptions = [ |
|
{ |
|
imageURL: { |
|
default: "https://source.unsplash.com/8xznAGy4HcY/400x600", |
|
hovered: "https://source.unsplash.com/Xc6gtOwSMSA/400x600" |
|
}, |
|
totalParticles: 1500, |
|
mouseRange: 80, |
|
particlesConfig: { |
|
jumpToRandomPosition: false, |
|
fill: true, |
|
randomRadius: true, |
|
minRadius: 1, |
|
maxRadius: 2 |
|
} |
|
}, |
|
{ |
|
imageURL: { |
|
default: "https://source.unsplash.com/wQImoykAwGs/400x600", |
|
hovered: "https://source.unsplash.com/QsWG0kjPQRY/400x600" |
|
}, |
|
totalParticles: 2500, |
|
particlesConfig: { |
|
jumpToRandomPosition: true, |
|
fill: true, |
|
shape: "square", |
|
radius: 2 |
|
} |
|
}, |
|
{ |
|
imageURL: { |
|
default: "https://source.unsplash.com/sLAk1guBG90/400x600", |
|
hovered: "https://source.unsplash.com/xe-ss5Tg2mo/400x600" |
|
}, |
|
totalParticles: 2500, |
|
particlesConfig: { |
|
jumpToRandomPosition: false, |
|
bounceFromEdges: false, |
|
fill: false, |
|
shape: "hexagon", |
|
radius: 1 |
|
} |
|
} |
|
]; |
|
|
|
const imageURLS = cardOptions |
|
.map((option) => Object.values(option.imageURL)) |
|
.flat(); |
|
|
|
// --------------------- CLASSES ---------------------------- |
|
|
|
class App { |
|
init() { |
|
// after all images are loaded remove loader |
|
// (this is not the best way to do so but it gets the job done) |
|
loadImages(imageURLS, (images) => { |
|
// this array holds the images in a sub array |
|
// i.e [img, img, img, img, img, img] ==> [[img, img], [img, img], [img, img]] |
|
const splitedImagesArray = splitArray(images, 2); |
|
|
|
cards.forEach((card, index) => { |
|
new Canvas({ |
|
parent: card.querySelector(".card__image--inner"), |
|
dimensions: { |
|
width: card.getBoundingClientRect().width, |
|
height: card.getBoundingClientRect().height |
|
}, |
|
...cardOptions[index], |
|
images: { |
|
default: splitedImagesArray[index][0], |
|
hovered: splitedImagesArray[index][1] |
|
} |
|
}); |
|
}); |
|
|
|
// hide the loading wrapper |
|
document.querySelector(".loading__wrapper").classList.add("hide"); |
|
|
|
// let the gsap animation begin |
|
gsap |
|
.timeline({ |
|
delay: 0.8, |
|
defaults: { |
|
duration: 1.5, |
|
stagger: 0.1, |
|
ease: "expo.out" |
|
} |
|
}) |
|
.fromTo( |
|
cards.map((card) => card.querySelector(".card__image")), |
|
{ |
|
translateY: "-100%" |
|
}, |
|
{ |
|
translateY: "0%" |
|
} |
|
) |
|
.fromTo( |
|
cards.map((card) => card.querySelector(".card__image--inner")), |
|
{ |
|
translateY: "100%" |
|
}, |
|
{ |
|
translateY: "0%" |
|
}, |
|
0 |
|
) |
|
.fromTo( |
|
cards.map((card) => card.querySelector(".card__text--inner")), |
|
{ |
|
translateY: "100%" |
|
}, |
|
{ |
|
duration: 1.2, |
|
translateY: "0%" |
|
}, |
|
0.4 |
|
); |
|
}); |
|
} |
|
} |
|
|
|
class Canvas { |
|
constructor(options = {}) { |
|
// the parent where the canvas will be appended |
|
this.parent = options.parent; |
|
|
|
// canvas dimensions |
|
this.dimensions = options.dimensions; |
|
|
|
// all imageURL's, images(optional) & imagesData that are required |
|
this.imageURL = options.imageURL || {}; |
|
this.images = options.images || {}; |
|
this.imagesData = options.imagesData || { |
|
default: null, |
|
hovered: null |
|
}; |
|
this.currentImageData = null; |
|
|
|
// Array where all the particles will be stored |
|
this.particles = null; |
|
this.totalParticles = options.totalParticles || 400; |
|
|
|
// boolean which changes to 'true' when hovered, oe else false |
|
this.hovered = false; |
|
|
|
// particles configs |
|
this.particlesConfig = options.particlesConfig; |
|
|
|
// mouse range and mouse particle instance |
|
this.mouseRange = options.mouseRange || null; |
|
this.mouse = null; |
|
|
|
// initialize the canvas |
|
this.init(); |
|
} |
|
|
|
init() { |
|
// create the canvas element |
|
this.canvas = document.createElement("canvas"); |
|
// get the canvas context |
|
this.ctx = this.canvas.getContext("2d"); |
|
// set the canvas dimensions |
|
this.canvas.width = this.dimensions.width; |
|
this.canvas.height = this.dimensions.height; |
|
|
|
const initialize = () => { |
|
// this variable holds the current image data |
|
this.currentImageData = this.imagesData.default; |
|
|
|
// add many Particle instances |
|
this.addParticles(this.totalParticles); |
|
// start rendering the canvas |
|
this.startRender(); |
|
// initialize all the canvas events |
|
this.initEvents(); |
|
// append the canvas on the parent |
|
this.parent.appendChild(this.canvas); |
|
}; |
|
|
|
// what happens here is if the user/dev provides the loaded image directly then use the images provided by the use directly |
|
// and if the user provides the URL for the image then load the images from the URL and initialize |
|
if ( |
|
!this.images.hasOwnProperty("default") && |
|
!this.images.hasOwnProperty("hovered") |
|
) { |
|
// load all the images that are required and after all the images are loaded the callback is called. |
|
loadImages([this.imageURL.default, this.imageURL.hovered], (images) => { |
|
// set the image data so that they can be accessed later when needed |
|
this.imagesData.default = returnImageData(images[0], this.dimensions); |
|
this.imagesData.hovered = returnImageData(images[1], this.dimensions); |
|
initialize(); |
|
}); |
|
} else { |
|
// set the image data so that they can be accessed later when needed |
|
this.imagesData.default = returnImageData( |
|
this.images.default, |
|
this.dimensions |
|
); |
|
this.imagesData.hovered = returnImageData( |
|
this.images.hovered, |
|
this.dimensions |
|
); |
|
initialize(); |
|
} |
|
|
|
// init mouse particle |
|
if (this.mouseRange != null) { |
|
this.mouse = new Particle({ |
|
ctx: this.ctx, |
|
position: { |
|
x: 0, |
|
y: 0 |
|
}, |
|
radius: this.mouseRange, |
|
color: "#000", |
|
avoisEdges: true, |
|
shape: "circle" |
|
}); |
|
} |
|
} |
|
|
|
initEvents() { |
|
const onMouseEnter = () => { |
|
this.hovered = true; |
|
this.currentImageData = this.imagesData.hovered; |
|
}; |
|
const onMouseLeave = () => { |
|
this.hovered = false; |
|
this.currentImageData = this.imagesData.default; |
|
}; |
|
const onMouseMove = (e) => { |
|
if (this.mouse != null && this.hovered) { |
|
this.mouse.position.x = e.offsetX; |
|
this.mouse.position.y = e.offsetY; |
|
} |
|
}; |
|
|
|
this.canvas.addEventListener("mouseenter", onMouseEnter); |
|
this.canvas.addEventListener("mouseleave", onMouseLeave); |
|
this.canvas.addEventListener("mousemove", onMouseMove); |
|
} |
|
|
|
addParticles(n) { |
|
this.particles = new Particles({ |
|
ctx: this.ctx, |
|
totalParticles: n, |
|
maxBounds: { width: this.dimensions.width, height: this.dimensions.height }, |
|
imageData: this.currentImageData, |
|
particlesConfig: this.particlesConfig |
|
}); |
|
} |
|
|
|
updateParticleColor(imageData, particle) { |
|
const color = returnPixelColor(imageData, Math.floor(this.dimensions.width), { |
|
x: Math.floor(particle.position.x), |
|
y: Math.floor(particle.position.y) |
|
}); |
|
particle.updateColor(color); |
|
} |
|
|
|
startRender() { |
|
requestAnimationFrame(() => this.render()); |
|
} |
|
|
|
render() { |
|
// this.ctx.clearRect(0, 0, this.dimensions.width, this.dimensions.height); |
|
|
|
// loop through all the particles |
|
this.particles.particles.forEach((particle) => { |
|
if (this.mouseRange != null) { |
|
// if the mouse range is not null then calculate the dist between mouse particle & all the other particles |
|
const d = dist(this.mouse.position, particle.position); |
|
// if the dist between the particles is less than the summation of the radius of the mouse particle & the other particle, that means they are intersecting |
|
if (d < this.mouse.radius + particle.radius && this.hovered) { |
|
// update the color of the intersecting particle only if mouse is hovered |
|
this.updateParticleColor(this.imagesData.hovered, particle); |
|
} |
|
// else update every other particle too |
|
else this.updateParticleColor(this.imagesData.default, particle); |
|
} |
|
// if the mouserange is null then update all particles at once |
|
else this.updateParticleColor(this.currentImageData, particle); |
|
}); |
|
|
|
this.particles.update(); |
|
|
|
requestAnimationFrame(() => this.render()); |
|
} |
|
} |
|
|
|
class Particles { |
|
constructor(options = {}) { |
|
this.ctx = options.ctx; // canvas context |
|
this.totalParticles = options.totalParticles; |
|
this.maxBounds = options.maxBounds; |
|
this.imageData = options.imageData; |
|
|
|
// array that holds all the particles |
|
this.particles = []; |
|
|
|
// all the particles config |
|
this.particlesConfig = { |
|
jumpToRandomPosition: options.particlesConfig.hasOwnProperty( |
|
"jumpToRandomPosition" |
|
) |
|
? options.particlesConfig.jumpToRandomPosition |
|
: false, |
|
growAndShrink: options.particlesConfig.hasOwnProperty("growAndShrink") |
|
? options.particlesConfig.growAndShrink |
|
: false, |
|
fill: options.particlesConfig.hasOwnProperty("fill") |
|
? options.particlesConfig.fill |
|
: true, |
|
bounceFromEdges: options.particlesConfig.hasOwnProperty("bounceFromEdges") |
|
? options.particlesConfig.bounceFromEdges |
|
: true, |
|
shape: options.particlesConfig.hasOwnProperty("shape") |
|
? options.particlesConfig.shape |
|
: "circle", |
|
radius: options.particlesConfig.hasOwnProperty("radius") |
|
? options.particlesConfig.radius |
|
: 5, |
|
randomRadius: options.particlesConfig.hasOwnProperty("randomRadius") |
|
? options.particlesConfig.randomRadius |
|
: false, |
|
maxRadius: options.particlesConfig.hasOwnProperty("maxRadius") |
|
? options.particlesConfig.maxRadius |
|
: 5, |
|
minRadius: options.particlesConfig.hasOwnProperty("minRadius") |
|
? options.particlesConfig.minRadius |
|
: 2, |
|
maxVelocity: options.particlesConfig.hasOwnProperty("maxVelocity") |
|
? options.particlesConfig.maxVelocity |
|
: 8 |
|
}; |
|
|
|
this.init(); |
|
} |
|
|
|
init() { |
|
const ctx = this.ctx; |
|
const color = "transparent"; |
|
for (let i = 0; i < this.totalParticles; i++) { |
|
const radius = this.particlesConfig.randomRadius |
|
? randomIntegerFromRange( |
|
this.particlesConfig.minRadius, |
|
this.particlesConfig.maxRadius |
|
) |
|
: this.particlesConfig.radius; |
|
const position = { |
|
x: randomIntegerFromRange(radius, this.maxBounds.width - radius), |
|
y: randomIntegerFromRange(radius, this.maxBounds.height - radius) |
|
}; |
|
this.particles.push( |
|
new Particle({ |
|
ctx, |
|
position, |
|
radius, |
|
color, |
|
imageData: this.imageData, |
|
maxVelocity: 8, |
|
bounceFromEdges: this.particlesConfig.bounceFromEdges, |
|
shape: this.particlesConfig.shape, |
|
edges: { width: this.maxBounds.width, height: this.maxBounds.height } |
|
}) |
|
); |
|
} |
|
} |
|
|
|
update() { |
|
// loop through particles, draw & update each particle |
|
this.particles.forEach((particle) => { |
|
particle.draw(); |
|
if (this.particlesConfig.fill) particle.fillShape(); |
|
else particle.strokeShape(); |
|
|
|
particle.update(); |
|
|
|
if (this.particlesConfig.growAndShrink) |
|
particle.growAndShrink(particle.minRadius * 0.65); |
|
|
|
if (!this.particlesConfig.jumpToRandomPosition) particle.updatePosition(); |
|
else |
|
particle.jumpToRandomPosition({ |
|
width: this.maxBounds.width, |
|
height: this.maxBounds.height |
|
}); |
|
}); |
|
} |
|
} |
|
|
|
class Particle { |
|
constructor(options = {}) { |
|
this.ctx = options.ctx; |
|
|
|
this.position = options.position || { |
|
x: 0, |
|
y: 0 |
|
}; |
|
this.maxVelocity = options.maxVelocity || 5; |
|
this.velocity = options.velocity || { |
|
x: (0.5 - Math.random()) * this.maxVelocity, |
|
y: (0.5 - Math.random()) * this.maxVelocity |
|
}; |
|
|
|
this.radius = options.radius; |
|
this.minRadius = this.radius; |
|
|
|
this.color = options.color; |
|
|
|
this.imageData = options.imageData; |
|
|
|
this.rotation = 0; |
|
this.rotationIncrement = randomIntegerFromRange(2, 5); |
|
|
|
this.stroke = false; |
|
this.fill = true; |
|
|
|
this.shape = options.shape || "circle"; |
|
|
|
this.edges = options.edges || null; |
|
this.bounceFromEdges = options.bounceFromEdges; |
|
this.avoidEdges = options.avoidEdges || false; |
|
|
|
this.tick = 0; |
|
this.tickIncrement = 0.02 + Math.random() * 0.03; |
|
} |
|
|
|
draw() { |
|
this.ctx.beginPath(); |
|
this.ctx.save(); |
|
this.ctx.translate(this.position.x, this.position.y); |
|
this.ctx.rotate((Math.PI / 180) * this.rotation); |
|
this.drawShape(this.shape); |
|
this.ctx.restore(); |
|
this.ctx.closePath(); |
|
} |
|
|
|
fillShape() { |
|
this.ctx.fillStyle = this.color; |
|
this.ctx.fill(); |
|
} |
|
|
|
strokeShape() { |
|
this.ctx.strokeStyle = this.color; |
|
this.ctx.stroke(); |
|
} |
|
|
|
drawShape(shape) { |
|
if (shape === "square") |
|
this.ctx.rect(-this.radius / 2, -this.radius / 2, this.radius, this.radius); |
|
else if (shape === "circle") this.ctx.arc(0, 0, this.radius, 0, Math.PI * 2); |
|
else if (shape === "hexagon") { |
|
this.ctx.moveTo(this.radius * Math.cos(0), this.radius * Math.sin(0)); |
|
for (let side = 0; side < 7; side++) { |
|
this.ctx.lineTo( |
|
this.radius * Math.cos((side * 2 * Math.PI) / 6), |
|
this.radius * Math.sin((side * 2 * Math.PI) / 6) |
|
); |
|
} |
|
} |
|
} |
|
|
|
update() { |
|
if (this.bounceFromEdges) this.changeVelocityOnBounce(this.edges); |
|
else this.continueFromEdge(); |
|
|
|
this.rotation += this.rotationIncrement; |
|
this.tick += this.tickIncrement; |
|
} |
|
|
|
updatePosition() { |
|
this.position.x += this.velocity.x; |
|
this.position.y += this.velocity.y; |
|
} |
|
|
|
jumpToRandomPosition(bounds) { |
|
this.position.x = Math.random() * bounds.width; |
|
this.position.y = Math.random() * bounds.height; |
|
} |
|
|
|
growAndShrink(max) { |
|
this.radius = this.minRadius + Math.abs(Math.sin(this.tick)) * max; |
|
} |
|
|
|
updateColor(color) { |
|
this.color = color; |
|
} |
|
|
|
continueFromEdge() { |
|
if (!this.avoidEdges) { |
|
if (this.position.x > this.edges.width) this.position.x = 0; |
|
else if (this.position.x < 0) this.position.x = this.edges.width; |
|
if (this.position.y > this.edges.height) this.position.y = 0; |
|
else if (this.position.y < 0) this.position.y = this.edges.height; |
|
} |
|
} |
|
|
|
changeVelocityOnBounce() { |
|
if (!this.avoidEdges) { |
|
if ( |
|
this.position.x + this.radius > this.edges.width || |
|
this.position.x - this.radius < 0 |
|
) |
|
this.velocity.x *= -1; |
|
if ( |
|
this.position.y + this.radius > this.edges.height || |
|
this.position.y - this.radius < 0 |
|
) |
|
this.velocity.y *= -1; |
|
} |
|
} |
|
} |
|
|
|
// ---------------- UTILITY FUNCTIONS ------------------------ |
|
|
|
function randomIntegerFromRange(min, max) { |
|
return Math.floor(Math.random() * (max - min + 1) + min); |
|
} |
|
|
|
function dist(a, b) { |
|
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); |
|
} |
|
|
|
function splitArray(array, n) { |
|
let [...arr] = array; |
|
var res = []; |
|
while (arr.length) { |
|
res.push(arr.splice(0, n)); |
|
} |
|
return res; |
|
} |
|
|
|
function returnPixelColor(imageData, width, position) { |
|
const index = (position.x + position.y * width) * 4; |
|
let pixel = { |
|
r: imageData.data[index + 0], |
|
g: imageData.data[index + 1], |
|
b: imageData.data[index + 2] |
|
}; |
|
return `rgb(${pixel.r}, ${pixel.g}, ${pixel.b})`; |
|
} |
|
|
|
function toDataURL(url) { |
|
return new Promise((resolve, reject) => { |
|
var xhr = new XMLHttpRequest(); |
|
xhr.onload = function () { |
|
var reader = new FileReader(); |
|
reader.onloadend = function () { |
|
resolve(reader.result); |
|
}; |
|
reader.readAsDataURL(xhr.response); |
|
}; |
|
xhr.onerror = reject; |
|
xhr.open("GET", url); |
|
xhr.responseType = "blob"; |
|
xhr.send(); |
|
}); |
|
} |
|
|
|
function returnImageData(image, dimensions) { |
|
const imageCanvas = document.createElement("canvas"); |
|
const imageCanvasCtx = imageCanvas.getContext("2d"); |
|
imageCanvas.width = dimensions.width; |
|
imageCanvas.height = dimensions.height; |
|
imageCanvasCtx.drawImage(image, 0, 0, imageCanvas.width, imageCanvas.height); |
|
return imageCanvasCtx.getImageData( |
|
0, |
|
0, |
|
imageCanvas.width, |
|
imageCanvas.height |
|
); |
|
} |
|
|
|
function loadImage(imageURL, callback) { |
|
toDataURL(imageURL).then((data) => { |
|
const IMAGE = new Image(); |
|
IMAGE.src = data; |
|
IMAGE.onload = function () { |
|
callback(IMAGE); |
|
}; |
|
}); |
|
} |
|
|
|
function loadImages(imagesURLS, callback) { |
|
const totalImageToLoad = imagesURLS.length; |
|
let curentImageIndex = 0; |
|
let imagesArray = []; |
|
|
|
const load = () => { |
|
loadImage(imagesURLS[curentImageIndex], (image) => { |
|
imagesArray.push(image); |
|
curentImageIndex++; |
|
if (curentImageIndex === totalImageToLoad) callback(imagesArray); |
|
else load(); |
|
}); |
|
}; |
|
|
|
load(); |
|
} |
|
|
|
// initiate the App instance |
|
const app = new App(); |
|
app.init(); |