dark 프로젝트 구현 현재 상태 스냅샷

- Vite 마이그레이션, OpenLayers+Deck.gl 지도 연동
- STOMP WebSocket 선박 실시간 데이터 수신
- 선박 범례/필터/카운트, 다크시그널 처리
- Ctrl+Drag 박스선택, 우클릭 컨텍스트 메뉴
- 측정도구, 상세모달, 호버 툴팁
- darkSignalIds Set 패턴, INSHORE/OFFSHORE 타임아웃

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-01-30 13:01:54 +09:00
부모 12258aa075
커밋 f4f0cb274f
111개의 변경된 파일14150개의 추가작업 그리고 130개의 파일을 삭제

225
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,225 @@
# CLAUDE.md - GIS 함정용 프로젝트 가이드
## 프로젝트 개요
| 항목 | 내용 |
|------|------|
| 프로젝트명 | dark (GIS 함정용) |
| 참조 프로젝트 | mda-react-front (메인 프로젝트) |
| 목적 | 선박위치정보 전시 및 조회 기능 프론트엔드 |
| 현재 단계 | 퍼블리싱 → 구현 전환 |
---
## 메인 프로젝트(mda-react-front) 기술 스택
| 항목 | 기술 |
|------|------|
| 지도 엔진 | OpenLayers 9.2.4 |
| 실시간 렌더링 | Deck.gl 9.0.38 (GPU) |
| UI 프레임워크 | React 18.2.0 |
| 상태 관리 | Zustand 4.5.2 |
| 라우팅 | React Router 6.15.0 |
| 데이터 페칭 | React Query 4.32.6 |
| 실시간 통신 | STOMP WebSocket |
| 번들러 | Vite 5.2.10 |
| 타입 | TypeScript 5.0.2 |
---
## 현재 프로젝트(dark) 기술 스택
| 항목 | 기술 | 변경 계획 |
|------|------|----------|
| 번들러 | CRA (react-scripts) | Vite 마이그레이션 검토 |
| 언어 | JavaScript | TypeScript 도입 검토 |
| 라우팅 | React Router 6.30.3 | 유지 |
| 상태관리 | React useState | Zustand 도입 |
| 스타일 | SCSS | 유지 |
| 지도 | 미연동 | OpenLayers + Deck.gl |
---
## 프로젝트 구조 설계
```
src/
├── index.js # 앱 엔트리 포인트
├── App.jsx # 라우트 정의
├── publish/ # [퍼블리싱 영역] - 기존 퍼블리시 파일
│ ├── layouts/ # 퍼블리시 레이아웃
│ ├── pages/ # 퍼블리시 페이지 (Panel1~8 등)
│ └── components/ # 퍼블리시 컴포넌트
├── pages/ # [구현 영역] - 실제 페이지
│ ├── Home.jsx # 메인 페이지
│ ├── ship/ # 선박 관련 페이지
│ ├── satellite/ # 위성 관련 페이지
│ ├── weather/ # 기상 관련 페이지
│ └── analysis/ # 분석 관련 페이지
├── components/ # [공통 컴포넌트]
│ ├── common/ # 기본 UI 컴포넌트
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── ...
│ ├── domain/ # 도메인별 컴포넌트
│ │ ├── ship/ # 선박 관련
│ │ ├── map/ # 지도 관련
│ │ └── ...
│ └── layout/ # 레이아웃 컴포넌트
│ ├── Header/
│ ├── Sidebar/
│ └── ...
├── map/ # [지도 모듈]
│ ├── MapContext.jsx # OpenLayers 맵 Context
│ ├── MapProvider.jsx # 맵 Provider
│ ├── layers/ # 레이어 정의
│ │ ├── baseLayer.js # 베이스맵
│ │ ├── shipLayer.js # 선박 레이어
│ │ └── ...
│ ├── controls/ # 지도 컨트롤
│ └── utils/ # 지도 유틸리티
├── stores/ # [Zustand 스토어]
│ ├── mapStore.js # 지도 상태
│ ├── shipStore.js # 선박 상태
│ ├── filterStore.js # 필터 상태
│ └── uiStore.js # UI 상태
├── api/ # [API 레이어]
│ ├── client.js # API 클라이언트
│ ├── ship.js # 선박 API
│ └── ...
├── hooks/ # [커스텀 훅]
│ ├── useMap.js
│ ├── useShip.js
│ └── ...
├── utils/ # [유틸리티]
│ ├── format.js
│ ├── coordinate.js
│ └── ...
├── types/ # [타입 정의] (TS 도입 시)
│ └── ...
├── assets/ # [정적 자원]
│ ├── images/
│ └── ...
└── scss/ # [스타일]
├── base/
├── components/
└── pages/
```
---
## 라우팅 구조
```
/ → 메인 (지도)
├── /ship → 선박 조회
├── /satellite → 위성
├── /weather → 기상
├── /analysis → 분석
├── /settings → 설정
└── /mypage → 마이페이지
/publish → 퍼블리싱 미리보기
├── /publish/panel1 → 선박 패널
├── /publish/panel2 → 위성 패널
├── /publish/panel3 → 기상 패널
├── /publish/panel4 → 분석 패널
├── /publish/panel5 → 타임라인
├── /publish/panel6 → AI모드
├── /publish/panel7 → 리플레이
├── /publish/panel8 → 항적조회
└── /publish/... → 기타 퍼블리시 페이지
```
---
## 기능 구현 로드맵
### Phase 1: 기반 구축
- [ ] 프로젝트 구조 재편 (퍼블리시/구현 분리)
- [ ] OpenLayers 연동 (베이스맵)
- [ ] Zustand 스토어 설정
- [ ] 기본 레이아웃 구현
### Phase 2: 지도 핵심 기능
- [ ] 지도 컨트롤 (줌, 패닝)
- [ ] 레이어 관리 (토글, 순서)
- [ ] 좌표 표시
- [ ] 축척 표시
### Phase 3: 선박 표시
- [ ] Deck.gl 연동
- [ ] 선박 아이콘 렌더링
- [ ] 선박 클릭/호버 이벤트
- [ ] 선박 정보 팝업
### Phase 4: 데이터 연동
- [ ] API 클라이언트 설정
- [ ] 선박 데이터 조회
- [ ] 필터링 기능
### Phase 5: 추가 기능
- [ ] 항적 조회
- [ ] 기상 레이어
- [ ] 관심영역 설정
- [ ] 위성 영상
---
## 메인 프로젝트 참조 파일
### 지도 초기화
- `mda-react-front/src/map/MainMapContext.tsx` - OpenLayers 맵 초기화
### 레이어 설정
- `mda-react-front/src/common/mapLayer.ts` - 베이스맵 타일
- `mda-react-front/src/common/targetLayer.ts` - 선박 레이어
- `mda-react-front/src/common/deck.ts` - Deck.gl 설정
### 상태 관리
- `mda-react-front/src/shared/model/` - Zustand 스토어
### 스타일
- `mda-react-front/src/map/control.css` - 지도 컨트롤 스타일
---
## 개발 명령어
```bash
# 개발 서버 실행
npm start
# 프로덕션 빌드
npm run build
# 테스트
npm test
```
---
## 세션 관리
- 세션 핸드오버: `.claude/SESSION_HANDOVER.md`
- 자동 요약: 컨텍스트 95% 도달 시 자동 저장
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-01-26 | 초기 프로젝트 분석 및 워크플로우 정의 |

15
index.html Normal file
파일 보기

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GIS 함정용</title>
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/font.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

파일 보기

@ -1,41 +1,36 @@
{
"name": "dark",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"sass": "^1.97.2",
"web-vitals": "^2.1.4"
},
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "vite --port 3000",
"build": "vite build",
"build:dev": "vite build --mode dev",
"build:prod": "vite build --mode prod",
"preview": "vite preview --port 3000",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"dependencies": {
"@deck.gl/core": "^9.2.6",
"@deck.gl/layers": "^9.2.6",
"@stomp/stompjs": "^7.2.1",
"axios": "^1.4.0",
"dayjs": "^1.11.11",
"ol": "^9.2.4",
"ol-ext": "^4.0.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.30.3",
"zustand": "^4.5.2"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"devDependencies": {
"@vitejs/plugin-react": "^4.0.1",
"eslint": "^8.44.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"sass": "^1.77.8",
"vite": "^5.2.10"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.1 KiB

28
src/App.jsx Normal file
파일 보기

@ -0,0 +1,28 @@
import { Routes, Route, Navigate } from 'react-router-dom';
// -
import MainLayout from './components/layout/MainLayout';
//
import PublishLayout from './publish/layouts/PublishLayout';
import PublishRoutes from './publish/PublishRoutes';
export default function App() {
return (
<Routes>
{/* =====================
구현 영역 (메인)
- 모든 메뉴 경로를 MainLayout으로 처리
===================== */}
<Route path="/*" element={<MainLayout />} />
{/* =====================
퍼블리시 영역
/publish/* 접근하여 퍼블리시 결과물 미리보기
===================== */}
<Route path="/publish/*" element={<PublishLayout />}>
{PublishRoutes}
</Route>
</Routes>
);
}

51
src/api/signalApi.js Normal file
파일 보기

@ -0,0 +1,51 @@
/**
* 선박 신호 API
* 참조: mda-react-front/src/api/query.ts - getAllSignals()
*/
import { parsePipeMessage, rowToShipObject } from '../common/stompClient';
/**
* 12 이내 전체 선박 신호 조회
* STOMP 구독 전에 호출하여 초기 선박 데이터 로드
*
* @returns {Promise<Array>} 선박 데이터 배열
*/
export async function fetchAllSignals() {
try {
const response = await fetch('/signal-api/all/12');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// API 응답 구조: { data: [...] } 또는 직접 배열
const rawData = result?.data || result || [];
if (!Array.isArray(rawData)) {
console.warn('[fetchAllSignals] Invalid response format:', result);
return [];
}
// 각 행을 선박 객체로 변환
const ships = rawData.map((row) => {
// row가 문자열이면 파이프로 파싱, 배열이면 그대로 사용
const parsed = typeof row === 'string' ? parsePipeMessage(row) : row;
return rowToShipObject(parsed);
});
// 좌표가 있는 선박만 필터링
const validShips = ships.filter(s => s.longitude && s.latitude);
console.log(`[fetchAllSignals] Loaded ${validShips.length}/${ships.length} ships (with coords)`);
return validShips;
} catch (error) {
console.error('[fetchAllSignals] Error:', error);
return [];
}
}
export default fetchAllSignals;

File diff suppressed because one or more lines are too long

182
src/assets/data/shiptype.js Normal file
파일 보기

@ -0,0 +1,182 @@
export const shipTypeMap = new Map();
shipTypeMap.set('0', 'Not available');
shipTypeMap.set('1', 'Reserved for future use');
shipTypeMap.set('2', 'Reserved for future use');
shipTypeMap.set('3', 'Reserved for future use');
shipTypeMap.set('4', 'Reserved for future use');
shipTypeMap.set('5', 'Reserved for future use');
shipTypeMap.set('6', 'Reserved for future use');
shipTypeMap.set('7', 'Reserved for future use');
shipTypeMap.set('8', 'Reserved for future use');
shipTypeMap.set('9', 'Reserved for future use');
shipTypeMap.set('10', 'Reserved for future use');
shipTypeMap.set('11', 'Reserved for future use');
shipTypeMap.set('12', 'Reserved for future use');
shipTypeMap.set('13', 'Reserved for future use');
shipTypeMap.set('14', 'Reserved for future use');
shipTypeMap.set('15', 'Reserved for future use');
shipTypeMap.set('16', 'Reserved for future use');
shipTypeMap.set('17', 'Reserved for future use');
shipTypeMap.set('18', 'Reserved for future use');
shipTypeMap.set('19', 'Reserved for future use');
shipTypeMap.set('20', 'Wing in ground (WIG)');
shipTypeMap.set('21', 'Wing in ground (WIG), Hazardous category A');
shipTypeMap.set('22', 'Wing in ground (WIG), Hazardous category B');
shipTypeMap.set('23', 'Wing in ground (WIG), Hazardous category C');
shipTypeMap.set('24', 'Wing in ground (WIG), Hazardous category D');
shipTypeMap.set('25', 'Wing in ground (WIG), Reserved for future use');
shipTypeMap.set('26', 'Wing in ground (WIG), Reserved for future use');
shipTypeMap.set('27', 'Wing in ground (WIG), Reserved for future use');
shipTypeMap.set('28', 'Wing in ground (WIG), Reserved for future use');
shipTypeMap.set('29', 'Wing in ground (WIG), Reserved for future use');
shipTypeMap.set('30', 'Fishing');
shipTypeMap.set('31', 'Towing');
shipTypeMap.set('32', 'Towing, length exceeds 200m or breadth exceeds 25m');
shipTypeMap.set('33', 'Dredging or underwater ops');
shipTypeMap.set('34', 'Diving ops');
shipTypeMap.set('35', 'Military ops');
shipTypeMap.set('36', 'Sailing');
shipTypeMap.set('37', 'Pleasure Craft');
shipTypeMap.set('38', 'Reserved');
shipTypeMap.set('39', 'Reserved');
shipTypeMap.set('40', 'High speed craft (HSC)');
shipTypeMap.set('41', 'High speed craft (HSC), Hazardous category A');
shipTypeMap.set('42', 'High speed craft (HSC), Hazardous category B');
shipTypeMap.set('43', 'High speed craft (HSC), Hazardous category C');
shipTypeMap.set('44', 'High speed craft (HSC), Hazardous category D');
shipTypeMap.set('45', 'High speed craft (HSC), Reserved for future use');
shipTypeMap.set('46', 'High speed craft (HSC), Reserved for future use');
shipTypeMap.set('47', 'High speed craft (HSC), Reserved for future use');
shipTypeMap.set('48', 'High speed craft (HSC), Reserved for future use');
shipTypeMap.set('49', 'High speed craft (HSC), No additional information');
shipTypeMap.set('50', 'Pilot Vessel');
shipTypeMap.set('51', 'Search and Rescue vessel');
shipTypeMap.set('52', 'Tug');
shipTypeMap.set('53', 'Port Tender');
shipTypeMap.set('54', 'Anti-pollution equipment');
shipTypeMap.set('55', 'Law Enforcement');
shipTypeMap.set('56', 'Spare - Local Vessel');
shipTypeMap.set('57', 'Spare - Local Vessel');
shipTypeMap.set('58', 'Medical Transport');
shipTypeMap.set('59', 'Noncombatant ship according to RR Resolution No. 18');
shipTypeMap.set('60', 'Passenger');
shipTypeMap.set('61', 'Passenger, Hazardous category A');
shipTypeMap.set('62', 'Passenger, Hazardous category B');
shipTypeMap.set('63', 'Passenger, Hazardous category C');
shipTypeMap.set('64', 'Passenger, Hazardous category D');
shipTypeMap.set('65', 'Passenger, Reserved for future use');
shipTypeMap.set('66', 'Passenger, Reserved for future use');
shipTypeMap.set('67', 'Passenger, Reserved for future use');
shipTypeMap.set('68', 'Passenger, Reserved for future use');
shipTypeMap.set('69', 'Passenger, No additional information');
shipTypeMap.set('70', 'Cargo');
shipTypeMap.set('71', 'Cargo, Hazardous category A');
shipTypeMap.set('72', 'Cargo, Hazardous category B');
shipTypeMap.set('73', 'Cargo, Hazardous category C');
shipTypeMap.set('74', 'Cargo, Hazardous category D');
shipTypeMap.set('75', 'Cargo, Reserved for future use');
shipTypeMap.set('76', 'Cargo, Reserved for future use');
shipTypeMap.set('77', 'Cargo, Reserved for future use');
shipTypeMap.set('78', 'Cargo, Reserved for future use');
shipTypeMap.set('79', 'Cargo, No additional information');
shipTypeMap.set('80', 'Tanker');
shipTypeMap.set('81', 'Tanker, Hazardous category A');
shipTypeMap.set('82', 'Tanker, Hazardous category B');
shipTypeMap.set('83', 'Tanker, Hazardous category C');
shipTypeMap.set('84', 'Tanker, Hazardous category D');
shipTypeMap.set('85', 'Tanker, Reserved for future use');
shipTypeMap.set('86', 'Tanker, Reserved for future use');
shipTypeMap.set('87', 'Tanker, Reserved for future use');
shipTypeMap.set('88', 'Tanker, Reserved for future use');
shipTypeMap.set('89', 'Tanker, No additional information');
shipTypeMap.set('90', 'Other Type');
shipTypeMap.set('91', 'Other Type, Hazardous category A');
shipTypeMap.set('92', 'Other Type, Hazardous category B');
shipTypeMap.set('93', 'Other Type, Hazardous category C');
shipTypeMap.set('94', 'Other Type, Hazardous category D');
shipTypeMap.set('95', 'Other Type, Reserved for future use');
shipTypeMap.set('96', 'Other Type, Reserved for future use');
shipTypeMap.set('97', 'Other Type, Reserved for future use');
shipTypeMap.set('98', 'Other Type, Reserved for future use');
shipTypeMap.set('99', 'Other Type, no additional information');
shipTypeMap.set('100', 'Unspecified');
// E-Navigation
shipTypeMap.set('A001', '\uC5EC\uAC1D\uC120(\uC77C\uBC18)');
shipTypeMap.set('A002', '\uC5EC\uAC1D\uC120(\uACE0\uC18D\uC120)');
shipTypeMap.set('A003', '\uC5EC\uAC1D\uC120(\uC7D8\uC18D\uC120)');
shipTypeMap.set('A004', '\uC5EC\uAC1D\uC120(\uCD08\uC7D8\uC18D\uC120)');
shipTypeMap.set('A005', '\uC5EC\uAC1D\uC120(\uCE74\uD6FC\uB9AC)');
shipTypeMap.set('A006', '\uC5EC\uAC1D\uC120(\uCC28\uB3C4\uC120)');
shipTypeMap.set('A007', '\uC5EC\uAC1D\uC120(\uD654\uAC1D\uC120)');
shipTypeMap.set('A008', '\uC5EC\uAC1D\uC120(\uC720\uB78C\uC120)');
shipTypeMap.set('B001', '\uC5B4\uC120(\uC77C\uBC18)');
shipTypeMap.set('B002', '\uC5B4\uC120(\uC720\uC790\uB9DD)');
shipTypeMap.set('B003', '\uC5B4\uC120(\uC120\uB9DD)');
shipTypeMap.set('B004', '\uC5B4\uC120(\uC548\uAC15\uB9DD)');
shipTypeMap.set('B005', '\uC5B4\uC120(\uCC44\uB0DA\uAE30)');
shipTypeMap.set('B006', '\uC5B4\uC120(\uC5F0\uC2B9)');
shipTypeMap.set('B007', '\uC5B4\uC120(\uD2B8\uB864)');
shipTypeMap.set('B008', '\uC5B4\uC120(\uD1B5\uBC1C)');
shipTypeMap.set('B009', '\uC5B4\uC120(\uBCF5\uD569\uC5B4\uC5C5)');
shipTypeMap.set('B010', '\uC5B4\uC120(\uC800\uC778\uB9DD)');
shipTypeMap.set('B011', '\uC5B4\uC120(\uC790\uB9DD)');
shipTypeMap.set('B012', '\uC5B4\uC120(\uAD8C\uD604\uB9DD)');
shipTypeMap.set('B013', '\uC5B4\uC120(\uC6D0\uC591\uC5B4\uC120)');
shipTypeMap.set('B014', '\uC5B4\uC120(\uC6D0\uC591\uCC38\uCE58\uC5F0\uC2B9\uC5B4\uC5C5)');
shipTypeMap.set('B015', '\uC5B4\uC120(\uC6D0\uC591\uC120\uB9DD\uC5B4\uC5C5)');
shipTypeMap.set('B016', '\uC5B4\uC120(\uC6D0\uC591\uD2B8\uB864\uC5B4\uC5C5)');
shipTypeMap.set('B017', '\uC5B4\uC120(\uC6D0\uC591\uC800\uC778\uB9DD\uC5B4\uC5C5)');
shipTypeMap.set('B018', '\uC5B4\uC120(\uC6D0\uC591\uCC44\uB0DA\uAE30\uC5B4\uC5C5)');
shipTypeMap.set('B019', '\uC5B4\uC120(\uC6D0\uC591\uD1B5\uBC1C\uC5B4\uC5C5)');
shipTypeMap.set('B020', '\uC5B4\uC120(\uC6D0\uC591\uC800\uC5F0\uC2B9\uC5B4\uC5C5)');
shipTypeMap.set('B021', '\uC5B4\uC120(\uC6D0\uC591\uBD09\uC218\uB9DD\uC5B4\uC5C5)');
shipTypeMap.set('B022', '\uC5B4\uC120(\uC6D0\uC591\uBAA8\uC120\uC2DD\uC5B4\uC5C5)');
shipTypeMap.set('B023', '\uC5B4\uC120(\uC6D0\uC591\uC5B4\uC5C5\uC6B4\uBC18\uC120)');
shipTypeMap.set('C001', '\uD654\uBB3C\uC120(\uC77C\uBC18)');
shipTypeMap.set('C002', '\uD654\uBB3C\uC120(\uBC8C\uD06C\uC120)');
shipTypeMap.set('C003', '\uD654\uBB3C\uC120(\uC591\uACE1\uC6B4\uBC18\uC120)');
shipTypeMap.set('C004', '\uD654\uBB3C\uC120(\uC6D0\uBAA9\uC6B4\uBC18\uC120)');
shipTypeMap.set('C005', '\uD654\uBB3C\uC120(\uAD11\uBAA9\uC6B4\uBC18\uC120)');
shipTypeMap.set('C006', '\uD654\uBB3C\uC120(\uC11D\uD0C4\uC6B4\uBC18\uC120)');
shipTypeMap.set('C007', '\uD654\uBB3C\uC120(\uC2DC\uBA58\uD2B8\uC6B4\uBC18\uC120)');
shipTypeMap.set('C008', '\uD654\uBB3C\uC120(\uC790\uB3D9\uCC28\uC6B4\uBC18\uC120)');
shipTypeMap.set('C009', '\uD654\uBB3C\uC120(\uD56B\uCF54\uC77C\uC6B4\uBC18\uC120)');
shipTypeMap.set('C010', '\uD654\uBB3C\uC120(\uCCA0\uAC15\uC7AC\uC6B4\uBC18\uC120)');
shipTypeMap.set('C011', '\uD654\uBB3C\uC120(\uBAA8\uB798\uC6B4\uBC18\uC120)');
shipTypeMap.set('C012', '\uD654\uBB3C\uC120(\uD3D0\uAE30\uBB3C\uC6B4\uBC18\uC120)');
shipTypeMap.set('C013', '\uD654\uBB3C\uC120(\uCF54\uC77C\uC6B4\uBC18\uC120-RORO\uC120)');
shipTypeMap.set('C014', '\uD654\uBB3C\uC120(\uB0C9\uB3D9, \uB0C9\uC7A5\uC120)');
shipTypeMap.set('C015', '\uD654\uBB3C\uC120(\uCEE8\uD14C\uC774\uB108\uC120)');
shipTypeMap.set('C016', '\uD654\uBB3C\uC120(\uC6D0\uC720\uC6B4\uBC18\uC120)');
shipTypeMap.set('C017', '\uD654\uBB3C\uC120(\uC11D\uC720\uC81C\uD488 \uC6B4\uBC18\uC120)');
shipTypeMap.set('C018', '\uD654\uBB3C\uC120(\uAE30\uD0C0 \uC720\uC870\uC120)');
shipTypeMap.set('C019', '\uD654\uBB3C\uC120(\uCF00\uBBF8\uCE7C \uC6B4\uBC18\uC120)');
shipTypeMap.set('C020', '\uD654\uBB3C\uC120(\uCF00\uBBF8\uCE7C\uAC00\uC2A4 \uC6B4\uBC18\uC120)');
shipTypeMap.set('C021', '\uD654\uBB3C\uC120(LPG \uC6B4\uBC18\uC120)');
shipTypeMap.set('C022', '\uD654\uBB3C\uC120(LNG \uC6B4\uBC18\uC120)');
shipTypeMap.set('C023', '\uD654\uBB3C\uC120(\uC77C\uBC18\uD0F1\uCEE4)');
shipTypeMap.set('D001', '\uAD00\uACF5\uC120(\uC77C\uBC18)');
shipTypeMap.set('D002', '\uAD00\uACF5\uC120(\uD574\uACBD\uC815)');
shipTypeMap.set('D003', '\uAD00\uACF5\uC120(\uC870\uC0AC\uC120)');
shipTypeMap.set('D004', '\uAD00\uACF5\uC120(\uC9C0\uB3C4\uC120)');
shipTypeMap.set('D005', '\uAD00\uACF5\uC120(\uC2DC\uD5D8\uC120)');
shipTypeMap.set('D006', '\uAD00\uACF5\uC120(\uAD70\uC120)');
shipTypeMap.set('D007', '\uAD00\uACF5\uC120(\uD574\uACBD\uD56D\uACF5\uAE30)');
shipTypeMap.set('D008', '\uAD00\uACF5\uC120(\uBC29\uC81C\uC120)');
shipTypeMap.set('D009', '\uAD00\uACF5\uC120(\uC758\uB8CC\uC120)');
shipTypeMap.set('E001', '\uAE30\uD0C0(\uC77C\uBC18)');
shipTypeMap.set('E002', '\uAE30\uD0C0(\uC608\uC120-\uB3C4\uC120)');
shipTypeMap.set('E003', '\uAE30\uD0C0(\uC678\uAD6D\uC801\uC120)');
shipTypeMap.set('E004', '\uAE30\uD0C0(\uC720\uB958\uAE09\uC720\uAE09\uC218\uC120)');
shipTypeMap.set('E005', '\uAE30\uD0C0(\uD1B5\uC120)');
shipTypeMap.set('E006', '\uAE30\uD0C0(\uBD80\uC120)');
shipTypeMap.set('E007', '\uAE30\uD0C0(\uC2E0\uC870\uC120)');
shipTypeMap.set('E008', '\uAE30\uD0C0(\uD2B9\uC218\uBAA9\uC801\uC120)');
shipTypeMap.set('E009', '\uAE30\uD0C0(\uC900\uC124\uC120)');
shipTypeMap.set('E010', '\uAE30\uD0C0(\uC720\uCC3D\uCCAD\uC18C\uC120)');
shipTypeMap.set('E011', '\uAE30\uD0C0(\uC704\uADF8\uC120)');
shipTypeMap.set('E012', '\uAE30\uD0C0(\uC7A0\uC218\uC9C0\uC6D0\uC120)');
shipTypeMap.set('E013', '\uAE30\uD0C0(\uBC94\uC120)');
shipTypeMap.set('E014', '\uAE30\uD0C0(\uACE0\uC18D\uC120-HSC)');

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 10 KiB

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g stroke="#fff"><path d="M20.77 10.384H3.231V21h17.54zM6.57 12.562v6.153M9.286 12.562v6.153M12.001 12.562v6.153M14.717 12.562v6.153M17.433 12.562v6.153M4.165 10.384l7.893-4.3 7.78 4.3"/><path d="M13.672 5.276a1.848 1.848 0 1 1-1.69-1.098V2"/></g></svg>

After

Width:  |  Height:  |  크기: 368 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><path fill="#fff" d="M3.121 11.986v3.135h4.576v.979H2.021V7.19h5.566v.979H3.121v2.827h4.257v.99zM7.968 7.19h7.073v1.012h-2.98V16.1h-1.112V8.202H7.97zm10.824 9.075c-2.046 0-3.751-1.397-3.751-3.817v-1.606c0-2.42 1.694-3.817 3.75-3.817 1.816 0 3.092 1.067 3.52 2.805l-1.044.297c-.352-1.353-1.155-2.079-2.475-2.079-1.463 0-2.651.979-2.651 2.871v1.452c0 1.903 1.188 2.871 2.65 2.871 1.32 0 2.124-.726 2.476-2.079l1.045.297c-.43 1.749-1.705 2.805-3.52 2.805"/></svg>

After

Width:  |  Height:  |  크기: 575 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M0 0h32v32H0z" style="fill:#8b98c5;fill-opacity:1;stroke:none"/><path d="M3.015 16.084c.052 1.828.357 3.9 1.643 1.538M8.748 14.057V5s-7.655 1.42-5.707 9.056M2.994 16.084a.994.994 0 1 0-.002-1.988.994.994 0 0 0 .002 1.988Zm0 0" style="fill:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke:#fff;stroke-opacity:1;stroke-miterlimit:4" transform="scale(1.33333)"/><path d="M20.941 19.374H6.78l-.934-5.317h16.031ZM11.839 13.94l2.669-3.408h6.7v3.407M6.39 16.717H21.7" style="fill:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke:#fff;stroke-opacity:1;stroke-miterlimit:4" transform="scale(1.33333)"/></svg>

After

Width:  |  Height:  |  크기: 711 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g stroke="#fff"><path d="M14.023 13.27V8.59h5.753v4.68"/><path d="M14.166 10.229h2.825v1.509h-2.744M18.984 5.701v2.617M17.398 6.685v1.634M4.89 5v6.297M2.629 6.558h6.468M2.629 8.444h6.468M2 11.44l2.472 7.52h15.929l1.917-5.604H8.783l-.679-1.916zM3.824 16.13h17.38"/></g></svg>

After

Width:  |  Height:  |  크기: 390 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g stroke="#fff"><path d="M9.904 6.718 10.997 2h2.899l1.073 4.718M6.667 11.875v-5.05h11.541V12"/><path d="M6.8 21.538 4 13.303l8.343-3.865 8.531 3.73-3.146 8.37M12.437 9.704v11.488M4.507 21.698h15.86M8.14 3.92h8.596"/></g></svg>

After

Width:  |  Height:  |  크기: 343 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g fill="#fff"><path d="M21.59 13.753h-1.425l-1.356-3.34a.41.41 0 0 0-.38-.257H16.23l-1.257-3.094a.41.41 0 0 0-.38-.257h-1.547l-.63-1.548a.411.411 0 0 0-.761.308l.503 1.24H9.782a.41.41 0 0 0-.391.284l-.452 1.39H5.548a.41.41 0 0 0-.391.285l-.452 1.39h-.439a.41.41 0 0 0-.39.285L2.8 13.75h-.39a.412.412 0 0 0-.4.504c.035.151.86 3.703 1.177 4.502a.41.41 0 0 0 .382.26c.274 0 13.092.004 13.944 0 .123 0 .24-.057.318-.154.531-.66 1.316-1.147 2.075-1.618.887-.55 1.725-1.069 2.06-1.83a.4.4 0 0 0 .034-.166v-1.086a.41.41 0 0 0-.411-.41M5.847 9.303h3.391a.41.41 0 0 0 .391-.284l.452-1.39h4.235l1.027 2.528H5.57l.277-.853M4.565 10.98H18.15l.468 1.151h-4.84a.41.41 0 1 0 0 .822h5.174l.325.802H9.107l.41-1.259a.411.411 0 0 0-.782-.253l-.491 1.512H7.33l.409-1.259a.411.411 0 0 0-.782-.253l-.491 1.512h-.914l.41-1.259a.411.411 0 0 0-.782-.253l-.492 1.512H3.665l.902-2.775zm16.614 4.178c-.264.498-.965.932-1.706 1.392-.727.45-1.545.958-2.155 1.648-.882.004-13.112 0-13.457 0-.244-.774-.678-2.546-.933-3.621h18.25z"/><path d="M12.587 8.481h-1.185a.41.41 0 1 0 0 .822h1.185a.41.41 0 1 0 0-.822M17.165 15.469h-.661a.41.41 0 1 0 0 .822h.661a.41.41 0 1 0 0-.822"/></g></svg>

After

Width:  |  Height:  |  크기: 1.2 KiB

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#8B98C5" d="M0 0h24v24H0z"/><g stroke="#fff"><path d="M11.937 2.797S5.083 11.002 6.27 16.46c.432 1.983 2.103 4.74 5.667 4.743 1.958.002 4.937-.493 5.812-4.868.574-2.869-.854-7.063-5.812-13.538Z"/><path d="M15.812 14.417s0 4.317-3.203 4.817"/></g></svg>

After

Width:  |  Height:  |  크기: 339 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="34" fill="none"><path fill="#E85F1B" d="M16.758 4.608h10.05L22.64 8.02l4.168 3.514h-10.05"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M16.758 4.608h10.05L22.64 8.02l4.168 3.514h-10.05"/><path fill="#E85F1B" stroke="#000" d="M16.757 32a8.023 8.023 0 1 0 0-16.046 8.023 8.023 0 0 0 0 16.046Z"/><path fill="#fff" stroke="#000" d="M8.536 25.336c-.567 0-1.028-.555-1.028-1.238v-.245c0-.683.46-1.238 1.028-1.238H24.98c.568 0 1.028.554 1.028 1.238v.245c0 .682-.46 1.238-1.028 1.238z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M16.758 3v12.441"/></svg>

After

Width:  |  Height:  |  크기: 660 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#FF8B36" stroke="#B55006" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#FF87CF" stroke="#FF39B0" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#197419" stroke="#005909" stroke-linejoin="round" d="M16.339 2.84 9.877 28.321l7.022-7.327 7.458 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#5C1EE0" stroke="#450089" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="red" stroke="#A40000" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 195 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#0029FF" stroke="#000A62" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#B02A2A" stroke="#6C2100" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="34" fill="none"><path fill="#E85F1B" d="M16.758 4.608h10.05L22.64 8.02l4.168 3.514h-10.05"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M16.758 4.608h10.05L22.64 8.02l4.168 3.514h-10.05"/><path fill="#E85F1B" stroke="#000" d="M16.757 32a8.023 8.023 0 1 0 0-16.046 8.023 8.023 0 0 0 0 16.046Z"/><path fill="#fff" stroke="#000" d="M8.536 25.336c-.567 0-1.028-.555-1.028-1.238v-.245c0-.683.46-1.238 1.028-1.238H24.98c.568 0 1.028.554 1.028 1.238v.245c0 .682-.46 1.238-1.028 1.238z"/><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M16.758 3v12.441"/></svg>

After

Width:  |  Height:  |  크기: 660 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#FF8B36" stroke="#B55006" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#5C1EE0" stroke="#450089" stroke-linejoin="round" d="m16.985 2.84-6.717 25.482 7.299-7.327 7.753 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="none"><path fill="#197419" stroke="#005909" stroke-linejoin="round" d="M16.339 2.84 9.877 28.321l7.022-7.327 7.458 6.853z"/></svg>

After

Width:  |  Height:  |  크기: 199 B

237
src/common/stompClient.js Normal file
파일 보기

@ -0,0 +1,237 @@
/**
* STOMP WebSocket 클라이언트
* 참조: mda-react-front/src/common/stompClient.ts
* 참조: mda-react-front/src/map/MapUpdater.tsx
*/
import { Client } from '@stomp/stompjs';
import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants';
/**
* STOMP 클라이언트 인스턴스
* 환경변수: VITE_SIGNAL_WS (: ws://10.26.252.39:9090/connect)
*/
export const signalStompClient = new Client({
brokerURL: import.meta.env.VITE_SIGNAL_WS || 'ws://localhost:8080/connect',
reconnectDelay: 10000,
connectionTimeout: 5000,
debug: () => {
// STOMP 디버그 로그 비활성화 (너무 많은 로그 발생)
},
});
/**
* 파이프 구분 문자열을 배열로 파싱
* 메인 프로젝트 메시지 형식: "value1|value2|value3|..."
* @param {string} msgString - 파이프 구분 문자열
* @returns {Array} 파싱된 배열
*/
export function parsePipeMessage(msgString) {
return msgString.split('|');
}
/**
* 메시지 배열을 선박 객체로 변환
* 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget()
*
* @param {Array} row - 파싱된 메시지 배열 (38 요소)
* @returns {Object} 선박 데이터 객체
*/
export function rowToShipObject(row) {
const idx = SHIP_MSG_INDEX;
const targetId = row[idx.TARGET_ID] || '';
const originalTargetId = row[idx.ORIGINAL_TARGET_ID] || '';
const signalSourceCode = row[idx.SIGNAL_SOURCE_CODE] || '';
return {
// 고유 식별자 (signalSourceCode + originalTargetId)
featureId: signalSourceCode + originalTargetId,
// 기본 식별 정보
targetId, // 통합 TARGET_ID
originalTargetId, // 개별 장비 고유 TARGET_ID
signalSourceCode,
shipName: row[idx.SHIP_NAME] || '', // 선박명
shipType: row[idx.SHIP_TYPE] || '', // 선박 타입
// 위치 정보
longitude: parseFloat(row[idx.LONGITUDE]) || 0,
latitude: parseFloat(row[idx.LATITUDE]) || 0,
// 항해 정보
sog: parseFloat(row[idx.SOG]) || 0,
cog: parseFloat(row[idx.COG]) || 0,
// 시간 정보
receivedTime: row[idx.RECV_DATE_TIME] || '',
// 선종 코드
signalKindCode: row[idx.SIGNAL_KIND_CODE] || '',
// 상태 플래그
lost: row[idx.LOST] === '1',
integrate: row[idx.INTEGRATE] === '1',
isPriority: row[idx.IS_PRIORITY] === '1',
// 위험물 카테고리
hazardousCategory: row[idx.HAZARDOUS_CATEGORY] || '',
// 국적 코드
nationalCode: row[idx.NATIONAL_CODE] || '',
// IMO 번호
imo: row[idx.IMO] || '',
// 흘수
draught: row[idx.DRAUGHT] || '',
// 선박 크기 (DIM)
dimA: row[idx.DIM_A] || '',
dimB: row[idx.DIM_B] || '',
dimC: row[idx.DIM_C] || '',
dimD: row[idx.DIM_D] || '',
// AVETDR 신호장비 플래그
// 값: '1'=활성, '0'=비활성, ''=장비없음
ais: row[idx.AIS], // AIS
vpass: row[idx.VPASS], // V-Pass
enav: row[idx.ENAV], // E-Nav
vtsAis: row[idx.VTS_AIS], // VTS-AIS
dMfHf: row[idx.D_MF_HF], // D-MF/HF
vtsRadar: row[idx.VTS_RADAR], // VTS-Radar
// 다크시그널 판단은 shipStore에서 처리 (darkSignalIds Set + isAnyEquipmentActive)
// 장비 활성 플래그(ais, vpass, enav, vtsAis, dMfHf, vtsRadar)는 위에서 개별 저장
// 원본 배열 (상세정보 등에 필요)
_raw: row,
};
}
/**
* AVETDR 문자열 파싱
* @param {string} avetdr - "A_V_E_T_D_R" 형식 (0 또는 1)
* @returns {Object} 신호원 활성 상태
*/
export function parseAvetdr(avetdr) {
const parts = (avetdr || '0_0_0_0_0_0').split('_');
return {
A: parts[0] === '1', // AIS
V: parts[1] === '1', // VPASS
E: parts[2] === '1', // ENAV
T: parts[3] === '1', // VTS_AIS
D: parts[4] === '1', // D_MF_HF
R: parts[5] === '1', // RADAR
};
}
/**
* STOMP 연결 시작
* @param {Object} callbacks - 콜백 함수들
* @param {Function} callbacks.onConnect - 연결 성공
* @param {Function} callbacks.onDisconnect - 연결 해제
* @param {Function} callbacks.onError - 에러 발생
*/
export function connectStomp(callbacks = {}) {
const { onConnect, onDisconnect, onError } = callbacks;
signalStompClient.onConnect = (frame) => {
console.log('[STOMP] Connected');
onConnect?.(frame);
};
signalStompClient.onDisconnect = () => {
console.log('[STOMP] Disconnected');
onDisconnect?.();
};
signalStompClient.onStompError = (frame) => {
console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error');
onError?.(frame);
};
signalStompClient.activate();
}
/**
* STOMP 연결 해제
*/
export function disconnectStomp() {
if (signalStompClient.connected) {
signalStompClient.deactivate();
}
}
/**
* 선박 토픽 구독
* - 개발: /topic/ship ()
* - 프로덕션: /topic/ship-throttled-60s ( )
* @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열)
* @returns {Object} 구독 객체 (unsubscribe 호출용)
*/
export function subscribeShips(onMessage) {
// 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀)
// const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
const throttleSeconds = 60;
const topic = throttleSeconds > 0
? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s`
: STOMP_TOPICS.SHIP;
console.log(`[STOMP] Subscribing to ${topic}`);
return signalStompClient.subscribe(topic, (message) => {
try {
const body = message.body;
const lines = body.split('\n').filter(line => line.trim());
const ships = lines.map(line => {
const row = parsePipeMessage(line);
return rowToShipObject(row);
});
onMessage(ships);
} catch (error) {
console.error('[STOMP] Ship message parse error:', error);
}
});
}
/**
* 선박 삭제 토픽 구독
* @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId)
* @returns {Object} 구독 객체
*/
export function subscribeShipDelete(onDelete) {
console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`);
return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => {
try {
const body = message.body;
const [signalSourceCode, targetId] = body.split('|');
if (signalSourceCode && targetId) {
const featureId = signalSourceCode + targetId;
onDelete(featureId);
}
} catch (error) {
console.error('[STOMP] Ship delete parse error:', error);
}
});
}
/**
* 선박 카운트 토픽 구독
* @param {Function} onCount - 카운트 메시지 수신 콜백
* @returns {Object} 구독 객체
*/
export function subscribeShipCount(onCount) {
return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => {
try {
const counts = JSON.parse(message.body);
onCount(counts);
} catch (error) {
console.error('[STOMP] Count message parse error:', error);
}
});
}

