UNPKG

react-fidget-spinner

Version:

Turn any React component into an interactive clickable fidget spinner! 🪿

1 lines • 81.8 kB
{"version":3,"file":"index.umd.cjs","sources":["../src/lib/FidgetSpinner/useAnimationFrame.tsx","../src/lib/FidgetSpinner/toBezierEasing.ts","../src/lib/FidgetSpinner/NumericControl.ts","../src/lib/FidgetSpinner/BubbleConfig.ts","../src/lib/FidgetSpinner/createId.ts","../src/lib/FidgetSpinner/Bubbles.tsx","../src/lib/FidgetSpinner/SparkConfig.ts","../src/lib/FidgetSpinner/Sparks.tsx","../src/lib/FidgetSpinner/SpinnerConfig.ts","../src/lib/FidgetSpinner/ScaleConfig.ts","../src/lib/FidgetSpinner/ResetConfig.ts","../src/lib/FidgetSpinner/ClickConfig.ts","../src/lib/FidgetSpinner/VelocityBreakpoints.ts","../src/lib/FidgetSpinner/useConfig.ts","../src/lib/FidgetSpinner/FidgetSpinner.tsx"],"sourcesContent":["import {useCallback, useEffect, useRef, useState} from 'react';\n\n// From this https://css-tricks.com/using-requestanimationframe-with-react-hooks/\n\nexport const useAnimationFrame = (callback: (deltaTime: number) => void, isEnabled = true) => {\n const requestRef = useRef(0);\n const previousTimeRef = useRef(0);\n\n const animate = useCallback(\n (time: number) => {\n if (previousTimeRef.current != undefined) {\n const deltaTime = time - previousTimeRef.current;\n callback(deltaTime);\n }\n previousTimeRef.current = time;\n requestRef.current = requestAnimationFrame(animate);\n },\n [callback]\n );\n\n useEffect(() => {\n if (isEnabled) {\n requestRef.current = requestAnimationFrame(animate);\n return () => cancelAnimationFrame(requestRef.current);\n }\n return undefined;\n }, [isEnabled, animate]);\n};\n\nexport const Counter = () => {\n const [count, setCount] = useState(0);\n\n useAnimationFrame(deltaTime => {\n setCount(prevCount => (prevCount + deltaTime * 0.01) % 100);\n });\n\n return <div>{Math.round(count)}</div>;\n};\n","import * as v from 'valibot';\nimport BezierEasing from 'bezier-easing';\n\nexport const EasingSchema = v.tuple([v.number(), v.number(), v.number(), v.number()]);\n\nexport type Easing = v.InferOutput<typeof EasingSchema>;\n\nexport const toBezierEasing = (easing: Easing) => {\n return BezierEasing(easing[0], easing[1], easing[2], easing[3]);\n};\n","import * as v from 'valibot';\n\nexport enum VariationType {\n Plus = 'Plus',\n Minus = 'Minus',\n PlusMinus = 'PlusMinus',\n}\n\nexport enum VariationUnit {\n Percent = 'Percent',\n Absolute = 'Absolute',\n}\n\nexport type VariationConfig = {\n type: VariationType;\n unit: VariationUnit;\n value: number;\n};\n\ntype NumericControlWithVariation = {\n value: number;\n variation?: VariationConfig;\n};\n\nexport const NumericVariationSchema = v.object({\n type: v.union([v.literal(VariationType.Plus), v.literal(VariationType.Minus), v.literal(VariationType.PlusMinus)]),\n unit: v.union([v.literal(VariationUnit.Percent), v.literal(VariationUnit.Absolute)]),\n value: v.number(),\n});\n\nexport const NumberWithVariationSchema = v.object({\n value: v.number(),\n variation: v.optional(NumericVariationSchema),\n});\n\nexport const NumericControlSchema = v.union([\n v.number(),\n v.object({\n value: v.number(),\n variation: v.optional(NumericVariationSchema),\n }),\n]);\n\nexport type NumericControl = NumericControlWithVariation | number;\n\nexport const toNumber = (numericControl: NumericControl) => {\n if (typeof numericControl === 'number') {\n return numericControl;\n }\n\n if (!numericControl.variation) {\n return numericControl.value;\n }\n\n const {variation, value: baseValue} = numericControl;\n\n const centredRandom = Math.random() * 2 - 1;\n\n if (variation.unit === VariationUnit.Absolute) {\n if (variation.type === VariationType.Plus) {\n return baseValue + Math.random() * variation.value;\n } else if (variation.type === VariationType.Minus) {\n return baseValue - Math.random() * variation.value;\n } else if (variation.type === VariationType.PlusMinus) {\n return baseValue + centredRandom * variation.value;\n }\n\n throw new Error('Invalid variation type');\n } else if (variation.unit === VariationUnit.Percent) {\n const percentage = variation.value / 100;\n\n if (variation.type === VariationType.Plus) {\n return baseValue + baseValue * Math.random() * percentage;\n } else if (variation.type === VariationType.Minus) {\n return baseValue - baseValue * Math.random() * percentage;\n } else if (variation.type === VariationType.PlusMinus) {\n return baseValue + baseValue * centredRandom * percentage;\n }\n throw new Error('Invalid variation type');\n } else {\n throw new Error('Invalid variation unit');\n }\n};\n","import * as v from 'valibot';\n\nimport {EasingSchema} from './toBezierEasing';\nimport {VariationType, VariationUnit, type NumericControl, NumericControlSchema} from './NumericControl';\n\nexport type BubbleConfig = {\n /** Whether the bubble spawner is active or not - setting the component as active will stop the animation loop */\n active: boolean;\n /** The components to use for the bubbles - each bubble will be a random component from this array */\n components: React.ReactNode[];\n /** The duration of the bubble animation */\n durationMs: NumericControl;\n /** The ending scale of the bubble */\n scaleEnd: NumericControl;\n /** The frame rate of the animation */\n frameRate: number;\n /** The callback function that is called when a bubble is removed */\n onRemove: () => void;\n /** The callback function that is called when a bubble is spawned */\n onSpawn: () => void;\n /** The ending opacity of the bubble */\n opacityEnd: NumericControl;\n /** The bezier curve definition which controls the opacity of the bubble over time */\n opacityEasing: [number, number, number, number];\n /** The starting opacity of the bubble */\n opacityStart: NumericControl;\n /** The bezier curve definition which controls the scale of the bubble over time */\n scaleEasing: [number, number, number, number];\n /** The starting scale of the bubble */\n scaleStart: NumericControl;\n /** The amplitude of the wobble animation */\n wobbleAmplitude: NumericControl;\n /** The frequency of the wobble animation */\n wobbleFrequency: NumericControl;\n /** The randomness in the x position of the bubble */\n xStart: NumericControl;\n /** The bezier curve definition which controls the y position of the bubble over time */\n yEasing: [number, number, number, number];\n /** The y position of the bubble when it reaches the end of its animation - nb: +ve `y` is up (which is the opposite of the html definition of positive y) */\n yEnd: NumericControl;\n /** The starting y position of the bubble */\n yStart: NumericControl;\n /** The interval between spawns */\n spawnIntervalMs: NumericControl;\n};\n\nexport const BubbleConfigSchema = v.object({\n active: v.boolean(),\n components: v.array(v.string()),\n durationMs: NumericControlSchema,\n scaleEnd: NumericControlSchema,\n frameRate: v.pipe(v.number(), v.toMinValue(0)),\n spawnIntervalMs: NumericControlSchema,\n onRemove: v.function(),\n onSpawn: v.function(),\n opacityEasing: EasingSchema,\n opacityEnd: NumericControlSchema,\n opacityStart: NumericControlSchema,\n scaleEasing: EasingSchema,\n scaleStart: NumericControlSchema,\n wobbleAmplitude: NumericControlSchema,\n wobbleFrequency: NumericControlSchema,\n yEasing: EasingSchema,\n yEnd: NumericControlSchema,\n yStart: NumericControlSchema,\n xStart: NumericControlSchema,\n});\n\nexport const defaultBubbleConfig: BubbleConfig = {\n active: false,\n components: ['💸', '🔥'],\n durationMs: {\n value: 1000,\n variation: {type: VariationType.Plus, unit: VariationUnit.Absolute, value: 1000},\n },\n scaleEnd: {\n value: 2,\n variation: {type: VariationType.PlusMinus, unit: VariationUnit.Percent, value: 20},\n },\n frameRate: 60,\n spawnIntervalMs: {\n value: 600,\n variation: {type: VariationType.PlusMinus, unit: VariationUnit.Absolute, value: 400},\n },\n onRemove: () => {},\n onSpawn: () => {},\n opacityEasing: [0.25, -0.75, 0.8, 1.2],\n opacityEnd: 0,\n opacityStart: 1,\n scaleEasing: [0.25, -0.75, 0.8, 1.2],\n scaleStart: {\n value: 1,\n variation: {type: VariationType.PlusMinus, unit: VariationUnit.Percent, value: 50},\n },\n wobbleAmplitude: {\n value: 1,\n variation: {type: VariationType.Plus, unit: VariationUnit.Absolute, value: 40},\n },\n wobbleFrequency: {\n value: 0.1,\n variation: {type: VariationType.Plus, unit: VariationUnit.Absolute, value: 0.4},\n },\n yEasing: [0.25, 0, 0.8, 1.2],\n yEnd: {\n value: 100,\n variation: {type: VariationType.Plus, unit: VariationUnit.Absolute, value: 200},\n },\n yStart: 0,\n xStart: {\n value: 0,\n variation: {type: VariationType.PlusMinus, unit: VariationUnit.Absolute, value: 100},\n },\n};\n\nexport const buildBubbleConfig = (bubbleConfigOverrides: Partial<BubbleConfig> = {}) => {\n const input = {\n ...defaultBubbleConfig,\n ...bubbleConfigOverrides,\n };\n return v.parse(BubbleConfigSchema, input);\n};\n","export const createId = () => {\n return Math.random().toString(36).substring(2, 15);\n};\n","import type {PropsWithChildren} from 'react';\nimport {useCallback, useEffect, useMemo, useRef, useState} from 'react';\nimport {useDebounceCallback} from 'usehooks-ts';\n\nimport {useAnimationFrame} from './useAnimationFrame';\nimport {toBezierEasing} from './toBezierEasing';\nimport type {BubbleConfig} from './BubbleConfig';\nimport {buildBubbleConfig} from './BubbleConfig';\nimport {createId} from './createId';\nimport {toNumber} from './NumericControl';\n\n/**\n * `Bubbles` is a standalone particle spawner component.\n *\n * The `Bubble` particles spawn at an origin point and then float upwards using a randomised cos/sin wave `wobble` function.\n *\n * Particles can be any valid `ReactNode` - we've used emojis by default.\n *\n * You can pass an array of your own `components` to render. The spawner will then pick one at random.\n *\n * We recommend that we recommend that you keep the components simple to render for performance\n *\n * ## Usage\n *\n * ```jsx\n *\n * import { Bubbles } from \"react-fidget-spinner\"\n *\n *\n * const MyBubbles = () => {\n *\n * return (\n * <Bubbles components={['💸', \"Bubble\", <ComplexBubble /> ]} />\n * )\n * }\n *\n * ```\n *\n */\nexport const Bubbles = (config: Partial<BubbleConfig>) => {\n const {\n spawnIntervalMs,\n // minSpawnIntervalMs,\n // maxSpawnIntervalMs,\n components,\n durationMs,\n // durationMsRandomness,\n opacityEasing,\n opacityStart,\n opacityEnd,\n scaleStart,\n // scaleStartRandomness,\n scaleEasing,\n scaleEnd,\n // scaleEndRandomness,\n wobbleFrequency,\n // wobbleFrequencyRandomness,\n wobbleAmplitude,\n // wobbleAmplitudeRandomness,\n // xOffsetRandomness,\n onSpawn,\n onRemove,\n yEasing,\n frameRate,\n yStart,\n yEnd,\n xStart,\n // yRandomness,\n active,\n } = buildBubbleConfig(config);\n\n const [bubbleMap, setBubbleMap] = useState<Record<string, BubbleProps>>({});\n\n const bubbles = useMemo(() => {\n return Object.values(bubbleMap);\n }, [bubbleMap]);\n\n const addBubble = useCallback(\n (id: string, bubbleProps: BubbleProps) => {\n setBubbleMap(prevBubbleMap => ({...prevBubbleMap, [id]: bubbleProps}));\n },\n [setBubbleMap]\n );\n\n const removeBubble = useCallback(\n (id: string) => {\n setBubbleMap(prevBubbleMap => {\n const newBubbleMap = {...prevBubbleMap};\n delete newBubbleMap[id];\n return newBubbleMap;\n });\n },\n [setBubbleMap]\n );\n\n const lastSpawnTime = useRef(performance.now());\n const spawnInterval = useRef(toNumber(spawnIntervalMs));\n\n const spawnLoop = useCallback(() => {\n const time = performance.now();\n const elapsed = time - lastSpawnTime.current;\n\n if (elapsed > spawnInterval.current) {\n lastSpawnTime.current = time;\n\n const newInterval = toNumber(spawnIntervalMs);\n spawnInterval.current = newInterval;\n\n const amplitude = toNumber(wobbleAmplitude);\n const frequency = toNumber(wobbleFrequency);\n\n const wobbleDirection = Math.random() < 0.5 ? -1 : 1;\n\n const xWobbleFunction = (timeMs: number) => {\n const timeS = timeMs / 1000;\n const wobbleX =\n Math.sin(timeS * Math.PI * 2 * frequency) * amplitude * 0.6 +\n Math.cos(timeS * Math.PI * 3.7 * frequency) * amplitude * 0.4 +\n Math.sin(timeS * Math.PI * 5.3 * frequency) * amplitude * 0.2;\n\n return wobbleDirection * wobbleX;\n };\n\n const duration = toNumber(durationMs);\n\n const yMax = -toNumber(yEnd);\n\n const id = createId();\n const Component = components[Math.floor(Math.random() * components.length)];\n\n const bubbleProps: BubbleProps = {\n id,\n durationMs: duration,\n scaleStart: toNumber(scaleStart),\n scaleEnd: toNumber(scaleEnd),\n scaleEasing: toBezierEasing(scaleEasing),\n opacityStart: toNumber(opacityStart),\n opacityEnd: toNumber(opacityEnd),\n opacityEasing: toBezierEasing(opacityEasing),\n yStart: toNumber(yStart),\n yEnd: yMax,\n yEasing: toBezierEasing(yEasing),\n xStart: toNumber(xStart),\n xWobbleFunction,\n cleanup: () => {\n removeBubble(id);\n },\n children: Component,\n frameRate,\n onSpawn,\n onRemove,\n };\n\n addBubble(id, bubbleProps);\n }\n }, [\n wobbleAmplitude,\n wobbleFrequency,\n durationMs,\n scaleStart,\n scaleEnd,\n scaleEasing,\n opacityEasing,\n opacityStart,\n opacityEnd,\n yEnd,\n yEasing,\n yStart,\n components,\n frameRate,\n onSpawn,\n onRemove,\n addBubble,\n removeBubble,\n xStart,\n spawnIntervalMs,\n ]);\n\n useAnimationFrame(spawnLoop, active);\n\n return (\n <div\n style={{\n position: 'relative',\n }}>\n {bubbles.map(bubbleProps => (\n <Bubble key={bubbleProps.id} {...bubbleProps} />\n ))}\n </div>\n );\n};\n\ntype BubbleProps = PropsWithChildren<{\n id: string;\n durationMs: number;\n scaleStart: number;\n scaleEnd: number;\n scaleEasing: (timeMs: number) => number;\n opacityStart: number;\n opacityEnd: number;\n opacityEasing: (timeMs: number) => number;\n yStart: number;\n yEnd: number;\n yEasing: (timeMs: number) => number;\n xStart: number;\n xWobbleFunction: (timeMs: number) => number;\n cleanup: () => void;\n frameRate: number;\n onSpawn: () => void;\n onRemove: () => void;\n}>;\n\nexport const Bubble = ({\n durationMs,\n scaleStart,\n scaleEnd,\n scaleEasing,\n opacityStart,\n opacityEnd,\n opacityEasing,\n yStart,\n yEnd,\n yEasing,\n xStart,\n xWobbleFunction,\n cleanup,\n frameRate,\n children,\n onSpawn,\n onRemove,\n}: PropsWithChildren<BubbleProps>) => {\n const startTimestamp = useRef(performance.now());\n const scale = useRef(scaleStart);\n const opacity = useRef(opacityStart);\n const x = useRef(xStart);\n const y = useRef(yStart);\n\n const [bubbleState, setBubbleState] = useState<{x: number; y: number; scale: number; opacity: number}>({\n x: xStart,\n y: yStart,\n scale: scaleStart,\n opacity: opacityStart,\n });\n\n const throttleTime = 1000 / frameRate;\n\n const debouncedSetBubbleState = useDebounceCallback(setBubbleState, throttleTime, {maxWait: throttleTime});\n\n const [active, setActive] = useState(true);\n\n useEffect(() => {\n onSpawn();\n\n return () => {\n onRemove();\n };\n }, [onSpawn, onRemove]);\n\n const animation = useCallback(() => {\n const elapsed = performance.now() - startTimestamp.current;\n const progress = Math.min(elapsed / durationMs, 1);\n\n const opacityProgress = opacityEasing(progress);\n opacity.current = opacityStart + (opacityEnd - opacityStart) * opacityProgress;\n\n const yProgress = yEasing(progress);\n y.current = yStart + yProgress * (yEnd - yStart);\n\n const scaleProgress = scaleEasing(progress);\n scale.current = scaleStart + (scaleEnd - scaleStart) * scaleProgress;\n\n const wobbleX = xWobbleFunction(elapsed) + xStart;\n x.current = wobbleX;\n\n debouncedSetBubbleState({x: wobbleX, y: y.current, scale: scale.current, opacity: opacity.current});\n\n if (progress >= 1) {\n setActive(false);\n cleanup();\n }\n }, [\n opacityStart,\n opacityEnd,\n yStart,\n yEnd,\n xWobbleFunction,\n scaleStart,\n scaleEnd,\n opacityEasing,\n yEasing,\n scaleEasing,\n xStart,\n durationMs,\n cleanup,\n debouncedSetBubbleState,\n ]);\n\n useAnimationFrame(animation, active);\n\n if (!active) {\n return null;\n }\n\n return (\n <div\n style={{\n left: '50%',\n top: '50%',\n position: 'absolute',\n scale: bubbleState.scale,\n opacity: bubbleState.opacity.toString(),\n userSelect: 'none',\n WebkitUserSelect: 'none',\n MozUserSelect: 'none',\n transform: `translate(calc(${bubbleState.x}px - 50%), calc(${bubbleState.y}px - 50%)) scale(${bubbleState.scale})`,\n }}>\n {children}\n </div>\n );\n};\n\nexport default Bubbles;\n","import * as v from 'valibot';\n\nimport type {NumericControl} from './NumericControl';\nimport {NumericControlSchema, VariationType, VariationUnit} from './NumericControl';\n\nexport type SparkConfig = {\n /** Whether the spark spawner is active or not - setting the component as active will stop the animation loop */\n active: boolean;\n /** The components to use for the sparks - each spark will be a random component from this array */\n components: React.ReactNode[];\n /** The bezier curve definition which controls the distance of the spark over time */\n distanceEasing: [number, number, number, number];\n /** The starting distance of the spark */\n distanceStart: NumericControl;\n /** The ending distance of the spark */\n distanceEnd: NumericControl;\n /** The duration of the spark animation */\n durationMs: NumericControl;\n /** The frame rate of the animation */\n frameRate: NumericControl;\n /** The intensity of the spark */\n intensity: NumericControl;\n /** The callback function that is called when a spark is removed */\n onRemove: () => void;\n /** The callback function that is called when a spark is spawned */\n onSpawn: () => void;\n /** The bezier curve definition which controls the opacity of the spark over time */\n opacityEasing: [number, number, number, number];\n /** The ending opacity of the spark */\n opacityEnd: NumericControl;\n /** The starting opacity of the spark */\n opacityStart: NumericControl;\n /** The bezier curve definition which controls the scale of the spark over time */\n scaleEasing: [number, number, number, number];\n /** The ending scale of the spark */\n scaleEnd: NumericControl;\n /** The starting scale of the spark */\n scaleStart: NumericControl;\n /** The interval between spawning sparks */\n spawnIntervalMs: NumericControl;\n};\n\nexport const SparkConfigSchema = v.object({\n active: v.boolean(),\n components: v.array(v.any()),\n distanceEasing: v.tuple([v.number(), v.number(), v.number(), v.number()]),\n distanceStart: NumericControlSchema,\n distanceEnd: NumericControlSchema,\n durationMs: NumericControlSchema,\n frameRate: NumericControlSchema,\n intensity: NumericControlSchema,\n onRemove: v.function(),\n onSpawn: v.function(),\n opacityEasing: v.tuple([v.number(), v.number(), v.number(), v.number()]),\n opacityEnd: NumericControlSchema,\n opacityStart: NumericControlSchema,\n scaleEasing: v.tuple([v.number(), v.number(), v.number(), v.number()]),\n scaleEnd: NumericControlSchema,\n scaleStart: NumericControlSchema,\n spawnIntervalMs: NumericControlSchema,\n});\n\nexport const defaultSparkConfig: SparkConfig = {\n active: true,\n components: ['💸', '🔥'],\n distanceEasing: [0.25, 0, 0.8, 1.2],\n distanceStart: 0,\n durationMs: 1000,\n frameRate: 50,\n intensity: 1,\n distanceEnd: {value: 400, variation: {type: VariationType.PlusMinus, unit: VariationUnit.Percent, value: 50}},\n onRemove: () => {},\n onSpawn: () => {},\n opacityEasing: [0.25, 0, 0.8, 1.2],\n opacityEnd: 0,\n opacityStart: 1,\n scaleEasing: [0.25, 0, 0.8, 1.2],\n scaleEnd: 5,\n scaleStart: 0.5,\n spawnIntervalMs: {\n value: 500,\n variation: {\n type: VariationType.PlusMinus,\n unit: VariationUnit.Percent,\n value: 50,\n },\n },\n};\n\nexport const buildSparkConfig = (sparkConfigOverrides: Partial<SparkConfig> = {}) => {\n return v.parse(SparkConfigSchema, {\n ...defaultSparkConfig,\n ...sparkConfigOverrides,\n });\n};\n","import {useCallback, useEffect, useMemo, useRef, useState} from 'react';\nimport {useDebounceCallback} from 'usehooks-ts';\n\nimport {useAnimationFrame} from './useAnimationFrame';\nimport {toBezierEasing} from './toBezierEasing';\nimport type {SparkConfig} from './SparkConfig';\nimport {buildSparkConfig} from './SparkConfig';\nimport {createId} from './createId';\nimport {toNumber} from './NumericControl';\n\n/**\n * `Sparks` is a standalone particle spawner component\n * \n * The `Spark` particles spawn within an origin area and then radiate outwards at a fixed angle from their start point.\n * \n * Particles can be any valid `ReactNode` - we've used emojis by default.\n * \n * You can pass an array of your own `components` to render. The spawner will then pick one at random.\n * We recommend that we recommend that you keep the components simple to render for performance\n\n * ## Usage\n *\n * ```jsx\n *\n * import { Sparks } from \"react-fidget-spinner\"\n *\n *\n * const MySparks = () => {\n *\n * return (\n * <Sparks components={['💸', \"Spark\", <ComplexSpark /> ]} />\n * )\n * }\n *\n * ```\n */\nexport const Sparks = (config: Partial<SparkConfig>) => {\n const {\n components,\n durationMs,\n distanceStart,\n distanceEnd,\n distanceEasing,\n opacityEasing,\n opacityStart,\n opacityEnd,\n scaleEasing,\n scaleStart,\n scaleEnd,\n onSpawn,\n onRemove,\n frameRate,\n active,\n spawnIntervalMs,\n } = buildSparkConfig(config);\n\n const [sparkMap, setSparkMap] = useState<Record<string, SparkProps>>({});\n\n const sparks = useMemo(() => {\n return Object.values(sparkMap);\n }, [sparkMap]);\n\n const addSpark = useCallback(\n (id: string, sparkProps: SparkProps) => {\n setSparkMap(prevSparkMap => ({...prevSparkMap, [id]: sparkProps}));\n },\n [setSparkMap]\n );\n\n const removeSpark = useCallback(\n (id: string) => {\n setSparkMap(prevSparkMap => {\n const newSparkMap = {...prevSparkMap};\n delete newSparkMap[id];\n return newSparkMap;\n });\n },\n [setSparkMap]\n );\n\n const lastSpawnTime = useRef(performance.now());\n const spawnInterval = useRef(toNumber(spawnIntervalMs));\n\n const spawnLoop = useCallback(() => {\n const time = performance.now();\n const elapsed = time - lastSpawnTime.current;\n\n if (elapsed > spawnInterval.current) {\n lastSpawnTime.current = time;\n\n spawnInterval.current = toNumber(spawnIntervalMs);\n\n const id = createId();\n const SparkComponent = components[Math.floor(Math.random() * components.length)];\n const angleRadians = Math.random() * 2 * Math.PI;\n\n const sparkProps: SparkProps = {\n id,\n durationMs: toNumber(durationMs),\n frameRate: toNumber(frameRate),\n opacityStart: toNumber(opacityStart),\n opacityEnd: toNumber(opacityEnd),\n opacityEasing,\n distanceStart: toNumber(distanceStart),\n distanceEnd: toNumber(distanceEnd),\n distanceEasing,\n onSpawn,\n onRemove,\n cleanup: () => {\n removeSpark(id);\n },\n angleRadians,\n scaleStart: toNumber(scaleStart),\n scaleEnd: toNumber(scaleEnd),\n scaleEasing,\n Component: SparkComponent,\n };\n\n addSpark(id, sparkProps);\n }\n }, [\n lastSpawnTime,\n addSpark,\n removeSpark,\n components,\n durationMs,\n frameRate,\n opacityStart,\n opacityEnd,\n opacityEasing,\n distanceEasing,\n onSpawn,\n onRemove,\n scaleStart,\n scaleEnd,\n scaleEasing,\n distanceStart,\n spawnIntervalMs,\n distanceEnd,\n ]);\n\n useAnimationFrame(spawnLoop, active);\n\n return (\n <div style={{position: 'relative'}}>\n {sparks.map(sparkProps => (\n <Spark key={sparkProps.id} {...sparkProps} />\n ))}\n </div>\n );\n};\n\ntype SparkProps = {\n id: string;\n durationMs: number;\n frameRate: number;\n angleRadians: number;\n scaleStart: number;\n scaleEnd: number;\n scaleEasing: [number, number, number, number];\n opacityStart: number;\n opacityEnd: number;\n opacityEasing: [number, number, number, number];\n distanceStart: number;\n distanceEnd: number;\n distanceEasing: [number, number, number, number];\n onSpawn: () => void;\n onRemove: () => void;\n cleanup: () => void;\n Component: React.ReactNode;\n};\n\nexport const Spark = ({\n durationMs,\n frameRate,\n angleRadians,\n scaleStart,\n scaleEnd,\n scaleEasing,\n opacityStart,\n opacityEnd,\n opacityEasing,\n distanceStart,\n distanceEnd,\n distanceEasing,\n onSpawn,\n onRemove,\n cleanup,\n Component,\n}: SparkProps) => {\n const startTimestamp = useRef(performance.now());\n\n const xStart = Math.cos(angleRadians) * distanceStart;\n const yStart = Math.sin(angleRadians) * distanceStart;\n\n const x = useRef(xStart);\n const y = useRef(yStart);\n const scale = useRef(scaleStart);\n const opacity = useRef(opacityStart);\n\n const [active, setActive] = useState(true);\n\n useEffect(() => {\n onSpawn();\n\n return () => {\n onRemove();\n };\n }, [onSpawn, onRemove]);\n\n const [sparkState, setSparkState] = useState<{x: number; y: number; scale: number; opacity: number}>({\n x: xStart,\n y: yStart,\n scale: scaleStart,\n opacity: opacityStart,\n });\n\n const throttleTime = 1000 / frameRate;\n\n const debouncedSetSparkState = useDebounceCallback(setSparkState, throttleTime, {maxWait: throttleTime});\n\n const animation = useCallback(() => {\n const elapsed = performance.now() - startTimestamp.current;\n const progress = Math.min(elapsed / durationMs, 1);\n\n const opacityProgress = toBezierEasing(opacityEasing)(progress);\n opacity.current = opacityStart + (opacityEnd - opacityStart) * opacityProgress;\n\n const scaleProgress = toBezierEasing(scaleEasing)(progress);\n scale.current = scaleStart + (scaleEnd - scaleStart) * scaleProgress;\n\n const distanceProgress = toBezierEasing(distanceEasing)(progress);\n const distancePx = distanceStart + (distanceEnd - distanceStart) * distanceProgress;\n\n const angle = angleRadians;\n x.current = Math.cos(angle) * distancePx;\n y.current = Math.sin(angle) * distancePx;\n\n debouncedSetSparkState({x: x.current, y: y.current, opacity: opacity.current, scale: scale.current});\n\n if (progress >= 1) {\n setActive(false);\n cleanup();\n }\n }, [\n debouncedSetSparkState,\n cleanup,\n distanceStart,\n distanceEnd,\n distanceEasing,\n opacityStart,\n opacityEnd,\n opacityEasing,\n scaleStart,\n scaleEnd,\n scaleEasing,\n angleRadians,\n durationMs,\n ]);\n\n useAnimationFrame(animation, active);\n\n if (!active) {\n return null;\n }\n\n return (\n <div\n style={{\n left: '50%',\n top: '50%',\n position: 'absolute',\n userSelect: 'none',\n WebkitUserSelect: 'none',\n MozUserSelect: 'none',\n opacity: sparkState.opacity,\n transform: `translate(calc(${sparkState.x}px - 50%), calc(${sparkState.y}px - 50%)) scale(${sparkState.scale})`,\n }}>\n {Component}\n </div>\n );\n};\n","import * as v from 'valibot';\n\ntype SpinnerConfigCallbacks = {\n onMaxAngularVelocity: () => void;\n onClick: () => void;\n};\n\nexport const SpinnerConfigSchema = v.object({\n dampingCoefficient: v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(1)),\n initialAngle: v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(Math.PI * 2)),\n initialAngularVelocity: v.pipe(v.number(), v.toMinValue(0)),\n maxAngularVelocity: v.pipe(v.number(), v.toMinValue(0)),\n onMaxAngularVelocity: v.function(),\n onClick: v.function(),\n direction: v.union([v.literal('clockwise'), v.literal('antiClockwise')]),\n});\n\nexport type SpinnerConfig = Omit<v.InferOutput<typeof SpinnerConfigSchema>, keyof SpinnerConfigCallbacks> &\n SpinnerConfigCallbacks;\n\nexport const defaultSpinnerConfig: SpinnerConfig = {\n dampingCoefficient: 0.5,\n initialAngle: 0,\n initialAngularVelocity: 0,\n maxAngularVelocity: Math.PI * 20,\n onMaxAngularVelocity: () => {},\n onClick: () => {},\n direction: 'clockwise',\n};\n\nexport const buildSpinnerConfig = (spinnerConfigOverrides: Partial<SpinnerConfig> = {}) => {\n return v.parse(SpinnerConfigSchema, {\n ...defaultSpinnerConfig,\n ...spinnerConfigOverrides,\n });\n};\n","import * as v from 'valibot';\n\nimport {EasingSchema} from './toBezierEasing';\ntype ScaleConfigCallbacks = {\n onScaleChange: (scale: number) => void;\n onScaleEnd: () => void;\n onScaleStart: () => void;\n};\n\nexport const ScaleConfigSchema = v.object({\n onScaleChange: v.function(),\n onScaleEnd: v.function(),\n onScaleStart: v.function(),\n scale: v.pipe(v.number(), v.toMinValue(0)),\n scaleDurationMs: v.pipe(v.number(), v.toMinValue(0)),\n scaleEasing: EasingSchema,\n});\n\nexport type ScaleConfig = Omit<v.InferOutput<typeof ScaleConfigSchema>, keyof ScaleConfigCallbacks> &\n ScaleConfigCallbacks;\n\nexport const defaultScaleConfig: ScaleConfig = {\n onScaleChange: () => {},\n onScaleEnd: () => {},\n onScaleStart: () => {},\n scale: 1,\n scaleDurationMs: 500,\n scaleEasing: [0.25, -0.75, 0.8, 1.2],\n};\n\nexport const buildScaleConfig = (scaleConfigOverrides: Partial<ScaleConfig> = {}) => {\n return v.parse(ScaleConfigSchema, {\n ...defaultScaleConfig,\n ...scaleConfigOverrides,\n });\n};\n","import * as v from 'valibot';\n\nimport {EasingSchema} from './toBezierEasing';\n\ntype ResetConfigCallbacks = {\n onResetStart: () => void;\n onResetEnd: () => void;\n onResetCancel: () => void;\n};\n\nexport const ResetConfigSchema = v.object({\n durationMs: v.pipe(v.number(), v.toMinValue(0)),\n easing: EasingSchema,\n onResetStart: v.function(),\n onResetEnd: v.function(),\n onResetCancel: v.function(),\n});\n\nexport type ResetConfig = Omit<v.InferOutput<typeof ResetConfigSchema>, keyof ResetConfigCallbacks> &\n ResetConfigCallbacks;\n\nexport const defaultResetConfig: ResetConfig = {\n durationMs: 200,\n easing: [0.25, -0.75, 0.8, 1.2],\n onResetStart: () => {},\n onResetEnd: () => {},\n onResetCancel: () => {},\n};\n\nexport const buildResetConfig = (resetConfigOverrides: Partial<ResetConfig> = {}) => {\n return v.parse(ResetConfigSchema, {\n ...defaultResetConfig,\n ...resetConfigOverrides,\n });\n};\n","import * as v from 'valibot';\n\nimport {type NumericControl, NumericControlSchema} from './NumericControl';\n\nexport const ClickConfigSchema = v.object({\n angularVelocityPerClick: NumericControlSchema,\n onSpawn: v.function(),\n onRemove: v.function(),\n active: v.boolean(),\n});\n\nexport type ClickConfig = {\n angularVelocityPerClick: NumericControl;\n onSpawn: () => void;\n onRemove: () => void;\n active: boolean;\n};\n\nexport const defaultClickConfig: ClickConfig = {\n angularVelocityPerClick: Math.PI * 2,\n onSpawn: () => {},\n onRemove: () => {},\n active: true,\n};\n\nexport const buildClickConfig = (clickConfigOverrides: Partial<ClickConfig> = {}) => {\n return v.parse(ClickConfigSchema, {\n ...defaultClickConfig,\n ...clickConfigOverrides,\n });\n};\n","import * as v from 'valibot';\n\nimport type {BubbleConfig} from './BubbleConfig';\nimport {BubbleConfigSchema, buildBubbleConfig} from './BubbleConfig';\nimport type {SparkConfig} from './SparkConfig';\nimport {buildSparkConfig, SparkConfigSchema} from './SparkConfig';\nimport type {ScaleConfig} from './ScaleConfig';\nimport {buildScaleConfig, ScaleConfigSchema} from './ScaleConfig';\nimport type {ResetConfig} from './ResetConfig';\nimport {buildResetConfig, ResetConfigSchema} from './ResetConfig';\nimport type {SpinnerConfig} from './SpinnerConfig';\nimport {buildSpinnerConfig, SpinnerConfigSchema} from './SpinnerConfig';\nimport type {ClickConfig} from './ClickConfig';\nimport {buildClickConfig, ClickConfigSchema} from './ClickConfig';\n\nconst VelocityBreakpointConfigSchema = v.object({\n scaleConfig: ScaleConfigSchema,\n bubbleConfig: BubbleConfigSchema,\n sparkConfig: SparkConfigSchema,\n resetConfig: ResetConfigSchema,\n spinnerConfig: SpinnerConfigSchema,\n clickConfig: ClickConfigSchema,\n});\n\nexport const VelocityBreakpointSchema = v.object({\n breakpoint: v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(1)),\n config: VelocityBreakpointConfigSchema,\n});\nexport type VelocityBreakpointConfig = v.InferOutput<typeof VelocityBreakpointConfigSchema>;\n\nexport type VelocityBreakpoint = v.InferOutput<typeof VelocityBreakpointSchema>;\n\nexport const VelocityBreakpointConfigsSchema = v.array(VelocityBreakpointSchema);\nexport type VelocityBreakpoints = v.InferOutput<typeof VelocityBreakpointConfigsSchema>;\n\nexport const defaultVelocityBreakpoints: VelocityBreakpointInput[] = [\n {\n breakpoint: 0.9,\n config: {\n scaleConfig: {scale: 3},\n },\n },\n {\n breakpoint: 0.7,\n config: {\n scaleConfig: {scale: 2},\n },\n },\n {\n breakpoint: 0.3,\n config: {\n scaleConfig: {scale: 1.5},\n },\n },\n];\n\ntype VelocityBreakpointConfigInput = {\n scaleConfig?: Partial<ScaleConfig>;\n bubbleConfig?: Partial<BubbleConfig>;\n sparkConfig?: Partial<SparkConfig>;\n resetConfig?: Partial<ResetConfig>;\n spinnerConfig?: Partial<SpinnerConfig>;\n clickConfig?: Partial<ClickConfig>;\n};\n\nexport type VelocityBreakpointInput = {\n breakpoint: number;\n config: VelocityBreakpointConfigInput;\n};\n\nexport type VelocityBreakpointsInput = VelocityBreakpointInput[];\n\nexport const buildVelocityBreakpoint = (breakpointInput: VelocityBreakpointInput) => {\n const {breakpoint, config} = breakpointInput;\n\n const {scaleConfig, bubbleConfig, sparkConfig, resetConfig, spinnerConfig, clickConfig} = config;\n\n return v.parse(VelocityBreakpointSchema, {\n breakpoint,\n config: {\n scaleConfig: buildScaleConfig(scaleConfig),\n bubbleConfig: buildBubbleConfig(bubbleConfig),\n sparkConfig: buildSparkConfig(sparkConfig),\n resetConfig: buildResetConfig(resetConfig),\n spinnerConfig: buildSpinnerConfig(spinnerConfig),\n clickConfig: buildClickConfig(clickConfig),\n },\n });\n};\n\nexport const buildVelocityBreakpoints = (\n breakpointInputs: VelocityBreakpointInput[] = defaultVelocityBreakpoints,\n baseConfig: VelocityBreakpointConfigInput\n) => {\n const breakpoints = breakpointInputs.map(breakpointInput => {\n const breakpointInputConfig = breakpointInput.config;\n\n const mergedConfig = {\n scaleConfig: {\n ...baseConfig.scaleConfig,\n ...breakpointInputConfig.scaleConfig,\n },\n bubbleConfig: {\n ...baseConfig.bubbleConfig,\n ...breakpointInputConfig.bubbleConfig,\n },\n sparkConfig: {\n ...baseConfig.sparkConfig,\n ...breakpointInputConfig.sparkConfig,\n },\n resetConfig: {\n ...baseConfig.resetConfig,\n ...breakpointInputConfig.resetConfig,\n },\n spinnerConfig: {\n ...baseConfig.spinnerConfig,\n ...breakpointInputConfig.spinnerConfig,\n },\n clickConfig: {\n ...baseConfig.clickConfig,\n ...breakpointInputConfig.clickConfig,\n },\n };\n\n return buildVelocityBreakpoint({...breakpointInput, config: mergedConfig});\n });\n return v.parse(VelocityBreakpointConfigsSchema, breakpoints);\n};\n","import {useCallback, useEffect, useMemo, useState} from 'react';\n\nexport const useConfig = <Config>(\n overrides: Partial<Config> = {},\n builder: (configOverrides: Partial<Config>) => Config\n): [Config, React.Dispatch<React.SetStateAction<Config>>, Config, () => void] => {\n const baseConfig = builder(overrides);\n const [config, setConfig] = useState(baseConfig);\n\n const serialisedOverrides = useMemo(() => JSON.stringify(overrides), [overrides]);\n\n useEffect(() => {\n const newConfig = builder(JSON.parse(serialisedOverrides));\n setConfig(newConfig);\n }, [serialisedOverrides, builder]);\n\n const reset = useCallback(() => {\n setConfig(baseConfig);\n }, [baseConfig]);\n\n return [config, setConfig, baseConfig, reset];\n};\n","import type {PropsWithChildren} from 'react';\nimport {useCallback, useMemo, useRef, useState} from 'react';\n\nimport {useAnimationFrame} from './useAnimationFrame';\nimport {toBezierEasing} from './toBezierEasing';\nimport {Bubbles} from './Bubbles';\nimport {Sparks} from './Sparks';\nimport type {SpinnerConfig} from './SpinnerConfig';\nimport {buildSpinnerConfig} from './SpinnerConfig';\nimport type {ScaleConfig} from './ScaleConfig';\nimport {buildScaleConfig} from './ScaleConfig';\nimport type {ResetConfig} from './ResetConfig';\nimport {buildResetConfig} from './ResetConfig';\nimport type {BubbleConfig} from './BubbleConfig';\nimport {buildBubbleConfig} from './BubbleConfig';\nimport type {SparkConfig} from './SparkConfig';\nimport {buildSparkConfig} from './SparkConfig';\nimport type {VelocityBreakpointsInput, VelocityBreakpoints} from './VelocityBreakpoints';\nimport {buildVelocityBreakpoints} from './VelocityBreakpoints';\nimport {useConfig} from './useConfig';\nimport type {ClickConfig} from './ClickConfig';\nimport {buildClickConfig} from './ClickConfig';\nconst containerId = 'fidget-spinner-container';\nimport {toNumber} from './NumericControl';\n\nexport type FidgetSpinnerProps = {\n /** Configuration that gets passed to the underlying `Bubbles` particle spawner component*/\n bubbleConfig?: Partial<BubbleConfig>;\n\n /** Configuration for the resetting animation */\n resetConfig?: Partial<ResetConfig>;\n /** Configuration for the animation that happens when the FidgetSpinner changes in size */\n scaleConfig?: Partial<ScaleConfig>;\n /** Configuration that gets passed to the underlying `Sparks` particle spawner component*/\n sparkConfig?: Partial<SparkConfig>;\n /** Configuration for the flywheel physics of the `FidgetSpinner` */\n spinnerConfig?: Partial<SpinnerConfig>;\n /** An array of configuration changes that trigger when the velocity of the fidget spinner gets to `x%` of its `maxAngularVelocity` */\n velocityBreakpoints?: VelocityBreakpointsInput;\n /** Configuration for the mouse click animation */\n clickConfig?: Partial<ClickConfig>;\n};\n\n/**\n *\n * ## Basic Usage\n * Turns `children` into a clickable interactive fidget spinner:\n *\n * - Each click adds energy which makes it spin faster\n * - It will then slow down and eventually trigger a reset animation.\n *\n *\n * ```tsx\n * <FidgetSpinner>\n * <YourComponent />\n * </FidgetSpinner>\n * ```\n *\n *\n * ## Config Overrides\n * Any props passed through to the `FidgetSpinner` will **shallow merge** with the default values.\n *\n * ```tsx\n * <FidgetSpinner scaleConfig={{scale: 2}}>\n * <YourComponent />\n * </FidgetSpinner>\n * ```\n *\n *\n * ## Complex Configuration\n * config builder functions are exported at the top level to help build out more complex configuration. Eg, when using velocity breakpoints.\n *\n *\n * ```tsx\n * // eg for scale config which controls the size of the spinner\n * import { buildScaleConfig } from './react-fidget-spinnner';\n * ```\n *\n * ## What are the default configuration values?\n * Each `control` visible in storybook is the `default` value for that prop.\n */\nexport const FidgetSpinner = ({\n bubbleConfig: bubbleConfigOverrides,\n children,\n resetConfig: resetConfigOverrides,\n scaleConfig: scaleConfigOverrides,\n sparkConfig: sparkConfigOverrides,\n spinnerConfig: spinnerConfigOverrides,\n velocityBreakpoints: velocityBreakpointsOverrides,\n clickConfig: clickConfigOverrides,\n}: PropsWithChildren<FidgetSpinnerProps>) => {\n const [scaleConfig, setScaleConfig, baseScaleConfig, resetScaleConfig] = useConfig(\n scaleConfigOverrides,\n buildScaleConfig\n );\n const [resetConfig, setResetConfig, baseResetConfig, resetResetConfig] = useConfig(\n resetConfigOverrides,\n buildResetConfig\n );\n const [sparkConfig, setSparkConfig, baseSparkConfig, resetSparkConfig] = useConfig(\n sparkConfigOverrides,\n buildSparkConfig\n );\n const [spinnerConfig, setSpinnerConfig, baseSpinnerConfig, resetSpinnerConfig] = useConfig(\n spinnerConfigOverrides,\n buildSpinnerConfig\n );\n const [bubbleConfig, setBubbleConfig, baseBubbleConfig, resetBubbleConfig] = useConfig(\n bubbleConfigOverrides,\n buildBubbleConfig\n );\n\n const [clickConfig, setClickConfig, baseClickConfig, resetClickConfig] = useConfig(\n clickConfigOverrides,\n buildClickConfig\n );\n\n const baseConfig = {\n scaleConfig: baseScaleConfig,\n resetConfig: baseResetConfig,\n bubbleConfig: baseBubbleConfig,\n sparkConfig: baseSparkConfig,\n spinnerConfig: baseSpinnerConfig,\n clickConfig: baseClickConfig,\n };\n\n const velocityBreakpoints: VelocityBreakpoints = buildVelocityBreakpoints(velocityBreakpointsOverrides, baseConfig);\n\n const [angleRadians, setAngleRadians] = useState(spinnerConfig.initialAngle);\n const angleRadiansRef = useRef(spinnerConfig.initialAngle);\n const angularVelocityRef = useRef(spinnerConfig.initialAngularVelocity);\n const isResettingRef = useRef(false);\n const resetStartTimeRef = useRef<number | null>(null);\n const resetStartAngleRef = useRef<number | null>(null);\n\n const scalingStartTimeRef = useRef<number | null>(null);\n const initialScaleRef = useRef<number | null>(null);\n const targetScaleRef = useRef<number | null>(null);\n const isScalingRef = useRef(false);\n const scaleRef = useRef(baseScaleConfig.scale);\n const currentBreakpointConfigRef = useRef<number | null>(null);\n\n const [scale, setScale] = useState(baseScaleConfig.scale);\n const [isActive, setIsActive] = useState(false);\n\n const sortedBreakpoints = useMemo(() => {\n return [...velocityBreakpoints].sort((a, b) => b.breakpoint - a.breakpoint);\n }, [velocityBreakpoints]);\n\n const getCurrentBreakpoint = useCallback(() => {\n const velocityPercentage = Math.abs(angularVelocityRef.current) / spinnerConfig.maxAngularVelocity;\n\n const breakpoint = sortedBreakpoints.find(breakpoint => velocityPercentage >= breakpoint.breakpoint);\n\n return breakpoint || null;\n }, [sortedBreakpoints, spinnerConfig.maxAngularVelocity]);\n\n const resetConfigs = useCallback(() => {\n resetSparkConfig();\n resetBubbleConfig();\n resetScaleConfig();\n resetResetConfig();\n resetSpinnerConfig();\n resetClickConfig();\n }, [resetSparkConfig, resetBubbleConfig, resetScaleConfig, resetResetConfig, resetSpinnerConfig, resetClickConfig]);\n\n const startScaling = useCallback(\n ({newScale = 1}: {newScale?: number}) => {\n scalingStartTimeRef.current = performance.now();\n initialScaleRef.current = scaleRef.current;\n targetScaleRef.current = newScale;\n isScalingRef.current = true;\n scaleConfig.onScaleStart();\n scaleConfig.onScaleChange(newScale);\n },\n [scaleConfig]\n );\n\n const endScaling = useCallback(() => {\n scalingStartTimeRef.current = null;\n initialScaleRef.current = null;\n targetScaleRef.current = null;\n isScalingRef.current = false;\n scaleConfig.onScaleEnd();\n }, [scaleConfig]);\n\n const scaleAnimation = useCallback(() => {\n const scaleTransitionTime = scaleConfig.scaleDurationMs;\n\n const scaleStartTime = scalingStartTimeRef.current;\n const scaleStartScale = initialScaleRef.current;\n const targetScale = targetScaleRef.current;\n\n if (!scaleStartTime || !scaleStartScale || !targetScale) {\n return;\n }\n const elapsedTime = performance.now() - scaleStartTime;\n const timeProgress = Math.min(elapsedTime / scaleTransitionTime, 1);\n const easing = toBezierEasing(scaleConfig.scaleEasing);\n const easedProgress = easing(timeProgress);\n\n const newScale = scaleStartScale + (targetScale - scaleStartScale) * easedProgress;\n scaleRef.current = newScale;\n setScale(newScale);\n\n if (timeProgress >= 1) {\n endScaling();\n }\n }, [setScale, endScaling, scaleConfig.scaleDurationMs, scaleConfig.scaleEasing]);\n\n const resetState = useCallback(() => {\n setAngleRadians(spinnerConfig.initialAngle);\n angleRadiansRef.current = spinnerConfig.initialAngle;\n angularVelocityRef.current = spinnerConfig.initialAngularVelocity;\n isResettingRef.current = false;\n resetStartTimeRef.current = null;\n resetStartAngleRef.current = null;\n currentBreakpointConfigRef.current = null;\n }, [spinnerConfig.initialAngle, spinnerConfig.initialAngularVelocity]);\n\n const beginReset = useCallback(() => {\n resetConfig.onResetStart();\n isResettingRef.current = true;\n resetStartTimeRef.current = performance.now();\n resetStartAngleRef.current = angleRadiansRef.current;\n }, [resetConfig]);\n\n const cancelReset = useCallback(() => {\n isResettingRef.current = false;\n resetStartTimeRef.current = null;\n resetStartAngleRef.current = null;\n resetConfig.onResetCancel();\n }, [resetConfig]);\n\n const rotationAnimation = useCallback(\n (deltaTime: number) => {\n if (isResettingRef.current) {\n if (resetStartTimeRef.current === null) {\n resetStartTimeRef.current = performance.now();\n }\n const elapsedTime = performance.now() - resetStartTimeRef.current;\n const timeProgre