dark 프로젝트 구현 현재 상태 스냅샷
- Vite 마이그레이션, OpenLayers+Deck.gl 지도 연동 - STOMP WebSocket 선박 실시간 데이터 수신 - 선박 범례/필터/카운트, 다크시그널 처리 - Ctrl+Drag 박스선택, 우클릭 컨텍스트 메뉴 - 측정도구, 상세모달, 호버 툴팁 - darkSignalIds Set 패턴, INSHORE/OFFSHORE 타임아웃 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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>
|
||||||
61
package.json
@ -1,41 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "dark",
|
"name": "dark",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"version": "0.1.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"type": "module",
|
||||||
"@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"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"dev": "vite --port 3000",
|
||||||
"build": "react-scripts build",
|
"build": "vite build",
|
||||||
"test": "react-scripts test",
|
"build:dev": "vite build --mode dev",
|
||||||
"eject": "react-scripts eject"
|
"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": {
|
"dependencies": {
|
||||||
"extends": [
|
"@deck.gl/core": "^9.2.6",
|
||||||
"react-app",
|
"@deck.gl/layers": "^9.2.6",
|
||||||
"react-app/jest"
|
"@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": {
|
"devDependencies": {
|
||||||
"production": [
|
"@vitejs/plugin-react": "^4.0.1",
|
||||||
">0.2%",
|
"eslint": "^8.44.0",
|
||||||
"not dead",
|
"eslint-plugin-react": "^7.34.1",
|
||||||
"not op_mini all"
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
],
|
"eslint-plugin-react-refresh": "^0.4.1",
|
||||||
"development": [
|
"sass": "^1.77.8",
|
||||||
"last 1 chrome version",
|
"vite": "^5.2.10"
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
|
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;
|
||||||
1
src/assets/data/largeTrench.json
Normal file
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)');
|
||||||
BIN
src/assets/img/default-ship.png
Normal file
|
After Width: | Height: | 크기: 1.3 KiB |
BIN
src/assets/img/icon/atlas.png
Normal file
|
After Width: | Height: | 크기: 10 KiB |
1
src/assets/img/shipDetail/detailKindIcon/cargo.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/etc.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/fishing.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/gov.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/kcgv.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/passenger.svg
Normal file
@ -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 |
1
src/assets/img/shipDetail/detailKindIcon/tanker.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/bouy.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/cargo.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/etc.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/fishing.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/gov.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/hazard.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/kcgv.svg
Normal file
@ -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 |
1
src/assets/img/shipKindIcons/pass.svg
Normal file
@ -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"
|
import { useState } from "react"
|
||||||
export default function ToolComponent() {
|
import useShipStore from '../../stores/shipStore';
|
||||||
|
|
||||||
|
export default function ToolComponent() {
|
||||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||||
|
const { isIntegrate, toggleIntegrate } = useShipStore();
|
||||||
|
|
||||||
return(
|
return(
|
||||||
<section id="tool">
|
<section id="tool">
|
||||||
{/* 툴바 */}
|
{/* 툴바 */}
|
||||||
<div className="toolBar">
|
<div className="toolBar">
|
||||||
<ul className="toolItem space">
|
<ul className="toolItem space">
|
||||||
<li><button type="button" className="tool01">초기화</button></li>
|
<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>
|
<li><button type="button" className="tool03">구역설정</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul className="toolItem mt30">
|
<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 { Link, useNavigate } from "react-router-dom";
|
||||||
import Slider from '../../common/Slider';
|
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 }) {
|
export default function DisplayComponent({ isOpen, onToggle }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 스토어 연결
|
||||||
|
const {
|
||||||
|
sourceVisibility,
|
||||||
|
kindVisibility,
|
||||||
|
nationalVisibility,
|
||||||
|
darkSignalVisible,
|
||||||
|
darkSignalCount,
|
||||||
|
toggleSourceVisibility,
|
||||||
|
toggleKindVisibility,
|
||||||
|
toggleNationalVisibility,
|
||||||
|
toggleDarkSignalVisible,
|
||||||
|
clearDarkSignals,
|
||||||
|
} = useShipStore();
|
||||||
|
|
||||||
// 투명도
|
// 투명도
|
||||||
const [opacity, setOpacity] = useState(70);
|
const [opacity, setOpacity] = useState(70);
|
||||||
|
|
||||||
// 아코디언
|
// 아코디언
|
||||||
const [isAccordionOpen1, setIsAccordionOpen1] = useState(true); // 기존
|
const [isAccordionOpen1, setIsAccordionOpen1] = useState(true); // 신호
|
||||||
const [isAccordionOpen2, setIsAccordionOpen2] = useState(true); //
|
const [isAccordionOpen2, setIsAccordionOpen2] = useState(true); // 선종
|
||||||
const [isAccordionOpen3, setIsAccordionOpen3] = useState(true); //
|
const [isAccordionOpen3, setIsAccordionOpen3] = useState(true); // 국적
|
||||||
const [isAccordionOpen4, setIsAccordionOpen4] = useState(false); //
|
const [isAccordionOpen4, setIsAccordionOpen4] = useState(false); // AI 모드
|
||||||
|
|
||||||
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
|
const toggleAccordion1 = () => setIsAccordionOpen1(prev => !prev);
|
||||||
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
|
const toggleAccordion2 = () => setIsAccordionOpen2(prev => !prev);
|
||||||
const toggleAccordion3 = () => setIsAccordionOpen3(prev => !prev);
|
const toggleAccordion3 = () => setIsAccordionOpen3(prev => !prev);
|
||||||
const toggleAccordion4 = () => setIsAccordionOpen4(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');
|
const [activeTab, setActiveTab] = useState('filter');
|
||||||
|
|
||||||
@ -50,140 +155,134 @@ export default function DisplayComponent({ isOpen, onToggle }) {
|
|||||||
<div className="tabWrapInner">
|
<div className="tabWrapInner">
|
||||||
<div className="tabWrapCnt">
|
<div className="tabWrapCnt">
|
||||||
|
|
||||||
{/* 스위치그룹 01 */}
|
{/* 스위치그룹 01 - 신호 */}
|
||||||
<div className="switchGroup">
|
<div className="switchGroup">
|
||||||
<div className="sgHeader">
|
<div className="sgHeader">
|
||||||
<div className="colL">
|
<div className="colL">
|
||||||
<span>신호</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`toggleBtn ${isAccordionOpen1 ? 'is-open' : ''}`}
|
className={`toggleBtn ${isAccordionOpen1 ? 'is-open' : ''}`}
|
||||||
aria-expanded={isAccordionOpen1}
|
aria-expanded={isAccordionOpen1}
|
||||||
onClick={toggleAccordion1}
|
onClick={toggleAccordion1}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/* 여기서부터 토글 */}
|
{/* 여기서부터 토글 */}
|
||||||
<div className={`switchBox ${isAccordionOpen1 ? 'is-open' : ''}`}>
|
<div className={`switchBox ${isAccordionOpen1 ? 'is-open' : ''}`}>
|
||||||
<ul className="switchList">
|
<ul className="switchList">
|
||||||
<li>
|
{SIGNAL_FILTERS.map(({ code, label }) => (
|
||||||
<span>AIS</span>
|
<li key={code}>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="AIS" /> <span></span></label>
|
<span>{label}</span>
|
||||||
</li>
|
<label className="switch sm">
|
||||||
<li>
|
<input
|
||||||
<span>V-PASS</span>
|
type="checkbox"
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="V-PASS" /> <span></span></label>
|
aria-label={label}
|
||||||
</li>
|
checked={sourceVisibility[code] || false}
|
||||||
<li>
|
onChange={() => toggleSourceVisibility(code)}
|
||||||
<span>VTS_AIS</span>
|
/>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="VTS_AIS" /> <span></span></label>
|
<span></span>
|
||||||
</li>
|
</label>
|
||||||
<li>
|
</li>
|
||||||
<span>D_MF_HF</span>
|
))}
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="D_MF_HF" /> <span></span></label>
|
</ul>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span>VTS_RADAR</span>
|
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="VTS_RADAR" /> <span></span></label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 여기까지 */}
|
{/* 여기까지 */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스위치그룹 02 */}
|
{/* 스위치그룹 02 - 선종 */}
|
||||||
<div className="switchGroup">
|
<div className="switchGroup">
|
||||||
<div className="sgHeader">
|
<div className="sgHeader">
|
||||||
<div className="colL">
|
<div className="colL">
|
||||||
<span>선종/기종</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`toggleBtn ${isAccordionOpen2 ? 'is-open' : ''}`}
|
className={`toggleBtn ${isAccordionOpen2 ? 'is-open' : ''}`}
|
||||||
aria-expanded={isAccordionOpen2}
|
aria-expanded={isAccordionOpen2}
|
||||||
onClick={toggleAccordion2}
|
onClick={toggleAccordion2}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/* 여기서부터 토글 */}
|
{/* 여기서부터 토글 */}
|
||||||
<div className={`switchBox ${isAccordionOpen2 ? 'is-open' : ''}`}>
|
<div className={`switchBox ${isAccordionOpen2 ? 'is-open' : ''}`}>
|
||||||
<ul className="switchList">
|
<ul className="switchList">
|
||||||
<li>
|
{KIND_FILTERS.map(({ code, label }) => (
|
||||||
<span>어선</span>
|
<li key={code}>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="어선" /> <span></span></label>
|
<span>{label}</span>
|
||||||
</li>
|
<label className="switch sm">
|
||||||
<li>
|
<input
|
||||||
<span>여객선</span>
|
type="checkbox"
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="여객선" /> <span></span></label>
|
aria-label={label}
|
||||||
</li>
|
checked={kindVisibility[code] || false}
|
||||||
<li>
|
onChange={() => toggleKindVisibility(code)}
|
||||||
<span>화물선</span>
|
/>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="화물선" /> <span></span></label>
|
<span></span>
|
||||||
</li>
|
</label>
|
||||||
<li>
|
</li>
|
||||||
<span>유조선</span>
|
))}
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="유조선" /> <span></span></label>
|
</ul>
|
||||||
</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>
|
||||||
{/* 여기까지 */}
|
{/* 여기까지 */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스위치그룹 03 */}
|
{/* 스위치그룹 03 - 국적 */}
|
||||||
<div className="switchGroup">
|
<div className="switchGroup">
|
||||||
<div className="sgHeader">
|
<div className="sgHeader">
|
||||||
<div className="colL">
|
<div className="colL">
|
||||||
<span>국적</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`toggleBtn ${isAccordionOpen3 ? 'is-open' : ''}`}
|
className={`toggleBtn ${isAccordionOpen3 ? 'is-open' : ''}`}
|
||||||
aria-expanded={isAccordionOpen3}
|
aria-expanded={isAccordionOpen3}
|
||||||
onClick={toggleAccordion3}
|
onClick={toggleAccordion3}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/* 여기서부터 토글 */}
|
{/* 여기서부터 토글 */}
|
||||||
<div className={`switchBox ${isAccordionOpen3 ? 'is-open' : ''}`}>
|
<div className={`switchBox ${isAccordionOpen3 ? 'is-open' : ''}`}>
|
||||||
<ul className="switchList">
|
<ul className="switchList">
|
||||||
<li>
|
{NATIONAL_FILTERS.map(({ code, label }) => (
|
||||||
<span>한국</span>
|
<li key={code}>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="한국" /> <span></span></label>
|
<span>{label}</span>
|
||||||
</li>
|
<label className="switch sm">
|
||||||
<li>
|
<input
|
||||||
<span>중국</span>
|
type="checkbox"
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="중국" /> <span></span></label>
|
aria-label={label}
|
||||||
</li>
|
checked={nationalVisibility[code] || false}
|
||||||
<li>
|
onChange={() => toggleNationalVisibility(code)}
|
||||||
<span>일본</span>
|
/>
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="일본" /> <span></span></label>
|
<span></span>
|
||||||
</li>
|
</label>
|
||||||
<li>
|
</li>
|
||||||
<span>북한</span>
|
))}
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="북한" /> <span></span></label>
|
</ul>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span>기타</span>
|
|
||||||
<label className="switch sm"> <input type="checkbox" aria-label="기타" /> <span></span></label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 여기까지 */}
|
{/* 여기까지 */}
|
||||||
</div>
|
</div>
|
||||||
@ -233,13 +332,32 @@ export default function DisplayComponent({ isOpen, onToggle }) {
|
|||||||
{/* 여기까지 */}
|
{/* 여기까지 */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스위치그룹 05 */}
|
{/* 스위치그룹 05 - 다크시그널 */}
|
||||||
<div className="switchGroup">
|
<div className="switchGroup">
|
||||||
<div className="sgHeader">
|
<div className="sgHeader">
|
||||||
<div className="colL">
|
<div className="colL">
|
||||||
<span>다크시그널</span>
|
<span>다크시그널</span>
|
||||||
|
{darkSignalCount > 0 && <span className="count">({darkSignalCount})</span>}
|
||||||
|
{darkSignalCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btnDelDark"
|
||||||
|
onClick={clearDarkSignals}
|
||||||
|
title="다크시그널 삭제"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
42
src/components/layout/Header.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/layout/MainLayout.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/layout/SideNav.jsx
Normal file
@ -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])
|
||||||
|
);
|
||||||
86
src/components/layout/Sidebar.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/layout/ToolBar.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/ship/ShipContextMenu.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/ship/ShipContextMenu.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/components/ship/ShipDetailModal.jsx
Normal file
@ -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)}°</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/components/ship/ShipDetailModal.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/components/ship/ShipLegend.jsx
Normal file
@ -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;
|
||||||
179
src/components/ship/ShipLegend.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/components/ship/ShipTooltip.jsx
Normal file
@ -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)}°</span>
|
||||||
|
<span className="ship-tooltip__sep">|</span>
|
||||||
|
<span>{isMoving ? '항해' : '정박'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/ship/ShipTooltip.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
618
src/map/ShipBatchRenderer.js
Normal file
@ -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;
|
||||||
70
src/map/layers/baseLayer.js
Normal file
@ -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
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² 또는 m²
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
41
src/map/measure/measure.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
72
src/map/measure/useMeasure.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
{/* 메인 페이지 추가 컨텐츠 */}
|
||||||
|
{/* 선박 정보 팝업, 검색 결과 등 */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/publish/PublishRoutes.jsx
Normal file
@ -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;
|
||||||
35
src/publish/components/FileUpload.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/publish/components/Slider.jsx
Normal file
@ -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;
|
||||||
36
src/publish/layouts/HeaderComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/publish/layouts/MainComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/publish/layouts/PublishLayout.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/publish/layouts/SideComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/publish/layouts/ToolComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/publish/layouts/WrapComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/publish/pages/Analysis1Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/publish/pages/Analysis2Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/publish/pages/Analysis3Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
src/publish/pages/Analysis4Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
355
src/publish/pages/DisplayComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/publish/pages/EmptyMain.jsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export default function EmptyMain() {
|
||||||
|
return null; // 또는 지도만 보여주는 영역
|
||||||
|
}
|
||||||
91
src/publish/pages/LayerComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/publish/pages/MyPageComponent.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/publish/pages/NavComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
727
src/publish/pages/Panel1Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/publish/pages/Panel1DetailComponent.jsx
Normal file
@ -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> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
src/publish/pages/Panel2Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
src/publish/pages/Panel3Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
521
src/publish/pages/Panel4Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/publish/pages/Panel5Component.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
export default function Panel5Component() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section></section>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/publish/pages/Panel6Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/publish/pages/Panel7Component.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
export default function Panel7Component() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section></section>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/publish/pages/Panel8Component.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
export default function Panel8Component() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section></section>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/publish/pages/ReplayComponent.jsx
Normal file
@ -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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
src/publish/pages/Satellite1Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/publish/pages/Satellite2Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/publish/pages/Satellite3Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/publish/pages/Satellite4Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/publish/pages/ShipComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/publish/pages/Signal1Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/publish/pages/Signal2Component.jsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/publish/pages/ToastComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/publish/pages/TopComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/publish/pages/TrackComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/publish/pages/WeatherComponent.jsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/publish/scss/HeaderComponent.scss
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
557
src/publish/scss/Layout.scss
Normal file
@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
188
src/publish/scss/MainComponent.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
539
src/publish/scss/SideComponent.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||