From b004a13b424a61f1dcf9cc51ad717c401359e5e6 Mon Sep 17 00:00:00 2001 From: Riley Shaw Date: Mon, 15 Jan 2024 10:28:08 -0800 Subject: [PATCH] Content: Add Propellers doodle to homepage --- src/components/GridDoodles.jsx | 2 + .../doodles/CircleConstrainedLines.css | 2 +- .../doodles/CircleConstrainedLines.jsx | 45 +++-- src/components/doodles/Propellers.css | 16 ++ src/components/doodles/Propellers.jsx | 164 ++++++++++++++++++ src/pages/index.jsx | 15 +- 6 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 src/components/doodles/Propellers.css create mode 100644 src/components/doodles/Propellers.jsx diff --git a/src/components/GridDoodles.jsx b/src/components/GridDoodles.jsx index d6056880a..0750a18d6 100644 --- a/src/components/GridDoodles.jsx +++ b/src/components/GridDoodles.jsx @@ -1,5 +1,6 @@ import CycleText from './CycleText'; import BackgroundGenerator from './doodles/BackgroundGenerator'; +import Propellers from './doodles/Propellers'; import CircleConstrainedLines from './doodles/CircleConstrainedLines'; import GameOver from './doodles/GameOver'; import Riot from './doodles/Riot'; @@ -14,6 +15,7 @@ export default [ )), 0.8, ], + [React.forwardRef((_, ref) => ), 0.8], [ React.forwardRef((_, ref) => ( diff --git a/src/components/doodles/CircleConstrainedLines.css b/src/components/doodles/CircleConstrainedLines.css index 43407f07a..6fa35452a 100644 --- a/src/components/doodles/CircleConstrainedLines.css +++ b/src/components/doodles/CircleConstrainedLines.css @@ -1,6 +1,6 @@ .doodle-constrained-lines { align-items: center; - background: var(--color-red-600); + background: var(--color-grey-500); display: flex; grid-area: span 3 / span 3; justify-content: center; diff --git a/src/components/doodles/CircleConstrainedLines.jsx b/src/components/doodles/CircleConstrainedLines.jsx index 01c7ccf2f..4abad8094 100644 --- a/src/components/doodles/CircleConstrainedLines.jsx +++ b/src/components/doodles/CircleConstrainedLines.jsx @@ -4,11 +4,11 @@ import './CircleConstrainedLines.css'; import React, {useRef, useState, useEffect, useMemo} from 'react'; const {PI, cos, sin, tan, pow} = Math; -const L = 800; -const R = L / 2.5; +const SIZE_UNSCALED = 800; +const R_UNSCALED = SIZE_UNSCALED / 2.5; // Derived. -const C = L / 2; +const C_UNSCALED = SIZE_UNSCALED / 2; const variants = [ { @@ -85,12 +85,34 @@ const variants = [ ]; const CircleConstrainedLines = React.forwardRef( - function CircleConstrainedLines({El = 'div', settings: {theme}}, ref) { + function CircleConstrainedLines( + {El = 'div', settings: {theme}, onFullCycle}, + ref, + ) { const canvasRef = useRef(null); - const [variant, setVariant] = useState( + const initialVariantIdxOffset = useRef( Math.floor(Math.random() * variants.length), ); + const [variantIdxOffset, setVariantIdxOffset] = useState(0); const themeColors = useMemo(() => ABSTRACT_COLORS[theme], [theme]); + + useEffect(() => { + if ( + variantIdxOffset > 0 && + variantIdxOffset % variants.length === 0 + ) { + onFullCycle?.(); + } + }, [variantIdxOffset]); + + const variantIdx = + (initialVariantIdxOffset.current + variantIdxOffset) % + variants.length; + + const SIZE = SIZE_UNSCALED * window.devicePixelRatio; + const R = R_UNSCALED * window.devicePixelRatio; + const C = C_UNSCALED * window.devicePixelRatio; + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -100,14 +122,14 @@ const CircleConstrainedLines = React.forwardRef( nLines, lineWidth = 1, globalCompositeOperation = 'source-over', - } = variants[variant]; + } = variants[variantIdx]; const angleStep = (2 * PI) / nLines; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); ctx.globalCompositeOperation = globalCompositeOperation; - ctx.lineWidth = lineWidth; + ctx.lineWidth = window.devicePixelRatio * lineWidth; ctx.strokeStyle = themeColors.fg; ctx.fillStyle = themeColors.bg; ctx.arc(C, C, R * 1.03, 0, 2 * PI); @@ -130,17 +152,18 @@ const CircleConstrainedLines = React.forwardRef( ctx.lineTo(C + x2, C + y2); ctx.stroke(); } - }, [variant, theme]); + }, [variantIdx, theme]); + return ( setVariant(v => (v + 1) % variants.length)} + onClick={() => setVariantIdxOffset(v => v + 1)} /> ); diff --git a/src/components/doodles/Propellers.css b/src/components/doodles/Propellers.css new file mode 100644 index 000000000..f131fc6f4 --- /dev/null +++ b/src/components/doodles/Propellers.css @@ -0,0 +1,16 @@ +.doodle-propellers { + align-items: center; + background: var(--color-grey-500); + display: flex; + grid-area: span 3 / span 3; + height: 100%; + justify-content: center; +} +.doodle-propellers canvas { + background: var(--color-bg); + border: 4px solid var(--color-fg); + border-radius: 50%; + cursor: pointer; + max-height: 86%; + max-width: 86%; +} diff --git a/src/components/doodles/Propellers.jsx b/src/components/doodles/Propellers.jsx new file mode 100644 index 000000000..37edd9579 --- /dev/null +++ b/src/components/doodles/Propellers.jsx @@ -0,0 +1,164 @@ +import {ABSTRACT_COLORS} from '../../util/constants'; +import {withSettings} from '../SettingsProvider'; +import './Propellers.css'; +import React, {useRef, useState, useEffect, useMemo} from 'react'; + +const SIZE_UNSCALED = 520; + +// Helper function: triangle wave. +function tri(x) { + return 1 - 2 * Math.abs(Math.round(x / 2) - x / 2); +} + +const variants = [ + { + nLines: 10, + lineWidth: 4, + lineLengthFactor: 1, + speed: 5, + getAngle: (t, isOdd) => + (Math.PI / 2) * (tri(t / Math.PI) + isOdd + 0.5), + }, + { + nLines: 8, + lineWidth: 20, + lineLengthFactor: 1 / 1.5, + speed: 2, + getAngle: (t, isOdd) => t + isOdd + 0.5, + }, + { + nLines: 13, + lineWidth: 3, + lineLengthFactor: 1 / 1.6, + speed: 10 / 3, + getAngle: (t, isOdd, x, y, nLines) => + (Math.PI / 2) * (t + isOdd + (x + y * 4) / nLines), + }, + { + nLines: 13, + lineWidth: 80 / 1.92, + lineLengthFactor: 1 / 1.92, + speed: 10 / 3, + globalCompositeOperation: 'xor', + isStarkClear: true, + getAngle: (t, isOdd) => t + isOdd + 0.5, + }, +]; + +const Propellers = React.forwardRef(function Propellers( + {El = 'div', settings: {theme}, onFullCycle}, + ref, +) { + const SIZE = SIZE_UNSCALED * window.devicePixelRatio; + + const animationFrameRef = useRef(null); + const canvasRef = useRef(null); + const settingsRef = useRef({colors: null, variant: null}); + const themeColors = useMemo(() => ABSTRACT_COLORS[theme], [theme]); + + const initialVariantIdxOffset = useRef( + Math.floor(Math.random() * variants.length), + ); + const [variantIdxOffset, setVariantIdxOffset] = useState(0); + + useEffect(() => { + if (variantIdxOffset > 0 && variantIdxOffset % variants.length === 0) { + onFullCycle?.(); + } + }, [variantIdxOffset]); + + const variantIdx = + (initialVariantIdxOffset.current + variantIdxOffset) % variants.length; + + useEffect(() => { + settingsRef.current.colors = themeColors; + }, [themeColors]); + + useEffect(() => { + settingsRef.current.variant = variants[variantIdx]; + }, [variantIdx]); + + useEffect(() => { + function draw() { + animationFrameRef.current = window.requestAnimationFrame(draw); + + const canvas = canvasRef.current; + if (!canvas) return; + + const { + colors, + variant: { + nLines, + speed, + lineWidth = 1, + lineLengthFactor = 3 / 4, + globalCompositeOperation = 'source-over', + isStarkClear, + getAngle, + }, + } = settingsRef.current; + + // Derived constants. + const lineSpacing = SIZE / nLines; + const lineLength = lineSpacing * lineLengthFactor; + const centerSpacings = Array.from( + {length: nLines}, + (_, n) => (n + 0.5) * lineSpacing, + ); + + const ctx = canvas.getContext('2d'); + ctx.fillStyle = `${colors.bg}2a`; + ctx.strokeStyle = colors.fg; + ctx.lineWidth = window.devicePixelRatio * lineWidth; + ctx[isStarkClear ? 'clearRect' : 'fillRect']( + 0, + 0, + canvas.width, + canvas.height, + ); + ctx.globalCompositeOperation = globalCompositeOperation; + + // Animation starts here. + const t = (Date.now() / 1000) * speed; + for (let y = 0; y < nLines; ++y) { + const cy = centerSpacings[y]; + for (let x = 0; x < nLines; ++x) { + const isOdd = (x + y) % 2; + const direction = isOdd ? 1 : -1; + + const angle = getAngle(t, isOdd, x, y, nLines); + + const cx = centerSpacings[x]; + const dx = Math.cos(angle) * lineLength; + const dy = Math.sin(angle) * lineLength * direction; + + ctx.beginPath(); + ctx.moveTo(cx - dx, cy - dy); + ctx.lineTo(cx + dx, cy + dy); + ctx.stroke(); + } + } + ctx.restore(); + } + animationFrameRef.current = window.requestAnimationFrame(draw); + return () => { + window.cancelAnimationFrame(animationFrameRef.current); + }; + }, []); + + return ( + + setVariantIdxOffset(i => i + 1)} + /> + + ); +}); + +export default withSettings(Propellers); diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 787b8a772..49e88366e 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -8,19 +8,28 @@ import allProjectsQuery from '../util/all-projects-query'; import {sortByDate} from '../util/sorting-methods'; import './index.css'; import {Link} from 'gatsby'; -import React from 'react'; +import React, {useCallback, useState} from 'react'; import GalleryImage from '../components/GalleryImage'; import {StaticImage} from 'gatsby-plugin-image'; import {ExternalLink} from '../components/AutoLink'; import BackgroundGenerator from '../components/doodles/BackgroundGenerator'; import Riot from '../components/doodles/Riot'; import CircleConstrainedLines from '../components/doodles/CircleConstrainedLines'; +import Propellers from '../components/doodles/Propellers'; import PinkNoise from '../components/PinkNoise'; import {DIRECT_COLORS} from '../util/constants.mjs'; export const Head = SEO; +const DOODLES = [CircleConstrainedLines, Propellers]; + const IndexPage = ({featuredProjects = []}) => { + const [featuredDoodleIdx, setFeaturedDoodleIdx] = useState(0); + const incrementFeaturedDoodleIdx = useCallback(() => { + setFeaturedDoodleIdx(i => (i + 1) % DOODLES.length); + }, []); + const FeaturedDoodle = DOODLES[featuredDoodleIdx]; + return (
@@ -99,7 +108,9 @@ const IndexPage = ({featuredProjects = []}) => { />
  • - +