"use client"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import Image from "next/image"; /** * CaseTabsCarousel.tsx (FINAL OPTIMIZED) * - 탭별 데이터 * - 카드 perPage(1/2/3) 반응형 * - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동 * - 3그룹 가상화(prev/cur/next)로 DOM 최소화 * - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지 * - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화 * - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리) * - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리 */ type TabKey = "rain" | "reuse" | "sewage" | "global"; type CaseItem = { id: string; title: string; desc: string; imageSrc: string; href?: string; }; const TABS: { key: TabKey; label: string }[] = [ { key: "rain", label: "빗물 재이용 사례" }, { key: "reuse", label: "중수 재이용 사례" }, { key: "sewage", label: "하수 재이용 사례" }, { key: "global", label: "해외 재이용 사례" }, ]; // 실제 데이터로 교체해서 쓰세요(구조 유지) const MOCK: Record = { rain: [ { id: "rain-0", title: "경기도 수원시 빗물이용과 중수도", desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", imageSrc: "/images/landing/rain_01.png", href: "/reuse/business/rain/suwon", }, // { // id: "rain-2", // title: "경기도 수원시 빗물이용과 중수도", // desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", // imageSrc: "/images/landing/rain_01.png", // href: "#", // }, ], reuse: Array.from({ length: 6 }).map((_, i) => ({ id: `reuse-${i}`, title: `중수 재이용 사례 ${i + 1}`, desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", imageSrc: "/images/landing/sample_case.png", href: "#", })), sewage: [ { id: "sewage-1", title: "경기도 파주시 하수 재이용", desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...", imageSrc: "/images/landing/sewage_01.png", href: "/reuse/business/sewage/paju", }, { id: "sewage-2", title: "충남보령 하수재이용", desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...", imageSrc: "/images/landing/sewage_02.png", href: "/reuse/business/sewage/bolyeong", }, { id: "sewage-3", title: "여수시 하수처리수 재이용", desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...", imageSrc: "/images/landing/sewage_03.png", href: "/reuse/business/sewage/yeosu", }, { id: "sewage-4", title: "청주시 하수 재이용 시설", desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...", imageSrc: "/images/landing/sewage_04.png", href: "/reuse/business/sewage/cheongju", }, { id: "sewage-5", title: "수원시 황구지천 하천유지용수", desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...", imageSrc: "/images/landing/sewage_05.png", href: "/reuse/business/sewage/hwanggu", }, { id: "sewage-6", title: "구리시 도로자동청소시스템", desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...", imageSrc: "/images/landing/sewage_06.png", href: "/reuse/business/sewage/guri", }, // ...Array.from({ length: 6 }).map((_, i) => ({ // id: `sewage-x-${i}`, // title: `하수 재이용 사례 ${i + 4}`, // desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", // imageSrc: "/images/landing/sample_case.png", // href: "#", // })), ], global: [ { id: "global-1", title: "해외 재이용 사례 개요", desc: "2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한 2030년 목표는 17%, 우리나라의 하수 처리 재이용률은 15% 수준...", imageSrc: "/images/landing/overview_01.png", href: "reuse/business/globalCase/overview", }, { id: "global-2", title: "[대만] 국가 주도의 공업용수 공급", desc: "대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑), 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능...", imageSrc: "/images/landing/taiwan_01.png", href: "/reuse/business/globalCase/taiwan", }, { id: "global-3", title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급", desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망, 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급...", imageSrc: "/images/landing/australia_01.png", href: "/reuse/business/globalCase/australia", }, { id: "global-4", title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화", desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설, NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용...", imageSrc: "/images/landing/singapore_01.png", href: "/reuse/business/globalCase/singapore", }, { id: "global-5", title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도", desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행...", imageSrc: "/images/landing/eu_01.png", href: "/reuse/business/globalCase/eu", }, ], }; /* ---------------- utils ---------------- */ function chunk(arr: T[], size: number) { const res: T[][] = []; if (size <= 0) return res; for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size)); return res; } function mod(n: number, m: number) { return ((n % m) + m) % m; } function usePrefersReducedMotion() { const [reduced, setReduced] = useState(false); useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = () => setReduced(mq.matches); onChange(); mq.addEventListener?.("change", onChange); return () => mq.removeEventListener?.("change", onChange); }, []); return reduced; } /** * wrapRef: perPage 계산용 * clipRef: "패딩 없는 측정 박스" 폭(px) 계산용 */ function useCarouselMeasure() { const wrapRef = useRef(null); const clipRef = useRef(null); const [perPage, setPerPage] = useState(3); const [clipWidth, setClipWidth] = useState(0); // perPage: container width로 반응형 결정 (rAF로 디바운스) useEffect(() => { const el = wrapRef.current; if (!el) return; let raf = 0; const calc = () => { const w = el.clientWidth; if (w < 640) setPerPage(1); else if (w < 1024) setPerPage(2); else setPerPage(3); }; const ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(calc); }); calc(); ro.observe(el); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, []); // clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림) useEffect(() => { const el = clipRef.current; if (!el) return; let raf = 0; const calc = () => { const w = Math.round(el.getBoundingClientRect().width); setClipWidth(w); }; const ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(calc); }); calc(); ro.observe(el); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, []); return { wrapRef, clipRef, perPage, clipWidth }; } /* ---------------- component ---------------- */ export default function CaseTabsCarousel() { const [active, setActive] = useState("rain"); // 탭별 마지막 그룹 인덱스 기억 const tabIndexRef = useRef>({ rain: 0, reuse: 0, sewage: 0, global: 0, }); const reducedMotion = usePrefersReducedMotion(); const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure(); const items = useMemo(() => MOCK[active] ?? [], [active]); const groups = useMemo(() => chunk(items, perPage), [items, perPage]); const groupCount = groups.length; const [cur, setCur] = useState(0); const curRef = useRef(0); // px 기반 트랙 const [trackPx, setTrackPx] = useState(0); const [anim, setAnim] = useState(false); const [busy, setBusy] = useState(false); // 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음) const dirRef = useRef<-1 | 1 | 0>(0); // cur 최신값 유지 useEffect(() => { curRef.current = cur; }, [cur]); /** * 리셋 로직 분리: * - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋 * - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치 */ useLayoutEffect(() => { const saved = tabIndexRef.current[active] ?? 0; const nextCur = groupCount ? mod(saved, groupCount) : 0; dirRef.current = 0; setCur(nextCur); setBusy(false); setAnim(false); }, [active, perPage, groupCount]); // clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음 useLayoutEffect(() => { if (busy) return; if (clipWidth > 0) setTrackPx(-clipWidth); else setTrackPx(0); }, [clipWidth, busy]); // 3그룹 가상화 const viewGroups = useMemo(() => { if (!groupCount) return [[], [], []] as CaseItem[][]; return [ groups[mod(cur - 1, groupCount)] ?? [], groups[mod(cur, groupCount)] ?? [], groups[mod(cur + 1, groupCount)] ?? [], ]; }, [groups, cur, groupCount]); const canSlide = groupCount > 1 && clipWidth > 0; const go = useCallback( (dir: -1 | 1) => { if (!canSlide) return; if (busy) return; dirRef.current = dir; setBusy(true); setAnim(!reducedMotion); // 중앙(-W) 기준: prev=0, next=-2W setTrackPx(dir === 1 ? -2 * clipWidth : 0); // reduced motion이면 즉시 확정 if (reducedMotion) { const nextCur = mod(curRef.current + dir, groupCount); tabIndexRef.current[active] = nextCur; setCur(nextCur); setAnim(false); setTrackPx(-clipWidth); setBusy(false); dirRef.current = 0; } }, [active, busy, canSlide, clipWidth, groupCount, reducedMotion] ); // BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리 const onTransitionEnd = useCallback( (e: React.TransitionEvent) => { // 자식 transitionend 버블링 방지 if (e.target !== e.currentTarget) return; if (e.propertyName !== "transform") return; if (!busy || reducedMotion) return; const dir = dirRef.current; if (dir !== 1 && dir !== -1) return; const nextCur = mod(curRef.current + dir, groupCount); tabIndexRef.current[active] = nextCur; // 1) 먼저 transition을 끄고(중요) setAnim(false); // 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅 // (이 순간은 "무조건 애니메이션 없이" 처리되어야 함) setCur(nextCur); setTrackPx(-clipWidth); // 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제 requestAnimationFrame(() => { requestAnimationFrame(() => { setAnim(!reducedMotion); setBusy(false); dirRef.current = 0; }); }); }, [active, busy, clipWidth, groupCount, reducedMotion] ); const goDot = useCallback( (idx: number) => { if (!groupCount) return; if (busy) return; const target = mod(idx, groupCount); tabIndexRef.current[active] = target; dirRef.current = 0; setCur(target); setAnim(false); if (clipWidth > 0) setTrackPx(-clipWidth); }, [active, busy, clipWidth, groupCount] ); const trackStyle: React.CSSProperties = useMemo( () => ({ transform: `translate3d(${trackPx}px, 0, 0)`, transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none", willChange: busy ? "transform" : undefined, }), [trackPx, anim, busy] ); return (
{/* 탭 바 */}
{TABS.map((t) => { const isActive = t.key === active; return ( ); })}
{/* 슬라이더 */}
{/* 좌우 버튼 */} {/* 중요: clip(가림)과 measure(측정) 분리 */}
{/* 측정은 패딩 없는 박스에서 */}
{/* track */}
{viewGroups.map((group, gi) => (
{/* 그룹 내부: perPage 컬럼 */}
{(group ?? []).map((c) => ( ))}
))}
{/* 도트 */}
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
); }