Source: client/scripts/stars.js

/**
 * @typedef { {
 *  xPosPercent: number,
 *  yPosPercent: number,
 *  zPosPercent: number
 * } } Star
 */

const NUM_STARS = 250;
const MAX_OPACITY_PERCENT = 0.95;
const STAR_SPEED_PERCENTILE = 0.0007;
const MOUSE_EFFECT_COEFF = 0.05;
const STAR_RADIUS_COEFF = 3;
const CENTER_EXCLUSION_RADIUS = 0.2;

let currentAnimationFrameID;

/**
 * @type { {
 *  xPosPercentFromCenter: number,
 *  yPosPercentFromCenter: number
 * } }
 */
const mouseState = {
  xPosPercentFromCenter: 0,
  yPosPercentFromCenter: 0,
};

/**
 * Updates mouse x and y positions as relative percents of total screen size
 * in mouseState object, using the center of the screen as the (0, 0) origin
 * @param { MouseEvent } event mouse movement event passed by listener; passive
 */
function updateMousePosition(event) {
  mouseState.xPosPercentFromCenter = event.clientX / window.innerWidth - 0.5;
  mouseState.yPosPercentFromCenter = event.clientY / window.innerHeight - 0.5;
} /* updateMousePosition */

/**
 * (Re)sizes canvas element to always cover the entire client screen
 * @param { HTMLCanvasElement } starBgCanvasEl canvas element to resize
 */
function handleResize(starBgCanvasEl) {
  starBgCanvasEl.width = window.innerWidth;
  starBgCanvasEl.height = window.innerHeight;
} /* handleResize */

/**
 * Creates and returns new Star objects, each including initial x, y, & z positions
 * @param { number } numStars number of stars to create
 * @returns { Star[] } array of newly created stars
 */
function generateStars(numStars) {
  return Array.from({ length: numStars }).map(() => {
    let xPosPercent, yPosPercent;

    do {
      xPosPercent = Math.random();
      yPosPercent = Math.random();
    } while (
      Math.hypot(xPosPercent - 0.5, yPosPercent - 0.5) < CENTER_EXCLUSION_RADIUS
    );

    return { xPosPercent, yPosPercent, zPosPercent: Math.random() };
  });
} /* generateStars */

/**
 * Calculates appropriate current opacity for a star, based off its z position
 * @param { Star } star target star
 * @returns { number } percent opacity in range [0, 1]
 */
function getPercentOpacity(star) {
  return star.zPosPercent ** 0.5 * MAX_OPACITY_PERCENT;
} /* getPercentOpacity */

/**
 * Handles animation of a single star for a single frame
 * @param { Star } star individual star to animate
 * @param { CanvasRenderingContext2D } canvasContext 2D canvas context to render onto
 * @param { number } cameraX x coordinate of viewing position
 * @param { number } cameraY y coordinate of viewing position
 */
function animateSingleStar(star, canvasContext, cameraX, cameraY) {
  /* move star a step toward the camera */
  star.zPosPercent = (star.zPosPercent + STAR_SPEED_PERCENTILE) % 1;

  /* project coordinate in 3D space to 2D space */
  const projX =
    (star.xPosPercent * window.innerWidth - cameraX) *
      (1 / (1 - star.zPosPercent)) +
    cameraX;
  const projY =
    (star.yPosPercent * window.innerHeight - cameraY) *
      (1 / (1 - star.zPosPercent)) +
    cameraY;

  /* paint */
  canvasContext.fillStyle = `rgba(255, 255, 255, ${getPercentOpacity(star)})`;
  canvasContext.beginPath();
  canvasContext.arc(
    projX,
    projY,
    star.zPosPercent * STAR_RADIUS_COEFF,
    0,
    Math.PI * 2,
  );
  canvasContext.fill();
} /* animateSingleStar */

/**
 * Handles animation of each frame; individually moves stars forward a step
 * in the direction of the camera. Self-invoking.
 * @param { Star[] } stars array of stars to animate
 * @param { CanvasRenderingContext2D } canvasContext 2D canvas context to render onto
 */
function animateNextFrame(stars, canvasContext) {
  /* move camera location with the user's mouse (dampened) */
  const { xPosPercentFromCenter, yPosPercentFromCenter } = mouseState;
  const cameraX =
    window.innerWidth * (0.5 - xPosPercentFromCenter * MOUSE_EFFECT_COEFF);
  const cameraY =
    window.innerHeight * (0.5 - yPosPercentFromCenter * MOUSE_EFFECT_COEFF);

  /* paint */
  canvasContext.clearRect(
    0,
    0,
    canvasContext.canvas.width,
    canvasContext.canvas.height,
  );

  stars.forEach((star) =>
    animateSingleStar(star, canvasContext, cameraX, cameraY),
  );

  /* self-invoke next frame */
  currentAnimationFrameID = requestAnimationFrame(() =>
    animateNextFrame(stars, canvasContext),
  );
} /* animateNextFrame */

/**
 * Creates and attaches canvas element to page, and then initializes animation and
 * appropriate event listeners
 */
export function starsInit() {
  /* create star canvas element if it doesn't already exist */
  let starBgCanvasEl = document.querySelector('.star-bg');
  if (!starBgCanvasEl) {
    starBgCanvasEl = document.createElement('canvas');
    starBgCanvasEl.classList.add('star-bg');
    document.body.append(starBgCanvasEl);
  }

  handleResize(starBgCanvasEl);

  /* initialize animation */
  const canvasContext = starBgCanvasEl.getContext('2d');
  animateNextFrame(generateStars(NUM_STARS), canvasContext);

  /* attach listeners */
  window.addEventListener('mousemove', updateMousePosition, { passive: true });
  window.addEventListener('resize', () => handleResize(starBgCanvasEl), {
    passive: true,
  });
} /* starsInit */

/**
 * Remove the stary background
 */
export function removeStars() {
  const starBgCanvasEl = document.querySelector('.star-bg');

  starBgCanvasEl?.remove();

  cancelAnimationFrame(currentAnimationFrameID);
}

window.addEventListener('DOMContentLoaded', starsInit);