diff --git a/package-lock.json b/package-lock.json index dba65d5..695eb94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@react-spring/web": "^9.7.3", "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -1408,6 +1409,66 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@react-spring/animated": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz", + "integrity": "sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==", + "dependencies": { + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.3.tgz", + "integrity": "sha512-IqFdPVf3ZOC1Cx7+M0cXf4odNLxDC+n7IN3MDcVCTIOSBfqEcBebSv+vlY5AhM0zw05PDbjKrNmBpzv/AqpjnQ==", + "dependencies": { + "@react-spring/animated": "~9.7.3", + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/shared": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.3.tgz", + "integrity": "sha512-NEopD+9S5xYyQ0pGtioacLhL2luflh6HACSSDUZOwLHoxA5eku1UPuqcJqjwSD6luKjjLfiLOspxo43FUHKKSA==", + "dependencies": { + "@react-spring/types": "~9.7.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.3.tgz", + "integrity": "sha512-Kpx/fQ/ZFX31OtlqVEFfgaD1ACzul4NksrvIgYfIFq9JpDHFwQkMVZ10tbo0FU/grje4rcL4EIrjekl3kYwgWw==" + }, + "node_modules/@react-spring/web": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.3.tgz", + "integrity": "sha512-BXt6BpS9aJL/QdVqEIX9YoUy8CE6TJrU0mNCqSoxdXlIeNcEBWOfIyE6B14ENNsyQKS3wOWkiJfco0tCr/9tUg==", + "dependencies": { + "@react-spring/animated": "~9.7.3", + "@react-spring/core": "~9.7.3", + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", @@ -5383,6 +5444,49 @@ "@babel/runtime": "^7.13.10" } }, + "@react-spring/animated": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz", + "integrity": "sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==", + "requires": { + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + } + }, + "@react-spring/core": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.3.tgz", + "integrity": "sha512-IqFdPVf3ZOC1Cx7+M0cXf4odNLxDC+n7IN3MDcVCTIOSBfqEcBebSv+vlY5AhM0zw05PDbjKrNmBpzv/AqpjnQ==", + "requires": { + "@react-spring/animated": "~9.7.3", + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + } + }, + "@react-spring/shared": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.3.tgz", + "integrity": "sha512-NEopD+9S5xYyQ0pGtioacLhL2luflh6HACSSDUZOwLHoxA5eku1UPuqcJqjwSD6luKjjLfiLOspxo43FUHKKSA==", + "requires": { + "@react-spring/types": "~9.7.3" + } + }, + "@react-spring/types": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.3.tgz", + "integrity": "sha512-Kpx/fQ/ZFX31OtlqVEFfgaD1ACzul4NksrvIgYfIFq9JpDHFwQkMVZ10tbo0FU/grje4rcL4EIrjekl3kYwgWw==" + }, + "@react-spring/web": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.3.tgz", + "integrity": "sha512-BXt6BpS9aJL/QdVqEIX9YoUy8CE6TJrU0mNCqSoxdXlIeNcEBWOfIyE6B14ENNsyQKS3wOWkiJfco0tCr/9tUg==", + "requires": { + "@react-spring/animated": "~9.7.3", + "@react-spring/core": "~9.7.3", + "@react-spring/shared": "~9.7.3", + "@react-spring/types": "~9.7.3" + } + }, "@remix-run/router": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", diff --git a/package.json b/package.json index f906df8..5a0d94e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@react-spring/web": "^9.7.3", "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -58,4 +59,4 @@ "typescript": "^5.2.2", "vite": "^5.0.0" } -} \ No newline at end of file +} diff --git a/src/components/counter.tsx b/src/components/counter.tsx index 0a40444..864e18c 100644 --- a/src/components/counter.tsx +++ b/src/components/counter.tsx @@ -1,42 +1,18 @@ import { useMemo } from "react"; import { DateTime } from "luxon"; import { cn } from "@/lib/utils"; +import { ScoreWheel } from "./score-wheel"; import { useFormData, useRemainingTime } from "@/lib/hooks"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - -function CounterDisplaySingleDigit({ num }: { num: number }) { - const offset = useMemo(() => { - if (num < 0) return 0; - else if (num > 9) return 9; - return num; - }, [num]); - - const getNumber = (n: number) => ( -
- {n} -
- ); - - return ( -
-
- {numbers.map((n) => getNumber(n))} -
-
- ); -} - function CounterDisplayNumber({ num, + max, tooltip, className, }: { num: number; + max?: number[]; tooltip?: string; className?: string; }) { @@ -53,7 +29,7 @@ function CounterDisplayNumber({
{nums.map((num, i) => ( - + ))}
@@ -104,6 +80,7 @@ export function Counter() { @@ -113,6 +90,7 @@ export function Counter() { @@ -122,6 +100,7 @@ export function Counter() { diff --git a/src/components/score-wheel.tsx b/src/components/score-wheel.tsx new file mode 100644 index 0000000..40836f8 --- /dev/null +++ b/src/components/score-wheel.tsx @@ -0,0 +1,120 @@ +import { usePrev } from "../lib/hooks"; +import { useEffect, useMemo } from "react"; +import { animated, useSpring, to } from "@react-spring/web"; + +const OFFSET = -0.1; +const ITEM_HEIGHT = 80; +/* keep ~80x45 aspect ratio */ +const ITEM_WIDTH = ITEM_HEIGHT * 0.56; + +type RotationMap = { + [k in number]: { + spawnPosition: number; + position: number; + }; +}; + +function getParams(itemCount: number) { + const itemAngle = 360 / itemCount; + + const alpha = itemAngle / 2; + const a = ITEM_HEIGHT / 2; + const c = a / Math.sin((alpha * Math.PI) / 180); + const b = Math.sqrt(c * c - a * a); + + return { + itemCount, + angle: itemAngle, + diameter: b * 2 + OFFSET, + radius: b + OFFSET, + }; +} + +function calculateRotationDelta(fromAngle: number, toAngle: number) { + const normalizedFrom = ((fromAngle % 360) + 360) % 360; + const normalizedTo = ((toAngle % 360) + 360) % 360; + + const clockwiseDiff = (normalizedTo - normalizedFrom + 360) % 360; + const counterclockwiseDiff = (normalizedFrom - normalizedTo + 360) % 360; + + return clockwiseDiff < counterclockwiseDiff + ? clockwiseDiff + : -counterclockwiseDiff; +} + +export function ScoreWheel({ + num: nextNum, + max = 9, +}: { + num: number; + max?: number; +}) { + nextNum = Math.min(Math.max(nextNum, 0), max); + const params = useMemo(() => getParams(max + 1), [max]); + const rotationMap = useMemo(() => { + const nums = Array.from({ length: params.itemCount }).map((_, i) => i); + const _rotationMap: RotationMap = {}; + for (let i = 0; i < nums.length; i++) { + const num = nums[i]; + _rotationMap[num] = { + spawnPosition: params.angle * (nums.length - i), + position: params.angle * i, + }; + } + return _rotationMap; + }, [params]); + const num = usePrev(nextNum) ?? 0; + + const [styles, api] = useSpring(() => ({ rotateX: 0 }), []); + + useEffect(() => { + if (num !== nextNum) { + const numAngle = rotationMap[num].position; + const nextNumAngle = rotationMap[nextNum].position; + const rotationDelta = calculateRotationDelta(numAngle, nextNumAngle); + api.start({ + from: { rotateX: numAngle }, + to: { rotateX: numAngle + rotationDelta }, + }); + } + }, [num, nextNum, api, rotationMap]); + + return ( +
+
+ { + return `rotateX(${x}deg)`; + }), + }} + > + {Object.entries(rotationMap).map(([num, rotation]) => ( +
+ {num} +
+ ))} +
+
+
+ ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 99bea9f..d2b2d0f 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,6 +1,6 @@ import { DateTime } from "luxon"; import { useSearchParams } from "react-router-dom"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export function useRemainingTime() { const [{ endDate: targetDate }] = useFormData(); @@ -117,3 +117,11 @@ export function useFormData(): [FormData, (values: Partial) => void] { return [{ endDate, digits, title, imageId }, setData]; } + +export function usePrev(state: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = state; + }); + return ref.current; +}