Browse Source

feat : 운영 유지보수 수정

remotes/origin/main
이시연 3 weeks ago
parent
commit
1f5eb909b1
  1. 175
      src/css/common.css
  2. 589
      src/pages/business/MaintenancePage.jsx

175
src/css/common.css

@ -915,7 +915,6 @@ body{overflow-x:hidden;}
/* ======================================== /* ========================================
SI PAGE, RND - PROJECT ARCHIVE SECTION SI PAGE, RND - PROJECT ARCHIVE SECTION
======================================== */ ======================================== */
.si-archive-wrap .inner-wrap { max-width: none; padding: 0; margin: 0; }
.si_archive { padding:100px 0 120px; max-width:1660px; margin-left: auto; margin-right: 0;} .si_archive { padding:100px 0 120px; max-width:1660px; margin-left: auto; margin-right: 0;}
.si_archive__main { display:flex; align-items:flex-start; gap:46px; } .si_archive__main { display:flex; align-items:flex-start; gap:46px; }
.si_archive__header { flex: 0 0 320px; padding-top: 24px; } .si_archive__header { flex: 0 0 320px; padding-top: 24px; }
@ -1011,103 +1010,97 @@ body{overflow-x:hidden;}
.si_archive__progress { gap: 8px; } .si_archive__progress { gap: 8px; }
} }
/* ================================================================
/*maintenance page*/ Maintenance Page
.mt-intro {display:flex;gap:80px;align-items:center;padding:80px 0 100px;} ================================================================ */
.mt-intro__left {flex:0 0 400px;} .mt-wrap.sub-content {
.mt-intro__right {flex:1;min-width:0;display:flex;justify-content:center;} background: linear-gradient(135deg, #f8f0ff 0%, #ffffff 60%);
.mt-title-line {overflow:hidden;padding-bottom:.06em;margin-bottom:-.06em;}
.mt-intro__title {font-size:clamp(28px,3vw,42px);font-weight:800;color:var(--color-primary);line-height:1.25;letter-spacing:-.03em;margin:0;}
.mt-intro__desc {font-size:.9rem;color:#666;line-height:1.9;margin:28px 0 36px;word-break:keep-all;}
.mt-intro__cta {display:inline-flex;align-items:center;gap:8px;font-size:.88rem;font-weight:700;color:var(--pink);text-decoration:none;letter-spacing:-.01em;transition:gap .25s;}
.mt-intro__cta:hover {gap:14px;}
/* ── OPS CENTER ── */
.mt-ops {position:relative;width:100%;max-width:560px;aspect-ratio:1/1;}
.mt-ops__svg {width:100%;height:100%;overflow:visible;}
.mt-ops__pulse {transform-origin:320px 320px;}
@keyframes mt-pulse-1 {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(3.2); opacity: 0; }
}
@keyframes mt-pulse-2 {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(2.6); opacity: 0; }
} }
.mt-intro { display:flex; gap:60px; align-items:center; padding:80px 0 100px; }
.mt-ops__pulse--1 {animation: mt-pulse-1 2.8s ease-out infinite;} .mt-intro__left { flex:0 0 400px; }
.mt-ops__pulse--2 {animation: mt-pulse-2 2.8s ease-out infinite 1.4s;} .mt-intro__right { flex:1; min-width:0; display:flex; justify-content:right; align-items:center; }
.mt-ops__center {position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:108px;height:108px;border-radius:50%;background:var(--grad-brand);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;box-shadow:0 8px 32px rgba(123,63,160,.35);} .mt-title-line { overflow:hidden; padding-bottom:.06em; margin-bottom:-.06em; }
.mt-ops__center-label {font-size:.95rem;font-weight:900;color:#fff;letter-spacing:.08em;line-height:1;} .mt-intro__title { font-size:clamp(28px,3vw,42px); font-weight:800; color:var(--color-primary); line-height:1.25; letter-spacing:-.03em; margin:0; }
.mt-ops__center-sub {font-size:.6rem;font-weight:700;color:rgba(255,255,255,.7);letter-spacing:.15em;} .mt-intro__desc { font-size:.9rem; color:#666; line-height:1.9; margin:28px 0 36px; word-break:keep-all; }
.mt-intro__cta { display:inline-flex; align-items:center; gap:8px; font-size:.88rem; font-weight:700; color:var(--pink); text-decoration:none; letter-spacing:-.01em; transition:gap .25s; }
.mt-intro__cta:hover { gap:14px; }
/* ── 벤다이어그램 ── */
.mt-venn { position:relative; width:100%; max-width:640px; }
.mt-venn__svg { width:100%; height:auto; display:block; overflow:visible; }
/* ── KPI 바 ── */ /* ── KPI 바 ── */
.mt-kpi {background:var(--color-primary);padding:56px 0;margin:0 0 100px;} .mt-kpi { background:var(--color-primary); padding:56px 0; margin:0 0 100px; }
.mt-kpi__grid {display:grid;grid-template-columns:repeat(4,1fr);gap:0;} .mt-kpi__grid { display:grid; grid-template-columns:repeat(4,1fr); gap:0; }
.mt-kpi__item {display:flex;flex-direction:column;align-items:center;gap:10px;padding:20px 16px;border-right:1px solid rgba(255,255,255,.1);text-align:center;} .mt-kpi__item { display:flex; flex-direction:column; align-items:center; gap:10px; padding:20px 16px; border-right:1px solid rgba(255,255,255,.1); text-align:center; }
.mt-kpi__item:last-child {border-right:none;} .mt-kpi__item:last-child { border-right:none; }
.mt-kpi__value {font-size:clamp(28px,3.5vw,52px);font-weight:900;color:#fff;letter-spacing:-.04em;line-height:1;} .mt-kpi__value { font-size:clamp(28px,3.5vw,52px); font-weight:900; color:#fff; letter-spacing:-.04em; line-height:1; }
.mt-kpi__label {font-size:.78rem;font-weight:500;color:rgba(255,255,255,.45);line-height:1.4;} .mt-kpi__label { font-size:.78rem; font-weight:500; color:rgba(255,255,255,.45); line-height:1.4; }
/* ── 5컬럼 서비스 ── */ /* ── 5컬럼 서비스 카드 ── */
.mt-services {padding-bottom:120px;} .mt-services { padding-bottom:120px; }
.mt-services__grid {display:grid;grid-template-columns:repeat(5,1fr);gap:0;border:1px solid var(--color-primary-soft-border);border-radius:16px;overflow:hidden;} .mt-services__grid { display:grid; grid-template-columns:repeat(5,1fr); gap:0; border:1px solid var(--color-primary-soft-border); border-radius:16px; overflow:hidden; }
.mt-service-card {position:relative;display:flex;flex-direction:column;gap:12px;padding:32px 24px 28px;border-right:1px solid var(--color-primary-soft-border);overflow:hidden;transition:background .25s;} .mt-service-card { position:relative; display:flex; flex-direction:column; gap:12px; padding:32px 24px 28px; border-right:1px solid var(--color-primary-soft-border); overflow:hidden; transition:background .25s; }
.mt-service-card:last-child {border-right:none;} .mt-service-card:last-child { border-right:none; }
.mt-service-card:hover {background:var(--color-primary-soft);} .mt-service-card:hover { background:var(--color-primary-soft); }
.mt-service-card__img-wrap {width:72px;height:72px;margin-bottom:8px;transition:transform .4s cubic-bezier(.22,1,.36,1);} .mt-service-card__img-wrap { width:72px; height:72px; margin-bottom:8px; transition:transform .4s cubic-bezier(.22,1,.36,1); }
.mt-service-card:hover .mt-service-card__img-wrap {transform:translateY(-6px) scale(1.08);} .mt-service-card:hover .mt-service-card__img-wrap { transform:translateY(-6px) scale(1.08); }
.mt-service-card__img {width:100%;height:100%;object-fit:contain;} .mt-service-card__img { width:100%; height:100%; object-fit:contain; }
.mt-service-card__num {font-size:.7rem;font-weight:800;letter-spacing:.12em;color:var(--pink);opacity:.8;} .mt-service-card__num { font-size:.7rem; font-weight:800; letter-spacing:.12em; color:var(--pink); opacity:.8; }
.mt-service-card__title {font-size:1rem;font-weight:800;color:var(--color-primary);letter-spacing:-.02em;margin:0;} .mt-service-card__title { font-size:1rem; font-weight:800; color:var(--color-primary); letter-spacing:-.02em; margin:0; }
.mt-service-card__desc {font-size:.78rem;color:#888;line-height:1.7;word-break:keep-all;margin:0;} .mt-service-card__desc { font-size:.78rem; color:#888; line-height:1.7; word-break:keep-all; margin:0; }
.mt-service-card__line {position:absolute;bottom:0;left:0;right:0;height:2px;background:var(--grad-brand-h);transform:scaleX(0);transform-origin:left;transition:transform .4s cubic-bezier(.22,1,.36,1);} .mt-service-card__line { position:absolute; bottom:0; left:0; right:0; height:2px; background:var(--grad-brand-h); transform:scaleX(0); transform-origin:left; transition:transform .4s cubic-bezier(.22,1,.36,1); }
.mt-service-card:hover .mt-service-card__line {transform:scaleX(1);} .mt-service-card:hover .mt-service-card__line { transform:scaleX(1); }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 1280px) { @media (min-width: 1660px) {
.mt-intro {gap:56px;} .si-archive-wrap .inner-wrap {
.mt-intro__left {flex:0 0 360px;} margin-left: 122.5px;
margin-right: 0;
padding-right: 0;
}
} }
@media (max-width: 1280px) {
.mt-intro { gap:40px; }
.mt-intro__left { flex:0 0 360px; }
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.mt-intro {flex-direction:column;gap:48px;padding:60px 0 80px;} .mt-intro { flex-direction:column; gap:48px; padding:60px 0 80px; }
.mt-intro__left {flex:none;width:100%;} .mt-intro__left { flex:none; width:100%; }
.mt-ops {max-width:420px;} .mt-venn { max-width:460px; }
.mt-services__grid {grid-template-columns:repeat(3,1fr);} .mt-services__grid { grid-template-columns:repeat(3,1fr); }
.mt-service-card:nth-child(3) {border-right:none;} .mt-service-card:nth-child(3) { border-right:none; }
.mt-service-card:nth-child(4), .mt-service-card:nth-child(5) {border-top:1px solid var(--color-primary-soft-border);} .mt-service-card:nth-child(4), .mt-service-card:nth-child(5) { border-top:1px solid var(--color-primary-soft-border); }
.mt-service-card:nth-child(5) {border-right:none;} .mt-service-card:nth-child(5) { border-right:none; }
.mt-kpi__grid {grid-template-columns:repeat(2,1fr);} .mt-kpi__grid { grid-template-columns:repeat(2,1fr); }
.mt-kpi__item:nth-child(2) {border-right:none;} .mt-kpi__item:nth-child(2) { border-right:none; }
.mt-kpi__item:nth-child(3), .mt-kpi__item:nth-child(4) {border-top:1px solid rgba(255,255,255,.1);} .mt-kpi__item:nth-child(3), .mt-kpi__item:nth-child(4) { border-top:1px solid rgba(255,255,255,.1); }
.mt-kpi__item:nth-child(4) {border-right:none;} .mt-kpi__item:nth-child(4) { border-right:none; }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.mt-intro {padding:48px 0 64px;} .mt-intro { padding:48px 0 64px; }
.mt-ops {max-width:340px;} .mt-venn { max-width:340px; }
.mt-services__grid {grid-template-columns:repeat(2,1fr);} .mt-services__grid { grid-template-columns:repeat(2,1fr); }
.mt-service-card:nth-child(2n) {border-right:none;} .mt-service-card:nth-child(2n) { border-right:none; }
.mt-service-card:nth-child(3) {border-right:1px solid var(--color-primary-soft-border);} .mt-service-card:nth-child(3) { border-right:1px solid var(--color-primary-soft-border); }
.mt-service-card:nth-child(n+3) {border-top:1px solid var(--color-primary-soft-border);} .mt-service-card:nth-child(n+3) { border-top:1px solid var(--color-primary-soft-border); }
.mt-service-card:nth-child(5) {border-right:none;grid-column:span 2;} .mt-service-card:nth-child(5) { border-right:none; grid-column:span 2; }
.mt-kpi {margin-bottom:64px;} .mt-kpi { margin-bottom:64px; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.mt-services__grid {grid-template-columns:1fr;} .mt-services__grid { grid-template-columns:1fr; }
.mt-service-card {border-right:none !important;} .mt-service-card { border-right:none !important; }
.mt-service-card:nth-child(n+2) {border-top:1px solid var(--color-primary-soft-border);} .mt-service-card:nth-child(n+2) { border-top:1px solid var(--color-primary-soft-border); }
.mt-service-card:nth-child(5) {grid-column:span 1;} .mt-service-card:nth-child(5) { grid-column:span 1; }
} }

589
src/pages/business/MaintenancePage.jsx

@ -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];
/* 아이콘 5개 위치 — 정오각형, 상단에서 시작 */ function VennDiagram({ inView }) {
const NODE_ANGLES = [-90, -18, 54, 126, 198]; const angleRef = useRef(-90);
const CX = 320, const dotAngleRef = useRef(0);
CY = 320, const lastTime = useRef(null);
R = 190, const rafRef = useRef(null);
ICON_R = 44;
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));
const initDots = DOT_OFFSETS.map((offset) => {
const rad = (offset * Math.PI) / 180;
return { x: CX + OUTER_R * Math.cos(rad), y: CY + OUTER_R * Math.sin(rad) };
});
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>
<linearGradient id="mtLineGrad" x1="0" y1="0" x2="1" y2="1"> {CIRCLES.map((c, i) => (
<stop offset="0%" stopColor="#d94889" /> <radialGradient key={c.id} id={`vg-${i}`} cx="38%" cy="32%" r="68%">
<stop offset="100%" stopColor="#7b3fa0" /> <stop offset="0%" stopColor={c.grad[0]} stopOpacity="0.95" />
</linearGradient> <stop offset="100%" stopColor={c.grad[1]} stopOpacity="0.82" />
<radialGradient id="mtPulse1" cx="50%" cy="50%" r="50%"> </radialGradient>
<stop offset="0%" stopColor="#d94889" stopOpacity="0.08" /> ))}
<stop offset="100%" stopColor="#d94889" stopOpacity="0" />
</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}
<circle cx={CX} cy={CY} r={R} stroke="url(#mtOrbitGrad)" strokeWidth="1.2" strokeDasharray="6 5" /> stroke="rgba(170,120,210,0.1)"
strokeWidth="1"
{/* 연결선 아이콘 → 중심 */} fill="none"
{NODE_ANGLES.map((deg, i) => { initial={{ opacity: 0 }}
const rad = (deg * Math.PI) / 180; animate={inView ? { opacity: 1 } : {}}
const ix = CX + R * Math.cos(rad); transition={{ duration: 1, ease, delay: 0.35 }}
const iy = CY + R * Math.sin(rad); />
const mx = CX + 80 * Math.cos(rad);
const my = CY + 80 * Math.sin(rad);
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 }} />;
})}
{/* 아이콘 노드 원 배경 */} {/* 가이드 원 실선 — fade in + scale */}
{NODE_ANGLES.map((deg, i) => { <motion.circle
const rad = (deg * Math.PI) / 180; cx={CX}
const ix = CX + R * Math.cos(rad); cy={CY}
const iy = CY + R * Math.sin(rad); r={GUIDE_R}
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 }} />; 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` }}
/>
{/* 아이콘 이미지 — SVG foreignObject */} {/* 그라데이션 호 — 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 size = 54; r={GUIDE_R}
return ( stroke="url(#arcGrad)"
<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 }}> strokeWidth="4"
<img src={SERVICES[i].img} alt={SERVICES[i].title} style={{ width: "100%", height: "100%", objectFit: "contain" }} /> strokeDasharray={`${dashLen} ${gapLen}`}
</motion.foreignObject> strokeDashoffset={arcLen}
); strokeLinecap="round"
})} fill="none"
/>
{/* 도트 3개 — fade in */}
{initDots.map((pos, k) => (
<motion.g
key={`dot-${k}`}
initial={{ opacity: 0 }}
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>
))}
{/* 아이콘 라벨 */} {/*
{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 labelOffset = 58; {centers.map(({ x, y }, i) => {
const lx = CX + (R + 68) * Math.cos(rad); const isActive = activeFill === i;
const ly = CY + (R + 68) * Math.sin(rad);
return ( return (
<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 }}> <motion.g
{SERVICES[i].title} key={`circle-group-${i}`}
</motion.text> 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 — 활성일 때만 */}
<circle
cx={x}
cy={y}
r={CR}
fill={`url(#vg-${i})`}
style={{
opacity: isActive ? 1 : 0,
transition: "opacity 0.4s ease",
}}
/>
{/* 테두리 */}
<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(
<div className="mt-title-line" key={i}> (line, 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 }}> <div className="mt-title-line" key={i}>
{line} <motion.h2
</motion.h2> className="mt-intro__title"
</div> initial={{ y: "105%" }}
))} animate={introInView ? { y: "0%" } : {}}
transition={{
duration: 1.1,
ease,
delay: 0.15 + i * 0.1,
}}
>
{line}
</motion.h2>
</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>

Loading…
Cancel
Save