heartwood every commit a ring

Add animation controls and new ParticleFlow component to art page

9ac4e0a3 by Isaac Bythewood · 11 months ago

Add animation controls and new ParticleFlow component to art page

- Add isActive prop to Constellations and RetroStars components to control animation state
- Implement play/pause functionality with toggle buttons for each art piece
- Add new ParticleFlow component to the art collection
- Update useEffect dependencies to respond to isActive state changes
- Prevent animations from running when components are inactive to improve performance
modified components/constellations.js
@@ -3,6 +3,7 @@ import styled from "styled-components";import PropTypes from "prop-types";const Constellations = ({ options }) => {  const isActive = options.isActive !== undefined ? options.isActive : true;  const canvas = useRef(null);  useEffect(() => {
@@ -104,17 +105,21 @@ const Constellations = ({ options }) => {        return star;      });      starsAnimationFrame = window.requestAnimationFrame(drawStars);      if (isActive) {        starsAnimationFrame = window.requestAnimationFrame(drawStars);      }    };    // Start the initial drawing and our recursion will take it from there    starsAnimationFrame = window.requestAnimationFrame(drawStars);    if (isActive) {      starsAnimationFrame = window.requestAnimationFrame(drawStars);    }    // Cancel star drawing animation frame rendering when dismounting component    return () => {      window.cancelAnimationFrame(starsAnimationFrame);    };  }, []);  }, [isActive]);  return <Canvas ref={canvas} />;};
added components/particleflow.js
@@ -0,0 +1,196 @@import React, { useEffect, useRef } from "react";import styled from "styled-components";import PropTypes from "prop-types";const ParticleFlow = ({ options }) => {  const isActive = options.isActive !== undefined ? options.isActive : true;  const canvas = useRef(null);  useEffect(() => {    // Get the canvas for resizing    const cvs = canvas.current;    // Size canvas to the parent    cvs.width = cvs.offsetWidth;    cvs.height = cvs.offsetHeight;    // Add new event listener for resize the canvas on window resize    const resizeCanvas = () => {      cvs.width = cvs.offsetWidth;      cvs.height = cvs.offsetHeight;    };    window.addEventListener("resize", resizeCanvas);    // Clean up event listener when dismounting the component    return () => {      window.removeEventListener("resize", resizeCanvas);    };  }, []);  useEffect(() => {    // Get our canvas and context    const cvs = canvas.current;    const ctx = cvs.getContext("2d");    // Flow field configuration    const cols = Math.floor(cvs.width / 20);    const rows = Math.floor(cvs.height / 20);    let time = 0;    // Simple noise function (Perlin-like)    const noise = (x, y, z) => {      return Math.sin(x * 0.01 + z) * Math.cos(y * 0.01 + z) * 0.5 + 0.5;    };    // Generate flow field    const generateFlowField = () => {      const field = [];      for (let y = 0; y < rows; y++) {        field[y] = [];        for (let x = 0; x < cols; x++) {          const angle = noise(x, y, time * 0.01) * Math.PI * 2;          field[y][x] = {            x: Math.cos(angle),            y: Math.sin(angle),          };        }      }      return field;    };    // Particle class    class Particle {      constructor() {        this.x = Math.random() * cvs.width;        this.y = Math.random() * cvs.height;        this.vx = 0;        this.vy = 0;        this.maxSpeed = 2;        this.maxForce = 0.1;        this.hue = Math.random() * 360;        this.alpha = 0.8;        this.trail = [];        this.maxTrailLength = 20;      }      follow(flowField) {        const col = Math.floor(this.x / 20);        const row = Math.floor(this.y / 20);        if (col >= 0 && col < cols && row >= 0 && row < rows) {          const desired = flowField[row][col];          const force = {            x: desired.x * this.maxSpeed - this.vx,            y: desired.y * this.maxSpeed - this.vy,          };          // Limit force          const mag = Math.sqrt(force.x * force.x + force.y * force.y);          if (mag > this.maxForce) {            force.x = (force.x / mag) * this.maxForce;            force.y = (force.y / mag) * this.maxForce;          }          this.vx += force.x;          this.vy += force.y;        }      }      update() {        // Add current position to trail        this.trail.push({ x: this.x, y: this.y });        if (this.trail.length > this.maxTrailLength) {          this.trail.shift();        }        // Update position        this.x += this.vx;        this.y += this.vy;        // Wrap around edges        if (this.x > cvs.width) this.x = 0;        if (this.x < 0) this.x = cvs.width;        if (this.y > cvs.height) this.y = 0;        if (this.y < 0) this.y = cvs.height;        // Slowly shift hue        this.hue += 0.5;        if (this.hue > 360) this.hue = 0;      }      draw(ctx) {        // Draw trail        for (let i = 0; i < this.trail.length; i++) {          const alpha = (i / this.trail.length) * this.alpha;          const size = (i / this.trail.length) * 3;          ctx.beginPath();          ctx.arc(this.trail[i].x, this.trail[i].y, size, 0, Math.PI * 2);          ctx.fillStyle = `hsla(${this.hue}, 70%, 60%, ${alpha})`;          ctx.fill();          ctx.closePath();        }        // Draw particle        ctx.beginPath();        ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);        ctx.fillStyle = `hsla(${this.hue}, 80%, 70%, ${this.alpha})`;        ctx.fill();        ctx.closePath();      }    }    // Create particles    const particles = [];    const numParticles = options.numParticles || 100;    for (let i = 0; i < numParticles; i++) {      particles.push(new Particle());    }    // Animation loop    let animationFrame = null;    const animate = () => {      // Semi-transparent black background for trail effect      ctx.fillStyle = "rgba(0, 0, 0, 0.05)";      ctx.fillRect(0, 0, cvs.width, cvs.height);      // Generate flow field      const flowField = generateFlowField();      // Update and draw particles      particles.forEach((particle) => {        particle.follow(flowField);        particle.update();        particle.draw(ctx);      });      time++;      if (isActive) {        animationFrame = window.requestAnimationFrame(animate);      }    };    // Start animation    if (isActive) {      animationFrame = window.requestAnimationFrame(animate);    }    // Clean up animation frame when dismounting component    return () => {      window.cancelAnimationFrame(animationFrame);    };  }, [isActive]);  return <Canvas ref={canvas} />;};ParticleFlow.propTypes = {  options: PropTypes.object,};export default ParticleFlow;const Canvas = styled.canvas`  width: 100%;  height: 100%;`;
modified components/retrostars.js
@@ -3,6 +3,7 @@ import styled from "styled-components";import PropTypes from "prop-types";const RetroStars = ({ options }) => {  const isActive = options.isActive !== undefined ? options.isActive : true;  const canvas = useRef(null);  const [mousePosition, setMousePosition] = useState({ x: 640, y: 400 });  const mousePositionRef = useRef(mousePosition);
@@ -63,20 +64,25 @@ const RetroStars = ({ options }) => {  useEffect(() => {    // Setup interval to change star sizes    const starSizeInterval = setInterval(() => {      const newStarSize = starSizeRef.current + 1;      if (newStarSize < 4) {        setStarSize(newStarSize);      } else {        setStarSize(0);      }    }, 500);    let starSizeInterval = null;    if (isActive) {      starSizeInterval = setInterval(() => {        const newStarSize = starSizeRef.current + 1;        if (newStarSize < 4) {          setStarSize(newStarSize);        } else {          setStarSize(0);        }      }, 500);    }    // Clean up interval when dismounting the component    return () => {      clearInterval(starSizeInterval);      if (starSizeInterval) {        clearInterval(starSizeInterval);      }    };  }, []);  }, [isActive]);  useEffect(() => {    // Get our canvas and draw stars
@@ -222,17 +228,21 @@ const RetroStars = ({ options }) => {      });      // Draw again      starsAnimationFrame = window.requestAnimationFrame(drawStars);      if (isActive) {        starsAnimationFrame = window.requestAnimationFrame(drawStars);      }    };    // Start the initial drawing and our recursion will take it from there    starsAnimationFrame = window.requestAnimationFrame(drawStars);    if (isActive) {      starsAnimationFrame = window.requestAnimationFrame(drawStars);    }    // Cancel star drawing animation frame rendering when dismounting component    return () => {      window.cancelAnimationFrame(starsAnimationFrame);    };  }, []);  }, [isActive]);  return <Canvas ref={canvas} />;};
modified pages/art.js
@@ -5,10 +5,12 @@ import Image from "next/image";import Page from "../components/page";import Constellations from "../components/constellations";import RetroStars from "../components/retrostars";import ParticleFlow from "../components/particleflow";const Art = () => {  const [lightboxImage, setLightboxImage] = useState(null);  const [lightboxLoaded, setLightboxLoaded] = useState(false);  const [activeArt, setActiveArt] = useState("constellations"); // Default to constellations playing  const openLightbox = (image) => {    setLightboxImage(image);
@@ -22,6 +24,10 @@ const Art = () => {    document.body.style.overflowY = "scroll";  };  const handleArtToggle = (artName) => {    setActiveArt(activeArt === artName ? null : artName);  };  return (
@@ -178,7 +184,13 @@ const Art = () => {        distance.      </Paragraph>      <ArtContainer>        <Constellations options={{ numStars: 50 }} />        <Constellations options={{ numStars: 50, isActive: activeArt === "constellations" }} />        <PlayButton          onClick={() => handleArtToggle("constellations")}          active={activeArt === "constellations"}        >          {activeArt === "constellations" ? "⏸" : "▶"}        </PlayButton>      </ArtContainer>      <Link        href="https://github.com/overshard/isaacbythewood.com/blob/master/components/constellations.js"
@@ -195,7 +207,13 @@ const Art = () => {        Inspired by the retro art style of Celeste.      </Paragraph>      <ArtContainer>        <RetroStars options={{ numStars: 50 }} />        <RetroStars options={{ numStars: 50, isActive: activeArt === "retrostars" }} />        <PlayButton          onClick={() => handleArtToggle("retrostars")}          active={activeArt === "retrostars"}        >          {activeArt === "retrostars" ? "⏸" : "▶"}        </PlayButton>      </ArtContainer>      <Link        href="https://github.com/overshard/isaacbythewood.com/blob/master/components/retrostars.js"
@@ -204,6 +222,30 @@ const Art = () => {      >        See the Code      </Link>      <Subheading>        <span>002</span> Particle Flow      </Subheading>      <Paragraph>        Colorful particles flowing through an invisible force field, creating        organic, flowing patterns with trailing effects. Each particle follows        the field while leaving a colorful trail that slowly fades.      </Paragraph>      <ArtContainer>        <ParticleFlow options={{ numParticles: 80, isActive: activeArt === "particleflow" }} />        <PlayButton          onClick={() => handleArtToggle("particleflow")}          active={activeArt === "particleflow"}        >          {activeArt === "particleflow" ? "⏸" : "▶"}        </PlayButton>      </ArtContainer>      <Link        href="https://github.com/overshard/isaacbythewood.com/blob/master/components/particleflow.js"        rel="noopener noreferrer"        target="_blank"      >        See the Code      </Link>      {lightboxImage !== null && (        <Lightbox onClick={() => closeLightbox()}>          <LightboxLoading className={lightboxLoaded && "hide"}>
@@ -422,3 +464,31 @@ const LightboxLoading = styled.div`    visibility: hidden;  }`;const PlayButton = styled.button`  position: absolute;  top: 20px;  right: 20px;  width: 60px;  height: 60px;  border: none;  background: transparent;  color: white;  font-size: 32px;  cursor: pointer;  display: flex;  align-items: center;  justify-content: center;  transition: all 0.3s ease;  z-index: 5;  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);  &:hover {    transform: scale(1.1);    text-shadow: 0 4px 8px rgba(0, 0, 0, 0.9);  }  &:active {    transform: scale(0.95);  }`;