579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
"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<TabKey, CaseItem[]> = {
|
|
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<T>(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<HTMLDivElement | null>(null);
|
|
const clipRef = useRef<HTMLDivElement | null>(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<TabKey>("rain");
|
|
|
|
// 탭별 마지막 그룹 인덱스 기억
|
|
const tabIndexRef = useRef<Record<TabKey, number>>({
|
|
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<HTMLDivElement>) => {
|
|
// 자식 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 (
|
|
<section className="w-full">
|
|
{/* 탭 바 */}
|
|
<div className="mx-auto max-w-[1520px] pt-10">
|
|
<div className="">
|
|
<div className="grid grid-cols-4">
|
|
{TABS.map((t) => {
|
|
const isActive = t.key === active;
|
|
|
|
return (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
onClick={() => setActive(t.key)}
|
|
className={[
|
|
"relative h-[74px] w-full text-base font-semibold",
|
|
"transition-colors",
|
|
// 기본(비활성): 검정 배경 + 회색 텍스트
|
|
!isActive ? "text-[#767676] hover:text-black border-b-2 border-sky-400" : "",
|
|
// 활성: 흰 배경 + 진한 텍스트 + 하늘색 테두리(위/좌/우) + 아래는 열어둠
|
|
isActive
|
|
? [
|
|
"bg-[#eaf4f4] text-black",
|
|
"border-t-2 border-l-2 border-r-2 border-sky-400",
|
|
"border-b-0", // 아래는 컨텐츠와 이어지도록 열어둠
|
|
"z-10", // 테두리가 위로 올라오게
|
|
].join(" ")
|
|
: "",
|
|
].join(" ")}
|
|
>
|
|
{t.label}
|
|
|
|
{/* 활성 탭 하단에 얇은 라인이 필요하면(이미지처럼) */}
|
|
{isActive && (
|
|
<span className="pointer-events-none absolute left-0 right-0 bottom-[-1px] h-[2px] bg-[#eaf4f4]" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 슬라이더 */}
|
|
<div ref={wrapRef} className="mx-auto max-w-[1520px] px-6 pb-5 pt-10 bg-[#eaf4f4]">
|
|
<div className="relative">
|
|
{/* 좌우 버튼 */}
|
|
<button
|
|
type="button"
|
|
onClick={() => go(-1)}
|
|
disabled={!canSlide || busy}
|
|
className={[
|
|
"absolute left-0 top-1/2 z-10 -translate-y-1/2",
|
|
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
|
|
"grid place-items-center border-2 border-[#C2CAD2]",
|
|
!canSlide || busy
|
|
? "opacity-40 cursor-not-allowed"
|
|
: "hover:brightness-95",
|
|
].join(" ")}
|
|
aria-label="이전"
|
|
>
|
|
<Image
|
|
src="/images/landing/icon_left_arrow.png"
|
|
alt="이전"
|
|
width={24}
|
|
height={24}
|
|
className="w-6 h-6 object-contain"
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => go(1)}
|
|
disabled={!canSlide || busy}
|
|
className={[
|
|
"absolute right-0 top-1/2 z-10 -translate-y-1/2",
|
|
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
|
|
"grid place-items-center border-2 border-[#C2CAD2]",
|
|
!canSlide || busy
|
|
? "opacity-40 cursor-not-allowed"
|
|
: "hover:brightness-95",
|
|
].join(" ")}
|
|
aria-label="다음"
|
|
>
|
|
<Image
|
|
src="/images/landing/icon_right_arrow.png"
|
|
alt="다음"
|
|
width={24}
|
|
height={24}
|
|
className="w-6 h-6 object-contain"
|
|
/>
|
|
</button>
|
|
|
|
{/* 중요: clip(가림)과 measure(측정) 분리 */}
|
|
<div className="overflow-hidden [clip-path:inset(0)] [contain:paint]">
|
|
<div className="w-[1250px] overflow-hidden m-auto">
|
|
{/* 측정은 패딩 없는 박스에서 */}
|
|
<div ref={clipRef} className="w-full">
|
|
{/* track */}
|
|
<div className="flex" style={trackStyle} onTransitionEnd={onTransitionEnd}>
|
|
{viewGroups.map((group, gi) => (
|
|
<div
|
|
key={`pane-${gi}`}
|
|
className="shrink-0"
|
|
style={{ width: clipWidth || "100%" }}
|
|
>
|
|
{/* 그룹 내부: perPage 컬럼 */}
|
|
<div
|
|
className="grid gap-7"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${perPage}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
{(group ?? []).map((c) => (
|
|
<article
|
|
key={c.id}
|
|
className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white"
|
|
>
|
|
<div className="h-[241px] w-full bg-slate-100 border-b border-b-blue-300">
|
|
<Image
|
|
src={c.imageSrc}
|
|
alt={c.title}
|
|
width={640}
|
|
height={241}
|
|
className="h-full w-full object-cover"
|
|
style={{ width: "100%", height: "100%" }}
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
|
|
<div className="p-8">
|
|
<h3 className="h-[77px] text-center text-[28px] font-extrabold leading-snug tracking-tight">
|
|
{c.title}
|
|
</h3>
|
|
<p className="mt-4 line-clamp-3 text-[16px] leading-7 text-slate-600">
|
|
{c.desc}
|
|
</p>
|
|
|
|
<a
|
|
href={c.href ?? "#"}
|
|
className="mt-8 inline-flex h-[56px] w-full items-center justify-center gap-3 rounded-md bg-[#02A0D6] text-[18px] font-bold text-white transition hover:brightness-110"
|
|
>
|
|
자세히 보기 <span aria-hidden>→</span>
|
|
</a>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 도트 */}
|
|
<div className="mt-8 flex items-center justify-center gap-3">
|
|
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
|
|
<button
|
|
key={`dot-${i}`}
|
|
type="button"
|
|
onClick={() => goDot(i)}
|
|
className={[
|
|
"h-[10px] w-[10px] rounded-full transition",
|
|
i === cur
|
|
? "bg-black opacity-100"
|
|
: "bg-black opacity-30 hover:opacity-60",
|
|
].join(" ")}
|
|
aria-label={`${i + 1}번 그룹`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|