"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" | "greywater" | "sewage" | "global"; type CaseItem = { id: string; title: string; desc: string; imageSrc: string; href?: string; }; const TABS: { key: TabKey; label: string }[] = [ { key: "rain", label: "빗물 재이용 사례" }, { key: "greywater", label: "중수 재이용 사례" }, { key: "sewage", label: "하수 재이용 사례" }, { key: "global", label: "해외 재이용 사례" }, ]; // 실제 데이터로 교체해서 쓰세요(구조 유지) const MOCK: Record = { rain: [ { id: "rain-1", title: "경기도 수원시 빗물 재이용", desc: "월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물공급기를 설치하여 축서기 도시 열섬효과 및 도로 비산먼지 저감", imageSrc: "/images/landing/business/rain/suwon_01.png", href: "/example#example-rain-suwon", }, { id: "rain-2", title: "제주특별자치도 빗물 재이용", desc: "강우시 비닐하우스에서 발생하는 대체수자원인 빗물을 순환 활용하고자 하는 사업으로, 지하수 사용 저감과 집중호우시 도로와 농경지 침수피해 예방", imageSrc: "/images/landing/business/rain/jeju_01.png", href: "/example#example-rain-jeju", }, ], greywater: [ { id: "greywater-1", title: "경기도 안산시 중수 재이용", desc: "수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현을 위해 안산시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급", imageSrc: "/images/landing/business/greywater/no_img.jpg", href: "/example#example-greywater-ansan", }, { id: "greywater-2", title: "경기도 용인시 중수 재이용", desc: "물 부족 등의 비상상황에 대한 대비, 하수라도 사용할 집결 등 수자원의 효율적 활용과 지속가능성 도모를 위해 용인시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급", imageSrc: "/images/landing/business/greywater/no_img.jpg", href: "/example#example-greywater-yongin", }, { id: "greywater-3", title: "경기도 평택시 중수 재이용", desc: "수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현을 위해 평택시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급", imageSrc: "/images/landing/business/greywater/no_img.jpg", href: "/example#example-greywater-pyeongtaek", }, ], sewage: [ { id: "sewage-1", title: "구리시 하수 재이용", desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화", imageSrc: "/images/landing/business/sewage/guri_01.png", href: "/example#example-sewage-guri", }, { id: "sewage-2", title: "부천시 하수재이용", desc: "굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공", imageSrc: "/images/landing/business/sewage/bucheon_01.png", href: "/example#example-sewage-bucheon", }, { id: "sewage-3", title: "수원시 하수처리수 재이용", desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전", imageSrc: "/images/landing/business/sewage/suwon_01.png", href: "/example#example-sewage-suwon", }, { id: "sewage-4", title: "파주시 하수 재이용 시설", desc: "운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)", imageSrc: "/images/landing/business/sewage/paju_01.png", href: "/example#example-sewage-paju", }, { id: "sewage-5", title: "포항시 하수 재이용 시설", desc: "포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등), 포항하수처리장 하수처리수를 재처리하여 공업용수로 공급", imageSrc: "/images/landing/business/sewage/pohang_01.png", href: "/example#example-sewage-pohang", }, { id: "sewage-6", title: "울산광역시 하수 재이용 시설", desc: "18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급", imageSrc: "/images/landing/business/sewage/ulsan_01.png", href: "/example#example-sewage-ulsan", }, { id: "sewage-7", title: "여수시 하수 재이용 시설", desc: "수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)", imageSrc: "/images/landing/business/sewage/yeosu_01.png", href: "/example#example-sewage-yeosu", }, { id: "sewage-8", title: "보령시 하수 재이용 시설", desc: "한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진, 하수처리수 재이용을 통한 수자원 절약 및 지역 물 부족 해소", imageSrc: "/images/landing/business/sewage/boryeong_01.png", href: "/example#example-sewage-boryeong", }, { id: "sewage-9", title: "아산시 하수 재이용 시설", desc: "신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모", imageSrc: "/images/landing/business/sewage/asan_01.png", href: "/example#example-sewage-asan", } ], global: [ { id: "global-1", title: "해외 재이용 사례 개요", desc: "전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐. 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편", imageSrc: "/images/landing/business/globalCase/overview_01.png", href: "/example#example-global-overview", }, { id: "global-2", title: "[대만] 국가 주도의 공업용수 공급", desc: "수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결. 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등", imageSrc: "/images/landing/business/globalCase/taiwan_01.png", href: "/example#example-global-taiwan", }, { id: "global-3", title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화", desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설. NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용", imageSrc: "/images/landing/business/globalCase/singapore_01.png", href: "/example#example-global-singapore", }, { id: "global-4", title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급", desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망. 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급", imageSrc: "/images/landing/business/globalCase/australia_01.png", href: "/example#example-global-australia", }, { id: "global-5", title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도", desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행", imageSrc: "/images/landing/business/globalCase/eu_01.png", href: "/example#example-global-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, greywater: 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) => (
); }