578 lines
19 KiB
JavaScript
578 lines
19 KiB
JavaScript
const CURSOR_INFLUENCE_INNER = 150;
|
|
const CURSOR_INFLUENCE_OUTER = 300;
|
|
const EASING_FACTOR = 0.03;
|
|
|
|
const COLORS = [
|
|
'#e0775d',
|
|
'#7cc0c6',
|
|
'#e05680',
|
|
'#46ae51',
|
|
'#bb8dd9',
|
|
'#f7ff29',
|
|
];
|
|
|
|
class FloatingShape {
|
|
constructor(element, originalX, originalY, width, height) {
|
|
this.element = element;
|
|
this.width = width;
|
|
this.height = height;
|
|
this.originalX = originalX;
|
|
this.originalY = originalY;
|
|
this.posX = originalX;
|
|
this.posY = originalY;
|
|
this.targetX = originalX;
|
|
this.targetY = originalY;
|
|
}
|
|
|
|
update(mouseX, mouseY) {
|
|
const shapeCenterX = this.originalX + this.width / 2;
|
|
const shapeCenterY = this.originalY + this.height / 2;
|
|
|
|
const dx = mouseX - shapeCenterX;
|
|
const dy = mouseY - shapeCenterY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
const innerRadius = CURSOR_INFLUENCE_INNER / 2;
|
|
const outerRadius = CURSOR_INFLUENCE_OUTER / 2;
|
|
|
|
if (distance < outerRadius && distance > 0) {
|
|
const dirX = dx / distance;
|
|
const dirY = dy / distance;
|
|
|
|
let strength, maxDisplacement;
|
|
|
|
if (distance < innerRadius) {
|
|
strength = (innerRadius - distance) / innerRadius;
|
|
maxDisplacement = innerRadius;
|
|
} else {
|
|
const outerZoneProgress = (outerRadius - distance) / (outerRadius - innerRadius);
|
|
strength = outerZoneProgress * 0.5;
|
|
maxDisplacement = innerRadius * 0.6;
|
|
}
|
|
|
|
this.targetX = this.originalX - dirX * strength * maxDisplacement;
|
|
this.targetY = this.originalY - dirY * strength * maxDisplacement;
|
|
} else {
|
|
this.targetX = this.originalX;
|
|
this.targetY = this.originalY;
|
|
}
|
|
|
|
this.posX += (this.targetX - this.posX) * EASING_FACTOR;
|
|
this.posY += (this.targetY - this.posY) * EASING_FACTOR;
|
|
}
|
|
|
|
render() {
|
|
this.element.style.transform = `translate3d(${this.posX}px, ${this.posY}px, 0)`;
|
|
}
|
|
}
|
|
|
|
class FloatingShapesManager {
|
|
constructor(containerId) {
|
|
this.container = document.getElementById(containerId);
|
|
if (!this.container) {
|
|
console.error(`Container #${containerId} not found`);
|
|
return;
|
|
}
|
|
|
|
this.shapes = [];
|
|
this.strokePaths = []; // Store all stroke paths for fill control
|
|
this.fillShape = null; // Reference to the filled shape SVG for fade control
|
|
this.fillShapeReady = false; // Track if fillshape animation has completed
|
|
this.thalimText = null; // Reference to the THALIM text element
|
|
this.textReady = false; // Track if text animation has completed
|
|
this.mouseX = 0;
|
|
this.mouseY = 0;
|
|
this.animationId = null;
|
|
this.isTouching = false; // Track if currently in a touch interaction
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const containerRect = this.container.getBoundingClientRect();
|
|
const containerWidth = containerRect.width;
|
|
const containerHeight = containerRect.height;
|
|
|
|
const shapeConfigs = [
|
|
{
|
|
id: 'shape-1',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/shape1.svg`,
|
|
baseWidth: 53.564522,
|
|
baseHeight: 112.37409,
|
|
scale: 1.5,
|
|
posX: this.isMobile() ? 20: 35,
|
|
posY: -20,
|
|
strokeWidth: 2,
|
|
animationDuration: 1.9,
|
|
gradientStart: COLORS[0], // orange
|
|
gradientEnd: '#e0b7ad'
|
|
},
|
|
{
|
|
id: 'shape-2',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/shape4.svg`,
|
|
baseWidth: 74.08564,
|
|
baseHeight: 121.90051,
|
|
scale: 1.5,
|
|
posX: 0,
|
|
posY: this.isMobile() ? -8 : -5,
|
|
strokeWidth: 2,
|
|
animationDuration: 2.5,
|
|
gradientStart: '#aec4c6',
|
|
gradientEnd: COLORS[1] // blue
|
|
},
|
|
{
|
|
id: 'shape-3',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/shape3.svg`,
|
|
baseWidth: 159.16571,
|
|
baseHeight: 87.756729,
|
|
scale: 1.5,
|
|
posX: 0,
|
|
posY: -10,
|
|
strokeWidth: 2,
|
|
animationDuration: 3.1,
|
|
gradientStart: COLORS[2], // pink
|
|
gradientEnd: '#e0b0be'
|
|
},
|
|
{
|
|
id: 'shape-4',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/shape2.svg`,
|
|
baseWidth: 143.26076,
|
|
baseHeight: 20.26207,
|
|
scale: 1.5,
|
|
posX: 0,
|
|
posY: this.isMobile() ? 20 : 45,
|
|
strokeWidth: 2,
|
|
animationDuration: 3.7,
|
|
gradientStart: '#8bc491',
|
|
gradientEnd: COLORS[3] // green
|
|
},
|
|
{
|
|
id: 'shape-5',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/shape5.svg`,
|
|
baseWidth: 155.66518,
|
|
baseHeight: 87.785599,
|
|
scale: 1.5,
|
|
posX: this.isMobile() ? 13 : 19.5,
|
|
posY: this.isMobile() ? -18 : -23.5,
|
|
strokeWidth: 2,
|
|
animationDuration: 4.3,
|
|
gradientStart: COLORS[4], // purple
|
|
gradientEnd: '#c9b0d9'
|
|
},
|
|
{
|
|
id: 'fillshape',
|
|
svgPath: `${themeDirURI}/assets/logo-shapes/fillshape.svg`,
|
|
baseWidth: 142.78297,
|
|
baseHeight: 72.903015,
|
|
scale: this.isMobile() ? 0.78 : 1.47,
|
|
posX: this.isMobile() ? 7.5 : 11,
|
|
posY: this.isMobile() ? -13 : -14.5,
|
|
isFilled: true,
|
|
targetOpacity: 1,
|
|
animationDuration: 0.9,
|
|
animationDelay: 3.7 // Start after longest stroke animation
|
|
}
|
|
];
|
|
|
|
// Create shapes with center-based positioning
|
|
const centerX = containerWidth / 2;
|
|
const centerY = containerHeight / 2;
|
|
|
|
// Apply mobile scale on smaller viewports
|
|
const mobileScale = 0.8;
|
|
shapeConfigs.forEach((config) => {
|
|
const scale = config.id === 'fillshape' ? config.scale : this.isMobile() ? mobileScale : config.scale;
|
|
const scaledWidth = config.baseWidth * scale;
|
|
const scaledHeight = config.baseHeight * scale;
|
|
|
|
// Calculate position from center with offset, minus half dimensions to center the shape
|
|
const x = centerX + (config.posX || 0) - scaledWidth / 2;
|
|
const y = centerY + (config.posY || 0) - scaledHeight / 2;
|
|
|
|
// Create animated stroke element
|
|
this.createAnimatedStrokeElement(config, scaledWidth, scaledHeight, x, y);
|
|
});
|
|
|
|
// Create THALIM text overlay
|
|
this.createThalimText(centerX, centerY);
|
|
|
|
this.container.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
this.container.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
|
this.container.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
|
this.container.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
|
this.container.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
|
window.addEventListener('resize', this.handleResize.bind(this));
|
|
|
|
// Set sketch margin on mobile based on hero-logos height
|
|
this.setSketchMarginOnMobile();
|
|
|
|
this.animate();
|
|
}
|
|
|
|
isMobile() {
|
|
return window.innerWidth < 768; // tablet breakpoint from scss/_variables.scss
|
|
}
|
|
|
|
setSketchMarginOnMobile() {
|
|
if (this.isMobile()) {
|
|
const heroLogos = document.querySelector('.hero-logos');
|
|
if (heroLogos) {
|
|
const logoHeight = heroLogos.offsetHeight;
|
|
this.container.style.marginTop = `${logoHeight}px`;
|
|
}
|
|
} else {
|
|
// Reset margin-top on desktop (handled by CSS)
|
|
this.container.style.marginTop = '';
|
|
}
|
|
}
|
|
|
|
async createAnimatedStrokeElement(config, width, height, x, y) {
|
|
try {
|
|
const response = await fetch(config.svgPath);
|
|
const svgText = await response.text();
|
|
|
|
const parser = new DOMParser();
|
|
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
|
|
const svgElement = svgDoc.querySelector('svg');
|
|
|
|
if (!svgElement) {
|
|
console.error(`Failed to parse SVG for ${config.id}`);
|
|
return;
|
|
}
|
|
|
|
// Create shape div for physics
|
|
const shapeDiv = document.createElement('div');
|
|
shapeDiv.className = 'floating-shape';
|
|
shapeDiv.id = config.id;
|
|
shapeDiv.style.zIndex = config.isFilled ? '10' : '1'; // Fillshape above strokes
|
|
|
|
// Set SVG dimensions
|
|
svgElement.setAttribute('width', width);
|
|
svgElement.setAttribute('height', height);
|
|
|
|
if (config.isFilled) {
|
|
// Handle filled shapes (fade-in animation)
|
|
svgElement.style.opacity = '0';
|
|
svgElement.style.transition = 'opacity 0.3s ease-out'; // Smooth fade on mouse interaction
|
|
const duration = config.animationDuration || 1.5;
|
|
const delay = config.animationDelay || 0;
|
|
const targetOpacity = config.targetOpacity || 0.5;
|
|
|
|
svgElement.style.setProperty('--target-opacity', targetOpacity);
|
|
svgElement.style.animation = `fadeIn ${duration}s ease-in-out ${delay}s forwards`;
|
|
|
|
// Remove animation after it completes so we can control opacity manually
|
|
setTimeout(() => {
|
|
svgElement.style.animation = 'none';
|
|
svgElement.style.opacity = targetOpacity;
|
|
this.fillShapeReady = true; // Mark fillshape as ready for interaction
|
|
}, (delay + duration) * 1000);
|
|
|
|
// Store reference for mouse interaction
|
|
this.fillShape = svgElement;
|
|
this.fillShapeOpacity = targetOpacity;
|
|
|
|
// Position the fillshape div (since it's not in the physics system)
|
|
shapeDiv.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
} else {
|
|
// Handle stroked shapes (gradient + stroke drawing animation)
|
|
// Create gradient for stroke
|
|
const gradientId = `gradient-${config.id}`;
|
|
|
|
// Create defs element if it doesn't exist
|
|
let defs = svgElement.querySelector('defs');
|
|
if (!defs) {
|
|
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
svgElement.insertBefore(defs, svgElement.firstChild);
|
|
}
|
|
|
|
// Create linear gradient
|
|
const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
|
gradient.setAttribute('id', gradientId);
|
|
gradient.setAttribute('x1', '0%');
|
|
gradient.setAttribute('y1', '0%');
|
|
gradient.setAttribute('x2', '100%');
|
|
gradient.setAttribute('y2', '100%');
|
|
|
|
// Create gradient stops
|
|
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
stop1.setAttribute('offset', '0%');
|
|
stop1.setAttribute('stop-color', config.gradientStart);
|
|
|
|
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
stop2.setAttribute('offset', '100%');
|
|
stop2.setAttribute('stop-color', config.gradientEnd);
|
|
|
|
gradient.appendChild(stop1);
|
|
gradient.appendChild(stop2);
|
|
defs.appendChild(gradient);
|
|
|
|
// Apply stroke styling and animation to all paths in the SVG
|
|
const paths = svgElement.querySelectorAll('path, polyline, polygon, line, circle, ellipse, rect');
|
|
paths.forEach(path => {
|
|
// Set stroke properties
|
|
path.style.fill = 'white';
|
|
path.style.fillOpacity = '0'; // Start invisible
|
|
path.style.transition = 'fill-opacity 0.5s ease-in-out'; // Smooth fill changes
|
|
path.style.stroke = `url(#${gradientId})`;
|
|
// Don't override stroke-width - preserve SVG's compensated values
|
|
path.style.strokeLinecap = 'round';
|
|
path.style.strokeLinejoin = 'round';
|
|
|
|
// Calculate path length for animation
|
|
const pathLength = path.getTotalLength ? path.getTotalLength() : 1000;
|
|
|
|
// Set CSS variable for path length
|
|
path.style.setProperty('--path-length', pathLength);
|
|
|
|
// Set up stroke dash animation
|
|
path.style.strokeDasharray = pathLength;
|
|
path.style.strokeDashoffset = pathLength;
|
|
|
|
// Create CSS animation (plays once and stays completed)
|
|
const duration = config.animationDuration || 3;
|
|
path.style.animation = `drawStroke ${duration}s ease-in-out forwards`;
|
|
|
|
// Store path reference for fill control
|
|
this.strokePaths.push(path);
|
|
});
|
|
|
|
// Fade in white fill when text starts appearing (at 4.3s)
|
|
setTimeout(() => {
|
|
paths.forEach(path => {
|
|
path.style.fillOpacity = '0.7';
|
|
});
|
|
}, 4300);
|
|
}
|
|
|
|
// Append SVG to shape div
|
|
shapeDiv.appendChild(svgElement);
|
|
this.container.appendChild(shapeDiv);
|
|
|
|
// Create FloatingShape instance (only for shapes with physics, not filled shapes)
|
|
if (!config.isFilled) {
|
|
const floatingShape = new FloatingShape(shapeDiv, x, y, width, height);
|
|
this.shapes.push(floatingShape);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`Error loading SVG for ${config.id}:`, error);
|
|
}
|
|
}
|
|
|
|
createThalimText(centerX, centerY) {
|
|
|
|
// Create container for the text
|
|
const textContainer = document.createElement('div');
|
|
textContainer.className = 'thalim-text';
|
|
textContainer.style.top = '-20px';
|
|
textContainer.style.left = '10px';
|
|
textContainer.style.transform = `translate3d(${centerX}px, ${centerY}px, 0) translate(-50%, -50%)`; // Center the text
|
|
textContainer.style.color = '#000000cc';
|
|
|
|
// Create individual letter spans
|
|
const letters = 'thalim'.split('');
|
|
letters.forEach((letter, index) => {
|
|
const span = document.createElement('span');
|
|
span.textContent = letter;
|
|
span.style.opacity = '0';
|
|
span.style.animation = `letterAppear 0.8s ease-in-out ${4.2 + index * 0.1}s forwards`;
|
|
textContainer.appendChild(span);
|
|
});
|
|
|
|
// Mark text as ready after last letter finishes animating
|
|
// Last letter: 4.2s + 0.5s (6th letter) + 0.8s (duration) = 5.5s
|
|
setTimeout(() => {
|
|
this.textReady = true;
|
|
}, 5500);
|
|
|
|
this.container.appendChild(textContainer);
|
|
this.thalimText = textContainer;
|
|
}
|
|
|
|
handleMouseMove(e) {
|
|
// Ignore mouse events during touch interactions (prevents interference from synthetic mouse events)
|
|
if (this.isTouching) {
|
|
return;
|
|
}
|
|
|
|
const rect = this.container.getBoundingClientRect();
|
|
this.mouseX = e.clientX - rect.left;
|
|
this.mouseY = e.clientY - rect.top;
|
|
|
|
// Fade out fillshape and text on mouse move (only if they've finished appearing)
|
|
if (this.fillShape && this.fillShapeReady) {
|
|
this.fillShape.style.opacity = '0';
|
|
}
|
|
if (this.thalimText && this.textReady) {
|
|
this.thalimText.style.opacity = '0';
|
|
}
|
|
// Fade out white fills on stroke shapes
|
|
this.strokePaths.forEach(path => {
|
|
path.style.fillOpacity = '0';
|
|
});
|
|
}
|
|
|
|
handleMouseLeave() {
|
|
// Move mouse far away to trigger return animation
|
|
this.mouseX = -10000;
|
|
this.mouseY = -10000;
|
|
|
|
// Fade fillshape and text back in after delay (let strokes settle first)
|
|
// Only if they have completed their initial animations
|
|
if (this.fillShape && this.fillShapeReady) {
|
|
setTimeout(() => {
|
|
this.fillShape.style.opacity = this.fillShapeOpacity;
|
|
}, 800); // Delay to allow strokes to return to position
|
|
}
|
|
if (this.thalimText && this.textReady) {
|
|
setTimeout(() => {
|
|
this.thalimText.style.opacity = '1';
|
|
}, 800);
|
|
}
|
|
// Fade white fills back in on stroke shapes
|
|
setTimeout(() => {
|
|
this.strokePaths.forEach(path => {
|
|
path.style.fillOpacity = '0.7';
|
|
});
|
|
}, 800);
|
|
}
|
|
|
|
handleTouchStart() {
|
|
// Mark that we're in a touch interaction to prevent mouse event interference
|
|
this.isTouching = true;
|
|
}
|
|
|
|
handleTouchMove(e) {
|
|
e.preventDefault(); // Prevent scrolling while interacting with sketch
|
|
const rect = this.container.getBoundingClientRect();
|
|
const touch = e.touches[0];
|
|
this.mouseX = touch.clientX - rect.left;
|
|
this.mouseY = touch.clientY - rect.top;
|
|
|
|
// Fade out fillshape and text on touch move (only if they've finished appearing)
|
|
if (this.fillShape && this.fillShapeReady) {
|
|
this.fillShape.style.opacity = '0';
|
|
}
|
|
if (this.thalimText && this.textReady) {
|
|
this.thalimText.style.opacity = '0';
|
|
}
|
|
// Fade out white fills on stroke shapes
|
|
this.strokePaths.forEach(path => {
|
|
path.style.fillOpacity = '0';
|
|
});
|
|
}
|
|
|
|
handleTouchEnd() {
|
|
// Reset shapes to original position
|
|
this.handleMouseLeave();
|
|
|
|
// Clear touch flag after a delay to ignore synthetic mouse events
|
|
setTimeout(() => {
|
|
this.isTouching = false;
|
|
}, 500);
|
|
}
|
|
|
|
handleResize() {
|
|
// Don't refresh on mobile (prevents refresh during scroll when browser bar appears/disappears)
|
|
if (this.isMobile()) {
|
|
// Still update margin-top on mobile if needed
|
|
this.setSketchMarginOnMobile();
|
|
return;
|
|
}
|
|
|
|
// Debounced resize: only recreate after user stops resizing for 250ms
|
|
// This prevents page crashes from rapid resize events
|
|
if (this.resizeTimeout) {
|
|
clearTimeout(this.resizeTimeout);
|
|
}
|
|
|
|
this.resizeTimeout = setTimeout(() => {
|
|
// Stop animation loop before destroying
|
|
if (this.animationId) {
|
|
cancelAnimationFrame(this.animationId);
|
|
this.animationId = null;
|
|
}
|
|
|
|
// Clear shapes and container
|
|
this.shapes = [];
|
|
this.container.innerHTML = '';
|
|
|
|
// Reinitialize
|
|
this.init();
|
|
}, 250);
|
|
}
|
|
|
|
animate() {
|
|
// Update all shapes
|
|
this.shapes.forEach(shape => {
|
|
shape.update(this.mouseX, this.mouseY);
|
|
shape.render();
|
|
});
|
|
|
|
// Continue loop
|
|
this.animationId = requestAnimationFrame(this.animate.bind(this));
|
|
}
|
|
|
|
destroy() {
|
|
if (this.animationId) {
|
|
cancelAnimationFrame(this.animationId);
|
|
}
|
|
if (this.resizeTimeout) {
|
|
clearTimeout(this.resizeTimeout);
|
|
}
|
|
this.container.removeEventListener('mousemove', this.handleMouseMove);
|
|
this.container.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
this.container.removeEventListener('touchstart', this.handleTouchStart);
|
|
this.container.removeEventListener('touchmove', this.handleTouchMove);
|
|
this.container.removeEventListener('touchend', this.handleTouchEnd);
|
|
window.removeEventListener('resize', this.handleResize);
|
|
}
|
|
}
|
|
|
|
// Inject CSS for stroke and fade animations
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes drawStroke {
|
|
0% {
|
|
stroke-dashoffset: var(--path-length);
|
|
opacity: 0;
|
|
}
|
|
10% {
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
stroke-dashoffset: 0;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
100% {
|
|
opacity: var(--target-opacity);
|
|
}
|
|
}
|
|
|
|
@keyframes letterAppear {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.floatingShapes = new FloatingShapesManager('sketch');
|
|
});
|
|
} else {
|
|
window.floatingShapes = new FloatingShapesManager('sketch');
|
|
}
|