More particles in canvas

Author: Mathias Bøe

For a long time I have liked the Hyperpower extension for the Hyper terminal. Although I am not using the terminal myself, I look at it as a "fun" terminal.

In this blog post, Ill try to explain my implementation of it. The code is written in TypeScript and uses React hooks. And to top it all of, it is built into a small project, a Haiku verification prototype.

Introduction

The application consists of two main components:

  1. AnimatedTextAreaWrapper: Manages the animated text area.
  2. Home: Validates the Haiku and renders the AnimatedTextAreaWrapper.

You can play around with it here

app/components/AnimatedTextAreaWrapper
haikuValidator.ts

_171
"use client"
_171
import React, {useEffect, useRef, useState} from "react";
_171
_171
interface Particle {
_171
x: number;
_171
y: number;
_171
alpha: number;
_171
color: number[];
_171
velocity: {
_171
x: number;
_171
y: number;
_171
};
_171
}
_171
_171
const MAX_PARTICLES = 50;
_171
const PARTICLE_ALPHA_FADEOUT = 0.99;
_171
const PARTICLE_VELOCITY_RANGE = {
_171
x: [0, 3], // controls the x direction particle velocity range
_171
y: [-3.5, -1.5], // controls the y direction particle velocity range
_171
};
_171
_171
interface AnimatedTextAreaProps {
_171
haiku: string;
_171
onChange: (event: any) => void;
_171
}
_171
_171
export const AnimatedTextAreaWrapper = ({
_171
haiku,
_171
onChange,
_171
}: AnimatedTextAreaProps) => {
_171
const canvasRef = useRef<HTMLCanvasElement>(null);
_171
const particles = useRef<Particle[]>([]);
_171
const [needsRedraw, setNeedsRedraw] = useState(false);
_171
const [safeToRender, setSafeToRender] = useState(false);
_171
_171
useEffect(() => {
_171
setSafeToRender(true);
_171
}, []);
_171
_171
useEffect(() => {
_171
if (typeof window === "undefined") return;
_171
_171
const canvas = canvasRef.current;
_171
if (!canvas) return;
_171
_171
const ctx = canvas.getContext("2d");
_171
if (!ctx) return;
_171
_171
const drawFrame = () => {
_171
if (particles.current.length) {
_171
ctx.clearRect(0, 0, canvas.width, canvas.height);
_171
particles.current.forEach((particle) => {
_171
particle.velocity.y += 0.075; // Gravity
_171
particle.x += particle.velocity.x;
_171
particle.y += particle.velocity.y;
_171
particle.alpha *= PARTICLE_ALPHA_FADEOUT;
_171
_171
if (particle.alpha > 0.1) {
_171
ctx.fillStyle = `rgba(${particle.color.join(",")}, ${
_171
particle.alpha
_171
})`;
_171
ctx.fillRect(
_171
Math.round(particle.x - 1),
_171
Math.round(particle.y - 1),
_171
3,
_171
3,
_171
);
_171
}
_171
});
_171
_171
particles.current = particles.current.filter(
_171
(particle) => particle.alpha > 0.1,
_171
);
_171
}
_171
_171
if (particles.current.length > 0 || needsRedraw) {
_171
requestAnimationFrame(drawFrame);
_171
}
_171
};
_171
_171
requestAnimationFrame(drawFrame);
_171
}, [needsRedraw]);
_171
_171
const spawnParticles = (x: number, y: number) => {
_171
const numParticles = 5 + Math.round(Math.random() * 5);
_171
for (let i = 0; i < numParticles; i++) {
_171
const color = [Math.random() * 155, Math.random() * 155 + 100, 255];
_171
particles.current.push(createParticle(x, y, color));
_171
}
_171
_171
if (!needsRedraw) {
_171
setNeedsRedraw(true);
_171
}
_171
};
_171
_171
const createParticle = (x: number, y: number, color: number[]): Particle => {
_171
return {
_171
x,
_171
y,
_171
alpha: 1,
_171
color,
_171
velocity: {
_171
x:
_171
PARTICLE_VELOCITY_RANGE.x[0] +
_171
Math.random() *
_171
(PARTICLE_VELOCITY_RANGE.x[1] - PARTICLE_VELOCITY_RANGE.x[0]),
_171
y:
_171
PARTICLE_VELOCITY_RANGE.y[0] +
_171
Math.random() *
_171
(PARTICLE_VELOCITY_RANGE.y[1] - PARTICLE_VELOCITY_RANGE.y[0]),
_171
},
_171
};
_171
};
_171
_171
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
_171
const textarea = e.currentTarget;
_171
const { left, top } = textarea.getBoundingClientRect();
_171
const cursorPos = textarea.selectionStart;
_171
const lines = textarea.value.substring(0, cursorPos).split("\n");
_171
_171
const lineNum = lines.length;
_171
const colNum = lines[lines.length - 1].length;
_171
_171
// You may need to adjust these based on your specific styling
_171
const charWidth = 9.65; // Approximate width of a character in pixels
_171
const lineHeight = 20; // Approximate line height in pixels
_171
_171
const x = left + colNum * charWidth;
_171
const y = top + (lineNum - 1) * lineHeight;
_171
_171
spawnParticles(x, y);
_171
};
_171
_171
return (
_171
<div
_171
className={
_171
"rounded-[calc(1.5rem-1px)] p-px bg-gradient-to-tr from-blue-400 to-cyan-500"
_171
}
_171
>
_171
<div
_171
className={
_171
"rounded-[calc(1.5rem-1px)] p-10 bg-gradient-to-tr from-blue-900 to-cyan-900"
_171
}
_171
>
_171
<textarea
_171
onInput={handleInput}
_171
id={"haikuWindow"}
_171
value={haiku}
_171
onChange={onChange}
_171
rows={5}
_171
cols={50}
_171
className={
_171
"font-mono h-32 leading-5 w-full z-index-10 border-none outline-none resize-none bg-transparent text-white"
_171
}
_171
/>
_171
{safeToRender && (
_171
<canvas
_171
ref={canvasRef}
_171
style={{
_171
position: "absolute",
_171
top: 0,
_171
left: 0,
_171
pointerEvents: "none",
_171
}}
_171
width={window.innerWidth}
_171
height={window.innerHeight}
_171
/>)}
_171
</div>
_171
</div>
_171
)
_171
};