@yoannchb/cattract
Version:
Animate anything just like it was attracted by the cursor
310 lines (277 loc) • 9.02 kB
text/typescript
type With3dOptions = {
axe?: "x" | "y";
inverted?: boolean | "x" | "y";
maxAngle?: number;
perspective?: number;
};
type Options = {
elementRadius?: number;
detectionRadius?: number | "full";
animation?: {
ease?: string;
duration?: number;
};
scale?: {
from?: number;
to?: number;
animated?: boolean;
};
inverted?: boolean | "x" | "y";
axe?: "x" | "y";
with_3d?: boolean | With3dOptions;
};
//all the instance of Cattract class
const instances: Cattract[] = [];
const defaultOptions: Options = {
elementRadius: 20,
detectionRadius: 50,
animation: {
ease: "ease-in-out",
duration: 1000,
},
};
const default3dOptions: With3dOptions = {
maxAngle: 35,
perspective: 500,
};
class Cattract {
/**
* Create a circle attraction animation on an element
* @param target The element you want to be animated
* @param options The differents options
*/
constructor(public target: HTMLElement, public options: Options = {}) {
this.options = Object.assign({}, defaultOptions, options);
this.options.animation = Object.assign(
{},
defaultOptions.animation,
options.animation ?? {}
);
if (this.options.with_3d) {
this.options.with_3d = Object.assign(
{},
default3dOptions,
this.options.with_3d === true ? {} : this.options.with_3d
);
}
this.target.style.transformOrigin = "center";
this.start();
}
/**
* Apply translation on the target with animation
* @param translation
*/
private applyTranslation(translation: string) {
this.target.animate([{ transform: translation }], {
duration: this.options.animation.duration,
fill: "forwards",
easing: this.options.animation.ease,
});
// this.target.style.transform = translation;
}
/**
* Return the screen radius
* @returns
*/
private getScreenRadius() {
return Math.max(window.innerWidth, window.innerHeight) / 2;
}
/**
* Get delta for a specified invertion
* @param invertion
* @returns
*/
private getDeltaFromInvertion(invertion: boolean | "x" | "y") {
const delta = { x: 1, y: 1 };
if (invertion === true) {
delta.x = delta.y = -1;
} else if (invertion === "x") {
delta.x = -1;
} else if (invertion === "y") {
delta.y = -1;
}
return delta;
}
/**
* Create a circle for the debug
* @param radius
* @returns
*/
private createCircle(radius: number, color: string) {
const circle = document.createElement("div");
circle.setAttribute(
"style",
`
position: absolute;
background-color: transparent;
width: ${radius * 2}px;
aspect-ratio: 1/1;
border: thin ${color} solid;
border-radius: 100%;
`
.replace(/\n+/g, "")
.replace(/\s+/g, " ")
);
return circle;
}
/**
* Display circle for detection zone and the element circle for better debugging
* @param color
*/
debug(color: string = "#e1e1e130") {
this.target.parentElement.style.position = "relative";
const append = (element: HTMLDivElement) => {
this.target.parentNode.insertBefore(element, this.target);
};
const elementRadiusCircle = this.createCircle(
this.options.elementRadius,
color
);
append(elementRadiusCircle);
if (this.options.detectionRadius !== "full") {
const detectionRadiusCircle = this.createCircle(
this.options.detectionRadius,
color
);
append(detectionRadiusCircle);
}
}
/**
* Get the actual needed transformation properties of the element
* @returns
*/
private getTransformation() {
const computedStyles = window.getComputedStyle(this.target);
const transformValue = computedStyles.transform;
let translationX = 0;
let translationY = 0;
let scaleX = 1;
let scaleY = 1;
if (transformValue && transformValue !== "none") {
const matrixRegex = /(matrix3d|matrix)\(([^)]+)\)/;
const match = transformValue.match(matrixRegex);
if (match) {
const matrixValues = match[2].split(",").map(parseFloat);
if (match[1] === "matrix") {
// 2D transformation matrix
scaleX = matrixValues[0];
scaleY = matrixValues[3];
translationX = matrixValues[4];
translationY = matrixValues[5];
} else if (match[1] === "matrix3d") {
// 3D transformation matrix
scaleX = matrixValues[0];
scaleY = matrixValues[5];
translationX = matrixValues[12];
translationY = matrixValues[13];
// const translationZ = matrixValues[14];
// const scaleZ = matrixValues[10];
}
}
}
return { translationX, translationY, scaleX, scaleY };
}
/**
* Update the position of the targer
* @param x The x position of the cursor
* @param y The y position of the cursor
*/
update(x: number, y: number) {
//TODO: Fixe this shit (fuckng lagging)
const rect = this.target.getBoundingClientRect();
const trans = this.getTransformation();
const width = rect.width / trans.scaleX;
const height = rect.height / trans.scaleY;
const targetMiddleX = rect.left - trans.translationX + width / 2;
const targetMiddleY = rect.top - trans.translationY + height / 2;
const [dx, dy] = [x - targetMiddleX, y - targetMiddleY];
const mouseRadius = Math.sqrt(dx * dx + dy * dy);
if (
this.options.detectionRadius === "full" ||
mouseRadius <= this.options.detectionRadius
) {
const transformations: string[] = [];
const hypp = Math.sqrt(dx * dx + dy * dy);
const tx = hypp === 0 ? 0 : dx / hypp;
const ty = hypp === 0 ? 0 : dy / hypp;
const totalRadius =
this.options.detectionRadius === "full"
? this.getScreenRadius()
: this.options.detectionRadius;
const pourcentage = hypp / totalRadius;
const computedRadius = this.options.elementRadius * pourcentage;
/* Handle 3D effect */
if (this.options.with_3d) {
const options3d = this.options.with_3d as With3dOptions;
const delta = this.getDeltaFromInvertion(options3d.inverted);
const computedAngle = options3d.maxAngle * pourcentage;
transformations.push(`perspective(${options3d.perspective}px)`);
if (!options3d.axe || options3d.axe === "x")
transformations.push(`rotateX(${ty * computedAngle * delta.x}deg)`);
if (!options3d.axe || options3d.axe === "y")
transformations.push(`rotateY(${-tx * computedAngle * delta.y}deg)`);
}
/* Handle scale */
if (this.options.scale?.to) {
const scaleOptions = this.options.scale;
const scaleFrom = scaleOptions.from ?? 1;
let finalScale = scaleOptions.to - scaleFrom;
if (scaleOptions.animated) finalScale *= pourcentage;
transformations.push(`scale(${scaleFrom + finalScale})`);
}
/* Handle translation */
const delta = this.getDeltaFromInvertion(this.options.inverted);
const [transX, transY] = [
tx * computedRadius * delta.x,
ty * computedRadius * delta.y,
];
if (!this.options.axe || this.options.axe === "x")
transformations.push(`translateX(${transX}px)`);
if (!this.options.axe || this.options.axe === "y")
transformations.push(`translateY(${transY}px)`);
this.applyTranslation(transformations.join(" "));
} else {
if (this.options.scale?.from)
this.applyTranslation(`scale(${this.options.scale.from})`);
else this.applyTranslation("none");
}
}
/**
* Start attraction animation
*/
start() {
this.target.style.willChange = "transform";
if (this.options.with_3d) this.target.style.transformStyle = "preserve-3d";
if (this.options.scale?.from)
this.target.style.transform = `scale(${this.options.scale.from})`;
instances.push(this);
}
/**
* Stop the attraction animation
*/
stop() {
const index = instances.findIndex(
(instance) => instance.target === this.target
);
if (index !== -1) instances.splice(index, 1);
}
/**
* Reset the element tranformation
*/
reset() {
this.target.style.transform = "none";
}
}
document.addEventListener("DOMContentLoaded", function () {
document.body.addEventListener("mousemove", function (event) {
const [x, y] = [event.clientX, event.clientY];
for (const instance of instances) {
if (!instance.target.isConnected) {
instance.stop();
continue;
}
instance.update(x, y);
}
});
});
export default Cattract;