|
|
|
@ -1,5 +1,6 @@ |
|
|
|
import { useRef } from "react"; |
|
|
|
import { useRef, useEffect, useState } from "react"; |
|
|
|
import { motion, useInView, useAnimationFrame } from "framer-motion"; |
|
|
|
import { motion, useInView } from "framer-motion"; |
|
|
|
|
|
|
|
import { gsap } from "gsap"; |
|
|
|
import SubHero from "../../components/SubHero"; |
|
|
|
import SubHero from "../../components/SubHero"; |
|
|
|
import useFadeIn from "../../hooks/useFadeIn"; |
|
|
|
import useFadeIn from "../../hooks/useFadeIn"; |
|
|
|
|
|
|
|
|
|
|
|
@ -11,7 +12,55 @@ const BUSINESS_NAV = [ |
|
|
|
{ label: "운영 · 유지보수", to: "/business/maintenance" }, |
|
|
|
{ label: "운영 · 유지보수", to: "/business/maintenance" }, |
|
|
|
]; |
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
const SERVICES = [ |
|
|
|
const CIRCLES = [ |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
id: "c0", |
|
|
|
|
|
|
|
title: "모니터링", |
|
|
|
|
|
|
|
items: [ |
|
|
|
|
|
|
|
"시스템·네트워크 전 계층 감시", |
|
|
|
|
|
|
|
"이상 징후 선제 포착", |
|
|
|
|
|
|
|
"24/7 실시간 알람", |
|
|
|
|
|
|
|
], |
|
|
|
|
|
|
|
grad: ["#d94889", "#a855f7"], |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
id: "c1", |
|
|
|
|
|
|
|
title: "장애 대응", |
|
|
|
|
|
|
|
items: ["즉각적인 원인 분석", "신속한 시스템 복구", "재발 방지 체계 수립"], |
|
|
|
|
|
|
|
grad: ["#a855f7", "#6366f1"], |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
id: "c2", |
|
|
|
|
|
|
|
title: "보안 관리", |
|
|
|
|
|
|
|
items: ["정기 취약점 점검", "패치 및 업데이트 관리", "외부 위협 차단"], |
|
|
|
|
|
|
|
grad: ["#6366f1", "#3b82f6"], |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
id: "c3", |
|
|
|
|
|
|
|
title: "기술 지원", |
|
|
|
|
|
|
|
items: [ |
|
|
|
|
|
|
|
"기술 문의 신속 응대", |
|
|
|
|
|
|
|
"가이드 및 교육 제공", |
|
|
|
|
|
|
|
"고객 역량 강화 지원", |
|
|
|
|
|
|
|
], |
|
|
|
|
|
|
|
grad: ["#3b82f6", "#06b6d4"], |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
id: "c4", |
|
|
|
|
|
|
|
title: "지속적 개선", |
|
|
|
|
|
|
|
items: ["정기 리포트 분석", "운영 효율 최적화", "서비스 품질 지속 향상"], |
|
|
|
|
|
|
|
grad: ["#06b6d4", "#d94889"], |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const KPI = [ |
|
|
|
|
|
|
|
{ value: "99.9%", label: "서비스 가용성" }, |
|
|
|
|
|
|
|
{ value: "24/7", label: "365일 실시간 운영" }, |
|
|
|
|
|
|
|
{ value: "10m 24s", label: "평균 응답 시간" }, |
|
|
|
|
|
|
|
{ value: "100%", label: "SLA 준수율" }, |
|
|
|
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const SERVICES_CARD = [ |
|
|
|
{ |
|
|
|
{ |
|
|
|
num: "01", |
|
|
|
num: "01", |
|
|
|
title: "모니터링", |
|
|
|
title: "모니터링", |
|
|
|
@ -44,113 +93,373 @@ const SERVICES = [ |
|
|
|
}, |
|
|
|
}, |
|
|
|
]; |
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
const KPI = [ |
|
|
|
/* ── KPI 카운트업 ── */ |
|
|
|
{ value: "99.9%", label: "서비스 가용성" }, |
|
|
|
function KpiItem({ value, label, inView, delay }) { |
|
|
|
{ value: "24/7", label: "365일 실시간 운영" }, |
|
|
|
const valRef = useRef(null); |
|
|
|
{ value: "10m 24s", label: "평균 응답 시간" }, |
|
|
|
const hasAnimated = useRef(false); |
|
|
|
{ value: "100%", label: "SLA 준수율" }, |
|
|
|
useEffect(() => { |
|
|
|
]; |
|
|
|
if (!inView || hasAnimated.current) return; |
|
|
|
|
|
|
|
hasAnimated.current = true; |
|
|
|
|
|
|
|
const el = valRef.current; |
|
|
|
|
|
|
|
const match = value.match(/^([^0-9]*)([0-9]+\.?[0-9]*)(.*)$/); |
|
|
|
|
|
|
|
if (!match) { |
|
|
|
|
|
|
|
gsap.from(el, { opacity: 0, y: 10, duration: 0.6, delay }); |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
const prefix = match[1], |
|
|
|
|
|
|
|
num = parseFloat(match[2]), |
|
|
|
|
|
|
|
suffix = match[3]; |
|
|
|
|
|
|
|
const decimals = match[2].includes(".") ? match[2].split(".")[1].length : 0; |
|
|
|
|
|
|
|
const obj = { val: 0 }; |
|
|
|
|
|
|
|
gsap.to(obj, { |
|
|
|
|
|
|
|
val: num, |
|
|
|
|
|
|
|
duration: 1.8, |
|
|
|
|
|
|
|
delay, |
|
|
|
|
|
|
|
ease: "power3.out", |
|
|
|
|
|
|
|
onUpdate: () => { |
|
|
|
|
|
|
|
el.textContent = prefix + obj.val.toFixed(decimals) + suffix; |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
onComplete: () => { |
|
|
|
|
|
|
|
el.textContent = value; |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
}, [inView]); // eslint-disable-line |
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
|
|
<motion.div |
|
|
|
|
|
|
|
className="mt-kpi__item" |
|
|
|
|
|
|
|
initial={{ opacity: 0, y: 20 }} |
|
|
|
|
|
|
|
animate={inView ? { opacity: 1, y: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.6, ease, delay }} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
<span className="mt-kpi__value" ref={valRef}> |
|
|
|
|
|
|
|
{value} |
|
|
|
|
|
|
|
</span> |
|
|
|
|
|
|
|
<span className="mt-kpi__label">{label}</span> |
|
|
|
|
|
|
|
</motion.div> |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 상수 ── */ |
|
|
|
|
|
|
|
const N = 5; |
|
|
|
|
|
|
|
const SVG_W = 760; |
|
|
|
|
|
|
|
const SVG_H = 760; |
|
|
|
|
|
|
|
const CX = SVG_W / 2; |
|
|
|
|
|
|
|
const CY = SVG_H / 2; |
|
|
|
|
|
|
|
const GUIDE_R = 240; |
|
|
|
|
|
|
|
const OUTER_R = 310; |
|
|
|
|
|
|
|
const CR = 108; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const SMALL_ANGLES = Array.from({ length: N }, (_, i) => -90 + (360 / N) * i); |
|
|
|
|
|
|
|
function getDeg(i) { |
|
|
|
|
|
|
|
return SMALL_ANGLES[i]; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function getCenter(i) { |
|
|
|
|
|
|
|
const rad = (getDeg(i) * Math.PI) / 180; |
|
|
|
|
|
|
|
return { x: CX + GUIDE_R * Math.cos(rad), y: CY + GUIDE_R * Math.sin(rad) }; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const DOT_OFFSETS = [0, 120, 240]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ARC_DEG = 55; |
|
|
|
|
|
|
|
const ROTATE_SPEED = 20; |
|
|
|
|
|
|
|
const DOT_SPEED = 15; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// spring-like easing for circle entrance |
|
|
|
|
|
|
|
const springEase = [0.34, 1.56, 0.64, 1]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function VennDiagram({ inView }) { |
|
|
|
|
|
|
|
const angleRef = useRef(-90); |
|
|
|
|
|
|
|
const dotAngleRef = useRef(0); |
|
|
|
|
|
|
|
const lastTime = useRef(null); |
|
|
|
|
|
|
|
const rafRef = useRef(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const arcRef = useRef(null); |
|
|
|
|
|
|
|
const dotRefs = useRef([]); |
|
|
|
|
|
|
|
const dotGlowRefs = useRef([]); |
|
|
|
|
|
|
|
const activeIdxRef = useRef(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const [activeFill, setActiveFill] = useState(null); |
|
|
|
|
|
|
|
const holdFrames = useRef(0); |
|
|
|
|
|
|
|
const HOLD = 90; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
if (!inView) return; |
|
|
|
|
|
|
|
let running = true; |
|
|
|
|
|
|
|
lastTime.current = null; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const arcLen = 2 * Math.PI * GUIDE_R; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function tick(now) { |
|
|
|
|
|
|
|
if (!running) return; |
|
|
|
|
|
|
|
if (lastTime.current === null) lastTime.current = now; |
|
|
|
|
|
|
|
const delta = (now - lastTime.current) / 1000; |
|
|
|
|
|
|
|
lastTime.current = now; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
angleRef.current = (angleRef.current + ROTATE_SPEED * delta) % 360; |
|
|
|
|
|
|
|
const a = angleRef.current; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (arcRef.current) { |
|
|
|
|
|
|
|
const offset = |
|
|
|
|
|
|
|
arcLen - (arcLen * ((((a + 90) % 360) + 360) % 360)) / 360; |
|
|
|
|
|
|
|
arcRef.current.setAttribute("stroke-dashoffset", offset); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const THRESHOLD = 22; |
|
|
|
|
|
|
|
let found = null; |
|
|
|
|
|
|
|
for (let i = 0; i < N; i++) { |
|
|
|
|
|
|
|
const diff = (((a - SMALL_ANGLES[i]) % 360) + 360) % 360; |
|
|
|
|
|
|
|
if (diff <= THRESHOLD) { |
|
|
|
|
|
|
|
found = i; |
|
|
|
|
|
|
|
break; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (found !== null) { |
|
|
|
|
|
|
|
holdFrames.current = HOLD; |
|
|
|
|
|
|
|
if (found !== activeIdxRef.current) { |
|
|
|
|
|
|
|
activeIdxRef.current = found; |
|
|
|
|
|
|
|
setActiveFill(found); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
if (holdFrames.current > 0) { |
|
|
|
|
|
|
|
holdFrames.current--; |
|
|
|
|
|
|
|
} else if (activeIdxRef.current !== null) { |
|
|
|
|
|
|
|
activeIdxRef.current = null; |
|
|
|
|
|
|
|
setActiveFill(null); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dotAngleRef.current = |
|
|
|
|
|
|
|
(dotAngleRef.current - DOT_SPEED * delta + 360) % 360; |
|
|
|
|
|
|
|
const da = dotAngleRef.current; |
|
|
|
|
|
|
|
DOT_OFFSETS.forEach((offset, k) => { |
|
|
|
|
|
|
|
const deg = da + offset; |
|
|
|
|
|
|
|
const rad = (deg * Math.PI) / 180; |
|
|
|
|
|
|
|
const dx = CX + OUTER_R * Math.cos(rad); |
|
|
|
|
|
|
|
const dy = CY + OUTER_R * Math.sin(rad); |
|
|
|
|
|
|
|
if (dotRefs.current[k]) { |
|
|
|
|
|
|
|
dotRefs.current[k].setAttribute("cx", dx); |
|
|
|
|
|
|
|
dotRefs.current[k].setAttribute("cy", dy); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (dotGlowRefs.current[k]) { |
|
|
|
|
|
|
|
dotGlowRefs.current[k].setAttribute("cx", dx); |
|
|
|
|
|
|
|
dotGlowRefs.current[k].setAttribute("cy", dy); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rafRef.current = requestAnimationFrame(tick); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rafRef.current = requestAnimationFrame(tick); |
|
|
|
|
|
|
|
return () => { |
|
|
|
|
|
|
|
running = false; |
|
|
|
|
|
|
|
cancelAnimationFrame(rafRef.current); |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
}, [inView]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const centers = CIRCLES.map((_, i) => getCenter(i)); |
|
|
|
|
|
|
|
|
|
|
|
/* 아이콘 5개 위치 — 정오각형, 상단에서 시작 */ |
|
|
|
const initDots = DOT_OFFSETS.map((offset) => { |
|
|
|
const NODE_ANGLES = [-90, -18, 54, 126, 198]; |
|
|
|
const rad = (offset * Math.PI) / 180; |
|
|
|
const CX = 320, |
|
|
|
return { x: CX + OUTER_R * Math.cos(rad), y: CY + OUTER_R * Math.sin(rad) }; |
|
|
|
CY = 320, |
|
|
|
}); |
|
|
|
R = 190, |
|
|
|
|
|
|
|
ICON_R = 44; |
|
|
|
const arcLen = 2 * Math.PI * GUIDE_R; |
|
|
|
|
|
|
|
const dashLen = arcLen * (ARC_DEG / 360); |
|
|
|
|
|
|
|
const gapLen = arcLen - dashLen; |
|
|
|
|
|
|
|
|
|
|
|
function OpsCircle({ inView }) { |
|
|
|
|
|
|
|
return ( |
|
|
|
return ( |
|
|
|
<div className="mt-ops"> |
|
|
|
<div className="mt-venn"> |
|
|
|
<svg className="mt-ops__svg" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg"> |
|
|
|
{/* |
|
|
|
|
|
|
|
── 등장 애니메이션 ── |
|
|
|
|
|
|
|
SVG 전체: 오른쪽에서 슬라이드 인 + fade |
|
|
|
|
|
|
|
*/} |
|
|
|
|
|
|
|
<motion.svg |
|
|
|
|
|
|
|
className="mt-venn__svg" |
|
|
|
|
|
|
|
viewBox={`0 0 ${SVG_W} ${SVG_H}`} |
|
|
|
|
|
|
|
fill="none" |
|
|
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg" |
|
|
|
|
|
|
|
initial={{ opacity: 0, x: 80 }} |
|
|
|
|
|
|
|
animate={inView ? { opacity: 1, x: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.9, ease, delay: 0.1 }} |
|
|
|
|
|
|
|
> |
|
|
|
<defs> |
|
|
|
<defs> |
|
|
|
<linearGradient id="mtOrbitGrad" x1="0" y1="0" x2="1" y2="1"> |
|
|
|
<linearGradient id="arcGrad" x1="0%" y1="0%" x2="100%" y2="0%"> |
|
|
|
<stop offset="0%" stopColor="#d94889" stopOpacity="0.5" /> |
|
|
|
<stop offset="0%" stopColor="#d94889" stopOpacity="0.1" /> |
|
|
|
<stop offset="100%" stopColor="#1a1f5e" stopOpacity="0.2" /> |
|
|
|
<stop offset="100%" stopColor="#a855f7" stopOpacity="0.95" /> |
|
|
|
</linearGradient> |
|
|
|
|
|
|
|
<linearGradient id="mtLineGrad" x1="0" y1="0" x2="1" y2="1"> |
|
|
|
|
|
|
|
<stop offset="0%" stopColor="#d94889" /> |
|
|
|
|
|
|
|
<stop offset="100%" stopColor="#7b3fa0" /> |
|
|
|
|
|
|
|
</linearGradient> |
|
|
|
</linearGradient> |
|
|
|
<radialGradient id="mtPulse1" cx="50%" cy="50%" r="50%"> |
|
|
|
{CIRCLES.map((c, i) => ( |
|
|
|
<stop offset="0%" stopColor="#d94889" stopOpacity="0.08" /> |
|
|
|
<radialGradient key={c.id} id={`vg-${i}`} cx="38%" cy="32%" r="68%"> |
|
|
|
<stop offset="100%" stopColor="#d94889" stopOpacity="0" /> |
|
|
|
<stop offset="0%" stopColor={c.grad[0]} stopOpacity="0.95" /> |
|
|
|
|
|
|
|
<stop offset="100%" stopColor={c.grad[1]} stopOpacity="0.82" /> |
|
|
|
</radialGradient> |
|
|
|
</radialGradient> |
|
|
|
<radialGradient id="mtPulse2" cx="50%" cy="50%" r="50%"> |
|
|
|
))} |
|
|
|
<stop offset="0%" stopColor="#7b3fa0" stopOpacity="0.06" /> |
|
|
|
|
|
|
|
<stop offset="100%" stopColor="#7b3fa0" stopOpacity="0" /> |
|
|
|
|
|
|
|
</radialGradient> |
|
|
|
|
|
|
|
<filter id="mtGlow"> |
|
|
|
|
|
|
|
<feGaussianBlur stdDeviation="3" result="blur" /> |
|
|
|
|
|
|
|
<feMerge> |
|
|
|
|
|
|
|
<feMergeNode in="blur" /> |
|
|
|
|
|
|
|
<feMergeNode in="SourceGraphic" /> |
|
|
|
|
|
|
|
</feMerge> |
|
|
|
|
|
|
|
</filter> |
|
|
|
|
|
|
|
</defs> |
|
|
|
</defs> |
|
|
|
|
|
|
|
|
|
|
|
{/* pulse 링 — CSS 애니메이션 */} |
|
|
|
{/* 바깥 장식 원 — fade in */} |
|
|
|
<circle cx={CX} cy={CY} r="80" fill="url(#mtPulse1)" className="mt-ops__pulse mt-ops__pulse--1" /> |
|
|
|
<motion.circle |
|
|
|
<circle cx={CX} cy={CY} r="80" fill="url(#mtPulse2)" className="mt-ops__pulse mt-ops__pulse--2" /> |
|
|
|
cx={CX} |
|
|
|
|
|
|
|
cy={CY} |
|
|
|
|
|
|
|
r={OUTER_R} |
|
|
|
|
|
|
|
stroke="rgba(170,120,210,0.1)" |
|
|
|
|
|
|
|
strokeWidth="1" |
|
|
|
|
|
|
|
fill="none" |
|
|
|
|
|
|
|
initial={{ opacity: 0 }} |
|
|
|
|
|
|
|
animate={inView ? { opacity: 1 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 1, ease, delay: 0.35 }} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
{/* 궤도 링 */} |
|
|
|
{/* 가이드 원 실선 — fade in + scale */} |
|
|
|
<circle cx={CX} cy={CY} r={R} stroke="url(#mtOrbitGrad)" strokeWidth="1.2" strokeDasharray="6 5" /> |
|
|
|
<motion.circle |
|
|
|
|
|
|
|
cx={CX} |
|
|
|
|
|
|
|
cy={CY} |
|
|
|
|
|
|
|
r={GUIDE_R} |
|
|
|
|
|
|
|
stroke="rgba(170,120,210,0.18)" |
|
|
|
|
|
|
|
strokeWidth="1.2" |
|
|
|
|
|
|
|
fill="none" |
|
|
|
|
|
|
|
initial={{ opacity: 0, scale: 0.88 }} |
|
|
|
|
|
|
|
animate={inView ? { opacity: 1, scale: 1 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.85, ease, delay: 0.4 }} |
|
|
|
|
|
|
|
style={{ transformOrigin: `${CX}px ${CY}px` }} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
{/* 연결선 아이콘 → 중심 */} |
|
|
|
{/* 그라데이션 호 — DOM 직접 조작 */} |
|
|
|
{NODE_ANGLES.map((deg, i) => { |
|
|
|
<circle |
|
|
|
const rad = (deg * Math.PI) / 180; |
|
|
|
ref={arcRef} |
|
|
|
const ix = CX + R * Math.cos(rad); |
|
|
|
cx={CX} |
|
|
|
const iy = CY + R * Math.sin(rad); |
|
|
|
cy={CY} |
|
|
|
const mx = CX + 80 * Math.cos(rad); |
|
|
|
r={GUIDE_R} |
|
|
|
const my = CY + 80 * Math.sin(rad); |
|
|
|
stroke="url(#arcGrad)" |
|
|
|
return <motion.line key={i} x1={ix} y1={iy} x2={mx} y2={my} stroke="url(#mtLineGrad)" strokeWidth="1" strokeDasharray="5 4" initial={{ pathLength: 0, opacity: 0 }} animate={inView ? { pathLength: 1, opacity: 0.45 } : {}} transition={{ delay: 0.8 + i * 0.12, duration: 0.6, ease }} />; |
|
|
|
strokeWidth="4" |
|
|
|
})} |
|
|
|
strokeDasharray={`${dashLen} ${gapLen}`} |
|
|
|
|
|
|
|
strokeDashoffset={arcLen} |
|
|
|
|
|
|
|
strokeLinecap="round" |
|
|
|
|
|
|
|
fill="none" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
{/* 아이콘 노드 원 배경 */} |
|
|
|
{/* 도트 3개 — fade in */} |
|
|
|
{NODE_ANGLES.map((deg, i) => { |
|
|
|
{initDots.map((pos, k) => ( |
|
|
|
const rad = (deg * Math.PI) / 180; |
|
|
|
<motion.g |
|
|
|
const ix = CX + R * Math.cos(rad); |
|
|
|
key={`dot-${k}`} |
|
|
|
const iy = CY + R * Math.sin(rad); |
|
|
|
initial={{ opacity: 0 }} |
|
|
|
return <motion.circle key={i} cx={ix} cy={iy} r={ICON_R} fill="white" stroke="rgba(217,72,137,0.2)" strokeWidth="1.2" style={{ filter: "drop-shadow(0 4px 16px rgba(123,63,160,0.13))" }} initial={{ scale: 0, opacity: 0 }} animate={inView ? { scale: 1, opacity: 1 } : {}} transition={{ delay: 0.4 + i * 0.1, duration: 0.55, ease }} />; |
|
|
|
animate={inView ? { opacity: 1 } : {}} |
|
|
|
})} |
|
|
|
transition={{ duration: 0.6, ease, delay: 0.5 + k * 0.08 }} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
<circle |
|
|
|
|
|
|
|
ref={(el) => (dotGlowRefs.current[k] = el)} |
|
|
|
|
|
|
|
cx={pos.x} |
|
|
|
|
|
|
|
cy={pos.y} |
|
|
|
|
|
|
|
r="9" |
|
|
|
|
|
|
|
fill="#a855f7" |
|
|
|
|
|
|
|
opacity="0.18" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
<circle |
|
|
|
|
|
|
|
ref={(el) => (dotRefs.current[k] = el)} |
|
|
|
|
|
|
|
cx={pos.x} |
|
|
|
|
|
|
|
cy={pos.y} |
|
|
|
|
|
|
|
r="5" |
|
|
|
|
|
|
|
fill="#a855f7" |
|
|
|
|
|
|
|
opacity="0.7" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</motion.g> |
|
|
|
|
|
|
|
))} |
|
|
|
|
|
|
|
|
|
|
|
{/* 아이콘 이미지 — SVG foreignObject */} |
|
|
|
{/* |
|
|
|
{NODE_ANGLES.map((deg, i) => { |
|
|
|
── 소원 5개: stagger scale + fade in ── |
|
|
|
const rad = (deg * Math.PI) / 180; |
|
|
|
각 원을 motion.g로 감싸서 자신의 중심 기준으로 scale |
|
|
|
const ix = CX + R * Math.cos(rad); |
|
|
|
delay: 0.45 + i * 0.1 → 순서대로 등장 |
|
|
|
const iy = CY + R * Math.sin(rad); |
|
|
|
*/} |
|
|
|
const size = 54; |
|
|
|
{centers.map(({ x, y }, i) => { |
|
|
|
|
|
|
|
const isActive = activeFill === i; |
|
|
|
return ( |
|
|
|
return ( |
|
|
|
<motion.foreignObject key={i} x={ix - size / 2} y={iy - size / 2} width={size} height={size} initial={{ opacity: 0, scale: 0.6 }} animate={inView ? { opacity: 1, scale: 1 } : {}} transition={{ delay: 0.5 + i * 0.1, duration: 0.55, ease }}> |
|
|
|
<motion.g |
|
|
|
<img src={SERVICES[i].img} alt={SERVICES[i].title} style={{ width: "100%", height: "100%", objectFit: "contain" }} /> |
|
|
|
key={`circle-group-${i}`} |
|
|
|
</motion.foreignObject> |
|
|
|
initial={{ opacity: 0, scale: 0.45 }} |
|
|
|
); |
|
|
|
animate={inView ? { opacity: 1, scale: 1 } : {}} |
|
|
|
})} |
|
|
|
transition={{ |
|
|
|
|
|
|
|
duration: 0.7, |
|
|
|
|
|
|
|
ease: springEase, |
|
|
|
|
|
|
|
delay: 0.45 + i * 0.1, |
|
|
|
|
|
|
|
}} |
|
|
|
|
|
|
|
style={{ transformOrigin: `${x}px ${y}px` }} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
{/* 흰색 fill — 호를 가림 */} |
|
|
|
|
|
|
|
<circle cx={x} cy={y} r={CR} fill="white" /> |
|
|
|
|
|
|
|
|
|
|
|
{/* 아이콘 라벨 */} |
|
|
|
{/* 그라데이션 fill — 활성일 때만 */} |
|
|
|
{NODE_ANGLES.map((deg, i) => { |
|
|
|
<circle |
|
|
|
const rad = (deg * Math.PI) / 180; |
|
|
|
cx={x} |
|
|
|
const ix = CX + R * Math.cos(rad); |
|
|
|
cy={y} |
|
|
|
const iy = CY + R * Math.sin(rad); |
|
|
|
r={CR} |
|
|
|
const labelOffset = 58; |
|
|
|
fill={`url(#vg-${i})`} |
|
|
|
const lx = CX + (R + 68) * Math.cos(rad); |
|
|
|
style={{ |
|
|
|
const ly = CY + (R + 68) * Math.sin(rad); |
|
|
|
opacity: isActive ? 1 : 0, |
|
|
|
return ( |
|
|
|
transition: "opacity 0.4s ease", |
|
|
|
<motion.text key={i} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle" fontSize="14" fontWeight="600" fill="#1a1f5e" opacity="0.75" initial={{ opacity: 0 }} animate={inView ? { opacity: 0.75 } : {}} transition={{ delay: 0.7 + i * 0.1, duration: 0.5 }}> |
|
|
|
}} |
|
|
|
{SERVICES[i].title} |
|
|
|
/> |
|
|
|
</motion.text> |
|
|
|
|
|
|
|
|
|
|
|
{/* 테두리 */} |
|
|
|
|
|
|
|
<circle |
|
|
|
|
|
|
|
cx={x} |
|
|
|
|
|
|
|
cy={y} |
|
|
|
|
|
|
|
r={CR} |
|
|
|
|
|
|
|
stroke={ |
|
|
|
|
|
|
|
isActive ? "rgba(255,255,255,0.25)" : "rgba(170,120,210,0.3)" |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
strokeWidth="1.2" |
|
|
|
|
|
|
|
fill="none" |
|
|
|
|
|
|
|
style={{ transition: "stroke 0.4s ease" }} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* 텍스트 */} |
|
|
|
|
|
|
|
<text |
|
|
|
|
|
|
|
x={x} |
|
|
|
|
|
|
|
y={y - 32} |
|
|
|
|
|
|
|
textAnchor="middle" |
|
|
|
|
|
|
|
fontSize="18" |
|
|
|
|
|
|
|
fontWeight="700" |
|
|
|
|
|
|
|
fontFamily="inherit" |
|
|
|
|
|
|
|
fill={isActive ? "white" : "#1a1f5e"} |
|
|
|
|
|
|
|
opacity={isActive ? "1" : "0.7"} |
|
|
|
|
|
|
|
style={{ transition: "fill 0.4s ease, opacity 0.4s ease" }} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
{CIRCLES[i].title} |
|
|
|
|
|
|
|
</text> |
|
|
|
|
|
|
|
<line |
|
|
|
|
|
|
|
x1={x - 28} |
|
|
|
|
|
|
|
y1={y - 18} |
|
|
|
|
|
|
|
x2={x + 28} |
|
|
|
|
|
|
|
y2={y - 18} |
|
|
|
|
|
|
|
stroke={ |
|
|
|
|
|
|
|
isActive ? "rgba(255,255,255,0.3)" : "rgba(100,80,160,0.15)" |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
strokeWidth="0.8" |
|
|
|
|
|
|
|
style={{ transition: "stroke 0.4s ease" }} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
{CIRCLES[i].items.map((item, j) => ( |
|
|
|
|
|
|
|
<text |
|
|
|
|
|
|
|
key={j} |
|
|
|
|
|
|
|
x={x} |
|
|
|
|
|
|
|
y={y + 4 + j * 22} |
|
|
|
|
|
|
|
textAnchor="middle" |
|
|
|
|
|
|
|
fontSize="15" |
|
|
|
|
|
|
|
fontWeight="400" |
|
|
|
|
|
|
|
fontFamily="inherit" |
|
|
|
|
|
|
|
fill={isActive ? "rgba(255,255,255,0.88)" : "#999"} |
|
|
|
|
|
|
|
style={{ transition: "fill 0.4s ease" }} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
{item} |
|
|
|
|
|
|
|
</text> |
|
|
|
|
|
|
|
))} |
|
|
|
|
|
|
|
</motion.g> |
|
|
|
); |
|
|
|
); |
|
|
|
})} |
|
|
|
})} |
|
|
|
</svg> |
|
|
|
</motion.svg> |
|
|
|
|
|
|
|
|
|
|
|
{/* OPS CENTER 중앙 — DOM 오버레이 */} |
|
|
|
|
|
|
|
<motion.div className="mt-ops__center" initial={{ opacity: 0, scale: 0.75 }} animate={inView ? { opacity: 1, scale: 1 } : {}} transition={{ delay: 0.2, duration: 0.7, ease }}> |
|
|
|
|
|
|
|
<span className="mt-ops__center-label">OPS</span> |
|
|
|
|
|
|
|
<span className="mt-ops__center-sub">CENTER</span> |
|
|
|
|
|
|
|
</motion.div> |
|
|
|
|
|
|
|
</div> |
|
|
|
</div> |
|
|
|
); |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 메인 페이지 ── */ |
|
|
|
function MaintenancePage() { |
|
|
|
function MaintenancePage() { |
|
|
|
const ref = useFadeIn(); |
|
|
|
const ref = useFadeIn(); |
|
|
|
const introRef = useRef(null); |
|
|
|
const introRef = useRef(null); |
|
|
|
@ -171,68 +480,106 @@ function MaintenancePage() { |
|
|
|
} |
|
|
|
} |
|
|
|
navItems={BUSINESS_NAV} |
|
|
|
navItems={BUSINESS_NAV} |
|
|
|
/> |
|
|
|
/> |
|
|
|
|
|
|
|
<div className="mt-wrap sub-content"> |
|
|
|
<div className="sub-content"> |
|
|
|
|
|
|
|
<div className="inner-wrap"> |
|
|
|
<div className="inner-wrap"> |
|
|
|
{/* ── 인트로 ── */} |
|
|
|
|
|
|
|
<section className="mt-intro" ref={introRef}> |
|
|
|
<section className="mt-intro" ref={introRef}> |
|
|
|
<div className="mt-intro__left"> |
|
|
|
<div className="mt-intro__left"> |
|
|
|
<motion.span className="fc-eyebrow" initial={{ opacity: 0, y: 14 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
|
<motion.span |
|
|
|
|
|
|
|
className="fc-eyebrow" |
|
|
|
|
|
|
|
initial={{ opacity: 0, y: 14 }} |
|
|
|
|
|
|
|
animate={introInView ? { opacity: 1, y: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.6, ease }} |
|
|
|
|
|
|
|
> |
|
|
|
OUR SERVICE |
|
|
|
OUR SERVICE |
|
|
|
</motion.span> |
|
|
|
</motion.span> |
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-intro__title-wrap"> |
|
|
|
<div className="mt-intro__title-wrap"> |
|
|
|
{["운영·유지보수는", "서비스의 안정성을", "완성합니다"].map((line, i) => ( |
|
|
|
{["운영·유지보수는", "서비스의 안정성을", "완성합니다"].map( |
|
|
|
|
|
|
|
(line, i) => ( |
|
|
|
<div className="mt-title-line" key={i}> |
|
|
|
<div className="mt-title-line" key={i}> |
|
|
|
<motion.h2 className="mt-intro__title" initial={{ y: "105%" }} animate={introInView ? { y: "0%" } : {}} transition={{ duration: 1.1, ease, delay: 0.15 + i * 0.1 }}> |
|
|
|
<motion.h2 |
|
|
|
|
|
|
|
className="mt-intro__title" |
|
|
|
|
|
|
|
initial={{ y: "105%" }} |
|
|
|
|
|
|
|
animate={introInView ? { y: "0%" } : {}} |
|
|
|
|
|
|
|
transition={{ |
|
|
|
|
|
|
|
duration: 1.1, |
|
|
|
|
|
|
|
ease, |
|
|
|
|
|
|
|
delay: 0.15 + i * 0.1, |
|
|
|
|
|
|
|
}} |
|
|
|
|
|
|
|
> |
|
|
|
{line} |
|
|
|
{line} |
|
|
|
</motion.h2> |
|
|
|
</motion.h2> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
))} |
|
|
|
), |
|
|
|
|
|
|
|
)} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<motion.p |
|
|
|
<motion.p className="mt-intro__desc" initial={{ opacity: 0, y: 16 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, ease, delay: 0.5 }}> |
|
|
|
className="mt-intro__desc" |
|
|
|
|
|
|
|
initial={{ opacity: 0, y: 16 }} |
|
|
|
|
|
|
|
animate={introInView ? { opacity: 1, y: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.7, ease, delay: 0.5 }} |
|
|
|
|
|
|
|
> |
|
|
|
PAL Networks는 24/7 통합 모니터링과 체계적인 유지보수로 |
|
|
|
PAL Networks는 24/7 통합 모니터링과 체계적인 유지보수로 |
|
|
|
<br /> |
|
|
|
<br /> |
|
|
|
시스템의 가용성과 안정성을 지속적으로 보장합니다. |
|
|
|
시스템의 가용성과 안정성을 지속적으로 보장합니다. |
|
|
|
</motion.p> |
|
|
|
</motion.p> |
|
|
|
|
|
|
|
<motion.a |
|
|
|
<motion.a className="mt-intro__cta" href="/contact" initial={{ opacity: 0, y: 12 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease, delay: 0.62 }}> |
|
|
|
className="mt-intro__cta" |
|
|
|
|
|
|
|
href="/contact" |
|
|
|
|
|
|
|
initial={{ opacity: 0, y: 12 }} |
|
|
|
|
|
|
|
animate={introInView ? { opacity: 1, y: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.6, ease, delay: 0.62 }} |
|
|
|
|
|
|
|
> |
|
|
|
서비스 자세히 보기 |
|
|
|
서비스 자세히 보기 |
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> |
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> |
|
|
|
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> |
|
|
|
<path |
|
|
|
|
|
|
|
d="M3 8h10M9 4l4 4-4 4" |
|
|
|
|
|
|
|
stroke="currentColor" |
|
|
|
|
|
|
|
strokeWidth="1.5" |
|
|
|
|
|
|
|
strokeLinecap="round" |
|
|
|
|
|
|
|
strokeLinejoin="round" |
|
|
|
|
|
|
|
/> |
|
|
|
</svg> |
|
|
|
</svg> |
|
|
|
</motion.a> |
|
|
|
</motion.a> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-intro__right"> |
|
|
|
<div className="mt-intro__right"> |
|
|
|
<OpsCircle inView={introInView} /> |
|
|
|
<VennDiagram inView={introInView} /> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
</section> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
{/* ── KPI 바 ── */} |
|
|
|
|
|
|
|
<section className="mt-kpi" ref={kpiRef}> |
|
|
|
<section className="mt-kpi" ref={kpiRef}> |
|
|
|
<div className="inner-wrap"> |
|
|
|
<div className="inner-wrap"> |
|
|
|
<div className="mt-kpi__grid"> |
|
|
|
<div className="mt-kpi__grid"> |
|
|
|
{KPI.map((k, i) => ( |
|
|
|
{KPI.map((k, i) => ( |
|
|
|
<motion.div key={i} className="mt-kpi__item" initial={{ opacity: 0, y: 20 }} animate={kpiInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease, delay: i * 0.1 }}> |
|
|
|
<KpiItem |
|
|
|
<span className="mt-kpi__value">{k.value}</span> |
|
|
|
key={i} |
|
|
|
<span className="mt-kpi__label">{k.label}</span> |
|
|
|
value={k.value} |
|
|
|
</motion.div> |
|
|
|
label={k.label} |
|
|
|
|
|
|
|
inView={kpiInView} |
|
|
|
|
|
|
|
delay={i * 0.15} |
|
|
|
|
|
|
|
/> |
|
|
|
))} |
|
|
|
))} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</section> |
|
|
|
</section> |
|
|
|
|
|
|
|
|
|
|
|
{/* ── 5컬럼 서비스 ── */} |
|
|
|
|
|
|
|
<div className="inner-wrap"> |
|
|
|
<div className="inner-wrap"> |
|
|
|
<section className="mt-services" ref={colsRef}> |
|
|
|
<section className="mt-services" ref={colsRef}> |
|
|
|
<div className="mt-services__grid"> |
|
|
|
<div className="mt-services__grid"> |
|
|
|
{SERVICES.map((svc, i) => ( |
|
|
|
{SERVICES_CARD.map((svc, i) => ( |
|
|
|
<motion.div key={i} className="mt-service-card" initial={{ opacity: 0, y: 32 }} animate={colsInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.65, ease, delay: i * 0.1 }}> |
|
|
|
<motion.div |
|
|
|
|
|
|
|
key={i} |
|
|
|
|
|
|
|
className="mt-service-card" |
|
|
|
|
|
|
|
initial={{ opacity: 0, y: 32 }} |
|
|
|
|
|
|
|
animate={colsInView ? { opacity: 1, y: 0 } : {}} |
|
|
|
|
|
|
|
transition={{ duration: 0.65, ease, delay: i * 0.1 }} |
|
|
|
|
|
|
|
> |
|
|
|
<div className="mt-service-card__img-wrap"> |
|
|
|
<div className="mt-service-card__img-wrap"> |
|
|
|
<img src={svc.img} alt={svc.title} className="mt-service-card__img" /> |
|
|
|
<img |
|
|
|
|
|
|
|
src={svc.img} |
|
|
|
|
|
|
|
alt={svc.title} |
|
|
|
|
|
|
|
className="mt-service-card__img" |
|
|
|
|
|
|
|
/> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<span className="mt-service-card__num">{svc.num}</span> |
|
|
|
<span className="mt-service-card__num">{svc.num}</span> |
|
|
|
<h3 className="mt-service-card__title">{svc.title}</h3> |
|
|
|
<h3 className="mt-service-card__title">{svc.title}</h3> |
|
|
|
|