From dc13207b4df28017c0f0402620f5fc646133b15f Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Sun, 4 Apr 2021 08:34:29 -0400 Subject: [PATCH] Make animation duration changeable (#77) This adds a slider that allows the duration of the mandala animation to be changed (for #71). In so doing, it also decouples the animation speed from the display's refresh rate. --- lib/animation.ts | 45 ++++++++++++++++++++++++++++++++ lib/pages/mandala-page.tsx | 53 ++++++++++++++++++-------------------- 2 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 lib/animation.ts diff --git a/lib/animation.ts b/lib/animation.ts new file mode 100644 index 0000000..941fbbb --- /dev/null +++ b/lib/animation.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; + +/** + * A React hook that can be used for animation. + * + * Given a duration in milliseconds, returns the percentage through + * that duration that has passed as a floating-point number + * from 0.0 to 1.0. + * + * Changes in the returned value will be triggered by `requestAnimationFrame`, + * so they will likely be tied to the monitor's refresh rate. + * + * Assumes that the animation is looping, so the percentage will + * reset to zero once it has finished. + */ +export function useAnimationPct(durationMs: number): number { + const [pct, setPct] = useState(0); + const [lastTimestamp, setLastTimestamp] = useState( + undefined + ); + + useEffect(() => { + if (!durationMs) { + setPct(0); + setLastTimestamp(undefined); + return; + } + + const callback = (timestamp: number) => { + if (typeof lastTimestamp === "number") { + const timeDelta = timestamp - lastTimestamp; + const pctDelta = timeDelta / durationMs; + setPct((pct + pctDelta) % 1.0); + } + setLastTimestamp(timestamp); + }; + const timeout = requestAnimationFrame(callback); + + return () => { + cancelAnimationFrame(timeout); + }; + }, [durationMs, pct, lastTimestamp]); + + return pct; +} diff --git a/lib/pages/mandala-page.tsx b/lib/pages/mandala-page.tsx index 427c175..d1a346d 100644 --- a/lib/pages/mandala-page.tsx +++ b/lib/pages/mandala-page.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { AutoSizingSvg } from "../auto-sizing-svg"; import { ExportWidget } from "../export-svg"; import { HoverDebugHelper } from "../hover-debug-helper"; @@ -20,6 +20,7 @@ import { } from "../svg-composition-context"; import { Page } from "../page"; import { MandalaCircle, MandalaCircleParams } from "../mandala-circle"; +import { useAnimationPct } from "../animation"; type ExtendedMandalaCircleParams = MandalaCircleParams & { scaling: number; @@ -77,6 +78,14 @@ const ROTATION: NumericRange = { step: 1, }; +const DURATION_SECS: NumericRange = { + min: 0.5, + max: 10, + step: 0.1, +}; + +const DEFAULT_DURATION_SECS = 3; + const ExtendedMandalaCircle: React.FC< ExtendedMandalaCircleParams & SvgSymbolContext > = ({ scaling, rotation, symbolScaling, symbolRotation, ...props }) => { @@ -94,12 +103,12 @@ const ExtendedMandalaCircle: React.FC< function animateMandalaCircleParams( value: ExtendedMandalaCircleParams, - frameNumber: number + animPct: number ): ExtendedMandalaCircleParams { if (value.animateSymbolRotation) { value = { ...value, - symbolRotation: frameNumber % ROTATION.max, + symbolRotation: animPct * ROTATION.max, }; } return value; @@ -195,33 +204,12 @@ function getRandomCircleParams(rng: Random): MandalaCircleParams { }; } -function useAnimation(isEnabled: boolean): number { - const [frameNumber, setFrameNumber] = useState(0); - - useEffect(() => { - if (!isEnabled) { - setFrameNumber(0); - return; - } - - const callback = () => { - setFrameNumber(frameNumber + 1); - }; - const timeout = requestAnimationFrame(callback); - - return () => { - cancelAnimationFrame(timeout); - }; - }, [isEnabled, frameNumber]); - - return frameNumber; -} - export const MandalaPage: React.FC<{}> = () => { const svgRef = useRef(null); const canvasRef = useRef(null); const [circle1, setCircle1] = useState(CIRCLE_1_DEFAULTS); const [circle2, setCircle2] = useState(CIRCLE_2_DEFAULTS); + const [durationSecs, setDurationSecs] = useState(DEFAULT_DURATION_SECS); const [baseCompCtx, setBaseCompCtx] = useState(createSvgCompositionContext()); const [useTwoCircles, setUseTwoCircles] = useState(false); const [invertCircle2, setInvertCircle2] = useState(true); @@ -232,7 +220,7 @@ export const MandalaPage: React.FC<{}> = () => { setCircle2({ ...circle2, ...getRandomCircleParams(rng) }); }; const isAnimated = isAnyMandalaCircleAnimated([circle1, circle2]); - const frameNumber = useAnimation(isAnimated); + const animPct = useAnimationPct(isAnimated ? durationSecs * 1000 : 0); const symbolCtx = noFillIfShowingSpecs(baseCompCtx); const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx; @@ -240,7 +228,7 @@ export const MandalaPage: React.FC<{}> = () => { const circles = [ , ]; @@ -249,7 +237,7 @@ export const MandalaPage: React.FC<{}> = () => { circles.push( ); @@ -297,6 +285,15 @@ export const MandalaPage: React.FC<{}> = () => { /> )} + {isAnimated && ( + setDurationSecs(duration)} + {...DURATION_SECS} + /> + )}