
maximatherapy clone coading ...
import { useRef, useState, useCallback, useEffect } from "react";
import gsap from "gsap";
import { SLIDES, SLIDE_COUNT, STEP } from "../data/slides";
import SliceArt from "./SliceArt";
import styles from "./RouletteSlider.module.css";
// 슬라이스 하나: 부채꼴 배경 + SVG 일러스트
interface SliceProps {
index: number;
rotationDeg: number; // 이 슬라이스의 초기 배치 각도
}
function Slice({ index, rotationDeg }: SliceProps) {
const clipId = `clip-${index}`;
return (
<div
className={styles.slice}
style={{ transform: `rotate(${rotationDeg}deg)` }}
>
<svg viewBox="0 0 800 800" className={styles.sliceSvg}>
<defs>
{/*
부채꼴 클리핑: 하단 중앙(피벗)에서 위로 펼쳐지는 삼각형
wheel의 transform-origin(50% 100%)과 맞물려 룰렛처럼 보임
*/}
<clipPath id={clipId}>
<polygon points="400,800 100,0 700,0" />
</clipPath>
</defs>
<SliceArt index={index} clipId={clipId} />
</svg>
</div>
);
}
// ──────────────────────────────────────────────
// SliderLabel: 현재 슬라이드 정보 표시
// ──────────────────────────────────────────────
interface LabelProps {
title: string;
tag: string;
bg: string;
fg: string;
}
function SliderLabel({ title, tag, bg, fg }: LabelProps) {
return (
<div className={styles.label} id="roulette-label">
<div className={styles.labelBox} style={{ background: bg }}>
<p className={styles.labelTitle} style={{ color: fg }}>
{title}
</p>
<span
className={styles.labelTag}
style={{ borderColor: fg, color: fg }}
>
{tag}
</span>
</div>
</div>
);
}
──────────────────────────────────────────────
// RouletteSlider (메인 컴포넌트)
// ──────────────────────────────────────────────
export default function RouletteSlider() {
const wheelRef = useRef<HTMLDivElement>(null);
const [cur, setCur] = useState(0);
const [label, setLabel] = useState(SLIDES[0]);
// 누적 회전각 (ref로 관리 → 리렌더 없이 GSAP에 전달)
const wheelAngle = useRef(0);
const busy = useRef(false);
const startX = useRef(0);
// ── 슬라이드 전환 ─────────────────────────────
const goTo = useCallback((next: number) => {
if (busy.current) return;
const total = SLIDE_COUNT;
const dir = next > cur ? 1 : -1;
next = ((next % total) + total) % total;
if (next === cur) return;
busy.current = true;
wheelAngle.current += STEP * dir;
// 핵심: #wheel 하나만 회전 → 도형·배경 모두 같이 돌아감
gsap.to(wheelRef.current, {
rotation: wheelAngle.current,
duration: 0.85,
ease: "power3.inOut",
onComplete: () => {
busy.current = false;
},
});
// 레이블 fade 전환
gsap.to("#roulette-label", {
opacity: 0,
y: 10,
duration: 0.18,
onComplete: () => {
setLabel(SLIDES[next]);
setCur(next);
gsap.to("#roulette-label", { opacity: 1, y: 0, duration: 0.28 });
},
});
}, [cur]);
// ── 드래그 (Pointer Events) ────────────────────
const handlePointerDown = useCallback((e: React.PointerEvent) => {
startX.current = e.clientX;
}, []);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
const dx = e.clientX - startX.current;
if (Math.abs(dx) > 50) goTo(dx < 0 ? cur + 1 : cur - 1);
},
[cur, goTo]
);
// ── 키보드 접근성 ─────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") goTo(cur + 1);
if (e.key === "ArrowLeft") goTo(cur - 1);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [cur, goTo]);
return (
<div
className={styles.slider}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onDragStart={(e) => e.preventDefault()}
role="region"
aria-label="프로그램 슬라이더"
>
{/*
#wheel: 이 요소만 gsap으로 rotate
transform-origin: 50% 100% (하단 중앙 = 룰렛 축)
크기를 뷰포트보다 훨씬 크게 → 회전 시 빈공간 노출 방지
*/}
<div className={styles.wheel} ref={wheelRef}>
{SLIDES.map((_, i) => (
<Slice key={i} index={i} rotationDeg={i * STEP} />
))}
</div>
{/* 네비게이션 버튼 */}
<button
className={`${styles.navBtn} ${styles.navBtnPrev}`}
onClick={() => goTo(cur - 1)}
aria-label="이전 슬라이드"
>
←
</button>
<button
className={`${styles.navBtn} ${styles.navBtnNext}`}
onClick={() => goTo(cur + 1)}
aria-label="다음 슬라이드"
>
→
</button>
{/* 레이블 */}
<SliderLabel
title={label.title}
tag={label.tag}
bg={label.labelBg}
fg={label.labelFg}
/>
{/* 인디케이터 dots */}
<div className={styles.dots} role="tablist">
{SLIDES.map((_, i) => (
<button
key={i}
role="tab"
aria-selected={i === cur}
aria-label={`슬라이드 ${i + 1}`}
className={`${styles.dot} ${i === cur ? styles.dotActive : ""}`}
onClick={() => goTo(i)}
/>
))}
</div>
</div>
);
}
[slider] ← 보이는 영역
└ [wheel] ← GSAP이 회전시키는 대상
├ [Slice 0] → rotate(0deg)
├ [Slice 1] → rotate(STEP deg)
├ [Slice 2] → rotate(STEP * 2 deg)
└ ...
wheel 하나만 돌린다는 게 핵심이에요. 슬라이스들은 wheel 안에 고정되어 있어서, wheel이 돌면 전부 같이 돌아가요.
┌─────────────┐
│ │
│ wheel │
│ │
└──────●──────┘ ← 이 점을 중심으로 회전
보통 요소는 중앙(50% 50%) 을 기준으로 회전하는데, 여기선 하단 중앙(50% 100%) 이 기준이에요.
이게 룰렛처럼 보이는 이유예요. 피벗이 아래에 있으니까 부채꼴들이 위로 펼쳐지면서 돌아가는 것처럼 보이죠.
const wheelAngle = useRef(0); // 현재까지 총 회전한 각도
wheelAngle.current += STEP * dir; // 다음 각도 = 기존 + 한 슬라이스 각도
왜 state가 아니라 ref냐면 GSAP한테 전달할 값이라 리렌더가 필요 없어서요.
state로 하면 값 바뀔 때마다 불필요하게 리렌더가 발생해요.
누적으로 관리하는 이유는, GSAP의 rotation은 항상 절대값으로 받기 때문이에요.
// 매번 "지금부터 N도 더 돌아"가 아니라
// "총N도인 지점까지 돌아" 로 지시
gsap.to(wheelRef.current, {
rotation: wheelAngle.current, // 절대 각도
duration: 0.85,
ease: "power3.inOut",
})
예를 들어 슬라이드 3번 이동했으면 STEP 3 을 넘기는 거에요.
"앞으로 STEP만큼"이 아니라 "총 STEP 3 지점으로"가라는 거죠. 이렇게 해야 GSAP이 중간에 끊기지 않고 부드럽게 이어서 애니메이션할 수 있어요.
const busy= useRef(false);
const goTo = (next) => {
if(busy.current) return; // 돌아가는 중이면 무시
busy.current = true;
gsap.to(wheel, {
// ...
onComplete: () => {
busy.current = false; // 다 돌았으면 다시 허용
}
})
}
애니메이션 도중에 연속 클릭하면 각도가 꼬일 수 있어서 막아두는 거예요.
이것도 리렌더가 필요 없는 플래그라서 ref로 관리해요.
gsap.to ("#roulette=label", {
opacity: 0, y: 10, duration: 0.18,
onComplete: () => {
setLabel(SLIDES[next]); // wheel 회전 중간에 텍스트 교체
gsap.to("#roulette-label", {opacity: 1, y: 0});
},
});
wheel이 돌아가는 도중에 텍스트를 몰래 바꿔치기 하는 패턴이에요.
opacity가 0인 순간에 교체하니까 사용자 눈에는 자연스러운 fade처럼 보이죠.
goTo(next) 호출
↓
busy 체크 → 애니메이션 중이면 return
↓
wheelAngle += STEP * dir (누적 각도 계산)
↓
GSAP: wheel을 wheelAngle까지 회전 (0.85s)
↓ (병렬)
GSAP: label fade out → 텍스트 교체 → fade in
↓
onComplete: busy = false

완성본..에셋을 구할 수 없어서 기능만 흉내 내보았다.