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'); }