파일 보기

@ -1,14 +1,27 @@
import { useState } from "react"
export default function ToolComponent() {
import useShipStore from '../../stores/shipStore';
export default function ToolComponent() {
const [isLegendOpen, setIsLegendOpen] = useState(false);
const { isIntegrate, toggleIntegrate } = useShipStore();
return(
<section id="tool">
{/* 툴바 */}
<div className="toolBar">
<ul className="toolItem space">
<li><button type="button" className="tool01">초기화</button></li>
<li><button type="button" className="tool02">선박통합</button></li>
<li>
<button
type="button"
className={`tool02 ${isIntegrate ? 'active' : ''}`}
onClick={() => {
console.log('[ToolComponent] 선박통합 버튼 클릭, current isIntegrate:', isIntegrate);
toggleIntegrate();
}}
title={isIntegrate ? '선박통합 ON' : '선박통합 OFF'}
>선박통합</button>
</li>
<li><button type="button" className="tool03">구역설정</button></li>
</ul>
<ul className="toolItem mt30">

파일 보기

@ -1,24 +1,129 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { Link, useNavigate } from "react-router-dom";
import Slider from '../../common/Slider';
import useShipStore from '../../../stores/shipStore';
import {
SIGNAL_SOURCE_CODE_AIS,
SIGNAL_SOURCE_CODE_ENAV,
SIGNAL_SOURCE_CODE_VPASS,
SIGNAL_SOURCE_CODE_VTS_AIS,
SIGNAL_SOURCE_CODE_D_MF_HF,
SIGNAL_SOURCE_CODE_RADAR,
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
NATIONAL_CODE_KR,
NATIONAL_CODE_CN,
NATIONAL_CODE_JP,
NATIONAL_CODE_KP,
NATIONAL_CODE_OTHER,
} from '../../../types/constants';
//
const SIGNAL_FILTERS = [
{ code: SIGNAL_SOURCE_CODE_AIS, label: 'AIS' },
{ code: SIGNAL_SOURCE_CODE_ENAV, label: 'E-NAV' },
{ code: SIGNAL_SOURCE_CODE_VPASS, label: 'V-PASS' },
{ code: SIGNAL_SOURCE_CODE_VTS_AIS, label: 'VTS_AIS' },
{ code: SIGNAL_SOURCE_CODE_D_MF_HF, label: 'D_MF_HF' },
{ code: SIGNAL_SOURCE_CODE_RADAR, label: 'VTS_RADAR' },
];
//
const KIND_FILTERS = [
{ code: SIGNAL_KIND_CODE_FISHING, label: '어선' },
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
{ code: SIGNAL_KIND_CODE_TANKER, label: '유조선' },
{ code: SIGNAL_KIND_CODE_GOV, label: '관공선' },
{ code: SIGNAL_KIND_CODE_KCGV, label: '함정' },
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
{ code: SIGNAL_KIND_CODE_BUOY, label: '어망/부이' },
];
//
const NATIONAL_FILTERS = [
{ code: NATIONAL_CODE_KR, label: '한국' },
{ code: NATIONAL_CODE_CN, label: '중국' },
{ code: NATIONAL_CODE_JP, label: '일본' },
{ code: NATIONAL_CODE_KP, label: '북한' },
{ code: NATIONAL_CODE_OTHER, label: '기타' },
];
export default function DisplayComponent({ isOpen, onToggle }) {
const navigate = useNavigate();
//
const {
sourceVisibility,
kindVisibility,
nationalVisibility,
darkSignalVisible,
darkSignalCount,
toggleSourceVisibility,
toggleKindVisibility,
toggleNationalVisibility,
toggleDarkSignalVisible,
clearDarkSignals,
} = useShipStore();
//
const [opacity, setOpacity] = useState(70);
//
const [isAccordionOpen1, setIsAccordionOpen1] = useState(true); //
const [isAccordionOpen2, setIsAccordionOpen2] = useState(true); //
const [isAccordionOpen3, setIsAccordionOpen3] = useState(true); //
const [isAccordionOpen4, setIsAccordionOpen4] = useState(false); //
const [isAccordionOpen1, setIsAccordionOpen1] = useState(true); //
const [isAccordionOpen2, setIsAccordionOpen2] = useState(true); //
const [isAccordionOpen3, setIsAccordionOpen3] = useState(true); //
const [isAccordionOpen4, setIsAccordionOpen4] = useState(false); // AI
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
const toggleAccordion3 = () => setIsAccordionOpen3(prev => !prev);
const toggleAccordion4 = () => setIsAccordionOpen4(prev => !prev);
// On/Off
const isAllSignalsOn = SIGNAL_FILTERS.every(f => sourceVisibility[f.code]);
const toggleAllSignals = useCallback(() => {
SIGNAL_FILTERS.forEach(f => {
if (isAllSignalsOn) {
//
if (sourceVisibility[f.code]) toggleSourceVisibility(f.code);
} else {
//
if (!sourceVisibility[f.code]) toggleSourceVisibility(f.code);
}
});
}, [isAllSignalsOn, sourceVisibility, toggleSourceVisibility]);
// On/Off
const isAllKindsOn = KIND_FILTERS.every(f => kindVisibility[f.code]);
const toggleAllKinds = useCallback(() => {
KIND_FILTERS.forEach(f => {
if (isAllKindsOn) {
if (kindVisibility[f.code]) toggleKindVisibility(f.code);
} else {
if (!kindVisibility[f.code]) toggleKindVisibility(f.code);
}
});
}, [isAllKindsOn, kindVisibility, toggleKindVisibility]);
// On/Off
const isAllNationalsOn = NATIONAL_FILTERS.every(f => nationalVisibility[f.code]);
const toggleAllNationals = useCallback(() => {
NATIONAL_FILTERS.forEach(f => {
if (isAllNationalsOn) {
if (nationalVisibility[f.code]) toggleNationalVisibility(f.code);
} else {
if (!nationalVisibility[f.code]) toggleNationalVisibility(f.code);
}
});
}, [isAllNationalsOn, nationalVisibility, toggleNationalVisibility]);
//
const [activeTab, setActiveTab] = useState('filter');
@ -50,140 +155,134 @@ export default function DisplayComponent({ isOpen, onToggle }) {
<div className="tabWrapInner">
<div className="tabWrapCnt">
{/* 스위치그룹 01 */}
{/* 스위치그룹 01 - 신호 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>신호</span>
<label className="switch"> <input type="checkbox" aria-label="신호"/> <span></span></label>
<label className="switch">
<input
type="checkbox"
aria-label="신호"
checked={isAllSignalsOn}
onChange={toggleAllSignals}
/>
<span></span>
</label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen1 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen1}
onClick={toggleAccordion1}
></button>
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen1 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>AIS</span>
<label className="switch sm"> <input type="checkbox" aria-label="AIS" /> <span></span></label>
</li>
<li>
<span>V-PASS</span>
<label className="switch sm"> <input type="checkbox" aria-label="V-PASS" /> <span></span></label>
</li>
<li>
<span>VTS_AIS</span>
<label className="switch sm"> <input type="checkbox" aria-label="VTS_AIS" /> <span></span></label>
</li>
<li>
<span>D_MF_HF</span>
<label className="switch sm"> <input type="checkbox" aria-label="D_MF_HF" /> <span></span></label>
</li>
<li>
<span>VTS_RADAR</span>
<label className="switch sm"> <input type="checkbox" aria-label="VTS_RADAR" /> <span></span></label>
</li>
</ul>
<ul className="switchList">
{SIGNAL_FILTERS.map(({ code, label }) => (
<li key={code}>
<span>{label}</span>
<label className="switch sm">
<input
type="checkbox"
aria-label={label}
checked={sourceVisibility[code] || false}
onChange={() => toggleSourceVisibility(code)}
/>
<span></span>
</label>
</li>
))}
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 02 */}
{/* 스위치그룹 02 - 선종 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>선종/기종</span>
<label className="switch"> <input type="checkbox" aria-label="선종/기종" /> <span></span></label>
<label className="switch">
<input
type="checkbox"
aria-label="선종/기종"
checked={isAllKindsOn}
onChange={toggleAllKinds}
/>
<span></span>
</label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen2 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen2}
onClick={toggleAccordion2}
></button>
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen2 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>어선</span>
<label className="switch sm"> <input type="checkbox" aria-label="어선" /> <span></span></label>
</li>
<li>
<span>여객선</span>
<label className="switch sm"> <input type="checkbox" aria-label="여객선" /> <span></span></label>
</li>
<li>
<span>화물선</span>
<label className="switch sm"> <input type="checkbox" aria-label="화물선" /> <span></span></label>
</li>
<li>
<span>유조선</span>
<label className="switch sm"> <input type="checkbox" aria-label="유조선" /> <span></span></label>
</li>
<li>
<span>관공선</span>
<label className="switch sm"> <input type="checkbox" aria-label="관공선" /> <span></span></label>
</li>
<li>
<span>함정</span>
<label className="switch sm"> <input type="checkbox" aria-label="함정" /> <span></span></label>
</li>
<li>
<span>항공기</span>
<label className="switch sm"> <input type="checkbox" aria-label="항공기" /> <span></span></label>
</li>
<li>
<span>기타</span>
<label className="switch sm"> <input type="checkbox" aria-label="기타" /> <span></span></label>
</li>
</ul>
<ul className="switchList">
{KIND_FILTERS.map(({ code, label }) => (
<li key={code}>
<span>{label}</span>
<label className="switch sm">
<input
type="checkbox"
aria-label={label}
checked={kindVisibility[code] || false}
onChange={() => toggleKindVisibility(code)}
/>
<span></span>
</label>
</li>
))}
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 03 */}
{/* 스위치그룹 03 - 국적 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>국적</span>
<label className="switch"> <input type="checkbox" aria-label="국적" /> <span></span></label>
<label className="switch">
<input
type="checkbox"
aria-label="국적"
checked={isAllNationalsOn}
onChange={toggleAllNationals}
/>
<span></span>
</label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen3 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen3}
onClick={toggleAccordion3}
></button>
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen3 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>한국</span>
<label className="switch sm"> <input type="checkbox" aria-label="한국" /> <span></span></label>
</li>
<li>
<span>중국</span>
<label className="switch sm"> <input type="checkbox" aria-label="중국" /> <span></span></label>
</li>
<li>
<span>일본</span>
<label className="switch sm"> <input type="checkbox" aria-label="일본" /> <span></span></label>
</li>
<li>
<span>북한</span>
<label className="switch sm"> <input type="checkbox" aria-label="북한" /> <span></span></label>
</li>
<li>
<span>기타</span>
<label className="switch sm"> <input type="checkbox" aria-label="기타" /> <span></span></label>
</li>
</ul>
<ul className="switchList">
{NATIONAL_FILTERS.map(({ code, label }) => (
<li key={code}>
<span>{label}</span>
<label className="switch sm">
<input
type="checkbox"
aria-label={label}
checked={nationalVisibility[code] || false}
onChange={() => toggleNationalVisibility(code)}
/>
<span></span>
</label>
</li>
))}
</ul>
</div>
{/* 여기까지 */}
</div>
@ -233,13 +332,32 @@ export default function DisplayComponent({ isOpen, onToggle }) {
{/* 여기까지 */}
</div>
{/* 스위치그룹 05 */}
{/* 스위치그룹 05 - 다크시그널 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>다크시그널</span>
{darkSignalCount > 0 && <span className="count">({darkSignalCount})</span>}
{darkSignalCount > 0 && (
<button
type="button"
className="btnDelDark"
onClick={clearDarkSignals}
title="다크시그널 삭제"
>
삭제
</button>
)}
</div>
<label className="switch"> <input type="checkbox" aria-label="다크시그널" /> <span></span></label>
<label className="switch">
<input
type="checkbox"
aria-label="다크시그널"
checked={darkSignalVisible}
onChange={toggleDarkSignalVisible}
/>
<span></span>
</label>
</div>
</div>

파일 보기

@ -0,0 +1,42 @@
import { Link } from 'react-router-dom';
/**
* 헤더 컴포넌트
* - 로고, 알람, 설정(드롭다운), 마이페이지
*/
export default function Header() {
return (
<header id="header">
<div className="logoArea">
<Link to="/main" className="logo">
<span className="blind">GIS 함정용</span>
</Link>
<span className="logoTxt">GIS 함정용</span>
</div>
<aside>
<ul>
<li>
<Link to="/main" className="alram" title="알람">
<i className="badge"></i>
<span className="blind">알람</span>
</Link>
</li>
<li className="setWrap">
<Link to="/signal" className="set" title="설정">
<span className="blind">설정</span>
</Link>
<div className="setMenu">
<Link to="/signal">신호설정</Link>
<Link to="/signal/custom">맞춤설정</Link>
</div>
</li>
<li>
<Link to="/mypage" className="user" title="마이페이지">
<span className="blind">마이페이지</span>
</Link>
</li>
</ul>
</aside>
</header>
);
}

파일 보기

@ -0,0 +1,24 @@
import Header from './Header';
import Sidebar from './Sidebar';
import MapContainer from '../../map/MapContainer';
import ToolBar from './ToolBar';
/**
* 메인 레이아웃
* - Header: 상단 헤더
* - Sidebar: 좌측 사이드바 (네비게이션 + 패널)
* - MapContainer: 중앙 지도 영역
* - ToolBar: 우측 툴바
*/
export default function MainLayout() {
return (
<div id="wrap" className="wrap">
<Header />
<Sidebar />
<main id="main">
<MapContainer />
</main>
<ToolBar />
</div>
);
}

파일 보기

@ -0,0 +1,76 @@
/**
* 사이드 네비게이션 메뉴
* - 퍼블리시 NavComponent 구조와 동일하게 맞춤
*/
const gnbList = [
{ key: 'gnb1', className: 'gnb1', label: '선박', path: 'ship' },
{ key: 'gnb2', className: 'gnb2', label: '위성', path: 'satellite' },
{ key: 'gnb3', className: 'gnb3', label: '기상', path: 'weather' },
{ key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' },
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
{ key: 'gnb6', className: 'gnb6', label: 'AI모드', path: 'ai' },
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
{ key: 'gnb8', className: 'gnb8', label: '항적조회', path: 'tracking' },
];
const sideList = [
{ key: 'filter', className: 'filter', label: '필터', path: 'filter' },
{ key: 'layer', className: 'layer', label: '레이어', path: 'layer' },
];
export default function SideNav({ activeKey, onChange }) {
return (
<nav id="nav">
<ul className="gnb">
{gnbList.map((item) => (
<li key={item.key}>
<button
type="button"
className={`${item.className} ${activeKey === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
aria-label={item.label}
title={item.label}
>
<span className="blind">{item.label}</span>
</button>
</li>
))}
</ul>
<ul className="side">
{sideList.map((item) => (
<li key={item.key}>
<button
type="button"
className={`${item.className} ${activeKey === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
aria-label={item.label}
title={item.label}
>
<span className="blind">{item.label}</span>
</button>
</li>
))}
</ul>
</nav>
);
}
// - export (Sidebar )
export const keyToPath = {
gnb1: 'ship',
gnb2: 'satellite',
gnb3: 'weather',
gnb4: 'analysis',
gnb5: 'timeline',
gnb6: 'ai',
gnb7: 'replay',
gnb8: 'tracking',
filter: 'filter',
layer: 'layer',
};
export const pathToKey = Object.fromEntries(
Object.entries(keyToPath).map(([k, v]) => [v, k])
);

파일 보기

@ -0,0 +1,86 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import SideNav, { keyToPath, pathToKey } from './SideNav';
//
import Panel1Component from '../../publish/pages/Panel1Component';
import Panel2Component from '../../publish/pages/Panel2Component';
import Panel3Component from '../../publish/pages/Panel3Component';
import Panel4Component from '../../publish/pages/Panel4Component';
import Panel5Component from '../../publish/pages/Panel5Component';
import Panel6Component from '../../publish/pages/Panel6Component';
import Panel7Component from '../../publish/pages/Panel7Component';
import Panel8Component from '../../publish/pages/Panel8Component';
// DisplayComponent
import DisplayComponent from '../../component/wrap/side/DisplayComponent';
/**
* 사이드바 컴포넌트
* - 네비게이션 메뉴
* - 패널 영역 (경로에 따라 다른 패널 표시)
*/
export default function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const [isPanelOpen, setIsPanelOpen] = useState(true);
// URL
const getActiveKey = () => {
const path = location.pathname.split('/')[1] || 'ship';
return pathToKey[path] || 'gnb1';
};
const activeKey = getActiveKey();
const handleMenuChange = (key) => {
setIsPanelOpen(true);
const path = keyToPath[key] || 'ship';
navigate(`/${path}`);
};
const handleTogglePanel = () => {
setIsPanelOpen((prev) => !prev);
};
// props
const panelProps = {
isOpen: isPanelOpen,
onToggle: handleTogglePanel,
};
//
const renderPanel = () => {
switch (activeKey) {
case 'gnb1':
return <Panel1Component {...panelProps} />;
case 'gnb2':
return <Panel2Component {...panelProps} />;
case 'gnb3':
return <Panel3Component {...panelProps} />;
case 'gnb4':
return <Panel4Component {...panelProps} />;
case 'gnb5':
return <Panel5Component {...panelProps} />;
case 'gnb6':
return <Panel6Component {...panelProps} />;
case 'gnb7':
return <Panel7Component {...panelProps} />;
case 'gnb8':
return <Panel8Component {...panelProps} />;
case 'filter':
case 'layer':
return <DisplayComponent {...panelProps} />;
default:
return <Panel1Component {...panelProps} />;
}
};
return (
<section id="sidePanel">
<SideNav activeKey={activeKey} onChange={handleMenuChange} />
<div className="sidePanelContent">
{renderPanel()}
</div>
</section>
);
}

파일 보기

@ -0,0 +1,201 @@
import { useState, useCallback } from 'react';
import { useMapStore } from '../../stores/mapStore';
import useShipStore from '../../stores/shipStore';
import { downloadShipCsv } from '../../utils/csvDownload';
//
const AREA_SHAPES = [
{ key: 'Box', label: '사각형' },
{ key: 'Polygon', label: '다각형' },
{ key: 'Circle', label: '원형' },
];
/**
* 우측 툴바 컴포넌트
* - 지도 도구 (초기화, 거리, 면적 )
* - 선명표시 (호버 패널)
* - 컨트롤
* - 범례, 미니맵
*/
export default function ToolBar() {
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState(false);
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState(false);
const { zoom, zoomIn, zoomOut, activeMeasureTool, setMeasureTool, setAreaShape } = useMapStore();
const {
isIntegrate,
toggleIntegrate,
showLabels,
toggleShowLabels,
labelOptions,
toggleLabelOption,
showLegend,
toggleShowLegend,
} = useShipStore();
//
const labelOptionList = [
{ key: 'showShipName', label: '선박명' },
{ key: 'showSpeedVector', label: '속도벡터' },
{ key: 'showShipSize', label: '선박크기' },
{ key: 'showSignalStatus', label: '신호상태' },
];
return (
<section id="tool">
{/* 툴바 */}
<div className="toolBar">
<ul className="toolItem space">
<li><button type="button" className="tool01">초기화</button></li>
<li>
<button
type="button"
className={`tool02 ${isIntegrate ? 'active' : ''}`}
onClick={toggleIntegrate}
title={isIntegrate ? '선박통합 ON' : '선박통합 OFF'}
>선박통합</button>
</li>
{/* 선명표시 버튼 + 호버 패널 영역 */}
<li
className="label-panel-wrap"
onMouseEnter={() => setIsLabelPanelOpen(true)}
onMouseLeave={() => setIsLabelPanelOpen(false)}
>
<button
type="button"
className={`tool02 ${showLabels ? 'active' : ''}`}
onClick={toggleShowLabels}
title={showLabels ? '선명표시 ON' : '선명표시 OFF'}
>선명표시</button>
{/* 선명표시 호버 패널 */}
{isLabelPanelOpen && (
<div className="labelPanel">
<div className="label-panel-inner">
<div className="label-panel-title">
선명표시 옵션
</div>
<ul className="label-panel-list">
{labelOptionList.map(({ key, label }) => (
<li key={key} className="label-panel-item">
<span>{label}</span>
<label className="switch sm">
<input
type="checkbox"
checked={labelOptions[key] || false}
onChange={() => toggleLabelOption(key)}
/>
<span></span>
</label>
</li>
))}
</ul>
</div>
</div>
)}
</li>
<li><button type="button" className="tool03">구역설정</button></li>
</ul>
<ul className="toolItem mt30">
<li>
<button
type="button"
className={`tool04 ${activeMeasureTool === 'distance' ? 'active' : ''}`}
onClick={() => setMeasureTool('distance')}
title="거리 측정"
>거리</button>
</li>
<li
className="label-panel-wrap"
onMouseEnter={() => activeMeasureTool === 'area' && setIsAreaPanelOpen(true)}
onMouseLeave={() => setIsAreaPanelOpen(false)}
>
<button
type="button"
className={`tool05 ${activeMeasureTool === 'area' ? 'active' : ''}`}
onClick={() => {
setMeasureTool('area');
setIsAreaPanelOpen(true);
}}
title="면적 측정"
>면적</button>
{activeMeasureTool === 'area' && isAreaPanelOpen && (
<div className="labelPanel">
<div className="label-panel-inner">
<div className="label-panel-title">도형 선택</div>
<ul className="label-panel-list">
{AREA_SHAPES.map(({ key, label }) => (
<li
key={key}
className="label-panel-item area-shape-item"
onClick={() => {
setAreaShape(key);
setIsAreaPanelOpen(false);
}}
>
<span>{label}</span>
</li>
))}
</ul>
</div>
</div>
)}
</li>
<li>
<button
type="button"
className={`tool06 ${activeMeasureTool === 'rangeRing' ? 'active' : ''}`}
onClick={() => setMeasureTool('rangeRing')}
title="거리환 측정"
>거리환</button>
</li>
</ul>
<ul className="toolItem space mt30">
<li><button type="button" className="tool07">인쇄</button></li>
<li>
<button
type="button"
className="tool08"
onClick={async () => {
const ships = useShipStore.getState().getDownloadShips();
if (ships.length === 0) {
alert('다운로드할 선박 데이터가 없습니다.');
return;
}
await downloadShipCsv(ships);
}}
>다운로드</button>
</li>
</ul>
</div>
{/* 맵컨트롤 툴바 */}
<div className="control">
<ul className="toolItem zoom">
<li>
<button type="button" className="zoomin" title="확대" onClick={zoomIn}>
<span className="blind">확대</span>
</button>
</li>
<li className="num">{zoom}</li>
<li>
<button type="button" className="zoomout" title="축소" onClick={zoomOut}>
<span className="blind">축소</span>
</button>
</li>
</ul>
<ul className="toolItem space mt30">
<li>
<button
type="button"
className={`legend ${showLegend ? 'active' : ''}`}
onClick={toggleShowLegend}
title={showLegend ? '범례 숨기기' : '범례 표시'}
>
범례
</button>
</li>
<li><button type="button" className="minimap">미니맵</button></li>
</ul>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,82 @@
/**
* 선박 우클릭 컨텍스트 메뉴
* - 단일 선박 우클릭: 해당 선박 메뉴
* - Ctrl+Drag 선택 우클릭: 선택된 선박 전체 메뉴
*/
import { useEffect, useRef, useCallback } from 'react';
import useShipStore from '../../stores/shipStore';
import './ShipContextMenu.scss';
const MENU_ITEMS = [
{ key: 'track', label: '항적조회' },
{ key: 'analysis', label: '항적분석' },
{ key: 'detail', label: '상세정보' },
];
export default function ShipContextMenu() {
const contextMenu = useShipStore((s) => s.contextMenu);
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
const menuRef = useRef(null);
//
useEffect(() => {
if (!contextMenu) return;
const handleClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeContextMenu();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, closeContextMenu]);
//
const handleAction = useCallback((key) => {
if (!contextMenu) return;
const { ships } = contextMenu;
// TODO: API
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
featureId: s.featureId,
shipName: s.shipName,
targetId: s.targetId,
})));
closeContextMenu();
}, [contextMenu, closeContextMenu]);
if (!contextMenu) return null;
const { x, y, ships } = contextMenu;
//
const menuWidth = 160;
const menuHeight = MENU_ITEMS.length * 36 + 40; // +
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
const title = ships.length === 1
? (ships[0].shipName || ships[0].featureId)
: `${ships.length}척 선택`;
return (
<div
ref={menuRef}
className="ship-context-menu"
style={{ left: adjustedX, top: adjustedY }}
>
<div className="ship-context-menu__header">{title}</div>
{MENU_ITEMS.map((item) => (
<div
key={item.key}
className="ship-context-menu__item"
onClick={() => handleAction(item.key)}
>
{item.label}
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,34 @@
.ship-context-menu {
position: fixed;
z-index: 1000;
background: #1a1a2e;
border: 1px solid #3a3a5e;
border-radius: 4px;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
user-select: none;
&__header {
padding: 6px 12px;
font-size: 12px;
color: #aaa;
border-bottom: 1px solid #3a3a5e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
&__item {
padding: 8px 12px;
font-size: 13px;
color: #ddd;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #2a2a4e;
color: #fff;
}
}
}

파일 보기

@ -0,0 +1,325 @@
/**
* 선박 상세 모달 컴포넌트 (다중 모달 지원, 최대 3)
* 퍼블리시 ShipComponent.jsx의 popupMap shipInfo 구조 활용
* 참조: mda-react-front/src/components/popup/ShipDetailModal.tsx
*
* - 헤더 드래그로 위치 이동 가능
* - 선박 사진 갤러리 (없으면 기본 이미지)
* - 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성
*/
import { useRef, useState, useCallback, useEffect } from 'react';
import useShipStore from '../../stores/shipStore';
import {
SHIP_KIND_LABELS,
SIGNAL_FLAG_CONFIGS,
SPEED_THRESHOLD,
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
} from '../../types/constants';
import defaultShipImg from '../../assets/img/default-ship.png';
import fishingIcon from '../../assets/img/shipDetail/detailKindIcon/fishing.svg';
import kcgvIcon from '../../assets/img/shipDetail/detailKindIcon/kcgv.svg';
import passengerIcon from '../../assets/img/shipDetail/detailKindIcon/passenger.svg';
import cargoIcon from '../../assets/img/shipDetail/detailKindIcon/cargo.svg';
import tankerIcon from '../../assets/img/shipDetail/detailKindIcon/tanker.svg';
import govIcon from '../../assets/img/shipDetail/detailKindIcon/gov.svg';
import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg';
import './ShipDetailModal.scss';
/** 선종코드 → 아이콘 매핑 */
const SHIP_KIND_ICONS = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passengerIcon,
[SIGNAL_KIND_CODE_CARGO]: cargoIcon,
[SIGNAL_KIND_CODE_TANKER]: tankerIcon,
[SIGNAL_KIND_CODE_GOV]: govIcon,
};
/** 선종 아이콘 URL 반환 */
function getShipKindIcon(signalKindCode) {
return SHIP_KIND_ICONS[signalKindCode] || etcIcon;
}
/**
* 국기 아이콘 URL 반환 (서버 API)
* 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag()
* @param {string} nationalCode - MID 숫자코드 (: '440', '412')
* @returns {string} 국기 이미지 URL
*/
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}/ship/image/small/${nationalCode}.svg`;
}
/**
* receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환
* 입력 : '20241123112300' 또는 '2024-11-23 11:23:00' 또는 '2024-11-23T11:23:00'
* @param {string} raw
* @returns {string}
*/
function formatDateTime(raw) {
if (!raw) return '-';
// YYYY-MM-DD HH:mm:ss
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
return raw;
}
// ( )
const digits = raw.replace(/\D/g, '');
if (digits.length >= 14) {
const y = digits.slice(0, 4);
const M = digits.slice(4, 6);
const d = digits.slice(6, 8);
const h = digits.slice(8, 10);
const m = digits.slice(10, 12);
const s = digits.slice(12, 14);
return `${y}-${M}-${d} ${h}:${m}:${s}`;
}
//
return raw;
}
/**
* AVETDR 신호 플래그 표시
*/
function SignalFlags({ ship }) {
const isIntegrate = useShipStore((s) => s.isIntegrate);
const isIntegratedShip = ship.targetId && ship.targetId.includes('_');
const useIntegratedMode = isIntegrate && isIntegratedShip;
return (
<ul className="shipTypeIco">
{SIGNAL_FLAG_CONFIGS.map((config) => {
let isActive = false;
let isVisible = false;
if (useIntegratedMode) {
const val = ship[config.dataKey];
if (val === '1') { isVisible = true; isActive = true; }
else if (val === '0') { isVisible = true; }
} else {
if (config.signalSourceCode === ship.signalSourceCode) {
isVisible = true;
isActive = true;
}
}
if (!isVisible) return null;
return (
<li
key={config.key}
className={isActive ? 'active' : 'inactive'}
style={{ backgroundColor: isActive ? config.activeColor : config.inactiveColor }}
>
{config.key}
</li>
);
})}
</ul>
);
}
/**
* 선박 사진 갤러리
* 이미지가 없으면 기본 이미지(default-ship.png) 표시
*/
function ShipGallery({ imageUrlList }) {
const [currentIndex, setCurrentIndex] = useState(0);
const hasImages = imageUrlList && imageUrlList.length > 0;
const images = hasImages ? imageUrlList : [defaultShipImg];
const total = images.length;
const canSlide = total > 1;
const handlePrev = useCallback(() => {
setCurrentIndex((prev) => (prev === 0 ? total - 1 : prev - 1));
}, [total]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1));
}, [total]);
const handleIndicatorClick = useCallback((index) => {
setCurrentIndex(index);
}, []);
return (
<div className="pmGallery">
{canSlide && (
<>
<button type="button" className="navBtn prev" onClick={handlePrev}>
<span className="blind">이전</span>
</button>
<button type="button" className="navBtn next" onClick={handleNext}>
<span className="blind">다음</span>
</button>
</>
)}
<div className="galleryView">
<img
className="galleryImg"
src={images[currentIndex]}
alt="선박 이미지"
onError={(e) => { e.target.src = defaultShipImg; }}
/>
</div>
{canSlide && (
<div className="galleryIndicators">
{images.map((_, i) => (
<button
type="button"
key={i}
className={`indicator${i === currentIndex ? ' active' : ''}`}
onClick={() => handleIndicatorClick(i)}
aria-label={`이미지 ${i + 1}`}
/>
))}
</div>
)}
</div>
);
}
/**
* 단일 선박 상세 모달
* @param {Object} props.modal - { ship, id, initialPos }
*/
export default function ShipDetailModal({ modal }) {
const closeDetailModal = useShipStore((s) => s.closeDetailModal);
const updateModalPos = useShipStore((s) => s.updateModalPos);
// - initialPos
const [position, setPosition] = useState(() => ({ ...modal.initialPos }));
const posRef = useRef(modal.initialPos);
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
//
const handleMouseDown = useCallback((e) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
e.preventDefault();
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
};
posRef.current = newPos;
setPosition(newPos);
};
const handleMouseUp = () => {
if (dragging.current) {
dragging.current = false;
// (ref render setState )
updateModalPos(modal.id, posRef.current);
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [modal.id, updateModalPos]);
const { ship, id } = modal;
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타';
const sog = Number(ship.sog) || 0;
const cog = Number(ship.cog) || 0;
const isMoving = sog > SPEED_THRESHOLD;
const draught = ship.draught ? `${(Number(ship.draught) / 10).toFixed(1)}m` : '-';
const formattedTime = formatDateTime(ship.receivedTime);
return (
<div
className="popupMap shipInfo ship-detail-modal"
style={{
left: position.x,
top: position.y,
transform: 'none',
}}
>
{/* header - 드래그 핸들 */}
<div className="pmHeader" onMouseDown={handleMouseDown}>
<div className="rowL">
<span className="shipTypeIcon">
<img src={getShipKindIcon(ship.signalKindCode)} alt={kindLabel} />
</span>
{ship.nationalCode && (
<span className="countryFlag">
<img
src={getNationalFlagUrl(ship.nationalCode)}
alt="국기"
onError={(e) => { e.target.style.display = 'none'; }}
/>
</span>
)}
<span className="shipName" title={ship.shipName || ''}>{ship.shipName || '-'}</span>
<span className="shipNum" title={ship.originalTargetId || ''}>{ship.originalTargetId || '-'}</span>
</div>
<button
type="button"
className="pmClose"
aria-label="닫기"
onClick={() => closeDetailModal(id)}
/>
</div>
{/* gallery */}
<ShipGallery imageUrlList={ship.imageUrlList} />
{/* body */}
<div className="pmBody">
<div className="shipAction">
<div className="rowL">
<button type="button" className="detailBtn">상세정보</button>
<SignalFlags ship={ship} />
</div>
</div>
<ul className="shipStatus">
<li className="status">
<div className="statusItem">
<span className="statusLabel">선박상태</span>
<span className="statusValue">{isMoving ? '항해' : '정박'}</span>
</div>
<div className="statusItem w13r">
<span className="statusLabel">속도/항로</span>
<span className="statusValue">{sog.toFixed(1)} kn / {cog.toFixed(1)}&deg;</span>
</div>
<div className="statusItem">
<span className="statusLabel">흘수</span>
<span className="statusValue">{draught}</span>
</div>
</li>
</ul>
<div className="btnWrap">
<button type="button" className="trackBtn">항적조회</button>
<button type="button" className="trackBtn">항로예측</button>
</div>
</div>
{/* footer */}
<div className="pmFooter">데이터 수신시간 : {formattedTime}</div>
</div>
);
}

파일 보기

@ -0,0 +1,203 @@
/**
* 선박 상세 모달 스타일
* public/css/common.css의 .popupMap 스타일을 재사용하면서 추가 스타일 정의
*/
.ship-detail-modal {
// common.css의 .popupMap 기본 스타일 오버라이드
// transform은 인라인으로 none 지정 (드래그 위치 직접 제어)
// common.css의 .popupMap > .pmHeader > .rowL 보다 높은 specificity 필요
&.popupMap > .pmHeader {
cursor: grab;
user-select: none;
&:active {
cursor: grabbing;
}
> .rowL {
min-width: 0;
flex: 1;
overflow: hidden;
}
> .pmClose {
flex-shrink: 0;
}
.shipTypeIcon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
img {
width: 2.8rem;
height: 2.8rem;
}
}
.countryFlag {
display: inline-flex;
align-items: center;
flex-shrink: 0;
img {
width: 2.4rem;
height: 1.7rem;
object-fit: cover;
border-radius: 2px;
}
}
.shipName {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shipNum {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.shipTypeIco {
display: flex;
gap: 2px;
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
color: #fff;
&.inactive {
opacity: 0.5;
}
}
}
// 상세정보 버튼과 신호 아이콘 사이 간격
.shipAction .rowL {
gap: 8px;
}
// 속도/항로 영역: 중앙 컬럼 넓히고 줄바꿈 방지
.shipStatus li.status {
grid-template-columns: 5.5rem 1fr 5.5rem;
.statusItem {
white-space: nowrap;
}
}
// 갤러리: 이미지 없을 때도 프레임 유지
.pmGallery {
position: relative;
overflow: hidden;
width: 29rem;
height: 10.6rem;
background-color: var(--gray-scale2, #2a2d35);
.galleryView {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// 좌우 슬라이드 버튼
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 24px;
height: 40px;
border: none;
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
padding: 0;
&:hover {
background: rgba(0, 0, 0, 0.7);
}
&.prev {
left: 0;
border-radius: 0 3px 3px 0;
&::after {
content: '';
font-size: 18px;
line-height: 1;
}
}
&.next {
right: 0;
border-radius: 3px 0 0 3px;
&::after {
content: '';
font-size: 18px;
line-height: 1;
}
}
}
// 하단 인디케이터 (클릭으로 바로가기)
.galleryIndicators {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
z-index: 2;
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
padding: 0;
background: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.7);
}
&.active {
background: #fff;
transform: scale(1.2);
}
}
}
}
}

파일 보기

@ -0,0 +1,139 @@
/**
* 선박 범례 컴포넌트
* - 선박 종류별 아이콘 카운트 표시
* - 선박 표시 On/Off 토글
* - 선박 종류별 필터 토글
*/
import { memo } from 'react';
import useShipStore from '../../stores/shipStore';
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
} from '../../types/constants';
import './ShipLegend.scss';
// SVG
import fishingIcon from '../../assets/img/shipKindIcons/fishing.svg';
import passIcon from '../../assets/img/shipKindIcons/pass.svg';
import cargoIcon from '../../assets/img/shipKindIcons/cargo.svg';
import hazardIcon from '../../assets/img/shipKindIcons/hazard.svg';
import govIcon from '../../assets/img/shipKindIcons/gov.svg';
import kcgvIcon from '../../assets/img/shipKindIcons/kcgv.svg';
import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
/**
* 선박 종류 코드 아이콘 매핑
*/
const SHIP_KIND_ICONS = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
[SIGNAL_KIND_CODE_CARGO]: cargoIcon,
[SIGNAL_KIND_CODE_TANKER]: hazardIcon,
[SIGNAL_KIND_CODE_GOV]: govIcon,
[SIGNAL_KIND_CODE_NORMAL]: etcIcon,
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
};
/**
* 범례 항목 설정
*/
const LEGEND_ITEMS = [
{ code: SIGNAL_KIND_CODE_FISHING, label: '어선' },
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
{ code: SIGNAL_KIND_CODE_TANKER, label: '유조선' },
{ code: SIGNAL_KIND_CODE_GOV, label: '관공선' },
{ code: SIGNAL_KIND_CODE_KCGV, label: '경비함정' },
{ code: SIGNAL_KIND_CODE_BUOY, label: '어망/부이' },
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
];
/**
* 선박 범례 항목
*/
const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
//
const isBuoy = code === SIGNAL_KIND_CODE_BUOY;
return (
<li className={`legend-item ${!isVisible ? 'disabled' : ''}`}>
<div className="legend-info" onClick={() => onToggle(code)}>
<span className="legend-icon">
<img
src={icon}
alt={label}
style={{ transform: isBuoy ? 'rotate(0deg)' : 'rotate(45deg)' }}
/>
</span>
<span className="legend-label">{label}</span>
</div>
<span className="legend-count">{count}</span>
</li>
);
});
/**
* 선박 범례 컴포넌트
*/
const ShipLegend = memo(() => {
const {
kindCounts,
kindVisibility,
isShipVisible,
totalCount,
isConnected,
toggleKindVisibility,
toggleShipVisible,
} = useShipStore();
return (
<article className="ship-legend">
{/* 헤더 - 전체 On/Off */}
<div className="legend-header">
<div className="legend-title">
<span className={`connection-status ${isConnected ? 'connected' : ''}`} />
<span>선박 현황</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={isShipVisible}
onChange={toggleShipVisible}
/>
<span className="slider" />
</label>
</div>
{/* 선박 종류별 목록 */}
<ul className="legend-list">
{LEGEND_ITEMS.map((item) => (
<LegendItem
key={item.code}
code={item.code}
label={item.label}
count={kindCounts[item.code] || 0}
icon={SHIP_KIND_ICONS[item.code]}
isVisible={kindVisibility[item.code]}
onToggle={toggleKindVisibility}
/>
))}
</ul>
{/* 푸터 - 전체 카운트 */}
<div className="legend-footer">
<span>전체</span>
<span className="total-count">{totalCount}</span>
</div>
</article>
);
});
export default ShipLegend;

파일 보기

@ -0,0 +1,179 @@
/**
* 선박 범례 스타일
*/
.ship-legend {
position: absolute;
right: 70px;
bottom: 72px;
z-index: 100;
width: 160px;
background: rgba(30, 35, 45, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
font-size: 12px;
color: #fff;
// 헤더
.legend-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.legend-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
.connection-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f44336;
transition: background 0.3s ease;
&.connected {
background: #4caf50;
}
}
}
// 토글 스위치
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .slider {
background-color: #2196f3;
&:before {
transform: translateX(16px);
}
}
}
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #555;
border-radius: 20px;
transition: 0.3s;
&:before {
content: '';
position: absolute;
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: #fff;
border-radius: 50%;
transition: 0.3s;
}
}
}
}
// 범례 목록
.legend-list {
list-style: none;
margin: 0;
padding: 8px 0;
.legend-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
transition: background 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.disabled {
opacity: 0.4;
.legend-icon img {
filter: grayscale(100%);
}
}
.legend-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.legend-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 16px;
height: 16px;
object-fit: contain;
}
}
.legend-label {
white-space: nowrap;
}
}
.legend-count {
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
min-width: 30px;
text-align: right;
}
}
}
// 푸터
.legend-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
.total-count {
color: #4fc3f7;
}
}
}
// 범례 숨김 상태 (선박 표시 Off )
.ship-legend.hidden {
.legend-list {
max-height: 0;
padding: 0;
overflow: hidden;
}
.legend-footer {
display: none;
}
}

파일 보기

@ -0,0 +1,40 @@
/**
* 선박 호버 툴팁 컴포넌트
* 마우스 위치 기준으로 선박 정보 표시
*/
import { SHIP_KIND_LABELS, SPEED_THRESHOLD } from '../../types/constants';
import './ShipTooltip.scss';
const OFFSET_X = 12;
const OFFSET_Y = -40;
export default function ShipTooltip({ ship, x, y }) {
if (!ship) return null;
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타';
const sog = Number(ship.sog) || 0;
const cog = Number(ship.cog) || 0;
const isMoving = sog > SPEED_THRESHOLD;
return (
<div
className="ship-tooltip"
style={{
left: x + OFFSET_X,
top: y + OFFSET_Y,
}}
>
<div className="ship-tooltip__header">
<span className="ship-tooltip__kind">{kindLabel}</span>
<span className="ship-tooltip__name">{ship.shipName || ship.targetId || '-'}</span>
</div>
<div className="ship-tooltip__body">
<span>{sog.toFixed(1)} kn</span>
<span className="ship-tooltip__sep">|</span>
<span>{cog.toFixed(1)}&deg;</span>
<span className="ship-tooltip__sep">|</span>
<span>{isMoving ? '항해' : '정박'}</span>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,46 @@
.ship-tooltip {
position: fixed;
z-index: 200;
pointer-events: none;
background: rgba(20, 24, 32, 0.95);
border-radius: 6px;
padding: 8px 12px;
min-width: 140px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 12px;
white-space: nowrap;
&__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
&__kind {
display: inline-block;
padding: 1px 5px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
font-size: 10px;
color: #adb5bd;
}
&__name {
font-weight: 700;
font-size: 13px;
color: #fff;
}
&__body {
display: flex;
align-items: center;
gap: 4px;
color: #ced4da;
}
&__sep {
color: rgba(255, 255, 255, 0.25);
}
}

231
src/hooks/useShipData.js Normal file
파일 보기

@ -0,0 +1,231 @@
/**
* 선박 데이터 관리
* - 초기 선박 데이터 API 로드 (/all/12)
* - STOMP WebSocket 연결 구독
* - 선박 데이터 수신 스토어 업데이트
* - 배치 머지 최적화 (1 or 500)
*
* 참조: mda-react-front/src/map/MapUpdater.tsx
* 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import {
signalStompClient,
connectStomp,
disconnectStomp,
subscribeShips,
subscribeShipDelete,
} from '../common/stompClient';
import useShipStore from '../stores/shipStore';
import { fetchAllSignals } from '../api/signalApi';
// =====================
// 배치 머지 설정
// =====================
const BATCH_CONFIG = {
maxInterval: 1000, // 최대 대기 시간 (1초)
maxCount: 500, // 최대 버퍼 크기 (500건)
};
/**
* 선박 데이터 관리
* @param {Object} options - 옵션
* @param {boolean} options.autoConnect - 자동 연결 여부 (기본값: true)
* @returns {Object} { isConnected, isLoading, connect, disconnect }
*/
export default function useShipData(options = {}) {
const { autoConnect = true } = options;
const subscriptionsRef = useRef([]);
const shipBufferRef = useRef([]);
const batchTimerRef = useRef(null);
const initialLoadDoneRef = useRef(false);
const [isLoading, setIsLoading] = useState(true);
const { mergeFeatures, deleteFeatureById, setConnected, isConnected } = useShipStore();
/**
* 버퍼된 선박 데이터 머지 실행
*/
const flushBuffer = useCallback(() => {
if (shipBufferRef.current.length === 0) return;
// 버퍼 복사 후 초기화
const ships = shipBufferRef.current;
shipBufferRef.current = [];
// 타이머 클리어
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
// 머지 실행
mergeFeatures(ships);
}, [mergeFeatures]);
/**
* 선박 메시지 수신 핸들러 (배치 처리)
* 조건: 1 경과 OR 500 누적 머지
*/
const handleShipMessage = useCallback((ships) => {
// 버퍼에 추가
shipBufferRef.current.push(...ships);
// 조건 1: 500건 이상이면 즉시 머지
if (shipBufferRef.current.length >= BATCH_CONFIG.maxCount) {
flushBuffer();
return;
}
// 조건 2: 타이머가 없으면 1초 타이머 설정
if (!batchTimerRef.current) {
batchTimerRef.current = setTimeout(() => {
flushBuffer();
}, BATCH_CONFIG.maxInterval);
}
}, [flushBuffer]);
/**
* 선박 삭제 메시지 수신 핸들러
* @param {string} featureId - signalSourceCode + targetId
*/
const handleShipDelete = useCallback((featureId) => {
deleteFeatureById(featureId);
}, [deleteFeatureById]);
/**
* 토픽 구독 시작
*/
const startSubscriptions = useCallback(() => {
// 기존 구독 해제
subscriptionsRef.current.forEach((sub) => {
try {
sub.unsubscribe();
} catch (e) {
// ignore
}
});
subscriptionsRef.current = [];
// 선박 토픽 구독
const shipSub = subscribeShips(handleShipMessage);
subscriptionsRef.current.push(shipSub);
// 선박 삭제 토픽 구독
const deleteSub = subscribeShipDelete(handleShipDelete);
subscriptionsRef.current.push(deleteSub);
}, [handleShipMessage, handleShipDelete]);
/**
* 연결 성공 토픽 구독
*/
const handleConnect = useCallback(() => {
setConnected(true);
startSubscriptions();
}, [setConnected, startSubscriptions]);
/**
* 연결 해제
*/
const handleDisconnect = useCallback(() => {
setConnected(false);
}, [setConnected]);
/**
* 에러 발생
*/
const handleError = useCallback(() => {
setConnected(false);
}, [setConnected]);
/**
* STOMP 연결 시작
*/
const connect = useCallback(() => {
connectStomp({
onConnect: handleConnect,
onDisconnect: handleDisconnect,
onError: handleError,
});
}, [handleConnect, handleDisconnect, handleError]);
/**
* STOMP 연결 해제
*/
const disconnect = useCallback(() => {
// 남은 버퍼 머지
flushBuffer();
// 타이머 클리어
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
// 구독 해제
subscriptionsRef.current.forEach((sub) => {
try {
sub.unsubscribe();
} catch (e) {
// ignore
}
});
subscriptionsRef.current = [];
disconnectStomp();
}, [flushBuffer]);
/**
* 초기 선박 데이터 로드 (API 호출)
* 참조: mda-react-front/src/map/MapUpdater.tsx (라인 128-152)
*/
const loadInitialData = useCallback(async () => {
if (initialLoadDoneRef.current) return;
setIsLoading(true);
try {
console.log('[useShipData] Loading initial ship data...');
const ships = await fetchAllSignals();
if (ships.length > 0) {
mergeFeatures(ships);
console.log(`[useShipData] Initial load complete: ${ships.length} ships`);
}
} catch (error) {
console.error('[useShipData] Initial load error:', error);
} finally {
initialLoadDoneRef.current = true;
setIsLoading(false);
}
}, [mergeFeatures]);
// 초기화: API로 선박 데이터 로드 후 STOMP 연결
useEffect(() => {
if (!autoConnect) return;
// 1단계: API로 초기 선박 데이터 로드
// 2단계: 로드 완료 후 STOMP 연결 (실시간 업데이트)
const initialize = async () => {
await loadInitialData();
connect();
};
initialize();
return () => {
// 타이머 클리어
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
disconnect();
};
}, [autoConnect]); // loadInitialData, connect, disconnect를 deps에서 제외 (의도적)
return {
isConnected,
isLoading,
connect,
disconnect,
};
}

273
src/hooks/useShipLayer.js Normal file
파일 보기

@ -0,0 +1,273 @@
/**
* 선박 Deck.gl 레이어 관리
* - OpenLayers 맵과 Deck.gl 레이어 통합
* - 배치 렌더러 기반 최적화된 렌더링
* - 선박 데이터 변경 레이어 업데이트
*
* 참조: mda-react-front/src/common/deck.ts
*/
import { useEffect, useRef, useCallback } from 'react';
import { Deck } from '@deck.gl/core';
import { toLonLat } from 'ol/proj';
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
import useShipStore from '../stores/shipStore';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/**
* 선박 레이어 관리
* @param {Object} map - OpenLayers 인스턴스
* @returns {Object} { deckCanvas }
*/
export default function useShipLayer(map) {
const deckRef = useRef(null);
const canvasRef = useRef(null);
const animationFrameRef = useRef(null);
const batchRendererInitialized = useRef(false);
const { getSelectedShips, isShipVisible, showLabels, labelOptions } = useShipStore();
/**
* Deck.gl 인스턴스 초기화
*/
const initDeck = useCallback((container) => {
if (deckRef.current) return;
// Canvas 엘리먼트 생성
const canvas = document.createElement('canvas');
canvas.id = 'deck-canvas';
canvas.style.position = 'absolute';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '0';
container.appendChild(canvas);
canvasRef.current = canvas;
// Deck.gl 인스턴스 생성
deckRef.current = new Deck({
canvas,
controller: false,
layers: [],
useDevicePixels: true,
onError: (error) => {
console.error('[Deck.gl] Error:', error);
},
});
}, []);
/**
* Deck.gl viewState를 OpenLayers 뷰와 동기화
*/
const syncViewState = useCallback(() => {
if (!map || !deckRef.current) return;
const view = map.getView();
const center = view.getCenter();
const zoom = view.getZoom();
const rotation = view.getRotation();
if (!center || zoom === undefined) return;
const [lon, lat] = toLonLat(center);
deckRef.current.setProps({
viewState: {
longitude: lon,
latitude: lat,
zoom: zoom - 1, // OpenLayers와 Deck.gl 줌 레벨 차이 보정
bearing: (-rotation * 180) / Math.PI,
pitch: 0,
},
});
}, [map]);
/**
* 뷰포트 범위 계산
*/
const getViewportBounds = useCallback(() => {
if (!map) return null;
const view = map.getView();
const size = map.getSize();
if (!size) return null;
const extent = view.calculateExtent(size);
// OpenLayers 좌표를 경위도로 변환
const [minX, minY, maxX, maxY] = extent;
const [minLon, minLat] = toLonLat([minX, minY]);
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
return { minLon, maxLon, minLat, maxLat };
}, [map]);
/**
* 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트
* @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
* @param {number} trigger - 렌더링 트리거
*/
const handleBatchRender = useCallback((ships, trigger) => {
if (!deckRef.current || !map) return;
const view = map.getView();
const zoom = view.getZoom() || 7;
const selectedShips = getSelectedShips();
// 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
// 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통)
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
// Deck.gl 레이어 업데이트
deckRef.current.setProps({ layers });
}, [map, getSelectedShips]);
/**
* 선박 레이어 업데이트 (배치 렌더러 사용)
*/
const updateLayers = useCallback(() => {
if (!deckRef.current || !map) return;
if (!isShipVisible) {
// 선박 표시 Off
deckRef.current.setProps({ layers: [] });
return;
}
// 줌 레벨 업데이트 (렌더링 간격 조정용)
const view = map.getView();
const zoom = view.getZoom() || 10;
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
// 뷰포트 범위 업데이트
const bounds = getViewportBounds();
shipBatchRenderer.setViewportBounds(bounds);
// 줌 정수 레벨이 변경되면 클러스터 캐시 클리어 + 즉시 렌더링
if (zoomIntChanged) {
clearClusterCache();
shipBatchRenderer.immediateRender();
return;
}
// 배치 렌더러에 렌더링 요청
shipBatchRenderer.requestRender();
}, [map, isShipVisible, getViewportBounds]);
/**
* 렌더링 루프
*/
const render = useCallback(() => {
syncViewState();
updateLayers();
deckRef.current?.redraw();
}, [syncViewState, updateLayers]);
// 맵 초기화 및 이벤트 바인딩
useEffect(() => {
if (!map) return;
// 맵 컨테이너에 Deck.gl 캔버스 추가
const viewport = map.getViewport();
initDeck(viewport);
// 배치 렌더러 초기화 (1회만)
if (!batchRendererInitialized.current) {
shipBatchRenderer.initialize(handleBatchRender);
batchRendererInitialized.current = true;
}
// 맵 이동/줌 시 동기화
const handleMoveEnd = () => {
render();
};
const handlePostRender = () => {
syncViewState();
deckRef.current?.redraw();
};
map.on('moveend', handleMoveEnd);
map.on('postrender', handlePostRender);
// 초기 렌더링
setTimeout(() => {
render();
}, 100);
// 클린업
return () => {
map.un('moveend', handleMoveEnd);
map.un('postrender', handlePostRender);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (deckRef.current) {
deckRef.current.finalize();
deckRef.current = null;
}
if (canvasRef.current) {
canvasRef.current.remove();
canvasRef.current = null;
}
// 배치 렌더러 정리
shipBatchRenderer.dispose();
batchRendererInitialized.current = false;
};
}, [map, initDeck, render, syncViewState, handleBatchRender]);
// 선박 데이터 변경 시 레이어 업데이트
useEffect(() => {
// 스토어 구독하여 변경 감지
const unsubscribe = useShipStore.subscribe(
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
(current, prev) => {
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds)
const filterChanged =
current[1] !== prev[1] ||
current[2] !== prev[2] ||
current[3] !== prev[3] ||
current[5] !== prev[5] ||
current[6] !== prev[6] ||
current[7] !== prev[7] ||
current[8] !== prev[8] ||
current[9] !== prev[9] ||
current[10] !== prev[10];
if (filterChanged) {
// 필터/선명표시 변경 시 즉시 렌더링 (사용자 인터랙션 응답성)
shipBatchRenderer.clearCache();
clearClusterCache();
shipBatchRenderer.immediateRender();
return; // 즉시 렌더링 후 추가 처리 불필요
}
// 데이터 변경 시 일반 렌더링 (적응형 주기 적용)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(() => {
updateLayers();
deckRef.current?.redraw();
});
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => {
unsubscribe();
};
}, [updateLayers]);
return {
deckCanvas: canvasRef.current,
deckRef,
};
}

16
src/main.jsx Normal file
파일 보기

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
// OpenLayers
import 'ol/ol.css';
//
import './scss/global.scss';
ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
);

296
src/map/MapContainer.jsx Normal file
파일 보기

@ -0,0 +1,296 @@
import { useEffect, useRef, useCallback } from 'react';
import Map from 'ol/Map';
import View from 'ol/View';
import { fromLonLat, transformExtent } from 'ol/proj';
import { defaults as defaultControls, ScaleLine } from 'ol/control';
import { defaults as defaultInteractions, DragBox } from 'ol/interaction';
import { platformModifierKeyOnly } from 'ol/events/condition';
import { createBaseLayers } from './layers/baseLayer';
import { useMapStore } from '../stores/mapStore';
import useShipStore from '../stores/shipStore';
import useShipData from '../hooks/useShipData';
import useShipLayer from '../hooks/useShipLayer';
import ShipLegend from '../components/ship/ShipLegend';
import ShipTooltip from '../components/ship/ShipTooltip';
import ShipDetailModal from '../components/ship/ShipDetailModal';
import ShipContextMenu from '../components/ship/ShipContextMenu';
import useMeasure from './measure/useMeasure';
import './measure/measure.scss';
import './MapContainer.scss';
/** 호버 쓰로틀 간격 (ms) */
const HOVER_THROTTLE_MS = 50;
/**
* 지도 컨테이너 컴포넌트
* - OpenLayers 초기화 관리
* - STOMP 선박 데이터 연결
* - Deck.gl 선박 레이어 렌더링
* - 선박 호버 툴팁 / 더블클릭 상세 모달
*/
export default function MapContainer() {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const { map, setMap, setZoom, center } = useMapStore();
const { showLegend } = useShipStore();
const hoverInfo = useShipStore((s) => s.hoverInfo);
const detailModals = useShipStore((s) => s.detailModals);
// STOMP
useShipData({ autoConnect: true });
// Deck.gl
const { deckRef } = useShipLayer(map);
//
useMeasure();
//
const hoverTimerRef = useRef(null);
/**
* deck.pickObject 헬퍼
*/
const pickShip = useCallback((pixel) => {
const deck = deckRef.current;
if (!deck) return null;
// deck.layerManager pickObject assertion error
if (!deck.layerManager) return null;
try {
const result = deck.pickObject({
x: pixel[0],
y: pixel[1],
layerIds: ['ship-icon-layer'],
});
return result?.object || null;
} catch {
return null;
}
}, [deckRef]);
/**
* OpenLayers pointermove 호버 툴팁
*/
const handlePointerMove = useCallback((evt) => {
//
if (evt.dragging) {
useShipStore.getState().setHoverInfo(null);
return;
}
//
if (hoverTimerRef.current) return;
hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null;
const pixel = evt.pixel;
const ship = pickShip(pixel);
if (ship) {
// evt.originalEvent
const { clientX, clientY } = evt.originalEvent;
useShipStore.getState().setHoverInfo({ ship, x: clientX, y: clientY });
} else {
useShipStore.getState().setHoverInfo(null);
}
}, HOVER_THROTTLE_MS);
}, [pickShip]);
/**
* OpenLayers dblclick 상세 모달
*/
const handleDblClick = useCallback((evt) => {
const pixel = evt.pixel;
const ship = pickShip(pixel);
if (ship) {
evt.stopPropagation();
useShipStore.getState().openDetailModal(ship);
}
}, [pickShip]);
/**
* pointerout 툴팁 숨김
*/
const handlePointerOut = useCallback(() => {
useShipStore.getState().setHoverInfo(null);
}, []);
/**
* singleclick 영역 클릭 선택/메뉴 해제
*/
const handleSingleClick = useCallback((evt) => {
const ship = pickShip(evt.pixel);
if (!ship) {
useShipStore.getState().clearSelectedShips();
useShipStore.getState().closeContextMenu();
}
}, [pickShip]);
// OL
useEffect(() => {
if (!map) return;
map.on('pointermove', handlePointerMove);
map.on('dblclick', handleDblClick);
map.on('singleclick', handleSingleClick);
// pointerout DOM
const viewport = map.getViewport();
viewport.addEventListener('pointerout', handlePointerOut);
//
const handleContextMenu = (e) => {
e.preventDefault();
const pixel = map.getEventPixel(e);
const ship = pickShip(pixel);
const state = useShipStore.getState();
if (ship) {
state.openContextMenu({ x: e.clientX, y: e.clientY, ships: [ship] });
} else if (state.selectedShipIds.length > 0) {
const selectedShips = state.getSelectedShips();
state.openContextMenu({ x: e.clientX, y: e.clientY, ships: selectedShips });
}
};
viewport.addEventListener('contextmenu', handleContextMenu);
return () => {
map.un('pointermove', handlePointerMove);
map.un('dblclick', handleDblClick);
map.un('singleclick', handleSingleClick);
viewport.removeEventListener('pointerout', handlePointerOut);
viewport.removeEventListener('contextmenu', handleContextMenu);
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
};
}, [map, handlePointerMove, handleDblClick, handleSingleClick, handlePointerOut, pickShip]);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
//
const { worldMap, eastAsiaMap, korMap } = createBaseLayers();
// ( )
const scaleLineControl = new ScaleLine({
units: 'nautical',
bar: true,
text: true,
});
//
const map = new Map({
target: mapRef.current,
layers: [
worldMap,
eastAsiaMap,
korMap,
],
view: new View({
center: fromLonLat(center),
zoom: 7,
minZoom: 0,
maxZoom: 17,
}),
controls: defaultControls({
attribution: false,
zoom: false,
rotate: false,
}).extend([scaleLineControl]),
interactions: defaultInteractions({
doubleClickZoom: false,
}),
});
// Ctrl+Drag
const dragBox = new DragBox({ condition: platformModifierKeyOnly });
map.addInteraction(dragBox);
dragBox.on('boxend', () => {
const extent3857 = dragBox.getGeometry().getExtent();
const [minLon, minLat, maxLon, maxLat] = transformExtent(extent3857, 'EPSG:3857', 'EPSG:4326');
const state = useShipStore.getState();
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility,
nationalVisibility, darkSignalVisible } = state;
// (shipStore.js )
const mapNational = (code) => {
if (!code) return 'OTHER';
const c = code.toUpperCase();
if (c === 'KR' || c === 'KOR' || c === '440') return 'KR';
if (c === 'CN' || c === 'CHN' || c === '412' || c === '413' || c === '414') return 'CN';
if (c === 'JP' || c === 'JPN' || c === '431' || c === '432') return 'JP';
if (c === 'KP' || c === 'PRK' || c === '445') return 'KP';
return 'OTHER';
};
const matchedIds = [];
features.forEach((ship, featureId) => {
//
if (ship.signalSourceCode === '000005' && !ship.integrate) return;
// ON: isPriority
if (isIntegrate && ship.integrate && !ship.isPriority) return;
// :
if (darkSignalIds.has(featureId)) {
if (!darkSignalVisible) return;
} else {
if (!kindVisibility[ship.signalKindCode]) return;
if (!sourceVisibility[ship.signalSourceCode]) return;
const mappedNational = mapNational(ship.nationalCode);
if (!nationalVisibility[mappedNational]) return;
}
const lon = parseFloat(ship.longitude);
const lat = parseFloat(ship.latitude);
if (lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat) {
matchedIds.push(featureId);
}
});
state.setSelectedShipIds(matchedIds);
});
//
map.getView().on('change:resolution', () => {
const zoom = Math.round(map.getView().getZoom());
setZoom(zoom);
});
//
setMap(map);
mapInstanceRef.current = map;
//
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.setTarget(null);
mapInstanceRef.current = null;
}
};
}, []);
return (
<>
<div id="map" ref={mapRef} className="map-container" />
{showLegend && <ShipLegend />}
{hoverInfo && (
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
)}
{detailModals.map((modal) => (
<ShipDetailModal key={modal.id} modal={modal} />
))}
<ShipContextMenu />
</>
);
}

100
src/map/MapContainer.scss Normal file
파일 보기

@ -0,0 +1,100 @@
/**
* 지도 컨테이너 스타일
*/
.map-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
}
// OpenLayers 기본 컨트롤 숨김 (스케일바 제외)
.ol-control {
display: none !important;
background: transparent !important;
}
// OpenLayers 기본 컨트롤 명시적 숨김
.ol-attribution,
.ol-zoom,
.ol-rotate,
.ol-mouse-position,
.ol-full-screen,
.ol-overviewmap {
display: none !important;
}
// OpenLayers 뷰포트 관련 스타일
.ol-viewport {
background-color: transparent;
}
// 컨트롤 컨테이너를 Deck.gl 캔버스 위에 표시
.ol-overlaycontainer-stopevent {
z-index: 1 !important;
}
// OpenLayers 오버레이 스타일
.ol-overlay-container {
background: transparent;
}
// =====================
// 스케일바 위치 스타일 오버라이드
// =====================
// 스케일바 wrapper (.ol-control이 아닌 .ol-scale-bar 직접 타겟)
.ol-scale-bar {
// 좌측우측으로 위치 변경
left: auto !important;
right: 70px !important;
bottom: 16px !important;
// 배경 스타일
background: rgba(30, 35, 45, 0.92) !important;
border-radius: 4px !important;
padding: 22px 10px 4px !important;
z-index: 100 !important;
}
// 상단 비율 텍스트 (1:XXX)
.ol-scale-bar .ol-scale-text {
text-shadow: none !important;
font-family: 'Segoe UI', sans-serif !important;
color: #eee !important;
bottom: 30px !important;
}
// 하단 거리 텍스트
.ol-scale-bar .ol-scale-step-text {
text-shadow: none !important;
font-family: 'Segoe UI', sans-serif !important;
color: #bbb !important;
bottom: 1px !important;
}
// 막대 색상
.ol-scale-bar .ol-scale-singlebar-even {
background-color: #4fc3f7 !important;
}
.ol-scale-bar .ol-scale-singlebar-odd {
background-color: #555 !important;
}
// 기존 라인 스케일 숨김
.ol-scale-line {
display: none !important;
}
// =====================
// Ctrl+Drag 박스 선택 스타일
// =====================
.ol-dragbox {
border: 2px solid rgba(255, 215, 0, 0.8);
background-color: rgba(255, 215, 0, 0.1);
}

파일 보기

@ -0,0 +1,618 @@
/**
* 선박 배치 렌더러
* 참조: mda-react-front/src/common/deck.ts
* 참조: mda-react-front/src/tracking/utils/ReplayBatchRenderer.ts
*
* 역할:
* - 필터 캐시 생성 O(1) 필터링
* - 뷰포트 범위 필터링
* - 통합선박 처리
* - 밀도 제한 클러스터링 (우선순위 기반)
* - 적응형 렌더링 간격 조정
* - requestAnimationFrame 기반 렌더링
*/
import useShipStore from '../stores/shipStore';
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
} from '../types/constants';
// =====================
// 렌더링 설정
// 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙
// =====================
const RENDER_CONFIG = {
defaultMinInterval: 1000, // 기본 최소 렌더링 간격 (1초)
maxInterval: 5000, // 최대 렌더링 간격 (5초)
targetRenderTime: 100, // 목표 렌더링 시간 (10fps 기준)
maxRenderTime: 500, // 최대 허용 렌더링 시간
};
// =====================
// 줌 레벨별 최소 렌더링 간격
// 낮은 줌 = 많은 선박 = 긴 간격
// =====================
const ZOOM_MIN_INTERVAL = {
// zoom < 8: 광역 (전국/동아시아)
7: 4000,
// zoom 8-9: 중광역 (해역)
8: 3500,
9: 3000,
// zoom 10-11: 중간 (항만 주변)
10: 2500,
11: 2000,
// zoom 12-13: 상세 (항만)
12: 1500,
13: 1500,
// zoom >= 14: 확대 (선석)
14: 1000,
};
/**
* 레벨에 따른 최소 렌더링 간격 반환
* @param {number} zoom - 현재 레벨
* @returns {number} 최소 렌더링 간격 (ms)
*/
function getMinIntervalByZoom(zoom) {
const zoomInt = Math.floor(zoom);
if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7];
if (zoomInt >= 14) return ZOOM_MIN_INTERVAL[14];
return ZOOM_MIN_INTERVAL[zoomInt] || RENDER_CONFIG.defaultMinInterval;
}
// =====================
// 밀도 제한 설정 (선박 아이콘 클러스터링)
// 참조: mda-react-front/src/util/realTimeLayerUtil.ts
//
// 줌 레벨별 셀당 최대 선박 수 및 그리드 크기 설정
// - 낮은 줌: 더 적은 개수 (밀집 지역 성능 최적화)
// - 높은 줌: 더 많은 개수 (상세 보기)
// =====================
const DENSITY_LIMITS = [
{ maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 },
{ maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 },
{ maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 },
{ maxZoom: 8, maxPerCell: 35, gridSizeMultiplier: 75 },
{ maxZoom: 9, maxPerCell: 38, gridSizeMultiplier: 70 },
{ maxZoom: 10, maxPerCell: 40, gridSizeMultiplier: 55 },
{ maxZoom: 11, maxPerCell: 43, gridSizeMultiplier: 40 },
{ maxZoom: Infinity, maxPerCell: Infinity, gridSizeMultiplier: 30 },
];
/**
* 밀도 제한 선박 우선순위 (낮을수록 높은 우선순위)
* 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이
*/
const SHIP_KIND_PRIORITY = {
[SIGNAL_KIND_CODE_KCGV]: 2, // 함정
[SIGNAL_KIND_CODE_GOV]: 3, // 관공선
[SIGNAL_KIND_CODE_PASSENGER]: 4, // 여객선
[SIGNAL_KIND_CODE_TANKER]: 6, // 유조선
[SIGNAL_KIND_CODE_CARGO]: 7, // 화물선
[SIGNAL_KIND_CODE_FISHING]: 8, // 어선
[SIGNAL_KIND_CODE_NORMAL]: 9, // 기타
[SIGNAL_KIND_CODE_BUOY]: 10, // 어망/부이
};
const PRIORITY_FAVORITE = 0; // 관심선박 (최우선)
const PRIORITY_HAZARDOUS = 5; // 위험물 (여객선과 유조선 사이)
const PRIORITY_DEFAULT = 11; // 기본값 (최하위)
/**
* 선박 우선순위 점수 계산
* @param {Object} ship - 선박 데이터
* @param {Set} favoriteSet - 관심선박 ID Set
* @returns {number} 우선순위 점수 (낮을수록 높은 우선순위)
*/
function getShipPriority(ship, favoriteSet) {
// 관심선박 체크 (최우선)
const favoriteKey = `${ship.targetId}_${ship.signalSourceCode}`;
if (favoriteSet && favoriteSet.has(favoriteKey)) {
return PRIORITY_FAVORITE;
}
// 위험물 체크 (hazardousCategory = '1')
if (ship.hazardousCategory === '1') {
return PRIORITY_HAZARDOUS;
}
// 선종 코드 기반 우선순위
return SHIP_KIND_PRIORITY[ship.signalKindCode] ?? PRIORITY_DEFAULT;
}
/**
* 줌레벨에 따른 밀도 설정 반환
* @param {number} zoomLevel - 현재 레벨
* @returns {Object} 밀도 설정 { maxZoom, maxPerCell, gridSizeMultiplier }
*/
function getDensityConfig(zoomLevel) {
for (const config of DENSITY_LIMITS) {
if (zoomLevel <= config.maxZoom) {
return config;
}
}
return DENSITY_LIMITS[DENSITY_LIMITS.length - 1];
}
/**
* 선박 아이콘용 밀도 제한 필터링 (우선순위 기반)
* 참조: mda-react-front/src/util/realTimeLayerUtil.ts - applyDensityLimit()
*
* - 그리드 기반으로 동일 영역 최대 N척까지만 렌더링
* - 밀집 지역도 N척이 겹쳐 보여서 "빈 공간"처럼 보이지 않음
* - 줌레벨이 높아지면 제한이 완화되어 많은 선박 표시
* - 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이
*
* @param {Array} ships - 필터링된 선박 데이터
* @param {number} zoomLevel - 현재 줌레벨
* @param {Set} favoriteSet - 관심선박 ID Set (optional)
* @returns {Array} 밀도 제한이 적용된 선박 데이터
*/
function applyDensityLimit(ships, zoomLevel, favoriteSet = null) {
const config = getDensityConfig(zoomLevel);
// 제한 없음 설정이면 원본 반환
if (config.maxPerCell === Infinity) {
return ships;
}
// 우선순위 기반 정렬 (낮은 점수 = 높은 우선순위)
const sortedShips = [...ships].sort((a, b) => {
return getShipPriority(a, favoriteSet) - getShipPriority(b, favoriteSet);
});
// 그리드 크기 계산 (줌레벨에 따라 조정)
const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier;
// 그리드별 선박 수 카운트
const gridCounts = new Map();
const result = [];
const len = sortedShips.length;
for (let i = 0; i < len; i++) {
const ship = sortedShips[i];
const gridX = Math.floor(ship.longitude / gridSize);
const gridY = Math.floor(ship.latitude / gridSize);
const gridKey = `${gridX},${gridY}`;
const currentCount = gridCounts.get(gridKey) || 0;
// 셀 내 최대 개수 미만이면 포함
if (currentCount < config.maxPerCell) {
result.push(ship);
gridCounts.set(gridKey, currentCount + 1);
}
// 초과하면 스킵 (우선순위 낮은 선박이 제외됨)
}
// deck.gl은 배열 뒤쪽 아이템이 위에 그려짐
// 우선순위 높은 선박이 위에 보이도록 역순 반환
return result.reverse();
}
// =====================
// 필터 캐시 인터페이스
// 참조: mda-react-front/src/common/deck.ts (52-108)
// =====================
/**
* 필터 캐시 생성
* 렌더링 시작 1 호출하여 Set 기반 O(1) lookup 가능하게
*
* @returns {Object} 필터 캐시 객체
*/
function buildFilterCache() {
const { kindVisibility, sourceVisibility, nationalVisibility, isShipVisible, isIntegrate, darkSignalVisible, darkSignalIds } = useShipStore.getState();
// 활성화된 선종 코드 Set
const enabledKinds = new Set();
Object.entries(kindVisibility).forEach(([code, isChecked]) => {
if (isChecked) {
enabledKinds.add(code);
}
});
// 활성화된 신호원 코드 Set
const enabledSources = new Set();
Object.entries(sourceVisibility).forEach(([code, isChecked]) => {
if (isChecked) {
enabledSources.add(code);
}
});
// 활성화된 국적 코드 Set
const enabledNationals = new Set();
Object.entries(nationalVisibility).forEach(([code, isChecked]) => {
if (isChecked) {
enabledNationals.add(code);
}
});
return {
enabledKinds,
enabledSources,
enabledNationals,
isShipVisible,
isIntegrate,
darkSignalVisible,
darkSignalIds,
};
}
/**
* 국적 코드 매핑 (실제 국적 코드 -> 필터 코드)
* @param {string} nationalCode - 선박의 국적 코드
* @returns {string} 필터용 국적 코드
*/
function mapNationalCode(nationalCode) {
if (!nationalCode) return 'OTHER';
const code = nationalCode.toUpperCase();
if (code === 'KR' || code === 'KOR' || code === '440') return 'KR';
if (code === 'CN' || code === 'CHN' || code === '412' || code === '413' || code === '414') return 'CN';
if (code === 'JP' || code === 'JPN' || code === '431' || code === '432') return 'JP';
if (code === 'KP' || code === 'PRK' || code === '445') return 'KP';
return 'OTHER';
}
/**
* 캐시된 필터를 사용한 필터링 (O(1) lookup)
* 참조: mda-react-front/src/common/deck.ts - applyFilterWithCache()
*
* @param {Object} ship - 선박 데이터
* @param {Object} cache - 필터 캐시
* @returns {boolean} 필터 통과 여부
*/
function applyFilterWithCache(ship, cache) {
// 전체 선박 표시 Off
if (!cache.isShipVisible) return false;
// 통합 모드 필터: 통합 모드 On이면 isPriority=1만 표시
// 참조: mda-react-front/src/common/deck.ts (354-355)
if (cache.isIntegrate && ship.integrate && !ship.isPriority) return false;
// 다크시그널은 독립 필터 (선종/신호원/국적 필터 무시)
if (cache.darkSignalIds.has(ship.featureId)) return cache.darkSignalVisible;
// 선종 필터 (Set.has = O(1))
if (!cache.enabledKinds.has(ship.signalKindCode)) return false;
// 신호원 필터 (Set.has = O(1))
if (!cache.enabledSources.has(ship.signalSourceCode)) return false;
// 국적 필터 (Set.has = O(1))
const mappedNational = mapNationalCode(ship.nationalCode);
if (!cache.enabledNationals.has(mappedNational)) return false;
// lost 플래그: 영해선 내/외 구분 (1=영해선 밖, 0=영해선 내)
// 렌더링 제외 대상 아님 - 삭제는 /topic/ship-delete 토픽으로만 처리
return true;
}
/**
* 뷰포트 범위 선박 필터링
* 참조: mda-react-front/src/util/realTimeLayerUtil.ts - filterFeaturesWithBounds()
*
* @param {Array} ships - 선박 배열
* @param {Object} bounds - 뷰포트 범위 { minLon, maxLon, minLat, maxLat }
* @returns {Array} 범위 선박 배열
*/
function filterByViewport(ships, bounds) {
if (!bounds) return ships;
const { minLon, maxLon, minLat, maxLat } = bounds;
return ships.filter((ship) => {
const lon = ship.longitude;
const lat = ship.latitude;
return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat;
});
}
/**
* 선박 배치 렌더러 클래스
* 참조: mda-react-front/src/tracking/utils/ReplayBatchRenderer.ts
*/
class ShipBatchRenderer {
constructor() {
// 렌더링 상태
this.renderState = {
animationFrameId: null,
pendingRender: false,
isRendering: false,
lastRenderTime: 0,
currentInterval: RENDER_CONFIG.defaultMinInterval,
currentZoom: 10, // 현재 줌 레벨
};
// 캐시
this.cache = {
filterCache: null,
lastFilterHash: '',
lastShipsData: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전)
lastRenderTrigger: 0,
favoriteSet: null, // 관심선박 Set (향후 구현)
};
// 외부 콜백
this.onRenderCallback = null;
// 뷰포트 범위
this.viewportBounds = null;
}
/**
* 배치 렌더러 초기화
* @param {Function} renderCallback - 레이어 렌더링 콜백
* (ships, trigger) => void
* - ships: 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
* - trigger: 렌더링 트리거 (주기적 갱신용)
*/
initialize(renderCallback) {
this.onRenderCallback = renderCallback;
console.log('[ShipBatchRenderer] Initialized');
}
/**
* 뷰포트 범위 업데이트
* @param {Object} bounds - { minLon, maxLon, minLat, maxLat }
*/
setViewportBounds(bounds) {
this.viewportBounds = bounds;
}
/**
* 레벨 업데이트
* 변경 최소 렌더링 간격도 재조정
* @param {number} zoom - 현재 레벨
* @returns {boolean} 정수 레벨이 변경되었는지 여부
*/
setZoom(zoom) {
const prevZoom = this.renderState.currentZoom;
const prevZoomInt = Math.floor(prevZoom);
const newZoomInt = Math.floor(zoom);
this.renderState.currentZoom = zoom;
// 줌 레벨이 변경되면 최소 간격에 맞게 현재 간격 재조정
const newMinInterval = getMinIntervalByZoom(zoom);
const prevMinInterval = getMinIntervalByZoom(prevZoom);
if (newMinInterval !== prevMinInterval) {
// 새 최소 간격이 현재 간격보다 크면 현재 간격 증가
if (newMinInterval > this.renderState.currentInterval) {
this.renderState.currentInterval = newMinInterval;
}
// 새 최소 간격이 현재 간격보다 작고, 현재 간격이 이전 최소 간격과 같으면 감소
else if (this.renderState.currentInterval === prevMinInterval) {
this.renderState.currentInterval = newMinInterval;
}
}
// 정수 줌 레벨 변경 여부 반환
return prevZoomInt !== newZoomInt;
}
/**
* 렌더링 요청
* 다음 렌더링 사이클에 처리됨
*/
requestRender() {
if (this.renderState.pendingRender) return;
this.renderState.pendingRender = true;
// requestAnimationFrame 사용하여 렌더링 스케줄
if (!this.renderState.animationFrameId) {
this.scheduleRender();
}
}
/**
* 렌더링 스케줄링
*/
scheduleRender() {
const now = Date.now();
const elapsed = now - this.renderState.lastRenderTime;
const delay = Math.max(0, this.renderState.currentInterval - elapsed);
this.renderState.animationFrameId = setTimeout(() => {
this.renderState.animationFrameId = requestAnimationFrame(() => {
this.executeRender();
});
}, delay);
}
/**
* 실제 렌더링 실행
*/
executeRender() {
if (this.renderState.isRendering || !this.onRenderCallback) {
this.renderState.animationFrameId = null;
return;
}
const startTime = performance.now();
this.renderState.isRendering = true;
this.renderState.pendingRender = false;
try {
// 1. 스토어에서 전체 선박 가져오기
const { features } = useShipStore.getState();
const allShips = Array.from(features.values());
// 2. 필터 캐시 생성 (렌더링 시작 시 1회)
this.cache.filterCache = buildFilterCache();
// 3. 뷰포트 범위 필터링 (먼저 수행 - 대량 데이터 감소)
const viewportShips = this.viewportBounds
? filterByViewport(allShips, this.viewportBounds)
: allShips;
// 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup)
const filteredShips = viewportShips.filter((ship) =>
applyFilterWithCache(ship, this.cache.filterCache)
);
// 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반)
const zoom = this.renderState.currentZoom;
const densityLimitedShips = applyDensityLimit(filteredShips, zoom, this.cache.favoriteSet);
// 6. 렌더링 트리거 증가
this.cache.lastRenderTrigger++;
// 7. 콜백 호출 (밀도 제한 적용된 데이터 = 아이콘 + 라벨 공통)
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
this.onRenderCallback(densityLimitedShips, this.cache.lastRenderTrigger);
// 8. 캐시 업데이트
this.cache.lastShipsData = densityLimitedShips;
this.cache.lastFilteredCount = filteredShips.length;
// 9. 렌더링 시간 측정 및 간격 조정
const renderTime = performance.now() - startTime;
this.adjustRenderInterval(renderTime);
} catch (error) {
console.error('[ShipBatchRenderer] Render error:', error);
} finally {
this.renderState.isRendering = false;
this.renderState.lastRenderTime = Date.now();
this.renderState.animationFrameId = null;
// 대기 중인 렌더링 요청이 있으면 다시 스케줄
if (this.renderState.pendingRender) {
this.scheduleRender();
}
}
}
/**
* 적응형 렌더링 간격 조정
* 레벨에 따른 최소 간격 + 성능 기반 적응형 조정
*
* @param {number} renderTime - 렌더링 소요 시간 (ms)
*/
adjustRenderInterval(renderTime) {
const { targetRenderTime, maxRenderTime, maxInterval } = RENDER_CONFIG;
const minInterval = getMinIntervalByZoom(this.renderState.currentZoom);
if (renderTime > maxRenderTime) {
// 렌더링 시간이 너무 길면 간격 증가 (최대 5초)
this.renderState.currentInterval = Math.min(
this.renderState.currentInterval * 1.2,
maxInterval
);
} else if (renderTime < targetRenderTime) {
// 렌더링 시간이 충분히 짧으면 간격 감소 (줌 레벨별 최소값까지)
this.renderState.currentInterval = Math.max(
this.renderState.currentInterval * 0.9,
minInterval
);
}
}
/**
* 강제 렌더링 (필터 변경 )
* 일반 렌더링 주기에 따름
*/
forceRender() {
this.cache.filterCache = null;
this.requestRender();
}
/**
* 즉시 렌더링 (필터/선명표시 토글 사용자 인터랙션)
* 렌더링 주기를 무시하고 즉시 실행
*/
immediateRender() {
// 기존 스케줄 취소
if (this.renderState.animationFrameId) {
clearTimeout(this.renderState.animationFrameId);
cancelAnimationFrame(this.renderState.animationFrameId);
this.renderState.animationFrameId = null;
}
// 캐시 클리어
this.cache.filterCache = null;
this.renderState.pendingRender = false;
// 즉시 렌더링 실행
requestAnimationFrame(() => {
this.executeRender();
});
}
/**
* 캐시 클리어
*/
clearCache() {
this.cache.filterCache = null;
this.cache.lastFilterHash = '';
this.cache.lastShipsData = [];
this.cache.lastFilteredCount = 0;
this.cache.favoriteSet = null;
}
/**
* 배치 렌더러 정리
*/
dispose() {
if (this.renderState.animationFrameId) {
cancelAnimationFrame(this.renderState.animationFrameId);
clearTimeout(this.renderState.animationFrameId);
this.renderState.animationFrameId = null;
}
this.clearCache();
this.onRenderCallback = null;
console.log('[ShipBatchRenderer] Disposed');
}
/**
* 마지막 필터링된 선박 데이터 반환
* @returns {Array} 필터링된 선박 배열
*/
getFilteredShips() {
return this.cache.lastShipsData;
}
/**
* 현재 렌더링 통계 반환
* @returns {Object} 렌더링 통계
*/
getStats() {
const zoom = this.renderState.currentZoom;
const densityConfig = getDensityConfig(zoom);
return {
currentZoom: zoom,
minInterval: getMinIntervalByZoom(zoom),
currentInterval: this.renderState.currentInterval,
lastRenderTime: this.renderState.lastRenderTime,
filteredCount: this.cache.lastFilteredCount, // 밀도 제한 전 선박 수
renderedCount: this.cache.lastShipsData.length, // 밀도 제한 후 렌더링 선박 수
densityConfig: {
maxPerCell: densityConfig.maxPerCell,
gridSizeMultiplier: densityConfig.gridSizeMultiplier,
},
renderTrigger: this.cache.lastRenderTrigger,
};
}
}
// 싱글톤 인스턴스
export const shipBatchRenderer = new ShipBatchRenderer();
// 유틸리티 함수 export
export { buildFilterCache, applyFilterWithCache, filterByViewport, applyDensityLimit, getDensityConfig };
export default ShipBatchRenderer;

파일 보기

@ -0,0 +1,70 @@
/**
* 베이스맵 레이어 설정
* - 메인 프로젝트(mda-react-front) mapLayer.ts 참조
*/
import { XYZ } from 'ol/source';
import { transformExtent } from 'ol/proj';
import WebGLTileLayer from 'ol/layer/WebGLTile';
// 좌표계 상수
const EPSG_3857 = 'EPSG:3857';
const EPSG_4326 = 'EPSG:4326';
/**
* 레이어 설정
*/
export const mapLayerConfig = {
// 세계 지도 (줌 0-11)
worldLayer: {
source: new XYZ({
url: '/MAPS/WORLD_webp/{z}/{x}/{y}.webp',
minZoom: 0,
maxZoom: 11,
attributions: 'ⓒ OpenStreetMap',
}),
preload: Infinity,
},
// 동아시아 상세 (줌 12-15)
eastAsiaLayer: {
source: new XYZ({
url: '/MAPS/EAST_ASIA_webp/{z}/{x}/{y}.webp',
minZoom: 12,
maxZoom: 15,
}),
preload: 0,
minZoom: 12,
zIndex: 1,
extent: transformExtent([110, 20, 140, 45], EPSG_4326, EPSG_3857),
},
// 한국 상세 (줌 16-17)
korLayer: {
source: new XYZ({
url: '/MAPS/KOR_webp/{z}/{x}/{y}.webp',
minZoom: 16,
maxZoom: 17,
}),
preload: Infinity,
minZoom: 16,
zIndex: 1,
extent: transformExtent([124, 32, 133, 39], EPSG_4326, EPSG_3857),
},
};
/**
* 베이스맵 레이어 생성
*/
export const createBaseLayers = () => {
const worldMap = new WebGLTileLayer(mapLayerConfig.worldLayer);
const eastAsiaMap = new WebGLTileLayer(mapLayerConfig.eastAsiaLayer);
const korMap = new WebGLTileLayer(mapLayerConfig.korLayer);
return {
worldMap,
eastAsiaMap,
korMap,
};
};
export default createBaseLayers;

1031
src/map/layers/shipLayer.js Normal file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

392
src/map/measure/measure.js Normal file
파일 보기

@ -0,0 +1,392 @@
/**
* 측정 도구 핵심 로직
* - MeasureSession: OL 객체 생명주기 관리
* - 거리/면적/거리환 설정 함수
* - 포맷 유틸리티
*
* 참조: mda-react-front/src/components/nav/rightNav/measure.ts
*/
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Draw } from 'ol/interaction';
import { Overlay } from 'ol';
import { createBox } from 'ol/interaction/Draw';
import { unByKey } from 'ol/Observable';
import { getArea, getLength } from 'ol/sphere';
import { LineString } from 'ol/geom';
// =====================
// MeasureSession 클래스
// =====================
/**
* 측정 세션: 생성한 OL 객체(레이어, 인터랙션, 오버레이, 리스너)
* 직접 추적하고, dispose() 번으로 일괄 정리.
*/
export class MeasureSession {
constructor(map) {
this.map = map;
this._layer = null;
this._interactions = [];
this._overlays = [];
this._listeners = [];
}
/** VectorLayer 생성+등록, source 반환 */
createLayer() {
const source = new VectorSource({ wrapX: false });
this._layer = new VectorLayer({ source, zIndex: 54 });
this.map.addLayer(this._layer);
return source;
}
/** Draw 인터랙션 등록+추적 */
addInteraction(draw) {
this.map.addInteraction(draw);
this._interactions.push(draw);
return draw;
}
/** 측정 툴팁 Overlay 생성+등록+추적 */
createTooltip() {
const el = document.createElement('div');
el.className = 'ol-tooltip ol-tooltip-measure';
const overlay = new Overlay({
element: el,
offset: [0, -15],
positioning: 'bottom-center',
});
this.map.addOverlay(overlay);
this._overlays.push(overlay);
return overlay;
}
/** 리스너 키 추적 (dispose 시 일괄 해제) */
addListener(key) {
if (key) this._listeners.push(key);
return key;
}
/** 모든 추적 객체 일괄 제거 */
dispose() {
this._listeners.forEach((key) => unByKey(key));
this._listeners = [];
this._interactions.forEach((i) => this.map.removeInteraction(i));
this._interactions = [];
this._overlays.forEach((o) => this.map.removeOverlay(o));
this._overlays = [];
if (this._layer) {
this.map.removeLayer(this._layer);
this._layer = null;
}
}
}
// =====================
// 포맷 유틸리티
// =====================
/**
* 거리 포맷: NM (km)
* @param {number} meters
* @returns {string} e.g. "5.2 NM (9.63 km)"
*/
export function formatDistance(meters) {
const nm = ((meters / 1000) * 0.5399568035).toFixed(1);
let sub;
if (meters > 1000) {
sub = (Math.round((meters / 1000) * 100) / 100) + ' km';
} else {
sub = (Math.round(meters * 100) / 100) + ' m';
}
return `${nm} NM (${sub})`;
}
/**
* 면적 포맷: km² 또는
* @param {number} sqMeters
* @returns {string}
*/
export function formatArea(sqMeters) {
if (sqMeters > 10000) {
return (Math.round((sqMeters / 1000000) * 100) / 100) + ' km\u00B2';
}
return (Math.round(sqMeters * 100) / 100) + ' m\u00B2';
}
/**
* 각도 계산 (북쪽 기준 시계방향)
* @param {number[]} start - [x, y] 좌표
* @param {number[]} end - [x, y] 좌표
* @param {number} [cog=0] - 선박 COG ()
* @returns {string} 각도 (0-360, 소수점 1자리)
*/
export function getCircleDegree(start, end, cog = 0) {
const x = Number(end[0]) - Number(start[0]);
const y = Number(end[1]) - Number(start[1]);
const radian = Math.atan2(y, x) * (180 / Math.PI);
let angle = 360 - (radian - 90);
angle = (angle - cog) % 360;
if (angle < 0) angle += 360;
return angle.toFixed(1);
}
/**
* 선분별 거리 툴팁 관리자
* 좌표 배열이 변경될 때마다 선분 개수에 맞춰 툴팁을 생성/업데이트/제거
*/
class SegmentTooltips {
constructor(session) {
this.session = session;
this.tooltips = []; // Overlay 배열
}
/**
* 좌표 배열을 받아 선분 중점에 거리 툴팁 배치
* @param {Array<number[]>} coords - 좌표 배열
*/
update(coords) {
const segCount = coords.length - 1;
// 부족하면 툴팁 추가 생성
while (this.tooltips.length < segCount) {
const el = document.createElement('div');
el.className = 'ol-tooltip ol-tooltip-segment';
const overlay = new Overlay({
element: el,
offset: [0, -10],
positioning: 'bottom-center',
});
this.session.map.addOverlay(overlay);
this.session._overlays.push(overlay);
this.tooltips.push(overlay);
}
// 남으면 숨기기
for (let i = segCount; i < this.tooltips.length; i++) {
this.tooltips[i].setPosition(undefined);
}
// 각 선분 거리 계산 및 표시
for (let i = 0; i < segCount; i++) {
const segLine = new LineString([coords[i], coords[i + 1]]);
const length = getLength(segLine);
const mid = [
(coords[i][0] + coords[i + 1][0]) / 2,
(coords[i][1] + coords[i + 1][1]) / 2,
];
this.tooltips[i].getElement().innerHTML = formatDistance(length);
this.tooltips[i].setPosition(mid);
}
}
/** 모든 선분 툴팁을 static 스타일로 고정 */
finalize() {
this.tooltips.forEach((overlay) => {
const el = overlay.getElement();
if (el && overlay.getPosition()) {
el.className = 'ol-tooltip ol-tooltip-segment-static';
}
});
}
}
// =====================
// 도구 설정 함수
// =====================
/**
* 거리 측정 설정 (LineString)
* @param {MeasureSession} session
* @param {VectorSource} source
*/
export function setupDistanceMeasure(session, source) {
const draw = new Draw({ source, type: 'LineString' });
session.addInteraction(draw);
let currentTooltip = null;
let segTooltips = null;
draw.on('drawstart', (evt) => {
const tooltip = session.createTooltip();
currentTooltip = tooltip;
segTooltips = new SegmentTooltips(session);
const geom = evt.feature.getGeometry();
const key = geom.on('change', (e) => {
const coords = e.target.getCoordinates();
const length = getLength(e.target);
tooltip.getElement().innerHTML = formatDistance(length);
tooltip.setPosition(e.target.getLastCoordinate());
// 선분별 거리 표시 (2개 이상 좌표일 때)
if (coords.length >= 2) {
segTooltips.update(coords);
}
});
session.addListener(key);
});
draw.on('drawend', () => {
if (currentTooltip) {
const el = currentTooltip.getElement();
el.className = 'ol-tooltip ol-tooltip-static';
currentTooltip.setOffset([0, -7]);
}
if (segTooltips) {
segTooltips.finalize();
}
});
}
/**
* 면적 측정 설정 (Polygon / Box / Circle)
* @param {MeasureSession} session
* @param {VectorSource} source
* @param {'Polygon'|'Box'|'Circle'} shape
*/
export function setupAreaMeasure(session, source, shape) {
// 메인 Draw 생성
let draw;
if (shape === 'Box') {
draw = new Draw({ source, type: 'Circle', geometryFunction: createBox() });
} else if (shape === 'Circle') {
draw = new Draw({ source, type: 'Circle' });
} else {
draw = new Draw({ source, type: 'Polygon' });
}
session.addInteraction(draw);
// Circle인 경우 반경 표시용 Line Draw 추가
let lineDraw = null;
let lineTooltip = null;
if (shape === 'Circle') {
lineTooltip = session.createTooltip();
lineDraw = new Draw({ source, type: 'LineString' });
session.addInteraction(lineDraw);
lineDraw.on('drawstart', (evt) => {
session.map.addOverlay(lineTooltip);
const geom = evt.feature.getGeometry();
const key = geom.on('change', (e) => {
const length = getLength(e.target);
const area = length * length * Math.PI;
lineTooltip.getElement().innerHTML = formatArea(area);
lineTooltip.setPosition(e.target.getFirstCoordinate());
});
session.addListener(key);
});
}
let currentTooltip = null;
let segTooltips = null;
draw.on('drawstart', (evt) => {
if (shape === 'Polygon' || shape === 'Box') {
currentTooltip = session.createTooltip();
segTooltips = new SegmentTooltips(session);
}
const geom = evt.feature.getGeometry();
const key = geom.on('change', (e) => {
if (shape === 'Polygon' || shape === 'Box') {
const areaValue = getArea(e.target);
currentTooltip.getElement().innerHTML = formatArea(areaValue);
currentTooltip.setPosition(e.target.getInteriorPoint().getCoordinates());
// 선분별 거리 표시
const coords = e.target.getCoordinates()[0]; // 외부 링
if (coords && coords.length >= 2) {
segTooltips.update(coords);
}
}
});
session.addListener(key);
});
draw.on('drawend', () => {
if (shape === 'Polygon' || shape === 'Box') {
if (currentTooltip) {
const el = currentTooltip.getElement();
el.className = 'ol-tooltip ol-tooltip-static';
currentTooltip.setOffset([0, -7]);
}
if (segTooltips) {
segTooltips.finalize();
}
}
if (shape === 'Circle' && lineDraw) {
lineDraw.finishDrawing();
}
});
}
/**
* 거리환 측정 설정 (Circle + Line 이중 Draw)
* 참조: mda-react-front measure.ts getCircleMeasureInteraction
*
* @param {MeasureSession} session
* @param {VectorSource} source
*/
export function setupRangeRingMeasure(session, source) {
// Line Draw (반경 거리 표시)
const lineTooltip = session.createTooltip();
const lineDraw = new Draw({ source, type: 'LineString' });
lineDraw.on('drawstart', (evt) => {
session.map.addOverlay(lineTooltip);
const geom = evt.feature.getGeometry();
const key = geom.on('change', (e) => {
const length = getLength(e.target);
lineTooltip.getElement().innerHTML = formatDistance(length);
lineTooltip.setPosition(e.target.getLastCoordinate());
});
session.addListener(key);
});
// Circle Draw (각도 표시)
const circleDraw = new Draw({ source, type: 'Circle' });
let circleTooltip = null;
let degree = '0.0';
circleDraw.on('drawstart', (evt) => {
circleTooltip = session.createTooltip();
const geom = evt.feature.getGeometry();
const key = geom.on('change', () => {
// sketchCoords_: [center, edge] — OL Draw 내부 좌표
const coords = evt.target.sketchCoords_;
if (coords && coords[0] && coords[1]) {
degree = getCircleDegree(coords[0], coords[1]);
}
circleTooltip.getElement().innerHTML = `각도: ${degree}°`;
circleTooltip.setPosition(geom.getCenter());
});
session.addListener(key);
});
circleDraw.on('drawend', () => {
lineDraw.finishDrawing();
if (circleTooltip) {
const el = circleTooltip.getElement();
el.className = 'ol-tooltip ol-tooltip-static';
// 최종 툴팁: 거리 + 각도
el.innerHTML = lineTooltip.getElement().innerHTML + ` 각도: ${degree}°`;
circleTooltip.setOffset([0, -7]);
}
session.map.removeOverlay(lineTooltip);
});
// circle → line 순서로 인터랙션 등록 (OL 이벤트 처리 순서)
session.addInteraction(circleDraw);
session.addInteraction(lineDraw);
}

파일 보기

@ -0,0 +1,41 @@
/* 측정 툴팁 스타일 */
/* 참조: mda-react-front/src/map/control.css */
.ol-tooltip {
position: relative;
padding: 0.4rem 0.8rem;
border-radius: 0.4rem;
font-size: 1.2rem;
white-space: nowrap;
pointer-events: none;
}
.ol-tooltip-measure {
background: rgba(255, 237, 169, 0.85);
color: #333;
font-weight: 600;
border: 0.1rem solid rgba(200, 180, 100, 0.5);
}
.ol-tooltip-static {
background: rgba(255, 237, 169, 0.85);
color: #333;
font-weight: 600;
border: 0.1rem solid rgba(200, 180, 100, 0.5);
}
.ol-tooltip-segment {
background: rgba(255, 255, 255, 0.8);
color: #555;
font-size: 1.1rem;
font-weight: 500;
border: 0.1rem solid rgba(180, 180, 180, 0.6);
}
.ol-tooltip-segment-static {
background: rgba(255, 255, 255, 0.75);
color: #666;
font-size: 1.1rem;
font-weight: 500;
border: 0.1rem solid rgba(180, 180, 180, 0.5);
}

파일 보기

@ -0,0 +1,72 @@
/**
* 측정 도구 React
* - Zustand 상태(activeMeasureTool, areaShape) OL 인터랙션 연결
* - ESC 키로 측정 취소
* - 도구 전환 이전 세션 자동 정리
*/
import { useEffect, useRef } from 'react';
import { useMapStore } from '../../stores/mapStore';
import {
MeasureSession,
setupDistanceMeasure,
setupAreaMeasure,
setupRangeRingMeasure,
} from './measure';
export default function useMeasure() {
const map = useMapStore((s) => s.map);
const activeTool = useMapStore((s) => s.activeMeasureTool);
const areaShape = useMapStore((s) => s.areaShape);
const clearMeasure = useMapStore((s) => s.clearMeasure);
const sessionRef = useRef(null);
// ESC 키로 측정 취소
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && activeTool) {
clearMeasure();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [activeTool, clearMeasure]);
// 도구 활성화/비활성화
useEffect(() => {
// 이전 세션 정리
if (sessionRef.current) {
sessionRef.current.dispose();
sessionRef.current = null;
}
if (!map || !activeTool) return;
// 면적 도구: 도형 선택 전까지 대기
if (activeTool === 'area' && !areaShape) return;
const session = new MeasureSession(map);
const source = session.createLayer();
sessionRef.current = session;
switch (activeTool) {
case 'distance':
setupDistanceMeasure(session, source);
break;
case 'area':
setupAreaMeasure(session, source, areaShape);
break;
case 'rangeRing':
setupRangeRingMeasure(session, source);
break;
default:
break;
}
return () => {
if (sessionRef.current) {
sessionRef.current.dispose();
sessionRef.current = null;
}
};
}, [map, activeTool, areaShape]);
}

13
src/pages/HomePage.jsx Normal file
파일 보기

@ -0,0 +1,13 @@
/**
* 페이지 (메인 지도 화면)
* - 지도는 MainLayout에서 렌더링
* - 여기서는 추가 UI 요소만 관리
*/
export default function HomePage() {
return (
<>
{/* 메인 페이지 추가 컨텐츠 */}
{/* 선박 정보 팝업, 검색 결과 등 */}
</>
);
}

파일 보기

@ -0,0 +1,134 @@
import { Route } from 'react-router-dom';
//
import WrapComponent from './layouts/WrapComponent';
import HeaderComponent from './layouts/HeaderComponent';
import SideComponent from './layouts/SideComponent';
import MainComponent from './layouts/MainComponent';
//
import Panel1Component from './pages/Panel1Component';
import Panel2Component from './pages/Panel2Component';
import Panel3Component from './pages/Panel3Component';
import Panel4Component from './pages/Panel4Component';
import Panel5Component from './pages/Panel5Component';
import Panel6Component from './pages/Panel6Component';
import Panel7Component from './pages/Panel7Component';
import Panel8Component from './pages/Panel8Component';
/**
* 퍼블리시 라우트 정의
* - /publish/* 하위에서 퍼블리시 파일들을 미리볼 있음
*/
const PublishRoutes = (
<>
{/* 기본 페이지 - 전체 레이아웃 미리보기 */}
<Route index element={<PublishHome />} />
{/* 개별 패널 미리보기 */}
<Route path="panel1/*" element={<Panel1Wrapper />} />
<Route path="panel2/*" element={<Panel2Wrapper />} />
<Route path="panel3/*" element={<Panel3Wrapper />} />
<Route path="panel4/*" element={<Panel4Wrapper />} />
<Route path="panel5/*" element={<Panel5Wrapper />} />
<Route path="panel6/*" element={<Panel6Wrapper />} />
<Route path="panel7/*" element={<Panel7Wrapper />} />
<Route path="panel8/*" element={<Panel8Wrapper />} />
{/* 전체 레이아웃 (원본 구조 그대로) */}
<Route path="full/*" element={<WrapComponent />} />
</>
);
//
function PublishHome() {
return (
<div className="publish-home">
<h1>퍼블리시 미리보기</h1>
<p>좌측 메뉴에서 확인할 페이지를 선택하세요.</p>
<div className="publish-info">
<h2>폴더 구조</h2>
<pre>
{`src/publish/
_incoming/ # 퍼블리시 파일 (원본)
layouts/ # 레이아웃 컴포넌트
pages/ # 페이지 컴포넌트
components/ # 공통 컴포넌트`}
</pre>
<h2>병합 방법</h2>
<ol>
<li> 퍼블리시 파일을 <code>_incoming/</code> 폴더에 복사</li>
<li>Claude에게 병합 요청</li>
<li>변경사항 확인 적용</li>
</ol>
</div>
</div>
);
}
//
function Panel1Wrapper() {
return (
<div className="panel-wrapper">
<Panel1Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel2Wrapper() {
return (
<div className="panel-wrapper">
<Panel2Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel3Wrapper() {
return (
<div className="panel-wrapper">
<Panel3Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel4Wrapper() {
return (
<div className="panel-wrapper">
<Panel4Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel5Wrapper() {
return (
<div className="panel-wrapper">
<Panel5Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel6Wrapper() {
return (
<div className="panel-wrapper">
<Panel6Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel7Wrapper() {
return (
<div className="panel-wrapper">
<Panel7Component isOpen={true} onToggle={() => {}} />
</div>
);
}
function Panel8Wrapper() {
return (
<div className="panel-wrapper">
<Panel8Component isOpen={true} onToggle={() => {}} />
</div>
);
}
export default PublishRoutes;

파일 보기

@ -0,0 +1,35 @@
import React, { useState } from 'react';
export default function FileUpload({ label = "파일 선택", inputId, maxLength = 25, placeholder = "선택된 파일 없음" }) {
const [fileName, setFileName] = useState('');
//
const truncateMiddle = (str, maxLen) => {
if (!str) return '';
if (str.length <= maxLen) return str;
const keep = Math.floor((maxLen - 3) / 2);
return str.slice(0, keep) + '...' + str.slice(str.length - keep);
};
const handleChange = (e) => {
const name = e.target.files[0]?.name || '';
setFileName(name);
};
return (
<div className="fileWrap">
<input
type="file"
id={inputId}
className="fileInput"
onChange={handleChange}
/>
<label htmlFor={inputId} className="fileLabel">
{label}
</label>
<span className="fileName">
{fileName ? truncateMiddle(fileName, maxLength) : placeholder}
</span>
</div>
);
}

파일 보기

@ -0,0 +1,24 @@
import { useState } from "react";
function Slider({ label = "", min = 0, max = 100, defaultValue = 50 }) {
const [value, setValue] = useState(defaultValue);
const percent = ((value - min) / (max - min)) * 100;
return (
<label className="rangeWrap">
<span className="rangeLabel">{label}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
style={{ "--percent": `${percent}%` }}
aria-label={label}
/>
</label>
);
}
export default Slider;

파일 보기

@ -0,0 +1,36 @@
import { Link } from "react-router-dom";
export default function HeaderComponent() {
return(
<header id="header">
<div className="logoArea"><Link to="/main" className="logo"><span className="blind">GIS 함정용</span></Link> <span className="logoTxt">GIS 함정용</span></div>
<aside>
<ul>
<li><Link to="/main" className="alram" title="알람"><i className="badge"></i><span className="blind">알람</span></Link></li>
<li className="setWrap">
<Link
to="/signal"
className="set"
title="설정"
><span className="blind">설정</span>
</Link>
<div className="setMenu">
<Link to="/signal">신호설정</Link>
<Link to="/signal/custom">맞춤설정</Link>
</div>
</li>
<li>
<Link
to="/mypage"
className="user"
title="마이페이지"
><span className="blind">마이페이지</span>
</Link>
</li>
</ul>
</aside>
</header>
)
}

파일 보기

@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import TopComponent from "../pages/TopComponent";
export default function MainComponent() {
return (
<main id="main">
<TopComponent />
<Outlet />
</main>
);
}

파일 보기

@ -0,0 +1,57 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
/**
* 퍼블리시 레이아웃
* - 퍼블리시 파일들을 미리보기 위한 레이아웃
* - 상단에 네비게이션 제공
*/
export default function PublishLayout() {
const location = useLocation();
const currentPath = location.pathname;
const menuItems = [
{ path: '/publish', label: '메인', exact: true },
{ path: '/publish/panel1', label: 'Panel1 (선박)' },
{ path: '/publish/panel2', label: 'Panel2 (위성)' },
{ path: '/publish/panel3', label: 'Panel3 (기상)' },
{ path: '/publish/panel4', label: 'Panel4 (분석)' },
{ path: '/publish/panel5', label: 'Panel5 (타임라인)' },
{ path: '/publish/panel6', label: 'Panel6 (AI모드)' },
{ path: '/publish/panel7', label: 'Panel7 (리플레이)' },
{ path: '/publish/panel8', label: 'Panel8 (항적조회)' },
];
const isActive = (path, exact) => {
if (exact) return currentPath === path;
return currentPath.startsWith(path);
};
return (
<div className="publish-wrapper">
{/* 퍼블리시 네비게이션 */}
<nav className="publish-nav">
<div className="publish-nav-header">
<Link to="/"> 메인으로</Link>
<span className="publish-title">퍼블리시 미리보기</span>
</div>
<ul className="publish-menu">
{menuItems.map((item) => (
<li key={item.path}>
<Link
to={item.path}
className={isActive(item.path, item.exact) ? 'active' : ''}
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
{/* 퍼블리시 콘텐츠 */}
<div className="publish-content">
<Outlet />
</div>
</div>
);
}

파일 보기

@ -0,0 +1,94 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import NavComponent from "../pages/NavComponent";
import Panel1Component from "../pages/Panel1Component";
import Panel2Component from "../pages/Panel2Component";
import Panel3Component from "../pages/Panel3Component";
import Panel4Component from "../pages/Panel4Component";
import Panel5Component from "../pages/Panel5Component";
import Panel6Component from "../pages/Panel6Component";
import Panel7Component from "../pages/Panel7Component";
import Panel8Component from "../pages/Panel8Component";
import DisplayComponent from "../pages/DisplayComponent";
export default function SideComponent() {
const navigate = useNavigate();
//const location = useLocation();
//
const [activePanel, setActivePanel] = useState("gnb1");
//
const [isPanelOpen, setIsPanelOpen] = useState(true);
const handleTogglePanel = () => setIsPanelOpen(prev => !prev);
// Display
const [displayTab, setDisplayTab] = useState("filter");
/* =========================
Nav 클릭 패널 + 라우팅
========================= */
const handleChangePanel = (key) => {
setIsPanelOpen(true);
//setActivePanel(key); // navigate
switch (key) {
case "gnb8": //
setActivePanel("gnb8");
navigate("/track");
break;
case "gnb7": //
setActivePanel("gnb7");
navigate("/replay");
break;
case "filter": //
case "layer": //
setActivePanel(key);
setDisplayTab(key);
// /
navigate("/main");
break;
default:
setActivePanel(key);
navigate("/main");
break;
}
};
/* =========================
공통 props
========================= */
const panelProps = {
isOpen: isPanelOpen,
onToggle: handleTogglePanel,
};
return (
<section id="sidePanel">
<NavComponent
activeKey={activePanel}
onChange={handleChangePanel}
/>
<div className="sidePanelContent">
{activePanel === "gnb1" && <Panel1Component {...panelProps} />}
{activePanel === "gnb2" && <Panel2Component {...panelProps} />}
{activePanel === "gnb3" && <Panel3Component {...panelProps} />}
{activePanel === "gnb4" && <Panel4Component {...panelProps} />}
{activePanel === "gnb5" && <Panel5Component {...panelProps} />}
{activePanel === "gnb6" && <Panel6Component {...panelProps} />}
{activePanel === "gnb7" && <Panel7Component {...panelProps} />}
{activePanel === "gnb8" && <Panel8Component {...panelProps} />}
{(activePanel === "filter" || activePanel === "layer") && (
<DisplayComponent {...panelProps}
activeTab={displayTab}/>
)}
</div>
</section>
);
}

파일 보기

@ -0,0 +1,131 @@
import { useState } from "react"
export default function ToolComponent() {
const [isLegendOpen, setIsLegendOpen] = useState(false);
return(
<section id="tool">
{/* 툴바 */}
<div className="toolBar">
<ul className="toolItem space">
<li><button type="button" className="tool01">초기화</button></li>
<li><button type="button" className="tool02">선박통합</button></li>
<li><button type="button" className="tool03">구역설정</button></li>
</ul>
<ul className="toolItem mt30">
<li><button type="button" className="tool04">거리</button></li>
<li><button type="button" className="tool05">면적</button></li>
<li><button type="button" className="tool06">거리환</button></li>
</ul>
<ul className="toolItem space mt30">
<li><button type="button" className="tool07">인쇄</button></li>
<li><button type="button" className="tool08">다운로드</button></li>
</ul>
</div>
{/* 맵컨트롤 툴바 */}
<div className="control">
<ul className="toolItem zoom">
<li><button type="button" className="zoomin" title="확대"><span className="blind">확대</span></button></li>
<li className="num">7</li>
<li><button type="button" className="zoomout" title="축소"><span className="blind">축소</span></button></li>
</ul>
<ul className="toolItem space mt30">
<li><button
type="button"
className={`legend ${isLegendOpen ? "active" : ""}`}
onClick={() => setIsLegendOpen(prev => !prev)}
>
범례</button>
</li>
<li><button type="button" className="minimap">미니맵</button></li>
</ul>
</div>
{/* 범례 */}
{isLegendOpen && (
<div className="legendWrap">
<ul className="legendList">
<li className="legendItem">
<span className="legendLabel"><img src="/images/ico_legend_all.svg" alt="통합" />통합</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_china.svg" alt="중국어선" />중국어선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_china_permit.svg" alt="중국어선허가" />중국어선허가</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_japan.svg" alt="일본어선" />일본어선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_danger.svg" alt="위험물" />위험물</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_passenger.svg" alt="여객선" />여객선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_vessel.svg" alt="함정" />함정</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_vessel_radar.svg" alt="함정-RADAR" />함정-RADAR</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_general.svg" alt="일반" />일반</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_vts_general.svg" alt="VTS-일반" />VTS-일반</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_vts_radar.svg" alt="VTS-RADAR" />VTS-RADAR</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_vpass.svg" alt="VPASS일반" />VPASS일반</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_enav_fishing.svg" alt="ENAV어선" />ENAV어선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_enav_danger.svg" alt="ENAV위험물" />ENAV위험물</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_enav_cargo.svg" alt="ENAV화물선" />ENAV화물선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_enav_government.svg" alt="ENAV관공선" />ENAV관공선</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_enav_general.svg" alt="ENAV일반" />ENAV일반</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_dmfhf.svg" alt="D-MF/HF" />D-MF/HF</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_aircraft.svg" alt="항공기" />항공기</span>
<span className="legendValue">0</span>
</li>
<li>
<span className="legendLabel"><img src="/images/ico_legend_nll.svg" alt="NLL" />NLL</span>
<span className="legendValue">0</span>
</li>
</ul>
</div>
)}
</section>
)
}

파일 보기

@ -0,0 +1,15 @@
import { Outlet } from "react-router-dom";
import HeaderComponent from "./HeaderComponent";
import SideComponent from "./SideComponent";
import ToolComponent from "./ToolComponent";
export default function WrapComponent() {
return (
<div id="wrap" className="wrap">
<HeaderComponent />
<SideComponent />
<Outlet /> {/* Main 영역 */}
<ToolComponent />
</div>
);
}

파일 보기

@ -0,0 +1,48 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Analysis1Component() {
const navigate = useNavigate();
return (
<section id="Analysis2Component">
{/* 위성 영상 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w46r">
<div className="puHeader">
<span className="title">관심 해역 설정</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody p0">
<div className="rowSB gap10">
<button type="button"
className="drawBtn"
onClick={() => navigate("/analysis/result")}
>
<i className="rect"></i>
사각형 그리기
</button>
<button type="button"
className="drawBtn"
onClick={() => navigate("/analysis/result")}
>
<i className="polygon"></i>
다각형 그리기
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,129 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Analysis2Component() {
const navigate = useNavigate();
return (
<section id="Analysis2Component">
{/* 위성 영상 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w61r">
<div className="puHeader">
<span className="title">관심 해역 설정</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<div className="rowSB gap10 pb10">
<button type="button" className="drawBtn sm">사각형 그리기<i className="rect"></i></button>
<button type="button" className="drawBtn sm">다각형 그리기<i className="polygon"></i></button>
</div>
<table className="table">
<caption>관심 해역 설정 - 해상영역명, 설정 옵션, 좌표,영역 옵션,해상영역명 크기, 해상영역명 색상,윤곽선 굵기,윤곽선 종류,윤곽선 색상,채우기 색상 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">해상영역명</th>
<td colSpan={3}><input type="text" placeholder="해상영역명" aria-label="해상영역명" /></td>
</tr>
<tr>
<th scope="row">설정 옵션</th>
<td colSpan={3}>
<div className="row">
<label class="checkbox checkL"><input type="checkbox" /><span>사용 여부</span></label>
<label class="checkbox checkL"><input type="checkbox" /><span>알림 여부</span></label>
<label class="checkbox checkL"><input type="checkbox" /><span>공유 여부</span></label>
</div>
</td>
</tr>
<tr>
<th scope="row">좌표</th>
<td colSpan={3}>[124,96891368166156, 36.37855817450263]<br />
[125,25105622872591, 36.37855817450263]<br />
[125,25105622872591, 36.37855817450263]<br />
[125,25105622872591, 36.37855817450263]<br />
[125,25105622872591, 36.37855817450263]
</td>
</tr>
<tr>
<th scope="row">영역 옵션</th>
<td colSpan={3}>
<div className="row">
<label class="checkbox checkL"><input type="checkbox" /><span>해상영역 표시</span></label>
<label class="checkbox checkL"><input type="checkbox" /><span>해상영역명 표시</span></label>
</div>
</td>
</tr>
<tr>
<th scope="row">해상영역명 크기</th>
<td>
<div className="numInput">
<input type="number" placeholder="0" min="" max="" aria-label="해상영역명 크기" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</td>
<th scope="row">해상영역명 색상</th>
<td><i className="colorBox" style={{ backgroundColor: "#000" }}></i></td>
</tr>
<tr>
<th scope="row">윤곽선 굵기 </th>
<td>
<div className="numInput">
<input type="number" placeholder="0" min="" max="" aria-label="윤곽선 굵기 " />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</td>
<th scope="row">윤곽선 종류 </th>
<td>
<select aria-label="윤곽선 종류 ">
<option value="">선택</option>
<option value="">실선</option>
<option value="">점선</option>
</select>
</td>
</tr>
<tr>
<th scope="row">윤곽선 색상 </th>
<td><i className="colorBox" style={{ backgroundColor: "#FF0000" }}></i></td>
<th scope="row">채우기 색상 </th>
<td><i className="colorBox" style={{ backgroundColor: "#7BEBB1" }}></i></td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,198 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Analysis3Component() {
const navigate = useNavigate();
return (
<section id="Analysis2Component">
{/* 위성 영상 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w61r">
<div className="puHeader">
<span className="title">관심 해역 분석 등록</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody noSc">
<div className="analyRow">
{/* 지도캡쳐/테이블 영역 */}
<div className="reg">
<div className="mapCapture"></div>
<button type="button" className="btn btnMS basic icoCapture">지도캡쳐</button>
<table className="table">
<caption>관심 해역 분석 등록 - 제목, 상세 내역, 공유 여부,공유 그룹 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">제목</th>
<td><input type="text" placeholder="제목" aria-label="제목" /></td>
</tr>
<tr>
<th scope="row">상세 내역</th>
<td>
<textarea placeholder="내용을 입력하세요" aria-label="상세내역"></textarea>
</td>
</tr>
<tr>
<th scope="row">공유 여부</th>
<td >
<div className="row">
<label class="radio radioL"> <input type="radio" name="share" /> <span>공유</span></label>
<label class="radio radioL"> <input type="radio" name="share" /> <span>공유 안함</span></label>
</div>
</td>
</tr>
<tr>
<th scope="row">공유 그룹 </th>
<td>
<select aria-label="윤곽선 종류 ">
<option value="">전체</option>
<option value="">부서</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
{/* 관심영역 체크박스 목록 -스크롤됨 */}
<div className="list" >
<div className="tit14">관심영역 목록</div>
<ul className="lineList rowSB">
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>진입진출 테스트</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>테스트 01</span>
</label>
<button type="button" className="btnMap"></button>
</li>
</ul>
</div>
</div>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,235 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Analysis4Component() {
const navigate = useNavigate();
return (
<section id="Analysis2Component">
{/* 위성 영상 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w61r">
<div className="puHeader">
<div className="headerL">
<span className="title">350 대해구도</span>
<span className="subTxt">조회시간: 2026-07-00 17:15:13</span>
</div>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody noSc">
<div className="trenchRow">
{/* 지도캡쳐/테이블 영역 */}
<div className="list">
<div className="tit14">통항 선박</div>
<table className="table dataView">
<caption>통항 선박 - 선박 종류, 승선원, 위험물 운반, 공유 여부 그룹 대한 표입니다</caption>
<colgroup>
<col style={{ width: '135px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">카고()</th>
<td>0</td>
</tr>
<tr>
<th scope="row">카고 승성원()</th>
<td>-</td>
</tr>
<tr>
<th scope="row">탱커수()</th>
<td></td>
</tr>
<tr>
<th scope="row">탱커 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">위험물 운반석()</th>
<td></td>
</tr>
<tr>
<th scope="row">위험물 운반선 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">위험물 ()</th>
<td></td>
</tr>
<tr>
<th scope="row">어선()</th>
<td></td>
</tr>
<tr>
<th scope="row">어선 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">기타 어선()</th>
<td></td>
</tr>
<tr>
<th scope="row">기타 어선 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">여객선()</th>
<td></td>
</tr>
<tr>
<th scope="row">유도선()</th>
<td></td>
</tr>
<tr>
<th scope="row">유도선 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">기타 선박()</th>
<td></td>
</tr>
<tr>
<th scope="row">기타 선박 승선원()</th>
<td></td>
</tr>
<tr>
<th scope="row">함정수()</th>
<td></td>
</tr>
</tbody>
</table>
</div>
{/* 관심영역 체크박스 목록 -스크롤됨 */}
<div className="list" >
<div className="tit14">신호별</div>
<table className="table dataView">
<caption>신호별 - 제목, 상세 내역, 공유 여부,공유 그룹 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '135px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">AIS</th>
<td>0</td>
</tr>
<tr>
<th scope="row">V-PASS</th>
<td>-</td>
</tr>
<tr>
<th scope="row">VHF</th>
<td></td>
</tr>
<tr>
<th scope="row">MFHF</th>
<td></td>
</tr>
</tbody>
</table>
<div className="tit14">E-NAV</div>
<table className="table dataView">
<caption>E-NAV - 여객선, 어선, 카고, 관공선, 기타 선박과 공유 정보 대한 표입니다.</caption>
<colgroup>
<col style={{ width: '135px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">E-NAV 여객선()</th>
<td>0</td>
</tr>
<tr>
<th scope="row">E-NAV 어선()</th>
<td>-</td>
</tr>
<tr>
<th scope="row">E-NAV 카고()</th>
<td></td>
</tr>
<tr>
<th scope="row">E-NAV 관공선()</th>
<td></td>
</tr>
<tr>
<th scope="row">E-NAV 기타()</th>
<td></td>
</tr>
</tbody>
</table>
<div className="tit14">기상정보</div>
<table className="table dataView">
<caption>기상정보 - 유향, 유속, 유의 파고, 파향, 파주기, 풍속, 풍향 나타내는 표입니다</caption>
<colgroup>
<col style={{ width: '135px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">유향</th>
<td>0</td>
</tr>
<tr>
<th scope="row">유속</th>
<td>-</td>
</tr>
<tr>
<th scope="row">유의 파고</th>
<td>0.5(m)</td>
</tr>
<tr>
<th scope="row">파향</th>
<td>
<div className="rowR gap5">
<img src="/images/ico_dir_arrow.svg" alt="파향" className="arrowDirect"
style={{ transform: 'rotate(350deg)' }}
/>
350(°)
</div>
</td>
</tr>
<tr>
<th scope="row">파주기</th>
<td>3.7(s)</td>
</tr>
<tr>
<th scope="row">풍속</th>
<td>9.2(m/s)</td>
</tr>
<tr>
<th scope="row">풍향</th>
<td>
<div className="rowR gap5">
<img src="/images/ico_dir_arrow.svg" alt="풍향" className="arrowDirect"
style={{ transform: 'rotate(45deg)' }}
/>
45(°)
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div className="puFooter">
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,355 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import Slider from '../components/Slider';
export default function DisplayComponent({ isOpen, onToggle, activeTab: externalTab }) {
const navigate = useNavigate();
//
const [opacity, setOpacity] = useState(70);
//
const [isAccordionOpen1, setIsAccordionOpen1] = useState(true); //
const [isAccordionOpen2, setIsAccordionOpen2] = useState(true); //
const [isAccordionOpen3, setIsAccordionOpen3] = useState(true); //
const [isAccordionOpen4, setIsAccordionOpen4] = useState(false); //
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
const toggleAccordion3 = () => setIsAccordionOpen3(prev => !prev);
const toggleAccordion4 = () => setIsAccordionOpen4(prev => !prev);
//
const [activeTab, setActiveTab] = useState('filter');
useEffect(() => {
if (externalTab) {
setActiveTab(externalTab);
}
}, [externalTab]);
const tabs = [
{ id: 'filter', label: '필터' },
{ id: 'layer', label: '레이어' },
];
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 탭 버튼 */}
<div className="tabBox p0">
<div className="tabDefault borderLess">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap scrollY ${activeTab === 'filter' ? 'is-active' : ''}`}>
<div className="tabWrapInner">
<div className="tabWrapCnt">
{/* 스위치그룹 01 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>신호</span>
<label className="switch"> <input type="checkbox" aria-label="신호"/> <span></span></label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen1 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen1}
onClick={toggleAccordion1}
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen1 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>AIS</span>
<label className="switch sm"> <input type="checkbox" aria-label="AIS" /> <span></span></label>
</li>
<li>
<span>V-PASS</span>
<label className="switch sm"> <input type="checkbox" aria-label="V-PASS" /> <span></span></label>
</li>
<li>
<span>VTS_AIS</span>
<label className="switch sm"> <input type="checkbox" aria-label="VTS_AIS" /> <span></span></label>
</li>
<li>
<span>D_MF_HF</span>
<label className="switch sm"> <input type="checkbox" aria-label="D_MF_HF" /> <span></span></label>
</li>
<li>
<span>VTS_RADAR</span>
<label className="switch sm"> <input type="checkbox" aria-label="VTS_RADAR" /> <span></span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 02 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>선종/기종</span>
<label className="switch"> <input type="checkbox" aria-label="선종/기종" /> <span></span></label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen2 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen2}
onClick={toggleAccordion2}
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen2 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>어선</span>
<label className="switch sm"> <input type="checkbox" aria-label="어선" /> <span></span></label>
</li>
<li>
<span>여객선</span>
<label className="switch sm"> <input type="checkbox" aria-label="여객선" /> <span></span></label>
</li>
<li>
<span>화물선</span>
<label className="switch sm"> <input type="checkbox" aria-label="화물선" /> <span></span></label>
</li>
<li>
<span>유조선</span>
<label className="switch sm"> <input type="checkbox" aria-label="유조선" /> <span></span></label>
</li>
<li>
<span>관공선</span>
<label className="switch sm"> <input type="checkbox" aria-label="관공선" /> <span></span></label>
</li>
<li>
<span>함정</span>
<label className="switch sm"> <input type="checkbox" aria-label="함정" /> <span></span></label>
</li>
<li>
<span>항공기</span>
<label className="switch sm"> <input type="checkbox" aria-label="항공기" /> <span></span></label>
</li>
<li>
<span>기타</span>
<label className="switch sm"> <input type="checkbox" aria-label="기타" /> <span></span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 03 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>국적</span>
<label className="switch"> <input type="checkbox" aria-label="국적" /> <span></span></label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen3 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen3}
onClick={toggleAccordion3}
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen3 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>한국</span>
<label className="switch sm"> <input type="checkbox" aria-label="한국" /> <span></span></label>
</li>
<li>
<span>중국</span>
<label className="switch sm"> <input type="checkbox" aria-label="중국" /> <span></span></label>
</li>
<li>
<span>일본</span>
<label className="switch sm"> <input type="checkbox" aria-label="일본" /> <span></span></label>
</li>
<li>
<span>북한</span>
<label className="switch sm"> <input type="checkbox" aria-label="북한" /> <span></span></label>
</li>
<li>
<span>기타</span>
<label className="switch sm"> <input type="checkbox" aria-label="기타" /> <span></span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 04 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>AI 모드</span>
<label className="switch"> <input type="checkbox" aria-label="AI 모드" /> <span></span></label>
</div>
<button
type="button"
className={`toggleBtn ${isAccordionOpen4 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen4}
onClick={toggleAccordion4}
></button>
</div>
{/* 여기서부터 토글 */}
<div className={`switchBox ${isAccordionOpen4 ? 'is-open' : ''}`}>
<ul className="switchList">
<li>
<span>MMSI 변조</span>
<label className="switch sm"> <input type="checkbox" aria-label="MMSI 변조" /> <span></span></label>
</li>
<li>
<span>중국 허가선박</span>
<label className="switch sm"> <input type="checkbox" aria-label="중국 허가선박" /> <span></span></label>
</li>
<li>
<span>관공선</span>
<label className="switch sm"> <input type="checkbox" aria-label="관공선" /> <span></span></label>
</li>
<li>
<span>비정상 접촉</span>
<label className="switch sm"> <input type="checkbox" aria-label="비정상 접촉" /> <span></span></label>
</li>
<li>
<span>비정상 선박</span>
<label className="switch sm"> <input type="checkbox" aria-label="비정상 선박" /> <span></span></label>
</li>
<li>
<span>북한선박</span>
<label className="switch sm"> <input type="checkbox" aria-label="북한선박" /> <span></span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 스위치그룹 05 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>다크시그널</span>
</div>
<label className="switch"> <input type="checkbox" aria-label="다크시그널" /> <span></span></label>
</div>
</div>
{/* 스위치그룹 06 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<span>위험물</span>
</div>
<label className="switch"> <input type="checkbox" aria-label="위험물" /> <span></span></label>
</div>
</div>
{/* 스위치그룹 07 */}
<div className="switchGroup">
<div className="sgHeader">
<div className="colL">
<i className="favship"></i>
<span>관심선박</span>
</div>
<label className="switch"> <input type="checkbox" aria-label="관심선박" /> <span></span></label>
</div>
</div>
</div>
{/* 버튼영역 */}
<div className="btnBox">
<button type="button" className="btn btnLine">저장</button>
</div>
</div>
</div>
{/* 탭 콘텐츠 02 */}
<div className={`tabWrap ${activeTab === 'layer' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">레이어</div>
</div>
<div className="tabBtm noLine">
<div className="tabBtmInner">
<ul className="lineList tabBtmCnt">
<li className="rowSB">
<label className="checkbox checkL">
<input type="checkbox" />
<span>배경지도</span>
</label>
<div className="row">
<span>투명도 조절</span>
<div>
<Slider label="투명도 조절" />
</div>
</div>
</li>
<li className="p0">
<ul className="optionList">
<li>
<span>전자해도</span>
<label className="radio"> <input type="radio" name="map" aria-label="전자해도" /> <span></span></label>
</li>
<li>
<span>일반지도</span>
<label className="radio"> <input type="radio" name="map" aria-label="일반지도" /> <span></span></label>
</li>
<li>
<span>영상지도</span>
<label className="radio"> <input type="radio" name="map" aria-label="영상지도" /> <span></span></label>
</li>
</ul>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>해경관할구역</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>검문검색위치</span>
</label>
</li>
</ul>
<div className='btnBox'>
<button
type="button"
className="btn btnLine w15r"
onClick={() => navigate("/layer/register")}
>레이어 등록</button>
</div>
</div>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -0,0 +1,4 @@
export default function EmptyMain() {
return null; //
}

파일 보기

@ -0,0 +1,91 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
import FileUpload from '../components/FileUpload';
export default function LayerComponent() {
const navigate = useNavigate();
return (
<section id="LayerComponent">
{/* 레이어등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title">레이어 등록</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>레이어등록 - 레이어명, 첨부파일, 공유설정 대한 내용을 나타내는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">레이어명 <span className="required">*</span></th>
<td><input type="text" placeholder="" aria-label="레이어명" /></td>
</tr>
<tr>
<th scope="row">첨부파일 <span className="required">*</span></th>
<td>
<div className="rowC">
<FileUpload
label="파일 선택"
inputId="layerFile"
maxLength={35}
placeholder="선택된 파일 없음"
/>
<span className="helpTxt">geojson 파일을 첨부해 주세요. </span>
</div>
</td>
</tr>
<tr>
<th scope="row">공유설정</th>
<td>
<div className="row flx1">
<label className="checkbox checkL w10r">
<input type="checkbox" />
<span>공유 여부</span>
</label>
<label className="">
<span className="blind">공유설정</span>
<select>
<option value="">전체</option>
<option value="">부서</option>
<option value="">개인</option>
<option value="">개인 & 부서</option>
</select>
</label>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,208 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export default function MyPageComponent() {
const navigate = useNavigate();
//
// null | "password" | "cert"
const [subPopup, setSubPopup] = useState(null);
return (
<section id="MyPageComponent">
{/* 내 정보 조회 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title"> 정보 조회</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>
정보 조회 - 아이디, 비밀번호, 이름, 이메일, 직급, 상세소속, 공인인증서 삭제
</caption>
<colgroup>
<col style={{ width: "30%" }} />
<col />
</colgroup>
<tbody>
<tr>
<th scope="row">아이디</th>
<td>admin222</td>
</tr>
<tr>
<th scope="row">비밀번호</th>
<td>
<button
type="button"
className="btn btnM deep flx0"
onClick={() => setSubPopup("password")}
>
비밀번호 변경
</button>
</td>
</tr>
<tr>
<th scope="row">이름</th>
<td>ADMIN</td>
</tr>
<tr>
<th scope="row">이메일</th>
<td>123@korea.kr</td>
</tr>
<tr>
<th scope="row">직급</th>
<td>경감</td>
</tr>
<tr>
<th scope="row">상세소속</th>
<td></td>
</tr>
<tr>
<th scope="row">공인인증서 삭제</th>
<td>
<button
type="button"
className="btn btnM deep flx0"
onClick={() => setSubPopup("cert")}
>
공인인증서 삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button type="button" className="btn dark">초기화</button>
</div>
</div>
</div>
</div>
{/* 딤 + 서브 팝업 */}
{subPopup && (
<div className="popupDim">
{/* 비밀번호 변경 */}
{subPopup === "password" && (
<div className="popupUtill">
<div className="puHeader">
<span className="title">비밀번호 수정</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => setSubPopup(null)}
/>
</div>
<div className="puBody">
<table className="table">
<caption>
비밀번호 수정 - 현재 비밀번호, 비밀번호, 비밀번호 확인
</caption>
<colgroup>
<col style={{ width: "30%" }} />
<col />
</colgroup>
<tbody>
<tr>
<th scope="row">현재 비밀번호</th>
<td>
<input type="password" aria-label="현재 비밀번호" />
</td>
</tr>
<tr>
<th scope="row"> 비밀번호</th>
<td>
<input type="password" aria-label="새 비밀번호" />
</td>
</tr>
<tr>
<th scope="row"> 비밀번호 확인</th>
<td>
<input type="password" aria-label="새 비밀번호 확인" />
</td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
onClick={() => setSubPopup(null)}
>
수정
</button>
<button
type="button"
className="btn dark"
onClick={() => setSubPopup(null)}
>
취소
</button>
</div>
</div>
</div>
)}
{/* 공인인증서 삭제 */}
{subPopup === "cert" && (
<div className="popupUtill cert">
<div className="puHeader">
<span className="title">공인인증서 삭제</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => setSubPopup(null)}
/>
</div>
<div className="puBody">
<div className="puTxtBox">
공인인증서를 삭제 하시겠습니까?
</div>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
onClick={() => setSubPopup(null)}
>
삭제
</button>
<button
type="button"
className="btn dark"
onClick={() => setSubPopup(null)}
>
취소
</button>
</div>
</div>
</div>
)}
</div>
)}
</section>
);
}

파일 보기

@ -0,0 +1,71 @@
export default function NavComponent({ activeKey, onChange }) {
const gnbList = [
{ key: 'gnb1', class: 'gnb1', label: '선박' },
{ key: 'gnb2', class: 'gnb2', label: '위성' },
{ key: 'gnb3', class: 'gnb3', label: '기상' },
{ key: 'gnb4', class: 'gnb4', label: '분석' },
{ key: 'gnb5', class: 'gnb5', label: '타임라인' },
{ key: 'gnb6', class: 'gnb6', label: 'AI모드' },
{ key: 'gnb7', class: 'gnb7', label: '리플레이' },
{ key: 'gnb8', class: 'gnb8', label: '항적조회' },
];
const sideList = [
{ key: 'filter', class: 'filter', label: '필터' },
{ key: 'layer', class: 'layer', label: '레이어' },
];
return(
<nav id="nav">
{/* <ul className="gnb">
<li><button type="button" className="gnb1 active" title="선박" aria-label="선박"></button></li>
<li><button type="button" className="gnb2" title="위성" aria-label="위성"></button></li>
<li><button type="button" className="gnb3" title="기상" aria-label="기상"></button></li>
<li><button type="button" className="gnb4" title="분석" aria-label="분석"></button></li>
<li><button type="button" className="gnb5" title="타임라인" aria-label="타임라인"></button></li>
<li><button type="button" className="gnb6" title="AI모드" aria-label="AI모드"></button></li>
<li><button type="button" className="gnb7" title="리플레이" aria-label="리플레이"></button></li>
<li><button type="button" className="gnb8" title="항적조회" aria-label="항적조회"><</button></li>
</ul>
<ul className="side">
<li><button type="button" className="filter" title="필터" aria-label="필터"></button></li>
<li><button type="button" className="layer" title="레이어" aria-label="레이어"></button></li>
</ul> */}
<ul className="gnb">
{gnbList.map(item => (
<li key={item.key}>
<button
type="button"
className={`${item.class} ${activeKey === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
aria-label={item.label}
title={item.label}
>
<span className="blind">{item.label}</span>
</button>
</li>
))}
</ul>
<ul className="side">
{sideList.map(item => (
<li key={item.key}>
<button
type="button"
className={`${item.class} ${activeKey === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
aria-label={item.label}
title={item.label}
>
<span className="blind">{item.label}</span>
</button>
</li>
))}
</ul>
</nav>
)
}

파일 보기

@ -0,0 +1,727 @@
import { useState, useEffect } from 'react';
import { Link } from "react-router-dom";
import Panel1DetailComponent from './Panel1DetailComponent';
export default function Panel1Component({ isOpen, onToggle }) {
//
const [view, setView] = useState('list'); // list | detail
//
const [isAccordionOpen1, setIsAccordionOpen1] = useState(false); //
const [isAccordionOpen2, setIsAccordionOpen2] = useState(false); //
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
//
const [activeTab, setActiveTab] = useState('ship01');
const tabs = [
{ id: 'ship01', label: '선박검색' },
{ id: 'ship02', label: '허가선박' },
{ id: 'ship03', label: '제재단속' },
{ id: 'ship04', label: '침몰선박' },
{ id: 'ship05', label: '선박입출항' },
{ id: 'ship06', label: '관심선박' }
];
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 👉 상세 화면일 때 */}
{view === 'detail' ? (
<Panel1DetailComponent
isOpen={isOpen}
onToggle={onToggle}
onBack={() => setView('list')}
/>
) : (
<>
{/* ===== 목록 화면 ===== */}
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap ${activeTab === 'ship01' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">선박 검색</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>선종</span>
<select>
<option value="">전체</option>
<option value="">어선</option>
<option value="">함정</option>
<option value="">여객선</option>
<option value="">카고</option>
<option value="">탱커</option>
<option value="">관공선</option>
<option value="">기타</option>
<option value="">낚시어선</option>
</select>
</label>
<label>
<span>국적</span>
<select>
<option value="">전체</option>
<option value="">한국</option>
<option value="">미국</option>
<option value="">중국</option>
<option value="">일본</option>
<option value="">북한</option>
<option value="">기타</option>
</select>
</label>
</li>
<li>
<label>
<span>타겟ID</span>
<input type="text" placeholder="타겟ID" />
</label>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
{/* 아코디언 1 */}
<div className={`accordion ${isAccordionOpen1 ? 'is-open' : ''}`}>
<li>
<label>
<span>위험물</span>
<input type="text" placeholder="타겟ID" />
</label>
<label className="checkbox">
<input type="checkbox" />
<span className="w70">MMSI / 호출부호 변경이력</span>
</label>
</li>
<li>
<label>
<span>승선원수</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>-</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>너비(m)</span>
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</label>
</li>
</div>
{/* 여기까지 아코디언1 */}
<li>
<button
type="button"
className={`btn btnS semi btnToggle ${isAccordionOpen1 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen1}
onClick={toggleAccordion1}
>
상세검색
{isAccordionOpen1 ? ' 닫기' : ' 열기'}
</button>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
{/* <div className="schbox mtb24">
<ul>
<li>
<input type="text" className="schInput" placeholder="대표검도" />
</li>
<li>
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div> */}
</div>
<div className="tabBtm">
<ul className="colList line">
<li>
<Link to="/ship" className="active">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle red"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle orng"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 02 */}
<div className={`tabWrap ${activeTab === 'ship02' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">허가선박</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>타겟 ID</span>
<input type="text" placeholder="타겟 ID" />
</label>
</li>
<li>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<div className="detailWrap">
{/* 선박정보 박스 */}
<ul className="detailBox">
<li className="dbHeader">
<div className="headerL">
<span className="name">ZHELINGYU29801</span>
<span className="type">Fishing</span>
</div>
<div className="headerR">
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
<span className="num">412</span>
<button
type="button"
className="icoArrow"
aria-label="상세보기"
onClick={() => setView('detail')}
></button>
</div>
</li>
<li>
<span className="label">타겟 ID</span>
<span className="value">412417712</span>
</li>
<li>
<span className="label">주정박항</span>
<span className="value">zhelingyu29801</span>
</li>
<li>
<span className="label">어획할당량</span>
<span className="value">100(ton)</span>
</li>
<li>
<span className="label">조업수역구역</span>
<span className="value">, </span>
</li>
</ul>
</div>
</div>
</div>
{/* 탭 콘텐츠 03 */}
<div className={`tabWrap ${activeTab === 'ship03' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">제재단속</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>제재 유형</span>
<select>
<option value="">전체</option>
<option value="">고래포획 의심</option>
<option value="">UN 제재</option>
<option value="">위반행위 규제 정보</option>
<option value="">불법 선박</option>
<option value="">음주 운항 이력</option>
<option value="">다잡아 처분 선박</option>
<option value="">어획량 위반</option>
<option value="">조업 일지 위반</option>
<option value="">망목 내경 미준수</option>
<option value="">입출역 미통보</option>
<option value="">선박서류 미비치</option>
<option value="">어구위반</option>
<option value="">허가 /표지판 위반</option>
<option value="">어획물 전재 위반</option>
<option value="">선원수첩 신분증명서 위반</option>
<option value="">정선 명령 위반</option>
<option value="">어구 설치 조업수역 이탈</option>
<option value="">어획물 운반선 체크포인트 제도 위반</option>
<option value="">포획 채취 금지 체장 위반 어획물 포획</option>
<option value="">조업수역 위반</option>
<option value="">조업 기간 위반</option>
<option value="">어창 용적 위반</option>
<option value="">어창 용적 위반</option>
<option value="">메모</option>
</select>
</label>
</li>
<li>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList line">
<li>
<Link to="/ship" className="active">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 04 */}
<div className={`tabWrap ${activeTab === 'ship04' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">침몰선박</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
<li>
<label>
<span>사고기간</span>
<div className='labelRow'>
<input type="text" className="dateInput" placeholder="연도-월-일" />
<span>-</span>
<input type="text"className="dateInput" placeholder="연도-월-일" /></div>
</label>
</li>
<li>
<label>
<span>사고내용</span>
<input type="text" placeholder="사고내용" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList line">
<li>
<Link to="/ship" className="active">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 05 */}
<div className={`tabWrap ${activeTab === 'ship05' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">선박입출항</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>출항일시</span>
<input type="text" className="dateInput" placeholder="연도-월-일 - -:-" />
</label>
<label>
<span>~ 입항일시</span>
<input type="text" className="dateInput" placeholder="연도-월-일 - -:-" />
</label>
</li>
<li>
<label>
<span>PMS<br/>출항항구</span>
<select>
<option value="">전체</option>
</select>
</label>
<label>
<span>PMS<br/>입항항구</span>
<select>
<option value="">전체</option>
</select>
</label>
</li>
<li>
<label>
<span>SIE<br/>출항항구</span>
<select>
<option value="">전체</option>
</select>
</label>
<label>
<span>SIE<br/>입항항구</span>
<select>
<option value="">전체</option>
</select>
</label>
</li>
<li>
<label>
<span>타겟ID</span>
<input type="text" placeholder="타겟ID" />
</label>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
{/* 여기부터 아코디언 */}
<div className={`accordion ${isAccordionOpen2 ? 'is-open' : ''}`}>
<li>
<label>
<span>낚시여부</span>
<select>
<option value="">전체</option>
<option value="">미선택</option>
<option value="">선택</option>
</select>
</label>
</li>
<li>
<label>
<span>최대<br/>적재톤수</span>
<input type="text" placeholder="0" />
</label>
<label>
<span>최소<br/>적재톤수</span>
<input type="text" placeholder="0" />
</label>
</li>
<li>
<label>
<span>최대<br/>승선원</span>
<input type="text" placeholder="0" />
</label>
<label>
<span>최소<br/>승선원</span>
<input type="text" placeholder="0" />
</label>
</li>
<li>
<label>
<span>최대<br/>승객수</span>
<input type="text" placeholder="0" />
</label>
<label>
<span>최소<br/>승객수</span>
<input type="text" placeholder="0" />
</label>
</li>
<li>
<label>
<span>선종</span>
<select>
<option value="">전체</option>
<option value="">어선</option>
<option value="">함정</option>
<option value="">여객선</option>
<option value="">카고</option>
<option value="">탱커</option>
<option value="">관공선</option>
<option value="">기타</option>
<option value="">낚시어선</option>
</select>
</label>
<label>
<span>국적</span>
<select>
<option value="">전체</option>
<option value="">한국</option>
<option value="">미국</option>
<option value="">중국</option>
<option value="">일본</option>
<option value="">북한</option>
<option value="">기타</option>
</select>
</label>
</li>
</div>
{/* 여기까지 아코디언 */}
<li>
<button
type="button"
className={`btn btnS semi btnToggle ${isAccordionOpen2 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen2}
onClick={toggleAccordion2}
>
상세검색
{isAccordionOpen2 ? ' 닫기' : ' 열기'}
</button>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList line">
<li>
<Link to="/ship" className="active">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 06 */}
<div className={`tabWrap ${activeTab === 'ship06' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">관심선박</div>
<div className="formGroup">
<ul className="lagelW12">
<li>
<label>
<span>관심사유 지정사유</span>
<select>
<option value="">전체</option>
<option value="">불법조업의심</option>
<option value="">불법포경의심</option>
<option value="">MMSI 신호 임의 변경</option>
<option value="">제재 선박 의심</option>
<option value="">북한 선박 의심</option>
<option value="">기타</option>
</select>
</label>
</li>
<li>
<label>
<span>타겟 ID</span>
<input type="text" placeholder="타겟 ID" />
</label>
</li>
<li>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList line">
<li>
<Link to="/ship" className="active">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
<li>
<Link to="/ship" className="">
<i className="cicle default"></i>
<span>0001</span>
<span>1511함A-05</span>
<span>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
</span>
<span>(AIS)</span>
<span className="legend">
<img src="/images/legend_ship_pink.svg" alt="선박" className="legendShip" />
</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
{/* 여기까지 전체목록 페이지 */}
</>
)}
</aside>
);
}

파일 보기

@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
import { Link } from "react-router-dom";
export default function Panel1DetailComponent({ isOpen, onToggle, onBack }) {
//
const [activeTab, setActiveTab] = useState('ship02');
const tabs = [
{ id: 'ship01', label: '선박검색' },
{ id: 'ship02', label: '허가선박' },
{ id: 'ship03', label: '제재단속' },
{ id: 'ship04', label: '침몰선박' },
{ id: 'ship05', label: '선박입출항' },
{ id: 'ship06', label: '관심선박' }
];
return (
<>
{/* <aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}> */}
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap ${activeTab === 'ship02' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">
<button
type="button"
className="prevBtn"
aria-label="이전"
onClick={onBack}
/>
ZHELINGYU29801
</div>
</div>
<div className="tabBtm noLine">
<table className="table">
<caption>선박상세설명 - 타겟 ID, 국가, 주정박항,선종,조업수역 구역,어획 할당량(ton),조업 기간,신호 출처 대한 내용을 나타내는 표입니다.</caption>
<colgroup>
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">타겟 ID</th>
<td>412417712</td>
<th scope="row">국가</th>
<td>412</td>
</tr>
<tr>
<th scope="row">주정박항</th>
<td>zhelingyu29801</td>
<th scope="row">선종</th>
<td>Fishing</td>
</tr>
<tr>
<th scope="row">조업수역 구역</th>
<td></td>
<th scope="row">어획 할당량(ton)</th>
<td></td>
</tr>
<tr>
<th scope="row">조업 기간 1</th>
<td colSpan={3}>2024/01/01 - 2024/04/15</td>
</tr>
<tr>
<th scope="row">조업 기간 2</th>
<td colSpan={3}>2024/10/16 - 2024/12/31</td>
</tr>
<tr>
<th scope="row">신호 출처</th>
<td colSpan={3}>VTS_AIS</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
{/* </aside> */}
</>
);
}

파일 보기

@ -0,0 +1,420 @@
import { useState } from 'react';
import { Link, useNavigate } from "react-router-dom";
import Slider from '../components/Slider';
export default function Panel2Component({ isOpen, onToggle }) {
const navigate = useNavigate();
//
const [isAccordionOpen1, setIsAccordionOpen1] = useState(false); //
const [isAccordionOpen2, setIsAccordionOpen2] = useState(false); //
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
//
const [activeTab, setActiveTab] = useState('ship01');
const tabs = [
{ id: 'ship01', label: '위성영상 관리' },
{ id: 'ship02', label: '위성사업자 관리' },
{ id: 'ship03', label: '위성 관리' },
];
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap ${activeTab === 'ship01' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">위성영상 관리</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>영상 촬영일</span>
<div class="labelRow">
<input class="dateInput" placeholder="연도-월-일" type="text" />
<span>-</span>
<input class="dateInput" placeholder="연도-월-일" type="text" />
</div>
</label>
</li>
{/* 아코디언 1 */}
<div className={`accordion pt8 ${isAccordionOpen1 ? 'is-open' : ''}`}>
<li>
<label>
<span>영상 종류</span>
<select>
<option value="">전체</option>
<option value="">VIRS</option>
<option value="">ICEYE_SAR</option>
<option value="">광학</option>
<option value="">예약</option>
<option value="">RF</option>
</select>
</label>
<label>
<span>영상 출처</span>
<select>
<option value="">전체</option>
<option value="">국내/자동</option>
<option value="">국내/수동</option>
<option value="">국외/수동</option>
<option value="">기타</option>
</select>
</label>
</li>
<li>
<label>
<span>위성 궤도</span>
<select>
<option value="">전체</option>
<option value="">저궤도</option>
<option value="">중궤도</option>
<option value="">정지궤도</option>
<option value="">기타</option>
</select>
</label>
<label>
<span>주기</span>
<select>
<option value="">전체</option>
<option value="">0</option>
<option value="">10</option>
<option value="">30</option>
<option value="">60</option>
</select>
</label>
</li>
</div>
{/* 여기까지 아코디언1 */}
<li>
<label>
<span>위성영상명</span>
<input type="text" placeholder="위성영상명" />
</label>
</li>
<li>
<button
type="button"
className={`btn btnS semi btnToggle ${isAccordionOpen1 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen1}
onClick={toggleAccordion1}
>
상세검색
{isAccordionOpen1 ? ' 닫기' : ' 열기'}
</button>
</li>
<li className="fgBtn rowSB">
<>
<div className="row gap10">
<span>투명도</span>
<div>
<Slider label="투명도 조절" />
</div>
</div>
<div className="row gap10">
<span>밝기</span>
<div>
<Slider label="밝기 조절" />
</div>
</div>
</>
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
<div className="detailWrap">
{/* 위성정보 박스 */}
<ul className="detailBox stretch">
<li className="dbHeader">
<div className="headerL item2">
<span className="name">업로드 테스트</span>
<span className="type">2025-09-25 16:09:00</span>
</div>
</li>
<li>
<ul className="dbList">
<li>
<span className="label">위성명</span>
<span className="value">VIRS</span>
</li>
<li>
<span className="label">위성영상파일</span>
<span className="value">mda:fae19b9b-e99e-4794- a305-bce6d537ac36_remark_ resized_2022_0102_1709_npp_DNB_750</span>
</li>
<li>
<span className="label">위성명</span>
<span className="value">VIRS</span>
</li>
<li>
<span className="label">영상 출처</span>
<span className="value">VIRS</span>
</li>
</ul>
<div className="btnArea">
<button type="button" className="btnEdit"></button>
<button type="button" className="btnDel" onClick={() => navigate("/satellite/delete")}></button>
<button type="button" className="btnMap"></button>
</div>
</li>
</ul>
{/* 위성정보 박스 */}
<ul className="detailBox stretch">
<li className="dbHeader">
<div className="headerL item2">
<span className="name">업로드 테스트</span>
<span className="type">2025-09-25 16:09:00</span>
</div>
</li>
<li>
<ul className="dbList">
<li>
<span className="label">위성명</span>
<span className="value">VIRS</span>
</li>
<li>
<span className="label">위성영상파일</span>
<span className="value">mda:fae19b9b-e99e-4794- a305-bce6d537ac36_remark_ resized_2022_0102_1709_npp_DNB_750</span>
</li>
<li>
<span className="label">위성명</span>
<span className="value">VIRS</span>
</li>
<li>
<span className="label">영상 출처</span>
<span className="value">VIRS</span>
</li>
</ul>
<div className="btnArea">
<button type="button" className="btnEdit"></button>
<button type="button" className="btnDel"></button>
<button type="button" className="btnMap"></button>
</div>
</li>
</ul>
</div>
</div>
{/* 하단버튼 영역 */}
<div class="btnBox rowSB">
<button type="button" class="btn btnLine">위성영상 폴더 업로드</button>
<button type="button" class="btn btnLine" onClick={() => navigate("/satellite/add")}>위성영상 등록</button>
</div>
</div>
</div>
</div>
{/* 탭 콘텐츠 02 */}
<div className={`tabWrap ${activeTab === 'ship02' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">위성사업자 관리</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>사업자 분류</span>
<select>
<option value="">전체</option>
<option value="">국가</option>
<option value="">연구기관</option>
<option value="">민간사업자</option>
<option value="">기타</option>
</select>
</label>
<label>
<span>사업자명</span>
<input type="text" placeholder="사업자명" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
<div className="detailWrap">
{/* 위성정보 박스 */}
<ul className="detailBox">
<li className="dbHeader">
<div className="headerL item1">
<span className="name">Test 01</span>
</div>
</li>
<li>
<span className="label">사업자 분류</span>
<span className="value">국가</span>
</li>
<li>
<span className="label">국가</span>
<span className="value">대한민국</span>
</li>
<li>
<span className="label">소재지</span>
<span className="value">test</span>
</li>
</ul>
</div>
</div>
{/* 하단버튼 영역 */}
<div class="btnBox">
<button
type="button"
class="btn btnLine"
onClick={() => navigate("/satellite/provider")}
>
등록
</button>
</div>
</div>
</div>
</div>
{/* 탭 콘텐츠 03 */}
<div className={`tabWrap ${activeTab === 'ship03' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">위성 관리</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>사업자 분류</span>
<select>
<option value="">전체</option>
<option value="">국가</option>
<option value="">연구기관</option>
<option value="">민간사업자</option>
<option value="">기타</option>
</select>
</label>
<label>
<span>센서 타입</span>
<select>
<option value="">전체</option>
<option value="">광학</option>
<option value="">SAR</option>
<option value="">RF</option>
<option value="">기타</option>
</select>
</label>
</li>
<li>
<label>
<span>위성명</span>
<input type="text" placeholder="위성명" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
<div className="detailWrap">
{/* 위성정보 박스 */}
<ul className="detailBox">
<li>
<span className="label">사업자명</span>
<span className="value">국토지리정보원</span>
</li>
<li>
<span className="label">위성명</span>
<span className="value">국가</span>
</li>
<li>
<span className="label">센서 타입</span>
<span className="value">test</span>
</li>
<li>
<span className="label">촬영 해상도</span>
<span className="value"></span>
</li>
</ul>
{/* 위성정보 박스 */}
<ul className="detailBox">
<li>
<span className="label">사업자명</span>
<span className="value">국토지리정보원</span>
</li>
<li>
<span className="label">위성명</span>
<span className="value">국가</span>
</li>
<li>
<span className="label">센서 타입</span>
<span className="value">test</span>
</li>
<li>
<span className="label">촬영 해상도</span>
<span className="value"></span>
</li>
</ul>
</div>
</div>
{/* 하단버튼 영역 */}
<div class="btnBox">
<button
type="button"
class="btn btnLine"
onClick={() => navigate("/satellite/manage")}
>
등록
</button>
</div>
</div>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -0,0 +1,322 @@
import { useState } from 'react';
import { Link } from "react-router-dom";
export default function Panel3Component({ isOpen, onToggle }) {
//
const [activeTab, setActiveTab] = useState('weather01');
const tabs = [
{ id: 'weather01', label: '기상특보' },
{ id: 'weather02', label: '태풍정보' },
{ id: 'weather03', label: '조위관측' },
{ id: 'weather04', label: '조석정보' },
{ id: 'weather05', label: '항공기상' },
];
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap ${activeTab === 'weather01' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">기상특보</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>일자</span>
<div className='labelRow'>
<input type="text" className="dateInput" placeholder="연도-월-일" />
<span>-</span>
<input type="text"className="dateInput" placeholder="연도-월-일" /></div>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList lineSB">
<li>
<Link to="/weather" className="">
<span className="title">1. 폭풍주의: 남해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
<li>
<Link to="/weather" className="">
<span className="title">2. 폭풍주의: 서해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
<li>
<Link to="/weather" className="">
<span className="title">3. 폭풍주의: 동해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 02 */}
<div className={`tabWrap ${activeTab === 'weather02' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">태풍정보</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>연도</span>
<select>
<option value="">선택</option>
</select>
</label>
<label>
<span></span>
<select>
<option value="">선택</option>
</select>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="colList lineSB">
<li>
<Link to="/weather" className="">
<span className="title">1. 폭풍주의: 남해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
<li>
<Link to="/weather" className="">
<span className="title">2. 폭풍주의: 서해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
<li>
<Link to="/weather" className="">
<span className="title">3. 폭풍주의: 동해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 03 */}
<div className={`tabWrap ${activeTab === 'weather03' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">조위관측</div>
<div className="legend">
<span className="legendTitle">조위관측 범례</span>
<ul className="legendList">
<li><img src="/images/ico_obsTide.svg" alt="조위관측소" />조위관측소</li>
<li><img src="/images/ico_obsOcean.svg" alt="해양관측소" />해양관측소</li>
<li><img src="/images/ico_obsBuoy.svg" alt="해양관측부이" />해양관측부이</li>
<li><img src="/images/ico_obsCurrent.svg" alt="해수유동관측소" />해수유동관측소</li>
<li><img src="/images/ico_obsScience.svg" alt="해양과학기지" />해양과학기지</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="lineList">
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>조위관측소</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>해양관측소</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>해양관측부이</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>해수유동관측측소</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>해양과학기지</span>
</label>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 04 */}
<div className={`tabWrap ${activeTab === 'weather04' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">조석정보</div>
<div className="legend">
<span className="legendTitle">조위관측 범례</span>
<ul className="legendList">
<li><img src="/images/ico_obsTide.svg" alt="조위관측소" />조위관측소</li>
<li><img src="/images/ico_obsSunrise.svg" alt="일출몰관측지역" />일출몰관측지역</li>
</ul>
</div>
</div>
<div className="tabBtm">
<ul className="lineList">
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>조위관측소</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>일출몰관측지역</span>
</label>
</li>
</ul>
</div>
</div>
{/* 탭 콘텐츠 05 */}
<div className={`tabWrap ${activeTab === 'weather05' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">항공기상</div>
</div>
<div className="tabBtm noLine">
<ul className="lineList">
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>전체</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>양양공항(RKNY) </span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>김포공항(RKSS)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>인천공항(RKSI)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>청주공항(RKTU)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>포항공항(RKTH)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>대구공항(RKTN)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>울산공항(RKPU)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>김해공항(RKPK)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>광주공항(RKJJ)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>사천공항(RKPS)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>무안공항(RKJB)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>여수공항(RKYJ)</span>
</label>
</li>
<li>
<label className="checkbox checkL">
<input type="checkbox" />
<span>제주공항(RKPC)</span>
</label>
</li>
</ul>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -0,0 +1,521 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Panel4Component({ isOpen, onToggle }) {
const navigate = useNavigate();
//
const [isAccordionOpen1, setIsAccordionOpen1] = useState(false); //
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
//
const [activeTab, setActiveTab] = useState('analysis01');
const tabs = [
{ id: 'analysis01', label: '관심 해역' },
{ id: 'analysis02', label: '해역 분석' },
{ id: 'analysis03', label: '해역 진입 선박' },
{ id: 'analysis04', label: '해구 분석' },
];
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 01 */}
<div className={`tabWrap ${activeTab === 'analysis01' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">관심 해역</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>영역명</span>
<input type="text" placeholder="" />
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤 영역 */}
<div className="tabBtmCnt">
<span>데이터가 없습니다.</span>
{/* <ul className="colList lineSB">
<li>
<Link to="/" className="">
<span className="title">1. 폭풍주의: 남해</span>
<span className="meta">시작일시 00:00:00 / 발효일시 00:00:00</span>
</Link>
</li>
</ul> */}
</div>
{/* 하단고정버튼 */}
<div className="btnBox">
<button
type="button"
class="btn btnLine"
onClick={() => navigate("/analysis/area")}
>등록</button>
</div>
</div>
</div>
</div>
{/* 탭 콘텐츠 02 */}
<div className={`tabWrap ${activeTab === 'analysis02' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">해역 분석</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>조회기간</span>
<div class="labelRow">
<input class="dateInput" placeholder="연도-월-일" type="text" />
<span>-</span>
<input class="dateInput" placeholder="연도-월-일" type="text" />
</div>
</label>
</li>
<li>
<label>
<span>제목</span>
<input type="text" placeholder="" />
</label>
</li>
<li className="fgBtn">
<button
type="button"
className="schBtn"
>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤 영역 */}
<div className="tabBtmCnt">
<span>데이터가 없습니다.</span>
</div>
{/* 하단고정버튼 */}
<div className="btnBox">
<button
type="button"
class="btn btnLine"
onClick={() => navigate("/analysis/register")}
>등록</button>
</div>
</div>
</div>
</div>
{/* 탭 콘텐츠 03 */}
<div className={`tabWrap ${activeTab === 'analysis03' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">해역 진입 선박</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>진입 일시</span>
<div class="labelRow">
<input class="dateInput" placeholder="연도-월-일" type="text" />
<span>-</span>
<input class="dateInput" placeholder="연도-월-일" type="text" />
</div>
</label>
</li>
{/* 아코디언 1 */}
<div className={`accordion pt8 ${isAccordionOpen1 ? 'is-open' : ''}`}>
<li>
<label>
<span>국적</span>
<select>
<option value="">전체</option>
<option value="">한국</option>
<option value="">미국</option>
<option value="">중국</option>
<option value="">일본</option>
<option value="">북한</option>
<option value="">기타</option>
</select>
</label>
<label>
<span>선종</span>
<select>
<option value="">전체</option>
<option value="">어선</option>
<option value="">함정</option>
<option value="">여객선</option>
<option value="">카고</option>
<option value="">탱커</option>
<option value="">관공선</option>
<option value="">기타</option>
<option value="">낚시어선</option>
</select>
</label>
</li>
<li>
<label>
{/* 사용자가 등록한 관심해역리스트 */}
<span>관심 해역</span>
<select>
<option value="">전체</option>
</select>
</label>
<label>
<span>위험물</span>
<select>
<option value="">전체</option>
<option value="">고압</option>
<option value="">가연성/인화성</option>
<option value="">산화성</option>
<option value="">독성</option>
<option value="">방사성</option>
<option value="">기타</option>
</select>
</label>
</li>
</div>
{/* 여기까지 아코디언1 */}
<li>
<label>
<span>타겟ID</span>
<input type="text" placeholder="타겟ID" />
</label>
<label className="checkbox">
<input type="checkbox" />
<span className="w70">허가 선박 여부</span>
</label>
</li>
<li>
<label>
<span>선박명</span>
<input type="text" placeholder="선박명" />
</label>
</li>
<li>
<button
type="button"
className={`btn btnS semi btnToggle ${isAccordionOpen1 ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen1}
onClick={toggleAccordion1}
>
상세검색
{isAccordionOpen1 ? ' 닫기' : ' 열기'}
</button>
</li>
<li className="fgBtn">
<button type="button" className="schBtn">검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
</div>
</div>
{/* 탭 콘텐츠 04 */}
<div className={`tabWrap ${activeTab === 'analysis04' ? 'is-active' : ''}`}>
<div className="tabTop">
<div className="title">해구 분석</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>전체 통화량</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>유의파고(m)</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>파향(deg)</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>파주기()</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>풍속(m/s)</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li>
<label>
<span>풍향(deg)</span>
<div className="labelRow">
<div className="numInput">
<input type="number" placeholder="최소" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
<span>~</span>
<div className="numInput">
<input type="number" placeholder="최대" min="" max="" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</div>
</label>
</li>
<li className="fgBtn rowSB">
<span className="infoTxt">통화량 조회에 최대 30 소요될 있습니다.</span>
<button
type="button"
className="schBtn"
>
검색
</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
<div className="detailWrap">
{/* 정보 박스 */}
<ul className="detailBox stretch">
<li className="dbHeader">
<div className="headerL item2">
<span className="name">대해구 1</span>
<span className="type">[131.5 42.5]</span>
</div>
</li>
<li>
<ul className="dbList twoCol">
<li>
<span className="label">전체 통화량</span>
<span className="value">0</span>
<span className="label"></span>
<span className="value noLine"></span>
</li>
<li>
<span className="label">유의 파고</span>
<span className="value">0.9(m)</span>
<span className="label">파향</span>
<span className="value">
<img src="/images/ico_dir_arrow.svg" alt="파향" className="arrowDirect"
style={{ transform: 'rotate(6deg)' }}
/>6(°C)
</span>
</li>
<li>
<span className="label">파주기</span>
<span className="value">4.0(s)</span>
<span className="label">풍속</span>
<span className="value">10.2(m/s)</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">
<img src="/images/ico_dir_arrow.svg" alt="풍향" className="arrowDirect"
style={{ transform: 'rotate(10deg)' }}
/>10(°)
</span>
<span className="label"></span>
<span className="value noLine"></span>
</li>
</ul>
<div className="btnArea w4r">
<button
type="button"
class="btnMap"
onClick={() => navigate("/analysis/trench")}
></button>
</div>
</li>
</ul>
{/* 정보 박스 */}
<ul className="detailBox stretch">
<li className="dbHeader">
<div className="headerL item2">
<span className="name">대해구 1</span>
<span className="type">[131.5 42.5]</span>
</div>
</li>
<li>
<ul className="dbList twoCol">
<li>
<span className="label">전체 통화량</span>
<span className="value">0</span>
<span className="label"></span>
<span className="value noLine"></span>
</li>
<li>
<span className="label">유의 파고</span>
<span className="value">0.9(m)</span>
<span className="label">파향</span>
<span className="value">
<img src="/images/ico_dir_arrow.svg" alt="파향" className="arrowDirect"
style={{ transform: 'rotate(6deg)' }}
/>6(°C)
</span>
</li>
<li>
<span className="label">파주기</span>
<span className="value">4.0(s)</span>
<span className="label">풍속</span>
<span className="value">10.2(m/s)</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">
<img src="/images/ico_dir_arrow.svg" alt="풍향" className="arrowDirect"
style={{ transform: 'rotate(10deg)' }}
/>10(°)
</span>
<span className="label"></span>
<span className="value noLine"></span>
</li>
</ul>
<div className="btnArea w4r">
<button type="button" className="btnMap"></button>
</div>
</li>
</ul>
</div>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -0,0 +1,7 @@
import { useState } from "react";
export default function Panel5Component() {
return (
<section></section>
);
}

파일 보기

@ -0,0 +1,77 @@
import { useState } from "react";
import { Link } from "react-router-dom";
export default function Panel6Component({ isOpen, onToggle }) {
return (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
<div className="panelHeader">
<h2 className="panelTitle">AI 분석 모드</h2>
</div>
<div className="panelBody">
<ul className="ai">
<li>
<Link to="/" className="on">
<div className="control"><i></i> ON</div>
<span className="title"><img src="/images/ico_ai_trackgap.svg" alt="소실항적" />소실항적</span>
<span className="desc">AIS 신호가 소실된 선박</span>
<span className="keywords">Signal Gap</span>
</Link>
</li>
<li>
<Link to="/" className="">
<div className="control"><i></i> OFF</div>
<span className="title"><img src="/images/ico_ai_route.svg" alt="항로예측" />항로예측</span>
<span className="desc">AI 기반 선박 항로 예측</span>
<span className="keywords">ML Pattern</span>
</Link>
</li>
<li>
<Link to="/" className="">
<div className="control"><i></i> OFF</div>
<span className="title"><img src="/images/ico_ai_shiptype.svg" alt="선종분석" />선종분석</span>
<span className="desc">선박 유형 자동 분류</span>
<span className="keywords">Auto Class</span>
</Link>
</li>
<li>
<Link to="/" className="on">
<div className="control"><i></i> ON</div>
<span className="title"><img src="/images/ico_ai_fishing.svg" alt="조업분석" />조업분석</span>
<span className="desc">구역별 위험도 평가</span>
<span className="keywords">Risk Score</span>
</Link>
</li>
<li>
<Link to="/" className="on">
<div className="control"><i></i> ON</div>
<span className="title"><img src="/images/ico_ai_risk.svg" alt="해역별 위험지수" />해역별 위험지수</span>
<span className="desc">구역별 위험도 평가</span>
<span className="keywords">Risk Score</span>
</Link>
</li>
</ul>
</div>
<div className="panelFooter">
<div className="btnWrap">
<button type="button" className="btn deep">전체 해제</button>
<button type="button" className="btn basic">설정 저장</button>
</div>
</div>
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -0,0 +1,7 @@
import { useState } from "react";
export default function Panel7Component() {
return (
<section></section>
);
}

파일 보기

@ -0,0 +1,7 @@
import { useState } from "react";
export default function Panel8Component() {
return (
<section></section>
);
}

파일 보기

@ -0,0 +1,100 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export default function ReplayComponent() {
const navigate = useNavigate();
const max = 100;
const [value, setValue] = useState(30);
const percent = (value / max) * 100;
return(
<section className="ReplayComponent">
<div className="replayWrap">
<button
type="button"
className="barCloseBtn"
aria-label="닫기"
onClick={() => navigate("/main")}
></button>
<div className="replayControlBar">
{/* 재생상태 컨트롤 */}
<div className="control">
<div className="ctrL">
<button
type="button"
className="playBtn"
aria-label="플레이"
></button>
<select className="controlSelect w8r" aria-label="재생속도 선택">
<option value="">2.0X</option>
</select>
</div>
<div className="playProgress">
<input
type="range"
className="playRange"
min="0"
max={max}
aria-label="재생 위치"
value={value}
onChange={(e) => setValue(Number(e.target.value))}
style={{
background: `linear-gradient(
to right,
#FF0000 0%,
#FF0000 ${percent}%,
#D7DBEC ${percent}%,
#D7DBEC 100%
)`
}}
/>
<div className="timeLabel">
2023-08-20&nbsp;&nbsp;10:15:30
</div>
</div>
<div className="ctrR">
<button
type="button"
className="captureBtn"
aria-label="캡쳐"
></button>
<button
type="button"
className="stopBtn"
aria-label="정지"
></button>
</div>
</div>
{/* 재생옵션 영역 */}
<div className="option">
<label>
<span>재생기간</span>
<select className="controlSelect w8r">
<option value="">어제</option>
</select>
<input placeholder="" type="text" className="controlInput w14r" /> ~
<input placeholder="" type="text" className="controlInput w14r" />
</label>
<label>
<span>표출정보</span>
<button
type="button"
className="trackRndBtn"
>항적</button>
</label>
</div>
</div>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,189 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
import FileUpload from '../components/FileUpload';
export default function Satellite1Component() {
const navigate = useNavigate();
return (
<section id="Satellite1Component">
{/* 위성 영상 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w61r">
<div className="puHeader">
<span className="title">위성 영상 등록</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>위성 영상 등록 - 사업자명/위성명, 영상 촬영일, 위성영상파일,CSV 파일,위성영상명, 영상전송 주기,영상 종류,위성 궤도,영상 출처,촬영 목적,촬영 모드,취득방법,구매가격, 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자명/위성명 <span className="required">*</span></th>
<td colSpan={3}>
<div className="row flex1">
<select aria-label="사업자명">
<option value="">BlackSky</option>
<option value="">ICEYE</option>
<option value="">VIIRS</option>
<option value="">hawkeye360</option>
<option value="">test1</option>
<option value="">국토지리정보원</option>
</select>
<input type="text" placeholder="" aria-label="위성명" />
</div>
</td>
</tr>
<tr>
<th scope="row">영상 촬영일 <span className="required">*</span></th>
<td colSpan={3}><input class="dateInput" placeholder="연도-월-일" type="text" aria-label="영상 촬영일" /></td>
</tr>
<tr>
<th scope="row">위성영상파일 <span className="required">*</span></th>
<td colSpan={3}>
<div className="rowC">
<FileUpload
label="파일 선택"
inputId="videoFile"
maxLength={40}
placeholder="선택된 파일 없음"
/>
</div>
</td>
</tr>
<tr>
<th scope="row">CSV 파일 <span className="required">*</span></th>
<td colSpan={3}>
<div className="rowC">
<FileUpload
label="파일 선택"
inputId="csvFile"
maxLength={45}
placeholder="선택된 파일 없음"
/>
</div>
</td>
</tr>
<tr>
<th scope="row">위성영상명 <span className="required">*</span></th>
<td colSpan={3}><input type="text" placeholder="" aria-label="위성영상명" /></td>
</tr>
<tr>
<th scope="row">영상전송 주기 </th>
<td colSpan={3}>
<select aria-label=">영상전송 주기">
<option value="">선택</option>
<option value="">0</option>
<option value="">10</option>
<option value="">30</option>
<option value="">60</option>
</select>
</td>
</tr>
<tr>
<th scope="row">영상 종류 </th>
<td colSpan={3}>
<div className="row">
<label className="radio radioL"> <input type="radio" name="type" /> <span>VIRS</span></label>
<label className="radio radioL"> <input type="radio" name="type" /> <span>ICEYE_SAR</span></label>
<label className="radio radioL"> <input type="radio" name="type" /> <span>광학</span></label>
<label className="radio radioL"> <input type="radio" name="type" /> <span>예약</span></label>
<label className="radio radioL"> <input type="radio" name="type" /> <span>RF</span></label>
</div>
</td>
</tr>
<tr>
<th scope="row">위성 궤도 </th>
<td>
<select aria-label="위성 궤도">
<option value="">선택</option>
<option value="">저궤도</option>
<option value="">중궤도</option>
<option value="">정지궤도</option>
<option value="">기타</option>
</select>
</td>
<th scope="row">영상 출처</th>
<td>
<select aria-label="영상 출처">
<option value="">선택</option>
<option value="">국내/자동</option>
<option value="">국내/수동</option>
<option value="">국외/수동</option>
<option value="">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">촬영 목적 </th>
<td>
<input type="text" placeholder="촬영 목적" aria-label="촬영 목적"/>
</td>
<th scope="row">촬영 모드 </th>
<td>
<select aria-label="촬영 모드">
<option value="">선택</option>
<option value="">스핏모드</option>
<option value="">스트랩모드</option>
<option value="">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">취득방법 </th>
<td>
<select aria-label="취득방법">
<option value="">선택</option>
<option value="">무료</option>
<option value="">개별구매</option>
<option value="">단가계약</option>
<option value="">연간계약</option>
<option value="">기타</option>
</select>
</td>
<th scope="row">구매가격 </th>
<td>
<div className="numInput">
<input type="number" placeholder="0" min="" max="" aria-label="구매가격" />
<div className="spin">
<button type="button" className="spinUp"><span className="blind">증가</span></button>
<button type="button" className="spinDown"><span className="blind">감소</span></button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,87 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Satellite2Component() {
const navigate = useNavigate();
return (
<section id="Satellite2Component">
{/* 위성 사업자 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title">위성 사업자 등록</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>위성 사업자 등록 - 사업자 분류, 사업자명, 국가, 소재지, 상세내역 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자 분류 <span className="required">*</span></th>
<td>
<select aria-label="사업자 분류">
<option value="">전체</option>
<option value="">국가</option>
<option value="">연구기관</option>
<option value="">민간사업자</option>
<option value="">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">사업자명 </th>
<td><input type="text" placeholder="사업자명" aria-label="사업자명" /></td>
</tr>
<tr>
<th scope="row">국가 <span className="required">*</span></th>
<td>
<select aria-label="국가">
<option value="">선택</option>
<option value="">대한민국</option>
<option value="">미국</option>
<option value="">일본</option>
<option value="">중국</option>
</select>
</td>
</tr>
<tr>
<th scope="row">소재지 </th>
<td><input type="text" placeholder="소재지" aria-label="소재지" /></td>
</tr>
<tr>
<th scope="row">상세내역 </th>
<td><textarea placeholder="내용을 입력하세요" aria-label="상세내역"></textarea></td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,94 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Satellite3Component() {
const navigate = useNavigate();
return (
<section id="Satellite3Component">
{/* 위성 관리 등록 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title">위성 관리 등록</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>위성 관리 등록 - 사업자명, 위성명, 센서 타입, 촬영 해상도, 주파수, 상세내역 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자명 <span className="required">*</span></th>
<td>
<select aria-label="사업자명">
<option value="">전체</option>
<option value="">BlackSky</option>
<option value="">ICEYE</option>
<option value="">VIIRS</option>
<option value="">hawkeye360</option>
<option value="">test1</option>
<option value="">국토지리정보원</option>
</select>
</td>
</tr>
<tr>
<th scope="row">위성명 <span className="required">*</span></th>
<td><input type="text" placeholder="위성명" aria-label="위성명" /></td>
</tr>
<tr>
<th scope="row">센서 타입 </th>
<td>
<select aria-label="센서 타입">
<option value="">전체</option>
<option value="">광학</option>
<option value="">SAR</option>
<option value="">RF</option>
<option value="">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">촬영 해상도 </th>
<td><input type="text" placeholder="촬영 해상도" aria-label="촬영 해상도" /></td>
</tr>
<tr>
<th scope="row">주파수 </th>
<td><input type="text" placeholder="주파수" aria-label="주파수" /></td>
</tr>
<tr>
<th scope="row">상세내역 </th>
<td><textarea placeholder="내용을 입력하세요" aria-label="상세내역"></textarea></td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,50 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Satellite4Component() {
const navigate = useNavigate();
return (
<section id="Satellite4Component">
{/* 삭제 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill w46r">
<div className="puHeader">
<span className="title">삭제</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<div className="puTxtBox">삭제 하시겠습니까?</div>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
oonClick={() => navigate("/main")}
>
삭제
</button>
<button
type="button"
className="btn dark"
onClick={() => navigate("/main")}
>
취소
</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,169 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export default function ShipComponent() {
const navigate = useNavigate();
//progress bar value
const [value, setValue] = useState(60);
//
const images = [
{ src: "/images/photo_ship_001.png", alt: "1511함A-05" },
{ src: "/images/photo_ship_002.png", alt: "1511함A-05" },
];
const [currentIndex, setCurrentIndex] = useState(0);
const handlePrev = () => {
if (currentIndex === 0) return;
setCurrentIndex(prev => prev - 1);
};
const handleNext = () => {
if (currentIndex === images.length - 1) return;
setCurrentIndex(prev => prev + 1);
};
return(
<section id="shipComponent">
{/* 배정보 팝업 */}
<div className="popupMap shipInfo">
{/* header */}
<div className="pmHeader">
<div className="rowL">
<i className="shipType"></i>
<img src="/images/flag_kor.svg" alt="대한민국" className="flagIcon" />
<span className="shipName">1511함A-05</span>
<span className="shipNum">13450135</span>
</div>
<button
type="button"
className="pmClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="pmGallery">
<button
type="button"
className={`navBtn prev ${currentIndex === 0 ? "disabled" : ""}`}
onClick={handlePrev}
disabled={currentIndex === 0}
>
<span className="blind">이전</span>
</button>
<button
type="button"
className={`navBtn next ${currentIndex === images.length - 1 ? "disabled" : ""}`}
onClick={handleNext}
disabled={currentIndex === images.length - 1}
>
<span className="blind">다음</span>
</button>
{/* 이미지 영역 */}
<div className="galleryView">
<img
className="galleryImg"
src={images[currentIndex].src}
alt={images[currentIndex].alt}
/>
</div>
</div>
{/* body */}
<div className="pmBody">
<div className="shipAction">
<div className="rowL">
<button type="button" className="detailBtn">상세정보</button>
<ul className="shipTypeIco">
<li>A</li>
<li>V</li>
<li>E</li>
<li>T</li>
<li>D</li>
<li>R</li>
</ul>
</div>
<button type="button" className="favBtn" aria-label="즐겨찾기"></button>
</div>
<div className="shipRoute">
<div
className="routeProgress"
style={{ "--progress": value }}
>
<progress max="100" value={value}>{value}%</progress>
<span className="routeShip"></span>
</div>
</div>
<ul className="shipStatus">
<li className="port">
<div className="rowL">
<span className="portLabel">출항지</span>
<span className="portName">서귀포해양경찰서</span>
</div>
<div className="rowR">
<span className="portLabel">입항지</span>
<span className="portName">하태도</span>
</div>
</li>
<li className="schedule">
<div className="rowL">
<span className="depart">출항일시</span>
<span className="scheduleDate">2024-11-23 11:23:00</span>
</div>
<div className="rowR">
<span className="arrive">입항일시</span>
<span className="scheduleDate">2024-11-23 11:23:00</span>
</div>
</li>
<li className="status">
<div className="statusItem">
<span className="statusLabel">선박상태</span>
<span className="statusValue">정박</span>
</div>
<div className="statusItem w13r">
<span className="statusLabel">속도/항로</span>
<span className="statusValue">4.2 kn / 13.3˚</span>
</div>
<div className="statusItem">
<span className="statusLabel">흘수</span>
<span className="statusValue">1.1m</span>
</div>
</li>
</ul>
{/* <ul className="shipSensor">
<li>
<span className="sensorLabel">AIS</span>
<span className="sensorValue"><i className="isNomal"></i>정상</span>
</li>
<li>
<span className="sensorLabel">RF</span>
<span className="sensorValue"><i className="isNomal"></i>정상</span>
</li>
<li>
<span className="sensorLabel">EO</span>
<span className="sensorValue"><i className="isNomal"></i>정상</span>
</li>
<li>
<span className="sensorLabel">SAR</span>
<span className="sensorValue"><i className="isOff"></i>비활성</span>
</li>
</ul> */}
<div className="btnWrap">
<button type="button" className="trackBtn">항적조회</button>
<button type="button" className="trackBtn">항로예측</button>
</div>
</div>
{/* footer */}
<div className="pmFooter">데이터 수신시간 : 2024-11-23 11:23:00</div>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,58 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Signal1Component() {
const navigate = useNavigate();
return (
<section id="SignalComponent">
{/* 신호설정 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title">신호설정</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
<table className="table">
<caption>신호설정 - 신호표출반경, 수신수기 설정 대한 내용을 나타내는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">신호표출반경</th>
<td>
<select aria-label="신호표출반경">
<option value="">25NM</option>
</select>
</td>
</tr>
<tr scope="row">
<th>수신수기 설정</th>
<td><input type="text" placeholder="" aria-label="수신수기 설정" /></td>
</tr>
</tbody>
</table>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button type="button" className="btn dark">초기화</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,149 @@
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
export default function Signal2Component() {
const navigate = useNavigate();
// (3, )
const [accordionOpen, setAccordionOpen] = useState({
signal1: true,
signal2: true,
signal3: true,
});
const toggleAccordion = (key) => {
setAccordionOpen((prev) => ({ ...prev, [key]: !prev[key] }));
};
return (
<section id="SignalComponent">
{/* 신호설정 팝업 */}
<div className="popupUtillWrap">
<div className="popupUtill">
<div className="puHeader">
<span className="title">맞춤 설정</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
<div className="puBody">
{/* 아코디언그룹 01 */}
<div className="accordionWrap">
<div className="acdHeader">
<span className="title">NLL 고속 선박 탐지</span>
<button
type="button"
className={`toggleListBtn ${accordionOpen.signal1 ? 'open' : ''}`}
onClick={() => toggleAccordion('signal1')}
aria-expanded={accordionOpen.signal1}
aria-label={accordionOpen.signal1 ? '접기' : '펼치기'}
></button>
</div>
{/* 여기서부터 아코디언 */}
<div className={`acdListBox ${accordionOpen.signal1 ? 'open' : ''}`}>
<ul className="acdList input">
<li className="state">
<label className="radio radioL"> <input type="radio" name="state1" /> <span>사용</span></label>
<label className="radio radioL"> <input type="radio" name="state1" /> <span>미사용</span></label>
</li>
<li className="input">
<label>
<span>SOG 기준</span>
<input type="text" placeholder="" />
</label>
</li>
<li className="input">
<label>
<span>COG 기준</span>
<input type="text" placeholder="" />
</label>
</li>
<li className="input">
<label>
<span>유지시간()</span>
<input type="text" placeholder="" />
</label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 아코디언그룹 02 */}
<div className="accordionWrap">
<div className="acdHeader">
<span className="title">특정 어업수역 탐지</span>
<button
type="button"
className={`toggleListBtn ${accordionOpen.signal2 ? 'open' : ''}`}
onClick={() => toggleAccordion('signal2')}
aria-expanded={accordionOpen.signal2}
aria-label={accordionOpen.signal2 ? '접기' : '펼치기'}
></button>
</div>
{/* 여기서부터 아코디언 */}
<div className={`acdListBox ${accordionOpen.signal2 ? 'open' : ''}`}>
<ul className="acdList check">
<li className="state">
<label className="radio radioL"> <input type="radio" name="state2" /> <span>사용</span></label>
<label className="radio radioL"> <input type="radio" name="state2" /> <span>미사용</span></label>
</li>
<li className="check">
<label class="checkbox checkL"><input type="checkbox" /><span>특정 어업수역 I</span></label>
</li>
<li className="check">
<label class="checkbox checkL"><input type="checkbox" /><span>특정 어업수역 II</span></label>
</li>
<li className="check">
<label class="checkbox checkL"><input type="checkbox" /><span>특정 어업수역 III</span></label>
</li>
<li className="check">
<label class="checkbox checkL"><input type="checkbox" /><span>특정 어업수역 IV</span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
{/* 아코디언그룹 03 */}
<div className="accordionWrap">
<div className="acdHeader">
<span className="title">위험화물 식별</span>
<button
type="button"
className={`toggleListBtn ${accordionOpen.signal3 ? 'open' : ''}`}
onClick={() => toggleAccordion('signal3')}
aria-expanded={accordionOpen.signal3}
aria-label={accordionOpen.signal1 ? '접기' : '펼치기'}
></button>
</div>
{/* 여기서부터 아코디언 */}
<div className={`acdListBox ${accordionOpen.signal3 ? 'open' : ''}`}>
<ul className="acdList">
<li className="state">
<label className="radio radioL"> <input type="radio" name="state3" /> <span>사용</span></label>
<label className="radio radioL"> <input type="radio" name="state3" /> <span>미사용</span></label>
</li>
</ul>
</div>
{/* 여기까지 */}
</div>
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button type="button" className="btn basic">저장</button>
<button type="button" className="btn dark">초기화</button>
</div>
</div>
</div>
</div>
</section>
);
}

파일 보기

@ -0,0 +1,59 @@
import { Link } from "react-router-dom";
export default function ToastComponent() {
return(
<section id="toastComponent">
{/* 지도상 배표식 */}
<div className="shipMapContainer">
<div className="shipMap shipCaution">
<Link to="/">
1511함A-05
<span className="status">12.5 kts | 45°</span>
</Link>
</div>
<div className="shipMap shipWarning">
<Link to="/">
1511함A-05
<span className="status">12.5 kts | 45°</span>
</Link>
</div>
<div className="shipMap shipDefault">
<Link to="/">
1511함A-05
<span className="status">12.5 kts | 45°</span>
</Link>
</div>
</div>
{/* 토스트팝업 */}
<div className="toastContainer">
<div className="toast toastCaution">
<span className="toastMsg">104 어업구역 비인가 선박</span>
<span className="toastR">
<button type="button" className="toastAction">위치보기</button>
<button type="button" className="toastClose" aria-label="닫기"></button>
</span>
</div>
<div className="toast toastCaution">
<span className="toastMsg">104 어업구역 비인가 선박</span>
<span className="toastR">
<button type="button" className="toastAction">위치보기</button>
<button type="button" className="toastClose" aria-label="닫기"></button>
</span>
</div>
<div className="toast toastWarining">
<span className="toastMsg">저속 이동 의심 선박</span>
<span className="toastR">
<button type="button" className="toastAction">위치보기</button>
<button type="button" className="toastClose" aria-label="닫기"></button>
</span>
</div>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,21 @@
export default function TopComponent() {
return(
<section className="topBar">
<div className="locationInfo">
<ul>
<li><button type="button" className="map active"><span className="blind">지도</span></button></li>
<li className="divider"><span className="wgs">경도</span><span>129°</span> <span>38</span><span>31.071</span><span>E</span></li>
<li className="divider"><span className="wgs">위도</span><span>35° </span> <span>21</span><span>24.580</span><span>N</span></li>
<li><span className="kst">KST</span><span>2024-07-01()</span> <span>12:00:00</span></li>
<li><button type="button" className="set"><span className="blind">설정</span></button></li>
<li><button type="button" className="ship"><span className="blind">선박</span></button></li>
</ul>
</div>
<div className="topSchBox">
<input type="text" className="tschInput" placeholder="선박 위치 검색" />
<button type="button" className="mainSchBtn">검색</button>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,41 @@
import { useNavigate } from "react-router-dom";
export default function TrackComponent() {
const navigate = useNavigate();
return(
<section className="TrackComponent">
<div className="trackWrap">
<button
type="button"
className="barCloseBtn"
aria-label="닫기"
onClick={() => navigate("/main")}
></button>
{/* 항적조회 검색바 */}
<div className="trackControlBar">
<label>
<span>시간선택</span>
<input placeholder="" type="text" className="controlInput w15r" aria-label="시작시간" /> ~
<input placeholder="" type="text" className="controlInput w15r" aria-label="종료시간" />
</label>
<label>
<span>재생주기</span>
<select className="controlSelect w16r">
<option value="">1</option>
</select>
</label>
<button
type="button"
className="schRndBtn"
>검색</button>
</div>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,71 @@
import { useNavigate } from "react-router-dom";
export default function WeatherComponent() {
const navigate = useNavigate();
return(
<section id="WeatherComponent">
<div className="popupMap osbInfo">
{/* header */}
<div className="pmHeader">
<div className="rowL">
<span className="title">해양관측소</span>
</div>
<button
type="button"
className="pmClose"
aria-label="닫기"
onClick={() => navigate("/main")}
/>
</div>
{/* body */}
<div className="pmBody">
<ul className="osbStatus">
<li className="date">
2023.10.16 20:54
</li>
<li>
<span className="label">조위</span>
<span className="value">251(cm)</span>
</li>
<li>
<span className="label">수온</span>
<span className="value">19.6(°C)</span>
</li>
<li>
<span className="label">염분</span>
<span className="value">31.8(PSU)</span>
</li>
<li>
<span className="label">기온</span>
<span className="value">16.9(°C)</span>
</li>
<li>
<span className="label">기압</span>
<span className="value">1016.6(hPa)</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">315(deg)</span>
</li>
<li>
<span className="label">풍속</span>
<span className="value">7.1(m/s)</span>
</li>
<li>
<span className="label">유속방향</span>
<span className="value">-(deg)</span>
</li>
<li>
<span className="label">유속</span>
<span className="value">-(m/s)</span>
</li>
</ul>
</div>
</div>
</section>
)
}

파일 보기

@ -0,0 +1,109 @@
@charset "utf-8";
#wrap {
/* header */
#header {
width: 100%;
height: 4.4rem;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--secondary5);
.logoArea {
display: flex;
align-items: center;
.logo {
width: 4.3rem;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
background: url(../assets/images/logo.svg) no-repeat center / contain;
}
.logoTxt {
color: var(--white);
font-weight: var(--fw-bold);
}
}
aside {
ul {
display: flex;
li {
position: relative;
width: 3rem;
height: 3rem;
margin-right: .9rem;
&.setWrap:hover .setMenu {
display: block;
}
a {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
&.alram {
background-image: url(../assets/images/ico_alarm.svg);
}
&.set {
background-image: url(../assets/images/ico_set.svg);
}
&.user {
background-image: url(../assets/images/ico_user.svg);
}
}
}
}
}
.setMenu {
display: none;
position: absolute;
top: 2.8rem;
right: -1rem;
min-width: 22rem;
background-color:var(--secondary2) ;
padding: .6rem 0;
z-index: 100;
pointer-events: auto;
a {
display: block;
padding: .8rem 1.5rem;
font-size: var(--fs-m);
font-weight: var(--fw-bold);
&:hover {
background-color:var(--primary1);
}
}
}
.badge {
position: absolute;
top: .6rem;
right: .8rem;
display: block;
width: .5rem;
height: .6rem;
border-radius: 50%;
background-color: var(--alert);
}
}
}

파일 보기

@ -0,0 +1,557 @@
@charset "utf-8";
#wrap {
width: 100%;
height: 100%;
/* header */
#header {
width: 100%;
height: 4.4rem;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--secondary5);
.logoArea {
display: flex;
align-items: center;
.logo {
width: 4.3rem;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
background: url(../assets/images/logo.svg) no-repeat center / contain;
}
.logoTxt {
color: var(--white);
font-weight: var(--fw-bold);
}
}
aside {
ul {
display: flex;
li {
width: 3rem;
height: 3rem;
margin-right: .9rem;
a {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
&.alram {
background-image: url(../assets/images/ico_alarm.svg);
}
&.set {
background-image: url(../assets/images/ico_set.svg);
}
&.user {
background-image: url(../assets/images/ico_user.svg);
}
}
}
}
}
.badge {
position: absolute;
top: .6rem;
right: .8rem;
display: block;
width: .5rem;
height: .6rem;
border-radius: 50%;
background-color: var(--alert);
}
}
#sidePanel {
/* gnb */
#nav {
position: fixed;
top: 4.4rem;
left: 0;
z-index: 100;
width: 4.3rem;
height: calc(100% - 4.4rem);
display: flex;
flex-direction: column;
justify-content: space-between;
background: var(--secondary1);
border-right: 1px solid var(--tertiary2);
.gnb {
width: 4.2rem;
display: flex;
flex-direction: column;
li {
width: 100%;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
a {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
/* 공통 상태 */
&:hover,
&:active,
&.active {
background-color: var(--primary1);
}
/* gnb icons */
&.gnb1 {
background-image: url(../assets/images/ico_gnb01.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb01_on.svg);
}
}
&.gnb2 {
background-image: url(../assets/images/ico_gnb02.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb02_on.svg);
}
}
&.gnb3 {
background-image: url(../assets/images/ico_gnb03.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb03_on.svg);
}
}
&.gnb4 {
background-image: url(../assets/images/ico_gnb04.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb04_on.svg);
}
}
&.gnb5 {
background-image: url(../assets/images/ico_gnb05.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb05_on.svg);
}
}
&.gnb6 {
background-image: url(../assets/images/ico_gnb06.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb06_on.svg);
}
}
}
}
}
}
/* side-pannel */
.slidePanel {
position: fixed;
top: 4.4rem;
left: 4.3rem;
width: 54rem;
height: calc(100% - 4.4rem);
display: flex;
flex-direction: column;
background-color: var(--secondary2);
z-index: 99;
transform: translateX(0);
transition: transform .3s ease;
/* 닫힘 상태 */
&.is-closed {
transform: translateX(-100%);
.toogle::after {
transform: translate(-50%, -50%) rotate(180deg);
}
}
.tabBox {
flex-shrink: 0;
width: 100%;
height: 4.8rem;
padding: 0 1.9rem;
margin-top: 1.5rem;
}
.tabWrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
&.is-active {
display: flex;
}
.tabTop {
flex-shrink: 0;
width: 100%;
padding: 0 2rem;
border-bottom: 1px solid var(--secondary1);
.title {
height: 6.4rem;
font-size: var(--fs-ml);
color: var(--white);
padding: 2rem 0;
}
}
.tabBtm {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 3rem 1.9rem;
}
}
.toogle {
position: absolute;
overflow: hidden;
z-index: 10;
top: 50%;
left: 100%;
width: 2.4rem;
height: 5rem;
border-radius: 0 1rem 1rem 0;
transform: translateY(-50%);
background-color: var(--secondary2);
border: solid 1px var(--secondary3);
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 2.4rem;
height: 2.4rem;
transform: translate(-50%, -50%);
background: url(../assets/images/ico_toggle.svg) no-repeat center / 2.4rem;
transition: transform .3s ease;
}
}
}
}
/* main */
#main {
width: 100%;
height: calc(100% - 4.4rem);
position: relative;
/* top-bar */
.topBar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 1.5rem 5.4rem;
}
/* top-location */
.locationInfo {
grid-column: 2;
width: 71rem;
max-width: 100%;
height: 3.8rem;
background-color: rgba(var(--secondary6-rgb), .9);
ul {
display: flex;
align-items: center;
width: 100%;
height: 100%;
li {
display: flex;
align-items: center;
height: 100%;
padding: 0 1rem;
color: var(--white);
font-weight: var(--fw-bold);
font-size: 1.3rem;
&:first-child,
&:last-child {
padding: 0;
}
span + span {
padding-right: .5rem;
}
&.divider {
position: relative;
&::after {
content: "";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 1.8rem;
background-color: rgba(255, 255, 255, .3);
}
}
.wgs {
display: flex;
align-items: center;
padding-right: .5rem;
&::before {
content: "";
display: block;
width: 2rem;
height: 2rem;
margin-right: .5rem;
background: url(../assets/images/ico_globe.svg) no-repeat center / 2rem;
}
}
.kst {
display: flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 1.8rem;
margin-right: .5rem;
border-radius: .3rem;
background-color: var(--tertiary3);
color: var(--white);
font-size: var(--fs-s);
}
}
}
button {
width: 4rem;
height: 3.8rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
background-color: var(--secondary3);
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
&:hover,
&.active {
background-color: rgba(var(--primary1-rgb), .8);
}
&.map {
background-image: url(../assets/images/ico_map.svg);
}
&.ship {
background-image: url(../assets/images/ico_ship.svg);
}
&.set {
width: 2rem;
height: 2rem;
background-color: transparent;
background-image: url(../assets/images/ico_set_s.svg);
background-size: 2rem;
&:hover,
&.active {
background-color: transparent;
}
}
}
}
/* top-search */
.schBox {
position: relative;
grid-column: 3;
justify-self: end;
width: 20rem;
height: 3.8rem;
.sch {
width: 100%;
height: 100%;
padding-right: 4.4rem;
border-radius: 2rem;
background-color: rgba(var(--secondary6-rgb), .9);
&::placeholder {
color: var(--white);
}
}
.mainSchBtn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 4rem;
height: 3.8rem;
font-size: 0;
text-indent: -999999em;
border: none;
cursor: pointer;
background: url(../assets/images/ico_search_main.svg) no-repeat center left / 2.4rem;
}
}
}
/* tool-bar */
#tool {
.toolBar {
position: absolute;
top: 5.9rem;
right: .9rem;
width: 3rem;
height: 41rem;
}
.control {
position: absolute;
bottom: 1.8rem;
right: .9rem;
width: 3rem;
height: 20rem;
}
.toolItem {
width: 100%;
display: flex;
flex-direction: column;
&.space {
li {
margin-bottom: .5rem;
}
}
&.zoom {
li {
height: 3rem;
&.num {
background-color: var(--white);
color: var(--gray-scale2);
border-left: 1px solid rgba(var(--secondary6-rgb), .8);
border-right: 1px solid rgba(var(--secondary6-rgb), .8);
font-size: var(--fs-m);
}
button {
padding-bottom: 0;
}
}
}
li {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 4.2rem;
background-color: rgba(var(--secondary6-rgb), .8);
&:hover,
&.active {
background-color: rgba(var(--primary1-rgb), .8);
}
button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding-bottom: .2rem;
cursor: pointer;
text-align: center;
font-size: var(--fs-xxs);
color: var(--white);
background-repeat: no-repeat;
background-position: top left;
background-size: 3rem;
&::before {
content: "";
flex-shrink: 0;
width: 3rem;
height: 3rem;
}
&.tool01 { background-image: url(../assets/images/ico_tool01.svg); }
&.tool02 { background-image: url(../assets/images/ico_tool02.svg); }
&.tool03 { background-image: url(../assets/images/ico_tool03.svg); }
&.tool04 { background-image: url(../assets/images/ico_tool04.svg); }
&.tool05 { background-image: url(../assets/images/ico_tool05.svg); }
&.tool06 { background-image: url(../assets/images/ico_tool06.svg); }
&.tool07 { background-image: url(../assets/images/ico_tool07.svg); }
&.tool08 { background-image: url(../assets/images/ico_tool08.svg); }
&.zoomin { background-image: url(../assets/images/ico_plus.svg); }
&.zoomout { background-image: url(../assets/images/ico_minus.svg); }
&.legend { background-image: url(../assets/images/ico_legend.svg); }
&.minimap { background-image: url(../assets/images/ico_minimap.svg); }
}
}
}
}
}

파일 보기

@ -0,0 +1,188 @@
@charset "utf-8";
#wrap {
//* main */
#main {
width: 100%;
min-height: calc(100vh - 4.4rem);
position: relative;
overscroll-behavior: none;
overflow: hidden;
//* top-bar */
.topBar {
position: absolute;
top: 1.5rem;
left: 0;
right: 0;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 5.4rem;
box-sizing: border-box;
z-index: 95;
}
//* top-location */
.locationInfo {
flex: 0 0 auto;
min-width: 71rem;
height: 3.8rem;
margin: 0 auto;
display: flex;
align-items: center;
background-color: rgba(var(--secondary6-rgb), .9);
overflow: hidden;
ul {
display: flex;
align-items: center;
width: 100%;
height: 100%;
li {
display: flex;
align-items: center;
height: 100%;
padding: 0 1rem;
color: var(--white);
font-weight: var(--fw-bold);
font-size: 1.3rem;
&:first-child,
&:last-child {
padding: 0;
}
span + span {
padding-right: .5rem;
}
&.divider {
position: relative;
&::after {
content: "";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 1.8rem;
background-color: rgba(255, 255, 255, .3);
}
}
.wgs {
display: flex;
align-items: center;
padding-right: .5rem;
&::before {
content: "";
display: block;
width: 2rem;
height: 2rem;
margin-right: .5rem;
background: url(../assets/images/ico_globe.svg) no-repeat center / 2rem;
}
}
.kst {
display: flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 1.8rem;
margin-right: .5rem;
border-radius: .3rem;
background-color: var(--tertiary3);
color: var(--white);
font-size: var(--fs-s);
}
}
}
button {
width: 4rem;
height: 3.8rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
background-color: var(--secondary3);
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
&:hover,
&.active {
background-color: rgba(var(--primary1-rgb), .8);
}
&.map {
background-image: url(../assets/images/ico_map.svg);
}
&.ship {
background-image: url(../assets/images/ico_ship.svg);
}
&.set {
width: 2rem;
height: 2rem;
background-color: transparent;
background-image: url(../assets/images/ico_set_s.svg);
background-size: 2rem;
&:hover,
&.active {
background-color: transparent;
}
}
}
}
//* top-search */
.topSchBox {
flex: 0 0 auto;
width: 20rem;
height: 3.8rem;
display: flex;
justify-content: flex-end;
position: relative;
.tschInput {
width: 100%;
height: 100%;
padding-right: 4.4rem;
border-radius: 2rem;
background-color: rgba(var(--secondary6-rgb), .9);
border: 0;
&::placeholder {
color: var(--white);
}
}
.mainSchBtn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 4rem;
height: 3.8rem;
font-size: 0;
text-indent: -999999em;
border: none;
cursor: pointer;
background: url(../assets/images/ico_search_main.svg) no-repeat center left / 2.4rem;
}
}
}
}

파일 보기

@ -0,0 +1,539 @@
@charset "utf-8";
#wrap {
#sidePanel {
/* gnb */
#nav {
position: fixed;
top: 4.4rem;
left: 0;
z-index: 100;
width: 4.3rem;
height: calc(100% - 4.4rem);
display: flex;
flex-direction: column;
justify-content: space-between;
background: var(--secondary1);
border-right: 1px solid var(--tertiary2);
.gnb {
width: 4.2rem;
display: flex;
flex-direction: column;
li {
width: 100%;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
button {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
/* 공통 상태 */
&:hover,
&:active,
&.active {
background-color: var(--primary1);
}
/* gnb icons */
&.gnb1 {
background-image: url(../assets/images/ico_gnb01.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb01_on.svg);
}
}
&.gnb2 {
background-image: url(../assets/images/ico_gnb02.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb02_on.svg);
}
}
&.gnb3 {
background-image: url(../assets/images/ico_gnb03.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb03_on.svg);
}
}
&.gnb4 {
background-image: url(../assets/images/ico_gnb04.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb04_on.svg);
}
}
&.gnb5 {
background-image: url(../assets/images/ico_gnb05.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb05_on.svg);
}
}
&.gnb6 {
background-image: url(../assets/images/ico_gnb06.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb06_on.svg);
}
}
&.gnb7 {
background-image: url(../assets/images/ico_gnb07.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb07_on.svg);
}
}
&.gnb8 {
background-image: url(../assets/images/ico_gnb08.svg);
&:hover,
&:active,
&.active {
background-image: url(../assets/images/ico_gnb08_on.svg);
}
}
}
}
}
.side {
width: 4.2rem;
display: flex;
flex-direction: column;
li {
width: 100%;
height: 4.4rem;
display: flex;
align-items: center;
justify-content: center;
button {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-position: center;
background-size: 3rem;
background-color: var(--primary3);
&.filter {
background-image: url('../assets/images/ico_side01.svg');
}
&.layer {
background-image: url('../assets/images/ico_side02.svg');
}
}
}
}
}
/* side-pannel */
.slidePanel {
position: fixed;
top: 4.4rem;
left: 4.3rem;
width: 54rem;
height: calc(100% - 4.4rem);
min-height: 0;
display: flex;
flex-direction: column;
background-color: var(--secondary2);
border-right: .1rem solid var(--secondary3);
z-index: 99;
transform: translateX(0);
transition: transform .3s ease;
/* 닫힘 상태 */
&.is-closed {
transform: translateX(-100%);
.toogle::after {
transform: translate(-50%, -50%) rotate(180deg);
}
}
.tabBox {
flex-shrink: 0;
width: 100%;
padding: 1.5rem 2rem 0 2rem;
}
.tabWrap {
flex: 1;
min-height: 0;
display: none;
flex-direction: column;
&.is-active {
display: flex;
}
.tabWrapInner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 100%;
.tabWrapCnt {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-bottom: 3rem;
// 필터 스위치그룹
.switchGroup {
display: flex;
flex-direction: column;
width: 100%;
.sgHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem 1rem;
background-color: var(--secondary1);
border-bottom: .1rem solid var(--secondary3);
.colL {
display: flex;
align-items: center;
gap: 1rem;
font-weight: var(--fw-bold);
.favship {
width: 2rem;
height: 2rem;
background: url(../assets/images/ico_favship.svg) no-repeat center / contain;
}
}
.toggleBtn {
display: flex;
align-items: center;
width: 2.4rem;
height: 2.4rem;
background: url(../assets/images/ico_arrow_toggle_down.svg) no-repeat center / contain;
transition: transform 0.3s ease;
&.is-open {
transform: rotate(180deg);
}
}
}
.switchBox {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease;
&.is-open {
max-height: 500rem;
}
.switchList {
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;
background-color: var(--tertiary1);
padding: 1rem;
li {
display: flex;
justify-content: space-between;
padding: .1rem 0;
span {
flex: 1;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
}
}
}
}
}
}
.btnBox {
flex: 0 0 auto;
margin-top: auto;
display: flex;
justify-content: flex-end;
padding: 2rem;
}
}
.tabTop {
flex-shrink: 0;
width: 100%;
padding: 0 2rem;
.title {
font-size: var(--fs-ml);
color: var(--white);
font-weight: var(--fw-bold);
padding: 1.7rem 0;
.prevBtn {
width: 2rem;
height: 2rem;
background: url(../assets/images/ico_tit_prev.svg) no-repeat center /contain;
margin-right: .5rem;
}
}
// 기상패널 범례
.legend {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: .4rem;
margin-bottom: 2.5rem;
.legendTitle {
font-size: var(--fs-m);
}
.legendList {
width: 100%;
border-radius: .6rem;
padding: 1.5rem;
background-color: rgba(var(--secondary4-rgb), .2);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
li {
display: flex;
align-items: center;
justify-content: flex-start;
font-size: var(--fs-ml);
img {
width: 2.6rem;
height: 2.6rem;
margin-right: .5rem;
}
}
}
}
}
.tabBtm {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-top: 1px solid var(--secondary1);
padding: 3rem 2rem;
&.noLine {
overflow-y: visible;
border-top: 0;
padding: 0 2rem;
}
&.noSc {
overflow-y: visible;
padding-bottom: 0;
}
.tabBtmInner {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
.tabBtmCnt {
flex: 1 1 auto;
min-height: 0 ;
overflow-y: auto;
padding-bottom: 2rem;
}
.btnBox {
flex: 0 0 auto;
margin-top: auto;
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 2rem 0;
&.rowSB {
justify-content: space-between;
}
button {
flex: initial;
min-width: 12rem;
}
}
}
}
}
.toogle {
position: absolute;
overflow: hidden;
z-index: 10;
top: 50%;
left: 100%;
width: 2.4rem;
height: 5rem;
border-radius: 0 1rem 1rem 0;
transform: translateY(-50%);
background-color: var(--secondary2);
border: solid 1px var(--secondary3);
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 2.4rem;
height: 2.4rem;
transform: translate(-50%, -50%);
background: url(../assets/images/ico_toggle.svg) no-repeat center / 2.4rem;
transition: transform .3s ease;
}
}
// ai모드
.panelHeader {
display: flex;
align-items: center;
padding: 0 2rem;
.panelTitle {
padding: 1.7rem 0;
font-size: var(--fs-ml);
font-weight: var(--fw-bold);
}
}
.panelBody {
display: flex;
flex: 1;
min-height: 0;
padding: 0 2rem;
.ai {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
gap: 1rem;
row-gap: 1rem;
grid-auto-rows: min-content;
width: 100%;
li {
display: flex;
align-items: center;
justify-content: flex-start;
a {
position: relative;
display: flex;
flex-direction: column;
gap: .6rem;
width: 100%;
height: auto;
padding: 2rem 1.2rem;
background-color: var(--secondary1);
border: 2px solid transparent;
border-radius: .6rem;
&.on {
background-color: var(--secondary5);
border-color: var(--primary1);
}
&:not(.on) > * {
opacity: .5;
}
.title {
display: flex;
align-items: center;
font-weight: var(--fw-bold);
img {
width: 2.2rem;
height: 2.2rem;
margin-right: .5rem;
}
}
.keyword {
font-weight: var(--fw-bold);
}
.control {
position: absolute;
top: 2rem;
right: 1.5rem;
display: flex;
align-items: center;
i {
width: .8rem;
height: .8rem;
border-radius: 50%;
background-color: var(--white);
margin-right: .5rem;
}
}
}
}
}
}
.panelFooter {
border-top: 1px solid var(--secondary1);
padding: 1rem 2rem;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More