|
|
import { useEffect, useRef, useState } from "react"; |
|
|
import SubHero from "../../components/SubHero"; |
|
|
import useFadeIn from "../../hooks/useFadeIn"; |
|
|
import { motion, AnimatePresence, useInView, useScroll, useTransform } from "framer-motion"; |
|
|
import { MapPin, ShieldAlert, Network, BadgeCheck, Cloud, FileCheck, Database, Layers, Navigation, CheckCircle, Radio, Plane, Bell } from "lucide-react"; |
|
|
import { useLanguage } from "../../context/LanguageContext"; |
|
|
|
|
|
const ease = [0.22, 1, 0.36, 1]; |
|
|
const basePath = import.meta.env.BASE_URL; |
|
|
|
|
|
const INTRO_CARDS_KO = [ |
|
|
{ label: "비행 계획", icon: Navigation }, |
|
|
{ label: "비행 승인", icon: CheckCircle }, |
|
|
{ label: "실시간 관제", icon: Radio }, |
|
|
{ label: "데이터 관리", icon: Database }, |
|
|
]; |
|
|
|
|
|
const INTRO_CARDS_EN = [ |
|
|
{ label: "Flight Plan", icon: Navigation }, |
|
|
{ label: "Approval", icon: CheckCircle }, |
|
|
{ label: "Live Control", icon: Radio }, |
|
|
{ label: "Data", icon: Database }, |
|
|
]; |
|
|
|
|
|
const UTM_WHAT_LEFT_KO = [ |
|
|
{ icon: MapPin, label: "실시간 위치 감시" }, |
|
|
{ icon: FileCheck, label: "비행 계획 및 경로 승인" }, |
|
|
{ icon: Network, label: "공역 설정·지오펜싱" }, |
|
|
{ icon: Cloud, label: "기상 정보 자동 연계" }, |
|
|
]; |
|
|
|
|
|
const UTM_WHAT_LEFT_EN = [ |
|
|
{ icon: MapPin, label: "Real-Time Location Tracking" }, |
|
|
{ icon: FileCheck, label: "Flight Plan & Route Approval" }, |
|
|
{ icon: Network, label: "Airspace & Geofencing" }, |
|
|
{ icon: Cloud, label: "Automated Weather Integration" }, |
|
|
]; |
|
|
|
|
|
const UTM_WHAT_RIGHT_KO = [ |
|
|
{ icon: BadgeCheck, label: "불법 기체 실시간 감지" }, |
|
|
{ icon: Database, label: "비행 데이터 통합 관리" }, |
|
|
{ icon: Layers, label: "UAM·드론 시스템 연동" }, |
|
|
{ icon: ShieldAlert, label: "충돌 위험 사전 분석" }, |
|
|
]; |
|
|
|
|
|
const UTM_WHAT_RIGHT_EN = [ |
|
|
{ icon: BadgeCheck, label: "Real-Time Unauthorized Aircraft Detection" }, |
|
|
{ icon: Database, label: "Integrated Flight Data Management" }, |
|
|
{ icon: Layers, label: "UAM & Drone System Integration" }, |
|
|
{ icon: ShieldAlert, label: "Proactive Collision Risk Analysis" }, |
|
|
]; |
|
|
|
|
|
const INTRO_FLIGHT_PLANS_KO = [ |
|
|
{ id: "FP-001", pilot: "홍길동", route: "서울 → 김포", status: "대기" }, |
|
|
{ id: "FP-002", pilot: "김철수", route: "인천 → 수원", status: "대기" }, |
|
|
]; |
|
|
|
|
|
const INTRO_FLIGHT_PLANS_EN = [ |
|
|
{ id: "FP-001", pilot: "J. Doe", route: "ICN → GMP", status: "Pending" }, |
|
|
{ id: "FP-002", pilot: "K. Kim", route: "ICN → SWU", status: "Pending" }, |
|
|
]; |
|
|
|
|
|
const AIRSPACES_KO = [ |
|
|
{ |
|
|
id: "AZ-001", |
|
|
name: "비행금지구역", |
|
|
type: "금지", |
|
|
color: "#ef4444", |
|
|
top: "30%", |
|
|
left: "45%", |
|
|
size: 80, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-002", |
|
|
name: "비행제한구역", |
|
|
type: "제한", |
|
|
color: "#f97316", |
|
|
top: "55%", |
|
|
left: "62%", |
|
|
size: 60, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-003", |
|
|
name: "비행허용구역", |
|
|
type: "허용", |
|
|
color: "#6366f1", |
|
|
top: "25%", |
|
|
left: "25%", |
|
|
size: 70, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-004", |
|
|
name: "초경량비행구역", |
|
|
type: "허용", |
|
|
color: "#22c55e", |
|
|
top: "60%", |
|
|
left: "30%", |
|
|
size: 50, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const AIRSPACES_EN = [ |
|
|
{ |
|
|
id: "AZ-001", |
|
|
name: "No-Fly Zone", |
|
|
type: "Prohibited", |
|
|
color: "#ef4444", |
|
|
top: "30%", |
|
|
left: "45%", |
|
|
size: 80, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-002", |
|
|
name: "Restricted Zone", |
|
|
type: "Restricted", |
|
|
color: "#f97316", |
|
|
top: "55%", |
|
|
left: "62%", |
|
|
size: 60, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-003", |
|
|
name: "Permitted Zone", |
|
|
type: "Permitted", |
|
|
color: "#6366f1", |
|
|
top: "25%", |
|
|
left: "25%", |
|
|
size: 70, |
|
|
}, |
|
|
{ |
|
|
id: "AZ-004", |
|
|
name: "Ultralight Zone", |
|
|
type: "Permitted", |
|
|
color: "#22c55e", |
|
|
top: "60%", |
|
|
left: "30%", |
|
|
size: 50, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const INTRO_DETAIL_KO = { |
|
|
id: "FP-001", |
|
|
pilot: "홍길동", |
|
|
pilotId: "hong001", |
|
|
route: "서울 → 김포", |
|
|
altitude: "100m", |
|
|
speed: "13m/s", |
|
|
date: "2025-06-10 14:30", |
|
|
}; |
|
|
|
|
|
const INTRO_DETAIL_EN = { |
|
|
id: "FP-001", |
|
|
pilot: "J. Doe", |
|
|
pilotId: "jdoe001", |
|
|
route: "ICN → GMP", |
|
|
altitude: "100m", |
|
|
speed: "13m/s", |
|
|
date: "2025-06-10 14:30", |
|
|
}; |
|
|
|
|
|
const INTRO_PHASES = ["list", "detail", "confirm", "done"]; |
|
|
const INTRO_PHASE_DURATION = { |
|
|
list: 1800, |
|
|
detail: 2200, |
|
|
confirm: 1800, |
|
|
done: 2000, |
|
|
}; |
|
|
|
|
|
const TAB_DURATION = [5000, 7500, 7500, 5000]; // 실시간감시, 비행계획, 공역, 날씨 |
|
|
|
|
|
const FP_PANEL_TEXT = { |
|
|
ko: { |
|
|
panelTitle: "비행계획 승인관리", |
|
|
badge: (n) => `검색결과 총 ${n}건`, |
|
|
thead: ["계획 ID", "신청자", "경로", "상태"], |
|
|
detailBtn: "상세보기", |
|
|
detailTitle: "비행계획 상세", |
|
|
rowLabels: ["계획 ID", "신청자", "경로", "고도", "신청일시", "상태"], |
|
|
approveBtn: "승인처리", |
|
|
toast: "FP-001 승인이 완료되었습니다.", |
|
|
confirmTitle: "비행계획을 승인하시겠습니까?", |
|
|
cancel: "취소", |
|
|
ok: "확인", |
|
|
statusDone: "승인", |
|
|
statusWait: "대기", |
|
|
}, |
|
|
en: { |
|
|
panelTitle: "Flight Plan Approval", |
|
|
badge: (n) => `${n} results found`, |
|
|
thead: ["Plan ID", "Pilot", "Route", "Status"], |
|
|
detailBtn: "View", |
|
|
detailTitle: "Flight Plan Details", |
|
|
rowLabels: ["Plan ID", "Pilot", "Route", "Altitude", "Requested", "Status"], |
|
|
approveBtn: "Approve", |
|
|
toast: "FP-001 has been approved.", |
|
|
confirmTitle: "Approve this flight plan?", |
|
|
cancel: "Cancel", |
|
|
ok: "Confirm", |
|
|
statusDone: "Approved", |
|
|
statusWait: "Pending", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const AIRSPACE_POPUP_TEXT = { |
|
|
ko: { |
|
|
zoneName: "구역명", |
|
|
zoneId: "구역 ID", |
|
|
altRange: "고도 범위", |
|
|
radius: "반경", |
|
|
status: "상태", |
|
|
altRangeVal: "0 ~ 150m", |
|
|
}, |
|
|
en: { |
|
|
zoneName: "Zone Name", |
|
|
zoneId: "Zone ID", |
|
|
altRange: "Altitude Range", |
|
|
radius: "Radius", |
|
|
status: "Status", |
|
|
altRangeVal: "0 - 150m", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const WEATHER_TEXT = { |
|
|
ko: { |
|
|
title: "기상 정보", |
|
|
desc: "맑음 · 비행 적합", |
|
|
windSpeedLabel: "풍속", |
|
|
windDirLabel: "풍향", |
|
|
windDirVal: "북동", |
|
|
humidityLabel: "습도", |
|
|
visibilityLabel: "시정", |
|
|
ceilingLabel: "운고", |
|
|
precipLabel: "강수", |
|
|
}, |
|
|
en: { |
|
|
title: "Weather", |
|
|
desc: "Clear · Suitable for Flight", |
|
|
windSpeedLabel: "Wind Speed", |
|
|
windDirLabel: "Wind Direction", |
|
|
windDirVal: "NE", |
|
|
humidityLabel: "Humidity", |
|
|
visibilityLabel: "Visibility", |
|
|
ceilingLabel: "Ceiling", |
|
|
precipLabel: "Precipitation", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const EVO_ITEMS_KO = [ |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_drone.png`, |
|
|
alt: "DRONE", |
|
|
name: "DRONE", |
|
|
desc: ( |
|
|
<> |
|
|
개별 드론 운용 단계. |
|
|
<br /> |
|
|
단순 비행 임무 수행 중심으로 체계적 관리 체계 부재. |
|
|
</> |
|
|
), |
|
|
tag: "단일 기체 운용", |
|
|
side: "left", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_utm.png`, |
|
|
alt: "UTM", |
|
|
name: "UTM", |
|
|
desc: ( |
|
|
<> |
|
|
무인기 교통관리 시스템 도입. |
|
|
<br /> |
|
|
비행 경로 승인·충돌 방지·공역 관리 체계화. |
|
|
</> |
|
|
), |
|
|
tag: "공역 관리 시스템", |
|
|
side: "right", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_uam.png`, |
|
|
alt: "UAM", |
|
|
name: "UAM", |
|
|
desc: ( |
|
|
<> |
|
|
도심항공모빌리티 등장. |
|
|
<br /> |
|
|
유인 eVTOL 기체가 도심 저고도 공역을 비행. |
|
|
</> |
|
|
), |
|
|
tag: "도심 항공 모빌리티", |
|
|
side: "left", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_uatm.png`, |
|
|
alt: "UATM", |
|
|
name: "UATM", |
|
|
desc: ( |
|
|
<> |
|
|
UAM과 UTM의 완전한 통합. |
|
|
<br /> |
|
|
기체 운용부터 관제까지 하나의 플랫폼으로 실현. |
|
|
</> |
|
|
), |
|
|
tag: "통합 관제 플랫폼", |
|
|
side: "right", |
|
|
active: true, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const EVO_ITEMS_EN = [ |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_drone.png`, |
|
|
alt: "DRONE", |
|
|
name: "DRONE", |
|
|
desc: ( |
|
|
<> |
|
|
Individual drone operation stage. |
|
|
<br /> |
|
|
Focused on simple flight missions, without a systematic management framework. |
|
|
</> |
|
|
), |
|
|
tag: "Single Aircraft Operation", |
|
|
side: "left", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_utm.png`, |
|
|
alt: "UTM", |
|
|
name: "UTM", |
|
|
desc: ( |
|
|
<> |
|
|
Introduction of unmanned traffic management systems. |
|
|
<br /> |
|
|
Systematic flight path approval, collision avoidance, and airspace management. |
|
|
</> |
|
|
), |
|
|
tag: "Airspace Management System", |
|
|
side: "right", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_uam.png`, |
|
|
alt: "UAM", |
|
|
name: "UAM", |
|
|
desc: ( |
|
|
<> |
|
|
Emergence of urban air mobility. |
|
|
<br /> |
|
|
Manned eVTOL aircraft fly through low-altitude urban airspace. |
|
|
</> |
|
|
), |
|
|
tag: "Urban Air Mobility", |
|
|
side: "left", |
|
|
active: false, |
|
|
}, |
|
|
{ |
|
|
img: `${basePath}images/utm_intro_uatm.png`, |
|
|
alt: "UATM", |
|
|
name: "UATM", |
|
|
desc: ( |
|
|
<> |
|
|
Full integration of UAM and UTM. |
|
|
<br /> |
|
|
From aircraft operation to air traffic control, realized on a single platform. |
|
|
</> |
|
|
), |
|
|
tag: "Integrated Control Platform", |
|
|
side: "right", |
|
|
active: true, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const SECTORS_KO = [ |
|
|
{ |
|
|
id: 0, |
|
|
num: "01", |
|
|
name: "UAM 기체 운용", |
|
|
color: "#E8193C", |
|
|
tags: ["eVTOL 기체 직접 운용", "실 운용 데이터 기반 검증", "국내 유일 통합 사업자", "안전 관리 체계 구축"], |
|
|
}, |
|
|
{ |
|
|
id: 1, |
|
|
num: "02", |
|
|
name: "UTM 관제 기술", |
|
|
color: "#1A1F5E", |
|
|
tags: ["실시간 공역 감시·관제", "충돌 위험 사전 분석", "비행 경로 승인 시스템", "지오펜싱·공역 설정"], |
|
|
}, |
|
|
{ |
|
|
id: 2, |
|
|
num: "03", |
|
|
name: "통합 항공 데이터 플랫폼", |
|
|
color: "#3D3D3D", |
|
|
tags: ["비행 데이터 수집·분석", "UAM·드론 시스템 연동", "기상 정보 자동 연계", "통합 데이터 관리"], |
|
|
}, |
|
|
]; |
|
|
|
|
|
const SECTORS_EN = [ |
|
|
{ |
|
|
id: 0, |
|
|
num: "01", |
|
|
name: "UAM Aircraft Operation", |
|
|
color: "#E8193C", |
|
|
tags: ["Direct eVTOL aircraft operation", "Verified through real operational data", "Korea's only integrated operator", "Established safety management system"], |
|
|
}, |
|
|
{ |
|
|
id: 1, |
|
|
num: "02", |
|
|
name: "UTM Control Technology", |
|
|
color: "#1A1F5E", |
|
|
tags: ["Real-time airspace monitoring & control", "Proactive collision risk analysis", "Flight path approval system", "Geofencing & airspace configuration"], |
|
|
}, |
|
|
{ |
|
|
id: 2, |
|
|
num: "03", |
|
|
name: "Integrated Aviation Data Platform", |
|
|
color: "#3D3D3D", |
|
|
tags: ["Flight data collection & analysis", "UAM & drone system integration", "Automated weather integration", "Integrated data management"], |
|
|
}, |
|
|
]; |
|
|
|
|
|
const PAGE_TEXT = { |
|
|
ko: { |
|
|
overviewTitle: ( |
|
|
<> |
|
|
PAL Networks의 UTM/UATM은 |
|
|
<br /> |
|
|
<em>도심 항공 관제의 {""}</em> |
|
|
<em>새로운 기준입니다.</em> |
|
|
</> |
|
|
), |
|
|
overviewDesc: ( |
|
|
<> |
|
|
UAM 기체 운용과 UTM 관제 플랫폼을 동시에 보유한 국내 유일의 통합 사업자입니다. |
|
|
<br />실 운용 데이터로 검증된 시스템으로 <strong>안전한 저고도 공역 관리</strong>를 실현합니다. |
|
|
</> |
|
|
), |
|
|
whatTitle: ( |
|
|
<> |
|
|
UTM, <em>무엇을 할 수 있나요?</em> |
|
|
</> |
|
|
), |
|
|
whatDesc: ( |
|
|
<> |
|
|
UTM은 단순한 관제 시스템을 넘어, 도심 저고도 공역 전체를 디지털로 운영하는 통합 플랫폼입니다. |
|
|
<br /> |
|
|
실시간 감시부터 충돌 위험 분석, 기상 연계, 공역 관리까지 — 비행의 모든 순간을 안전하게 연결합니다. |
|
|
</> |
|
|
), |
|
|
mobileImgAlt: (i) => `UTM 기능 ${i}`, |
|
|
evoTitle: ( |
|
|
<> |
|
|
UTM에서 UATM으로, |
|
|
<br /> |
|
|
<em>하늘길의 패러다임이 바뀝니다</em> |
|
|
</> |
|
|
), |
|
|
evoDesc: "드론 한 대의 비행에서 시작된 기술이, 이제 도심 전체의 하늘을 통합 관제하는 UATM으로 진화했습니다. PAL Networks는 이 변화의 최전선에서 기체와 관제를 함께 운영합니다.", |
|
|
capTitle: ( |
|
|
<> |
|
|
PAL Networks의 <br /> |
|
|
<em>세 가지 핵심 역량</em> |
|
|
</> |
|
|
), |
|
|
capDesc: "기체 운용부터 관제, 데이터까지 — 하나의 사업자가 모두 보유한 통합 역량입니다.", |
|
|
}, |
|
|
en: { |
|
|
overviewTitle: ( |
|
|
<> |
|
|
PAL Networks' UTM/UATM is |
|
|
<br /> |
|
|
<em>the new standard </em> |
|
|
<em>for urban air traffic control.</em> |
|
|
</> |
|
|
), |
|
|
overviewDesc: ( |
|
|
<> |
|
|
We are Korea's only integrated operator with both UAM aircraft operations and a UTM control platform. |
|
|
<br /> |
|
|
Our system, proven through real operational data, delivers <strong>safe low-altitude airspace management</strong>. |
|
|
</> |
|
|
), |
|
|
whatTitle: ( |
|
|
<> |
|
|
UTM, <em>what can it do?</em> |
|
|
</> |
|
|
), |
|
|
whatDesc: ( |
|
|
<> |
|
|
UTM is more than a control system — it's an integrated platform that digitally operates the entire urban low-altitude airspace. |
|
|
<br /> |
|
|
From real-time monitoring to collision risk analysis, weather integration, and airspace management, it safely connects every moment of flight. |
|
|
</> |
|
|
), |
|
|
mobileImgAlt: (i) => `UTM Feature ${i}`, |
|
|
evoTitle: ( |
|
|
<> |
|
|
From UTM to UATM, |
|
|
<br /> |
|
|
<em>the paradigm of the skies is changing</em> |
|
|
</> |
|
|
), |
|
|
evoDesc: "What began as technology for flying a single drone has evolved into UATM, integrating control over an entire city's skies. PAL Networks operates at the forefront of this shift, running both aircraft and air traffic control together.", |
|
|
capTitle: ( |
|
|
<> |
|
|
PAL Networks' <br /> |
|
|
<em>Three Core Capabilities</em> |
|
|
</> |
|
|
), |
|
|
capDesc: "From aircraft operation to control and data — integrated capabilities held by a single operator.", |
|
|
}, |
|
|
}; |
|
|
|
|
|
/* ──────────────────────────────────────── |
|
|
EvoItem |
|
|
──────────────────────────────────────── */ |
|
|
function EvoItem({ item, index }) { |
|
|
const itemRef = useRef(null); |
|
|
const itemInView = useInView(itemRef, { once: true, margin: "-80px" }); |
|
|
const itemActive = useInView(itemRef, { margin: "-45% 0px -45% 0px" }); |
|
|
|
|
|
return ( |
|
|
<motion.div ref={itemRef} className={`utm-evo__item${item.active ? " utm-evo__item--active" : ""}${itemActive ? " utm-evo__item--highlight" : ""}`} initial={{ opacity: 0, y: 40 }} animate={itemInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
{item.side === "left" ? ( |
|
|
<> |
|
|
<div className="utm-evo__card-wrap utm-evo__card-wrap--left"> |
|
|
<div className="utm-evo__card"> |
|
|
<img src={item.img} alt={item.alt} className="utm-evo__card-img" /> |
|
|
<div className="utm-evo__card-body"> |
|
|
<div className="utm-evo__card-name">{item.name}</div> |
|
|
<div className="utm-evo__card-desc">{item.desc}</div> |
|
|
<span className="utm-evo__card-tag">{item.tag}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="utm-evo__dot-wrap"> |
|
|
<div className="utm-evo__dot"> |
|
|
<span>{String(index + 1).padStart(2, "0")}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div className="utm-evo__empty" /> |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
<div className="utm-evo__empty" /> |
|
|
<div className="utm-evo__dot-wrap"> |
|
|
<div className="utm-evo__dot"> |
|
|
<span>{String(index + 1).padStart(2, "0")}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div className="utm-evo__card-wrap utm-evo__card-wrap--right"> |
|
|
<div className="utm-evo__card"> |
|
|
<img src={item.img} alt={item.alt} className="utm-evo__card-img" /> |
|
|
<div className="utm-evo__card-body"> |
|
|
<div className="utm-evo__card-name">{item.name}</div> |
|
|
<div className="utm-evo__card-desc">{item.desc}</div> |
|
|
<span className="utm-evo__card-tag">{item.tag}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</> |
|
|
)} |
|
|
</motion.div> |
|
|
); |
|
|
} |
|
|
|
|
|
/* ──────────────────────────────────────── |
|
|
IntroPage |
|
|
──────────────────────────────────────── */ |
|
|
function IntroPage() { |
|
|
const { lang } = useLanguage(); |
|
|
const ref = useFadeIn(); |
|
|
|
|
|
const INTRO_CARDS = lang === "en" ? INTRO_CARDS_EN : INTRO_CARDS_KO; |
|
|
const UTM_WHAT_LEFT = lang === "en" ? UTM_WHAT_LEFT_EN : UTM_WHAT_LEFT_KO; |
|
|
const UTM_WHAT_RIGHT = lang === "en" ? UTM_WHAT_RIGHT_EN : UTM_WHAT_RIGHT_KO; |
|
|
const INTRO_FLIGHT_PLANS = lang === "en" ? INTRO_FLIGHT_PLANS_EN : INTRO_FLIGHT_PLANS_KO; |
|
|
const AIRSPACES = lang === "en" ? AIRSPACES_EN : AIRSPACES_KO; |
|
|
const INTRO_DETAIL = lang === "en" ? INTRO_DETAIL_EN : INTRO_DETAIL_KO; |
|
|
const EVO_ITEMS = lang === "en" ? EVO_ITEMS_EN : EVO_ITEMS_KO; |
|
|
const SECTORS = lang === "en" ? SECTORS_EN : SECTORS_KO; |
|
|
const fp = FP_PANEL_TEXT[lang]; |
|
|
const azp = AIRSPACE_POPUP_TEXT[lang]; |
|
|
const wx = WEATHER_TEXT[lang]; |
|
|
const t = PAGE_TEXT[lang]; |
|
|
|
|
|
const UTM_NAV = |
|
|
lang === "en" |
|
|
? [ |
|
|
{ label: "UTM/UATM Overview", to: "/utm/intro" }, |
|
|
{ label: "Case Studies", to: "/utm/case" }, |
|
|
] |
|
|
: [ |
|
|
{ label: "UTM/UATM 소개", to: "/utm/intro" }, |
|
|
{ label: "도입사례", to: "/utm/case" }, |
|
|
]; |
|
|
|
|
|
const [activeIndex, setActiveIndex] = useState(0); |
|
|
|
|
|
const [fpPhase, setFpPhase] = useState("list"); |
|
|
const fpCursorRef = useRef(null); |
|
|
const fpPanelRef = useRef(null); |
|
|
|
|
|
const introRef = useRef(null); |
|
|
const introInView = useInView(introRef, { once: true, margin: "-60px" }); |
|
|
|
|
|
const whatRef = useRef(null); |
|
|
const whatInView = useInView(whatRef, { once: true, margin: "-100px" }); |
|
|
|
|
|
const evoRef = useRef(null); |
|
|
const evoInView = useInView(evoRef, { once: true, margin: "-100px" }); |
|
|
const evoTrackRef = useRef(null); |
|
|
|
|
|
const capRef = useRef(null); |
|
|
const capInView = useInView(capRef, { once: true, margin: "-80px" }); |
|
|
|
|
|
const { scrollYProgress } = useScroll({ |
|
|
target: evoTrackRef, |
|
|
offset: ["start center", "end center"], |
|
|
}); |
|
|
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]); |
|
|
|
|
|
const [selectedAirspace, setSelectedAirspace] = useState(null); |
|
|
const airspaceCursorRef = useRef(null); |
|
|
const airspaceAreaRef = useRef(null); |
|
|
|
|
|
useEffect(() => { |
|
|
if (activeIndex !== 1) return; |
|
|
let timer; |
|
|
const run = (current) => { |
|
|
const next = INTRO_PHASES[(INTRO_PHASES.indexOf(current) + 1) % INTRO_PHASES.length]; |
|
|
timer = setTimeout(() => { |
|
|
setFpPhase(next); |
|
|
run(next); |
|
|
}, INTRO_PHASE_DURATION[current]); |
|
|
}; |
|
|
timer = setTimeout(() => run("list"), 1000); |
|
|
return () => { |
|
|
clearTimeout(timer); |
|
|
setFpPhase("list"); |
|
|
}; |
|
|
}, [activeIndex]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (activeIndex !== 1) return; |
|
|
|
|
|
const panel = fpPanelRef.current; |
|
|
const cursor = fpCursorRef.current; |
|
|
if (!panel || !cursor) return; |
|
|
|
|
|
let timers = []; |
|
|
|
|
|
function getPos(selector) { |
|
|
const el = panel.querySelector(selector); |
|
|
if (!el) return null; |
|
|
const pr = panel.getBoundingClientRect(); |
|
|
const er = el.getBoundingClientRect(); |
|
|
return { |
|
|
x: er.left - pr.left + er.width / 2, |
|
|
y: er.top - pr.top + er.height / 2, |
|
|
}; |
|
|
} |
|
|
|
|
|
function moveTo(x, y) { |
|
|
cursor.style.transform = `translate(${x}px, ${y}px)`; |
|
|
} |
|
|
|
|
|
function click() { |
|
|
cursor.classList.add("utm-cursor--click"); |
|
|
setTimeout(() => cursor.classList.remove("utm-cursor--click"), 250); |
|
|
} |
|
|
|
|
|
function after(ms, fn) { |
|
|
const t = setTimeout(fn, ms); |
|
|
timers.push(t); |
|
|
} |
|
|
|
|
|
function loop() { |
|
|
timers.forEach(clearTimeout); |
|
|
timers = []; |
|
|
|
|
|
setFpPhase("list"); |
|
|
after(1000, () => { |
|
|
const p = getPos(".utm-panel__row--active .utm-panel__btn"); |
|
|
if (p) moveTo(p.x, p.y); |
|
|
}); |
|
|
after(2000, () => { |
|
|
click(); |
|
|
after(200, () => setFpPhase("detail")); |
|
|
}); |
|
|
after(3200, () => { |
|
|
const p = getPos(".utm-panel__approve-btn"); |
|
|
if (p) moveTo(p.x, p.y); |
|
|
}); |
|
|
after(4200, () => { |
|
|
click(); |
|
|
after(200, () => setFpPhase("confirm")); |
|
|
}); |
|
|
after(5000, () => { |
|
|
const p = getPos(".utm-confirm__ok"); |
|
|
if (p) moveTo(p.x, p.y); |
|
|
}); |
|
|
after(5800, () => { |
|
|
click(); |
|
|
after(200, () => setFpPhase("done")); |
|
|
}); |
|
|
after(7500, () => loop()); |
|
|
} |
|
|
|
|
|
moveTo(180, 80); |
|
|
after(500, () => loop()); |
|
|
|
|
|
return () => { |
|
|
timers.forEach(clearTimeout); |
|
|
timers = []; |
|
|
cursor.classList.remove("utm-cursor--click"); |
|
|
setFpPhase("list"); |
|
|
}; |
|
|
}, [activeIndex]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (activeIndex !== 2) return; |
|
|
setTimeout(() => setSelectedAirspace(null), 0); // ← 이렇게 변경 |
|
|
|
|
|
const cursor = airspaceCursorRef.current; |
|
|
const area = airspaceAreaRef.current; |
|
|
if (!cursor || !area) return; |
|
|
|
|
|
let timers = []; |
|
|
|
|
|
function getPos(selector) { |
|
|
const el = area.querySelector(selector); |
|
|
if (!el) return null; |
|
|
const ar = area.getBoundingClientRect(); |
|
|
const er = el.getBoundingClientRect(); |
|
|
return { |
|
|
x: er.left - ar.left + er.width / 2, |
|
|
y: er.top - ar.top + er.height / 2, |
|
|
}; |
|
|
} |
|
|
|
|
|
function moveTo(x, y) { |
|
|
cursor.style.transform = `translate(${x}px, ${y}px)`; |
|
|
} |
|
|
|
|
|
function click() { |
|
|
cursor.classList.add("utm-cursor--click"); |
|
|
setTimeout(() => cursor.classList.remove("utm-cursor--click"), 250); |
|
|
} |
|
|
|
|
|
function after(ms, fn) { |
|
|
const t = setTimeout(fn, ms); |
|
|
timers.push(t); |
|
|
} |
|
|
|
|
|
function loop() { |
|
|
timers.forEach(clearTimeout); |
|
|
timers = []; |
|
|
setSelectedAirspace(null); |
|
|
|
|
|
after(800, () => { |
|
|
const p = getPos(".utm-airspace--0"); |
|
|
if (p) moveTo(p.x, p.y); |
|
|
}); |
|
|
after(1800, () => { |
|
|
click(); |
|
|
after(200, () => setSelectedAirspace(AIRSPACES[0])); |
|
|
}); |
|
|
after(4000, () => { |
|
|
setSelectedAirspace(null); |
|
|
}); |
|
|
after(4600, () => { |
|
|
const p = getPos(".utm-airspace--2"); |
|
|
if (p) moveTo(p.x, p.y); |
|
|
}); |
|
|
after(5400, () => { |
|
|
click(); |
|
|
after(200, () => setSelectedAirspace(AIRSPACES[2])); |
|
|
}); |
|
|
after(7500, () => loop()); |
|
|
} |
|
|
|
|
|
moveTo(100, 100); |
|
|
after(500, () => loop()); |
|
|
|
|
|
return () => { |
|
|
timers.forEach(clearTimeout); |
|
|
setSelectedAirspace(null); |
|
|
cursor.classList.remove("utm-cursor--click"); |
|
|
}; |
|
|
}, [activeIndex, AIRSPACES]); |
|
|
|
|
|
useEffect(() => { |
|
|
const t = setTimeout(() => { |
|
|
setActiveIndex((prev) => (prev + 1) % UTM_WHAT_LEFT.length); |
|
|
}, TAB_DURATION[activeIndex]); |
|
|
return () => clearTimeout(t); |
|
|
}, [activeIndex, UTM_WHAT_LEFT.length]); |
|
|
|
|
|
return ( |
|
|
<article ref={ref}> |
|
|
<SubHero |
|
|
label="UTM/UATM" |
|
|
title={ |
|
|
<> |
|
|
<em>UTM/UATM</em> |
|
|
</> |
|
|
} |
|
|
navItems={UTM_NAV} |
|
|
/> |
|
|
|
|
|
<div className="sub-content"> |
|
|
{/* ── 01 Overview ── */} |
|
|
<div className="inner-wrap"> |
|
|
<section className="utm-intro" ref={introRef}> |
|
|
<div className="utm-intro__top"> |
|
|
<motion.span className="fc-eyebrow" initial={{ opacity: 0, y: 16 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
Overview |
|
|
</motion.span> |
|
|
<motion.h2 className="utm-intro__title" initial={{ opacity: 0, y: 20 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, delay: 0.1, ease }}> |
|
|
{t.overviewTitle} |
|
|
</motion.h2> |
|
|
<motion.p className="utm-intro__desc" initial={{ opacity: 0, y: 16 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, delay: 0.2, ease }}> |
|
|
{t.overviewDesc} |
|
|
</motion.p> |
|
|
</div> |
|
|
|
|
|
<ul className="utm-intro__cards"> |
|
|
{INTRO_CARDS.map((card, i) => ( |
|
|
<motion.li key={card.label} className="utm-intro__card" initial={{ opacity: 0, y: 60 }} animate={introInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.8, delay: 0.2 + i * 0.12, ease }}> |
|
|
<span className="utm-intro__card-num">{String(i + 1).padStart(2, "0")}</span> |
|
|
<span className="utm-intro__card-icon"> |
|
|
<svg width="0" height="0"> |
|
|
<defs> |
|
|
<linearGradient id="icon-grad" x1="0%" y1="0%" x2="100%" y2="100%"> |
|
|
<stop offset="0%" stopColor="#C850C0" /> |
|
|
<stop offset="100%" stopColor="#4158D0" /> |
|
|
</linearGradient> |
|
|
</defs> |
|
|
</svg> |
|
|
<card.icon size={36} strokeWidth={1.5} stroke="url(#icon-grad)" /> |
|
|
</span> |
|
|
<p className="utm-intro__card-label">{card.label}</p> |
|
|
</motion.li> |
|
|
))} |
|
|
</ul> |
|
|
</section> |
|
|
</div> |
|
|
|
|
|
{/* ── 02 What is UTM ── */} |
|
|
<section className="utm-what" ref={whatRef}> |
|
|
<div className="inner-wrap"> |
|
|
<motion.div initial={{ opacity: 0, y: 40 }} animate={whatInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.8, ease }}> |
|
|
<div className="utm-what__top"> |
|
|
<motion.span className="utm-what__eyebrow" initial={{ opacity: 0, y: 16 }} animate={whatInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
What is UTM |
|
|
</motion.span> |
|
|
<motion.h2 className="utm-what__title" initial={{ opacity: 0, y: 20 }} animate={whatInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, delay: 0.1, ease }}> |
|
|
{t.whatTitle} |
|
|
</motion.h2> |
|
|
<motion.p className="utm-what__desc" initial={{ opacity: 0, y: 16 }} animate={whatInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, delay: 0.2, ease }}> |
|
|
{t.whatDesc} |
|
|
</motion.p> |
|
|
</div> |
|
|
|
|
|
<div className="utm-what__body"> |
|
|
<motion.ul className="utm-what__cards" initial={{ opacity: 0, x: -24 }} animate={whatInView ? { opacity: 1, x: 0 } : {}} transition={{ duration: 0.7, delay: 0.3, ease }} style={{ pointerEvents: "auto" }}> |
|
|
{UTM_WHAT_LEFT.map(({ icon: Icon, label }, i) => ( |
|
|
<li key={label} className={`utm-what__card${activeIndex === i ? " utm-what__card--active" : ""}`} onClick={() => setActiveIndex(i)} style={{ cursor: "pointer" }}> |
|
|
<span className="utm-what__card-icon"> |
|
|
<Icon size={16} /> |
|
|
</span> |
|
|
<span className="utm-what__card-label">{label}</span> |
|
|
</li> |
|
|
))} |
|
|
</motion.ul> |
|
|
|
|
|
<motion.div className="utm-what__mockup" initial={{ opacity: 0, y: 24 }} animate={whatInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.8, delay: 0.2, ease }}> |
|
|
{/* PC 전용 — 애니메이션 맵 */} |
|
|
<div |
|
|
className="utm-what__img-wrap utm-what__pc-only" |
|
|
style={{ |
|
|
backgroundImage: `url(${basePath}images/utm_what_img2.png)`, |
|
|
}} |
|
|
> |
|
|
<div className="utm-map-menu"> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<Plane size={16} /> |
|
|
</button> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<ShieldAlert size={16} /> |
|
|
</button> |
|
|
<button className={`utm-map-menu__btn${activeIndex === 3 ? " utm-map-menu__btn--active" : ""}`}> |
|
|
<Cloud size={16} /> |
|
|
</button> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<Layers size={16} /> |
|
|
</button> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<Radio size={16} /> |
|
|
</button> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<Database size={16} /> |
|
|
</button> |
|
|
<button className="utm-map-menu__btn"> |
|
|
<Bell size={16} /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<AnimatePresence> |
|
|
{activeIndex === 1 && ( |
|
|
<motion.div className="utm-panel utm-fp-panel" ref={fpPanelRef} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} transition={{ duration: 0.35, ease }}> |
|
|
<div className="utm-panel__header"> |
|
|
<span className="utm-panel__title">{fp.panelTitle}</span> |
|
|
<span className="utm-panel__badge">{fp.badge(INTRO_FLIGHT_PLANS.length)}</span> |
|
|
</div> |
|
|
|
|
|
<div className="utm-panel__table"> |
|
|
<div className="utm-panel__thead"> |
|
|
{fp.thead.map((label) => ( |
|
|
<span key={label}>{label}</span> |
|
|
))} |
|
|
<span></span> |
|
|
</div> |
|
|
{INTRO_FLIGHT_PLANS.map((row, i) => ( |
|
|
<div key={row.id} className={`utm-panel__row${i === 0 ? " utm-panel__row--active" : ""}`}> |
|
|
<span className="utm-panel__cell">{row.id}</span> |
|
|
<span className="utm-panel__cell">{row.pilot}</span> |
|
|
<span className="utm-panel__cell">{row.route}</span> |
|
|
<span className={`utm-panel__status utm-panel__status--${row.status === fp.statusDone ? "done" : "wait"}`}>{row.status}</span> |
|
|
<span className="utm-panel__cell"> |
|
|
<span className={`utm-panel__btn${i === 0 && fpPhase === "list" ? " utm-panel__btn--hover" : ""}`}>{fp.detailBtn}</span> |
|
|
</span> |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
|
|
|
<div className={`utm-panel__detail${fpPhase === "detail" || fpPhase === "confirm" || fpPhase === "done" ? " utm-panel__detail--show" : ""}`}> |
|
|
<div className="utm-panel__detail-title">{fp.detailTitle}</div> |
|
|
<div className="utm-panel__detail-rows"> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[0]}</span> |
|
|
<span>{INTRO_DETAIL.id}</span> |
|
|
</div> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[1]}</span> |
|
|
<span> |
|
|
{INTRO_DETAIL.pilot} ({INTRO_DETAIL.pilotId}) |
|
|
</span> |
|
|
</div> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[2]}</span> |
|
|
<span>{INTRO_DETAIL.route}</span> |
|
|
</div> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[3]}</span> |
|
|
<span> |
|
|
{INTRO_DETAIL.altitude} · {INTRO_DETAIL.speed} |
|
|
</span> |
|
|
</div> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[4]}</span> |
|
|
<span>{INTRO_DETAIL.date}</span> |
|
|
</div> |
|
|
<div className="utm-panel__detail-row"> |
|
|
<span>{fp.rowLabels[5]}</span> |
|
|
<span className={`utm-panel__status utm-panel__status--${fpPhase === "done" ? "done" : "wait"}`}>{fpPhase === "done" ? fp.statusDone : fp.statusWait}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{fpPhase === "detail" && ( |
|
|
<div className="utm-panel__detail-actions"> |
|
|
<span className="utm-panel__approve-btn utm-panel__approve-btn--hover">{fp.approveBtn}</span> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<AnimatePresence> |
|
|
{fpPhase === "done" && ( |
|
|
<motion.div className="utm-panel__toast" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.35, ease }}> |
|
|
<span className="utm-panel__toast-icon">✓</span> |
|
|
{fp.toast} |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
</div> |
|
|
|
|
|
<AnimatePresence> |
|
|
{fpPhase === "confirm" && ( |
|
|
<motion.div className="utm-panel__confirm-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.25 }}> |
|
|
<motion.div className="utm-confirm" initial={{ opacity: 0, scale: 0.92, y: 16 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.35, ease }}> |
|
|
<div className="utm-confirm__icon">⚠️</div> |
|
|
<div className="utm-confirm__title">{fp.confirmTitle}</div> |
|
|
<div className="utm-confirm__desc"> |
|
|
{INTRO_DETAIL.id} · {INTRO_DETAIL.pilot} · {INTRO_DETAIL.route} |
|
|
</div> |
|
|
<div className="utm-confirm__btns"> |
|
|
<span className="utm-confirm__cancel">{fp.cancel}</span> |
|
|
<span className="utm-confirm__ok">{fp.ok}</span> |
|
|
</div> |
|
|
</motion.div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
<div className="utm-cursor" ref={fpCursorRef}> |
|
|
<div className="utm-cursor__dot" /> |
|
|
</div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
<AnimatePresence> |
|
|
{activeIndex === 2 && ( |
|
|
<motion.div className="utm-airspace-layer" ref={airspaceAreaRef} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }}> |
|
|
{AIRSPACES.map((az, i) => ( |
|
|
<div |
|
|
key={az.id} |
|
|
className={`utm-airspace utm-airspace--${i}`} |
|
|
style={{ |
|
|
top: az.top, |
|
|
left: az.left, |
|
|
width: az.size * 2, |
|
|
height: az.size * 2, |
|
|
borderColor: az.color, |
|
|
background: `${az.color}22`, |
|
|
}} |
|
|
/> |
|
|
))} |
|
|
|
|
|
<AnimatePresence> |
|
|
{selectedAirspace && ( |
|
|
<motion.div |
|
|
className="utm-airspace-popup" |
|
|
style={{ |
|
|
top: selectedAirspace.top, |
|
|
left: `calc(${selectedAirspace.left} + ${selectedAirspace.size}px + 10px)`, |
|
|
}} |
|
|
initial={{ opacity: 0, scale: 0.9, y: -10 }} |
|
|
animate={{ opacity: 1, scale: 1, y: 0 }} |
|
|
exit={{ opacity: 0, scale: 0.9 }} |
|
|
transition={{ duration: 0.25, ease }} |
|
|
> |
|
|
<div |
|
|
className="utm-airspace-popup__header" |
|
|
style={{ |
|
|
borderColor: selectedAirspace.color, |
|
|
}} |
|
|
> |
|
|
<span className="utm-airspace-popup__id">{selectedAirspace.id}</span> |
|
|
<span |
|
|
className="utm-airspace-popup__badge" |
|
|
style={{ |
|
|
background: `${selectedAirspace.color}22`, |
|
|
color: selectedAirspace.color, |
|
|
}} |
|
|
> |
|
|
{selectedAirspace.type} |
|
|
</span> |
|
|
</div> |
|
|
<div className="utm-airspace-popup__rows"> |
|
|
<div className="utm-airspace-popup__row"> |
|
|
<span>{azp.zoneName}</span> |
|
|
<span>{selectedAirspace.name}</span> |
|
|
</div> |
|
|
<div className="utm-airspace-popup__row"> |
|
|
<span>{azp.zoneId}</span> |
|
|
<span>{selectedAirspace.id}</span> |
|
|
</div> |
|
|
<div className="utm-airspace-popup__row"> |
|
|
<span>{azp.altRange}</span> |
|
|
<span>{azp.altRangeVal}</span> |
|
|
</div> |
|
|
<div className="utm-airspace-popup__row"> |
|
|
<span>{azp.radius}</span> |
|
|
<span>{selectedAirspace.size * 10}m</span> |
|
|
</div> |
|
|
<div className="utm-airspace-popup__row"> |
|
|
<span>{azp.status}</span> |
|
|
<span |
|
|
style={{ |
|
|
color: selectedAirspace.color, |
|
|
fontWeight: 700, |
|
|
}} |
|
|
> |
|
|
{selectedAirspace.type} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
<div className="utm-cursor" ref={airspaceCursorRef}> |
|
|
<div className="utm-cursor__dot" /> |
|
|
</div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
<AnimatePresence> |
|
|
{activeIndex === 3 && ( |
|
|
<motion.div className="utm-weather-panel" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} transition={{ duration: 0.35, ease }}> |
|
|
<div className="utm-weather-panel__header"> |
|
|
<Cloud size={14} /> |
|
|
<span>{wx.title}</span> |
|
|
<span className="utm-weather-panel__time">2025-06-10 14:32</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__main"> |
|
|
<div className="utm-weather-panel__icon">☀️</div> |
|
|
<div className="utm-weather-panel__temp">18°</div> |
|
|
<div className="utm-weather-panel__desc">{wx.desc}</div> |
|
|
</div> |
|
|
<div className="utm-weather-panel__grid"> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.windSpeedLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">3.2 m/s</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.windDirLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">{wx.windDirVal}</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.humidityLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">62%</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.visibilityLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">10km</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.ceilingLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">1,500m</span> |
|
|
</div> |
|
|
<div className="utm-weather-panel__item"> |
|
|
<span className="utm-weather-panel__item-label">{wx.precipLabel}</span> |
|
|
<span className="utm-weather-panel__item-val">0mm</span> |
|
|
</div> |
|
|
</div> |
|
|
</motion.div> |
|
|
)} |
|
|
</AnimatePresence> |
|
|
|
|
|
<div |
|
|
className="drone drone--1" |
|
|
style={{ |
|
|
top: "35%", |
|
|
left: "48%", |
|
|
display: activeIndex === 2 ? "none" : "flex", |
|
|
}} |
|
|
> |
|
|
<div className="drone__badge drone__badge--red"> |
|
|
<div className="drone__badge-top"> |
|
|
<img src={`${basePath}images/red_drone.png`} alt="drone" width="16" height="16" /> |
|
|
<span className="drone__badge-conn">PA001</span> |
|
|
</div> |
|
|
<span className="drone__badge-id">COLL-ACR-001</span> |
|
|
<span className="drone__badge-status">13m/s / 100m</span> |
|
|
</div> |
|
|
<div className="drone__ping drone__ping--red" /> |
|
|
<div className="drone__ping drone__ping--red drone__ping--delay" /> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
className="drone drone--2" |
|
|
style={{ |
|
|
top: "25%", |
|
|
left: "22%", |
|
|
display: activeIndex === 2 ? "none" : "flex", |
|
|
}} |
|
|
> |
|
|
<div className="drone__badge drone__badge--blue"> |
|
|
<div className="drone__badge-top"> |
|
|
<img src={`${basePath}images/blue_drone.png`} alt="drone" width="16" height="16" /> |
|
|
<span className="drone__badge-conn">BLE</span> |
|
|
</div> |
|
|
<span className="drone__badge-id">DJI-TEST-0005</span> |
|
|
<span className="drone__badge-status">LTE · 9m/s / 100m</span> |
|
|
</div> |
|
|
<div className="drone__ping drone__ping--blue" /> |
|
|
<div className="drone__ping drone__ping--blue drone__ping--delay" /> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
className="drone drone--3" |
|
|
style={{ |
|
|
top: "58%", |
|
|
left: "72%", |
|
|
display: activeIndex === 2 ? "none" : "flex", |
|
|
}} |
|
|
> |
|
|
<div className="drone__badge drone__badge--orange"> |
|
|
<div className="drone__badge-top"> |
|
|
<img src={`${basePath}images/orange_drone.png`} alt="drone" width="16" height="16" /> |
|
|
<span className="drone__badge-conn">LTE</span> |
|
|
</div> |
|
|
<span className="drone__badge-id">FPA502</span> |
|
|
<span className="drone__badge-status"> |
|
|
338m / 172k/m / 81° |
|
|
<br /> |
|
|
126.745021/37.287339 |
|
|
</span> |
|
|
</div> |
|
|
<div className="drone__ping drone__ping--orange" /> |
|
|
<div className="drone__ping drone__ping--orange drone__ping--delay" /> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* 모바일 전용 — 탭 이미지 */} |
|
|
<div className="utm-what__mobile-only"> |
|
|
<div className="utm-what__m-img-wrap"> |
|
|
{UTM_WHAT_LEFT.map((_, i) => ( |
|
|
<img key={i} src={`${basePath}images/utm-mobile-img${i + 1}.png`} alt={t.mobileImgAlt(i + 1)} className={`utm-what__m-img${activeIndex === i ? " utm-what__m-img--active" : ""}`} /> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
</motion.div> |
|
|
|
|
|
<motion.ul className="utm-what__cards utm-what__cards--right" initial={{ opacity: 0, x: 24 }} animate={whatInView ? { opacity: 1, x: 0 } : {}} transition={{ duration: 0.7, delay: 0.3, ease }}> |
|
|
{UTM_WHAT_RIGHT.map(({ icon: Icon, label }) => ( |
|
|
<li key={label} className="utm-what__card utm-what__card--right"> |
|
|
<span className="utm-what__card-icon"> |
|
|
<Icon size={16} /> |
|
|
</span> |
|
|
<span className="utm-what__card-label">{label}</span> |
|
|
</li> |
|
|
))} |
|
|
</motion.ul> |
|
|
</div> |
|
|
</motion.div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
{/* ── 03 Evolution ── */} |
|
|
<div className="inner-wrap"> |
|
|
<section className="utm-evo" ref={evoRef}> |
|
|
<motion.div className="utm-evo__left" initial={{ opacity: 0, x: -32 }} animate={evoInView ? { opacity: 1, x: 0 } : {}} transition={{ duration: 0.7, ease }}> |
|
|
<motion.span className="fc-eyebrow" initial={{ opacity: 0, y: 16 }} animate={evoInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
Evolution |
|
|
</motion.span> |
|
|
<h2 className="utm-evo__title">{t.evoTitle}</h2> |
|
|
<p className="utm-evo__desc">{t.evoDesc}</p> |
|
|
</motion.div> |
|
|
|
|
|
<div className="utm-evo__right"> |
|
|
<div className="utm-evo__track" ref={evoTrackRef}> |
|
|
<div className="utm-evo__line-bg" /> |
|
|
<motion.div className="utm-evo__line-fill" style={{ height: lineHeight }} /> |
|
|
{EVO_ITEMS.map((item, i) => ( |
|
|
<EvoItem key={item.name} item={item} index={i} /> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
</div> |
|
|
|
|
|
{/* ── 04 Capabilities ── */} |
|
|
<section className="utm-cap" ref={capRef}> |
|
|
<div className="inner-wrap"> |
|
|
<div className="utm-cap__top"> |
|
|
<motion.span className="fc-eyebrow" initial={{ opacity: 0, y: 16 }} animate={capInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, ease }}> |
|
|
Capabilities |
|
|
</motion.span> |
|
|
<motion.h2 className="utm-cap__title" initial={{ opacity: 0, y: 20 }} animate={capInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, delay: 0.1, ease }}> |
|
|
{t.capTitle} |
|
|
</motion.h2> |
|
|
<motion.p className="utm-cap__desc" initial={{ opacity: 0, y: 16 }} animate={capInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6, delay: 0.2, ease }}> |
|
|
{t.capDesc} |
|
|
</motion.p> |
|
|
</div> |
|
|
|
|
|
<ul className="utm-cap__cards"> |
|
|
{SECTORS.map((s, i) => ( |
|
|
<motion.li key={s.id} className="utm-cap__card" style={{ "--cap-color": s.color }} initial={{ opacity: 0, y: 48 }} animate={capInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, delay: 0.2 + i * 0.12, ease }}> |
|
|
<span className="utm-cap__card-num">{s.num}</span> |
|
|
<div className="utm-cap__card-body"> |
|
|
<strong className="utm-cap__card-name">{s.name}</strong> |
|
|
<ul className="utm-cap__card-tags"> |
|
|
{s.tags.map((tag) => ( |
|
|
<li key={tag}>{tag}</li> |
|
|
))} |
|
|
</ul> |
|
|
</div> |
|
|
</motion.li> |
|
|
))} |
|
|
</ul> |
|
|
</div> |
|
|
</section> |
|
|
</div> |
|
|
</article> |
|
|
); |
|
|
} |
|
|
|
|
|
export default IntroPage;
|
|
|
|