diff --git a/PetitProjects/Team3/package.json b/PetitProjects/Team3/package.json index efd0c57..c0375c9 100644 --- a/PetitProjects/Team3/package.json +++ b/PetitProjects/Team3/package.json @@ -30,6 +30,7 @@ "@storybook/react": "^6.5.7", "@storybook/testing-library": "^0.0.11", "@testing-library/react": "^13.3.0", + "@testing-library/react-hooks": "^8.0.0", "@types/animejs": "^3.1.4", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/PetitProjects/Team3/src/components/Akalee/Akalee.stories.tsx b/PetitProjects/Team3/src/components/Akalee/Akalee.stories.tsx new file mode 100644 index 0000000..53e9013 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/Akalee.stories.tsx @@ -0,0 +1,14 @@ +import Akalee from './Akalee'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; + +export default { + title: 'TEAM3/Akalee', + component: Akalee, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); diff --git a/PetitProjects/Team3/src/components/Akalee/Akalee.tsx b/PetitProjects/Team3/src/components/Akalee/Akalee.tsx new file mode 100644 index 0000000..e811ded --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/Akalee.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { styled, globalCss } from '@stitches/react'; +import Clock from './Clock'; +import { useMediaQuery, useMediaValue } from './hooks'; + +const Akalee: React.FC = () => { + globalStyles(); + + const value = useMediaValue( + 320, + [320, 360, 480, 560, 720], + useMediaQuery('(min-width: 320px) and (min-height: 320px)'), + useMediaQuery('(min-width: 360px) and (min-height: 360px)'), + useMediaQuery('(min-width: 480px) and (min-height: 480px)'), + useMediaQuery('(min-width: 560px) and (min-height: 560px)'), + useMediaQuery('(min-width: 720px) and (min-height: 720px)'), + ); + + return ( + + + + ); +}; + +export default Akalee; + +const globalStyles = globalCss({ + ':root': { + '--color-white': '#ffffff', + '--color-black': '#000000', + '--color-transparent': 'transparent', + }, + '*': { + margin: 0, + padding: 0, + boxSizing: 'border-box', + }, + 'html, body': { + width: '100%', + height: '100%', + overflow: 'hidden', + backgroundColor: 'var(--color-black)', + }, + '#root': { + isolation: 'isolate', + width: '100%', + height: '100%', + }, +}); + +const Box = styled('div', { + widht: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', +}); diff --git a/PetitProjects/Team3/src/components/Akalee/Clock.tsx b/PetitProjects/Team3/src/components/Akalee/Clock.tsx new file mode 100644 index 0000000..8dd4d9e --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/Clock.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { styled } from '@stitches/react'; +import ClockCore from './ClockCore'; +import ClockCoreFrame from './ClockCoreFrame'; +import { useClockState } from './hooks'; + +interface ClockProps { + size: number; +} + +const Clock: React.FC = ({ size }) => { + const { hour, minute, second } = useClockState(); + + const ref = React.useRef(null); + + React.useEffect(() => { + const el = ref.current; + if (el === null) return; + + const listener = (e: PointerEvent) => { + const offsetWidth = window.document.body.offsetWidth; + const offsetHeight = window.document.body.offsetHeight; + + const x = e.pageX - (offsetWidth - size) / 2; + const y = e.pageY - (offsetHeight - size) / 2; + + el.style.setProperty('--x', `${x}px`); + el.style.setProperty('--y', `${y}px`); + }; + + el.addEventListener('pointermove', listener, { + passive: true, + }); + + return () => { + el.removeEventListener('pointermove', listener); + }; + }, [size]); + + return ( + + + + + + + ); +}; + +export default Clock; + +const AspectRatioBox = styled('div', { + position: 'relative', + margin: 'auto', +}); + +const ClockBox = styled('div', { + position: 'absolute', + top: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); diff --git a/PetitProjects/Team3/src/components/Akalee/ClockCore.stories.tsx b/PetitProjects/Team3/src/components/Akalee/ClockCore.stories.tsx new file mode 100644 index 0000000..ec06063 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/ClockCore.stories.tsx @@ -0,0 +1,61 @@ +import ClockCore from './ClockCore'; +import { useClockState } from './hooks'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; + +export default { + title: 'TEAM3/Akalee/ClockCore', + component: ClockCore, + argTypes: { + hour: { + control: { + type: 'range', + min: 0, + max: 23, + step: 1, + }, + }, + minute: { + control: { + type: 'range', + min: 0, + max: 59, + step: 1, + }, + }, + second: { + control: { + type: 'range', + min: 0, + max: 59, + step: 1, + }, + }, + }, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta; + +const Template: ComponentStory = + (args) => ; + +export const Default = Template.bind({}); +Default.args = { + size: 480, + hour: 0, + minute: 0, + second: 0, +}; + +export const WithHook = () => { + const { hour, minute, second } = useClockState(); + + return ( + + ); +}; diff --git a/PetitProjects/Team3/src/components/Akalee/ClockCore.tsx b/PetitProjects/Team3/src/components/Akalee/ClockCore.tsx new file mode 100644 index 0000000..d7834bd --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/ClockCore.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { styled } from '@stitches/react'; +import ClockHand from './ClockHand'; + +interface ClockCoreProps { + hour: number; + minute: number; + second: number; + size: number; +} + +const ClockCore: React.FC = ({ + hour, + minute, + second, + size, +}) => { + return ( + + + + = 12 ? hour - 12 : hour)} + min={0} + max={11} + size={size * RELATIVE_HOUR_HAND_SIZE} + /> + + + ); +}; + +export default ClockCore; + +const RELATIVE_HOUR_HAND_SIZE = 0.1875; +const RELATIVE_MINUTE_HAND_SIZE = 0.25; +const RELATIVE_SECOND_HAND_SIZE = 0.375; + +const Box = styled('div', { + position: 'relative', + width: 'var(--clock-size)', + height: 'var(--clock-size)', + borderRadius: '50%', +}); + +const HandDot = styled('div', { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 16, + height: 16, + borderRadius: 8, + background: '#000000', + border: '2px solid var(--color-white)', +}); diff --git a/PetitProjects/Team3/src/components/Akalee/ClockCoreFrame.tsx b/PetitProjects/Team3/src/components/Akalee/ClockCoreFrame.tsx new file mode 100644 index 0000000..0e112f5 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/ClockCoreFrame.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { styled } from '@stitches/react'; +import MakerLabel from './MakerLabel'; + +const ClockCoreFrame: React.FC = () => { + return ( + + + {TIME_PIVOT_ITEMS.map(({ text, deg }) => ( + + {text} + + ))} + + + + ); +}; + +export default ClockCoreFrame; + +const TIME_PIVOT_ITEMS = [ + { text: 'I', deg: 30 }, + { text: 'II', deg: 60 }, + { text: 'III', deg: 90 }, + { text: 'IV', deg: 120 }, + { text: 'V', deg: 150 }, + { text: 'VI', deg: 180 }, + { text: 'VII', deg: 210 }, + { text: 'VIII', deg: 240 }, + { text: 'IX', deg: 270 }, + { text: 'X', deg: 300 }, + { text: 'XI', deg: 330 }, + { text: 'XII', deg: 360 }, +] as const; + +const Box = styled('div', { + position: 'absolute', + top: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + border: '2px dashed rgba(255, 255, 255, 0.48)', + overflow: 'hidden', + '-webkit-mask-image': `radial-gradient( + circle var(--size-pos) at var(--x, var(--size-neg)) var(--y, var(--size-neg)), + black 36%, + transparent + )`, +}); + +const ClockFrame = styled('div', { + width: '100%', + height: '100%', + position: 'relative', +}); + +const TimePivot = styled('div', { + position: 'absolute', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + paddingTop: 16, +}); + +const TimePivotText = styled('span', { + fontSize: '1.25rem', + fontWeight: 700, + color: 'rgba(255, 255, 255, 0.48)', +}); diff --git a/PetitProjects/Team3/src/components/Akalee/ClockHand.tsx b/PetitProjects/Team3/src/components/Akalee/ClockHand.tsx new file mode 100644 index 0000000..adea453 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/ClockHand.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { styled } from '@stitches/react'; +import { motion } from 'framer-motion'; + +interface ClockHandProps { + time: number; + min: number; + max: number; + size: number; +} + +const ClockHand: React.FC = ({ + time, + min, + max, + size, +}) => { + const _time = React.useRef(0); + + React.useEffect(() => { + if (_time.current === 0) { + _time.current = time; + } else { + _time.current += 1; + } + }, [time]); + + const rotate = 360 * (_time.current / (max - min + 1)); + + return ( + + + + ); +}; + +export default ClockHand; + +const Box = styled(motion.div, { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +const Hand = styled(motion.div, { + width: 8, + height: 'var(--clock-hand-height)', + borderRadius: 8, + backgroundColor: '#000000', + border: '2px solid var(--color-white)', +}); diff --git a/PetitProjects/Team3/src/components/Akalee/MakerLabel.tsx b/PetitProjects/Team3/src/components/Akalee/MakerLabel.tsx new file mode 100644 index 0000000..dc3fa89 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/MakerLabel.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { styled } from '@stitches/react'; + +const MakerLabel: React.FC = () => { + return ( + + + + + + + + AKALEE'S PROFILE + + + PROJECT REPOSITORY + + + ); +}; + +export default MakerLabel; + +const GITHUB_URL = 'https://github.com'; +const GITHUB_PROFILE_URL = 'https://github.com/wooogi123'; +const GITHUB_PROJECT_URL = 'https://github.com/Febase/FeWebAnimations'; + +const Box = styled('div', { + position: 'absolute', + bottom: '16%', + width: '100%', + borderColor: 'rgba(255, 255, 255, 0.24)', + borderTopWidth: 1, + borderRightWidth: 0, + borderBottomWidth: 1, + borderLeftWidth: 0, + borderStyle: 'solid', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingTop: 8, + paddingBottom: 8, + gap: 16, + '--color-maker': 'rgba(255, 255, 255, 0.64)', +}); + +const Anchor = styled('a', { + color: 'var(--color-maker)', + fontSize: '1rem', + textDecoration: 'none', + inlineSize: 'min-content', +}); + diff --git a/PetitProjects/Team3/src/components/Akalee/hooks/index.ts b/PetitProjects/Team3/src/components/Akalee/hooks/index.ts new file mode 100644 index 0000000..e367bd3 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/hooks/index.ts @@ -0,0 +1,3 @@ +export { default as useClockState } from './useClockState'; +export { default as useMediaQuery } from './useMediaQuery'; +export { default as useMediaValue } from './useMediaValue'; diff --git a/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.test.ts b/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.test.ts new file mode 100644 index 0000000..63f3423 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { + it, + describe, + expect, + beforeEach, + afterEach, + vi, +} from 'vitest'; +import useClockState from './useClockState'; + +let delta = 0; + +describe('useClockState.ts', () => { + beforeEach(() => { + vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb) => { + setTimeout(() => { + cb(delta); + delta += 16; + }, 16) + + return delta; + }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }) + + it('match initial value', () => { + const { result } = renderHook(() => useClockState()); + + expect(result.current.hour).toBe(-1); + expect(result.current.minute).toBe(-1); + expect(result.current.second).toBe(-1); + }); + + it('match current hour', () => { + const date = new Date(); + + const { result } = renderHook(() => useClockState()); + + vi.advanceTimersByTime(1000); + expect(result.current.hour).toBe(date.getHours()); + }); + + it('match current minute', () => { + const date = new Date(); + + const { result } = renderHook(() => useClockState()); + + vi.advanceTimersByTime(1000); + expect(result.current.minute).toBe(date.getMinutes()); + }); + + it('match current second', () => { + const date = new Date(); + + const { result } = renderHook(() => useClockState()); + + vi.advanceTimersByTime(1000); + expect(result.current.second).toBe(date.getSeconds()); + }); +}); diff --git a/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.ts b/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.ts new file mode 100644 index 0000000..3010b05 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/hooks/useClockState.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; + +type ReturnRAF = ReturnType; + +const useClockState = () => { + const [hour, setHour] = React.useState(-1); + const [minute, setMinute] = React.useState(-1); + const [second, setSecond] = React.useState(-1); + + const date = React.useRef(new Date()); + const delta = React.useRef(null); + const rAF = React.useRef(null); + + const setTime = React.useCallback( + () => { + date.current.setTime(Date.now()); + setHour(date.current.getHours()); + setMinute(date.current.getMinutes()); + setSecond(date.current.getSeconds()); + }, + [], + ); + + const rAFCallback = React.useCallback( + (ms: number) => { + rAF.current = window.requestAnimationFrame(rAFCallback); + + if (delta.current === null) { + delta.current = ms; + setTime(); + return; + } + + if (ms - delta.current < 1000) return; + + delta.current = ms; + setTime(); + }, + [setTime], + ); + + React.useEffect(() => { + rAF.current = window.requestAnimationFrame(rAFCallback); + + return () => { + if (rAF.current === null) return; + window.cancelAnimationFrame(rAF.current); + }; + }, []); + + return { + hour, + minute, + second, + }; +}; + +export default useClockState; diff --git a/PetitProjects/Team3/src/components/Akalee/hooks/useMediaQuery.ts b/PetitProjects/Team3/src/components/Akalee/hooks/useMediaQuery.ts new file mode 100644 index 0000000..750b80f --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/hooks/useMediaQuery.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; + +const getMatches = (query: string): boolean => { + if (typeof window === 'undefined') return true; + return window.matchMedia(query).matches; +}; + +const useMediaQuery = (query: string): boolean => { + const [matches, setMatches] = React.useState(getMatches(query)); + + React.useEffect(() => { + const matchMedia = window.matchMedia(query); + + const handleChange = () => setMatches(getMatches(query)); + + if (matchMedia.addListener) { + matchMedia.addListener(handleChange); + } else { + matchMedia.addEventListener('change', handleChange); + } + + handleChange(); + + return () => { + if (matchMedia.removeListener) { + matchMedia.removeListener(handleChange); + } else { + matchMedia.removeEventListener('change', handleChange); + } + }; + }, [query]); + + return matches; +}; + +export default useMediaQuery diff --git a/PetitProjects/Team3/src/components/Akalee/hooks/useMediaValue.ts b/PetitProjects/Team3/src/components/Akalee/hooks/useMediaValue.ts new file mode 100644 index 0000000..3f96f02 --- /dev/null +++ b/PetitProjects/Team3/src/components/Akalee/hooks/useMediaValue.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; + +const useMediaValue = ( + initialValue: number, + values: number[], + ...rest: boolean[] +) => { + const [value, setValue] = React.useState(initialValue); + + const updatedValue = rest.reduce((acc, item, index) => { + if (item) acc = values[index]; + + return acc; + }, initialValue); + + React.useEffect(() => { + setValue(updatedValue); + }, [...rest]); + + return value; +}; + +export default useMediaValue; diff --git a/PetitProjects/Team3/src/components/Akalee/index.ts b/PetitProjects/Team3/src/components/Akalee/index.ts index cb0ff5c..1103e26 100644 --- a/PetitProjects/Team3/src/components/Akalee/index.ts +++ b/PetitProjects/Team3/src/components/Akalee/index.ts @@ -1 +1 @@ -export {}; +export { default } from './Akalee'; diff --git a/PetitProjects/Team3/yarn.lock b/PetitProjects/Team3/yarn.lock index e6b2f4a..9d3c54f 100644 --- a/PetitProjects/Team3/yarn.lock +++ b/PetitProjects/Team3/yarn.lock @@ -2368,6 +2368,14 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/react-hooks@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf" + integrity sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^13.3.0": version "13.3.0" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.3.0.tgz#bf298bfbc5589326bbcc8052b211f3bb097a97c5" @@ -8852,6 +8860,13 @@ react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-inspector@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-5.1.1.tgz#58476c78fde05d5055646ed8ec02030af42953c8"