Skip to content

Instantly share code, notes, and snippets.

@zalez
Created September 17, 2025 13:13
Show Gist options
  • Select an option

  • Save zalez/80685e2406c01f60344839f879ccdf78 to your computer and use it in GitHub Desktop.

Select an option

Save zalez/80685e2406c01f60344839f879ccdf78 to your computer and use it in GitHub Desktop.
Constantin Thinking blog banner autumn animation JavaScript code
/**
* 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