Created
September 17, 2025 13:13
-
-
Save zalez/80685e2406c01f60344839f879ccdf78 to your computer and use it in GitHub Desktop.
Constantin Thinking blog banner autumn animation JavaScript code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Autumn seasonal animation. | |
| * Create a number of fall leaves that flow across the banner, simulating wind. | |
| */ | |
| // Leaf parameters | |
| const DEG_TO_RAD = Math.PI / 180; | |
| const maxLeaves = 50; | |
| const leafSpawnX = 1.1; // * banner width | |
| const leafSpawnXVariation = 0.1; | |
| const leafSpawnY = -1.0; // * banner height | |
| const leafSpawnYVariation = 2.0; | |
| const minLeafSpawnInterval = 10; // ms | |
| const maxLeavSpawnInterval = 100; // ms | |
| const leafScale = 20; // SVG viewBox units | |
| const leafScaleVariation = 0.2; | |
| const leafRotationDeadZone = 10; // min degrees that avoids flat objects becoming invisible | |
| const leafZBlurRange = 0.3; | |
| // Wind and physics parameters | |
| const defaultWindSpeed = -3 / 1000; // units per millisecond in x direction | |
| const gravity = 1 / 1000; // momentum per ms in y direction | |
| const friction = 10 / 1000; // speed loss factor per ms in any direction | |
| const rotation = 360 / 1000; // degrees per second | |
| const rotationVariation = 2; // -1 .. 1 times rotation | |
| const windRotationInfluence = 10; // How much the wind affects rotation | |
| const windMinAreaFactor = 0.3; // Minimum effect of wind on leaves even if edge-on | |
| const windToggleProbability = 2 / 1000; // probability of switching wind on/off per ms | |
| const windGustProbability = 0.5 / 1000; // per ms | |
| const windGustFactor = 2; | |
| const windGustVariation = 0.5; | |
| const windReturnFactor = 10 / 1000; // How much to return to normal wind speed per ms | |
| const windLiftFactor = 0.1; // How much of the wind is available for pos/neg lift. | |
| // Rain parameters | |
| const rainPatternSize = 250; | |
| const rainDropsPerTile = rainPatternSize / 5; | |
| const rainDropsLength = 20; | |
| const rainDropsLengthVariation = 0.4; | |
| const rainColor = "rgba(0, 100, 192, 0.6)"; | |
| const rainDefaultOpacity = 0.5; | |
| const rainOpacityVariability = 0.2; | |
| const rainToggleProbability = 0.5 / 1000; | |
| const rainSpeed = 0.8; | |
| const rainAngle = -0.4; // x units per y unit down. | |
| const rainLayers = 5; | |
| const rainDepthLayers = 3; // near, mid, far | |
| const zFactorSpread = 2; | |
| // HUD parameters | |
| const messageTextDisplayTime = 2000; // ms | |
| const leafColors = [ | |
| "#D2691E", | |
| "#CD853F", | |
| "#B22222", | |
| "#DAA520", | |
| "#8B4513", | |
| "#FF8C00", | |
| ]; | |
| const leafPaths = [ | |
| "M 0 0.21 Q 0.09 0.48 0.36 0.5 Q 0.35 0.3 0.23 0.21 Q 0.47 0.11 0.54 -0.14 Q 0.29 -0.11 0.15 0.04 Q 0.17 -0.26 0 -0.5 Q -0.17 -0.26 -0.15 0.04 Q -0.29 -0.11 -0.54 -0.14 Q -0.47 0.11 -0.23 0.21 Q -0.35 0.3 -0.36 0.5 Q -0.09 0.48 0 0.21 Q 0.01 0.35 0.02 0.5 Q 0.015 0.5 0.01 0.5 Q -0.02 0.35 0 0.21", // Acorn leaf | |
| "M 0 0.33 Q -0.42 0.07 0 -0.5 Q 0.42 0.07 0 0.33 M 0.02 0.5 Q -0.02 0.33 0 -0.5", // Oak leaf. | |
| "M 0 -0.5 Q -0.14 -0.5 -0.14 -0.25 Q -0.42 -0.32 -0.17 -0.07 Q -0.45 -0.09 -0.16 0.19 Q -0.34 0.31 0 0.4 Q 0.34 0.31 0.17 0.19 Q 0.45 -0.09 0.17 -0.07 Q 0.42 -0.32 0.14 -0.25 Q 0.14 -0.5 0 -0.5 Q -0.03 0.5 0.03 0.5", // Pappel leaf | |
| ]; | |
| class Leaf { | |
| constructor(parentGroup, zFactor) { | |
| this.parentGroup = parentGroup; // Where this leave lives. | |
| this.element = null; // the SVG element for this leaf. | |
| this.color = leafColors[Math.floor(Math.random() * leafColors.length)]; | |
| this.zFactor = zFactor; // 0..1, how far the leaf is in Z-direction. | |
| this.x = 0; | |
| this.y = 0; | |
| this.xMomentum = 0; | |
| this.yMomentum = 0; | |
| this.rotateX = 0; | |
| this.rotateY = 0; | |
| this.rotateZ = 0; | |
| this.rotateMomentumX = rotation * (Math.random() - 0.5) * rotationVariation; | |
| this.rotateMomentumY = rotation * (Math.random() - 0.5) * rotationVariation; | |
| this.rotateMomentumZ = rotation * (Math.random() - 0.5) * rotationVariation; | |
| this.scale = 10 * (1 + this.zFactor * zFactorSpread); // px | |
| this.createSvgElement(); | |
| } | |
| createSvgElement() { | |
| this.element = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "path", | |
| ); | |
| const path = leafPaths[Math.floor(Math.random() * leafPaths.length)]; | |
| this.element.setAttribute("d", path); | |
| this.element.setAttribute("fill", this.color); | |
| this.element.setAttribute("stroke", "#000000"); | |
| this.element.setAttribute("stroke-width", "0.02"); | |
| this.element.setAttribute("stroke-linejoin", "round"); | |
| this.element.style.opacity = 0.3 + 0.7 * this.zFactor; // Far=30%, close=100% | |
| // Depth-based blur | |
| const blurAmount = leafZBlurRange - this.zFactor * leafZBlurRange; // subtle blur | |
| if (blurAmount > 0.1) { | |
| this.element.style.filter = `blur(${blurAmount}px)`; | |
| } | |
| this.parentGroup.appendChild(this.element); | |
| this.updateTransform(); | |
| } | |
| avoidCompleteInvisibility(angle) { | |
| const normalized = ((angle % 360) + 360) % 360; | |
| // Check for problematic angles: 90°, 270° (edge-on views) | |
| if (Math.abs(normalized - 90) < leafRotationDeadZone) { | |
| return normalized < 90 | |
| ? 90 - leafRotationDeadZone | |
| : 90 + leafRotationDeadZone; | |
| } | |
| if (Math.abs(normalized - 270) < leafRotationDeadZone) { | |
| return normalized < 270 | |
| ? 270 - leafRotationDeadZone | |
| : 270 + leafRotationDeadZone; | |
| } | |
| return angle; // Safe as-is | |
| } | |
| // Update the SVG transform element to match the object’s x, y and rotation values. | |
| updateTransform() { | |
| const rotateX = this.avoidCompleteInvisibility(this.rotateX); | |
| const rotateY = this.avoidCompleteInvisibility(this.rotateY); | |
| const transform = `translate(${this.x}px, ${this.y}px) scale(${this.scale}) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${this.rotateZ}deg)`; | |
| this.element.style.transform = transform; | |
| } | |
| applyMomentum(deltaTime) { | |
| const speedFactor = 1 + (1 - this.zFactor) * zFactorSpread; | |
| this.x += this.xMomentum * deltaTime * speedFactor; | |
| this.y += this.yMomentum * deltaTime * speedFactor; | |
| this.rotateX = (this.rotateX + this.rotateMomentumX * deltaTime) % 360; | |
| this.rotateY = (this.rotateY + this.rotateMomentumY * deltaTime) % 360; | |
| this.rotateZ = (this.rotateZ + this.rotateMomentumZ * deltaTime) % 360; | |
| } | |
| applyGravity(deltaTime) { | |
| this.yMomentum += deltaTime * gravity; | |
| } | |
| applyFriction(deltaTime) { | |
| const frictionFactor = Math.pow(1 - friction, deltaTime); | |
| this.xMomentum *= frictionFactor; | |
| this.yMomentum *= frictionFactor; | |
| this.rotateMomentumX *= frictionFactor; | |
| this.rotateMomentumY *= frictionFactor; | |
| this.rotateMomentumZ *= frictionFactor; | |
| } | |
| animate(deltaTime) { | |
| // Update our position/rotation based on elapsed time. | |
| // To be added later. | |
| this.applyMomentum(deltaTime); | |
| this.applyGravity(deltaTime); | |
| this.applyFriction(deltaTime); | |
| this.updateTransform(); | |
| } | |
| destroy() { | |
| if (this.element) { | |
| this.element.remove(); | |
| } | |
| } | |
| } | |
| export default { | |
| name: "autumn", // For debugging/logging | |
| parentSvg: null, // the SVG element to attach ourselves to | |
| width: null, | |
| height: null, | |
| layerGroups: null, | |
| animationId: null, | |
| leaves: [], | |
| rainPatterns: [], | |
| rainRects: [], | |
| targetWindSpeed: defaultWindSpeed, | |
| windSpeed: defaultWindSpeed, | |
| // Rain state | |
| rainIntensity: 0, | |
| rainTargetIntensity: 0, | |
| // Interactive controls | |
| mouseInBanner: false, | |
| keyboardListener: null, | |
| mouseEnterListener: null, | |
| mouseLeaveListener: null, | |
| // Performance tracking | |
| frameCount: 0, | |
| lastFpsUpdate: 0, | |
| currentFps: 60, | |
| executionTime: 0, | |
| cpuUsage: 0, | |
| // HUD | |
| hudElement: null, | |
| hudMessage: "", // For quick update messages | |
| hudMessageLastShown: 0, | |
| hudLastContent: "", | |
| hudHintElement: null, | |
| hudEverActivated: false, // Track if user discovered HUD | |
| // Visibility controls | |
| isVisible: true, | |
| intersectionObserver: null, | |
| visibilityChangeListener: null, | |
| createRainPattern(zFactor, patternId) { | |
| const defs = | |
| this.parentSvg.querySelector("defs") || | |
| this.parentSvg.insertBefore( | |
| document.createElementNS("http://www.w3.org/2000/svg", "defs"), | |
| this.parentSvg.firstChild, | |
| ); | |
| const pattern = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "pattern", | |
| ); | |
| pattern.id = patternId; | |
| pattern.setAttribute("x", "0"); | |
| pattern.setAttribute("y", "0"); | |
| pattern.setAttribute("width", rainPatternSize); | |
| pattern.setAttribute("height", rainPatternSize); | |
| pattern.setAttribute("patternUnits", "userSpaceOnUse"); | |
| // Scale rain drop size based on zFactor | |
| const dropScale = 0.5 + zFactor * 1.5; // 0.5x to 2x scale | |
| const strokeWidth = 2 + (zFactor - 0.5); // 2px +/- 0.5 | |
| // Create rain drops in pattern | |
| for (let i = 0; i < rainDropsPerTile; i++) { | |
| const line = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "line", | |
| ); | |
| const x = Math.random() * rainPatternSize; | |
| const y = Math.random() * rainPatternSize; | |
| const length = | |
| rainDropsLength * | |
| dropScale * | |
| (Math.random() * 2 - 1) * | |
| rainDropsLengthVariation; | |
| line.setAttribute("x1", x); | |
| line.setAttribute("y1", y); | |
| line.setAttribute("x2", x + length * rainAngle); // Diagonal | |
| line.setAttribute("y2", y + length); | |
| line.setAttribute("stroke", rainColor); | |
| line.setAttribute("stroke-width", strokeWidth); | |
| line.setAttribute("stroke-linecap", "round"); | |
| pattern.appendChild(line); | |
| } | |
| defs.appendChild(pattern); | |
| return pattern; | |
| }, | |
| createRainLayer(group, zFactor) { | |
| const rainRect = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "rect", | |
| ); | |
| rainRect.setAttribute("x", "0"); | |
| rainRect.setAttribute("y", "0"); | |
| rainRect.setAttribute("width", this.width); | |
| rainRect.setAttribute("height", this.height); | |
| const patternLayer = Math.floor(zFactor * rainDepthLayers); | |
| const patternId = `autumn-rain-pattern-${Math.min(patternLayer, rainDepthLayers - 1)}`; | |
| rainRect.setAttribute("fill", `url(#${patternId})`); | |
| rainRect.style.opacity = "0"; // Start invisible | |
| rainRect.classList.add("autumn-rain"); | |
| group.appendChild(rainRect); | |
| return rainRect; | |
| }, | |
| initRainPatterns() { | |
| // Create rain patterns for each depth | |
| for (let layer = 0; layer < rainDepthLayers; layer++) { | |
| const layerZFactor = layer / (rainDepthLayers - 1); | |
| const rainPattern = this.createRainPattern( | |
| layerZFactor, | |
| `autumn-rain-pattern-${layer}`, | |
| ); | |
| this.rainPatterns.push({ | |
| element: rainPattern, | |
| zFactor: layerZFactor, | |
| rainOffset: 0, | |
| }); | |
| } | |
| // Create rain layers with differing z-factors | |
| for (let i = 0; i < rainLayers; i++) { | |
| const zFactor = Math.random(); | |
| const targetGroup = | |
| this.layerGroups[Math.floor(zFactor * this.layerGroups.length)]; | |
| const rainRect = this.createRainLayer(targetGroup, zFactor); | |
| this.rainRects.push({ | |
| element: rainRect, | |
| baseOpacity: | |
| rainDefaultOpacity + (zFactor - 0.5) * rainOpacityVariability, | |
| zFactor: zFactor, | |
| }); | |
| } | |
| }, | |
| showHUDHint() { | |
| if (!this.hudEverActivated && this.hudHintElement) { | |
| this.hudHintElement.style.display = "block"; | |
| } | |
| }, | |
| hideHUDHint() { | |
| if (this.hudHintElement) { | |
| this.hudHintElement.style.display = "none"; | |
| } | |
| }, | |
| showHudMessage(message) { | |
| this.hudMessage = message; | |
| this.hudMessageLastShown = performance.now(); | |
| }, | |
| onMouseEnter() { | |
| this.mouseInBanner = true; | |
| // Show HUD hint for first-time users | |
| this.showHUDHint(); | |
| // Subtle visual feedback | |
| this.parentSvg.style.cursor = "crosshair"; | |
| this.showHudMessage("🎮 Controls active"); | |
| }, | |
| onMouseLeave() { | |
| this.mouseInBanner = false; | |
| // Hide HUD hint when mouse leaves | |
| this.hideHUDHint(); | |
| // Remove visual feedback | |
| this.parentSvg.style.cursor = ""; | |
| this.showHudMessage("💤 Controls inactive"); | |
| }, | |
| windIsOn() { | |
| return Math.abs(this.targetWindSpeed) > 0.0001; | |
| }, | |
| turnWindOn() { | |
| this.targetWindSpeed = defaultWindSpeed; | |
| this.showHudMessage("🌬️ Wind on"); | |
| }, | |
| turnWindOff() { | |
| this.targetWindSpeed = 0.0; | |
| this.showHudMessage("🌬️ Wind off"); | |
| }, | |
| toggleWind() { | |
| if (this.windIsOn()) { | |
| this.turnWindOff(); | |
| } else { | |
| this.turnWindOn(); | |
| } | |
| }, | |
| triggerGust() { | |
| this.windSpeed *= | |
| windGustFactor + (Math.random() - 0.5) * windGustVariation; | |
| this.showHudMessage("💨 Wind gust!"); | |
| }, | |
| rainIsOn() { | |
| return this.rainTargetIntensity != 0; | |
| }, | |
| turnRainOn() { | |
| this.rainTargetIntensity = 0.8 + Math.random() * 0.2; | |
| this.showHudMessage("🌧️ Rain on"); | |
| }, | |
| turnRainOff() { | |
| this.rainTargetIntensity = 0; | |
| this.showHudMessage("🌧️ Rain off"); | |
| }, | |
| toggleRain() { | |
| if (this.rainIsOn()) { | |
| this.turnRainOff(); | |
| } else { | |
| this.turnRainOn(); | |
| } | |
| }, | |
| toggleHUD() { | |
| this.hudVisible = !this.hudVisible; | |
| // Mark HUD as discovered on first activation | |
| if (!this.hudEverActivated) { | |
| this.hudEverActivated = true; | |
| this.hideHUDHint(); | |
| } | |
| if (this.hudVisible) { | |
| this.hudElement.style.display = "block"; | |
| } else { | |
| this.hudElement.style.display = "none"; | |
| } | |
| }, | |
| onKeyDown(event) { | |
| if (!this.mouseInBanner) return; // Only work when mouse is in banner | |
| switch (event.key.toLowerCase()) { | |
| case "w": | |
| this.toggleWind(); | |
| event.preventDefault(); // Prevent default browser behavior | |
| break; | |
| case "g": | |
| this.triggerGust(); | |
| event.preventDefault(); | |
| break; | |
| case "r": | |
| this.toggleRain(); | |
| event.preventDefault(); | |
| break; | |
| case "h": | |
| this.toggleHUD(); | |
| event.preventDefault(); | |
| break; | |
| } | |
| }, | |
| initInteractiveControls() { | |
| // Create and add event listeners for keyboard controls | |
| this.mouseEnterListener = this.onMouseEnter.bind(this); | |
| this.mouseLeaveListener = this.onMouseLeave.bind(this); | |
| this.keyboardListener = this.onKeyDown.bind(this); | |
| this.parentSvg.addEventListener("mouseenter", this.mouseEnterListener); | |
| this.parentSvg.addEventListener("mouseleave", this.mouseLeaveListener); | |
| document.addEventListener("keydown", this.keyboardListener); | |
| }, | |
| updateHUDContent() { | |
| if (!this.hudElement) return; | |
| const helpText = "W=wind G=gust R=rain H=HUD/help"; | |
| const messageText = this.hudMessage || ""; | |
| const metricsText = `${this.currentFps} fps ${this.cpuUsage} % time/frame`; | |
| const hudContent = helpText + messageText + metricsText; | |
| if (this.hudLastContent != hudContent) { | |
| this.hudElement.innerHTML = ` | |
| <div style="display: flex; justify-content: space-between;"> | |
| <span>${helpText}</span> | |
| <span>${messageText}</span> | |
| <span>${metricsText}</span> | |
| </div> | |
| `; | |
| this.hudLastContent = hudContent; | |
| } | |
| // Count down message text timer. | |
| if (messageText && this.hudMessageLastShown) { | |
| const elapsed = performance.now() - this.hudMessageLastShown; | |
| if (elapsed > messageTextDisplayTime) { | |
| this.hudMessage = ""; | |
| this.hudMessageLastShown = 0; | |
| } | |
| } | |
| }, | |
| createHUD() { | |
| this.hudElement = document.createElement("div"); | |
| this.hudElement.style.cssText = ` | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| height: 25px; | |
| background: rgba(0, 0, 0, 0.5); | |
| color: #00ff00; | |
| font-family: 'Courier New', monospace; | |
| font-size: 12px; | |
| line-height: 25px; | |
| padding: 0 10px; | |
| display: none; | |
| z-index: 1000; | |
| pointer-events: none; | |
| border-top: 1px solid #00ff00; | |
| box-shadow: inset 0 1px 0 rgba(0, 255, 0, 0.2); | |
| `; | |
| // Position relative to the SVG | |
| const svgRect = this.parentSvg.getBoundingClientRect(); | |
| const svgParent = this.parentSvg.parentElement; | |
| // Make sure parent has relative positioning | |
| if (getComputedStyle(svgParent).position === "static") { | |
| svgParent.style.position = "relative"; | |
| } | |
| svgParent.appendChild(this.hudElement); | |
| this.updateHUDContent(); | |
| }, | |
| createHUDHint() { | |
| this.hudHintElement = document.createElement("div"); | |
| this.hudHintElement.style.cssText = ` | |
| position: absolute; | |
| bottom: 5px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| color: white; | |
| opacity: 0.5; | |
| font-family: 'Courier New', monospace; | |
| font-size: 11px; | |
| padding: 2px 8px; | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 3px; | |
| display: none; | |
| z-index: 999; | |
| pointer-events: none; | |
| transition: opacity 0.3s ease; | |
| `; | |
| this.hudHintElement.textContent = | |
| "Press H with mouse inside banner for HUD"; | |
| // Position relative to the SVG | |
| const svgParent = this.parentSvg.parentElement; | |
| if (getComputedStyle(svgParent).position === "static") { | |
| svgParent.style.position = "relative"; | |
| } | |
| svgParent.appendChild(this.hudHintElement); | |
| }, | |
| animateLeaf(leaf, deltaTime) { | |
| // Apply wind based on rotation. This simulates the affected surface area. | |
| const radianX = leaf.rotateX * DEG_TO_RAD; | |
| const radianY = leaf.rotateY * DEG_TO_RAD; | |
| const areaFactor = Math.cos(radianX) * Math.cos(radianY); | |
| const effectiveAreaFactor = | |
| windMinAreaFactor + (1 - windMinAreaFactor) * Math.abs(areaFactor); | |
| leaf.xMomentum += this.windSpeed * deltaTime * effectiveAreaFactor; | |
| const liftEfficiency = Math.sin(2 * radianX) * Math.cos(radianY); // Max at 45° rotation | |
| leaf.yMomentum += | |
| this.windSpeed * deltaTime * windLiftFactor * liftEfficiency; | |
| const turbulence = | |
| Math.abs(this.windSpeed) * deltaTime * windRotationInfluence; | |
| leaf.rotateMomentumX += turbulence * (Math.random() - 0.5); | |
| leaf.rotateMomentumY += turbulence * (Math.random() - 0.5); | |
| leaf.rotateMomentumZ += turbulence * (Math.random() - 0.5); | |
| leaf.animate(deltaTime); | |
| // Remove leaf if it goes off the left or bottom side. | |
| if (leaf.x < 0 - leaf.scale || leaf.y > this.height + leaf.scale) { | |
| leaf.destroy(); | |
| return false; // remove from array | |
| } | |
| return true; // keep leaf alive | |
| }, | |
| // Turn wind on and off at random, manage wind gusts | |
| adjustWind() { | |
| // Randomly turn wind on/off | |
| if (Math.random() < windToggleProbability) { | |
| this.toggleWind(); | |
| } | |
| // Wind gusts randomly multiply current wind speed | |
| if (this.windSpeed > 0.0 && Math.random() < windGustProbability) { | |
| this.triggerGust(); | |
| } | |
| // Slowly convert towards targetWindSpeed | |
| const windSpeedDelta = this.windSpeed - this.targetWindSpeed; | |
| this.windSpeed -= windSpeedDelta * windReturnFactor; | |
| }, | |
| checkRainToggle(deltaTime) { | |
| if (Math.random() < rainToggleProbability) { | |
| this.toggleRain(); | |
| } | |
| }, | |
| updateRainVisibility() { | |
| this.rainRects.forEach((rainData) => { | |
| rainData.element.style.opacity = | |
| rainData.baseOpacity * this.rainIntensity; | |
| }); | |
| }, | |
| animateRainPattern(deltaTime) { | |
| this.rainPatterns.forEach((patternData) => { | |
| const dropScale = 0.5 + patternData.zFactor * 1.5; // 0.5x to 2x scale | |
| const perspectiveScale = 1 + (1 - patternData.zFactor) * 0.5; // distant rain moves faster | |
| patternData.rainOffset += deltaTime * rainSpeed * perspectiveScale; | |
| patternData.element.setAttribute( | |
| "x", | |
| (patternData.rainOffset * rainAngle) % rainPatternSize, | |
| ); | |
| patternData.element.setAttribute( | |
| "y", | |
| patternData.rainOffset % rainPatternSize, | |
| ); | |
| }); | |
| }, | |
| updateRain(deltaTime) { | |
| // Check if we should toggle rain state | |
| this.checkRainToggle(deltaTime); | |
| // Update rain intensity (fade in/out) | |
| if (this.rainIntensity !== this.rainTargetIntensity) { | |
| const fadeSpeed = 0.001; | |
| if (this.rainIntensity < this.rainTargetIntensity) { | |
| this.rainIntensity = Math.min( | |
| this.rainTargetIntensity, | |
| this.rainIntensity + fadeSpeed * deltaTime, | |
| ); | |
| } else { | |
| this.rainIntensity = Math.max( | |
| this.rainTargetIntensity, | |
| this.rainIntensity - fadeSpeed * deltaTime, | |
| ); | |
| } | |
| this.updateRainVisibility(); | |
| } | |
| // Animate rain if visible | |
| if (this.rainIntensity > 0) { | |
| this.animateRainPattern(deltaTime); | |
| } | |
| }, | |
| updatePerformanceMetrics(deltaTime) { | |
| this.frameCount++; | |
| // Update FPS every 500ms | |
| const now = performance.now(); | |
| if (now - this.lastFpsUpdate > 500) { | |
| this.currentFps = Math.round(1000 / (deltaTime || 16.67)); | |
| this.lastFpsUpdate = now; | |
| } | |
| // Calculate CPU usage (our execution time vs ideal frame time) | |
| const idealFrameTime = 1000 / 60; // 16.67ms at 60fps | |
| this.cpuUsage = Math.round((this.executionTime / idealFrameTime) * 100); | |
| // Update HUD content if visible | |
| if (this.hudVisible) { | |
| this.updateHUDContent(); | |
| } | |
| }, | |
| // Initialize visibility detection | |
| initVisibilityDetection() { | |
| // 1. Intersection Observer for viewport visibility (scrolling) | |
| this.intersectionObserver = new IntersectionObserver( | |
| (entries) => { | |
| const entry = entries[0]; | |
| if (entry.isIntersecting && !this.isVisible) { | |
| this.resume(); | |
| } else if (!entry.isIntersecting && this.isVisible) { | |
| this.pause(); | |
| } | |
| }, | |
| { | |
| // Trigger when at least 10% of header is visible | |
| threshold: 0.1, | |
| // Add some margin to trigger slightly before/after | |
| rootMargin: "50px", | |
| }, | |
| ); | |
| // Start observing the header | |
| this.intersectionObserver.observe(this.parentSvg); | |
| // 2. Page Visibility API for tab switching/minimization | |
| this.visibilityChangeListener = () => { | |
| if (document.hidden && this.isVisible) { | |
| this.pause(); | |
| } else if (!document.hidden && !this.isVisible) { | |
| // Only resume if header is also in viewport | |
| const rect = this.parentSvg.getBoundingClientRect(); | |
| const isInViewport = rect.top < window.innerHeight && rect.bottom > 0; | |
| if (isInViewport) { | |
| this.resume(); | |
| } | |
| } | |
| }; | |
| document.addEventListener( | |
| "visibilitychange", | |
| this.visibilityChangeListener, | |
| ); | |
| // 3. Window focus/blur as backup | |
| window.addEventListener("blur", () => this.pause()); | |
| window.addEventListener("focus", () => { | |
| if (!document.hidden) { | |
| const rect = this.parentSvg.getBoundingClientRect(); | |
| const isInViewport = rect.top < window.innerHeight && rect.bottom > 0; | |
| if (isInViewport) { | |
| this.resume(); | |
| } | |
| } | |
| }); | |
| }, | |
| animate() { | |
| const frameStartTime = performance.now(); | |
| const deltaTime = frameStartTime - this.lastTime; | |
| this.lastTime = frameStartTime; | |
| // Track execution time | |
| const executionStartTime = performance.now(); | |
| // Update all leaves (at a slower rate, if invisible) | |
| if (this.isVisible || Math.random() < 0.1) { | |
| // 10% update rate when invisible | |
| this.leaves = this.leaves.filter((leaf) => | |
| this.animateLeaf(leaf, deltaTime), | |
| ); | |
| } | |
| // Update wind, rain, and performance metrics only if visible | |
| if (this.isVisible) { | |
| this.adjustWind(); | |
| this.updateRain(deltaTime); | |
| // Calculate execution time and CPU usage | |
| const executionEndTime = performance.now(); | |
| this.executionTime = executionEndTime - executionStartTime; | |
| // Update performance metrics | |
| this.updatePerformanceMetrics(deltaTime); | |
| } | |
| this.animationId = requestAnimationFrame(() => this.animate()); | |
| }, | |
| scheduleNextLeafSpawn() { | |
| // Clear any existing timeout | |
| if (this.spawnTimeoutId) { | |
| clearTimeout(this.spawnTimeoutId); | |
| } | |
| this.spawnTimeoutId = setTimeout( | |
| () => this.spawnLeaf(), | |
| minLeafSpawnInterval + | |
| Math.random() * (maxLeavSpawnInterval - minLeafSpawnInterval), | |
| ); | |
| }, | |
| spawnLeaf() { | |
| if ( | |
| this.isVisible && | |
| Math.abs(this.targetWindSpeed) > 0.0001 && | |
| this.leaves.length < maxLeaves | |
| ) { | |
| const zFactor = Math.random(); | |
| const targetGroup = | |
| this.layerGroups[Math.floor(zFactor * this.layerGroups.length)]; | |
| const leaf = new Leaf(targetGroup, zFactor); | |
| leaf.x = | |
| this.width * (leafSpawnX - (Math.random() - 0.5) * leafSpawnXVariation); // Start off-banner right | |
| leaf.y = | |
| this.height * | |
| (leafSpawnY - (Math.random() - 0.5) * leafSpawnYVariation); | |
| leaf.scale *= 1 - leafScaleVariation * (Math.random() - 0.5); | |
| leaf.updateTransform(); | |
| this.leaves.push(leaf); | |
| } | |
| // Schedule next leaf spawning to keep them coming. | |
| this.scheduleNextLeafSpawn(); | |
| }, | |
| start() { | |
| this.lastTime = performance.now(); | |
| this.animate(); | |
| this.spawnLeaf(); | |
| }, | |
| init(parentSvg) { | |
| this.parentSvg = parentSvg; | |
| const viewBox = this.parentSvg.viewBox.baseVal; | |
| this.width = viewBox.width; | |
| this.height = viewBox.height; | |
| // Find all g elements with IDs starting with "seasonal" | |
| this.layerGroups = Array.from( | |
| this.parentSvg.querySelectorAll('g[id^="seasonal"]'), | |
| ); | |
| // Initialize various elements | |
| this.initRainPatterns(); | |
| this.initInteractiveControls(); | |
| this.createHUD(); | |
| this.createHUDHint(); | |
| // Initialize visibility detection | |
| this.initVisibilityDetection(); | |
| this.start(); | |
| }, | |
| pause() { | |
| if (!this.isVisible) return; // Already paused | |
| this.isVisible = false; | |
| this.turnWindOff(); | |
| this.turnRainOff(); | |
| if (this.hudVisible) { | |
| this.showHudMessage("⏸️ Paused"); | |
| } | |
| }, | |
| resume() { | |
| if (this.isVisible) return; // Already running | |
| this.isVisible = true; | |
| if (this.hudVisible) { | |
| this.showHudMessage("▶️ Resumed"); | |
| } | |
| }, | |
| cleanup() { | |
| // Clean up visibility detection | |
| if (this.intersectionObserver) { | |
| this.intersectionObserver.disconnect(); | |
| } | |
| if (this.visibilityChangeListener) { | |
| document.removeEventListener( | |
| "visibilitychange", | |
| this.visibilityChangeListener, | |
| ); | |
| } | |
| // Clear any pending timeouts | |
| if (this.spawnTimeoutId) { | |
| clearTimeout(this.spawnTimeoutId); | |
| } | |
| if (this.animationId) { | |
| cancelAnimationFrame(this.animationId); | |
| } | |
| this.leaves.forEach((leaf) => leaf.destroy()); | |
| this.leaves = []; | |
| // Clean up rain rects | |
| this.rainRects.forEach((rainData) => rainData.element.remove()); | |
| this.rainRects = []; | |
| // Clean up HUD and hint | |
| if (this.hudElement) { | |
| this.hudElement.remove(); | |
| } | |
| if (this.hudHintElement) { | |
| this.hudHintElement.remove(); | |
| } | |
| // Clean up event listeners | |
| if (this.keyboardListener) { | |
| document.removeEventListener("keydown", this.keyboardListener); | |
| } | |
| if (this.mouseEnterListener && this.mouseLeaveListener) { | |
| this.parentSvg.removeEventListener("mouseenter", this.mouseEnterListener); | |
| this.parentSvg.removeEventListener("mouseleave", this.mouseLeaveListener); | |
| } | |
| }, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment